diff --git a/.github/linters/.checkov.yaml b/.github/linters/.checkov.yaml new file mode 100644 index 00000000..d197d72e --- /dev/null +++ b/.github/linters/.checkov.yaml @@ -0,0 +1,15 @@ +--- +# Checkov configuration for this repository. +# Options: https://www.checkov.io/2.Basics/CLI%20Command%20Reference.html +# +# Supplying this file replaces super-linter's bundled one: it invokes +# `checkov --config-file `. + +# Passed checks are not interesting; a failure is. +quiet: true + +skip-path: + # See the note in trivy.yaml. The pinned ThousandEyes document's + # client-certificate example is a sample PEM, not a credential, and the document is + # committed verbatim so it cannot be edited to satisfy a scanner. + - openapi-specs diff --git a/.github/linters/trivy.yaml b/.github/linters/trivy.yaml new file mode 100644 index 00000000..91bcdeda --- /dev/null +++ b/.github/linters/trivy.yaml @@ -0,0 +1,33 @@ +--- +# Trivy configuration for this repository. +# +# Supplying this file replaces super-linter's bundled one wholesale -- it invokes +# `trivy filesystem --config ` -- so the scanner selection and exit code +# are restated here rather than inherited. + +disable-telemetry: true + +# A finding must fail the job. The default is 0, which reports and passes. +exit-code: 1 + +scan: + scanners: + - vuln + - misconfig + - secret + + skip-dirs: + # Pinned third-party API documents, committed verbatim for reproducibility. + # + # The ThousandEyes v7 document documents a client-certificate field, and its + # `example:` block contains a sample PEM private key. Both Trivy and Checkov read + # that as a leaked key. It is illustrative text in a vendor's documentation, and + # the only way to satisfy the scanner would be to edit a pinned artefact -- which + # would defeat the point of pinning it, and is why VALIDATE_OPENAPI is off too. + - openapi-specs + + # Deliberately NOT skipped: probe-evidence. Those cassettes are our own recorded + # HTTP traffic against a live tenant, so a secret appearing there is exactly the + # thing worth being told about. The Authorization header is absent by construction + # -- it is not on the recorder's allow list -- but that is a property to keep + # verifying, not to assume. diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml index 3ac00574..f27d31b8 100644 --- a/.github/workflows/auto-merge-dependabot.yml +++ b/.github/workflows/auto-merge-dependabot.yml @@ -1,6 +1,5 @@ name: Auto-Merge Dependabot - on: pull_request: branches: [main] @@ -11,13 +10,18 @@ permissions: jobs: dependabot-merge: - name: '๐Ÿค– Auto-Merge Dependabot' + name: "๐Ÿค– Auto-Merge Dependabot" runs-on: ubuntu-latest - if: ${{ github.actor == 'dependabot[bot]' }} + # Gated on the pull request author's immutable numeric id, not on github.actor. + # + # This job holds contents: write and merges pull requests, so this condition is the + # whole of its security. github.actor is a login, and a login is a display name that + # can be changed; 49699333 is dependabot[bot]'s user id and cannot be. Verified + # against dependabot pull requests in this repository. + if: ${{ github.event.pull_request.user.id == 49699333 }} steps: - - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: diff --git a/.github/workflows/codegen-verify.yml b/.github/workflows/codegen-verify.yml index 3861e4d9..ea8e6ccd 100644 --- a/.github/workflows/codegen-verify.yml +++ b/.github/workflows/codegen-verify.yml @@ -12,15 +12,15 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - - '.github/workflows/codegen-verify.yml' - - 'blueprints/**' - - 'cmd/**' - - 'internal/**' - - 'interop-specs/**' - - 'probe-evidence/**' - - 'pilot/**' - - 'go.mod' - - 'go.sum' + - ".github/workflows/codegen-verify.yml" + - "blueprints/**" + - "cmd/**" + - "internal/**" + - "interop-specs/**" + - "probe-evidence/**" + - "pilot/**" + - "go.mod" + - "go.sum" permissions: contents: read @@ -31,7 +31,7 @@ concurrency: jobs: verify: - name: '๐Ÿ” Regenerate and diff' + name: "๐Ÿ” Regenerate and diff" runs-on: ubuntu-24.04-arm if: github.event.pull_request.draft == false @@ -42,13 +42,16 @@ jobs: egress-policy: audit - name: Check Out - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # These jobs only read the tree; none of them runs git push. + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version-file: 'go.mod' - cache-dependency-path: 'go.sum' + go-version-file: "go.mod" + cache-dependency-path: "go.sum" cache: true - name: Download Dependencies @@ -161,7 +164,7 @@ jobs: exit 1 bindings: - name: '๐Ÿ”— Verify SDK bindings' + name: "๐Ÿ”— Verify SDK bindings" runs-on: ubuntu-24.04-arm if: github.event.pull_request.draft == false @@ -172,13 +175,16 @@ jobs: egress-policy: audit - name: Check Out - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # These jobs only read the tree; none of them runs git push. + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version-file: 'go.mod' - cache-dependency-path: 'go.sum' + go-version-file: "go.mod" + cache-dependency-path: "go.sum" cache: true # A blueprint names SDK symbols as strings, so it can name one that does not @@ -196,7 +202,7 @@ jobs: echo "::endgroup::" interop: - name: '๐Ÿ”€ Round-trip through tfplugingen-framework' + name: "๐Ÿ”€ Round-trip through tfplugingen-framework" runs-on: ubuntu-24.04-arm if: github.event.pull_request.draft == false @@ -207,13 +213,16 @@ jobs: egress-policy: audit - name: Check Out - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # These jobs only read the tree; none of them runs git push. + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version-file: 'go.mod' - cache-dependency-path: 'go.sum' + go-version-file: "go.mod" + cache-dependency-path: "go.sum" cache: true # The exported specification is committed, so it drifts for the same reasons @@ -236,8 +245,11 @@ jobs: echo "### โŒ The exported specification is out of date" echo "" echo '```bash' - echo 'go run ./cmd/tfpluginframeworkgen interop export \' - echo ' -blueprint blueprints/thousandeyes \' + # Double quotes with an escaped backslash, not a single-quoted string ending + # in one: the latter reads as an attempt to escape the closing quote. The text + # has no expansion in it, so the quoting style makes no other difference. + echo "go run ./cmd/tfpluginframeworkgen interop export \\" + echo " -blueprint blueprints/thousandeyes \\" echo ' -out interop-specs/thousandeyes/provider-code-spec.json' echo '```' } >> "$GITHUB_STEP_SUMMARY" @@ -291,7 +303,7 @@ jobs: echo "โœ… HashiCorp's own generator accepted the export and produced formatted Go." probe-replay: - name: '๐Ÿ”ฌ Re-derive probe facts offline' + name: "๐Ÿ”ฌ Re-derive probe facts offline" runs-on: ubuntu-24.04-arm if: github.event.pull_request.draft == false @@ -323,13 +335,16 @@ jobs: dl.google.com:443 - name: Check Out - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # These jobs only read the tree; none of them runs git push. + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version-file: 'go.mod' - cache-dependency-path: 'go.sum' + go-version-file: "go.mod" + cache-dependency-path: "go.sum" cache: true - name: Download Dependencies diff --git a/.github/workflows/dependancy-review.yml b/.github/workflows/dependancy-review.yml index 56129588..f335c7b1 100644 --- a/.github/workflows/dependancy-review.yml +++ b/.github/workflows/dependancy-review.yml @@ -1,4 +1,4 @@ -name: 'Dependency Review' +name: "Dependency Review" on: [pull_request] permissions: @@ -6,19 +6,20 @@ permissions: jobs: dependency-review: - name: '๐Ÿ”Ž Dependency Review' + name: "๐Ÿ”Ž Dependency Review" runs-on: ubuntu-latest steps: - - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - - name: 'Checkout Repository' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: "Checkout Repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # These jobs only read the tree; none of them runs git push. + persist-credentials: false fetch-depth: 0 - - - name: 'Dependency Review' + + - name: "Dependency Review" uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index 587906cd..3f40d50c 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -5,18 +5,18 @@ on: pull_request: types: [opened, synchronize] paths: - - '.github/workflows/go-lint.yml' - - '.golangci.yml' - - '**/*.go' + - ".github/workflows/go-lint.yml" + - ".golangci.yml" + - "**/*.go" permissions: contents: read - pull-requests: write # Needed for "only-new-issues" and commenting on PR - issues: write # Needed for commenting on PR + pull-requests: write # Needed for "only-new-issues" and commenting on PR + issues: write # Needed for commenting on PR jobs: golint: - name: 'โœจ Run golangci-lint' + name: "โœจ Run golangci-lint" runs-on: ubuntu-24.04-arm steps: @@ -25,16 +25,18 @@ jobs: with: egress-policy: audit - - name: Check Out - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Check Out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # Get full history for full merge-base detection - + # These jobs only read the tree; none of them runs git push. + persist-credentials: false + fetch-depth: 0 # Get full history for full merge-base detection + - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version-file: 'go.mod' - cache-dependency-path: 'go.sum' + go-version-file: "go.mod" + cache-dependency-path: "go.sum" cache: true go-version: stable @@ -45,41 +47,53 @@ jobs: args: --timeout=30m --verbose --config=./.golangci.yml --issues-exit-code=0 only-new-issues: true github-token: ${{ secrets.GITHUB_TOKEN }} - skip-cache: false # restore and save cache - skip-save-cache: false # allow saving any new cache + skip-cache: false # restore and save cache + skip-save-cache: false # allow saving any new cache cache-invalidation-interval: 7 # auto-invalidate (refresh) once per week - + # Save artifacts on failure - name: Save artifacts if: failure() + # Through the environment, not through ${{ }}. An expression is spliced into the + # script text before the shell runs, and a fork's repository name is chosen by + # whoever owns the fork, so it can carry shell metacharacters. printf rather than + # echo because echo's handling of a leading dash or a backslash varies by shell. + env: + GH_OWNER: ${{ github.repository_owner }} + GH_REPO: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | mkdir -p wr_actions - echo ${{ github.repository_owner }} > wr_actions/ghowner.txt - echo ${{ github.event.repository.name }} > wr_actions/ghrepo.txt - echo ${{ github.event.pull_request.number }} > wr_actions/prnumber.txt - + printf '%s\n' "$GH_OWNER" > wr_actions/ghowner.txt + printf '%s\n' "$GH_REPO" > wr_actions/ghrepo.txt + printf '%s\n' "$PR_NUMBER" > wr_actions/prnumber.txt + - name: Upload artifacts if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: artifact path: wr_actions - + # Comment on failure - name: Get run url if: failure() run: | - echo "gha_url=https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" >> $GITHUB_ENV - + echo "gha_url=https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" >> "$GITHUB_ENV" + - name: Send build failure comment if: failure() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: result-encoding: string + # No ${{ }} in the script body: github-script provides the issue number + # through its own context, and gha_url is already in the environment from the + # previous step, so both are read at run time rather than pasted into the + # source before it is evaluated. script: | github.rest.issues.createComment({ - issue_number: ${{ github.event.pull_request.number }}, + issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: 'Build failure \n\n This pull request contains a build failure which needs addressed [here](${{ env.gha_url}}) .' - }) \ No newline at end of file + body: `Build failure \n\n This pull request contains a build failure which needs addressed [here](${process.env.gha_url}) .`, + }) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 5af293ab..a60491a4 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -3,88 +3,139 @@ name: Linter on: pull_request: paths-ignore: - - '.github/**' + - ".github/**" permissions: contents: read packages: read statuses: write # To report GitHub Actions status checks - + jobs: build: - name: 'โœจ Linter' + name: "โœจ Linter" runs-on: ubuntu-latest timeout-minutes: 25 steps: - - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # Full history: super-linter diffs against DEFAULT_BRANCH to find changed files. fetch-depth: 0 - + # This job lints and reports; it never pushes. Leaving the token in the + # runner's git config would only widen what a compromised step could reach. + persist-credentials: false + - name: Lint Code Base with Super-Linter uses: super-linter/super-linter@61abc07d755095a68f4987d1c2c3d1d64408f1f9 # v8.5.0 env: DEFAULT_BRANCH: origin/main - VALIDATE_ALL_CODEBASE: false # tells the linter to only check files that have been modified in the current pull request + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # To report GitHub Actions status checks - # Go is linted by go-lint.yml, not here. + # Only files changed in the pull request. Note this cuts both ways: a + # validator configured differently from this repository's own tools fires + # only when a file is touched, so the same code passes or fails depending + # on whether it was in the diff. That inconsistency is the reason several + # validators below are off rather than merely configured. + VALIDATE_ALL_CODEBASE: false + + # --------------------------------------------------------------------- + # Go: linted by go-lint.yml, not here. # - # Two reasons, and the second is decisive. First, these duplicate - # go-lint.yml, which runs golangci-lint over the whole module with this - # repository's own .golangci.yml -- and that config already enables - # govet, gocyclo and the gofumpt/goimports/gci/golines formatters, so - # super-linter's versions add nothing but a second opinion configured - # differently. + # go-lint.yml runs golangci-lint over the whole module with this + # repository's own .golangci.yml, which already enables govet, gocyclo and + # the gofumpt/goimports/gci/golines formatters -- and, importantly, its + # exclusions: gocyclo and dupl are off for _test.go, because a table-driven + # test is meant to be a long flat table. # - # Second, with VALIDATE_ALL_CODEBASE false, super-linter invokes - # golangci-lint with the list of *changed files*. golangci-lint rejects - # named files spanning more than one directory: + # super-linter's GO_MODULES runs golangci-lint again with its own + # configuration and none of those exclusions, over changed files only. The + # result is that touching a test file reports complexity the repository has + # deliberately decided not to police, and leaving it alone does not. Two + # golangci-lint runs disagreeing means the unowned one sets the style. + VALIDATE_GO_MODULES: false + VALIDATE_GO: false + VALIDATE_GO_RELEASER: false + + # --------------------------------------------------------------------- + # Biome: a JavaScript/TypeScript toolchain. This repository has no + # JavaScript, so the only thing it reaches is JSON -- and every JSON file + # here is either written by blueprint.Save or by the emitter's manifest + # writer. Their byte-for-byte formatting is asserted by this repository's + # own tests and by the "Regenerate and diff" job, so Biome's opinion is not + # merely noise: satisfying it would make that job fail. Two checks cannot + # both be authoritative about the same bytes. + VALIDATE_BIOME_FORMAT: false + VALIDATE_BIOME_LINT: false + VALIDATE_JSON: false + VALIDATE_JSON_PRETTIER: false + VALIDATE_JSONC: false + VALIDATE_JSONC_PRETTIER: false + + # --------------------------------------------------------------------- + # Copy-paste detection, at super-linter's bundled threshold of 0%. # - # typechecking error: named files must all be in one directory; - # have /github/workspace/internal/manifest and /github/workspace/cmd/tfpluginframeworkgen + # No codebase passes 0%, and this one would be among the last: a code + # generator's fixtures are near-identical by construction, its templates + # emit near-identical CRUD bodies on purpose, and its tests assert on both. + # Duplication here is the product, not a defect. # - # In a code generator almost every change touches several packages at - # once, so this fails on ordinary pull requests regardless of whether - # the code is correct. A check that cannot pass is worse than no check: - # people learn to ignore it, and then ignore it when it means something. - VALIDATE_GO: false - VALIDATE_GOFMT: false - VALIDATE_GOCYCLO: false - VALIDATE_GOVET: false - VALIDATE_GOLANGCI_LINT: false + # The previous VALIDATE_CPD had no effect -- v8 names this validator JSCPD. + VALIDATE_JSCPD: false + # --------------------------------------------------------------------- # The pinned specifications are third-party documents committed verbatim # for reproducibility. Linting them reports faults we cannot fix without # editing a pinned artefact, which would defeat the point of pinning it. # The ThousandEyes v7 document alone has dozens of $ref-sibling errors. VALIDATE_OPENAPI: false - VALIDATE_CPD: false # Disable CPD (Copy Paste Detection) for all languages - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # To report GitHub Actions status checks - MARKDOWN_CONFIG_FILE: .markdown-lint.yml + # --------------------------------------------------------------------- + # Kept on, and why. + # + # GITLEAKS is the secret scanner this repository relies on, and it passes. + # CHECKOV and TRIVY also scan for secrets and both report one finding: an + # `example:` block inside the vendored OpenAPI document, which is a false + # positive on a third-party artefact we do not author. They are pointed at + # repository-level configs that skip that directory rather than switched + # off, because their other findings are worth having -- TRIVY reports zero + # vulnerabilities across both go.mod files and zero misconfigurations in + # the generated Terraform examples, and that is a real check. + # No env var is needed to point at either config: super-linter looks for + # CHECKOV_FILE_NAME (default .checkov.yaml) and TRIVY_CONFIG_FILE (default + # trivy.yaml) under LINTER_RULES_PATH, which defaults to .github/linters -- + # where .codespellrc and .gitleaks.toml already live. A config at the + # repository root would be silently ignored in favour of the bundled one. + # One regex on one line, deliberately. # # This was previously a YAML block scalar with trailing "# ..." comments. # A block scalar has no comments: the "#" text became part of the regex, # so the alternatives after the first never matched and .*\.json$ was - # silently doing nothing. That is why JSON was still being linted despite - # being listed here. + # silently doing nothing. + # + # Note this list only reaches validators that take a file list. Checkov and + # Trivy walk the workspace themselves, which is why they get config files + # above instead. # # Excluded, and why: - # .*\.md$ prose, linted by the markdown validator instead - # .*_test\.go$ test files - # .*/test/.* test fixtures - # .*\.json$ blueprints and manifests, whose formatting this - # repository defines and asserts in its own tests - # ^pilot/.* generated output. Third-party style opinions about - # generated code are noise: the shape is decided by the - # templates, and duplicate-code detection in particular - # will always fire on it, because near-identical CRUD - # bodies are the point of generating them. - FILTER_REGEX_EXCLUDE: '(.*\.md$|.*_test\.go$|.*/test/.*|.*\.json$|^pilot/.*)' + # .*\.md$ prose. Note this excludes markdown from *every* + # validator including MARKDOWN, so markdownlint is + # effectively off. Turning it on is a separate decision: + # the long-form docs here would need a rule set chosen + # for them, and there is no .markdown-lint.yml to do it. + # .*_test\.go$ test files + # .*/test/.* test fixtures + # .*\.json$ blueprints and manifests, whose formatting this + # repository defines and asserts in its own tests + # ^pilot/.* generated output. Third-party style opinions about + # generated code are noise: the shape is decided by the + # templates. + # ^openapi-specs/. vendored third-party API documents, pinned verbatim + # ^probe-evidence/ recorded HTTP cassettes and derived facts, replayed + # byte-for-byte by the probe verification job + FILTER_REGEX_EXCLUDE: '(.*\.md$|.*_test\.go$|.*/test/.*|.*\.json$|^pilot/.*|^openapi-specs/.*|^probe-evidence/.*)' diff --git a/.github/workflows/pr-title-validation.yml b/.github/workflows/pr-title-validation.yml index 52e2decd..3652ee81 100644 --- a/.github/workflows/pr-title-validation.yml +++ b/.github/workflows/pr-title-validation.yml @@ -4,22 +4,32 @@ on: pull_request: types: [opened, edited, synchronize, reopened] +# Declared rather than inherited: a workflow with no permissions block gets the +# repository default, which may be write-all. This one reads the title out of the event +# payload and checks it out of nothing, so it needs no write anywhere. +permissions: + contents: read + jobs: validate-pr-title: - name: 'โœ… Validate PR Title' + name: "โœ… Validate PR Title" runs-on: ubuntu-latest steps: - - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Check PR Title + # The title reaches the script through the environment, not through ${{ }}. + # Actions splices an expression into the script text before the shell runs, so a + # title containing a quote and a semicolon would execute as commands on the + # runner -- and a pull request title is supplied by whoever opened it. + env: + PR_TITLE: ${{ github.event.pull_request.title }} run: | - PR_TITLE="${{ github.event.pull_request.title }}" PATTERN="^(([Ff]eat|[Ff]ix|[Dd]ocs|[Ss]tyle|[Rr]efactor|[Tt]est|[Cc]hore|[Bb]uild|[Cc]i|[Pp]erf)(\(.+\))?: .+|dependabot.*)$" - + if ! echo "$PR_TITLE" | grep -qE "$PATTERN"; then echo "โŒ ERROR: Invalid PR title format" echo "" @@ -50,5 +60,5 @@ jobs: echo "โœ… dependabot: bump lodash from 4.17.20 to 4.17.21" exit 1 fi - + echo "โœ… PR title '$PR_TITLE' follows the conventional commit format and is compatible with the release-please" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4b48c4d4..87b5e596 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,7 +1,7 @@ on: push: branches: - - main + - main permissions: contents: write @@ -11,16 +11,15 @@ name: release-please jobs: release-please: - name: '๐Ÿ”– Release Please' + name: "๐Ÿ”– Release Please" runs-on: ubuntu-latest steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 - with: - release-type: terraform-module - token: ${{ secrets.RELEASE_PLEASE_PAT }} + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + with: + release-type: terraform-module + token: ${{ secrets.RELEASE_PLEASE_PAT }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 54435b9f..4b44a80d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,19 +5,19 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - - '.github/workflows/unit-tests.yml' - - '**/*.go' - - 'go.mod' - - 'go.sum' + - ".github/workflows/unit-tests.yml" + - "**/*.go" + - "go.mod" + - "go.sum" permissions: contents: read - pull-requests: write # Needed for commenting on PR - issues: write # Needed for commenting on PR + pull-requests: write # Needed for commenting on PR + issues: write # Needed for commenting on PR jobs: unit-tests: - name: '๐Ÿงช Run Unit Tests' + name: "๐Ÿงช Run Unit Tests" runs-on: ubuntu-24.04-arm if: github.event.pull_request.draft == false @@ -28,15 +28,17 @@ jobs: egress-policy: audit - name: Check Out - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # Get full history for coverage analysis + # These jobs only read the tree; none of them runs git push. + persist-credentials: false + fetch-depth: 0 # Get full history for coverage analysis - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version-file: 'go.mod' - cache-dependency-path: 'go.sum' + go-version-file: "go.mod" + cache-dependency-path: "go.sum" cache: true - name: Download Dependencies @@ -49,8 +51,18 @@ jobs: - name: Run Unit Tests run: | echo "::group::๐Ÿงช Unit Tests" + # The package list goes into an array. Relying on the shell to split an + # unquoted command substitution works, but says nothing about whether the + # splitting is wanted; here it is, because go test takes many packages. + # + # read in a loop rather than mapfile: mapfile arrived in bash 4, and while the + # runner has bash 5, this form can be tested on a machine that does not. + packages=() + while IFS= read -r pkg; do packages+=("$pkg"); done < <( + go list ./... | grep -v '/acceptance' | grep -v '/examples' + ) go test -v -race -coverprofile=coverage.out -covermode=atomic \ - $(go list ./... | grep -v '/acceptance' | grep -v '/examples') 2>&1 | tee test_output.txt + "${packages[@]}" 2>&1 | tee test_output.txt echo "::endgroup::" echo "" @@ -109,11 +121,19 @@ jobs: # Save artifacts on failure - name: Save artifacts if: failure() + # Through the environment, not through ${{ }}. An expression is spliced into the + # script text before the shell runs, and a fork's repository name is chosen by + # whoever owns the fork, so it can carry shell metacharacters. printf rather than + # echo because echo's handling of a leading dash or a backslash varies by shell. + env: + GH_OWNER: ${{ github.repository_owner }} + GH_REPO: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | mkdir -p wr_actions - echo ${{ github.repository_owner }} > wr_actions/ghowner.txt - echo ${{ github.event.repository.name }} > wr_actions/ghrepo.txt - echo ${{ github.event.pull_request.number }} > wr_actions/prnumber.txt + printf '%s\n' "$GH_OWNER" > wr_actions/ghowner.txt + printf '%s\n' "$GH_REPO" > wr_actions/ghrepo.txt + printf '%s\n' "$PR_NUMBER" > wr_actions/prnumber.txt - name: Upload artifacts if: failure() @@ -126,7 +146,7 @@ jobs: - name: Get run url if: failure() run: | - echo "gha_url=https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" >> $GITHUB_ENV + echo "gha_url=https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" >> "$GITHUB_ENV" - name: Send build failure comment if: failure() && github.event.pull_request.head.repo.full_name == github.repository diff --git a/README.md b/README.md index de0b8d92..58ca69a4 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,10 @@ provider-defined functions, state upgraders. drawn honestly. - **`ingest` refuses partial resources by default.** A resource whose CRUD set is incomplete is a curation decision, not something to guess at. -- **Nesting is supported one level deep** and refused beyond it, naming the - offending attribute. Deeper shapes need flattening or a deliberate extension. +- **Nesting is generated to any depth** the blueprint declares. Two things are refused, + naming the offending attribute: two nested objects that would declare the same Go + identifier, and nesting past ten levels โ€” a runaway guard, since a schema deeper than + that is usually one whose depth is decided at runtime and so is not expressible here. - **Two attribute decisions in the pilot are unprobed guesses**, recorded as such in the blueprint's own descriptions: whether `color`, `access_type` and `match_type` really carry server defaults, and whether `legacy_id` is diff --git a/blueprints/thousandeyes/datasources/tag.blueprint.json b/blueprints/thousandeyes/datasources/tag.blueprint.json new file mode 100644 index 00000000..754cf0ac --- /dev/null +++ b/blueprints/thousandeyes/datasources/tag.blueprint.json @@ -0,0 +1,491 @@ +{ + "formatVersion": "2", + "dataSources": [ + { + "key": "tag", + "name": "tag", + "goPackage": "tag", + "goPackageAlias": "v7TagData", + "goTypeName": "TagDataSource", + "modelTypeName": "TagDataSourceModel", + "serviceGroup": "tags", + "apiVersionDir": "v7", + "docRefUrl": "https://developer.cisco.com/docs/thousandeyes/get-tag/", + "schema": { + "attributes": [ + { + "name": "id", + "goField": "ID", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "required", + "markdownDescription": "The identifier of the tag to look up.", + "wire": { + "jsonPath": "id", + "sdkField": "ID", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "key", + "goField": "Key", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's key. Together with `value` this forms the label applied to assigned objects.", + "wire": { + "jsonPath": "key", + "sdkField": "Key", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "value", + "goField": "Value", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's value.\n\n\nThe API enforces this field's presence, which the specification does not declare.\n", + "wire": { + "jsonPath": "value", + "sdkField": "Value", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "color", + "goField": "Color", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's display colour as a hex string. Computed as well as optional because the API assigns one when it is omitted; this has not yet been confirmed by probing.\n\n\nObserved: the API assigns \"#A7EB10\" when this is omitted.\n", + "wire": { + "jsonPath": "color", + "sdkField": "Color", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "description", + "goField": "Description", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "A human-readable description of the tag.", + "wire": { + "jsonPath": "description", + "sdkField": "Description", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "icon", + "goField": "Icon", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's icon.\n\n\nObserved: the API assigns \"LABEL\" when this is omitted.\n", + "wire": { + "jsonPath": "icon", + "sdkField": "Icon", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "object_type", + "goField": "ObjectType", + "type": { + "kind": "string", + "enum": [ + "test", + "dashboard", + "endpoint-test", + "v-agent", + "connected-devices-test", + "endpoint-agent" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated because the API's enumerations are open: an undocumented value must not be rejected by the provider.\n\n\nValues accepted here: `test`, `dashboard`, `endpoint-test`, `v-agent`, `connected-devices-test`.\nThe specification documents `endpoint-agent`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", + "wire": { + "jsonPath": "objectType", + "sdkField": "ObjectType", + "sdkGoType": "tags.ObjectType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "access_type", + "goField": "AccessType", + "type": { + "kind": "string", + "enum": [ + "all", + "partner", + "system" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's access level. Documented values are `all`, `partner` and `system`.\n\n\nValues accepted here: `all`.\nThe specification documents `system`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", + "wire": { + "jsonPath": "accessType", + "sdkField": "AccessType", + "sdkGoType": "tags.AccessType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "match_type", + "goField": "MatchType", + "type": { + "kind": "string", + "enum": [ + "and", + "or" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "How the tag's filters combine when it is assigned dynamically.\n\n\nValues accepted here: `and`, `or`.\n", + "wire": { + "jsonPath": "matchType", + "sdkField": "MatchType", + "sdkGoType": "tags.TagMatchType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "type", + "goField": "Type", + "type": { + "kind": "string", + "enum": [ + "static", + "dynamic" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's type, assigned by the API.", + "wire": { + "jsonPath": "type", + "sdkField": "Type", + "sdkGoType": "tags.Type", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "built_in", + "goField": "BuiltIn", + "type": { + "kind": "bool" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "Whether the tag is built in rather than user-created.", + "wire": { + "jsonPath": "builtIn", + "sdkField": "BuiltIn", + "sdkGoType": "*bool", + "flatten": { + "func": "convert.PtrBoolToFramework" + } + } + }, + { + "name": "account_group_id", + "goField": "AccountGroupID", + "type": { + "kind": "int64" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The account group the tag belongs to. Computed rather than configurable: the provider scopes every request through its own `account_group_id` setting, so accepting a second value here would let the two disagree.", + "wire": { + "jsonPath": "aid", + "sdkField": "AID", + "sdkGoType": "*int64", + "flatten": { + "func": "convert.PtrInt64ToFramework" + } + } + }, + { + "name": "create_date", + "goField": "CreateDate", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "When the tag was created.", + "wire": { + "jsonPath": "createDate", + "sdkField": "CreateDate", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "modified_date", + "goField": "ModifiedDate", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "When the tag was last modified.", + "wire": { + "jsonPath": "modifiedDate", + "sdkField": "ModifiedDate", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "legacy_id", + "goField": "LegacyID", + "type": { + "kind": "float64" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's identifier in the v6 API. Typed as a number because the specification declares it as one, although observed values are integral; probing will settle whether this should be an integer.", + "wire": { + "jsonPath": "legacyId", + "sdkField": "LegacyID", + "sdkGoType": "*float64", + "flatten": { + "func": "convert.PtrFloat64ToFramework" + } + } + }, + { + "name": "assignments", + "goField": "Assignments", + "type": { + "kind": "set_nested", + "nestedObject": { + "goTypeName": "TagAssignmentModel", + "sdkType": "tags.Assignment", + "attrTypesVar": "tagAssignmentAttrTypes", + "objectTypeVar": "tagAssignmentObjectType", + "flattenFunc": "flattenTagAssignments", + "attributes": [ + { + "name": "id", + "goField": "ID", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The identifier of the object the tag is assigned to.", + "wire": { + "jsonPath": "id", + "sdkField": "ID", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "type", + "goField": "Type", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`.", + "wire": { + "jsonPath": "type", + "sdkField": "Type", + "sdkGoType": "tags.AssignmentType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + } + ] + } + }, + "computedOptionalRequired": "computed", + "markdownDescription": "Objects this tag is assigned to. The API returns assignments only when the request asks for them to be expanded, which this data source does not, so this is null rather than empty.", + "wire": { + "jsonPath": "assignments", + "sdkField": "Assignments", + "sdkGoType": "[]tags.Assignment", + "flatten": { + "func": "flattenTagAssignments", + "needsCtx": true, + "returnsError": true + } + } + }, + { + "name": "filters", + "goField": "Filters", + "type": { + "kind": "set_nested", + "nestedObject": { + "goTypeName": "TagFilterModel", + "sdkType": "tags.TagFilter", + "attrTypesVar": "tagFilterAttrTypes", + "objectTypeVar": "tagFilterObjectType", + "flattenFunc": "flattenTagFilters", + "attributes": [ + { + "name": "key", + "goField": "Key", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The filter key used for matching.", + "wire": { + "jsonPath": "key", + "sdkField": "Key", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "mode", + "goField": "Mode", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "How the filter values are matched.", + "wire": { + "jsonPath": "mode", + "sdkField": "Mode", + "sdkGoType": "tags.TagFilterMode", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "scope", + "goField": "Scope", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The scope the filter applies within.", + "wire": { + "jsonPath": "scope", + "sdkField": "Scope", + "sdkGoType": "tags.TagFilterScope", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "values", + "goField": "Values", + "type": { + "kind": "set", + "elementType": { + "kind": "string" + } + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The values the filter matches against.", + "wire": { + "jsonPath": "values", + "sdkField": "Values", + "sdkGoType": "[]string", + "flatten": { + "func": "convert.StringSliceToFrameworkSet", + "needsCtx": true, + "returnsError": true + } + } + } + ] + } + }, + "computedOptionalRequired": "computed", + "markdownDescription": "Filters that dynamically assign this tag to endpoint agents.", + "wire": { + "jsonPath": "filters", + "sdkField": "Filters", + "sdkGoType": "[]tags.TagFilter", + "flatten": { + "func": "flattenTagFilters", + "needsCtx": true, + "returnsError": true + } + } + } + ], + "markdownDescription": "Looks up a single ThousandEyes tag by its identifier." + }, + "binding": { + "service": { + "importPath": "github.com/deploymenttheory/go-sdk-thousandeyes/thousandeyes/thousandeyes_api/tags", + "typeName": "Tags", + "accessor": "d.client.API.Tags" + }, + "read": { + "style": "method", + "method": "GetTag", + "args": [ + { + "kind": "ctx" + }, + { + "kind": "configField", + "field": "ID" + } + ], + "return": "resultTransportError", + "resultType": "tags.Tag", + "httpMethod": "GET", + "pathTemplate": "/tags/{id}", + "successCodes": [ + 200 + ] + }, + "response": { + "type": "tags.Tag", + "accessStyle": "structField" + } + } + } + ] +} diff --git a/blueprints/thousandeyes/datasources/tags.blueprint.json b/blueprints/thousandeyes/datasources/tags.blueprint.json new file mode 100644 index 00000000..6d2c1f91 --- /dev/null +++ b/blueprints/thousandeyes/datasources/tags.blueprint.json @@ -0,0 +1,515 @@ +{ + "formatVersion": "2", + "dataSources": [ + { + "key": "tags", + "name": "tags", + "goPackage": "tags", + "goPackageAlias": "v7TagsData", + "goTypeName": "TagsDataSource", + "modelTypeName": "TagsDataSourceModel", + "serviceGroup": "tags", + "apiVersionDir": "v7", + "docRefUrl": "https://developer.cisco.com/docs/thousandeyes/list-tags/", + "schema": { + "attributes": [ + { + "name": "tags", + "goField": "Tags", + "type": { + "kind": "list_nested", + "nestedObject": { + "goTypeName": "TagSummaryModel", + "sdkType": "tags.Tag", + "attrTypesVar": "tagSummaryAttrTypes", + "objectTypeVar": "tagSummaryObjectType", + "flattenFunc": "flattenTagSummaries", + "attributes": [ + { + "name": "id", + "goField": "ID", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's unique identifier, assigned by the API.", + "wire": { + "jsonPath": "id", + "sdkField": "ID", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "key", + "goField": "Key", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's key. Together with `value` this forms the label applied to assigned objects.", + "wire": { + "jsonPath": "key", + "sdkField": "Key", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "value", + "goField": "Value", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's value.\n\n\nThe API enforces this field's presence, which the specification does not declare.\n", + "wire": { + "jsonPath": "value", + "sdkField": "Value", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "color", + "goField": "Color", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's display colour as a hex string. Computed as well as optional because the API assigns one when it is omitted; this has not yet been confirmed by probing.\n\n\nObserved: the API assigns \"#A7EB10\" when this is omitted.\n", + "wire": { + "jsonPath": "color", + "sdkField": "Color", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "description", + "goField": "Description", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "A human-readable description of the tag.", + "wire": { + "jsonPath": "description", + "sdkField": "Description", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "icon", + "goField": "Icon", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's icon.\n\n\nObserved: the API assigns \"LABEL\" when this is omitted.\n", + "wire": { + "jsonPath": "icon", + "sdkField": "Icon", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "object_type", + "goField": "ObjectType", + "type": { + "kind": "string", + "enum": [ + "test", + "dashboard", + "endpoint-test", + "v-agent", + "connected-devices-test", + "endpoint-agent" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated because the API's enumerations are open: an undocumented value must not be rejected by the provider.\n\n\nValues accepted here: `test`, `dashboard`, `endpoint-test`, `v-agent`, `connected-devices-test`.\nThe specification documents `endpoint-agent`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", + "wire": { + "jsonPath": "objectType", + "sdkField": "ObjectType", + "sdkGoType": "tags.ObjectType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "access_type", + "goField": "AccessType", + "type": { + "kind": "string", + "enum": [ + "all", + "partner", + "system" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's access level. Documented values are `all`, `partner` and `system`.\n\n\nValues accepted here: `all`.\nThe specification documents `system`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", + "wire": { + "jsonPath": "accessType", + "sdkField": "AccessType", + "sdkGoType": "tags.AccessType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "match_type", + "goField": "MatchType", + "type": { + "kind": "string", + "enum": [ + "and", + "or" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "How the tag's filters combine when it is assigned dynamically.\n\n\nValues accepted here: `and`, `or`.\n", + "wire": { + "jsonPath": "matchType", + "sdkField": "MatchType", + "sdkGoType": "tags.TagMatchType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "type", + "goField": "Type", + "type": { + "kind": "string", + "enum": [ + "static", + "dynamic" + ] + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's type, assigned by the API.", + "wire": { + "jsonPath": "type", + "sdkField": "Type", + "sdkGoType": "tags.Type", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "built_in", + "goField": "BuiltIn", + "type": { + "kind": "bool" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "Whether the tag is built in rather than user-created.", + "wire": { + "jsonPath": "builtIn", + "sdkField": "BuiltIn", + "sdkGoType": "*bool", + "flatten": { + "func": "convert.PtrBoolToFramework" + } + } + }, + { + "name": "account_group_id", + "goField": "AccountGroupID", + "type": { + "kind": "int64" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The account group the tag belongs to. Computed rather than configurable: the provider scopes every request through its own `account_group_id` setting, so accepting a second value here would let the two disagree.", + "wire": { + "jsonPath": "aid", + "sdkField": "AID", + "sdkGoType": "*int64", + "flatten": { + "func": "convert.PtrInt64ToFramework" + } + } + }, + { + "name": "create_date", + "goField": "CreateDate", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "When the tag was created.", + "wire": { + "jsonPath": "createDate", + "sdkField": "CreateDate", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "modified_date", + "goField": "ModifiedDate", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "When the tag was last modified.", + "wire": { + "jsonPath": "modifiedDate", + "sdkField": "ModifiedDate", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "legacy_id", + "goField": "LegacyID", + "type": { + "kind": "float64" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's identifier in the v6 API. Typed as a number because the specification declares it as one, although observed values are integral; probing will settle whether this should be an integer.", + "wire": { + "jsonPath": "legacyId", + "sdkField": "LegacyID", + "sdkGoType": "*float64", + "flatten": { + "func": "convert.PtrFloat64ToFramework" + } + } + }, + { + "name": "assignments", + "goField": "Assignments", + "type": { + "kind": "set_nested", + "nestedObject": { + "goTypeName": "TagSummaryAssignmentModel", + "sdkType": "tags.Assignment", + "attrTypesVar": "tagSummaryAssignmentAttrTypes", + "objectTypeVar": "tagSummaryAssignmentObjectType", + "flattenFunc": "flattenTagSummaryAssignments", + "attributes": [ + { + "name": "id", + "goField": "ID", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The identifier of the object the tag is assigned to.", + "wire": { + "jsonPath": "id", + "sdkField": "ID", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "type", + "goField": "Type", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`.", + "wire": { + "jsonPath": "type", + "sdkField": "Type", + "sdkGoType": "tags.AssignmentType", + "flatten": { + "func": "convert.EnumToFramework" + } + } + } + ] + } + }, + "computedOptionalRequired": "computed", + "markdownDescription": "Objects this tag is assigned to. A set rather than a list because the API does not preserve ordering.", + "wire": { + "jsonPath": "assignments", + "sdkField": "Assignments", + "sdkGoType": "[]tags.Assignment", + "flatten": { + "func": "flattenTagSummaryAssignments", + "needsCtx": true, + "returnsError": true + } + } + }, + { + "name": "filters", + "goField": "Filters", + "type": { + "kind": "set_nested", + "nestedObject": { + "goTypeName": "TagSummaryFilterModel", + "sdkType": "tags.TagFilter", + "attrTypesVar": "tagSummaryFilterAttrTypes", + "objectTypeVar": "tagSummaryFilterObjectType", + "flattenFunc": "flattenTagSummaryFilters", + "attributes": [ + { + "name": "key", + "goField": "Key", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The filter key used for matching.", + "wire": { + "jsonPath": "key", + "sdkField": "Key", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + } + } + }, + { + "name": "mode", + "goField": "Mode", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "How the filter values are matched.", + "wire": { + "jsonPath": "mode", + "sdkField": "Mode", + "sdkGoType": "tags.TagFilterMode", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "scope", + "goField": "Scope", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The scope the filter applies within.", + "wire": { + "jsonPath": "scope", + "sdkField": "Scope", + "sdkGoType": "tags.TagFilterScope", + "flatten": { + "func": "convert.EnumToFramework" + } + } + }, + { + "name": "values", + "goField": "Values", + "type": { + "kind": "set", + "elementType": { + "kind": "string" + } + }, + "computedOptionalRequired": "computed", + "markdownDescription": "The values the filter matches against.", + "wire": { + "jsonPath": "values", + "sdkField": "Values", + "sdkGoType": "[]string", + "flatten": { + "func": "convert.StringSliceToFrameworkSet", + "needsCtx": true, + "returnsError": true + } + } + } + ] + } + }, + "computedOptionalRequired": "computed", + "markdownDescription": "Filters that dynamically assign this tag to endpoint agents.", + "wire": { + "jsonPath": "filters", + "sdkField": "Filters", + "sdkGoType": "[]tags.TagFilter", + "flatten": { + "func": "flattenTagSummaryFilters", + "needsCtx": true, + "returnsError": true + } + } + } + ] + } + }, + "computedOptionalRequired": "computed", + "markdownDescription": "Every tag visible to the account group, in the order the API returned them. A list rather than a set because the API's ordering is the only ordering there is, and a set would discard it.", + "wire": { + "jsonPath": "tags", + "sdkField": "Tags", + "sdkGoType": "[]tags.Tag", + "flatten": { + "func": "flattenTagSummaries", + "needsCtx": true, + "returnsError": true + } + } + } + ], + "markdownDescription": "Lists every ThousandEyes tag visible to the configured account group." + }, + "binding": { + "service": { + "importPath": "github.com/deploymenttheory/go-sdk-thousandeyes/thousandeyes/thousandeyes_api/tags", + "typeName": "Tags", + "accessor": "d.client.API.Tags" + }, + "read": { + "style": "method", + "method": "GetTags", + "args": [ + { + "kind": "ctx" + } + ], + "return": "resultTransportError", + "resultType": "tags.ResourceTags", + "httpMethod": "GET", + "pathTemplate": "/tags", + "successCodes": [ + 200 + ] + }, + "response": { + "type": "tags.ResourceTags", + "accessStyle": "structField" + } + } + } + ] +} diff --git a/blueprints/thousandeyes/provider.blueprint.json b/blueprints/thousandeyes/provider.blueprint.json index 1882c982..3856c774 100644 --- a/blueprints/thousandeyes/provider.blueprint.json +++ b/blueprints/thousandeyes/provider.blueprint.json @@ -1,5 +1,5 @@ { - "formatVersion": "1", + "formatVersion": "2", "provider": { "name": "thousandeyes", "goModule": "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes", diff --git a/blueprints/thousandeyes/resources/tag.blueprint.json b/blueprints/thousandeyes/resources/tag.blueprint.json index 2c693fd2..73d8dc4b 100644 --- a/blueprints/thousandeyes/resources/tag.blueprint.json +++ b/blueprints/thousandeyes/resources/tag.blueprint.json @@ -1,577 +1,579 @@ { - "formatVersion": "1", + "formatVersion": "2", "resources": [ { "key": "tag", - "terraformType": "thousandeyes_tag", + "name": "tag", "goPackage": "tag", "goPackageAlias": "v7Tag", "goTypeName": "TagResource", "modelTypeName": "TagResourceModel", "serviceGroup": "tags", "apiVersionDir": "v7", - "markdownDescription": "Manages a ThousandEyes tag. Tags are key/value labels that can be assigned to tests, agents and dashboards.", "docRefUrl": "https://developer.cisco.com/docs/thousandeyes/list-tags/", - "attributes": [ - { - "name": "id", - "goField": "ID", - "type": { - "kind": "string" - }, - "computedOptionalRequired": "computed", - "markdownDescription": "The tag's unique identifier, assigned by the API.", - "wire": { - "jsonPath": "id", - "sdkField": "ID", - "sdkGoType": "*string", - "flatten": { - "func": "convert.PtrStringToFramework" + "schema": { + "attributes": [ + { + "name": "id", + "goField": "ID", + "type": { + "kind": "string" }, - "skipExpand": true - } - }, - { - "name": "key", - "goField": "Key", - "type": { - "kind": "string" - }, - "computedOptionalRequired": "required", - "markdownDescription": "The tag's key. Together with `value` this forms the label applied to assigned objects.", - "behaviour": { - "immutable": false, - "returnedOnRead": true + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's unique identifier, assigned by the API.", + "wire": { + "jsonPath": "id", + "sdkField": "ID", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + }, + "skipExpand": true + } }, - "wire": { - "jsonPath": "key", - "sdkField": "Key", - "sdkGoType": "*string", - "expand": { - "func": "convert.FrameworkToPtrString" + { + "name": "key", + "goField": "Key", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "required", + "markdownDescription": "The tag's key. Together with `value` this forms the label applied to assigned objects.", + "behaviour": { + "immutable": false, + "returnedOnRead": true }, - "flatten": { - "func": "convert.PtrStringToFramework" + "wire": { + "jsonPath": "key", + "sdkField": "Key", + "sdkGoType": "*string", + "expand": { + "func": "convert.FrameworkToPtrString" + }, + "flatten": { + "func": "convert.PtrStringToFramework" + } } - } - }, - { - "name": "value", - "goField": "Value", - "type": { - "kind": "string" - }, - "computedOptionalRequired": "optional", - "markdownDescription": "The tag's value.\n\n\nThe API enforces this field's presence, which the specification does not declare.\n", - "behaviour": { - "requiredByApi": true, - "returnedOnRead": true }, - "wire": { - "jsonPath": "value", - "sdkField": "Value", - "sdkGoType": "*string", - "expand": { - "func": "convert.FrameworkToPtrString" + { + "name": "value", + "goField": "Value", + "type": { + "kind": "string" }, - "flatten": { - "func": "convert.PtrStringToFramework" + "computedOptionalRequired": "optional", + "markdownDescription": "The tag's value.\n\n\nThe API enforces this field's presence, which the specification does not declare.\n", + "behaviour": { + "requiredByApi": true, + "returnedOnRead": true + }, + "wire": { + "jsonPath": "value", + "sdkField": "Value", + "sdkGoType": "*string", + "expand": { + "func": "convert.FrameworkToPtrString" + }, + "flatten": { + "func": "convert.PtrStringToFramework" + } } - } - }, - { - "name": "color", - "goField": "Color", - "type": { - "kind": "string" }, - "computedOptionalRequired": "computed_optional", - "markdownDescription": "The tag's display colour as a hex string. Computed as well as optional because the API assigns one when it is omitted; this has not yet been confirmed by probing.\n\n\nObserved: the API assigns \"#A7EB10\" when this is omitted.\n", - "behaviour": { - "immutable": false, - "serverDefault": { - "kind": "", - "raw": "\"#A7EB10\"" + { + "name": "color", + "goField": "Color", + "type": { + "kind": "string" }, - "returnedOnRead": true - }, - "wire": { - "jsonPath": "color", - "sdkField": "Color", - "sdkGoType": "*string", - "expand": { - "func": "convert.FrameworkToPtrString" + "computedOptionalRequired": "computed_optional", + "markdownDescription": "The tag's display colour as a hex string. Computed as well as optional because the API assigns one when it is omitted; this has not yet been confirmed by probing.\n\n\nObserved: the API assigns \"#A7EB10\" when this is omitted.\n", + "behaviour": { + "immutable": false, + "serverDefault": { + "kind": "", + "raw": "\"#A7EB10\"" + }, + "returnedOnRead": true }, - "flatten": { - "func": "convert.PtrStringToFramework" + "wire": { + "jsonPath": "color", + "sdkField": "Color", + "sdkGoType": "*string", + "expand": { + "func": "convert.FrameworkToPtrString" + }, + "flatten": { + "func": "convert.PtrStringToFramework" + } } - } - }, - { - "name": "description", - "goField": "Description", - "type": { - "kind": "string" - }, - "computedOptionalRequired": "optional", - "markdownDescription": "A human-readable description of the tag.", - "behaviour": { - "returnedOnRead": true }, - "wire": { - "jsonPath": "description", - "sdkField": "Description", - "sdkGoType": "*string", - "expand": { - "func": "convert.FrameworkToPtrString" + { + "name": "description", + "goField": "Description", + "type": { + "kind": "string" + }, + "computedOptionalRequired": "optional", + "markdownDescription": "A human-readable description of the tag.", + "behaviour": { + "returnedOnRead": true }, - "flatten": { - "func": "convert.PtrStringToFramework" + "wire": { + "jsonPath": "description", + "sdkField": "Description", + "sdkGoType": "*string", + "expand": { + "func": "convert.FrameworkToPtrString" + }, + "flatten": { + "func": "convert.PtrStringToFramework" + } } - } - }, - { - "name": "icon", - "goField": "Icon", - "type": { - "kind": "string" }, - "computedOptionalRequired": "optional", - "markdownDescription": "The tag's icon.\n\n\nObserved: the API assigns \"LABEL\" when this is omitted.\n", - "behaviour": { - "serverDefault": { - "kind": "", - "raw": "\"LABEL\"" + { + "name": "icon", + "goField": "Icon", + "type": { + "kind": "string" }, - "returnedOnRead": true - }, - "wire": { - "jsonPath": "icon", - "sdkField": "Icon", - "sdkGoType": "*string", - "expand": { - "func": "convert.FrameworkToPtrString" + "computedOptionalRequired": "optional", + "markdownDescription": "The tag's icon.\n\n\nObserved: the API assigns \"LABEL\" when this is omitted.\n", + "behaviour": { + "serverDefault": { + "kind": "", + "raw": "\"LABEL\"" + }, + "returnedOnRead": true }, - "flatten": { - "func": "convert.PtrStringToFramework" + "wire": { + "jsonPath": "icon", + "sdkField": "Icon", + "sdkGoType": "*string", + "expand": { + "func": "convert.FrameworkToPtrString" + }, + "flatten": { + "func": "convert.PtrStringToFramework" + } } - } - }, - { - "name": "object_type", - "goField": "ObjectType", - "type": { - "kind": "string", - "enum": [ - "test", - "dashboard", - "endpoint-test", - "v-agent", - "connected-devices-test", - "endpoint-agent" - ] }, - "computedOptionalRequired": "required", - "markdownDescription": "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated because the API's enumerations are open: an undocumented value must not be rejected by the provider.\n\n\nValues accepted here: `test`, `dashboard`, `endpoint-test`, `v-agent`, `connected-devices-test`.\nThe specification documents `endpoint-agent`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", - "behaviour": { - "immutable": true, - "requiredByApi": true, - "returnedOnRead": true - }, - "wire": { - "jsonPath": "objectType", - "sdkField": "ObjectType", - "sdkGoType": "tags.ObjectType", - "expand": { - "func": "convert.FrameworkToEnum", - "typeArgs": [ - "tags.ObjectType" + { + "name": "object_type", + "goField": "ObjectType", + "type": { + "kind": "string", + "enum": [ + "test", + "dashboard", + "endpoint-test", + "v-agent", + "connected-devices-test", + "endpoint-agent" ] }, - "flatten": { - "func": "convert.EnumToFramework" + "computedOptionalRequired": "required", + "markdownDescription": "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated because the API's enumerations are open: an undocumented value must not be rejected by the provider.\n\n\nValues accepted here: `test`, `dashboard`, `endpoint-test`, `v-agent`, `connected-devices-test`.\nThe specification documents `endpoint-agent`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", + "behaviour": { + "immutable": true, + "requiredByApi": true, + "returnedOnRead": true + }, + "wire": { + "jsonPath": "objectType", + "sdkField": "ObjectType", + "sdkGoType": "tags.ObjectType", + "expand": { + "func": "convert.FrameworkToEnum", + "typeArgs": [ + "tags.ObjectType" + ] + }, + "flatten": { + "func": "convert.EnumToFramework" + } } - } - }, - { - "name": "access_type", - "goField": "AccessType", - "type": { - "kind": "string", - "enum": [ - "all", - "partner", - "system" - ] - }, - "computedOptionalRequired": "computed_optional", - "markdownDescription": "The tag's access level. Documented values are `all`, `partner` and `system`.\n\n\nValues accepted here: `all`.\nThe specification documents `system`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", - "behaviour": { - "requiredByApi": true, - "returnedOnRead": true }, - "wire": { - "jsonPath": "accessType", - "sdkField": "AccessType", - "sdkGoType": "tags.AccessType", - "expand": { - "func": "convert.FrameworkToEnum", - "typeArgs": [ - "tags.AccessType" + { + "name": "access_type", + "goField": "AccessType", + "type": { + "kind": "string", + "enum": [ + "all", + "partner", + "system" ] }, - "flatten": { - "func": "convert.EnumToFramework" + "computedOptionalRequired": "computed_optional", + "markdownDescription": "The tag's access level. Documented values are `all`, `partner` and `system`.\n\n\nValues accepted here: `all`.\nThe specification documents `system`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n", + "behaviour": { + "requiredByApi": true, + "returnedOnRead": true + }, + "wire": { + "jsonPath": "accessType", + "sdkField": "AccessType", + "sdkGoType": "tags.AccessType", + "expand": { + "func": "convert.FrameworkToEnum", + "typeArgs": [ + "tags.AccessType" + ] + }, + "flatten": { + "func": "convert.EnumToFramework" + } } - } - }, - { - "name": "match_type", - "goField": "MatchType", - "type": { - "kind": "string", - "enum": [ - "and", - "or" - ] - }, - "computedOptionalRequired": "computed_optional", - "markdownDescription": "How the tag's filters combine when it is assigned dynamically.\n\n\nValues accepted here: `and`, `or`.\n", - "behaviour": { - "returnedOnRead": false }, - "wire": { - "jsonPath": "matchType", - "sdkField": "MatchType", - "sdkGoType": "tags.TagMatchType", - "expand": { - "func": "convert.FrameworkToEnum", - "typeArgs": [ - "tags.TagMatchType" + { + "name": "match_type", + "goField": "MatchType", + "type": { + "kind": "string", + "enum": [ + "and", + "or" ] }, - "flatten": { - "func": "convert.EnumToFramework" + "computedOptionalRequired": "computed_optional", + "markdownDescription": "How the tag's filters combine when it is assigned dynamically.\n\n\nValues accepted here: `and`, `or`.\n", + "behaviour": { + "returnedOnRead": false + }, + "wire": { + "jsonPath": "matchType", + "sdkField": "MatchType", + "sdkGoType": "tags.TagMatchType", + "expand": { + "func": "convert.FrameworkToEnum", + "typeArgs": [ + "tags.TagMatchType" + ] + }, + "flatten": { + "func": "convert.EnumToFramework" + } } - } - }, - { - "name": "type", - "goField": "Type", - "type": { - "kind": "string", - "enum": [ - "static", - "dynamic" - ] }, - "computedOptionalRequired": "computed", - "markdownDescription": "The tag's type, assigned by the API.", - "wire": { - "jsonPath": "type", - "sdkField": "Type", - "sdkGoType": "tags.Type", - "flatten": { - "func": "convert.EnumToFramework" + { + "name": "type", + "goField": "Type", + "type": { + "kind": "string", + "enum": [ + "static", + "dynamic" + ] }, - "skipExpand": true - } - }, - { - "name": "built_in", - "goField": "BuiltIn", - "type": { - "kind": "bool" + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's type, assigned by the API.", + "wire": { + "jsonPath": "type", + "sdkField": "Type", + "sdkGoType": "tags.Type", + "flatten": { + "func": "convert.EnumToFramework" + }, + "skipExpand": true + } }, - "computedOptionalRequired": "computed", - "markdownDescription": "Whether the tag is built in rather than user-created.", - "wire": { - "jsonPath": "builtIn", - "sdkField": "BuiltIn", - "sdkGoType": "*bool", - "flatten": { - "func": "convert.PtrBoolToFramework" + { + "name": "built_in", + "goField": "BuiltIn", + "type": { + "kind": "bool" }, - "skipExpand": true - } - }, - { - "name": "account_group_id", - "goField": "AccountGroupID", - "type": { - "kind": "int64" + "computedOptionalRequired": "computed", + "markdownDescription": "Whether the tag is built in rather than user-created.", + "wire": { + "jsonPath": "builtIn", + "sdkField": "BuiltIn", + "sdkGoType": "*bool", + "flatten": { + "func": "convert.PtrBoolToFramework" + }, + "skipExpand": true + } }, - "computedOptionalRequired": "computed", - "markdownDescription": "The account group the tag belongs to. Computed rather than configurable: the provider scopes every request through its own `account_group_id` setting, so accepting a second value here would let the two disagree.", - "wire": { - "jsonPath": "aid", - "sdkField": "AID", - "sdkGoType": "*int64", - "flatten": { - "func": "convert.PtrInt64ToFramework" + { + "name": "account_group_id", + "goField": "AccountGroupID", + "type": { + "kind": "int64" }, - "skipExpand": true - } - }, - { - "name": "create_date", - "goField": "CreateDate", - "type": { - "kind": "string" + "computedOptionalRequired": "computed", + "markdownDescription": "The account group the tag belongs to. Computed rather than configurable: the provider scopes every request through its own `account_group_id` setting, so accepting a second value here would let the two disagree.", + "wire": { + "jsonPath": "aid", + "sdkField": "AID", + "sdkGoType": "*int64", + "flatten": { + "func": "convert.PtrInt64ToFramework" + }, + "skipExpand": true + } }, - "computedOptionalRequired": "computed", - "markdownDescription": "When the tag was created.", - "wire": { - "jsonPath": "createDate", - "sdkField": "CreateDate", - "sdkGoType": "*string", - "flatten": { - "func": "convert.PtrStringToFramework" + { + "name": "create_date", + "goField": "CreateDate", + "type": { + "kind": "string" }, - "skipExpand": true - } - }, - { - "name": "modified_date", - "goField": "ModifiedDate", - "type": { - "kind": "string" + "computedOptionalRequired": "computed", + "markdownDescription": "When the tag was created.", + "wire": { + "jsonPath": "createDate", + "sdkField": "CreateDate", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + }, + "skipExpand": true + } }, - "computedOptionalRequired": "computed", - "markdownDescription": "When the tag was last modified.", - "wire": { - "jsonPath": "modifiedDate", - "sdkField": "ModifiedDate", - "sdkGoType": "*string", - "flatten": { - "func": "convert.PtrStringToFramework" + { + "name": "modified_date", + "goField": "ModifiedDate", + "type": { + "kind": "string" }, - "skipExpand": true - } - }, - { - "name": "legacy_id", - "goField": "LegacyID", - "type": { - "kind": "float64" + "computedOptionalRequired": "computed", + "markdownDescription": "When the tag was last modified.", + "wire": { + "jsonPath": "modifiedDate", + "sdkField": "ModifiedDate", + "sdkGoType": "*string", + "flatten": { + "func": "convert.PtrStringToFramework" + }, + "skipExpand": true + } }, - "computedOptionalRequired": "computed", - "markdownDescription": "The tag's identifier in the v6 API. Typed as a number because the specification declares it as one, although observed values are integral; probing will settle whether this should be an integer.", - "wire": { - "jsonPath": "legacyId", - "sdkField": "LegacyID", - "sdkGoType": "*float64", - "flatten": { - "func": "convert.PtrFloat64ToFramework" + { + "name": "legacy_id", + "goField": "LegacyID", + "type": { + "kind": "float64" }, - "skipExpand": true - } - }, - { - "name": "assignments", - "goField": "Assignments", - "type": { - "kind": "set_nested", - "nestedObject": { - "goTypeName": "TagAssignmentModel", - "sdkType": "tags.Assignment", - "attrTypesVar": "tagAssignmentAttrTypes", - "objectTypeVar": "tagAssignmentObjectType", - "expandFunc": "expandTagAssignments", - "flattenFunc": "flattenTagAssignments", - "attributes": [ - { - "name": "id", - "goField": "ID", - "type": { - "kind": "string" - }, - "computedOptionalRequired": "required", - "markdownDescription": "The identifier of the object the tag is assigned to.", - "wire": { - "jsonPath": "id", - "sdkField": "ID", - "sdkGoType": "*string", - "expand": { - "func": "convert.FrameworkToPtrString" + "computedOptionalRequired": "computed", + "markdownDescription": "The tag's identifier in the v6 API. Typed as a number because the specification declares it as one, although observed values are integral; probing will settle whether this should be an integer.", + "wire": { + "jsonPath": "legacyId", + "sdkField": "LegacyID", + "sdkGoType": "*float64", + "flatten": { + "func": "convert.PtrFloat64ToFramework" + }, + "skipExpand": true + } + }, + { + "name": "assignments", + "goField": "Assignments", + "type": { + "kind": "set_nested", + "nestedObject": { + "goTypeName": "TagAssignmentModel", + "sdkType": "tags.Assignment", + "attrTypesVar": "tagAssignmentAttrTypes", + "objectTypeVar": "tagAssignmentObjectType", + "expandFunc": "expandTagAssignments", + "flattenFunc": "flattenTagAssignments", + "attributes": [ + { + "name": "id", + "goField": "ID", + "type": { + "kind": "string" }, - "flatten": { - "func": "convert.PtrStringToFramework" + "computedOptionalRequired": "required", + "markdownDescription": "The identifier of the object the tag is assigned to.", + "wire": { + "jsonPath": "id", + "sdkField": "ID", + "sdkGoType": "*string", + "expand": { + "func": "convert.FrameworkToPtrString" + }, + "flatten": { + "func": "convert.PtrStringToFramework" + } } - } - }, - { - "name": "type", - "goField": "Type", - "type": { - "kind": "string" }, - "computedOptionalRequired": "required", - "markdownDescription": "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`.", - "wire": { - "jsonPath": "type", - "sdkField": "Type", - "sdkGoType": "tags.AssignmentType", - "expand": { - "func": "convert.FrameworkToEnum", - "typeArgs": [ - "tags.AssignmentType" - ] + { + "name": "type", + "goField": "Type", + "type": { + "kind": "string" }, - "flatten": { - "func": "convert.EnumToFramework" + "computedOptionalRequired": "required", + "markdownDescription": "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`.", + "wire": { + "jsonPath": "type", + "sdkField": "Type", + "sdkGoType": "tags.AssignmentType", + "expand": { + "func": "convert.FrameworkToEnum", + "typeArgs": [ + "tags.AssignmentType" + ] + }, + "flatten": { + "func": "convert.EnumToFramework" + } } } - } - ] - } - }, - "computedOptionalRequired": "computed_optional", - "markdownDescription": "Objects this tag is assigned to. A set rather than a list because the API does not preserve ordering.", - "wire": { - "jsonPath": "assignments", - "sdkField": "Assignments", - "sdkGoType": "[]tags.Assignment", - "expand": { - "func": "expandTagAssignments", - "needsCtx": true, - "returnsError": true + ] + } }, - "flatten": { - "func": "flattenTagAssignments", - "needsCtx": true, - "returnsError": true + "computedOptionalRequired": "computed_optional", + "markdownDescription": "Objects this tag is assigned to. A set rather than a list because the API does not preserve ordering.", + "wire": { + "jsonPath": "assignments", + "sdkField": "Assignments", + "sdkGoType": "[]tags.Assignment", + "expand": { + "func": "expandTagAssignments", + "needsCtx": true, + "returnsError": true + }, + "flatten": { + "func": "flattenTagAssignments", + "needsCtx": true, + "returnsError": true + } } - } - }, - { - "name": "filters", - "goField": "Filters", - "type": { - "kind": "set_nested", - "nestedObject": { - "goTypeName": "TagFilterModel", - "sdkType": "tags.TagFilter", - "attrTypesVar": "tagFilterAttrTypes", - "objectTypeVar": "tagFilterObjectType", - "expandFunc": "expandTagFilters", - "flattenFunc": "flattenTagFilters", - "attributes": [ - { - "name": "key", - "goField": "Key", - "type": { - "kind": "string" - }, - "computedOptionalRequired": "required", - "markdownDescription": "The filter key used for matching.", - "wire": { - "jsonPath": "key", - "sdkField": "Key", - "sdkGoType": "*string", - "expand": { - "func": "convert.FrameworkToPtrString" + }, + { + "name": "filters", + "goField": "Filters", + "type": { + "kind": "set_nested", + "nestedObject": { + "goTypeName": "TagFilterModel", + "sdkType": "tags.TagFilter", + "attrTypesVar": "tagFilterAttrTypes", + "objectTypeVar": "tagFilterObjectType", + "expandFunc": "expandTagFilters", + "flattenFunc": "flattenTagFilters", + "attributes": [ + { + "name": "key", + "goField": "Key", + "type": { + "kind": "string" }, - "flatten": { - "func": "convert.PtrStringToFramework" + "computedOptionalRequired": "required", + "markdownDescription": "The filter key used for matching.", + "wire": { + "jsonPath": "key", + "sdkField": "Key", + "sdkGoType": "*string", + "expand": { + "func": "convert.FrameworkToPtrString" + }, + "flatten": { + "func": "convert.PtrStringToFramework" + } } - } - }, - { - "name": "mode", - "goField": "Mode", - "type": { - "kind": "string" }, - "computedOptionalRequired": "computed_optional", - "markdownDescription": "How the filter values are matched.", - "wire": { - "jsonPath": "mode", - "sdkField": "Mode", - "sdkGoType": "tags.TagFilterMode", - "expand": { - "func": "convert.FrameworkToEnum", - "typeArgs": [ - "tags.TagFilterMode" - ] + { + "name": "mode", + "goField": "Mode", + "type": { + "kind": "string" }, - "flatten": { - "func": "convert.EnumToFramework" + "computedOptionalRequired": "computed_optional", + "markdownDescription": "How the filter values are matched.", + "wire": { + "jsonPath": "mode", + "sdkField": "Mode", + "sdkGoType": "tags.TagFilterMode", + "expand": { + "func": "convert.FrameworkToEnum", + "typeArgs": [ + "tags.TagFilterMode" + ] + }, + "flatten": { + "func": "convert.EnumToFramework" + } } - } - }, - { - "name": "scope", - "goField": "Scope", - "type": { - "kind": "string" }, - "computedOptionalRequired": "computed_optional", - "markdownDescription": "The scope the filter applies within.", - "wire": { - "jsonPath": "scope", - "sdkField": "Scope", - "sdkGoType": "tags.TagFilterScope", - "expand": { - "func": "convert.FrameworkToEnum", - "typeArgs": [ - "tags.TagFilterScope" - ] - }, - "flatten": { - "func": "convert.EnumToFramework" - } - } - }, - { - "name": "values", - "goField": "Values", - "type": { - "kind": "set", - "elementType": { + { + "name": "scope", + "goField": "Scope", + "type": { "kind": "string" + }, + "computedOptionalRequired": "computed_optional", + "markdownDescription": "The scope the filter applies within.", + "wire": { + "jsonPath": "scope", + "sdkField": "Scope", + "sdkGoType": "tags.TagFilterScope", + "expand": { + "func": "convert.FrameworkToEnum", + "typeArgs": [ + "tags.TagFilterScope" + ] + }, + "flatten": { + "func": "convert.EnumToFramework" + } } }, - "computedOptionalRequired": "required", - "markdownDescription": "The values the filter matches against.", - "wire": { - "jsonPath": "values", - "sdkField": "Values", - "sdkGoType": "[]string", - "expand": { - "func": "convert.FrameworkSetToStringSlice", - "needsCtx": true, - "returnsError": true + { + "name": "values", + "goField": "Values", + "type": { + "kind": "set", + "elementType": { + "kind": "string" + } }, - "flatten": { - "func": "convert.StringSliceToFrameworkSet", - "needsCtx": true, - "returnsError": true + "computedOptionalRequired": "required", + "markdownDescription": "The values the filter matches against.", + "wire": { + "jsonPath": "values", + "sdkField": "Values", + "sdkGoType": "[]string", + "expand": { + "func": "convert.FrameworkSetToStringSlice", + "needsCtx": true, + "returnsError": true + }, + "flatten": { + "func": "convert.StringSliceToFrameworkSet", + "needsCtx": true, + "returnsError": true + } } } - } - ] - } - }, - "computedOptionalRequired": "computed_optional", - "markdownDescription": "Filters that dynamically assign this tag to endpoint agents.", - "wire": { - "jsonPath": "filters", - "sdkField": "Filters", - "sdkGoType": "[]tags.TagFilter", - "expand": { - "func": "expandTagFilters", - "needsCtx": true, - "returnsError": true + ] + } }, - "flatten": { - "func": "flattenTagFilters", - "needsCtx": true, - "returnsError": true + "computedOptionalRequired": "computed_optional", + "markdownDescription": "Filters that dynamically assign this tag to endpoint agents.", + "wire": { + "jsonPath": "filters", + "sdkField": "Filters", + "sdkGoType": "[]tags.TagFilter", + "expand": { + "func": "expandTagFilters", + "needsCtx": true, + "returnsError": true + }, + "flatten": { + "func": "flattenTagFilters", + "needsCtx": true, + "returnsError": true + } } } - } - ], + ], + "markdownDescription": "Manages a ThousandEyes tag. Tags are key/value labels that can be assigned to tests, agents and dashboards." + }, "binding": { "service": { "importPath": "github.com/deploymenttheory/go-sdk-thousandeyes/thousandeyes/thousandeyes_api/tags", diff --git a/cmd/tfpluginframeworkgen/ingest.go b/cmd/tfpluginframeworkgen/ingest.go index 48b8d345..e6b9d5a6 100644 --- a/cmd/tfpluginframeworkgen/ingest.go +++ b/cmd/tfpluginframeworkgen/ingest.go @@ -103,7 +103,7 @@ func inferAll(doc *openapi.Document, candidates []openapi.Candidate, opts openap return err } - log.Printf("wrote %s (%d attributes)", path, len(res.Attributes)) + log.Printf("wrote %s (%d attributes)", path, len(res.Schema.Attributes)) written++ } diff --git a/cmd/tfpluginframeworkgen/probe_test.go b/cmd/tfpluginframeworkgen/probe_test.go index 8eb0d671..95f95a3f 100644 --- a/cmd/tfpluginframeworkgen/probe_test.go +++ b/cmd/tfpluginframeworkgen/probe_test.go @@ -453,7 +453,7 @@ func TestUnit_CLI_ThePilotBlueprintCarriesSpecEnumValues(t *testing.T) { found := map[string]int{} for _, res := range bp.Resources { - for _, a := range res.Attributes { + for _, a := range res.Schema.Attributes { if len(a.Type.Enum) > 0 { found[a.Name] = len(a.Type.Enum) } diff --git a/docs/blueprint.md b/docs/blueprint.md index 2cbc3dcd..fb6ce7fc 100644 --- a/docs/blueprint.md +++ b/docs/blueprint.md @@ -67,31 +67,113 @@ picks one, and which one would depend on filename ordering. `support` is data rather than constants because those packages belong to the provider, not the generator. +## Block kinds + +The top level is the **Terraform block kind**, not the Terraform type, and everything +cascades down from it โ€” the same organising principle the framework itself uses. Each +kind is a `Name`, a `Schema`, and then the operations that kind supports. + +`resource` and `datasource` are built. `list`, `ephemeral` and `action` have their +kinds declared in `internal/blueprint/blockkind.go` and arrive in later phases. + +**An attribute's legal fields depend on the kind rendering it.** Every kind has its own +schema package โ€” `resource/schema`, `datasource/schema` and so on โ€” and those packages +are structurally similar but deliberately not identical: + +| field | resource | datasource | ephemeral | action | list | +|---|---|---|---|---|---| +| `computedOptionalRequired` (computed), `sensitive` | โœ“ | โœ“ | โœ“ | โœ— | โœ— | +| `planModifiers`, `default` | โœ“ | โœ— | โœ— | โœ— | โœ— | +| `writeOnly` | โœ“ | โœ— | โœ— | โœ“ | โœ— | + +`Validate` refuses a field the target kind has no home for, naming both the attribute +and the kind. Without that refusal the generator emits +`datasourceschema.StringAttribute{Default: ...}` and the failure surfaces as a compiler +error in generated output, which names neither. + +An action or list attribute having no `computed` is why a list resource's config schema +is filter-only: there is nowhere to put a result. + +## Data source + +The same skeleton as a resource, minus everything a data source has no operation for. + +```jsonc +{ + "key": "tag", + "name": "tag", // registers as thousandeyes_tag + "schema": { "attributes": [ ... ] }, + "binding": { "service": {...}, "read": {...}, "response": {...} }, + "timeouts": { "readSeconds": 180 } +} +``` + +`binding` is a **`DataSourceBinding`, not a `ResourceBinding`**: it holds a service +reference, one read operation and a response model, and there is no create, update, delete +or request body on the type at all. Modelling it as a resource binding with most fields +refused would put the refusal in a rule somebody has to remember to keep. + +Two consequences follow from a data source sending nothing to the API: + +- **Its attributes carry only the flatten direction.** An `expand` on a data source + attribute is refused, because there is no request body for the value to reach. A + *required* attribute here is a lookup argument or a filter, and it reaches the API as a + call argument โ€” which is what the `configField` argument kind is for. +- **No `planModifiers` and no `default`.** Neither field exists on + `datasource/schema`'s attribute types. `Validate` refuses a declared one, and the + generator does not synthesise the `UseStateForUnknown` it adds for a resource's computed + strings. + +Emitted as four files โ€” `datasource.go`, `model.go`, `read.go`, `state.go` โ€” against a +resource's five. There is no `construct.go` because there is nothing to construct. + ## Resource `key` is the stable merge key. Probe facts and hand-authored overrides join on it, so it is the one field never to rename casually. -The naming fields โ€” `terraformType`, `goPackage`, `goPackageAlias`, `goTypeName`, -`modelTypeName` โ€” are explicit rather than derived. Deriving them would make a -rename of the Go type an invisible consequence of a rename of the Terraform type. +`name` is the type name **without** the provider prefix โ€” `tag`, not +`thousandeyes_tag`. The registry-visible type is composed at render time from +`provider.typePrefix`, exactly as the framework composes it: `Metadata` sets +`req.ProviderTypeName + "_" + Name`. Storing the composed string as well would +denormalise it, and a denormalised field is one that can disagree with itself. + +The naming fields โ€” `goPackage`, `goPackageAlias`, `goTypeName`, `modelTypeName` โ€” are +explicit rather than derived. Deriving them would make a rename of the Go type an +invisible consequence of a rename of the Terraform type. `serviceGroup` and `apiVersionDir` place the package on disk: `///`. +## Schema + +Attributes hang off a `schema` object rather than off the block directly, because the +framework's schema is itself a thing with a `Version`, a description and a deprecation +message โ€” and because that is where an attribute's legal field set is decided. + +```jsonc +"schema": { + "attributes": [ ... ], + "version": 0, // resource schema version, for state upgrades + "markdownDescription": "..." +} +``` + +There is no `blocks`. See the note below. + ## Attributes ```jsonc { - "name": "color", // tfsdk name, snake_case - "goField": "Color", // model struct field + "name": "colour", // tfsdk name, snake_case + "goField": "Colour", // model struct field "type": { "kind": "string" }, - "presence": "computed_optional", + "computedOptionalRequired": "computed_optional", "wire": { ... } } ``` -`presence` is spelled exactly as the official specification spells it โ€” +`computedOptionalRequired` is spelled exactly as the official specification spells it โ€” `required`, `optional`, `computed`, `computed_optional` โ€” so interop needs no mapping table. @@ -99,14 +181,57 @@ mapping table. `number`. Collections of scalars: `list`, `set`, `map`, each with `elem`. Nested objects: `list_nested`, `set_nested`, `single_nested`, each with `nested`. -Nested attributes, not blocks. The choice is permanent for a published provider, -and HashiCorp's OpenAPI generator emits no blocks, so there is no upstream signal -to follow. It is recorded here so it stays reviewable. - -Nesting is supported **one level deep** and refused beyond it, naming the -offending attribute. Each level needs its own model, `attr.Type` map and helper -pair, and a partially-correct nested mapping is the class of bug that surfaces as -a diff a practitioner cannot resolve. +Nested attributes, not blocks. The choice is permanent for a published provider, so it +is recorded here to stay reviewable โ€” and the evidence is one-sided. In +`deploymenttheory/terraform-provider-microsoft365`, a 167-resource provider, +`schema.ListNestedBlock` appears three times across two files, of which one use is live +and one is dead code; `SetNestedBlock` and `SingleNestedBlock` appear not at all. Against +366 `SingleNestedAttribute`, 178 `ListNestedAttribute` and 110 `SetNestedAttribute`. The +single live block has an empty validator slice and enforces its cardinality by hand in +`modify_plan.go`, which is what a nested attribute would have done for it. + +**Nesting is generated to whatever depth the blueprint declares.** Four levels is routine +in the reference provider. Each level gets its own model struct, `attr.Type` map, object +type var and conversion helper pair, and an enclosing level refers to the one below it +through that object type var rather than restating its shape. + +Two things are refused rather than half-emitted: + +- **Two nested objects that would declare the same Go identifier.** Every nested object + contributes a package-level model, `attr.Type` map, object type var and helper pair, so + a repeat is a redeclaration error in the generated package. The refusal names both + attributes; the compiler error would name neither. +- **Nesting past ten levels.** That is a runaway guard rather than a design limit โ€” above + any fixed schema in the reference provider. What exceeds it is a schema whose depth is + decided at runtime from the practitioner's own configuration, which this IR cannot + express at all, so the message says to write that resource by hand. + +### Where an inferred nested object's names come from + +`ingest` infers nested objects, so the five generated identifiers are derived rather than +authored. The rule, for a schema `Assignment` inside resource `tag`: + +| field | value | rule | +|---|---|---| +| `goTypeName` | `TagAssignmentModel` | resource stem + schema name + `Model` | +| `sdkType` | `tags.Assignment` | SDK package + schema name | +| `attrTypesVar` | `tagAssignmentAttrTypes` | the same stem, lowerCamel | +| `objectTypeVar` | `tagAssignmentObjectType` | ditto | +| `expandFunc` / `flattenFunc` | `expandTagAssignments` | pluralised for a collection | + +A schema already carrying the resource's name keeps it rather than doubling it: `TagFilter` +becomes `TagFilterModel`, not `TagTagFilterModel`. Collisions are resolved with a numeric +suffix at the naming layer, before anything is rendered, because render refuses two nested +objects that would declare the same identifier. + +These names are a starting point. They happen to match the pilot's hand-curated blueprint +exactly, which is the evidence that the rule is the one a person reaches for โ€” but renaming +them is expected, and is the same thing curation does to every other inferred name. + +**A schema that contains itself is refused whole**, naming the schema. Not just at the +cycle point: refusing only the recursive field would leave the enclosing object in place +minus its recursive dimension โ€” a `tree` attribute offering a label and no children, which +looks usable and cannot express the shape it is named for. ## Wire diff --git a/docs/generated-boundary.md b/docs/generated-boundary.md index 7ebbefe5..295119b7 100644 --- a/docs/generated-boundary.md +++ b/docs/generated-boundary.md @@ -8,7 +8,8 @@ to do when a file genuinely cannot be generated. | Path | Owner | Change it by | |---|---|---| -| `internal/services/**/{resource,model,construct,state,crud}.go` | toolkit | editing the blueprint, then `emit` | +| `internal/services/resources/**/{resource,model,construct,state,crud}.go` | toolkit | editing the blueprint, then `emit` | +| `internal/services/datasources/**/{datasource,model,read,state}.go` | toolkit | editing the blueprint, then `emit` | | `internal/services/**/{modify_plan,validate}.go` | **you** | editing them; `emit` never touches them again | | `internal/provider/{resources,datasources}.go` | toolkit | adding a blueprint, then `emit` | | `internal/provider/provider.go` | **you** | editing it โ€” authentication is always bespoke | diff --git a/internal/blueprint/binding.go b/internal/blueprint/binding.go index c01a5690..29554f34 100644 --- a/internal/blueprint/binding.go +++ b/internal/blueprint/binding.go @@ -23,6 +23,36 @@ type ResourceBinding struct { Body BodyModels `json:"body"` } +// DataSourceBinding is the SDK calls one data source makes: a read, and nothing else. +// +// This is a separate type from ResourceBinding rather than a reuse of it. A data source +// has no create, no update, no delete and no request body, and modelling it as a +// ResourceBinding with most of the fields refused would put the refusal in a validation +// rule somebody has to remember to keep. Here those fields simply do not exist, which is +// the same move BlockKind makes for per-kind attribute fields. +type DataSourceBinding struct { + Service ServiceRef `json:"service"` + + // Read is the only operation. It is a pointer for symmetry with ResourceBinding's + // operations and so that "not yet authored" is distinguishable from an empty call, + // which is what an imported draft needs; Validate requires it. + Read *Operation `json:"read,omitempty"` + + Response ResponseModel `json:"response"` +} + +// ResponseModel is the SDK type a data source reads back, and how its fields are reached. +// +// The resource equivalent is BodyModels, which also carries a request type and a +// constructor for it. A data source sends no body, so it carries neither. +type ResponseModel struct { + // Type is the Go type read back, e.g. "tags.Tag", written as it appears at the use + // site including any package qualifier. + Type string `json:"type"` + + AccessStyle AccessStyle `json:"accessStyle"` +} + // ServiceRef locates the SDK symbols for a resource. type ServiceRef struct { // ImportPath is the SDK package, e.g. @@ -120,6 +150,10 @@ const ( ArgStateField ArgKind = "stateField" // ArgPlanField reads a model field from the plan. ArgPlanField ArgKind = "planField" + // ArgConfigField reads a model field from a data source's configuration, which is + // the only place a data source has to read an argument from: it has no prior state + // and no plan. + ArgConfigField ArgKind = "configField" // ArgBody passes the constructed request body. ArgBody ArgKind = "body" // ArgLiteral passes a verbatim Go expression. diff --git a/internal/blueprint/blockkind.go b/internal/blueprint/blockkind.go new file mode 100644 index 00000000..bdf76b36 --- /dev/null +++ b/internal/blueprint/blockkind.go @@ -0,0 +1,185 @@ +package blueprint + +import "fmt" + +// BlockKind is a top-level Terraform block kind, and the level the whole IR is organised around. +// +// The framework defines each kind by a required method set plus opt-in With* interfaces, and gives +// each its own schema package. Those packages are structurally similar and deliberately not +// identical, which is why an attribute's legal fields depend on the kind rendering it rather than on +// the attribute alone. +type BlockKind string + +const ( + // BlockResource is a managed resource: Metadata, Schema, Create, Read, Update, Delete. + BlockResource BlockKind = "resource" + // BlockDataSource is a data source: Metadata, Schema, Read. + BlockDataSource BlockKind = "datasource" + // BlockEphemeral is an ephemeral resource: Metadata, Schema, Open. + BlockEphemeral BlockKind = "ephemeral" + // BlockAction is an action: Metadata, Schema, Invoke. + BlockAction BlockKind = "action" + // BlockList is a list resource, which is a facet of a managed resource rather than an + // independent block: its type name must equal the resource's, and it requires that resource to + // declare an identity. + BlockList BlockKind = "list" +) + +// SchemaPackage is the framework import path suffix this kind's schema types come from. +// +// The attribute types in each are structurally identical, so schema rendering is parameterised by +// this rather than duplicated per kind. +func (k BlockKind) SchemaPackage() string { + switch k { + case BlockResource: + return "resource/schema" + case BlockDataSource: + return "datasource/schema" + case BlockEphemeral: + return "ephemeral/schema" + case BlockAction: + return "action/schema" + case BlockList: + return "list/schema" + default: + return "" + } +} + +// SupportsComputed reports whether this kind's attributes may be Computed. +// +// Action and list attributes may not. For a list resource that is the structural reason its config +// schema is filter-only: there is nowhere to put a result. +func (k BlockKind) SupportsComputed() bool { + return k == BlockResource || k == BlockDataSource || k == BlockEphemeral +} + +// SupportsSensitive reports whether this kind's attributes may be Sensitive. +func (k BlockKind) SupportsSensitive() bool { return k.SupportsComputed() } + +// SupportsPlanModifiers reports whether this kind's attributes may carry plan modifiers. +// +// Only a managed resource has a plan to modify. +func (k BlockKind) SupportsPlanModifiers() bool { return k == BlockResource } + +// SupportsDefault reports whether this kind's attributes may carry a default. +func (k BlockKind) SupportsDefault() bool { return k == BlockResource } + +// SupportsWriteOnly reports whether this kind's attributes may be write-only. +func (k BlockKind) SupportsWriteOnly() bool { return k == BlockResource || k == BlockAction } + +// Expands reports whether this kind sends attribute values to the API, and so needs an +// expand conversion on every attribute a practitioner can set. +// +// Only a resource and an action do. A data source, an ephemeral resource and a list +// resource read: a settable attribute on one of those is a lookup argument or a filter, +// which reaches the API as a call argument rather than through a request body. Requiring +// an expand there would demand a conversion into a body that is never sent. +func (k BlockKind) Expands() bool { + return k == BlockResource || k == BlockAction +} + +// validateForKind refuses a field the target block kind has no home for. +// +// Without this the generator emits code that does not compile: a Default on a data source attribute +// becomes `datasourceschema.StringAttribute{Default: ...}` and that field does not exist. Refusing in +// the blueprint names the attribute and the kind, which a compiler error in generated output does +// not. +func (a Attribute) validateForKind(kind BlockKind, at string, p *problems) { + if !kind.SupportsComputed() && a.ComputedOptionalRequired.IsComputed() { + p.add(at+".computedOptionalRequired", + "%q is not available on a %s attribute; only required and optional are", + a.ComputedOptionalRequired, kind) + } + + if !kind.SupportsSensitive() && a.Sensitive { + p.add(at+".sensitive", "a %s attribute cannot be sensitive", kind) + } + + if !kind.SupportsPlanModifiers() && len(a.PlanModifiers) > 0 { + p.add(at+".planModifiers", + "a %s attribute has no plan to modify; plan modifiers are available on resources only", + kind) + } + + if !kind.SupportsDefault() && a.Default != nil { + p.add(at+".default", + "a %s attribute cannot carry a default; defaults are available on resources only", kind) + } + + if !kind.SupportsWriteOnly() && a.WriteOnly { + p.add(at+".writeOnly", "a %s attribute cannot be write-only", kind) + } + + a.validateWireForKind(kind, at, p) + + for i, nested := range a.nestedAttributes() { + nat := fmt.Sprintf("%s.type.nestedObject.attributes[%d]", at, i) + if nested.Name != "" { + nat = fmt.Sprintf("%s.type.nestedObject.attributes[%s]", at, nested.Name) + } + + nested.validateForKind(kind, nat, p) + } +} + +// validateWireForKind checks the wire directions the kind actually uses. +// +// The flatten direction is universal: every kind reads. The expand direction is not, and +// which of the two an attribute needs is decided by the kind rather than by the +// attribute, which is why this lives here rather than in Attribute.validate. +func (a Attribute) validateWireForKind(kind BlockKind, at string, p *problems) { + // An attribute the practitioner can set must have a way to reach the API, and one + // that is read must have a way back. Catching this here is the difference between a + // clear message and a silently inert attribute. + if !a.Wire.SkipFlatten && a.Wire.Flatten == nil { + p.add(at+".wire.flatten", "is required unless skipFlatten is set") + } + + if n := a.Type.NestedObject; n != nil { + if n.FlattenFunc == "" { + p.add(at+".type.nested.flattenFunc", "is required") + } + if kind.Expands() && n.ExpandFunc == "" { + p.add( + at+".type.nested.expandFunc", + "is required for a %s, which sends this object to the API", + kind, + ) + } + } + + if !kind.Expands() { + if a.Wire.Expand != nil { + p.add( + at+".wire.expand", + "is set on a %s attribute, which sends nothing to the API", + kind, + ) + } + return + } + + if !a.ComputedOptionalRequired.IsRequired() && !a.ComputedOptionalRequired.IsOptional() { + return + } + if a.Wire.SkipExpand { + p.add( + at+".wire.skipExpand", + "is set on a writable attribute, so its value would never reach the API", + ) + return + } + if a.Wire.Expand == nil { + p.add(at+".wire.expand", "is required on a writable attribute") + } +} + +// nestedAttributes returns the attributes of a nested object, or nothing. +func (a Attribute) nestedAttributes() []Attribute { + if a.Type.NestedObject == nil { + return nil + } + + return a.Type.NestedObject.Attributes +} diff --git a/internal/blueprint/blockkind_test.go b/internal/blueprint/blockkind_test.go new file mode 100644 index 00000000..4e8e45b5 --- /dev/null +++ b/internal/blueprint/blockkind_test.go @@ -0,0 +1,364 @@ +package blueprint + +import ( + "strings" + "testing" +) + +// TestUnit_BlockKind_SchemaPackage pins the framework import path suffix per kind. +// +// These strings become import paths in generated code, so a wrong one is a build +// failure in the emitted provider rather than here. The unknown kind returns empty +// rather than guessing, so a caller that forgets to handle it produces an obviously +// broken import instead of a plausible wrong one. +func TestUnit_BlockKind_SchemaPackage(t *testing.T) { + t.Parallel() + + want := map[BlockKind]string{ + BlockResource: "resource/schema", + BlockDataSource: "datasource/schema", + BlockEphemeral: "ephemeral/schema", + BlockAction: "action/schema", + BlockList: "list/schema", + BlockKind("nonesuch"): "", + } + + for kind, w := range want { + if got := kind.SchemaPackage(); got != w { + t.Errorf("%s.SchemaPackage() = %q, want %q", kind, got, w) + } + } +} + +// TestUnit_BlockKind_FieldSupport is the table from the framework's own schema +// packages, asserted directly. +// +// Each row is a field that exists on some kinds' attribute structs and not others. If +// one of these predicates is wrong the generator emits a struct literal setting a +// field the type does not have, and the failure surfaces as a compiler error in +// somebody else's generated provider. +func TestUnit_BlockKind_FieldSupport(t *testing.T) { + t.Parallel() + + kinds := []BlockKind{BlockResource, BlockDataSource, BlockEphemeral, BlockAction, BlockList} + + tests := []struct { + field string + got func(BlockKind) bool + // want is indexed the same as kinds. + want []bool + }{ + // Action and list attributes have no Computed. For a list resource that is the + // structural reason its config schema is filter-only. + {"Computed", BlockKind.SupportsComputed, []bool{true, true, true, false, false}}, + {"Sensitive", BlockKind.SupportsSensitive, []bool{true, true, true, false, false}}, + // Only a managed resource has a plan to modify. + {"PlanModifiers", BlockKind.SupportsPlanModifiers, []bool{true, false, false, false, false}}, + {"Default", BlockKind.SupportsDefault, []bool{true, false, false, false, false}}, + {"WriteOnly", BlockKind.SupportsWriteOnly, []bool{true, false, false, true, false}}, + } + + for _, tc := range tests { + for i, kind := range kinds { + if got := tc.got(kind); got != tc.want[i] { + t.Errorf("%s on a %s attribute: got %v, want %v", tc.field, kind, got, tc.want[i]) + } + } + } +} + +// TestUnit_BlockKind_ValidateRefusesFieldsTheKindHasNoHomeFor is the refusal this +// phase exists to add. +// +// Before it, a blueprint could declare a default on a data source attribute and the +// generator would emit datasourceschema.StringAttribute{Default: ...}, which does not +// compile. The point of refusing here is that the message names the attribute and the +// kind; the compiler error in generated output names neither. +func TestUnit_BlockKind_ValidateRefusesFieldsTheKindHasNoHomeFor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind BlockKind + attr Attribute + wantPath string + wantMsg string + }{ + { + name: "default on a data source attribute", + kind: BlockDataSource, + attr: Attribute{Name: "f", Default: &Default{Static: &Literal{Kind: KindString, Raw: `"x"`}}}, + wantPath: "attributes[f].default", + wantMsg: "resources only", + }, + { + name: "plan modifier on a data source attribute", + kind: BlockDataSource, + attr: Attribute{Name: "f", PlanModifiers: []CustomCode{{}}}, + wantPath: "attributes[f].planModifiers", + wantMsg: "no plan to modify", + }, + { + name: "write-only on a data source attribute", + kind: BlockDataSource, + attr: Attribute{Name: "f", WriteOnly: true}, + wantPath: "attributes[f].writeOnly", + wantMsg: "write-only", + }, + { + name: "computed on a list attribute", + kind: BlockList, + attr: Attribute{Name: "f", ComputedOptionalRequired: Computed}, + wantPath: "attributes[f].computedOptionalRequired", + wantMsg: "required and optional", + }, + { + // computed_optional also sets Computed, so it must be refused too -- + // checking only for the exact value "computed" would let it through. + name: "computed_optional on an action attribute", + kind: BlockAction, + attr: Attribute{Name: "f", ComputedOptionalRequired: ComputedOptional}, + wantPath: "attributes[f].computedOptionalRequired", + wantMsg: "required and optional", + }, + { + name: "sensitive on an action attribute", + kind: BlockAction, + attr: Attribute{Name: "f", Sensitive: true}, + wantPath: "attributes[f].sensitive", + wantMsg: "sensitive", + }, + { + // The refusal has to reach through nesting, because that is where a + // hand-authored blueprint is least likely to be read carefully. + name: "default on an attribute nested inside a data source attribute", + kind: BlockDataSource, + attr: Attribute{ + Name: "outer", + Type: AttrType{ + Kind: KindSingleNested, + NestedObject: &NestedAttributeObject{ + Attributes: []Attribute{{ + Name: "inner", + Default: &Default{Static: &Literal{Kind: KindBool, Raw: "false"}}, + }}, + }, + }, + }, + wantPath: "attributes[outer].type.nestedObject.attributes[inner].default", + wantMsg: "resources only", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var p problems + tc.attr.validateForKind(tc.kind, "attributes["+tc.attr.Name+"]", &p) + + if len(p) == 0 { + t.Fatalf("a %s attribute should have been refused", tc.kind) + } + + var paths, msgs []string + for _, prob := range p { + paths = append(paths, prob.path) + msgs = append(msgs, prob.msg) + } + joinedPaths := strings.Join(paths, "; ") + joinedMsgs := strings.Join(msgs, "; ") + + if !strings.Contains(joinedPaths, tc.wantPath) { + t.Errorf("path %q not among %q", tc.wantPath, joinedPaths) + } + // The kind is named, so the reader knows which schema package refused and + // does not have to guess why the same attribute is legal elsewhere. + if !strings.Contains(joinedMsgs, string(tc.kind)) { + t.Errorf("message should name the kind %q: %q", tc.kind, joinedMsgs) + } + if !strings.Contains(joinedMsgs, tc.wantMsg) { + t.Errorf("message should explain %q: %q", tc.wantMsg, joinedMsgs) + } + }) + } +} + +// TestUnit_BlockKind_ValidateAcceptsEveryFieldOnAResource is the other half of the +// refusal: a resource attribute setting all of them is fine. +// +// Without this, the refusal could be made to pass by rejecting everything. +func TestUnit_BlockKind_ValidateAcceptsEveryFieldOnAResource(t *testing.T) { + t.Parallel() + + a := Attribute{ + Name: "f", + ComputedOptionalRequired: ComputedOptional, + Sensitive: true, + WriteOnly: true, + PlanModifiers: []CustomCode{{}}, + Default: &Default{Static: &Literal{Kind: KindString, Raw: `"x"`}}, + // Both wire directions, because a resource is a kind that expands: the + // per-kind check covers wire as well as the schema fields, and an attribute + // missing an expand would fail here for a reason this test is not about. + Wire: WireBinding{ + Expand: &ConvertCall{Func: "convert.FrameworkToPtrString"}, + Flatten: &ConvertCall{Func: "convert.PtrStringToFramework"}, + }, + } + + var p problems + a.validateForKind(BlockResource, "attributes[f]", &p) + + if len(p) != 0 { + t.Errorf("a resource attribute may set all of these; got %v", p) + } +} + +// TestUnit_BlockKind_Expands pins which kinds send attribute values to the API. +// +// A kind that expands needs an expand conversion on every settable attribute; one that +// does not must have none, because a settable attribute there is a lookup argument or a +// filter that reaches the API as a call argument rather than through a request body. +func TestUnit_BlockKind_Expands(t *testing.T) { + t.Parallel() + + want := map[BlockKind]bool{ + BlockResource: true, + BlockAction: true, + BlockDataSource: false, + BlockEphemeral: false, + BlockList: false, + } + + for kind, w := range want { + if got := kind.Expands(); got != w { + t.Errorf("%s.Expands() = %v, want %v", kind, got, w) + } + } +} + +// TestUnit_BlockKind_WireDirectionsAreCheckedPerKind covers the rule that moved out of +// Attribute.validate in this phase. +// +// The flatten direction is universal, because every kind reads. The expand direction is +// required on a resource and refused on a data source, and putting that check in the +// kind-agnostic path made the first data source blueprint unrepresentable: its lookup +// argument is required, and a required attribute was assumed to need an expand. +func TestUnit_BlockKind_WireDirectionsAreCheckedPerKind(t *testing.T) { + t.Parallel() + + expand := &ConvertCall{Func: "convert.FrameworkToPtrString"} + flatten := &ConvertCall{Func: "convert.PtrStringToFramework"} + + tests := []struct { + name string + kind BlockKind + attr Attribute + wantPath string + }{ + { + name: "a writable resource attribute needs an expand", + kind: BlockResource, + attr: Attribute{ + Name: "f", ComputedOptionalRequired: Required, + Wire: WireBinding{Flatten: flatten}, + }, + wantPath: "attributes[f].wire.expand", + }, + { + name: "skipExpand on a writable resource attribute is refused", + kind: BlockResource, + attr: Attribute{ + Name: "f", ComputedOptionalRequired: Optional, + Wire: WireBinding{Flatten: flatten, SkipExpand: true}, + }, + wantPath: "attributes[f].wire.skipExpand", + }, + { + name: "an expand on a data source attribute is refused", + kind: BlockDataSource, + attr: Attribute{ + Name: "f", ComputedOptionalRequired: Required, + Wire: WireBinding{Expand: expand, Flatten: flatten}, + }, + wantPath: "attributes[f].wire.expand", + }, + { + name: "every kind needs a flatten", + kind: BlockDataSource, + attr: Attribute{Name: "f", ComputedOptionalRequired: Computed}, + wantPath: "attributes[f].wire.flatten", + }, + { + name: "a resource's nested object needs an expand helper", + kind: BlockResource, + attr: Attribute{ + Name: "outer", ComputedOptionalRequired: Computed, + Wire: WireBinding{Flatten: flatten}, + Type: AttrType{ + Kind: KindSetNested, + NestedObject: &NestedAttributeObject{ + FlattenFunc: "flattenOuter", + Attributes: []Attribute{{Name: "id", Wire: WireBinding{Flatten: flatten}}}, + }, + }, + }, + wantPath: "attributes[outer].type.nested.expandFunc", + }, + { + name: "every kind's nested object needs a flatten helper", + kind: BlockDataSource, + attr: Attribute{ + Name: "outer", ComputedOptionalRequired: Computed, + Wire: WireBinding{Flatten: flatten}, + Type: AttrType{ + Kind: KindSetNested, + NestedObject: &NestedAttributeObject{ + Attributes: []Attribute{{Name: "id", Wire: WireBinding{Flatten: flatten}}}, + }, + }, + }, + wantPath: "attributes[outer].type.nested.flattenFunc", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var p problems + tc.attr.validateForKind(tc.kind, "attributes["+tc.attr.Name+"]", &p) + + var paths []string + for _, prob := range p { + paths = append(paths, prob.path) + } + joined := strings.Join(paths, "; ") + if !strings.Contains(joined, tc.wantPath) { + t.Errorf("path %q not among %q", tc.wantPath, joined) + } + }) + } +} + +// TestUnit_BlockKind_ReadOnlyAttributeNeedsNoExpand is the converse: the shape the pilot's +// data sources actually use must pass. +func TestUnit_BlockKind_ReadOnlyAttributeNeedsNoExpand(t *testing.T) { + t.Parallel() + + flatten := &ConvertCall{Func: "convert.PtrStringToFramework"} + + // A required lookup argument with no expand, which is what a by-id data source is. + lookup := Attribute{ + Name: "id", GoField: "ID", ComputedOptionalRequired: Required, + Wire: WireBinding{Flatten: flatten}, + } + + var p problems + lookup.validateForKind(BlockDataSource, "attributes[id]", &p) + + if len(p) != 0 { + t.Errorf("a data source's required lookup argument needs no expand; got %v", p) + } +} diff --git a/internal/blueprint/blueprint.go b/internal/blueprint/blueprint.go index a1d2fbe1..b53f4c54 100644 --- a/internal/blueprint/blueprint.go +++ b/internal/blueprint/blueprint.go @@ -25,9 +25,11 @@ // input changing would make the drift check useless. package blueprint +import "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/naming" + // FormatVersion is the blueprint format version. It is deliberately unrelated to // the Provider Code Specification's version, which this format does not track. -const FormatVersion = "1" +const FormatVersion = "2" // Blueprint is one provider. type Blueprint struct { @@ -74,6 +76,17 @@ type Provider struct { Support SupportPkgs `json:"support,omitzero"` } +// TerraformType composes the registry-visible type name for a block from its short +// Name, the way the framework composes it: Metadata sets +// req.ProviderTypeName + "_" + Name. +// +// Every caller that needs the composed name goes through here, so there is exactly +// one place the composition rule lives. That is the whole reason the blocks store +// only the short name โ€” see the note on Resource.Name. +func (p Provider) TerraformType(name string) string { + return naming.TerraformTypeName(p.TypePrefix, name) +} + // SDKDialect distinguishes the call shapes a generated provider must produce. type SDKDialect string @@ -141,8 +154,13 @@ type Resource struct { // on it, so it is the one field a human must never casually rename. Key string `json:"key"` - // TerraformType is the registry-visible type, e.g. "thousandeyes_tag". - TerraformType string `json:"terraformType"` + // Name is the type name without the provider prefix, e.g. "tag". + // + // The registry-visible type is composed from Provider.TypePrefix at render time, exactly as the + // framework composes it: Metadata sets req.ProviderTypeName + "_" + Name. Storing the composed + // string as well would denormalise it, and a denormalised field is one that can disagree with + // itself. + Name string `json:"name"` // GoPackage is the directory and package name, e.g. "tag". GoPackage string `json:"goPackage"` // GoPackageAlias is the import alias the provider registration uses. It must @@ -158,13 +176,11 @@ type Resource struct { ServiceGroup string `json:"serviceGroup,omitempty"` APIVersionDir string `json:"apiVersionDir,omitempty"` - MarkdownDescription string `json:"markdownDescription,omitempty"` // DocRefURL becomes the "// REF: " comment above the model's package // clause, as the archetype provider does. - DocRefURL string `json:"docRefUrl,omitempty"` - DeprecationMessage string `json:"deprecationMessage,omitempty"` + DocRefURL string `json:"docRefUrl,omitempty"` - Attributes []Attribute `json:"attributes"` + Schema Schema `json:"schema"` Binding ResourceBinding `json:"binding"` Policy ResourcePolicy `json:"policy,omitzero"` @@ -180,16 +196,27 @@ type Resource struct { // DataSource is one Terraform data source. Phase 1 does not emit these; the type // exists so a blueprint written now does not need reshaping later. type DataSource struct { - Key string `json:"key"` - TerraformType string `json:"terraformType"` - GoPackage string `json:"goPackage"` - GoPackageAlias string `json:"goPackageAlias"` - GoTypeName string `json:"goTypeName"` - ModelTypeName string `json:"modelTypeName"` - ServiceGroup string `json:"serviceGroup,omitempty"` - APIVersionDir string `json:"apiVersionDir,omitempty"` - Attributes []Attribute `json:"attributes"` - Drop bool `json:"drop,omitempty"` + Key string `json:"key"` + Name string `json:"name"` + GoPackage string `json:"goPackage"` + GoPackageAlias string `json:"goPackageAlias"` + GoTypeName string `json:"goTypeName"` + ModelTypeName string `json:"modelTypeName"` + ServiceGroup string `json:"serviceGroup,omitempty"` + APIVersionDir string `json:"apiVersionDir,omitempty"` + + DocRefURL string `json:"docRefUrl,omitempty"` + + Schema Schema `json:"schema"` + + Binding DataSourceBinding `json:"binding"` + + // Timeouts carries only a read deadline that is ever used, since read is the only + // operation a data source has. The other three are accepted and ignored rather than + // modelled separately, so that Conventions.DefaultTimeouts stays one type. + Timeouts Timeouts `json:"timeouts,omitzero"` + + Drop bool `json:"drop,omitempty"` } // ComputedOptionalRequired is how Terraform treats an attribute. The four values are spelled @@ -307,7 +334,38 @@ type NestedAttributeObject struct { Attributes []Attribute `json:"attributes"` } +// Schema is a block's schema, mirroring the framework's own schema.Schema. +// +// Every block kind has its own schema package -- resource/schema, datasource/schema, +// ephemeral/schema, action/schema, list/schema -- whose types are structurally identical and differ +// only in which fields they carry. So one Schema serves every kind, and which of an Attribute's +// fields are legal is a function of the kind rendering it: see Attribute. +// +// Deliberately no Blocks. Blocks are the older configuration syntax and the choice is permanent for +// a published provider. The reference provider surveyed for phase 5 -- +// terraform-provider-microsoft365, 170 resources -- uses ListNestedBlock exactly once, with an empty +// validator slice and its cardinality enforced by hand elsewhere, against 366 SingleNestedAttribute, +// 178 ListNestedAttribute and 110 SetNestedAttribute. It should have been a nested attribute. That +// is the evidence for nested attributes only, replacing an earlier argument from the absence of an +// upstream signal. +type Schema struct { + Attributes []Attribute `json:"attributes"` + + // Version is the schema version, bumped when an attribute change needs a state upgrader. Zero + // is the framework's default and the common case. + Version int64 `json:"version,omitempty"` + + Description string `json:"description,omitempty"` + MarkdownDescription string `json:"markdownDescription,omitempty"` + DeprecationMessage string `json:"deprecationMessage,omitempty"` +} + // Attribute is one Terraform schema attribute. +// +// One type serves every block kind, and not every field is legal in every one of them. The +// framework's per-kind schema packages differ: an action or list attribute has no Computed or +// Sensitive, and only a resource attribute has PlanModifiers or Default. Validate refuses a field +// the target kind has no home for rather than emitting code that will not compile. type Attribute struct { // Name is the tfsdk name, snake_case. Name string `json:"name"` @@ -318,6 +376,9 @@ type Attribute struct { ComputedOptionalRequired ComputedOptionalRequired `json:"computedOptionalRequired"` Sensitive bool `json:"sensitive,omitempty"` + // WriteOnly marks a value the practitioner supplies that is never persisted to state. Legal on + // resource and action attributes only. + WriteOnly bool `json:"writeOnly,omitempty"` MarkdownDescription string `json:"markdownDescription,omitempty"` DeprecationMessage string `json:"deprecationMessage,omitempty"` diff --git a/internal/blueprint/blueprint_test.go b/internal/blueprint/blueprint_test.go index 222a6916..4ef36577 100644 --- a/internal/blueprint/blueprint_test.go +++ b/internal/blueprint/blueprint_test.go @@ -2,6 +2,7 @@ package blueprint import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -14,36 +15,38 @@ import ( func validResource() Resource { return Resource{ Key: "tag", - TerraformType: "thousandeyes_tag", + Name: "tag", GoPackage: "tag", GoPackageAlias: "v7Tag", GoTypeName: "TagResource", ModelTypeName: "TagResourceModel", - Attributes: []Attribute{ - { - Name: "id", - GoField: "ID", - Type: AttrType{Kind: KindString}, - ComputedOptionalRequired: Computed, - Wire: WireBinding{ - JSONPath: "id", - SDKField: "ID", - SDKGoType: "*string", - SkipExpand: true, - Flatten: &ConvertCall{Func: "convert.PtrStringToFramework"}, + Schema: Schema{ + Attributes: []Attribute{ + { + Name: "id", + GoField: "ID", + Type: AttrType{Kind: KindString}, + ComputedOptionalRequired: Computed, + Wire: WireBinding{ + JSONPath: "id", + SDKField: "ID", + SDKGoType: "*string", + SkipExpand: true, + Flatten: &ConvertCall{Func: "convert.PtrStringToFramework"}, + }, }, - }, - { - Name: "key", - GoField: "Key", - Type: AttrType{Kind: KindString}, - ComputedOptionalRequired: Required, - Wire: WireBinding{ - JSONPath: "key", - SDKField: "Key", - SDKGoType: "*string", - Expand: &ConvertCall{Func: "convert.FrameworkToPtrString"}, - Flatten: &ConvertCall{Func: "convert.PtrStringToFramework"}, + { + Name: "key", + GoField: "Key", + Type: AttrType{Kind: KindString}, + ComputedOptionalRequired: Required, + Wire: WireBinding{ + JSONPath: "key", + SDKField: "Key", + SDKGoType: "*string", + Expand: &ConvertCall{Func: "convert.FrameworkToPtrString"}, + Flatten: &ConvertCall{Func: "convert.PtrStringToFramework"}, + }, }, }, }, @@ -145,13 +148,13 @@ func TestUnit_Blueprint_Validate_RejectsStructuralProblems(t *testing.T) { }, { name: "resource with no attributes", - mutate: func(b *Blueprint) { b.Resources[0].Attributes = nil }, + mutate: func(b *Blueprint) { b.Resources[0].Schema.Attributes = nil }, wantPath: "attributes", }, { name: "duplicate attribute name", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].Name = "id" + b.Resources[0].Schema.Attributes[1].Name = "id" }, wantPath: "attribute name", }, @@ -160,26 +163,26 @@ func TestUnit_Blueprint_Validate_RejectsStructuralProblems(t *testing.T) { // loses an attribute. name: "duplicate model field", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].GoField = "ID" + b.Resources[0].Schema.Attributes[1].GoField = "ID" }, wantPath: "model field", }, { - name: "duplicate terraform type across resources", + name: "duplicate name across resources", mutate: func(b *Blueprint) { second := validResource() second.Key = "tag2" second.GoPackageAlias = "v7Tag2" b.Resources = append(b.Resources, second) }, - wantPath: "Terraform type", + wantPath: "name", }, { name: "duplicate import alias across resources", mutate: func(b *Blueprint) { second := validResource() second.Key = "tag2" - second.TerraformType = "thousandeyes_tag2" + second.Name = "tag2" b.Resources = append(b.Resources, second) }, wantPath: "import alias", @@ -187,21 +190,21 @@ func TestUnit_Blueprint_Validate_RejectsStructuralProblems(t *testing.T) { { name: "collection kind without an element type", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].Type = AttrType{Kind: KindSet} + b.Resources[0].Schema.Attributes[1].Type = AttrType{Kind: KindSet} }, wantPath: "elem", }, { name: "scalar kind with an element type", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].Type.ElementType = &AttrType{Kind: KindString} + b.Resources[0].Schema.Attributes[1].Type.ElementType = &AttrType{Kind: KindString} }, wantPath: "elem", }, { name: "unknown type kind", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].Type = AttrType{Kind: "octopus"} + b.Resources[0].Schema.Attributes[1].Type = AttrType{Kind: "octopus"} }, wantPath: "kind", }, @@ -282,21 +285,21 @@ func TestUnit_Blueprint_Validate_RejectsStructuralProblems(t *testing.T) { // and inert is worse than broken because nothing complains. name: "writable attribute with skipExpand", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].Wire.SkipExpand = true + b.Resources[0].Schema.Attributes[1].Wire.SkipExpand = true }, wantPath: "would never reach the API", }, { name: "writable attribute with no expand conversion", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].Wire.Expand = nil + b.Resources[0].Schema.Attributes[1].Wire.Expand = nil }, wantPath: "wire.expand", }, { name: "attribute with neither flatten nor skipFlatten", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[0].Wire.Flatten = nil + b.Resources[0].Schema.Attributes[0].Wire.Flatten = nil }, wantPath: "wire.flatten", }, @@ -304,7 +307,7 @@ func TestUnit_Blueprint_Validate_RejectsStructuralProblems(t *testing.T) { // A default on a non-computed attribute is silently dead config. name: "default on a non-computed attribute", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[1].Default = &Default{ + b.Resources[0].Schema.Attributes[1].Default = &Default{ Static: &Literal{Kind: KindString, Raw: `"x"`}, } }, @@ -313,7 +316,7 @@ func TestUnit_Blueprint_Validate_RejectsStructuralProblems(t *testing.T) { { name: "default setting both static and custom", mutate: func(b *Blueprint) { - b.Resources[0].Attributes[0].Default = &Default{ + b.Resources[0].Schema.Attributes[0].Default = &Default{ Static: &Literal{Kind: KindString, Raw: `"x"`}, Custom: &CustomCode{SchemaDefinition: "x()"}, } @@ -444,11 +447,14 @@ func TestUnit_Blueprint_Marshal_IsDeterministic(t *testing.T) { func TestUnit_Blueprint_Unmarshal_RejectsUnknownFields(t *testing.T) { t.Parallel() - data := `{ - "formatVersion": "1", + // The version is interpolated rather than written out: the version check runs + // before the strict decode, so a stale literal here would turn this into a second + // test of the version check instead of the unknown-field check. + data := fmt.Sprintf(`{ + "formatVersion": %q, "provider": {"name": "x", "goModule": "m", "typePrefix": "x", "updateStile": "putFull", "sdk": {"dialect": "restyService", "modulePath": "m", "clientType": "*c"}} - }` + }`, FormatVersion) _, err := Unmarshal([]byte(data)) if err == nil { @@ -514,7 +520,7 @@ func TestUnit_Blueprint_LoadDir_MergesAndSorts(t *testing.T) { // exercised. zebra := validResource() zebra.Key = "zebra" - zebra.TerraformType = "thousandeyes_zebra" + zebra.Name = "zebra" zebra.GoPackageAlias = "v7Zebra" if err := Save(filepath.Join(dir, "resources", "zebra"+Ext), Blueprint{FormatVersion: FormatVersion, Resources: []Resource{zebra}}); err != nil { @@ -607,7 +613,7 @@ func TestUnit_Blueprint_LoadDir_ValidatesAcrossFiles(t *testing.T) { if err == nil { t.Fatal("expected the cross-file type collision to be caught") } - if !strings.Contains(err.Error(), "Terraform type") { + if !strings.Contains(err.Error(), "used more than once") { t.Errorf("error should name the collision: %v", err) } } diff --git a/internal/blueprint/coverage_test.go b/internal/blueprint/coverage_test.go index 449461d9..e765a714 100644 --- a/internal/blueprint/coverage_test.go +++ b/internal/blueprint/coverage_test.go @@ -61,7 +61,7 @@ func TestUnit_Blueprint_LoadReportsFileErrors(t *testing.T) { // A file whose content is valid JSON but not a valid blueprint must fail // validation rather than load a half-built document. invalid := filepath.Join(t.TempDir(), "invalid"+Ext) - if err := os.WriteFile(invalid, []byte(`{"formatVersion":"1"}`), 0o600); err != nil { + if err := os.WriteFile(invalid, []byte(`{"formatVersion":"`+FormatVersion+`"}`), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } if _, err := Load(invalid); !errors.Is(err, ErrInvalid) { @@ -217,7 +217,7 @@ func TestUnit_Blueprint_ValidateNestedAttributes(t *testing.T) { t.Parallel() b := validBlueprint() - b.Resources[0].Attributes = append(b.Resources[0].Attributes, tc.attr) + b.Resources[0].Schema.Attributes = append(b.Resources[0].Schema.Attributes, tc.attr) err := b.Validate() if err == nil { @@ -231,7 +231,7 @@ func TestUnit_Blueprint_ValidateNestedAttributes(t *testing.T) { // The valid nested shape must pass, or every case above proves nothing. b := validBlueprint() - b.Resources[0].Attributes = append(b.Resources[0].Attributes, nested(nil)) + b.Resources[0].Schema.Attributes = append(b.Resources[0].Schema.Attributes, nested(nil)) if err := b.Validate(); err != nil { t.Errorf("a well-formed nested attribute should validate: %v", err) } diff --git a/internal/blueprint/load.go b/internal/blueprint/load.go index 0aec0265..f2540646 100644 --- a/internal/blueprint/load.go +++ b/internal/blueprint/load.go @@ -43,6 +43,14 @@ func Marshal(b Blueprint) ([]byte, error) { // schema says `policy.updateStyle`, sees no complaint, and gets a provider that // clears attributes it should preserve. func Unmarshal(data []byte) (Blueprint, error) { + // The version is read first, before the strict decode, and that ordering is the whole point of + // having a version at all. A format change moves fields, so a document from the previous format + // fails a strict decode with "unknown field \"attributes\"" -- which tells a reader nothing + // about what is actually wrong or what to do about it. + if err := checkFormatVersion(data); err != nil { + return Blueprint{}, err + } + var b Blueprint dec := json.NewDecoder(bytes.NewReader(data)) @@ -52,11 +60,6 @@ func Unmarshal(data []byte) (Blueprint, error) { return Blueprint{}, fmt.Errorf("parsing blueprint: %w", err) } - if b.FormatVersion != FormatVersion { - return Blueprint{}, fmt.Errorf("%w: %q (this build understands %q)", - ErrUnsupportedFormat, b.FormatVersion, FormatVersion) - } - if err := b.Validate(); err != nil { return Blueprint{}, err } @@ -64,6 +67,36 @@ func Unmarshal(data []byte) (Blueprint, error) { return b, nil } +// checkFormatVersion reads only formatVersion, tolerating everything else. +// +// Deliberately not strict: the document being checked is one this build may not understand, so +// refusing it for an unknown field would defeat the purpose. A fragment with no formatVersion at all +// is accepted here and settled by the caller, because a resource file legitimately omits it. +func checkFormatVersion(data []byte) error { + var peek struct { + FormatVersion string `json:"formatVersion"` + } + + if err := json.Unmarshal(data, &peek); err != nil { + // Not decodable even loosely, so let the strict decode produce the better message. + return nil + } + + if peek.FormatVersion == "" || peek.FormatVersion == FormatVersion { + return nil + } + + return fmt.Errorf( + "%w: found %q, this build understands %q. Blueprints written before format %s "+ + "put attributes directly on a resource; they now live under \"schema\". Re-run `ingest` "+ + "against the snapshot, or move the key by hand", + ErrUnsupportedFormat, + peek.FormatVersion, + FormatVersion, + FormatVersion, + ) +} + // Load reads and validates a blueprint from a file. func Load(path string) (Blueprint, error) { data, err := os.ReadFile(path) //nolint:gosec // the path is operator-supplied by design @@ -112,7 +145,12 @@ func LoadDir(root string) (Blueprint, error) { return Blueprint{}, err } if len(paths) == 0 { - return Blueprint{}, fmt.Errorf("%w under %s (expected files named *%s)", ErrNoBlueprint, root, Ext) + return Blueprint{}, fmt.Errorf( + "%w under %s (expected files named *%s)", + ErrNoBlueprint, + root, + Ext, + ) } // Sorted so that the merged result does not depend on directory order. @@ -149,11 +187,21 @@ func LoadDir(root string) (Blueprint, error) { } if providerSetBy == "" { - return Blueprint{}, fmt.Errorf("%w: no file under %s declares a provider block", ErrInvalid, root) + return Blueprint{}, fmt.Errorf( + "%w: no file under %s declares a provider block", + ErrInvalid, + root, + ) } - sort.Slice(merged.Resources, func(i, j int) bool { return merged.Resources[i].Key < merged.Resources[j].Key }) - sort.Slice(merged.DataSources, func(i, j int) bool { return merged.DataSources[i].Key < merged.DataSources[j].Key }) + sort.Slice( + merged.Resources, + func(i, j int) bool { return merged.Resources[i].Key < merged.Resources[j].Key }, + ) + sort.Slice( + merged.DataSources, + func(i, j int) bool { return merged.DataSources[i].Key < merged.DataSources[j].Key }, + ) // Validate once on the whole document. Cross-resource rules -- duplicate // type names, duplicate import aliases -- are invisible to a per-file check, @@ -173,6 +221,11 @@ func loadPart(path string) (Blueprint, error) { return Blueprint{}, fmt.Errorf("reading %s: %w", path, err) } + // Version before strict decode, for the reason given on checkFormatVersion. + if err := checkFormatVersion(data); err != nil { + return Blueprint{}, fmt.Errorf("%s: %w", path, err) + } + var b Blueprint dec := json.NewDecoder(bytes.NewReader(data)) diff --git a/internal/blueprint/merge/merge.go b/internal/blueprint/merge/merge.go index 9ce438e5..e5300a00 100644 --- a/internal/blueprint/merge/merge.go +++ b/internal/blueprint/merge/merge.go @@ -237,10 +237,10 @@ func applyToResource( applyResourceLevel(res, resourceLevel, result) - // Attributes are walked by pointer, including one level of nesting, so a fact about a - // field inside an object lands on the right attribute. - for i := range res.Attributes { - applyToAttribute(res, &res.Attributes[i], "", byPath, opts, result) + // Attributes are walked by pointer, to any depth of nesting, so a fact about a field + // inside an object lands on the right attribute wherever it sits. + for i := range res.Schema.Attributes { + applyToAttribute(res, &res.Schema.Attributes[i], "", byPath, opts, result) } // Anything left addressed a field with no attribute. Reported, because a fact that cannot @@ -294,8 +294,7 @@ func applyToAttribute( } } -// a fact whose field merge did not recognise would be silently ignored, which is the one -// One case per fact field, deliberately. +// applyAttributeFacts is one case per fact field, deliberately. // // The complexity is the arity of the fact vocabulary, not tangled control flow: every branch is // a flat "this kind of fact means this". A reviewer checking what merge does with a given fact diff --git a/internal/blueprint/merge/merge_test.go b/internal/blueprint/merge/merge_test.go index 2502a3c6..e6e76ba4 100644 --- a/internal/blueprint/merge/merge_test.go +++ b/internal/blueprint/merge/merge_test.go @@ -17,32 +17,34 @@ func testBlueprint() blueprint.Blueprint { Provider: blueprint.Provider{Name: "example"}, Resources: []blueprint.Resource{{ Key: "thing", - Attributes: []blueprint.Attribute{ - { - Name: "colour", ComputedOptionalRequired: blueprint.ComputedOptional, - Type: blueprint.AttrType{Kind: blueprint.KindString}, - Wire: blueprint.WireBinding{JSONPath: "colour"}, - MarkdownDescription: "The thing's colour.", - }, - { - Name: "key", ComputedOptionalRequired: blueprint.Required, - Type: blueprint.AttrType{Kind: blueprint.KindString}, - Wire: blueprint.WireBinding{JSONPath: "key"}, - }, - { - Name: "items", ComputedOptionalRequired: blueprint.Optional, - Type: blueprint.AttrType{ - Kind: blueprint.KindSetNested, - NestedObject: &blueprint.NestedAttributeObject{ - GoTypeName: "ItemModel", - Attributes: []blueprint.Attribute{{ - Name: "mode", ComputedOptionalRequired: blueprint.Optional, - Type: blueprint.AttrType{Kind: blueprint.KindString}, - Wire: blueprint.WireBinding{JSONPath: "mode"}, - }}, + Schema: blueprint.Schema{ + Attributes: []blueprint.Attribute{ + { + Name: "colour", ComputedOptionalRequired: blueprint.ComputedOptional, + Type: blueprint.AttrType{Kind: blueprint.KindString}, + Wire: blueprint.WireBinding{JSONPath: "colour"}, + MarkdownDescription: "The thing's colour.", + }, + { + Name: "key", ComputedOptionalRequired: blueprint.Required, + Type: blueprint.AttrType{Kind: blueprint.KindString}, + Wire: blueprint.WireBinding{JSONPath: "key"}, + }, + { + Name: "items", ComputedOptionalRequired: blueprint.Optional, + Type: blueprint.AttrType{ + Kind: blueprint.KindSetNested, + NestedObject: &blueprint.NestedAttributeObject{ + GoTypeName: "ItemModel", + Attributes: []blueprint.Attribute{{ + Name: "mode", ComputedOptionalRequired: blueprint.Optional, + Type: blueprint.AttrType{Kind: blueprint.KindString}, + Wire: blueprint.WireBinding{JSONPath: "mode"}, + }}, + }, }, + Wire: blueprint.WireBinding{JSONPath: "items"}, }, - Wire: blueprint.WireBinding{JSONPath: "items"}, }, }, }}, @@ -72,7 +74,7 @@ func TestUnit_Merge_NoServerDefaultConflictsWithComputedOptional(t *testing.T) { t.Parallel() bp := testBlueprint() - before := bp.Resources[0].Attributes[0].ComputedOptionalRequired + before := bp.Resources[0].Schema.Attributes[0].ComputedOptionalRequired facts := []probe.Fact{ // A server-default fact with no literal: the probe looked and found nothing. @@ -103,9 +105,9 @@ func TestUnit_Merge_NoServerDefaultConflictsWithComputedOptional(t *testing.T) { } // Nothing changed, even under apply. - if bp.Resources[0].Attributes[0].ComputedOptionalRequired != before { + if bp.Resources[0].Schema.Attributes[0].ComputedOptionalRequired != before { t.Errorf("presence changed to %q; narrowing must never be automatic", - bp.Resources[0].Attributes[0].ComputedOptionalRequired) + bp.Resources[0].Schema.Attributes[0].ComputedOptionalRequired) } if !errors.Is(result.Err(), ErrConflicts) { @@ -141,7 +143,7 @@ func TestUnit_Merge_ConstantDefaultIsRecordedAndDescribed(t *testing.T) { t.Errorf("Err() = %v, want nil", result.Err()) } - attr := bp.Resources[0].Attributes[0] + attr := bp.Resources[0].Schema.Attributes[0] if attr.Behaviour.ServerDefault == nil || attr.Behaviour.ServerDefault.Raw != `"blue"` { t.Errorf("the server default was not recorded: %+v", attr.Behaviour.ServerDefault) @@ -192,7 +194,7 @@ func TestUnit_Merge_DerivedDefaultConfirmsTheGuess(t *testing.T) { if len(result.Conflicts) != 0 { t.Errorf("a derived default should not conflict with computed_optional: %+v", result.Conflicts) } - if bp.Resources[0].Attributes[0].ComputedOptionalRequired != blueprint.ComputedOptional { + if bp.Resources[0].Schema.Attributes[0].ComputedOptionalRequired != blueprint.ComputedOptional { t.Error("presence should be left alone") } @@ -211,7 +213,7 @@ func TestUnit_Merge_DerivedDefaultConfirmsTheGuess(t *testing.T) { // And a static default on a derived value is a conflict, because it is a permanent lie. withStatic := testBlueprint() - withStatic.Resources[0].Attributes[0].Default = &blueprint.Default{ + withStatic.Resources[0].Schema.Attributes[0].Default = &blueprint.Default{ Static: &blueprint.Literal{Kind: blueprint.KindString, Raw: `"blue"`}, } @@ -249,7 +251,7 @@ func TestUnit_Merge_RequiredByAPI(t *testing.T) { t.Errorf("confirming a requirement should not conflict: %+v", result.Conflicts) } - attr := bp.Resources[0].Attributes[1] + attr := bp.Resources[0].Schema.Attributes[1] if attr.ComputedOptionalRequired != blueprint.Required { t.Errorf("presence = %q, want it left required", attr.ComputedOptionalRequired) } @@ -281,7 +283,7 @@ func TestUnit_Merge_RequiredByAPI(t *testing.T) { if len(result.Conflicts) != 1 { t.Fatalf("annotate should report rather than change presence: %+v", result.Conflicts) } - if annotated.Resources[0].Attributes[1].ComputedOptionalRequired != blueprint.Required { + if annotated.Resources[0].Schema.Attributes[1].ComputedOptionalRequired != blueprint.Required { t.Error("annotate must not change presence") } @@ -290,8 +292,8 @@ func TestUnit_Merge_RequiredByAPI(t *testing.T) { t.Fatalf("Apply: %v", err) } - if bp.Resources[0].Attributes[1].ComputedOptionalRequired != blueprint.Optional { - t.Errorf("presence = %q, want optional", bp.Resources[0].Attributes[1].ComputedOptionalRequired) + if bp.Resources[0].Schema.Attributes[1].ComputedOptionalRequired != blueprint.Optional { + t.Errorf("presence = %q, want optional", bp.Resources[0].Schema.Attributes[1].ComputedOptionalRequired) } // Widening is safe but surprising, so it has to be said out loud. @@ -379,7 +381,7 @@ func TestUnit_Merge_ImmutableNeverSetsAPlanModifier(t *testing.T) { t.Fatalf("Apply: %v", err) } - attr := bp.Resources[0].Attributes[0] + attr := bp.Resources[0].Schema.Attributes[0] if len(attr.PlanModifiers) != 0 { t.Errorf("merge must never add a plan modifier: %+v", attr.PlanModifiers) @@ -427,7 +429,7 @@ func TestUnit_Merge_SuspectedFactsAreNeverApplied(t *testing.T) { if result.Ignored != 2 { t.Errorf("Ignored = %d, want 2 -- a run that ignored facts must say so", result.Ignored) } - if bp.Resources[0].Attributes[0].Behaviour.Writable != nil { + if bp.Resources[0].Schema.Attributes[0].Behaviour.Writable != nil { t.Error("behaviour was written from a suspected fact") } } @@ -557,7 +559,7 @@ func TestUnit_Merge_NestedFieldsAreReached(t *testing.T) { t.Fatalf("a nested field should be found: %+v", result.Conflicts) } - nested := bp.Resources[0].Attributes[2].Type.NestedObject.Attributes[0] + nested := bp.Resources[0].Schema.Attributes[2].Type.NestedObject.Attributes[0] if nested.Behaviour.Volatile == nil || !*nested.Behaviour.Volatile { t.Errorf("the nested attribute's behaviour was not written: %+v", nested.Behaviour) } @@ -655,7 +657,7 @@ func TestUnit_Merge_IsIdempotent(t *testing.T) { t.Fatal("the first merge should change something, or this test is vacuous") } - afterFirst := bp.Resources[0].Attributes[0].MarkdownDescription + afterFirst := bp.Resources[0].Schema.Attributes[0].MarkdownDescription second, err := Apply(&bp, facts, opts) if err != nil { @@ -666,7 +668,7 @@ func TestUnit_Merge_IsIdempotent(t *testing.T) { t.Errorf("the second merge changed %d thing(s); merging the same evidence twice must be "+ "a no-op:\n%+v", len(second.Changes), second.Changes) } - if got := bp.Resources[0].Attributes[0].MarkdownDescription; got != afterFirst { + if got := bp.Resources[0].Schema.Attributes[0].MarkdownDescription; got != afterFirst { t.Errorf("the description drifted on a second merge:\n--- first\n%s\n--- second\n%s", afterFirst, got) } @@ -776,7 +778,7 @@ func TestUnit_Merge_EnumFactsDescribeButDoNotValidate(t *testing.T) { t.Fatalf("Apply: %v", err) } - attr := bp.Resources[0].Attributes[0] + attr := bp.Resources[0].Schema.Attributes[0] // **No validator**, ever. An over-tight one rejects configurations the API would have // accepted, and the practitioner cannot work around it. diff --git a/internal/blueprint/validate.go b/internal/blueprint/validate.go index 92dc84c8..f2eb6119 100644 --- a/internal/blueprint/validate.go +++ b/internal/blueprint/validate.go @@ -83,13 +83,78 @@ func (b Blueprint) Validate() error { // the registration file fail to compile; two sharing a key make probe // facts and overrides land on the wrong one. dup(&p, seenKeys, r.Key, at+".key", "resource key") - dup(&p, seenTypes, r.TerraformType, at+".terraformType", "Terraform type") + dup(&p, seenTypes, r.Name, at+".name", "resource name") dup(&p, seenAliases, r.GoPackageAlias, at+".goPackageAlias", "import alias") } + // Data sources were not validated at all before format 2, which mattered less while none were + // emitted. Their keys and type names live in their own namespaces -- a `tag` resource and a + // `tag` data source are a normal pair -- but the import alias is shared, because both register + // into the same generated provider package. + seenDataKeys := map[string]bool{} + seenDataTypes := map[string]bool{} + + for i, d := range b.DataSources { + at := fmt.Sprintf("dataSources[%d]", i) + if d.Key != "" { + at = fmt.Sprintf("dataSources[%s]", d.Key) + } + + if d.Drop { + continue + } + + d.validate(at, &p) + + dup(&p, seenDataKeys, d.Key, at+".key", "data source key") + dup(&p, seenDataTypes, d.Name, at+".name", "data source name") + dup(&p, seenAliases, d.GoPackageAlias, at+".goPackageAlias", "import alias") + } + return p.err() } +// validate checks one data source. +// +// The same skeleton as a resource minus everything a data source has no operation for: no binding +// beyond a read, no policy, no import. Its attributes are validated against BlockDataSource, which +// refuses a default or a plan modifier -- fields the framework's datasource/schema package does not +// have. +func (d DataSource) validate(at string, p *problems) { + required(p, at+".key", d.Key) + required(p, at+".name", d.Name) + required(p, at+".goPackage", d.GoPackage) + required(p, at+".goPackageAlias", d.GoPackageAlias) + required(p, at+".goTypeName", d.GoTypeName) + required(p, at+".modelTypeName", d.ModelTypeName) + + if len(d.Schema.Attributes) == 0 { + p.add(at+".schema.attributes", "a data source with no attributes cannot be emitted") + } + + d.Binding.validate(at+".binding", p) + + seenNames := map[string]bool{} + seenFields := map[string]bool{} + + for i, a := range d.Schema.Attributes { + aat := fmt.Sprintf("%s.schema.attributes[%d]", at, i) + if a.Name != "" { + aat = fmt.Sprintf("%s.schema.attributes[%s]", at, a.Name) + } + + if a.Drop { + continue + } + + a.validate(aat, p) + a.validateForKind(BlockDataSource, aat, p) + + dup(p, seenNames, a.Name, aat+".name", "attribute name") + dup(p, seenFields, a.GoField, aat+".goField", "model field") + } +} + func dup(p *problems, seen map[string]bool, value, path, what string) { if value == "" { return @@ -122,30 +187,31 @@ func (pr Provider) validate(p *problems) { func (r Resource) validate(at string, p *problems) { required(p, at+".key", r.Key) - required(p, at+".terraformType", r.TerraformType) + required(p, at+".name", r.Name) required(p, at+".goPackage", r.GoPackage) required(p, at+".goPackageAlias", r.GoPackageAlias) required(p, at+".goTypeName", r.GoTypeName) required(p, at+".modelTypeName", r.ModelTypeName) - if len(r.Attributes) == 0 { - p.add(at+".attributes", "a resource with no attributes cannot be emitted") + if len(r.Schema.Attributes) == 0 { + p.add(at+".schema.attributes", "a resource with no attributes cannot be emitted") } seenNames := map[string]bool{} seenFields := map[string]bool{} hasWritable := false - for i, a := range r.Attributes { - aat := fmt.Sprintf("%s.attributes[%d]", at, i) + for i, a := range r.Schema.Attributes { + aat := fmt.Sprintf("%s.schema.attributes[%d]", at, i) if a.Name != "" { - aat = fmt.Sprintf("%s.attributes[%s]", at, a.Name) + aat = fmt.Sprintf("%s.schema.attributes[%s]", at, a.Name) } if a.Drop { continue } a.validate(aat, p) + a.validateForKind(BlockResource, aat, p) dup(p, seenNames, a.Name, aat+".name", "attribute name") // A duplicated Go field is the subtler failure: the schema is fine and @@ -290,22 +356,8 @@ func (a Attribute) validate(at string, p *problems) { } } - // An attribute the practitioner can set must have a way to reach the API, - // and one that is read must have a way back. Catching this here is the - // difference between a clear message and a silently inert attribute. - if a.ComputedOptionalRequired.IsRequired() || a.ComputedOptionalRequired.IsOptional() { - if a.Wire.SkipExpand { - p.add( - at+".wire.skipExpand", - "is set on a writable attribute, so its value would never reach the API", - ) - } else if a.Wire.Expand == nil { - p.add(at+".wire.expand", "is required on a writable attribute") - } - } - if !a.Wire.SkipFlatten && a.Wire.Flatten == nil { - p.add(at+".wire.flatten", "is required unless skipFlatten is set") - } + // The wire directions are checked by validateForKind, because which of them an + // attribute needs depends on whether its block kind sends anything to the API. } func (t AttrType) validate(at string, p *problems) { @@ -345,8 +397,8 @@ func (n NestedAttributeObject) validate(at string, p *problems) { required(p, at+".sdkType", n.SDKType) required(p, at+".attrTypesVar", n.AttrTypesVar) required(p, at+".objectTypeVar", n.ObjectTypeVar) - required(p, at+".expandFunc", n.ExpandFunc) - required(p, at+".flattenFunc", n.FlattenFunc) + // expandFunc and flattenFunc are checked by validateWireForKind: a data source needs + // only the flatten direction, so requiring both here would refuse a valid one. if len(n.Attributes) == 0 { p.add(at+".attributes", "a nested object with no attributes cannot be emitted") @@ -357,9 +409,9 @@ func (n NestedAttributeObject) validate(at string, p *problems) { seenFields := map[string]bool{} for i, a := range n.Attributes { - aat := fmt.Sprintf("%s.attributes[%d]", at, i) + aat := fmt.Sprintf("%s.schema.attributes[%d]", at, i) if a.Name != "" { - aat = fmt.Sprintf("%s.attributes[%s]", at, a.Name) + aat = fmt.Sprintf("%s.schema.attributes[%s]", at, a.Name) } if a.Drop { continue @@ -408,6 +460,38 @@ func (b ResourceBinding) validate(at string, p *problems) { } } +// validate checks a data source's binding. +// +// Read is not merely required, it is the whole binding: a data source that cannot read is +// not a data source. The resource equivalent has three more operations and a request +// body, none of which exist on this type, so there is nothing here to refuse. +func (b DataSourceBinding) validate(at string, p *problems) { + required(p, at+".service.importPath", b.Service.ImportPath) + required(p, at+".service.typeName", b.Service.TypeName) + required(p, at+".service.accessor", b.Service.Accessor) + + required(p, at+".response.type", b.Response.Type) + + switch b.Response.AccessStyle { + case AccessStructField: + case AccessMethod: + p.add( + at+".response.accessStyle", + "%q is reserved but not yet implemented by the emitter", + b.Response.AccessStyle, + ) + default: + p.add(at+".response.accessStyle", "%q is not a known access style", b.Response.AccessStyle) + } + + if b.Read == nil { + p.add(at+".read", "is required: a data source with no read operation has nothing to do") + return + } + + b.Read.validate(at+".read", p) +} + func (o Operation) validate(at string, p *problems) { switch o.Style { case CallStyleMethod: @@ -443,7 +527,7 @@ func (o Operation) validate(at string, p *problems) { func (a Argument) validate(at string, p *problems) { switch a.Kind { case ArgContext, ArgBody: - case ArgStateField, ArgPlanField: + case ArgStateField, ArgPlanField, ArgConfigField: if a.Field == "" && a.Expr == "" { p.add(at, "kind %q needs either field or expr", a.Kind) } diff --git a/internal/emit/emit.go b/internal/emit/emit.go index 810bc8da..2e73ca3c 100644 --- a/internal/emit/emit.go +++ b/internal/emit/emit.go @@ -115,6 +115,18 @@ func (g *Generator) Build(bp blueprint.Blueprint, opts Options) (Plan, error) { plan.Files = append(plan.Files, files...) } + for _, d := range bp.DataSources { + if d.Drop || (opts.Only != "" && d.Key != opts.Only) { + continue + } + + files, err := g.dataSourceFiles(bp, d, ropts) + if err != nil { + return Plan{}, fmt.Errorf("data source %q: %w", d.Key, err) + } + plan.Files = append(plan.Files, files...) + } + // Registration files are rendered from the whole blueprint even under -only, // because a registration listing a subset would not compile against a tree // containing the rest. @@ -132,10 +144,14 @@ func (g *Generator) Build(bp blueprint.Blueprint, opts Options) (Plan, error) { } // resourceFiles renders the five files that make up one resource. -func (g *Generator) resourceFiles(bp blueprint.Blueprint, r blueprint.Resource, ropts render.Options) ([]File, error) { +func (g *Generator) resourceFiles( + bp blueprint.Blueprint, + r blueprint.Resource, + ropts render.Options, +) ([]File, error) { view, err := render.Resource(bp, r, ropts) if err != nil { - return nil, err + return nil, fmt.Errorf("building the render view: %w", err) } dir := render.ResourceDir(bp, r) @@ -168,7 +184,52 @@ func (g *Generator) resourceFiles(bp blueprint.Blueprint, r blueprint.Resource, return out, nil } -func (g *Generator) registrationFiles(bp blueprint.Blueprint, ropts render.Options) ([]File, error) { +// dataSourceFiles renders the four files that make up one data source. +// +// Four rather than the resource's five: a data source sends no request body, so there is +// no construct.go. The names match the reference provider's, in which read lives in +// read.go rather than in a crud.go that would be three-quarters empty. +func (g *Generator) dataSourceFiles( + bp blueprint.Blueprint, + d blueprint.DataSource, + ropts render.Options, +) ([]File, error) { + view, err := render.DataSource(bp, d, ropts) + if err != nil { + return nil, fmt.Errorf("building the render view: %w", err) + } + + dir := render.DataSourceDir(bp, d) + + wanted := []struct { + name, tmpl string + skip bool + }{ + {"datasource.go", "datasource.go.tmpl", false}, + {"model.go", "datasource_model.go.tmpl", false}, + {"read.go", "datasource_read.go.tmpl", false}, + {"state.go", "datasource_state.go.tmpl", len(view.State.Assignments) == 0}, + } + + out := make([]File, 0, len(wanted)) + for _, w := range wanted { + if w.skip { + continue + } + content, err := g.renderFile(w.tmpl, view) + if err != nil { + return nil, fmt.Errorf("%s: %w", w.name, err) + } + out = append(out, File{Path: filepath.Join(dir, w.name), Content: content}) + } + + return out, nil +} + +func (g *Generator) registrationFiles( + bp blueprint.Blueprint, + ropts render.Options, +) ([]File, error) { dir := render.ProviderDir(bp) specs := []struct { @@ -219,7 +280,12 @@ func formatGo(src []byte) ([]byte, error) { if err != nil { // The unformattable source is the only way to debug a broken template, so // it is returned with the error rather than discarded. - return nil, fmt.Errorf("%w: %v\n--- unformatted output ---\n%s", ErrFormat, err, numberLines(src)) + return nil, fmt.Errorf( + "%w: %w\n--- unformatted output ---\n%s", + ErrFormat, + err, + numberLines(src), + ) } return out, nil } @@ -277,7 +343,9 @@ func Write(plan Plan, opts WriteOptions) (WriteResult, error) { for _, f := range plan.Files { target := filepath.Join(opts.Root, f.Path) - existing, err := os.ReadFile(target) //nolint:gosec // the path is operator-supplied by design + existing, err := os.ReadFile( + target, + ) //nolint:gosec // the path is operator-supplied by design switch { case err == nil: if bytes.Equal(existing, f.Content) { diff --git a/internal/emit/emit_test.go b/internal/emit/emit_test.go index 023f4358..8a679d04 100644 --- a/internal/emit/emit_test.go +++ b/internal/emit/emit_test.go @@ -337,14 +337,14 @@ func TestUnit_Emit_EnumValuesDoNotReachGeneratedCode(t *testing.T) { stripped.Resources = append([]blueprint.Resource(nil), bp.Resources...) for i := range stripped.Resources { - attrs := append([]blueprint.Attribute(nil), stripped.Resources[i].Attributes...) + attrs := append([]blueprint.Attribute(nil), stripped.Resources[i].Schema.Attributes...) for j := range attrs { if len(attrs[j].Type.Enum) > 0 { carried++ attrs[j].Type.Enum = nil } } - stripped.Resources[i].Attributes = attrs + stripped.Resources[i].Schema.Attributes = attrs } if carried == 0 { @@ -385,3 +385,99 @@ func TestUnit_Emit_EnumValuesDoNotReachGeneratedCode(t *testing.T) { } } } + +// TestUnit_Emit_DataSourceProducesFourFiles pins the per-data-source file split. +// +// Four rather than the resource's five: a data source sends no request body, so there is +// nothing for a construct.go to expand into one. The names match the reference provider's, +// where read lives in read.go rather than a crud.go that would be three-quarters empty. +func TestUnit_Emit_DataSourceProducesFourFiles(t *testing.T) { + t.Parallel() + + g, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + + bp := pilotBlueprint(t) + if len(bp.DataSources) == 0 { + t.Skip("the committed pilot blueprint declares no data sources") + } + + plan, err := g.Build(bp, Options{BlueprintPath: "blueprints/thousandeyes"}) + if err != nil { + t.Fatalf("Build: %v", err) + } + + for _, d := range bp.DataSources { + if d.Drop { + continue + } + + var got []string + for _, f := range plan.Files { + if strings.Contains(f.Path, "/datasources/") && strings.HasSuffix( + filepath.Dir(f.Path), string(filepath.Separator)+d.GoPackage, + ) { + got = append(got, filepath.Base(f.Path)) + } + } + + want := []string{"datasource.go", "model.go", "read.go", "state.go"} + if len(got) != len(want) { + t.Errorf("data source %q emitted %v, want %v", d.Key, got, want) + continue + } + for i, w := range want { + if got[i] != w { + t.Errorf("data source %q file %d = %q, want %q", d.Key, i, got[i], w) + } + } + + // construct.go is a resource file. Emitting one here would mean the generator + // believes a data source has a request body. + for _, f := range plan.Files { + if strings.Contains(f.Path, "/datasources/") && + filepath.Base(f.Path) == "construct.go" { + t.Errorf("a data source must not emit construct.go: %s", f.Path) + } + } + } +} + +// TestUnit_Emit_DataSourcesRegisterInTheProvider checks the generated registry, which is +// what makes the provider serve them at all. +func TestUnit_Emit_DataSourcesRegisterInTheProvider(t *testing.T) { + t.Parallel() + + g, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + + bp := pilotBlueprint(t) + plan, err := g.Build(bp, Options{BlueprintPath: "blueprints/thousandeyes"}) + if err != nil { + t.Fatalf("Build: %v", err) + } + + var registry string + for _, f := range plan.Files { + if filepath.Base(f.Path) == "datasources.go" { + registry = string(f.Content) + } + } + if registry == "" { + t.Fatal("no datasources.go was emitted") + } + + for _, d := range bp.DataSources { + if d.Drop { + continue + } + want := d.GoPackageAlias + ".New" + d.GoTypeName + if !strings.Contains(registry, want) { + t.Errorf("datasources.go does not register %q:\n%s", want, registry) + } + } +} diff --git a/internal/ingest/openapi/coverage_test.go b/internal/ingest/openapi/coverage_test.go index f9af392a..be96f6a3 100644 --- a/internal/ingest/openapi/coverage_test.go +++ b/internal/ingest/openapi/coverage_test.go @@ -125,8 +125,8 @@ paths: t.Fatalf("Infer: %v", err) } - if len(res.Attributes) != 2 { - t.Fatalf("got %d attributes, want 2: a vendor JSON media type must still be read", len(res.Attributes)) + if len(res.Schema.Attributes) != 2 { + t.Fatalf("got %d attributes, want 2: a vendor JSON media type must still be read", len(res.Schema.Attributes)) } } @@ -375,7 +375,7 @@ paths: } byName := map[string]blueprint.TypeKind{} - for _, a := range res.Attributes { + for _, a := range res.Schema.Attributes { byName[a.Name] = a.Type.Kind } diff --git a/internal/ingest/openapi/infer.go b/internal/ingest/openapi/infer.go index 4e2fc906..53e56ac8 100644 --- a/internal/ingest/openapi/infer.go +++ b/internal/ingest/openapi/infer.go @@ -87,7 +87,7 @@ func (d *Document) Infer(c Candidate, opts InferOptions) (blueprint.Resource, [] r := blueprint.Resource{ Key: c.Key, - TerraformType: naming.TerraformTypeName(opts.Provider, c.Key), + Name: naming.TerraformName(c.Key), GoPackage: pkgDir, GoPackageAlias: namingOpts.PackageAlias(opts.APIVersionDir, c.Key), GoTypeName: goType, @@ -105,7 +105,7 @@ func (d *Document) Infer(c Candidate, opts InferOptions) (blueprint.Resource, [] } if s := summaryOf(c); s != "" { - r.MarkdownDescription = s + r.Schema.MarkdownDescription = s } requestType, responseType := d.bodyTypeNames(c) @@ -132,10 +132,10 @@ func (d *Document) Infer(c Candidate, opts InferOptions) (blueprint.Resource, [] bindOperations(&r, c, sdkPkg+"."+responseType) attrs, attrNotes := d.attributes(c, sdkPkg) - r.Attributes = attrs + r.Schema.Attributes = attrs notes = append(notes, attrNotes...) - if len(r.Attributes) == 0 { + if len(r.Schema.Attributes) == 0 { return blueprint.Resource{}, notes, fmt.Errorf( "%w: %s: nothing usable in its schemas", ErrNoAttributes, c.Key, @@ -143,7 +143,7 @@ func (d *Document) Infer(c Candidate, opts InferOptions) (blueprint.Resource, [] } // Without an identifier there is nothing to read, import or delete by. - if !hasAttribute(r.Attributes, "id") { + if !hasAttribute(r.Schema.Attributes, "id") { notes = append(notes, Note{ Resource: c.Key, Message: "no id attribute in the schemas, so the resource cannot be imported or refreshed", @@ -250,10 +250,9 @@ func (d *Document) attributes(c Candidate, sdkPkg string) ([]blueprint.Attribute } sort.Strings(names) - var ( - out []blueprint.Attribute - notes []Note - ) + ctx := newInferCtx(c.Key, sdkPkg) + + var out []blueprint.Attribute for _, name := range names { f, inRead := readable[name] @@ -273,19 +272,19 @@ func (d *Document) attributes(c Candidate, sdkPkg string) ([]blueprint.Attribute } if skip, why := skipField(f); skip { - notes = append(notes, Note{Resource: c.Key, Field: name, Message: why}) + ctx.note(name, why) continue } - attr, note := attributeOf(f, inWrite, sdkPkg) - if note != "" { - notes = append(notes, Note{Resource: c.Key, Field: name, Message: note}) + attr, why := ctx.attributeOf(f, name, inWrite) + if why != "" { + ctx.note(name, why) continue } out = append(out, attr) } - return out, notes + return out, ctx.notes } func (d *Document) requestSchema(c Candidate) *base.Schema { @@ -340,7 +339,17 @@ func skipField(f Field) (bool, string) { case f.Kind == "": return true, "no framework type maps to this schema" case f.Kind.IsNested() && f.ObjectTypeName == "": + // Without a $ref there is no schema name, and a nested object's model, attr.Type + // map and helper pair all take their names from it. Naming them after the + // attribute instead would collide the moment two objects had a field in common. return true, "nested object with no named schema, which cannot be given a model type" + case f.Kind.IsNested() && f.CyclicSchema() != "": + return true, fmt.Sprintf( + "the schema %s contains itself, so its depth is decided by the data rather than "+ + "by the schema; that is not expressible as a fixed set of generated types, so "+ + "write this resource by hand", + f.CyclicSchema(), + ) default: return false, "" } @@ -349,8 +358,13 @@ func skipField(f Field) (bool, string) { // attributeOf builds one attribute from a merged field. // // inWrite is whether the field appears in the request body, which is what -// decides presence and therefore whether the attribute is ever sent. -func attributeOf(f Field, inWrite bool, sdkPkg string) (blueprint.Attribute, string) { +// decides presence and therefore whether the attribute is ever sent. path is the dotted +// attribute path, so a note about something inside a nested object says where it was. +func (ctx *inferCtx) attributeOf( + f Field, + path string, + inWrite bool, +) (blueprint.Attribute, string) { goField := namingOpts.GoFieldName(f.Name) a := blueprint.Attribute{ @@ -377,13 +391,44 @@ func attributeOf(f Field, inWrite bool, sdkPkg string) (blueprint.Attribute, str a.Type.Enum = append([]string(nil), f.EnumValues...) } + writable := a.ComputedOptionalRequired.IsRequired() || a.ComputedOptionalRequired.IsOptional() + if f.Kind.IsNested() { - // A nested shape needs a model, an attr.Type map and a helper pair, none - // of which can be named without the object's schema name. - return blueprint.Attribute{}, "nested objects are not inferred yet; add it by hand" + n, why := ctx.nestedObject(f, path, writable) + if why != "" { + return blueprint.Attribute{}, why + } + a.Type.NestedObject = n + + // The SDK holds a collection of objects as a slice and a single object behind a + // pointer. That distinction decides the generated helper's signature, so it is + // recorded rather than left for the emitter to guess from the kind. + sdkGoType := "*" + n.SDKType + if f.Kind.IsNestedCollection() { + sdkGoType = "[]" + n.SDKType + } + + a.Wire = blueprint.WireBinding{ + JSONPath: f.Name, + SDKField: goField, + SDKGoType: sdkGoType, + Flatten: &blueprint.ConvertCall{ + Func: n.FlattenFunc, NeedsCtx: true, ReturnsError: true, + }, + } + + if writable { + a.Wire.Expand = &blueprint.ConvertCall{ + Func: n.ExpandFunc, NeedsCtx: true, ReturnsError: true, + } + } else { + a.Wire.SkipExpand = true + } + + return a, "" } - sdkType, convertFlatten, convertExpand := conversionsFor(f, sdkPkg) + sdkType, convertFlatten, convertExpand := conversionsFor(f, ctx.sdkPkg) a.Wire = blueprint.WireBinding{ JSONPath: f.Name, @@ -395,7 +440,7 @@ func attributeOf(f Field, inWrite bool, sdkPkg string) (blueprint.Attribute, str // A computed field is read and never sent. Marking it here is what stops the // generated construct function referring to a request field that may not // exist. - if a.ComputedOptionalRequired.IsRequired() || a.ComputedOptionalRequired.IsOptional() { + if writable { a.Wire.Expand = convertExpand } else { a.Wire.SkipExpand = true diff --git a/internal/ingest/openapi/infer_test.go b/internal/ingest/openapi/infer_test.go index e07b3633..89984058 100644 --- a/internal/ingest/openapi/infer_test.go +++ b/internal/ingest/openapi/infer_test.go @@ -163,7 +163,7 @@ func inferWidget(t *testing.T) (blueprint.Resource, []Note) { func attrByName(t *testing.T, r blueprint.Resource, name string) blueprint.Attribute { t.Helper() - for _, a := range r.Attributes { + for _, a := range r.Schema.Attributes { if a.Name == name { return a } @@ -173,8 +173,8 @@ func attrByName(t *testing.T, r blueprint.Resource, name string) blueprint.Attri } func attrNames(r blueprint.Resource) []string { - out := make([]string, 0, len(r.Attributes)) - for _, a := range r.Attributes { + out := make([]string, 0, len(r.Schema.Attributes)) + for _, a := range r.Schema.Attributes { out = append(out, a.Name) } return out @@ -419,16 +419,20 @@ func TestUnit_Infer_ReportsWhatItSkipped(t *testing.T) { t.Errorf("the hypermedia envelope should be reported as skipped:\n%s", all) } for _, name := range []string{"links", "_links"} { - for _, a := range res.Attributes { + for _, a := range res.Schema.Attributes { if a.Name == name { t.Errorf("%q should not have become an attribute", name) } } } - // NestedAttributeObject objects are a known gap, and must be named rather than dropped. - if !strings.Contains(all, "parts") { - t.Errorf("the nested collection should be reported as skipped:\n%s", all) + // The nested collection is inferred now, so it must not appear in the notes at all. + // It was a reported gap; a note about it would mean the inference silently regressed. + if strings.Contains(all, "parts") { + t.Errorf("parts is inferred and should not be reported as skipped:\n%s", all) + } + if a := attrByName(t, res, "parts"); a.Type.NestedObject == nil { + t.Error("parts should have become a nested attribute") } } @@ -452,7 +456,7 @@ func TestUnit_Infer_NamesFollowTheConventions(t *testing.T) { tests := map[string]string{ "key": res.Key, - "terraformType": res.TerraformType, + "name": res.Name, "goPackage": res.GoPackage, "goPackageAlias": res.GoPackageAlias, "goTypeName": res.GoTypeName, @@ -460,7 +464,7 @@ func TestUnit_Infer_NamesFollowTheConventions(t *testing.T) { } want := map[string]string{ "key": "widget", - "terraformType": "example_widget", + "name": "widget", "goPackage": "widget", "goPackageAlias": "v1Widget", "goTypeName": "WidgetResource", @@ -514,14 +518,14 @@ func TestUnit_Infer_IsDeterministic(t *testing.T) { for i := range 10 { again, _ := inferWidget(t) - if len(again.Attributes) != len(first.Attributes) { + if len(again.Schema.Attributes) != len(first.Schema.Attributes) { t.Fatalf("run %d produced %d attributes, first produced %d", - i, len(again.Attributes), len(first.Attributes)) + i, len(again.Schema.Attributes), len(first.Schema.Attributes)) } - for j := range again.Attributes { - if again.Attributes[j].Name != first.Attributes[j].Name { + for j := range again.Schema.Attributes { + if again.Schema.Attributes[j].Name != first.Schema.Attributes[j].Name { t.Fatalf("run %d differs at %d: %q vs %q", - i, j, again.Attributes[j].Name, first.Attributes[j].Name) + i, j, again.Schema.Attributes[j].Name, first.Schema.Attributes[j].Name) } } } @@ -554,8 +558,8 @@ func TestUnit_Infer_AgainstTheCommittedSpecification(t *testing.T) { } // The mechanical parts must match the curated blueprint exactly. - if res.TerraformType != "thousandeyes_tag" { - t.Errorf("terraformType = %q", res.TerraformType) + if res.Name != "tag" { + t.Errorf("name = %q", res.Name) } if res.Binding.Service.Accessor != "r.client.API.Tags" { t.Errorf("accessor = %q; the pinned SDK groups services under API", res.Binding.Service.Accessor) @@ -575,11 +579,62 @@ func TestUnit_Infer_AgainstTheCommittedSpecification(t *testing.T) { t.Errorf("legacy_id kind = %q, want float64 to match the SDK's *float64", legacy.Type.Kind) } - // Both nested collections are a known gap and must be reported, not dropped. + // Both nested collections are inferred, and their generated identifiers match the + // curated blueprint exactly. That agreement is the point: it says the naming rule is + // the one a person reached for by hand, so a curated blueprint and an inferred draft + // differ only where judgement was applied. + assertNestedIdentifiers(t, res, nestedIdentifiers{ + attr: "assignments", goType: "TagAssignmentModel", sdkType: "tags.Assignment", + attrTypes: "tagAssignmentAttrTypes", objectType: "tagAssignmentObjectType", + expand: "expandTagAssignments", flatten: "flattenTagAssignments", + }) + // TagFilter already carries the resource's name, so the prefix is elided rather than + // doubled into TagTagFilterModel. + assertNestedIdentifiers(t, res, nestedIdentifiers{ + attr: "filters", goType: "TagFilterModel", sdkType: "tags.TagFilter", + attrTypes: "tagFilterAttrTypes", objectType: "tagFilterObjectType", + expand: "expandTagFilters", flatten: "flattenTagFilters", + }) + + // The only thing still reported is the hypermedia envelope. reported := strings.Join(noteStrings(notes), "\n") for _, field := range []string{"assignments", "filters"} { - if !strings.Contains(reported, field) { - t.Errorf("%s should be reported as not inferred:\n%s", field, reported) + if strings.Contains(reported, field) { + t.Errorf("%s is inferred and should not be reported:\n%s", field, reported) + } + } +} + +// nestedIdentifiers is the five generated identifiers a nested object declares, plus the +// SDK type it binds to. +type nestedIdentifiers struct { + attr, goType, sdkType, attrTypes, objectType, expand, flatten string +} + +// assertNestedIdentifiers checks one inferred nested object against the curated blueprint. +// +// Extracted from the caller rather than inlined: the assertions are a flat table, and +// keeping them here is what stops the test that reads the real specification from growing +// a cyclomatic complexity the house linter refuses. +func assertNestedIdentifiers(t *testing.T, res blueprint.Resource, want nestedIdentifiers) { + t.Helper() + + n := attrByName(t, res, want.attr).Type.NestedObject + if n == nil { + t.Errorf("%s should have become a nested attribute", want.attr) + return + } + + for _, got := range []struct{ field, have, want string }{ + {"goTypeName", n.GoTypeName, want.goType}, + {"sdkType", n.SDKType, want.sdkType}, + {"attrTypesVar", n.AttrTypesVar, want.attrTypes}, + {"objectTypeVar", n.ObjectTypeVar, want.objectType}, + {"expandFunc", n.ExpandFunc, want.expand}, + {"flattenFunc", n.FlattenFunc, want.flatten}, + } { + if got.have != got.want { + t.Errorf("%s.%s = %q, want %q", want.attr, got.field, got.have, got.want) } } } diff --git a/internal/ingest/openapi/nested.go b/internal/ingest/openapi/nested.go new file mode 100644 index 00000000..25c2906c --- /dev/null +++ b/internal/ingest/openapi/nested.go @@ -0,0 +1,159 @@ +package openapi + +import ( + "strings" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/blueprint" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/naming" +) + +// inferCtx carries what inferring one resource's attributes needs beyond the field itself. +// +// The taken set is the reason this is a struct rather than more parameters: every nested +// object contributes five package-level identifiers to the same generated package, and +// they have to be unique across the whole resource rather than within one object. Render +// refuses a collision, so resolving it here is the difference between a draft that emits +// and one that does not. +type inferCtx struct { + // resource is the blueprint key, for notes. + resource string + // resourceBase is the resource's Go type stem, e.g. "Tag", used to prefix a nested + // object's generated names. + resourceBase string + // sdkPkg is the SDK package a nested object's Go type lives in, e.g. "tags". + sdkPkg string + + taken map[string]bool + notes []Note +} + +func newInferCtx(resourceKey, sdkPkg string) *inferCtx { + return &inferCtx{ + resource: resourceKey, + resourceBase: namingOpts.GoTypeName(resourceKey), + sdkPkg: sdkPkg, + taken: map[string]bool{}, + } +} + +func (ctx *inferCtx) note(path, msg string) { + ctx.notes = append(ctx.notes, Note{Resource: ctx.resource, Field: path, Message: msg}) +} + +// nestedObject builds the generated shape for one nested object, recursing into it. +// +// writable says whether the enclosing attribute can be written. It is threaded down rather +// than recomputed per level because a field inside a read-only object cannot be written +// however the schema marks it, and an expand conversion on it would name a helper the +// emitter never generates. +func (ctx *inferCtx) nestedObject( + f Field, + path string, + writable bool, +) (*blueprint.NestedAttributeObject, string) { + // Ordinarily unreachable: skipField refuses a cyclic attribute before it gets here, and + // refuses it whole rather than at the cycle point. Kept because this is exported + // behaviour of the type, not of one caller. + if n := f.CyclicSchema(); n != "" { + return nil, "the schema " + n + " contains itself, so its depth is decided by the " + + "data rather than by the schema; write this resource by hand" + } + if len(f.Object) == 0 { + return nil, "the nested object's schema declares no properties, so it would generate " + + "an empty model" + } + + base := ctx.nestedBase(f.ObjectTypeName) + varBase := namingOpts.GoVarName(base) + + // A collection's helpers read better plural: expandTagAssignments over + // expandTagAssignment for a function that takes a set. + helperBase := base + if f.Kind.IsNestedCollection() { + helperBase = pluralise(base) + } + + n := &blueprint.NestedAttributeObject{ + GoTypeName: naming.Unique(ctx.taken, base+"Model"), + SDKType: ctx.sdkPkg + "." + namingOpts.GoTypeName(f.ObjectTypeName), + AttrTypesVar: naming.Unique(ctx.taken, varBase+"AttrTypes"), + ObjectTypeVar: naming.Unique(ctx.taken, varBase+"ObjectType"), + // Both directions are named even when the object is read-only. The name costs + // nothing, blueprint.Validate requires it on a resource, and a later decision to + // make the attribute writable then needs no new identifier. + ExpandFunc: naming.Unique(ctx.taken, "expand"+helperBase), + FlattenFunc: naming.Unique(ctx.taken, "flatten"+helperBase), + } + + for _, child := range f.Object { + cpath := path + "." + child.Name + + if skip, why := skipField(child); skip { + ctx.note(cpath, why) + continue + } + + attr, why := ctx.attributeOf(child, cpath, writable && !child.ReadOnly) + if why != "" { + ctx.note(cpath, why) + continue + } + n.Attributes = append(n.Attributes, attr) + } + + if len(n.Attributes) == 0 { + return nil, "every property of the nested object was skipped, so it would generate an " + + "empty model" + } + + return n, "" +} + +// nestedBase names a nested object's generated types, eliding a repeated prefix. +// +// A schema already named for its resource -- TagFilter inside tag -- would otherwise +// become TagTagFilter. Both forms are unique, so this is a readability rule rather than a +// correctness one, but it is what makes an inferred draft match what a person would have +// written by hand. +func (ctx *inferCtx) nestedBase(objectTypeName string) string { + name := namingOpts.GoTypeName(objectTypeName) + if name == "" { + return ctx.resourceBase + "Object" + } + if strings.HasPrefix(name, ctx.resourceBase) { + return name + } + return ctx.resourceBase + name +} + +// pluralise is the small subset of English plurals that generated identifiers need. +// +// It reads a generated function name, not prose: the point is that expandTagAssignments +// says it takes many where expandTagAssignment would not. An irregular plural comes out +// wrong and a person renames it, which is the same thing they do to every other inferred +// name. +func pluralise(s string) string { + switch { + case s == "": + return s + case hasAnySuffix(s, "s", "x", "z", "ch", "sh"): + return s + "es" + case len(s) > 1 && strings.HasSuffix(s, "y") && !isVowel(s[len(s)-2]): + return s[:len(s)-1] + "ies" + default: + return s + "s" + } +} + +func hasAnySuffix(s string, suffixes ...string) bool { + for _, suffix := range suffixes { + if strings.HasSuffix(s, suffix) { + return true + } + } + return false +} + +func isVowel(b byte) bool { + return strings.IndexByte("aeiouAEIOU", b) >= 0 +} diff --git a/internal/ingest/openapi/nested_test.go b/internal/ingest/openapi/nested_test.go new file mode 100644 index 00000000..8ec14a7a --- /dev/null +++ b/internal/ingest/openapi/nested_test.go @@ -0,0 +1,398 @@ +package openapi + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/blueprint" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/render" +) + +// nestedSpec is a document whose widget carries every nested shape inference has to +// handle: a collection of objects, a single object, an object nested inside an object, a +// scalar collection inside an object, and a schema that contains itself. +const nestedSpec = ` +openapi: 3.0.3 +info: {title: Nested API, version: "1.0"} +paths: + /widgets: + post: + operationId: createWidget + tags: [Widgets] + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/WidgetInfo'} + responses: + "201": + content: + application/json: + schema: {$ref: '#/components/schemas/Widget'} + /widgets/{id}: + get: + operationId: getWidget + tags: [Widgets] + responses: + "200": + content: + application/json: + schema: {$ref: '#/components/schemas/Widget'} + put: + operationId: updateWidget + tags: [Widgets] + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/WidgetInfo'} + responses: + "200": + content: + application/json: + schema: {$ref: '#/components/schemas/Widget'} + delete: + operationId: deleteWidget + tags: [Widgets] + responses: + "204": {description: gone} +components: + schemas: + WidgetInfo: + type: object + required: [name] + properties: + name: {type: string} + parts: + type: array + items: {$ref: '#/components/schemas/Part'} + placement: {$ref: '#/components/schemas/Placement'} + tree: {$ref: '#/components/schemas/Node'} + Widget: + type: object + properties: + id: {type: string, readOnly: true} + name: {type: string} + parts: + type: array + items: {$ref: '#/components/schemas/Part'} + placement: {$ref: '#/components/schemas/Placement'} + tree: {$ref: '#/components/schemas/Node'} + audit: + type: array + readOnly: true + items: {$ref: '#/components/schemas/AuditEntry'} + Part: + type: object + required: [sku] + properties: + sku: {type: string} + tags: + type: array + items: {type: string} + origin: {$ref: '#/components/schemas/Origin'} + Origin: + type: object + properties: + country: {type: string} + Placement: + type: object + properties: + row: {type: integer} + AuditEntry: + type: object + properties: + at: {type: string} + Node: + type: object + properties: + label: {type: string} + children: + type: array + items: {$ref: '#/components/schemas/Node'} +` + +func inferNested(t *testing.T) (blueprint.Resource, []Note) { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "api.yaml") + if err := os.WriteFile(path, []byte(nestedSpec), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + doc, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + res, notes, err := doc.Infer(find(t, doc.Discover(), "widget"), inferOptions()) + if err != nil { + t.Fatalf("Infer: %v", err) + } + return res, notes +} + +// TestUnit_Infer_NestedCollectionAndSingleObject covers the two nested kinds and the SDK +// type each implies. +// +// The SDK holds many objects as a slice and one behind a pointer. That decides the +// generated helper's signature, so an inferred draft that got it the wrong way round +// would produce a helper that does not compile against its own model. +func TestUnit_Infer_NestedCollectionAndSingleObject(t *testing.T) { + t.Parallel() + + res, _ := inferNested(t) + + parts := attrByName(t, res, "parts") + if parts.Type.Kind != blueprint.KindSetNested { + t.Errorf("parts kind = %q, want set_nested", parts.Type.Kind) + } + if got := parts.Wire.SDKGoType; got != "[]widgets.Part" { + t.Errorf("parts sdkGoType = %q, want []widgets.Part", got) + } + if n := parts.Type.NestedObject; n == nil || n.SDKType != "widgets.Part" { + t.Errorf("parts sdkType = %+v, want widgets.Part", n) + } + + placement := attrByName(t, res, "placement") + if placement.Type.Kind != blueprint.KindSingleNested { + t.Errorf("placement kind = %q, want single_nested", placement.Type.Kind) + } + if got := placement.Wire.SDKGoType; got != "*widgets.Placement" { + t.Errorf("placement sdkGoType = %q, want *widgets.Placement", got) + } + + // A collection's helpers read plural, a single object's singular. + if got := parts.Type.NestedObject.ExpandFunc; got != "expandWidgetParts" { + t.Errorf("parts expandFunc = %q, want expandWidgetParts", got) + } + if got := placement.Type.NestedObject.ExpandFunc; got != "expandWidgetPlacement" { + t.Errorf("placement expandFunc = %q, want expandWidgetPlacement", got) + } +} + +// TestUnit_Infer_NestedObjectsRecurse checks that inference descends, not just that it +// stops refusing. +func TestUnit_Infer_NestedObjectsRecurse(t *testing.T) { + t.Parallel() + + res, _ := inferNested(t) + + part := attrByName(t, res, "parts").Type.NestedObject + if part == nil { + t.Fatal("parts has no nested object") + } + + var origin, tags *blueprint.Attribute + for i := range part.Attributes { + switch part.Attributes[i].Name { + case "origin": + origin = &part.Attributes[i] + case "tags": + tags = &part.Attributes[i] + } + } + + // An object inside an object: level two, which the emitter now generates. + if origin == nil || origin.Type.NestedObject == nil { + t.Fatalf("parts.origin should be a nested object: %+v", origin) + } + if got := origin.Type.NestedObject.GoTypeName; got != "WidgetOriginModel" { + t.Errorf("parts.origin goTypeName = %q, want WidgetOriginModel", got) + } + + // And a scalar collection inside an object still maps to a set with an element type. + if tags == nil || tags.Type.Kind != blueprint.KindSet { + t.Fatalf("parts.tags should be a set: %+v", tags) + } + if tags.Type.ElementType == nil || tags.Type.ElementType.Kind != blueprint.KindString { + t.Errorf("parts.tags element type = %+v, want string", tags.Type.ElementType) + } +} + +// TestUnit_Infer_ReadOnlyNestedObjectSendsNothing checks the writability thread. +// +// A field inside a read-only object cannot be written however its own schema marks it, and +// an expand conversion on it would name a helper the emitter never generates -- construct +// skips a shape whose attribute is SkipExpand. +func TestUnit_Infer_ReadOnlyNestedObjectSendsNothing(t *testing.T) { + t.Parallel() + + res, _ := inferNested(t) + + audit := attrByName(t, res, "audit") + if !audit.Wire.SkipExpand { + t.Error("a read-only nested collection must not be expanded") + } + if audit.ComputedOptionalRequired != blueprint.Computed { + t.Errorf("audit presence = %q, want computed", audit.ComputedOptionalRequired) + } + + for _, child := range audit.Type.NestedObject.Attributes { + if child.Wire.Expand != nil { + t.Errorf("audit.%s must not carry an expand: its parent is never sent", child.Name) + } + if !child.Wire.SkipExpand { + t.Errorf("audit.%s should be marked skipExpand", child.Name) + } + if child.ComputedOptionalRequired != blueprint.Computed { + t.Errorf("audit.%s presence = %q, want computed", child.Name, + child.ComputedOptionalRequired) + } + } + + // The writable sibling is the control: its children do carry expands. + parts := attrByName(t, res, "parts") + if parts.Wire.Expand == nil { + t.Fatal("parts is writable and should carry an expand") + } + sku := parts.Type.NestedObject.Attributes[0] + if sku.Wire.Expand == nil { + t.Errorf("parts.%s should carry an expand", sku.Name) + } +} + +// TestUnit_Infer_SelfReferentialSchemaIsRefusedByName is the case that would otherwise +// recurse forever. +// +// A schema containing itself has a depth decided by the data, not the schema, so it cannot +// become a fixed set of generated types. This is the shape ms365's settings catalogue has, +// and the message says to write that resource by hand rather than implying a flatter form +// would do. +func TestUnit_Infer_SelfReferentialSchemaIsRefusedByName(t *testing.T) { + t.Parallel() + + res, notes := inferNested(t) + + for _, a := range res.Schema.Attributes { + if a.Name == "tree" { + t.Error("a self-referential schema must not become an attribute") + } + } + + all := strings.Join(noteStrings(notes), "\n") + if !strings.Contains(all, "tree") { + t.Errorf("the self-referential field should be named:\n%s", all) + } + if !strings.Contains(all, "contains itself") { + t.Errorf("the note should say why:\n%s", all) + } + if !strings.Contains(all, "by hand") { + t.Errorf("the note should say what to do instead:\n%s", all) + } +} + +// TestUnit_Infer_NestedIdentifiersAreUnique guards the collision arbitrary depth makes +// likely. +// +// Every nested object contributes five package-level identifiers to one generated package. +// Render refuses a repeat, so resolving it here is the difference between a draft that +// emits and one that does not. +func TestUnit_Infer_NestedIdentifiersAreUnique(t *testing.T) { + t.Parallel() + + res, _ := inferNested(t) + + seen := map[string]string{} + + var walk func(attrs []blueprint.Attribute, path string) + walk = func(attrs []blueprint.Attribute, path string) { + for _, a := range attrs { + n := a.Type.NestedObject + if n == nil { + continue + } + at := path + a.Name + for _, id := range []string{ + n.GoTypeName, n.AttrTypesVar, n.ObjectTypeVar, n.ExpandFunc, n.FlattenFunc, + } { + if id == "" { + t.Errorf("%s: a nested object left a generated identifier empty: %+v", at, n) + continue + } + if first, dup := seen[id]; dup { + t.Errorf("%s reuses identifier %q, already used by %s", at, id, first) + } + seen[id] = at + } + walk(n.Attributes, at+".") + } + } + walk(res.Schema.Attributes, "") +} + +// TestUnit_Infer_InferredNestedBlueprintRenders is the end-to-end assertion. +// +// Identifiers agreeing with the curated blueprint is reassuring but not proof: what matters +// is that a wholly inferred nested shape passes validation and reaches the renderer, which +// is where a missing helper name or a mismatched SDK type actually bites. The pilot's +// provider block supplies the parts inference does not invent. +func TestUnit_Infer_InferredNestedBlueprintRenders(t *testing.T) { + t.Parallel() + + res, _ := inferNested(t) + + bp := blueprint.Blueprint{ + FormatVersion: blueprint.FormatVersion, + Provider: blueprint.Provider{ + Name: "example", + TypePrefix: "example", + GoModule: "example.com/provider", + SDK: blueprint.SDKModule{ + Dialect: blueprint.DialectRestyService, + ModulePath: "example.com/sdk", + ClientType: "*sdk.Client", + }, + }, + Resources: []blueprint.Resource{res}, + } + + if err := bp.Validate(); err != nil { + t.Fatalf("an inferred blueprint must validate: %v", err) + } + + v, err := render.Resource(bp, res, render.Options{BlueprintPath: "b", BlueprintSHA256: "s"}) + if err != nil { + t.Fatalf("an inferred blueprint must render: %v", err) + } + + // One model per nested object, at every depth: Part, Origin, Placement, AuditEntry. + want := []string{ + "WidgetPartModel", "WidgetOriginModel", "WidgetPlacementModel", "WidgetAuditEntryModel", + } + got := map[string]bool{} + for _, nm := range v.NestedModels { + got[nm.GoTypeName] = true + } + for _, w := range want { + if !got[w] { + t.Errorf("no generated model %q; got %v", w, got) + } + } +} + +func TestUnit_Infer_Pluralise(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "Part": "Parts", + "Assignment": "Assignments", + "TagFilter": "TagFilters", + // A trailing s, x, z or a ch/sh cluster takes es, so the identifier does not read + // as a typo. + "Address": "Addresses", + "Box": "Boxes", + "Match": "Matches", + // Consonant plus y becomes ies; a vowel plus y does not. + "Policy": "Policies", + "Key": "Keys", + "": "", + } + + for in, want := range tests { + if got := pluralise(in); got != want { + t.Errorf("pluralise(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/ingest/openapi/schema.go b/internal/ingest/openapi/schema.go index 02de9351..d1ca3d35 100644 --- a/internal/ingest/openapi/schema.go +++ b/internal/ingest/openapi/schema.go @@ -45,11 +45,39 @@ type Field struct { ObjectTypeName string // EnumValues are the documented members, used for documentation only. EnumValues []string + + // Object holds a nested object's own fields, resolved recursively, so a shape + // several levels down arrives whole rather than as a name to look up later. + Object []Field + // SelfReferential marks a nested object that reaches itself. Its depth is decided by + // the data rather than by the schema, so it is not expressible as a fixed set of + // generated types -- inference reports it by name instead of recursing forever. + SelfReferential bool } // IsEnum reports whether the field resolved to a named enumeration. func (f Field) IsEnum() bool { return f.EnumTypeName != "" } +// CyclicSchema returns the name of the schema that re-enters itself, anywhere beneath this +// field, or empty. +// +// It looks all the way down rather than only at this field, and the caller refuses the +// whole attribute on the strength of it. Refusing only the cycle point would leave the +// enclosing object in place minus its recursive dimension -- a tree attribute offering a +// label and no children, which looks usable and cannot express the shape it is named for. +// That is the failure mode this package exists to avoid. +func (f Field) CyclicSchema() string { + if f.SelfReferential { + return f.ObjectTypeName + } + for _, c := range f.Object { + if n := c.CyclicSchema(); n != "" { + return n + } + } + return "" +} + // jsonContentTypes are the media types a JSON body may be declared as. // // The ThousandEyes API serves application/hal+json rather than @@ -108,7 +136,8 @@ func schemaFromContent(content *orderedmap.Map[string, *v3.MediaType]) *base.Sch // Fall back to any media type whose name looks like JSON, so an API using a // vendor content type is not silently skipped. for pair := content.First(); pair != nil; pair = pair.Next() { - if strings.Contains(pair.Key(), "json") && pair.Value() != nil && pair.Value().Schema != nil { + if strings.Contains(pair.Key(), "json") && pair.Value() != nil && + pair.Value().Schema != nil { return resolve(pair.Value().Schema) } } @@ -127,7 +156,8 @@ func proxyFromContent(content *orderedmap.Map[string, *v3.MediaType]) *base.Sche } } for pair := content.First(); pair != nil; pair = pair.Next() { - if strings.Contains(pair.Key(), "json") && pair.Value() != nil && pair.Value().Schema != nil { + if strings.Contains(pair.Key(), "json") && pair.Value() != nil && + pair.Value().Schema != nil { return pair.Value().Schema } } @@ -150,6 +180,16 @@ func resolve(p *base.SchemaProxy) *base.Schema { // produces a resource that can express only part of the API -- inference reports // what it skipped instead. func Fields(s *base.Schema) []Field { + return fieldsWithin(s, nil) +} + +// fieldsWithin is Fields, carrying the chain of object schema names already entered. +// +// The chain is what stops a self-referential schema recursing forever. It is a path +// rather than a set of everything seen: the same object appearing twice in different +// branches is ordinary reuse, and only re-entering one still on the current path is a +// cycle. +func fieldsWithin(s *base.Schema, path []string) []Field { if s == nil { return nil } @@ -167,14 +207,12 @@ func Fields(s *base.Schema) []Field { if m == nil { continue } - for _, f := range Fields(m) { - out = append(out, f) - } + out = append(out, fieldsWithin(m, path)...) } if s.Properties != nil { for pair := s.Properties.First(); pair != nil; pair = pair.Next() { - f, ok := fieldOf(pair.Key(), pair.Value()) + f, ok := fieldOf(pair.Key(), pair.Value(), path) if !ok { continue } @@ -208,7 +246,7 @@ func replaceOrAppend(in []Field, f Field) []Field { // reports it. // // It returns false only when there is no schema at all to look at. -func fieldOf(name string, proxy *base.SchemaProxy) (Field, bool) { +func fieldOf(name string, proxy *base.SchemaProxy, path []string) (Field, bool) { s := resolve(proxy) if s == nil { return Field{}, false @@ -243,9 +281,41 @@ func fieldOf(name string, proxy *base.SchemaProxy) (Field, bool) { f.ObjectTypeName = itemTypeName(s) } + if kind.IsNested() { + f.Object, f.SelfReferential = nestedFields(s, f.ObjectTypeName, path) + } + return f, true } +// nestedFields resolves a nested object's own fields, one level of recursion at a time. +// +// The object schema for a collection is its item schema; for a single nested object it is +// the schema itself. Re-entering a name already on the path is a cycle, reported rather +// than followed. +func nestedFields(s *base.Schema, typeName string, path []string) (fields []Field, cyclic bool) { + obj := s + if primaryType(s) == "array" { + obj = itemSchema(s) + } + if obj == nil { + return nil, false + } + + // An inline object has no name to key the cycle check on. That is not a gap: without + // a $ref it cannot refer back to anything, so it cannot be part of a cycle. + if typeName != "" { + for _, entered := range path { + if entered == typeName { + return nil, true + } + } + path = append(append([]string(nil), path...), typeName) + } + + return fieldsWithin(obj, path), false +} + // kindOf maps an OpenAPI type onto a framework type. // // The mapping follows the rules HashiCorp's own OpenAPI generator documents, so diff --git a/internal/interop/coverage_test.go b/internal/interop/coverage_test.go index e07c59d3..ac1ea713 100644 --- a/internal/interop/coverage_test.go +++ b/internal/interop/coverage_test.go @@ -22,9 +22,11 @@ func bp(attrs ...blueprint.Attribute) blueprint.Blueprint { TypePrefix: "example", }, Resources: []blueprint.Resource{{ - Key: "thing", - TerraformType: "example_thing", - Attributes: attrs, + Key: "thing", + Name: "thing", + Schema: blueprint.Schema{ + Attributes: attrs, + }, }}, } } @@ -552,7 +554,9 @@ func TestUnit_Interop_DataSourceLosesModifiersAndDefaults(t *testing.T) { in := bp() in.Resources = nil in.DataSources = []blueprint.DataSource{{ - Key: "thing", TerraformType: "example_thing", Attributes: []blueprint.Attribute{a}, + Key: "thing", Name: "thing", Schema: blueprint.Schema{ + Attributes: []blueprint.Attribute{a}, + }, }} s, report, err := FromBlueprint(in) @@ -615,7 +619,7 @@ func TestUnit_Interop_DropIsCountedNotSilent(t *testing.T) { // A dropped resource, and a dropped data source, take the same path. in := bp(keep) in.Resources[0].Drop = true - in.DataSources = []blueprint.DataSource{{Key: "d", TerraformType: "example_d", Drop: true}} + in.DataSources = []blueprint.DataSource{{Key: "d", Name: "d", Drop: true}} _, report, err = FromBlueprint(in) if err != nil { @@ -626,30 +630,6 @@ func TestUnit_Interop_DropIsCountedNotSilent(t *testing.T) { } } -func TestUnit_Interop_ShortName(t *testing.T) { - t.Parallel() - - tests := []struct { - terraformType, prefix, provider, want string - }{ - {"thousandeyes_tag", "thousandeyes", "thousandeyes", "tag"}, - // TypePrefix empty is legal and means the provider name. - {"thousandeyes_tag", "", "thousandeyes", "tag"}, - // A prefix that does not match leaves the name alone rather than mangling it. - {"tag", "thousandeyes", "thousandeyes", "tag"}, - {"other_tag", "thousandeyes", "thousandeyes", "other_tag"}, - {"thousandeyes_test_http_server", "thousandeyes", "thousandeyes", "test_http_server"}, - } - - for _, tc := range tests { - got := shortName(tc.terraformType, tc.prefix, tc.provider) - if got != tc.want { - t.Errorf("shortName(%q, %q, %q) = %q, want %q", - tc.terraformType, tc.prefix, tc.provider, got, tc.want) - } - } -} - func TestUnit_Interop_ReportRendering(t *testing.T) { t.Parallel() @@ -816,12 +796,16 @@ func TestUnit_Interop_ResourcesAreSortedByExportedName(t *testing.T) { in := bp(attr("f", blueprint.KindString, blueprint.Optional)) in.Resources = []blueprint.Resource{ { - Key: "aaa", TerraformType: "example_zebra", - Attributes: []blueprint.Attribute{attr("f", blueprint.KindString, blueprint.Optional)}, + Key: "aaa", Name: "zebra", + Schema: blueprint.Schema{ + Attributes: []blueprint.Attribute{attr("f", blueprint.KindString, blueprint.Optional)}, + }, }, { - Key: "zzz", TerraformType: "example_antelope", - Attributes: []blueprint.Attribute{attr("f", blueprint.KindString, blueprint.Optional)}, + Key: "zzz", Name: "antelope", + Schema: blueprint.Schema{ + Attributes: []blueprint.Attribute{attr("f", blueprint.KindString, blueprint.Optional)}, + }, }, } diff --git a/internal/interop/export.go b/internal/interop/export.go index 4bb5f53d..9e8cd59f 100644 --- a/internal/interop/export.go +++ b/internal/interop/export.go @@ -3,7 +3,6 @@ package interop import ( "fmt" "sort" - "strings" "github.com/hashicorp/terraform-plugin-codegen-spec/datasource" "github.com/hashicorp/terraform-plugin-codegen-spec/provider" @@ -40,7 +39,7 @@ func FromBlueprint(bp blueprint.Blueprint) (spec.Specification, Report, error) { continue } - converted, err := exportResource(bp, res, &r) + converted, err := exportResource(res, &r) if err != nil { return spec.Specification{}, r, err } @@ -55,7 +54,7 @@ func FromBlueprint(bp blueprint.Blueprint) (spec.Specification, Report, error) { continue } - converted, err := exportDataSource(bp, ds, &r) + converted, err := exportDataSource(ds, &r) if err != nil { return spec.Specification{}, r, err } @@ -65,13 +64,19 @@ func FromBlueprint(bp blueprint.Blueprint) (spec.Specification, Report, error) { } // Sorted by the name the official document carries, not by blueprint key. - // LoadDir sorts by Key, and Key need not sort the same way as TerraformType -- + // LoadDir sorts by Key, and Key need not sort the same way as Name -- // a resource keyed "tag" and typed "thousandeyes_tag" is the common case, but // nothing enforces the correspondence. Sorting on the field that actually // appears in the output is what makes the export independent of how the // blueprint happens to be split across files. - sort.Slice(out.Resources, func(i, j int) bool { return out.Resources[i].Name < out.Resources[j].Name }) - sort.Slice(out.DataSources, func(i, j int) bool { return out.DataSources[i].Name < out.DataSources[j].Name }) + sort.Slice( + out.Resources, + func(i, j int) bool { return out.Resources[i].Name < out.Resources[j].Name }, + ) + sort.Slice( + out.DataSources, + func(i, j int) bool { return out.DataSources[i].Name < out.DataSources[j].Name }, + ) return out, r, nil } @@ -106,18 +111,18 @@ func exportProvider(p blueprint.Provider, r *Report) *provider.Provider { return &provider.Provider{Name: p.Name} } -func exportResource(bp blueprint.Blueprint, res blueprint.Resource, r *Report) (resource.Resource, error) { +func exportResource(res blueprint.Resource, r *Report) (resource.Resource, error) { path := fmt.Sprintf("resources[%s]", res.Key) out := resource.Resource{ - Name: shortName(res.TerraformType, bp.Provider.TypePrefix, bp.Provider.Name), + Name: res.Name, Schema: &resource.Schema{ // Only MarkdownDescription is set. The schema carries both it and a // plain Description, and writing the same text into both would double // the size of the block for no reader: every consumer that renders one // falls back to the other. - MarkdownDescription: strPtr(res.MarkdownDescription), - DeprecationMessage: strPtr(res.DeprecationMessage), + MarkdownDescription: strPtr(res.Schema.MarkdownDescription), + DeprecationMessage: strPtr(res.Schema.DeprecationMessage), }, } @@ -128,7 +133,7 @@ func exportResource(bp blueprint.Blueprint, res blueprint.Resource, r *Report) ( described int ) - for _, a := range res.Attributes { + for _, a := range res.Schema.Attributes { if a.Drop { r.Omitted++ continue @@ -153,7 +158,11 @@ func exportResource(bp blueprint.Blueprint, res blueprint.Resource, r *Report) ( } if len(out.Schema.Attributes) == 0 { - return resource.Resource{}, fmt.Errorf("%s: %w: a resource with no attributes", path, ErrUnrepresentable) + return resource.Resource{}, fmt.Errorf( + "%s: %w: a resource with no attributes", + path, + ErrUnrepresentable, + ) } // The aggregate notes, each with its count so the reader can see the scale of @@ -207,19 +216,43 @@ func reportResourceLosses(res blueprint.Resource, path string, r *Report) { } } -func exportDataSource(bp blueprint.Blueprint, ds blueprint.DataSource, r *Report) (datasource.DataSource, error) { +// reportDataSourceLosses is the data source counterpart of reportResourceLosses. +// +// A data source's binding is smaller than a resource's -- one operation, a service +// reference and a response model -- but the format has no counterpart for any of it, so +// dropping it silently would leave a reader believing the exported document could be +// emitted from. It cannot. +func reportDataSourceLosses(ds blueprint.DataSource, path string, r *Report) { + r.note("naming", path+".naming") + r.note("binding", path+".binding") + + if ds.Timeouts != (blueprint.Timeouts{}) { + r.note("timeouts", path+".timeouts") + } + if ds.DocRefURL != "" { + r.note("docRefUrl", path+".docRefUrl") + } +} + +func exportDataSource(ds blueprint.DataSource, r *Report) (datasource.DataSource, error) { path := fmt.Sprintf("dataSources[%s]", ds.Key) out := datasource.DataSource{ - Name: shortName(ds.TerraformType, bp.Provider.TypePrefix, bp.Provider.Name), - Schema: &datasource.Schema{}, + Name: ds.Name, + Schema: &datasource.Schema{ + // Only MarkdownDescription is set, for the same reason as a resource: the + // schema carries both it and a plain Description, and every consumer that + // renders one falls back to the other. + MarkdownDescription: strPtr(ds.Schema.MarkdownDescription), + DeprecationMessage: strPtr(ds.Schema.DeprecationMessage), + }, } - r.note("naming", path+".naming") + reportDataSourceLosses(ds, path, r) var acc attrLosses - for _, a := range ds.Attributes { + for _, a := range ds.Schema.Attributes { if a.Drop { r.Omitted++ continue @@ -240,7 +273,11 @@ func exportDataSource(bp blueprint.Blueprint, ds blueprint.DataSource, r *Report } if len(out.Schema.Attributes) == 0 { - return datasource.DataSource{}, fmt.Errorf("%s: %w: a data source with no attributes", path, ErrUnrepresentable) + return datasource.DataSource{}, fmt.Errorf( + "%s: %w: a data source with no attributes", + path, + ErrUnrepresentable, + ) } if acc.wire > 0 { @@ -252,23 +289,3 @@ func exportDataSource(bp blueprint.Blueprint, ds blueprint.DataSource, r *Report return out, nil } - -// shortName strips the provider prefix from a Terraform type. -// -// The official format's resource name is the suffix -- "tag", not -// "thousandeyes_tag" -- because the provider name is already stated once at the top -// of the document. Both the configured prefix and the provider name are tried, in -// that order, since a blueprint may legitimately leave TypePrefix empty and mean -// Name. -func shortName(terraformType, typePrefix, providerName string) string { - for _, prefix := range []string{typePrefix, providerName} { - if prefix == "" { - continue - } - if trimmed := strings.TrimPrefix(terraformType, prefix+"_"); trimmed != terraformType { - return trimmed - } - } - - return terraformType -} diff --git a/internal/interop/import.go b/internal/interop/import.go index 3f46ec77..40364c35 100644 --- a/internal/interop/import.go +++ b/internal/interop/import.go @@ -162,19 +162,21 @@ func importResource(res resource.Resource, opts Options, r *Report) (blueprint.R out := blueprint.Resource{ Key: res.Name, - TerraformType: naming.TerraformTypeName(opts.typePrefix(), res.Name), + Name: res.Name, GoPackage: naming.SnakeDirName(res.Name), GoTypeName: namingOpts.GoTypeName(res.Name) + "Resource", ModelTypeName: namingOpts.GoTypeName(res.Name) + "ResourceModel", ServiceGroup: opts.ServiceGroup, APIVersionDir: opts.APIVersionDir, - MarkdownDescription: describe( - res.Schema.MarkdownDescription, - res.Schema.Description, - &resourceDesc, - ), - DeprecationMessage: derefStr(res.Schema.DeprecationMessage), + Schema: blueprint.Schema{ + MarkdownDescription: describe( + res.Schema.MarkdownDescription, + res.Schema.Description, + &resourceDesc, + ), + DeprecationMessage: derefStr(res.Schema.DeprecationMessage), + }, } out.GoPackageAlias = namingOpts.PackageAlias(opts.APIVersionDir, res.Name) @@ -202,7 +204,7 @@ func importResource(res resource.Resource, opts Options, r *Report) (blueprint.R if err != nil { return blueprint.Resource{}, err } - out.Attributes = attrs + out.Schema.Attributes = attrs if acc.promoted > 0 { r.noteCount("importedDescription", path+".attributes[*].description", acc.promoted) @@ -240,7 +242,7 @@ func Unauthored(bp blueprint.Blueprint) []string { wire, sdkTypes := 0, 0 - for _, a := range res.Attributes { + for _, a := range res.Schema.Attributes { if a.Wire == (blueprint.WireBinding{}) { wire++ } diff --git a/internal/interop/import_test.go b/internal/interop/import_test.go index 64adfa36..cd5cb94a 100644 --- a/internal/interop/import_test.go +++ b/internal/interop/import_test.go @@ -91,9 +91,9 @@ type attrSlice struct { func schemaSliceOf(r blueprint.Resource) schemaSlice { return schemaSlice{ - MarkdownDescription: r.MarkdownDescription, - DeprecationMessage: r.DeprecationMessage, - Attributes: attrSlicesOf(r.Attributes), + MarkdownDescription: r.Schema.MarkdownDescription, + DeprecationMessage: r.Schema.DeprecationMessage, + Attributes: attrSlicesOf(r.Schema.Attributes), } } @@ -205,7 +205,7 @@ func TestUnit_Interop_RoundtripStaticDefaults(t *testing.T) { t.Fatalf("ToBlueprint: %v", err) } - got := back.Resources[0].Attributes[0].Default + got := back.Resources[0].Schema.Attributes[0].Default if got == nil || got.Static == nil { t.Fatalf("the static default did not come back: %+v", got) } @@ -294,8 +294,8 @@ func TestUnit_Interop_ImportRequiresAProviderName(t *testing.T) { if err != nil { t.Fatalf("ToBlueprint: %v", err) } - if got := back.Resources[0].TerraformType; got != "example_thing" { - t.Errorf("TerraformType = %q, want example_thing", got) + if got := back.Resources[0].Name; got != "thing" { + t.Errorf("Name = %q, want thing", got) } if got := back.Provider.TypePrefix; got != "example" { t.Errorf("TypePrefix = %q, want example", got) @@ -500,7 +500,7 @@ func TestUnit_Interop_ImportConvertsBlocks(t *testing.T) { t.Fatalf("ToBlueprint: %v", err) } - attrs := back.Resources[0].Attributes + attrs := back.Resources[0].Schema.Attributes if len(attrs) != 4 { t.Fatalf("got %d attributes, want 4 (one attribute plus three converted blocks)", len(attrs)) } @@ -554,7 +554,7 @@ func TestUnit_Interop_ImportPromotesAPlainDescription(t *testing.T) { t.Fatalf("ToBlueprint: %v", err) } - if got := back.Resources[0].Attributes[0].MarkdownDescription; got != plain { + if got := back.Resources[0].Schema.Attributes[0].MarkdownDescription; got != plain { t.Errorf("MarkdownDescription = %q, want %q", got, plain) } @@ -788,7 +788,7 @@ func TestUnit_Interop_ImportDefaultsCarryBothForms(t *testing.T) { wantRaw := map[string]string{"b": "true", "s": `"x"`, "i": "3", "f": "2.5"} - for _, a := range back.Resources[0].Attributes { + for _, a := range back.Resources[0].Schema.Attributes { if a.Default == nil { t.Errorf("%s: lost its default", a.Name) continue @@ -828,7 +828,7 @@ func TestUnit_Interop_ImportSkipsNilValidators(t *testing.T) { t.Fatalf("ToBlueprint: %v", err) } - a := back.Resources[0].Attributes[0] + a := back.Resources[0].Schema.Attributes[0] if len(a.Validators) != 0 || len(a.PlanModifiers) != 0 { t.Errorf("an empty entry should be skipped, got %d validators and %d modifiers", len(a.Validators), len(a.PlanModifiers)) @@ -856,7 +856,7 @@ func TestUnit_Interop_ImportCollectionsOfCollections(t *testing.T) { t.Fatalf("ToBlueprint: %v", err) } - got := back.Resources[0].Attributes[0].Type + got := back.Resources[0].Schema.Attributes[0].Type if got.Kind != blueprint.KindList || got.ElementType == nil || got.ElementType.Kind != blueprint.KindSet || got.ElementType.ElementType == nil || got.ElementType.ElementType.Kind != blueprint.KindMap || @@ -893,7 +893,7 @@ func TestUnit_Interop_ImportEveryScalarElementType(t *testing.T) { t.Fatalf("ToBlueprint: %v", err) } - if got := back.Resources[0].Attributes[0].Type.ElementType.Kind; got != want { + if got := back.Resources[0].Schema.Attributes[0].Type.ElementType.Kind; got != want { t.Errorf("element kind = %q, want %q", got, want) } }) diff --git a/internal/interop/kinds_test.go b/internal/interop/kinds_test.go index 083cd9f6..0fc23518 100644 --- a/internal/interop/kinds_test.go +++ b/internal/interop/kinds_test.go @@ -181,7 +181,9 @@ func TestUnit_Interop_EveryKindExportsAsADataSource(t *testing.T) { in := bp() in.Resources = nil in.DataSources = []blueprint.DataSource{{ - Key: "thing", TerraformType: "example_thing", Attributes: attrs, + Key: "thing", Name: "thing", Schema: blueprint.Schema{ + Attributes: attrs, + }, }} s, report, err := FromBlueprint(in) diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 296ca6ce..e93f6ac3 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -263,11 +263,18 @@ func (o Options) GoFieldName(s string) string { return exported(SplitWords(s), o.initialisms()) } +// fallbackIdentifier is what an empty name becomes. +// +// Both places that need it would otherwise repeat the literal, and they have to agree: +// SafeIdentifier and Unique are routinely composed, so a divergence would produce two +// different names for the same nothing. +const fallbackIdentifier = "value" + // SafeIdentifier returns an identifier safe to use as a local variable or // parameter, suffixing anything reserved rather than silently shadowing it. func SafeIdentifier(s string) string { if s == "" { - return "value" + return fallbackIdentifier } if IsReserved(s) { return s + "Value" @@ -314,6 +321,22 @@ func TerraformTypeName(providerPrefix string, parts ...string) string { // ("v7", "tag") -> "v7Tag" // ("v7", "http_server_test") -> "v7HTTPServerTest" func (o Options) PackageAlias(parts ...string) string { + if out := o.GoVarName(parts...); out != "" { + return out + } + return "pkg" +} + +// GoVarName builds a lowerCamel identifier from parts, or empty if there is nothing to +// build one from. +// +// Package-level vars in generated code use it as well as import aliases: a nested +// object's attr.Type map and object type are declared at package scope and need the same +// spelling rule. +// +// ("tag", "assignment") -> "tagAssignment" +// ("v7", "tag") -> "v7Tag" +func (o Options) GoVarName(parts ...string) string { var b strings.Builder for i, p := range parts { @@ -331,9 +354,9 @@ func (o Options) PackageAlias(parts ...string) string { out := b.String() if out == "" { - return "pkg" + return "" } - // An alias colliding with a keyword or an imported package name would break + // An identifier colliding with a keyword or an imported package name would break // every file that used it. return SafeIdentifier(out) } @@ -361,7 +384,7 @@ func lowerFirst(s string) string { // it at the naming layer removes the entire class of bug. func Unique(taken map[string]bool, want string) string { if want == "" { - want = "value" + want = fallbackIdentifier } if !taken[want] { taken[want] = true diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index 2fa8496f..f66298dd 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -442,3 +442,37 @@ func TestUnit_Naming_TestNames(t *testing.T) { t.Errorf("AccTestName = %q", got) } } + +// TestUnit_Naming_GoVarName covers the package-level var spelling generated nested objects +// need, which is the same rule as an import alias but a distinct promise. +func TestUnit_Naming_GoVarName(t *testing.T) { + t.Parallel() + + o := Options{StripPrefix: DefaultStripPrefix} + + tests := []struct { + parts []string + want string + }{ + {[]string{"tag", "assignment"}, "tagAssignment"}, + {[]string{"TagFilter"}, "tagFilter"}, + {[]string{"v7", "tag"}, "v7Tag"}, + // An acronym run is left alone: lowering only the first rune of "ID" reads as a typo. + {[]string{"ID", "map"}, "IDMap"}, + // Nothing to build from returns empty, which is how PackageAlias knows to fall back. + {nil, ""}, + {[]string{""}, ""}, + } + + for _, tc := range tests { + if got := o.GoVarName(tc.parts...); got != tc.want { + t.Errorf("GoVarName(%q) = %q, want %q", tc.parts, got, tc.want) + } + } + + // PackageAlias still substitutes its own fallback rather than returning empty, because + // an empty import alias would not compile. + if got := o.PackageAlias(); got != "pkg" { + t.Errorf("PackageAlias() = %q, want pkg", got) + } +} diff --git a/internal/probe/ledger_test.go b/internal/probe/ledger_test.go index 49cfbaab..72a4edc4 100644 --- a/internal/probe/ledger_test.go +++ b/internal/probe/ledger_test.go @@ -341,8 +341,10 @@ func TestUnit_Probe_DirtyErrorNamesTheFix(t *testing.T) { } msg := err.Error() - for _, want := range []string{"2 object(s)", "42", "identifier never recorded", - "-mode sweep", "-resource tag", "/tmp/x/ledger.jsonl"} { + for _, want := range []string{ + "2 object(s)", "42", "identifier never recorded", + "-mode sweep", "-resource tag", "/tmp/x/ledger.jsonl", + } { if !strings.Contains(msg, want) { t.Errorf("the message omits %q:\n%s", want, msg) } diff --git a/internal/probe/probes_write_test.go b/internal/probe/probes_write_test.go index ad74b8a1..f92676e7 100644 --- a/internal/probe/probes_write_test.go +++ b/internal/probe/probes_write_test.go @@ -1082,14 +1082,22 @@ func TestUnit_Probe_TheFiveOpenPilotGuessesAreSettled(t *testing.T) { subj := quirkSubject() subj.Fields = append(subj.Fields, - Field{JSONPath: "colour", Attribute: "colour", Kind: blueprint.KindString, - ComputedOptionalRequired: blueprint.ComputedOptional, Writable: true}, - Field{JSONPath: "accessType", Attribute: "access_type", Kind: blueprint.KindString, - ComputedOptionalRequired: blueprint.ComputedOptional, Writable: true}, - Field{JSONPath: "matchType", Attribute: "match_type", Kind: blueprint.KindString, - ComputedOptionalRequired: blueprint.ComputedOptional, Writable: true}, - Field{JSONPath: "objectType", Attribute: "object_type", Kind: blueprint.KindString, - ComputedOptionalRequired: blueprint.Required, Writable: true}, + Field{ + JSONPath: "colour", Attribute: "colour", Kind: blueprint.KindString, + ComputedOptionalRequired: blueprint.ComputedOptional, Writable: true, + }, + Field{ + JSONPath: "accessType", Attribute: "access_type", Kind: blueprint.KindString, + ComputedOptionalRequired: blueprint.ComputedOptional, Writable: true, + }, + Field{ + JSONPath: "matchType", Attribute: "match_type", Kind: blueprint.KindString, + ComputedOptionalRequired: blueprint.ComputedOptional, Writable: true, + }, + Field{ + JSONPath: "objectType", Attribute: "object_type", Kind: blueprint.KindString, + ComputedOptionalRequired: blueprint.Required, Writable: true, + }, ) // The fixtures set key and objectType and omit the three computed_optional fields, which is diff --git a/internal/probe/subject.go b/internal/probe/subject.go index ea3f752e..d8922755 100644 --- a/internal/probe/subject.go +++ b/internal/probe/subject.go @@ -163,7 +163,7 @@ func SubjectOf(bp blueprint.Blueprint, res blueprint.Resource) (Subject, error) subj.CollectionTemplate = collectionOf(item) } - subj.Fields = fieldsOf(res.Attributes, "") + subj.Fields = fieldsOf(res.Schema.Attributes, "") subj.NameField = nameFieldOf(subj.Fields) subj.IDField = idFieldOf(res, subj.Fields) @@ -186,7 +186,7 @@ func collectionOf(item string) string { return trimmed } -// fieldsOf flattens attributes, descending one level into nested objects. +// fieldsOf flattens attributes, descending fully into nested objects. // // NestedAttributeObject children are addressed with a dotted JSON path, which is how a probe reports // a fact about a field inside an object without needing to know Terraform's nesting diff --git a/internal/probe/subject_test.go b/internal/probe/subject_test.go index 71dfd50f..c34b90a9 100644 --- a/internal/probe/subject_test.go +++ b/internal/probe/subject_test.go @@ -19,31 +19,33 @@ func pilotResource() blueprint.Resource { Update: &blueprint.Operation{HTTPMethod: "PUT", PathTemplate: "/v7/tags/{id}", SuccessCodes: []int{200}}, Delete: &blueprint.Operation{HTTPMethod: "DELETE", PathTemplate: "/v7/tags/{id}", SuccessCodes: []int{204}}, }, - Attributes: []blueprint.Attribute{ - { - Name: "id", ComputedOptionalRequired: blueprint.Computed, - Type: blueprint.AttrType{Kind: blueprint.KindString}, - Wire: blueprint.WireBinding{JSONPath: "id"}, - }, - { - Name: "key", ComputedOptionalRequired: blueprint.Required, - Type: blueprint.AttrType{Kind: blueprint.KindString}, - Wire: blueprint.WireBinding{JSONPath: "key"}, - }, - { - Name: "assignments", ComputedOptionalRequired: blueprint.Optional, - Type: blueprint.AttrType{ - Kind: blueprint.KindSetNested, - NestedObject: &blueprint.NestedAttributeObject{ - GoTypeName: "M", - Attributes: []blueprint.Attribute{{ - Name: "type", ComputedOptionalRequired: blueprint.Optional, - Type: blueprint.AttrType{Kind: blueprint.KindString}, - Wire: blueprint.WireBinding{JSONPath: "type"}, - }}, + Schema: blueprint.Schema{ + Attributes: []blueprint.Attribute{ + { + Name: "id", ComputedOptionalRequired: blueprint.Computed, + Type: blueprint.AttrType{Kind: blueprint.KindString}, + Wire: blueprint.WireBinding{JSONPath: "id"}, + }, + { + Name: "key", ComputedOptionalRequired: blueprint.Required, + Type: blueprint.AttrType{Kind: blueprint.KindString}, + Wire: blueprint.WireBinding{JSONPath: "key"}, + }, + { + Name: "assignments", ComputedOptionalRequired: blueprint.Optional, + Type: blueprint.AttrType{ + Kind: blueprint.KindSetNested, + NestedObject: &blueprint.NestedAttributeObject{ + GoTypeName: "M", + Attributes: []blueprint.Attribute{{ + Name: "type", ComputedOptionalRequired: blueprint.Optional, + Type: blueprint.AttrType{Kind: blueprint.KindString}, + Wire: blueprint.WireBinding{JSONPath: "type"}, + }}, + }, }, + Wire: blueprint.WireBinding{JSONPath: "assignments"}, }, - Wire: blueprint.WireBinding{JSONPath: "assignments"}, }, }, } @@ -102,7 +104,7 @@ func TestUnit_Probe_IDFieldResolvesThroughTheWirePath(t *testing.T) { res := pilotResource() res.Binding.ID = blueprint.IDBinding{Attribute: "id", GoField: "ID", FromCreate: "created.ID"} - res.Attributes[0].Wire.JSONPath = "tagId" + res.Schema.Attributes[0].Wire.JSONPath = "tagId" subj, err := SubjectOf(blueprint.Blueprint{}, res) if err != nil { @@ -173,7 +175,7 @@ func TestUnit_Probe_SubjectSkipsUnjoinableAttributes(t *testing.T) { t.Parallel() res := pilotResource() - res.Attributes = append(res.Attributes, blueprint.Attribute{ + res.Schema.Attributes = append(res.Schema.Attributes, blueprint.Attribute{ Name: "orphan", ComputedOptionalRequired: blueprint.Optional, Type: blueprint.AttrType{Kind: blueprint.KindString}, }) @@ -192,7 +194,7 @@ func TestUnit_Probe_SubjectSkipsUnjoinableAttributes(t *testing.T) { // And a dropped attribute is not in the schema, so a fact about it would have // nowhere to go either. dropped := pilotResource() - dropped.Attributes[1].Drop = true + dropped.Schema.Attributes[1].Drop = true subj, err = SubjectOf(blueprint.Blueprint{}, dropped) if err != nil { @@ -382,7 +384,7 @@ func TestUnit_Probe_TheIdentifierIsNeverANestedField(t *testing.T) { res.Binding.ID = blueprint.IDBinding{Attribute: "id", GoField: "ID"} // A nested object whose child is also called "id", and named so it sorts before the real one. - res.Attributes = append(res.Attributes, blueprint.Attribute{ + res.Schema.Attributes = append(res.Schema.Attributes, blueprint.Attribute{ Name: "assignments", ComputedOptionalRequired: blueprint.ComputedOptional, Wire: blueprint.WireBinding{JSONPath: "assignments"}, diff --git a/internal/render/coverage_test.go b/internal/render/coverage_test.go index 9e968b48..524003ab 100644 --- a/internal/render/coverage_test.go +++ b/internal/render/coverage_test.go @@ -10,8 +10,6 @@ import ( func TestUnit_Render_ArgExpr(t *testing.T) { t.Parallel() - r := blueprint.Resource{Key: "tag"} - tests := []struct { name string arg blueprint.Argument @@ -30,6 +28,13 @@ func TestUnit_Render_ArgExpr(t *testing.T) { blueprint.Argument{Kind: blueprint.ArgPlanField, Field: "Name"}, "plan.Name.ValueString()", false, }, + { + // A data source has no prior state and no plan, so its arguments come from + // configuration. The variable name differs accordingly. + "config field", + blueprint.Argument{Kind: blueprint.ArgConfigField, Field: "ID"}, + "data.ID.ValueString()", false, + }, { // An explicit expression overrides the derived one, which is the // escape hatch for an argument the convention does not cover. @@ -45,7 +50,7 @@ func TestUnit_Render_ArgExpr(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := argExpr(r, tc.arg) + got, err := argExpr(`resource "tag"`, tc.arg) if tc.wantErr { if err == nil { t.Fatal("expected an error") @@ -116,17 +121,19 @@ func TestUnit_Render_AttrTypeExprCollections(t *testing.T) { // TestUnit_Render_NestedDepthAllowsOneLevel is the companion to the refusal test: // the supported depth must actually be supported. -func TestUnit_Render_NestedDepthAllowsOneLevel(t *testing.T) { +func TestUnit_Render_NestedShapesSkipDroppedAttributes(t *testing.T) { t.Parallel() r := blueprint.Resource{ Key: "tag", - Attributes: []blueprint.Attribute{ - nestedAttr(blueprint.KindSetNested, scalarChild("id", "ID")), + Schema: blueprint.Schema{ + Attributes: []blueprint.Attribute{ + nestedAttr(blueprint.KindSetNested, scalarChild("id", "ID")), + }, }, } - shapes, err := nestedShapes(r) + shapes, err := nestedShapes(testResourceScope, r.Schema) if err != nil { t.Fatalf("one level of nesting must be supported: %v", err) } @@ -136,8 +143,8 @@ func TestUnit_Render_NestedDepthAllowsOneLevel(t *testing.T) { // A dropped nested attribute is not a shape to generate. dropped := r - dropped.Attributes[0].Drop = true - if got, err := nestedShapes(dropped); err != nil || len(got) != 0 { + dropped.Schema.Attributes[0].Drop = true + if got, err := nestedShapes(testResourceScope, dropped.Schema); err != nil || len(got) != 0 { t.Errorf("a dropped attribute should yield no shape: %v, %v", got, err) } } @@ -147,13 +154,15 @@ func TestUnit_Render_NestedShapeWithoutAnObjectFails(t *testing.T) { r := blueprint.Resource{ Key: "tag", - Attributes: []blueprint.Attribute{{ - Name: "items", GoField: "Items", - Type: blueprint.AttrType{Kind: blueprint.KindSetNested}, - }}, + Schema: blueprint.Schema{ + Attributes: []blueprint.Attribute{{ + Name: "items", GoField: "Items", + Type: blueprint.AttrType{Kind: blueprint.KindSetNested}, + }}, + }, } - if _, err := nestedShapes(r); err == nil { + if _, err := nestedShapes(testResourceScope, r.Schema); err == nil { t.Error("a nested kind with no object shape must fail") } } @@ -161,8 +170,7 @@ func TestUnit_Render_NestedShapeWithoutAnObjectFails(t *testing.T) { func TestUnit_Render_NestedFlattenView(t *testing.T) { t.Parallel() - shapes, err := nestedShapes(blueprint.Resource{ - Key: "tag", + shapes, err := nestedShapes(testResourceScope, blueprint.Schema{ Attributes: []blueprint.Attribute{nestedAttr(blueprint.KindSetNested, scalarChild("id", "ID"))}, }) if err != nil { @@ -192,11 +200,11 @@ func TestUnit_Render_NestedSkipDirectionsAreHonoured(t *testing.T) { bp := pilot(t) // The pilot has two nested collections; suppress expansion on one. - for i := range bp.Resources[0].Attributes { - if bp.Resources[0].Attributes[i].Type.Kind.IsNested() { - bp.Resources[0].Attributes[i].Wire.SkipExpand = true - bp.Resources[0].Attributes[i].Wire.Expand = nil - bp.Resources[0].Attributes[i].ComputedOptionalRequired = blueprint.Computed + for i := range bp.Resources[0].Schema.Attributes { + if bp.Resources[0].Schema.Attributes[i].Type.Kind.IsNested() { + bp.Resources[0].Schema.Attributes[i].Wire.SkipExpand = true + bp.Resources[0].Schema.Attributes[i].Wire.Expand = nil + bp.Resources[0].Schema.Attributes[i].ComputedOptionalRequired = blueprint.Computed break } } @@ -218,7 +226,7 @@ func TestUnit_Render_DataSourcePackagePath(t *testing.T) { bp := pilot(t) bp.DataSources = []blueprint.DataSource{{ - Key: "agent", TerraformType: "thousandeyes_agent", GoPackage: "agent", + Key: "agent", Name: "agent", GoPackage: "agent", GoPackageAlias: "v7Agent", GoTypeName: "AgentDataSource", ModelTypeName: "AgentDataSourceModel", ServiceGroup: "agents", APIVersionDir: "v7", }} diff --git a/internal/render/datasource.go b/internal/render/datasource.go new file mode 100644 index 00000000..213132db --- /dev/null +++ b/internal/render/datasource.go @@ -0,0 +1,192 @@ +package render + +import ( + "fmt" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/blueprint" +) + +// DataSourceView is everything the per-data-source templates need. +// +// It is a sibling of ResourceView rather than a superset or a subset of it. A data source +// has one operation, no request body and no plan, so the fields a resource needs for the +// other three operations are not absent here -- they were never applicable. Sharing one +// view would mean every data source template guarding against fields that are always +// empty, which is the sort of conditional the templates are meant not to contain. +type DataSourceView struct { + Header string + Package string + DocRefComment string + + Imports DataSourceImports + + // DataSourceName is the Terraform type, e.g. "thousandeyes_tag". + DataSourceName string + GoTypeName string + ModelTypeName string + ConstructorFn string + + SDKClientType string + + MarkdownDescription string + + // ReadTimeout is the read deadline in seconds. There is no other operation to give + // a data source a deadline for. + ReadTimeout int + + Interfaces []string + + SchemaAttributes []string + ModelFields []string + NestedModels []NestedModelView + + // Read is the SDK call, and State is the flatten function it feeds. + Read *OpView + State StateView +} + +// DataSourceImports holds the rendered import block for each emitted file. +// +// There is no Construct: a data source sends no request body, so there is nothing to +// expand into one. model.go declares its own imports in the template, as the resource's +// does. +type DataSourceImports struct { + DataSource string + Read string + State string +} + +// DataSource builds the view for one data source. +func DataSource( + bp blueprint.Blueprint, + d blueprint.DataSource, + opts Options, +) (DataSourceView, error) { + var ( + impDataSource = newImportSet() + impRead = newImportSet() + impState = newImportSet() + ) + + sc := dataSourceScope(d) + + v := DataSourceView{ + Header: GeneratedHeader(opts.BlueprintPath, opts.BlueprintSHA256), + Package: d.GoPackage, + DataSourceName: bp.Provider.TerraformType(d.Name), + GoTypeName: d.GoTypeName, + ModelTypeName: d.ModelTypeName, + ConstructorFn: "New" + d.GoTypeName, + SDKClientType: bp.Provider.SDK.ClientType, + MarkdownDescription: d.Schema.MarkdownDescription, + ReadTimeout: pickTimeout( + d.Timeouts.ReadSeconds, + bp.Provider.Conventions.DefaultTimeouts.ReadSeconds, + ), + } + + if d.DocRefURL != "" { + v.DocRefComment = "// REF: " + d.DocRefURL + } + + sdk := bp.Provider.SDK + sup := bp.Provider.Support + + // datasource.go: schema, metadata, configure. + impDataSource.add(pkgContext, "") + impDataSource.add(pkgDataSrc, "") + impDataSource.add(sc.schemaImport(), "") + impDataSource.add(sdk.ClientImport.Path, sdk.ClientImport.Alias) + impDataSource.add(sup.Client.Path, sup.Client.Alias) + impDataSource.add(sup.CommonSchema.Path, sup.CommonSchema.Alias) + + // state.go: the conversions, plus the SDK package whose types appear in generic + // conversion arguments. + impState.add(pkgContext, "") + impState.add(pkgTflog, "") + impState.add(sup.Convert.Path, sup.Convert.Alias) + impState.add(d.Binding.Service.ImportPath, d.Binding.Service.Alias) + + // read.go: the one operation. + impRead.add(pkgContext, "") + impRead.add(pkgTime, "") + impRead.add(pkgDataSrc, "") + impRead.add(pkgTflog, "") + impRead.add(sup.CRUD.Path, sup.CRUD.Alias) + impRead.add(sup.Errors.Path, sup.Errors.Alias) + + v.Interfaces = dataSourceInterfaces(d) + + attrs, fields, err := attributes(sc, d.Schema, impDataSource) + if err != nil { + return DataSourceView{}, err + } + v.SchemaAttributes = attrs + + // The timeouts value is last in the model, as it is for a resource. The type comes + // from the framework's datasource timeouts package, not the resource one: they are + // distinct types and mixing them does not compile. + fields = append(fields, "Timeouts timeouts.Value `tfsdk:\"timeouts\"`") + v.ModelFields = fields + + if usesElementTypes(d.Schema) { + impDataSource.add(pkgTypes, "") + } + + shapes, err := nestedShapes(sc, d.Schema) + if err != nil { + return DataSourceView{}, err + } + + for _, sh := range shapes { + nm, nmErr := nestedModelView(sh) + if nmErr != nil { + return DataSourceView{}, nmErr + } + v.NestedModels = append(v.NestedModels, nm) + } + + if len(shapes) > 0 { + impState.add(pkgTypes, "") + impState.add(pkgDiag, "") + } + + v.State = stateView(d.Schema, d.Binding.Response.Type, shapes) + + if d.Binding.Read == nil { + return DataSourceView{}, &ErrUnsupported{ + What: sc.what, + Why: "a data source with no read operation has nothing to generate", + } + } + + read, err := opView( + sc.what, d.Binding.Service.Accessor, + *d.Binding.Read, "crud.PhaseRead", "errors.OpRead", "ReadTimeout", + ) + if err != nil { + return DataSourceView{}, err + } + v.Read = read + + org := bp.Provider.GoModule + v.Imports = DataSourceImports{ + DataSource: impDataSource.render(org), + Read: impRead.render(org), + State: impState.render(org), + } + + return v, nil +} + +// dataSourceInterfaces are the framework interfaces a generated data source asserts. +// +// Every data source implements both, so unlike the resource equivalent there is nothing +// conditional here: DataSource is the required method set and DataSourceWithConfigure is +// how the SDK client reaches it, which every generated data source needs. +func dataSourceInterfaces(d blueprint.DataSource) []string { + return []string{ + fmt.Sprintf("_ datasource.DataSource = &%s{}", d.GoTypeName), + fmt.Sprintf("_ datasource.DataSourceWithConfigure = &%s{}", d.GoTypeName), + } +} diff --git a/internal/render/datasource_test.go b/internal/render/datasource_test.go new file mode 100644 index 00000000..5df1a237 --- /dev/null +++ b/internal/render/datasource_test.go @@ -0,0 +1,270 @@ +package render + +import ( + "strings" + "testing" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/blueprint" +) + +// dataSourceByKey returns one committed pilot data source's view. +func dataSourceByKey(t *testing.T, key string) (blueprint.Blueprint, DataSourceView) { + t.Helper() + + bp := pilot(t) + + for _, d := range bp.DataSources { + if d.Key != key { + continue + } + v, err := DataSource(bp, d, Options{BlueprintPath: "blueprints/x", BlueprintSHA256: "abc"}) + if err != nil { + t.Fatalf("DataSource(%q): %v", key, err) + } + return bp, v + } + + t.Fatalf("the committed pilot blueprint has no data source keyed %q", key) + return blueprint.Blueprint{}, DataSourceView{} +} + +// TestUnit_Render_DataSourceUsesTheDataSourceSchemaPackage is the whole point of +// parameterising schema rendering by kind. +// +// The generated selector stays `schema` for every kind -- each block is emitted into its +// own package, so there is nothing to disambiguate -- which means the only observable +// difference is the import path. If that resolves to resource/schema, the generated data +// source assigns a resource schema to a datasource.SchemaResponse and does not compile. +func TestUnit_Render_DataSourceUsesTheDataSourceSchemaPackage(t *testing.T) { + t.Parallel() + + _, v := dataSourceByKey(t, "tag") + + const want = "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + if !strings.Contains(v.Imports.DataSource, want) { + t.Errorf("datasource.go should import %q:\n%s", want, v.Imports.DataSource) + } + if strings.Contains(v.Imports.DataSource, "terraform-plugin-framework/resource/schema") { + t.Errorf("datasource.go must not import the resource schema package:\n%s", v.Imports.DataSource) + } + + // The same schema rendered for a resource must reach the other package, or the + // parameterisation is not doing anything. + rv := pilotView(t) + if !strings.Contains(rv.Imports.Resource, "terraform-plugin-framework/resource/schema") { + t.Errorf("resource.go should still import the resource schema package:\n%s", rv.Imports.Resource) + } +} + +// TestUnit_Render_DataSourceEmitsNoPlanModifiers guards the one place the generator can +// put a plan modifier somewhere blueprint.Validate cannot see it. +// +// UseStateForUnknown is synthesised by planModifiersFor for computed strings rather than +// declared in the blueprint, so nothing upstream would refuse it on a data source. The +// pilot's tag data source has fourteen computed string attributes, so this would fire +// fourteen times if the guard were dropped -- and datasource/schema.StringAttribute has +// no PlanModifiers field, so the generated provider would not compile. +func TestUnit_Render_DataSourceEmitsNoPlanModifiers(t *testing.T) { + t.Parallel() + + _, v := dataSourceByKey(t, "tag") + + joined := strings.Join(v.SchemaAttributes, "\n") + for _, forbidden := range []string{"PlanModifiers", "planmodifier", "Default:"} { + if strings.Contains(joined, forbidden) { + t.Errorf("a data source attribute must not carry %s", forbidden) + } + } + if strings.Contains(v.Imports.DataSource, "planmodifier") { + t.Errorf("datasource.go must not import a planmodifier package:\n%s", v.Imports.DataSource) + } + + // The resource, rendered from the same attribute shapes, still gets them. Without + // this the test would pass if the synthesis were removed altogether. + rv := pilotView(t) + if !strings.Contains(strings.Join(rv.SchemaAttributes, "\n"), "stringplanmodifier.UseStateForUnknown()") { + t.Error("the resource should still get a synthesised UseStateForUnknown") + } +} + +// TestUnit_Render_DataSourceModelUsesTheDataSourceTimeouts pins the timeouts type. +// +// The resource and data source timeouts packages both export a Value, they are distinct +// types, and the data source's Read method is what crud.HandleTimeout is handed. Getting +// this wrong compiles nowhere near the mistake. +func TestUnit_Render_DataSourceModelUsesTheDataSourceTimeouts(t *testing.T) { + t.Parallel() + + _, v := dataSourceByKey(t, "tag") + + last := v.ModelFields[len(v.ModelFields)-1] + if !strings.Contains(last, `Timeouts timeouts.Value `) { + t.Errorf("the last model field should be the timeouts value, got %q", last) + } + + if v.ReadTimeout <= 0 { + t.Errorf("ReadTimeout = %d, want the provider default", v.ReadTimeout) + } +} + +// TestUnit_Render_DataSourceReadsFromConfig checks the read call and its argument source. +// +// A data source has no prior state and no plan, so an argument that renders as state.X +// would reference a variable the generated Read never declares. +func TestUnit_Render_DataSourceReadsFromConfig(t *testing.T) { + t.Parallel() + + _, v := dataSourceByKey(t, "tag") + + if v.Read == nil { + t.Fatal("a data source view must carry a read") + } + if want := "d.client.API.Tags.GetTag(ctx, data.ID.ValueString())"; v.Read.Call != want { + t.Errorf("Call = %q, want %q", v.Read.Call, want) + } + for _, forbidden := range []string{"state.", "plan.", "body"} { + if strings.Contains(v.Read.Call, forbidden) { + t.Errorf("a data source read must not reference %q: %s", forbidden, v.Read.Call) + } + } + if v.Read.ResultVar != "remote" { + t.Errorf("ResultVar = %q, want remote", v.Read.ResultVar) + } +} + +// TestUnit_Render_DataSourceFlattensAListAsAList is the regression test for a latent bug +// this phase surfaced. +// +// A list_nested attribute's model field is a types.List, but the flatten helper used to +// hardcode types.SetNull and types.SetValueFrom -- so any list_nested attribute generated +// a helper whose return type did not match its own signature. It went unnoticed because +// the only nested attributes in the pilot were sets. +func TestUnit_Render_DataSourceFlattensAListAsAList(t *testing.T) { + t.Parallel() + + _, v := dataSourceByKey(t, "tags") + + // Three helpers now, because the element carries two nested objects of its own. The + // list one is the outermost. + var list *NestedFuncView + for i := range v.State.NestedObject { + if v.State.NestedObject[i].FuncName == "flattenTagSummaries" { + list = &v.State.NestedObject[i] + } + } + if list == nil { + t.Fatalf("no flattenTagSummaries helper among %d", len(v.State.NestedObject)) + } + + if list.FrameworkType != "types.List" { + t.Errorf("FrameworkType = %q, want types.List", list.FrameworkType) + } + if list.Container != "List" { + t.Errorf("Container = %q, want List; a types.List built with types.SetNull does not compile", + list.Container) + } + + // Every helper's container must match its own framework type, at any depth. + for _, h := range v.State.NestedObject { + want := strings.TrimPrefix(h.FrameworkType, "types.") + if h.Container != want { + t.Errorf("%s: Container = %q but FrameworkType = %q", h.FuncName, h.Container, h.FrameworkType) + } + } + + // And a set still says Set, or the parameterisation has simply flipped the bug. + rv := pilotView(t) + for _, rh := range rv.State.NestedObject { + if rh.FrameworkType == "types.Set" && rh.Container != "Set" { + t.Errorf("%s: Container = %q, want Set", rh.FuncName, rh.Container) + } + } +} + +// TestUnit_Render_DataSourceAssertsItsInterfaces keeps the compile-time assertions honest. +func TestUnit_Render_DataSourceAssertsItsInterfaces(t *testing.T) { + t.Parallel() + + _, v := dataSourceByKey(t, "tag") + + want := []string{ + "_ datasource.DataSource = &TagDataSource{}", + "_ datasource.DataSourceWithConfigure = &TagDataSource{}", + } + if len(v.Interfaces) != len(want) { + t.Fatalf("got %d assertions, want %d: %v", len(v.Interfaces), len(want), v.Interfaces) + } + for i, w := range want { + if v.Interfaces[i] != w { + t.Errorf("assertion %d = %q, want %q", i, v.Interfaces[i], w) + } + } +} + +// TestUnit_Render_DataSourceWithNoReadIsRefused covers the guard that would otherwise +// dereference a nil operation. +func TestUnit_Render_DataSourceWithNoReadIsRefused(t *testing.T) { + t.Parallel() + + bp, _ := dataSourceByKey(t, "tag") + + d := bp.DataSources[0] + d.Binding.Read = nil + + if _, err := DataSource(bp, d, Options{}); err == nil { + t.Error("a data source with no read operation must be refused") + } +} + +// TestUnit_Render_UsesElementTypesDescendsIntoNestedObjects pins the condition that +// decides whether a schema file imports the framework's types package. +// +// Checking only the top level made the resource compile by luck: its one collection is +// nested, and a separate "has nested shapes" rule pulled the import in anyway. A data +// source whose nested objects are all scalars got the import with nothing to use it, and +// an unused import is a compile error. +func TestUnit_Render_UsesElementTypesDescendsIntoNestedObjects(t *testing.T) { + t.Parallel() + + nestedCollection := blueprint.Schema{Attributes: []blueprint.Attribute{{ + Name: "outer", GoField: "Outer", + Type: blueprint.AttrType{ + Kind: blueprint.KindSetNested, + NestedObject: &blueprint.NestedAttributeObject{ + Attributes: []blueprint.Attribute{{ + Name: "values", GoField: "Values", + Type: blueprint.AttrType{ + Kind: blueprint.KindSet, + ElementType: &blueprint.AttrType{Kind: blueprint.KindString}, + }, + }}, + }, + }, + }}} + if !usesElementTypes(nestedCollection) { + t.Error("a collection nested inside an object is still a use of an element type") + } + + nestedScalars := blueprint.Schema{Attributes: []blueprint.Attribute{{ + Name: "outer", GoField: "Outer", + Type: blueprint.AttrType{ + Kind: blueprint.KindSetNested, + NestedObject: &blueprint.NestedAttributeObject{ + Attributes: []blueprint.Attribute{{ + Name: "id", GoField: "ID", + Type: blueprint.AttrType{Kind: blueprint.KindString}, + }}, + }, + }, + }}} + if usesElementTypes(nestedScalars) { + t.Error("nested scalars render no element type, so the import would be unused") + } + + // A dropped attribute is not rendered, so it cannot justify an import. + dropped := nestedCollection + dropped.Attributes[0].Drop = true + if usesElementTypes(dropped) { + t.Error("a dropped attribute must not pull in an import") + } +} diff --git a/internal/render/depth_test.go b/internal/render/depth_test.go new file mode 100644 index 00000000..17bb7674 --- /dev/null +++ b/internal/render/depth_test.go @@ -0,0 +1,277 @@ +package render + +import ( + "fmt" + "strings" + "testing" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/blueprint" +) + +// deepSchema builds a schema nested `levels` deep, every level a set of objects. +// +// Level n is named lN, its model is LNModel, and its helpers are expandLN/flattenLN. The +// innermost level carries one scalar so the leaf is not an empty object, which validation +// refuses. +func deepSchema(levels int) blueprint.Schema { + fallible := func(f string) *blueprint.ConvertCall { + return &blueprint.ConvertCall{Func: f, NeedsCtx: true, ReturnsError: true} + } + + // Built inside out, so each level's wire can name the level below it. + inner := []blueprint.Attribute{{ + Name: "leaf", GoField: "Leaf", + Type: blueprint.AttrType{Kind: blueprint.KindString}, + ComputedOptionalRequired: blueprint.Optional, + Wire: blueprint.WireBinding{ + JSONPath: "leaf", SDKField: "Leaf", SDKGoType: "*string", + Expand: &blueprint.ConvertCall{Func: "convert.FrameworkToPtrString"}, + Flatten: &blueprint.ConvertCall{Func: "convert.PtrStringToFramework"}, + }, + }} + + for n := levels; n >= 1; n-- { + name := fmt.Sprintf("l%d", n) + up := strings.ToUpper(name) + + inner = []blueprint.Attribute{{ + Name: name, GoField: up, + Type: blueprint.AttrType{ + Kind: blueprint.KindSetNested, + NestedObject: &blueprint.NestedAttributeObject{ + GoTypeName: up + "Model", + SDKType: "sdk." + up, + AttrTypesVar: name + "AttrTypes", + ObjectTypeVar: name + "ObjectType", + ExpandFunc: "expand" + up, + FlattenFunc: "flatten" + up, + Attributes: inner, + }, + }, + ComputedOptionalRequired: blueprint.Optional, + Wire: blueprint.WireBinding{ + JSONPath: name, SDKField: up, SDKGoType: "[]sdk." + up, + Expand: fallible("expand" + up), + Flatten: fallible("flatten" + up), + }, + }} + } + + return blueprint.Schema{Attributes: inner} +} + +// TestUnit_Render_NestingIsGeneratedAtAnyDepth is the phase's central claim. +// +// One shape per level, in pre-order, each with its own model, attr.Type map and helper +// pair. Before this phase the emitter refused anything past one level. +func TestUnit_Render_NestingIsGeneratedAtAnyDepth(t *testing.T) { + t.Parallel() + + const levels = 4 + + shapes, err := nestedShapes(testResourceScope, deepSchema(levels)) + if err != nil { + t.Fatalf("nestedShapes at %d levels: %v", levels, err) + } + if len(shapes) != levels { + t.Fatalf("got %d shapes, want one per level (%d)", len(shapes), levels) + } + + // Pre-order: the outermost object first, so a reader meets the schema top down. + for i, sh := range shapes { + wantPath := "l1" + for n := 2; n <= i+1; n++ { + wantPath += fmt.Sprintf(".l%d", n) + } + if sh.path != wantPath { + t.Errorf("shape %d path = %q, want %q", i, sh.path, wantPath) + } + } + + // Each level's model names the level below through its object type var, rather than + // restating the shape. + for i, sh := range shapes[:len(shapes)-1] { + nm, err := nestedModelView(sh) + if err != nil { + t.Fatalf("nestedModelView(%s): %v", sh.path, err) + } + + child := fmt.Sprintf("l%dObjectType", i+2) + joined := strings.Join(nm.AttrTypeEntries, "\n") + if !strings.Contains(joined, child) { + t.Errorf("level %d attr.Type map should refer to %s:\n%s", i+1, child, joined) + } + } + + // The leaf level has no nested child and so refers to no object type var. + leaf, err := nestedModelView(shapes[len(shapes)-1]) + if err != nil { + t.Fatalf("nestedModelView(leaf): %v", err) + } + if strings.Contains(strings.Join(leaf.AttrTypeEntries, "\n"), "ObjectType") { + t.Errorf("the leaf level should refer to no object type var: %v", leaf.AttrTypeEntries) + } +} + +// TestUnit_Render_DeepExpandAndFlattenChain checks both directions at depth. +// +// Each level's helper must call the level below it. The flatten direction is proved by the +// pilot's data source; the expand direction has no pilot coverage, because the tag API has +// nothing writable nested two deep. +func TestUnit_Render_DeepExpandAndFlattenChain(t *testing.T) { + t.Parallel() + + shapes, err := nestedShapes(testResourceScope, deepSchema(3)) + if err != nil { + t.Fatalf("nestedShapes: %v", err) + } + + for i, sh := range shapes[:len(shapes)-1] { + below := fmt.Sprintf("L%d", i+2) + + ev := nestedExpandView(sh) + if got := strings.Join(ev.Assignments, "\n"); !strings.Contains(got, "expand"+below) { + t.Errorf("level %d expand should call expand%s:\n%s", i+1, below, got) + } + if !ev.NeedsDiagnostics { + t.Errorf("level %d expand calls a fallible helper, so it must carry diagnostics", i+1) + } + + fv := nestedFlattenView(sh) + if got := strings.Join(fv.Assignments, "\n"); !strings.Contains(got, "flatten"+below) { + t.Errorf("level %d flatten should call flatten%s:\n%s", i+1, below, got) + } + } +} + +// TestUnit_Render_DeepSchemaDeclarationRecurses checks the schema literal itself, which +// already recursed before this phase but was unreachable past one level. +func TestUnit_Render_DeepSchemaDeclarationRecurses(t *testing.T) { + t.Parallel() + + imports := newImportSet() + + decl, err := nestedAttributeDecl(testResourceScope, deepSchema(3).Attributes[0], imports) + if err != nil { + t.Fatalf("nestedAttributeDecl: %v", err) + } + + // Three NestedObject wrappers, one per level. + if got := strings.Count(decl, "schema.NestedAttributeObject{"); got != 3 { + t.Errorf("got %d nested object literals, want 3:\n%s", got, decl) + } + if !strings.Contains(decl, `"leaf"`) { + t.Errorf("the innermost attribute should reach the declaration:\n%s", decl) + } +} + +// TestUnit_Render_NestingBeyondTheCeilingIsRefusedByName covers the runaway guard. +// +// The ceiling is above any fixed schema in the reference provider. What exceeds it is a +// schema whose depth is decided at runtime, which this IR cannot express, so the message +// says to write that resource by hand rather than suggesting a flatter shape. +func TestUnit_Render_NestingBeyondTheCeilingIsRefusedByName(t *testing.T) { + t.Parallel() + + _, err := nestedShapes(testResourceScope, deepSchema(maxNestDepth+1)) + if err == nil { + t.Fatalf("nesting %d levels deep must be refused", maxNestDepth+1) + } + + msg := err.Error() + // The attribute is named, so the reader knows where to look. + if !strings.Contains(msg, fmt.Sprintf("l%d", maxNestDepth+1)) { + t.Errorf("the error should name the offending attribute: %v", msg) + } + if !strings.Contains(msg, "by hand") { + t.Errorf("the error should say what to do instead: %v", msg) + } + + // And the ceiling itself is generated, not refused. + if _, err := nestedShapes(testResourceScope, deepSchema(maxNestDepth)); err != nil { + t.Errorf("exactly %d levels must be supported: %v", maxNestDepth, err) + } +} + +// TestUnit_Render_TwoNestedObjectsMayNotShareAnIdentifier is the collision arbitrary depth +// makes likely. +// +// Every nested object declares a package-level model, attr.Type map, object type var and +// helper pair. At one level a repeat was unlikely; at four, two objects called "ItemModel" +// is an easy mistake, and it emits two declarations of the same name. +func TestUnit_Render_TwoNestedObjectsMayNotShareAnIdentifier(t *testing.T) { + t.Parallel() + + s := deepSchema(2) + + // Make the inner object claim the outer one's model name. + outer := s.Attributes[0].Type.NestedObject + outer.Attributes[0].Type.NestedObject.GoTypeName = outer.GoTypeName + + _, err := nestedShapes(testResourceScope, s) + if err == nil { + t.Fatal("two nested objects sharing a goTypeName must be refused") + } + + msg := err.Error() + for _, want := range []string{"goTypeName", "L1Model", "l1.l2"} { + if !strings.Contains(msg, want) { + t.Errorf("the error should mention %q: %v", want, msg) + } + } +} + +// TestUnit_Render_NestedAttributeCarriesItsValidators is a gap this phase closed. +// +// A nested attribute's validators and plan modifiers were dropped silently: the blueprint +// said the value was constrained and the generated provider did not enforce it. Silently +// is the operative word -- a refusal would have been fine. +func TestUnit_Render_NestedAttributeCarriesItsValidators(t *testing.T) { + t.Parallel() + + a := deepSchema(1).Attributes[0] + a.Validators = []blueprint.CustomCode{{ + SchemaDefinition: "setvalidator.SizeAtLeast(1)", + Imports: []blueprint.Import{{ + Path: "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator", + }}, + }} + + imports := newImportSet() + + decl, err := nestedAttributeDecl(testResourceScope, a, imports) + if err != nil { + t.Fatalf("nestedAttributeDecl: %v", err) + } + + if !strings.Contains(decl, "setvalidator.SizeAtLeast(1)") { + t.Errorf("a declared validator must reach the schema:\n%s", decl) + } + // A set_nested attribute takes []validator.Set. The fallthrough used to make this + // []validator.String, which does not compile against a SetNestedAttribute. + if !strings.Contains(decl, "[]validator.Set{") { + t.Errorf("a set_nested attribute's validators must be validator.Set:\n%s", decl) + } + if !strings.Contains(imports.render("irrelevant"), "setvalidator") { + t.Error("a validator's own imports must be registered") + } +} + +// TestUnit_Render_ValidatorKindCoversTheNestedKinds pins the mapping directly. +// +// The default was String, so every nested kind silently rendered []validator.String. +func TestUnit_Render_ValidatorKindCoversTheNestedKinds(t *testing.T) { + t.Parallel() + + want := map[blueprint.TypeKind]string{ + blueprint.KindListNested: "List", + blueprint.KindSetNested: "Set", + blueprint.KindSingleNested: "Object", + } + + for kind, w := range want { + if got := validatorKind(kind); got != w { + t.Errorf("validatorKind(%q) = %q, want %q", kind, got, w) + } + } +} diff --git a/internal/render/nested.go b/internal/render/nested.go index ec2b8722..7335618f 100644 --- a/internal/render/nested.go +++ b/internal/render/nested.go @@ -7,76 +7,144 @@ import ( "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/blueprint" ) -// maxNestDepth is how deep a nested attribute may go. +// maxNestDepth is a runaway guard, not a design limit. // -// One level is emitted today. Deeper nesting is refused rather than emitted -// wrongly: each level needs its own model type, attr.Type map and conversion -// helper, and a partially-correct nested mapping is the kind of bug that only -// shows up as a diff a practitioner cannot resolve. Raising this is a deliberate -// piece of work, not a constant change. -const maxNestDepth = 1 - -// nestedShapes collects every nested object shape a resource declares, in -// declaration order so output does not depend on map iteration. -func nestedShapes(r blueprint.Resource) ([]nestedShape, error) { +// Nesting is generated to whatever depth a blueprint declares: four levels is routine in +// terraform-provider-microsoft365, and each level needs nothing the first level did not +// already need. The ceiling exists because a blueprint constructed in Go rather than +// loaded from JSON could nest into itself, and a generator that recurses forever gives a +// stack trace instead of a diagnostic. +// +// Ten is above any hand-written schema in the reference provider. The one case that +// exceeds it is a settings catalogue whose depth is sized at runtime from the +// practitioner's own configuration, up to fifteen levels. That shape is not expressible +// in this IR at all -- it is not a fixed schema -- so it is refused here by name rather +// than half-emitted. +const maxNestDepth = 10 + +// nestedShapes collects every nested object shape a schema declares, at any depth. +// +// Order is pre-order over the declaration order: an object appears before the objects +// nested inside it. Go initialises package-level vars by dependency rather than by +// position, so the order is for the reader rather than the compiler -- but it must be +// deterministic, because the drift check compares generated bytes. +func nestedShapes(sc schemaScope, s blueprint.Schema) ([]nestedShape, error) { var out []nestedShape - for _, a := range r.Attributes { + // Each generated identifier is claimed once. At one level of nesting a collision was + // unlikely; at four, two different objects called "ItemModel" is an easy mistake, and + // it would emit two declarations of the same type. + claimed := map[string]string{} + + if err := collectShapes(sc, s.Attributes, "", 1, claimed, &out); err != nil { + return nil, err + } + + return out, nil +} + +type nestedShape struct { + attr blueprint.Attribute + nested blueprint.NestedAttributeObject + // path is the dotted attribute path to this object, for error messages. + path string +} + +func collectShapes( + sc schemaScope, + attrs []blueprint.Attribute, + path string, + depth int, + claimed map[string]string, + out *[]nestedShape, +) error { + for _, a := range attrs { if a.Drop || !a.Type.Kind.IsNested() { continue } + + at := a.Name + if path != "" { + at = path + "." + a.Name + } + if a.Type.NestedObject == nil { - return nil, &ErrUnsupported{ - What: fmt.Sprintf("attribute %q of resource %q", a.Name, r.Key), + return &ErrUnsupported{ + What: fmt.Sprintf("attribute %q of %s", at, sc.what), Why: "a nested kind needs a nested object shape", } } - // Depth is checked here rather than while rendering, so the error names the - // attribute instead of surfacing as a confusing type mismatch later. - if err := checkDepth(r.Key, a.Name, *a.Type.NestedObject, 1); err != nil { - return nil, err + if depth > maxNestDepth { + return &ErrUnsupported{ + What: fmt.Sprintf("attribute %q of %s", at, sc.what), + Why: fmt.Sprintf( + "nesting reaches %d levels and the emitter stops at %d; a schema this deep is "+ + "usually one whose depth is decided at runtime, which this IR cannot express -- "+ + "write that resource by hand", + depth, maxNestDepth, + ), + } } - out = append(out, nestedShape{attr: a, nested: *a.Type.NestedObject}) - } + n := *a.Type.NestedObject + if err := claimIdentifiers(sc, at, n, claimed); err != nil { + return err + } - return out, nil -} + *out = append(*out, nestedShape{attr: a, nested: n, path: at}) -type nestedShape struct { - attr blueprint.Attribute - nested blueprint.NestedAttributeObject + if err := collectShapes(sc, n.Attributes, at, depth+1, claimed, out); err != nil { + return err + } + } + + return nil } -func checkDepth(resourceKey, path string, n blueprint.NestedAttributeObject, depth int) error { - for _, child := range n.Attributes { - if child.Drop || !child.Type.Kind.IsNested() { +// claimIdentifiers refuses two nested objects that would declare the same Go identifier. +// +// Every one of these becomes a package-level declaration, so a repeat is a redeclaration +// error in the generated package. Catching it here names both attributes; the compiler +// error names neither. +func claimIdentifiers( + sc schemaScope, + at string, + n blueprint.NestedAttributeObject, + claimed map[string]string, +) error { + for _, id := range []struct{ kind, name string }{ + {"goTypeName", n.GoTypeName}, + {"attrTypesVar", n.AttrTypesVar}, + {"objectTypeVar", n.ObjectTypeVar}, + {"expandFunc", n.ExpandFunc}, + {"flattenFunc", n.FlattenFunc}, + } { + if id.name == "" { continue } - if depth+1 > maxNestDepth { + if first, ok := claimed[id.name]; ok { return &ErrUnsupported{ - What: fmt.Sprintf("attribute %q of resource %q", path+"."+child.Name, resourceKey), - Why: fmt.Sprintf("nesting is %d level(s) deep and the emitter supports %d; "+ - "flatten the shape or extend the emitter deliberately", depth+1, maxNestDepth), - } - } - if child.Type.NestedObject != nil { - if err := checkDepth( - resourceKey, - path+"."+child.Name, - *child.Type.NestedObject, - depth+1, - ); err != nil { - return err + What: fmt.Sprintf("attribute %q of %s", at, sc.what), + Why: fmt.Sprintf( + "its %s %q is already used by %q; every nested object declares its own "+ + "package-level identifiers, so two objects cannot share one", + id.kind, id.name, first, + ), } } + claimed[id.name] = at } + return nil } // nestedAttributeDecl renders a nested attribute's schema declaration. -func nestedAttributeDecl(a blueprint.Attribute, imports *importSet) (string, error) { +func nestedAttributeDecl( + sc schemaScope, + a blueprint.Attribute, + imports *importSet, +) (string, error) { n := a.Type.NestedObject var children []string @@ -91,7 +159,7 @@ func nestedAttributeDecl(a blueprint.Attribute, imports *importSet) (string, err Why: fmt.Sprintf("type kind %q has no framework mapping", child.Type.Kind), } } - decl, err := attributeDecl(child, schemaType, imports) + decl, err := attributeDecl(sc, child, schemaType, imports) if err != nil { return "", err } @@ -125,6 +193,27 @@ func nestedAttributeDecl(a blueprint.Attribute, imports *importSet) (string, err if a.MarkdownDescription != "" { fmt.Fprintf(&b, "MarkdownDescription: %s,\n", goStringLit(a.MarkdownDescription)) } + if a.DeprecationMessage != "" { + fmt.Fprintf(&b, "DeprecationMessage: %s,\n", goStringLit(a.DeprecationMessage)) + } + + // A nested attribute takes validators and plan modifiers like any other, and + // dropping a declared one silently is worse than refusing it: the blueprint says the + // value is constrained and the generated provider does not enforce it. + writeCustomCodeBlock( + &b, + "Validators", + "validator."+validatorKind(a.Type.Kind), + a.Validators, + imports, + ) + writeCustomCodeBlock( + &b, + "PlanModifiers", + "planmodifier."+validatorKind(a.Type.Kind), + planModifiersFor(sc, a, imports), + imports, + ) b.WriteString("}") @@ -199,10 +288,38 @@ func attrTypeExpr(t blueprint.AttrType) (string, error) { } return fmt.Sprintf("types.MapType{ElemType: %s}", elem), nil + case blueprint.KindSingleNested, blueprint.KindListNested, blueprint.KindSetNested: + // A nested object describes itself through the object type var its own model + // declares, so an outer attr.Type map refers to that rather than restating the + // shape. Restating it would be a second copy of the same truth, free to drift. + if t.NestedObject == nil { + return "", &ErrUnsupported{ + What: fmt.Sprintf("type kind %q", t.Kind), + Why: "a nested kind needs a nested object shape to name its object type", + } + } + if t.NestedObject.ObjectTypeVar == "" { + return "", &ErrUnsupported{ + What: fmt.Sprintf("nested object %q", t.NestedObject.GoTypeName), + Why: "it declares no objectTypeVar, so an enclosing attr.Type map cannot refer to it", + } + } + + if t.Kind == blueprint.KindSingleNested { + return t.NestedObject.ObjectTypeVar, nil + } + + container := "ListType" + if t.Kind == blueprint.KindSetNested { + container = "SetType" + } + + return fmt.Sprintf("types.%s{ElemType: %s}", container, t.NestedObject.ObjectTypeVar), nil + default: return "", &ErrUnsupported{ What: fmt.Sprintf("type kind %q", t.Kind), - Why: "it has no attr.Type expression; nesting inside a nested object is not supported", + Why: "it has no attr.Type expression", } } } @@ -250,6 +367,7 @@ func nestedFlattenView(s nestedShape) NestedFuncView { ObjectTypeVar: s.nested.ObjectTypeVar, ModelType: s.nested.GoTypeName, IsCollection: s.attr.Type.Kind.IsNestedCollection(), + Container: nestedContainer(s.attr.Type.Kind), } for _, child := range s.nested.Attributes { @@ -271,3 +389,17 @@ func nestedFlattenView(s nestedShape) NestedFuncView { return v } + +// nestedContainer names the framework container a nested collection flattens into. +// +// Empty for a single nested object, which uses types.Object* rather than a container. +func nestedContainer(k blueprint.TypeKind) string { + switch k { + case blueprint.KindListNested: + return "List" + case blueprint.KindSetNested: + return "Set" + default: + return "" + } +} diff --git a/internal/render/nested_test.go b/internal/render/nested_test.go index 66bdedd1..af0ece60 100644 --- a/internal/render/nested_test.go +++ b/internal/render/nested_test.go @@ -1,13 +1,19 @@ package render import ( - "errors" "strings" "testing" "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/blueprint" ) +// testResourceScope is the scope the resource-oriented rendering tests pass. It is named +// rather than inlined so a test that means to render as a data source has to say so. +var testResourceScope = schemaScope{ + kind: blueprint.BlockResource, + what: `resource "tag"`, +} + func nestedAttr(kind blueprint.TypeKind, children ...blueprint.Attribute) blueprint.Attribute { return blueprint.Attribute{ Name: "assignments", @@ -52,7 +58,7 @@ func TestUnit_Render_NestedSchemaUsesNestedObject(t *testing.T) { imports := newImportSet() - decl, err := nestedAttributeDecl(nestedAttr(blueprint.KindSetNested, scalarChild("id", "ID")), imports) + decl, err := nestedAttributeDecl(testResourceScope, nestedAttr(blueprint.KindSetNested, scalarChild("id", "ID")), imports) if err != nil { t.Fatalf("nestedAttributeDecl: %v", err) } @@ -71,7 +77,7 @@ func TestUnit_Render_NestedSchemaUsesNestedObject(t *testing.T) { func TestUnit_Render_SingleNestedHoldsAttributesDirectly(t *testing.T) { t.Parallel() - decl, err := nestedAttributeDecl(nestedAttr(blueprint.KindSingleNested, scalarChild("id", "ID")), newImportSet()) + decl, err := nestedAttributeDecl(testResourceScope, nestedAttr(blueprint.KindSingleNested, scalarChild("id", "ID")), newImportSet()) if err != nil { t.Fatalf("nestedAttributeDecl: %v", err) } @@ -84,41 +90,47 @@ func TestUnit_Render_SingleNestedHoldsAttributesDirectly(t *testing.T) { } } -// TestUnit_Render_NestedDepthIsRefusedRatherThanEmittedWrongly is the important -// one. Each nesting level needs its own model, attr.Type map and helper; emitting -// a partially-correct mapping produces a diff a practitioner cannot resolve, so -// exceeding the supported depth must fail loudly. -func TestUnit_Render_NestedDepthIsRefusedRatherThanEmittedWrongly(t *testing.T) { +// TestUnit_Render_NestedDepthTwoLevelsGenerateTwoShapes replaces a test that asserted two +// levels were refused. +// +// Deleting rather than adapting it: the old fixture kept passing after the cap was +// removed, but against the identifier-collision check, because nestedAttr gives every +// level the same generated names. A test that still passes for a reason its name disowns +// is worse than one that fails. +func TestUnit_Render_NestedDepthTwoLevelsGenerateTwoShapes(t *testing.T) { t.Parallel() inner := nestedAttr(blueprint.KindSetNested, scalarChild("id", "ID")) inner.Name = "inner" inner.GoField = "Inner" + // Distinct generated identifiers, or the collision check fires and the depth path is + // never exercised. + inner.Type.NestedObject.GoTypeName = "InnerModel" + inner.Type.NestedObject.AttrTypesVar = "innerAttrTypes" + inner.Type.NestedObject.ObjectTypeVar = "innerObjectType" + inner.Type.NestedObject.ExpandFunc = "expandInner" + inner.Type.NestedObject.FlattenFunc = "flattenInner" outer := nestedAttr(blueprint.KindSetNested, inner) - r := blueprint.Resource{Key: "tag", Attributes: []blueprint.Attribute{outer}} - - _, err := nestedShapes(r) - if err == nil { - t.Fatal("expected nesting beyond the supported depth to be refused") + shapes, err := nestedShapes(testResourceScope, blueprint.Schema{ + Attributes: []blueprint.Attribute{outer}, + }) + if err != nil { + t.Fatalf("two levels of nesting must be supported: %v", err) } - - var unsupported *ErrUnsupported - if !errors.As(err, &unsupported) { - t.Fatalf("error should be an ErrUnsupported: %v", err) + if len(shapes) != 2 { + t.Fatalf("got %d shapes, want one per level", len(shapes)) } - // The message has to name the offending attribute, not merely the limit. - if !strings.Contains(err.Error(), "inner") { - t.Errorf("error should name the attribute at fault: %v", err) + if shapes[0].path != "assignments" || shapes[1].path != "assignments.inner" { + t.Errorf("paths = %q, %q; want the outermost first", shapes[0].path, shapes[1].path) } } func TestUnit_Render_NestedModelDeclaresTheShapeOnce(t *testing.T) { t.Parallel() - shapes, err := nestedShapes(blueprint.Resource{ - Key: "tag", + shapes, err := nestedShapes(testResourceScope, blueprint.Schema{ Attributes: []blueprint.Attribute{ nestedAttr(blueprint.KindSetNested, scalarChild("id", "ID"), scalarChild("type", "Type")), }, @@ -165,8 +177,7 @@ func TestUnit_Render_FallibleChildMakesTheHelperFallible(t *testing.T) { Func: "convert.FrameworkSetToStringSlice", NeedsCtx: true, ReturnsError: true, } - shapes, err := nestedShapes(blueprint.Resource{ - Key: "tag", + shapes, err := nestedShapes(testResourceScope, blueprint.Schema{ Attributes: []blueprint.Attribute{nestedAttr(blueprint.KindSetNested, fallible)}, }) if err != nil { @@ -220,11 +231,45 @@ func TestUnit_Render_AttrTypeExpr(t *testing.T) { // TestUnit_Render_AttrTypeExprRefusesNesting confirms an unsupported shape errors // rather than silently emitting something that does not compile. -func TestUnit_Render_AttrTypeExprRefusesNesting(t *testing.T) { +func TestUnit_Render_AttrTypeExprNeedsAnObjectToNameIt(t *testing.T) { t.Parallel() - _, err := attrTypeExpr(blueprint.AttrType{Kind: blueprint.KindSetNested}) - if err == nil { - t.Fatal("expected a nested kind to have no attr.Type expression") + // A nested kind now has an attr.Type expression -- the object type var its own model + // declares -- so what is refused is a nested kind with nothing to name. + if _, err := attrTypeExpr(blueprint.AttrType{Kind: blueprint.KindSetNested}); err == nil { + t.Error("a nested kind with no object shape has nothing to name") + } + + noVar := blueprint.AttrType{ + Kind: blueprint.KindSetNested, + NestedObject: &blueprint.NestedAttributeObject{GoTypeName: "ItemModel"}, + } + if _, err := attrTypeExpr(noVar); err == nil { + t.Error("a nested object with no objectTypeVar cannot be referred to") + } + + // And the expressions an enclosing attr.Type map actually gets. + set := blueprint.AttrType{ + Kind: blueprint.KindSetNested, + NestedObject: &blueprint.NestedAttributeObject{ObjectTypeVar: "itemObjectType"}, + } + got, err := attrTypeExpr(set) + if err != nil { + t.Fatalf("attrTypeExpr: %v", err) + } + if want := "types.SetType{ElemType: itemObjectType}"; got != want { + t.Errorf("attrTypeExpr = %q, want %q", got, want) + } + + single := blueprint.AttrType{ + Kind: blueprint.KindSingleNested, + NestedObject: &blueprint.NestedAttributeObject{ObjectTypeVar: "itemObjectType"}, + } + got, err = attrTypeExpr(single) + if err != nil { + t.Fatalf("attrTypeExpr: %v", err) + } + if want := "itemObjectType"; got != want { + t.Errorf("a single nested object is its own object type: got %q, want %q", got, want) } } diff --git a/internal/render/render.go b/internal/render/render.go index 01c96e1f..5732e4ef 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -27,7 +27,7 @@ const ( pkgTime = "time" pkgPath = "github.com/hashicorp/terraform-plugin-framework/path" pkgResource = "github.com/hashicorp/terraform-plugin-framework/resource" - pkgSchema = "github.com/hashicorp/terraform-plugin-framework/resource/schema" + pkgDataSrc = "github.com/hashicorp/terraform-plugin-framework/datasource" pkgTypes = "github.com/hashicorp/terraform-plugin-framework/types" pkgTimeouts = "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" pkgTflog = "github.com/hashicorp/terraform-plugin-log/tflog" @@ -36,8 +36,44 @@ const ( pkgAttr = "github.com/hashicorp/terraform-plugin-framework/attr" pkgDiag = "github.com/hashicorp/terraform-plugin-framework/diag" pkgBaseTypes = "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + + // frameworkRoot is the prefix each kind's schema package hangs off. The suffix + // comes from BlockKind.SchemaPackage, so the two never disagree. + frameworkRoot = "github.com/hashicorp/terraform-plugin-framework/" ) +// schemaScope is what attribute rendering needs to know about the block it is +// rendering for. +// +// The attribute types in each kind's schema package are structurally identical, so one +// renderer serves every kind rather than one per kind. Two things differ, and this +// carries both: which import path the generated `schema` selector resolves to, and which +// fields the kind's attribute struct actually has. +// +// The selector itself stays `schema` for every kind. That is not a shortcut -- each block +// is emitted into its own package directory, so a resource's `schema` and a data source's +// `schema` never appear in the same file and there is nothing to disambiguate. +type schemaScope struct { + kind blueprint.BlockKind + // what names the block in an error message, e.g. `resource "tag"`. + what string +} + +// schemaImport is the framework import path this scope's `schema` selector resolves to. +func (sc schemaScope) schemaImport() string { + return frameworkRoot + sc.kind.SchemaPackage() +} + +// resourceScope and dataSourceScope name a block for the error messages attribute +// rendering produces. +func resourceScope(r blueprint.Resource) schemaScope { + return schemaScope{kind: blueprint.BlockResource, what: fmt.Sprintf("resource %q", r.Key)} +} + +func dataSourceScope(d blueprint.DataSource) schemaScope { + return schemaScope{kind: blueprint.BlockDataSource, what: fmt.Sprintf("data source %q", d.Key)} +} + // ResourceView is everything the per-resource templates need. type ResourceView struct { // Header is the generated-file marker. Its wording is not cosmetic: the house @@ -158,6 +194,12 @@ type NestedFuncView struct { // IsCollection distinguishes a helper over many objects from one over a // single object. IsCollection bool + // Container is the framework container type a collection helper builds: "Set" or + // "List". The flatten side needs it because types.SetNull and types.ListNull are + // different functions returning different types, and the model field's type comes + // from the attribute kind -- so hardcoding one of them emits a list_nested + // attribute whose helper does not compile against its own model. + Container string // Assignments are finished per-field statements inside the helper. Assignments []string // NeedsDiagnostics is true when a field conversion inside the helper can fail. @@ -235,14 +277,14 @@ func Resource(bp blueprint.Blueprint, r blueprint.Resource, opts Options) (Resou v := ResourceView{ Header: GeneratedHeader(opts.BlueprintPath, opts.BlueprintSHA256), Package: r.GoPackage, - ResourceName: r.TerraformType, + ResourceName: bp.Provider.TerraformType(r.Name), GoTypeName: r.GoTypeName, ModelTypeName: r.ModelTypeName, ConstructorFn: "New" + r.GoTypeName, SDKClientType: bp.Provider.SDK.ClientType, ServiceAccessor: r.Binding.Service.Accessor, IDField: r.Binding.ID.GoField, - MarkdownDescription: r.MarkdownDescription, + MarkdownDescription: r.Schema.MarkdownDescription, Timeouts: timeoutsView(r, bp.Provider.Conventions.DefaultTimeouts), } @@ -250,13 +292,14 @@ func Resource(bp blueprint.Blueprint, r blueprint.Resource, opts Options) (Resou v.DocRefComment = "// REF: " + r.DocRefURL } + sc := resourceScope(r) sdk := bp.Provider.SDK sup := bp.Provider.Support // resource.go: schema, metadata, configure, import. impResource.add(pkgContext, "") impResource.add(pkgResource, "") - impResource.add(pkgSchema, "") + impResource.add(sc.schemaImport(), "") impResource.add(sdk.ClientImport.Path, sdk.ClientImport.Alias) impResource.add(sup.Client.Path, sup.Client.Alias) impResource.add(sup.CommonSchema.Path, sup.CommonSchema.Alias) @@ -289,20 +332,24 @@ func Resource(bp blueprint.Blueprint, r blueprint.Resource, opts Options) (Resou ) } - attrs, fields, err := attributes(r, impResource) + attrs, fields, err := attributes(sc, r.Schema, impResource) if err != nil { return ResourceView{}, err } v.SchemaAttributes = attrs + + // The timeouts value is last in the model, matching the archetype, and is what the + // generated CRUD reads its per-operation deadlines from. + fields = append(fields, "Timeouts timeouts.Value `tfsdk:\"timeouts\"`") v.ModelFields = fields // A collection attribute's ElementType expression needs the types package, // and a scalar-only resource must not import it. - if usesElementTypes(r) { + if usesElementTypes(r.Schema) { impResource.add(pkgTypes, "") } - shapes, err := nestedShapes(r) + shapes, err := nestedShapes(sc, r.Schema) if err != nil { return ResourceView{}, err } @@ -320,7 +367,6 @@ func Resource(bp blueprint.Blueprint, r blueprint.Resource, opts Options) (Resou // diagnostics to carry. It adds nothing to crud.go, which only ever appends to // resp.Diagnostics. if len(shapes) > 0 { - impResource.add(pkgTypes, "") for _, s := range []*importSet{impConstruct, impState} { s.add(pkgTypes, "") s.add(pkgDiag, "") @@ -335,7 +381,7 @@ func Resource(bp blueprint.Blueprint, r blueprint.Resource, opts Options) (Resou } v.Construct = constructView(r, shapes) - v.State = stateView(r, shapes) + v.State = stateView(r.Schema, r.Binding.Body.ResponseType, shapes) // A fallible conversion anywhere means construct or state returns diagnostics, // which changes the shape of the generated CRUD call sites. crud.go needs no @@ -358,30 +404,53 @@ func Resource(bp blueprint.Blueprint, r blueprint.Resource, opts Options) (Resou return v, nil } -func usesElementTypes(r blueprint.Resource) bool { - for _, a := range r.Attributes { - if !a.Drop && a.Type.Kind.IsCollection() { +// usesElementTypes reports whether any attribute renders an ElementType expression, at +// any depth. +// +// The depth matters: those expressions are the only reason a schema file imports the +// framework's types package, and a collection nested inside an object is just as much a +// use as one at the top level. Checking only the top level made the resource compile by +// luck -- its collection happens to be nested, and a separate "has nested shapes" rule +// pulled the import in -- while a data source whose nested objects are all scalars got +// an unused import and did not compile. +func usesElementTypes(s blueprint.Schema) bool { + for _, a := range s.Attributes { + if a.Drop { + continue + } + if a.Type.Kind.IsCollection() { + return true + } + if n := a.Type.NestedObject; n != nil && + usesElementTypes(blueprint.Schema{Attributes: n.Attributes}) { return true } } return false } -func timeoutsView(r blueprint.Resource, def blueprint.Timeouts) TimeoutsView { - pick := func(a, b int) int { - if a > 0 { - return a - } - if b > 0 { - return b +// defaultTimeoutSeconds is the deadline used when neither the block nor the provider +// conventions state one. A generated operation always has a bounded context: an +// unbounded one hangs a terraform apply with no diagnostic a practitioner can act on. +const defaultTimeoutSeconds = 180 + +// pickTimeout returns the first positive value, which is how a block-level timeout falls +// back to the provider default and then to the built-in. +func pickTimeout(vals ...int) int { + for _, v := range vals { + if v > 0 { + return v } - return 180 } + return defaultTimeoutSeconds +} + +func timeoutsView(r blueprint.Resource, def blueprint.Timeouts) TimeoutsView { return TimeoutsView{ - Create: pick(r.Timeouts.CreateSeconds, def.CreateSeconds), - Read: pick(r.Timeouts.ReadSeconds, def.ReadSeconds), - Update: pick(r.Timeouts.UpdateSeconds, def.UpdateSeconds), - Delete: pick(r.Timeouts.DeleteSeconds, def.DeleteSeconds), + Create: pickTimeout(r.Timeouts.CreateSeconds, def.CreateSeconds), + Read: pickTimeout(r.Timeouts.ReadSeconds, def.ReadSeconds), + Update: pickTimeout(r.Timeouts.UpdateSeconds, def.UpdateSeconds), + Delete: pickTimeout(r.Timeouts.DeleteSeconds, def.DeleteSeconds), } } @@ -464,8 +533,18 @@ func (e *ErrUnsupported) Error() string { return fmt.Sprintf("cannot render %s: %s", e.What, e.Why) } -func attributes(r blueprint.Resource, imports *importSet) (attrs, fields []string, err error) { - for _, a := range r.Attributes { +// attributes renders one schema's attributes and the matching model fields. +// +// It deliberately does not append the timeouts model field. That field's type differs by +// kind -- timeouts.Value for a resource, the datasource/timeouts package's Value for a +// data source -- and appending it here would make a function named "attributes" quietly +// responsible for something that is not an attribute. +func attributes( + sc schemaScope, + s blueprint.Schema, + imports *importSet, +) (attrs, fields []string, err error) { + for _, a := range s.Attributes { if a.Drop { continue } @@ -473,12 +552,12 @@ func attributes(r blueprint.Resource, imports *importSet) (attrs, fields []strin schemaType, ok := frameworkSchemaType[a.Type.Kind] if !ok { return nil, nil, &ErrUnsupported{ - What: fmt.Sprintf("attribute %q of resource %q", a.Name, r.Key), + What: fmt.Sprintf("attribute %q of %s", a.Name, sc.what), Why: fmt.Sprintf("type kind %q has no framework mapping", a.Type.Kind), } } - decl, err := attributeDecl(a, schemaType, imports) + decl, err := attributeDecl(sc, a, schemaType, imports) if err != nil { return nil, nil, err } @@ -488,16 +567,17 @@ func attributes(r blueprint.Resource, imports *importSet) (attrs, fields []strin fields = append(fields, fmt.Sprintf("%s %s `tfsdk:%q`", a.GoField, modelType, a.Name)) } - // The timeouts block is last in the model, matching the archetype, and is - // what the generated CRUD reads its per-operation deadlines from. - fields = append(fields, "Timeouts timeouts.Value `tfsdk:\"timeouts\"`") - return attrs, fields, nil } -func attributeDecl(a blueprint.Attribute, schemaType string, imports *importSet) (string, error) { +func attributeDecl( + sc schemaScope, + a blueprint.Attribute, + schemaType string, + imports *importSet, +) (string, error) { if a.Type.Kind.IsNested() { - return nestedAttributeDecl(a, imports) + return nestedAttributeDecl(sc, a, imports) } var b strings.Builder @@ -544,7 +624,7 @@ func attributeDecl(a blueprint.Attribute, schemaType string, imports *importSet) &b, "PlanModifiers", "planmodifier."+validatorKind(a.Type.Kind), - planModifiersFor(a, imports), + planModifiersFor(sc, a, imports), imports, ) @@ -567,7 +647,19 @@ func attributeDecl(a blueprint.Attribute, schemaType string, imports *importSet) // even when nothing about it changed. UseStateForUnknown is the standard remedy, // and applying it by default is what stops a generated provider producing noisy // plans that train people to skim them. -func planModifiersFor(a blueprint.Attribute, imports *importSet) []blueprint.CustomCode { +func planModifiersFor( + sc schemaScope, + a blueprint.Attribute, + imports *importSet, +) []blueprint.CustomCode { + // Only a managed resource has a plan to modify, and this is the one place the + // generator can put a plan modifier somewhere blueprint.Validate cannot see it: + // the UseStateForUnknown below is synthesised here rather than declared in the + // blueprint, so nothing upstream would refuse it on a data source. + if !sc.kind.SupportsPlanModifiers() { + return nil + } + if len(a.PlanModifiers) > 0 { return a.PlanModifiers } @@ -623,6 +715,11 @@ func writeAttributeFlags(b *strings.Builder, a blueprint.Attribute) { // validatorKind is the framework's per-type sub-package suffix, which both the // validator and planmodifier packages share. +// +// The nested kinds matter as much as the scalars: a ListNestedAttribute takes +// []validator.List, and the fallthrough to String below would have emitted +// []validator.String against it. That was unreachable while nested attributes silently +// dropped their validators, and became reachable the moment they stopped. func validatorKind(k blueprint.TypeKind) string { switch k { case blueprint.KindBool: @@ -643,6 +740,12 @@ func validatorKind(k blueprint.TypeKind) string { return "Set" case blueprint.KindMap: return "Map" + case blueprint.KindListNested: + return "List" + case blueprint.KindSetNested: + return "Set" + case blueprint.KindSingleNested: + return "Object" default: return "String" } @@ -721,7 +824,7 @@ func constructView(r blueprint.Resource, shapes []nestedShape) ConstructView { v.NestedObject = append(v.NestedObject, nestedExpandView(sh)) } - for _, a := range r.Attributes { + for _, a := range r.Schema.Attributes { if a.Drop || a.Wire.SkipExpand || a.Wire.Expand == nil { continue } @@ -743,8 +846,8 @@ func constructView(r blueprint.Resource, shapes []nestedShape) ConstructView { return v } -func stateView(r blueprint.Resource, shapes []nestedShape) StateView { - v := StateView{ResponseType: r.Binding.Body.ResponseType} +func stateView(s blueprint.Schema, responseType string, shapes []nestedShape) StateView { + v := StateView{ResponseType: responseType} for _, sh := range shapes { if sh.attr.Wire.SkipFlatten { @@ -753,7 +856,7 @@ func stateView(r blueprint.Resource, shapes []nestedShape) StateView { v.NestedObject = append(v.NestedObject, nestedFlattenView(sh)) } - for _, a := range r.Attributes { + for _, a := range s.Attributes { if a.Drop || a.Wire.SkipFlatten || a.Wire.Flatten == nil { continue } @@ -815,7 +918,10 @@ func crudView(bp blueprint.Blueprint, r blueprint.Resource) (CRUDView, error) { if o.op == nil { continue } - view, err := opView(r, *o.op, o.phase, o.errOp, o.timeout) + view, err := opView( + fmt.Sprintf("resource %q", r.Key), r.Binding.Service.Accessor, + *o.op, o.phase, o.errOp, o.timeout, + ) if err != nil { return CRUDView{}, err } @@ -848,21 +954,23 @@ func crudView(bp blueprint.Blueprint, r blueprint.Resource) (CRUDView, error) { return v, nil } +// opView renders one SDK call. what names the owning block for error messages and +// accessor reaches its service, so this serves a resource and a data source alike. func opView( - r blueprint.Resource, + what, accessor string, op blueprint.Operation, phase, errOp, timeout string, ) (*OpView, error) { if op.Style != blueprint.CallStyleMethod { return nil, &ErrUnsupported{ - What: fmt.Sprintf("operation %q of resource %q", op.Method, r.Key), + What: fmt.Sprintf("operation %q of %s", op.Method, what), Why: fmt.Sprintf("call style %q is not implemented", op.Style), } } args := make([]string, 0, len(op.Args)) for _, a := range op.Args { - expr, err := argExpr(r, a) + expr, err := argExpr(what, a) if err != nil { return nil, err } @@ -872,7 +980,7 @@ func opView( v := &OpView{ Call: fmt.Sprintf( "%s.%s(%s)", - r.Binding.Service.Accessor, + accessor, op.Method, strings.Join(args, ", "), ), @@ -896,7 +1004,7 @@ func opView( v.Assign = "err :=" default: return nil, &ErrUnsupported{ - What: fmt.Sprintf("operation %q of resource %q", op.Method, r.Key), + What: fmt.Sprintf("operation %q of %s", op.Method, what), Why: fmt.Sprintf("return arity %q is not implemented", op.Return), } } @@ -917,7 +1025,7 @@ func resultVarFor(phase string) string { } } -func argExpr(r blueprint.Resource, a blueprint.Argument) (string, error) { +func argExpr(what string, a blueprint.Argument) (string, error) { if a.Expr != "" { return a.Expr, nil } @@ -931,21 +1039,26 @@ func argExpr(r blueprint.Resource, a blueprint.Argument) (string, error) { return fmt.Sprintf("state.%s.ValueString()", a.Field), nil case blueprint.ArgPlanField: return fmt.Sprintf("plan.%s.ValueString()", a.Field), nil + case blueprint.ArgConfigField: + // A data source reads its arguments from configuration: it has no prior state + // and no plan. The variable is named for what it holds rather than reusing + // "state", which would read as a lie in a generated data source body. + return fmt.Sprintf("data.%s.ValueString()", a.Field), nil case blueprint.ArgLiteral: return "", &ErrUnsupported{ - What: fmt.Sprintf("argument of resource %q", r.Key), + What: fmt.Sprintf("argument of %s", what), Why: "a literal argument needs an expression", } default: return "", &ErrUnsupported{ - What: fmt.Sprintf("argument of resource %q", r.Key), + What: fmt.Sprintf("argument of %s", what), Why: fmt.Sprintf("argument kind %q is not implemented", a.Kind), } } } func findAttribute(r blueprint.Resource, name string) (blueprint.Attribute, bool) { - for _, a := range r.Attributes { + for _, a := range r.Schema.Attributes { if a.Name == name { return a, true } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 3c82db2c..8b84ce7c 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -248,7 +248,7 @@ func TestUnit_Render_AttributeDeclarations(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := attributeDecl(tc.attr, frameworkSchemaType[tc.attr.Type.Kind], imports) + got, err := attributeDecl(testResourceScope, tc.attr, frameworkSchemaType[tc.attr.Type.Kind], imports) if err != nil { t.Fatalf("attributeDecl: %v", err) } @@ -274,7 +274,7 @@ func TestUnit_Render_ComputedStringsGetUseStateForUnknown(t *testing.T) { imports := newImportSet() - got, err := attributeDecl(blueprint.Attribute{ + got, err := attributeDecl(testResourceScope, blueprint.Attribute{ Name: "id", GoField: "ID", ComputedOptionalRequired: blueprint.Computed, Type: blueprint.AttrType{Kind: blueprint.KindString}, }, "StringAttribute", imports) @@ -287,7 +287,7 @@ func TestUnit_Render_ComputedStringsGetUseStateForUnknown(t *testing.T) { } // An explicit plan modifier replaces the default rather than adding to it. - custom := planModifiersFor(blueprint.Attribute{ + custom := planModifiersFor(testResourceScope, blueprint.Attribute{ ComputedOptionalRequired: blueprint.Computed, Type: blueprint.AttrType{Kind: blueprint.KindString}, PlanModifiers: []blueprint.CustomCode{{SchemaDefinition: "mine()"}}, @@ -298,7 +298,7 @@ func TestUnit_Render_ComputedStringsGetUseStateForUnknown(t *testing.T) { // Optional attributes get none: pinning a configurable value to prior state // would stop a practitioner changing it. - if got := planModifiersFor(blueprint.Attribute{ + if got := planModifiersFor(testResourceScope, blueprint.Attribute{ ComputedOptionalRequired: blueprint.Optional, Type: blueprint.AttrType{Kind: blueprint.KindString}, }, imports); len(got) != 0 { @@ -311,7 +311,7 @@ func TestUnit_Render_ValidatorsAndImportsAreRegistered(t *testing.T) { imports := newImportSet() - got, err := attributeDecl(blueprint.Attribute{ + got, err := attributeDecl(testResourceScope, blueprint.Attribute{ Name: "mode", GoField: "Mode", ComputedOptionalRequired: blueprint.Optional, Type: blueprint.AttrType{Kind: blueprint.KindString}, Validators: []blueprint.CustomCode{{ @@ -336,7 +336,7 @@ func TestUnit_Render_Defaults(t *testing.T) { imports := newImportSet() - static, err := attributeDecl(blueprint.Attribute{ + static, err := attributeDecl(testResourceScope, blueprint.Attribute{ Name: "size", GoField: "Size", ComputedOptionalRequired: blueprint.ComputedOptional, Type: blueprint.AttrType{Kind: blueprint.KindInt64}, Default: &blueprint.Default{Static: &blueprint.Literal{Kind: blueprint.KindInt64, Raw: "5"}}, @@ -348,7 +348,7 @@ func TestUnit_Render_Defaults(t *testing.T) { t.Errorf("static default not rendered:\n%s", static) } - custom, err := attributeDecl(blueprint.Attribute{ + custom, err := attributeDecl(testResourceScope, blueprint.Attribute{ Name: "x", GoField: "X", ComputedOptionalRequired: blueprint.ComputedOptional, Type: blueprint.AttrType{Kind: blueprint.KindString}, Default: &blueprint.Default{Custom: &blueprint.CustomCode{SchemaDefinition: "mydefault()"}}, @@ -362,7 +362,7 @@ func TestUnit_Render_Defaults(t *testing.T) { // A default with neither form set is a blueprint bug, and must not silently // render nothing. - if _, err := attributeDecl(blueprint.Attribute{ + if _, err := attributeDecl(testResourceScope, blueprint.Attribute{ Name: "y", GoField: "Y", ComputedOptionalRequired: blueprint.Computed, Type: blueprint.AttrType{Kind: blueprint.KindString}, Default: &blueprint.Default{}, }, "StringAttribute", imports); err == nil { @@ -374,7 +374,7 @@ func TestUnit_Render_UnmappableTypeIsAHardError(t *testing.T) { t.Parallel() bp := pilot(t) - bp.Resources[0].Attributes[1].Type = blueprint.AttrType{Kind: "octopus"} + bp.Resources[0].Schema.Attributes[1].Type = blueprint.AttrType{Kind: "octopus"} _, err := Resource(bp, bp.Resources[0], Options{}) if err == nil { @@ -395,7 +395,7 @@ func TestUnit_Render_UnmappableTypeIsAHardError(t *testing.T) { func TestUnit_Render_CollectionWithoutAnElementTypeFails(t *testing.T) { t.Parallel() - _, err := attributeDecl(blueprint.Attribute{ + _, err := attributeDecl(testResourceScope, blueprint.Attribute{ Name: "x", GoField: "X", ComputedOptionalRequired: blueprint.Optional, Type: blueprint.AttrType{Kind: blueprint.KindSet}, }, "SetAttribute", newImportSet()) @@ -582,7 +582,7 @@ func TestUnit_Render_RegistrationIsSortedAndComplete(t *testing.T) { // A second resource, added out of order, so the sort is exercised. second := bp.Resources[0] second.Key = "aaa" - second.TerraformType = "thousandeyes_aaa" + second.Name = "aaa" second.GoPackage = "aaa" second.GoPackageAlias = "v7Aaa" second.GoTypeName = "AaaResource" @@ -607,9 +607,24 @@ func TestUnit_Render_RegistrationIsSortedAndComplete(t *testing.T) { t.Errorf("a dropped resource should not be registered: %v", got.Entries) } - // No data sources yet, and an empty list must still render. + // Data sources register through the same path, sorted by the same rule. + ds := Registration(bp, KindDataSources, Options{}) + if len(ds.Entries) != 2 { + t.Fatalf("got %d data source entries, want 2: %v", len(ds.Entries), ds.Entries) + } + if !strings.HasPrefix(ds.Entries[0], "v7TagData.") { + t.Errorf("data source entries are not sorted by alias: %v", ds.Entries) + } + if !strings.Contains(ds.Imports, "v7TagsData ") { + t.Errorf("data source imports missing an alias:\n%s", ds.Imports) + } + + // A dropped data source must not be registered either, and an empty list must + // still render rather than failing. + bp.DataSources[0].Drop = true + bp.DataSources[1].Drop = true if got := Registration(bp, KindDataSources, Options{}); len(got.Entries) != 0 { - t.Errorf("expected no data sources: %v", got.Entries) + t.Errorf("dropped data sources should not be registered: %v", got.Entries) } } diff --git a/internal/sdkbind/sdkbind_test.go b/internal/sdkbind/sdkbind_test.go index 1188f22f..b1c66bca 100644 --- a/internal/sdkbind/sdkbind_test.go +++ b/internal/sdkbind/sdkbind_test.go @@ -150,7 +150,7 @@ func TestUnit_SDKBind_CatchesBindingMistakes(t *testing.T) { // Plausible and absent, which is the shape of the mistake. "Colour" would not // do: the SDK type really has one, so the binding would be valid and the case // would silently stop testing anything. - b.Resources[0].Attributes[3].Wire.SDKField = "Shade" + b.Resources[0].Schema.Attributes[3].Wire.SDKField = "Shade" }, wantPath: "wire.sdkField", wantDetail: `has no field "Shade"`, @@ -219,7 +219,7 @@ func TestUnit_SDKBind_DoesNotCascadeOnABadBodyType(t *testing.T) { t.Parallel() bp, l := loadPilot(t) - attributeCount := len(bp.Resources[0].Attributes) + attributeCount := len(bp.Resources[0].Schema.Attributes) bp.Resources[0].Binding.Body.ResponseType = "tags.TagResponse" report := Verify(l, bp) diff --git a/internal/sdkbind/verify.go b/internal/sdkbind/verify.go index f98d77f2..4f220e99 100644 --- a/internal/sdkbind/verify.go +++ b/internal/sdkbind/verify.go @@ -80,8 +80,10 @@ func resolveClientType(l *Loader, bp blueprint.Blueprint) (types.Type, error) { sdk := bp.Provider.SDK if sdk.ClientImport.Path == "" { - return nil, fmt.Errorf("%w: provider.sdk.clientImport.path is empty, so the client type cannot be resolved", - ErrBindings) + return nil, fmt.Errorf( + "%w: provider.sdk.clientImport.path is empty, so the client type cannot be resolved", + ErrBindings, + ) } // "*thousandeyes.Client" -> "Client". The package qualifier is carried by @@ -99,7 +101,13 @@ func resolveClientType(l *Loader, bp blueprint.Blueprint) (types.Type, error) { return named, nil } -func verifyResource(l *Loader, bp blueprint.Blueprint, res blueprint.Resource, clientType types.Type, r *Report) { +func verifyResource( + l *Loader, + bp blueprint.Blueprint, + res blueprint.Resource, + clientType types.Type, + r *Report, +) { svc := res.Binding.Service // The accessor. This is the check that pays for the package: it walks the @@ -119,8 +127,10 @@ func verifyResource(l *Loader, bp blueprint.Blueprint, res blueprint.Resource, c r.Problems = append(r.Problems, Problem{ Resource: res.Key, Path: "binding.service.accessor", - Detail: fmt.Sprintf("%q is not of the form \"r.client....\", so it cannot be verified", - svc.Accessor), + Detail: fmt.Sprintf( + "%q is not of the form \"r.client....\", so it cannot be verified", + svc.Accessor, + ), }) } @@ -238,7 +248,10 @@ func verifyBodyModels(l *Loader, res blueprint.Resource, r *Report) (requestOK, return false } if _, err := l.LookupType(svc.ImportPath, name); err != nil { - r.Problems = append(r.Problems, Problem{Resource: res.Key, Path: path, Detail: unwrapDetail(err)}) + r.Problems = append( + r.Problems, + Problem{Resource: res.Key, Path: path, Detail: unwrapDetail(err)}, + ) return false } r.Checked++ @@ -272,7 +285,7 @@ func verifyWireFields(l *Loader, res blueprint.Resource, requestOK, responseOK b response = typeNameOf(res.Binding.Body.ResponseType) } - for _, a := range res.Attributes { + for _, a := range res.Schema.Attributes { if a.Drop || a.Wire.SDKField == "" { continue } @@ -292,7 +305,12 @@ func verifyWireFields(l *Loader, res blueprint.Resource, requestOK, responseOK b r.Problems = append(r.Problems, Problem{ Resource: res.Key, Path: fmt.Sprintf("attributes[%s].wire.sdkField", a.Name), - Detail: fmt.Sprintf("%s needs it on %s: %s", direction, typeName, unwrapDetail(err)), + Detail: fmt.Sprintf( + "%s needs it on %s: %s", + direction, + typeName, + unwrapDetail(err), + ), }) continue } diff --git a/internal/templates/_nested_flatten.go.tmpl b/internal/templates/_nested_flatten.go.tmpl new file mode 100644 index 00000000..1419b098 --- /dev/null +++ b/internal/templates/_nested_flatten.go.tmpl @@ -0,0 +1,66 @@ +{{- /* +nestedFlattenHelpers renders the per-shape helpers that turn SDK structs into the +framework values state holds. + +It is defined here and included by both state.go.tmpl and datasource_state.go.tmpl +because the two are character-for-character identical: a flatten helper reads an SDK +struct and writes a framework value, and neither of those depends on whether a resource +or a data source is asking. The dot passed in is the StateView, not the whole view, so +this fragment cannot reach anything kind-specific even by accident. +*/ -}} +{{ define "nestedFlattenHelpers" }} +{{- range .NestedObject }} +// {{ .FuncName }} converts SDK structs into the framework value state holds. +{{- if .IsCollection }} +func {{ .FuncName }}(ctx context.Context, in []{{ .SDKType }}) ({{ .FrameworkType }}, diag.Diagnostics) { + var diags diag.Diagnostics +{{- if .NeedsDiagnostics }} + var d diag.Diagnostics +{{- end }} + + // A nil slice is null and an empty slice is empty. Conflating them produces a + // diff no configuration change can resolve. + if in == nil { + return types.{{ .Container }}Null({{ .ObjectTypeVar }}), diags + } + + models := make([]{{ .ModelType }}, 0, len(in)) + for _, item := range in { + var m {{ .ModelType }} +{{ range .Assignments }} + {{ . }} +{{- end }} + models = append(models, m) + } + + out, d2 := types.{{ .Container }}ValueFrom(ctx, {{ .ObjectTypeVar }}, models) + diags.Append(d2...) + + return out, diags +} +{{- else }} +func {{ .FuncName }}(ctx context.Context, in *{{ .SDKType }}) ({{ .FrameworkType }}, diag.Diagnostics) { + var diags diag.Diagnostics +{{- if .NeedsDiagnostics }} + var d diag.Diagnostics +{{- end }} + + if in == nil { + return types.ObjectNull({{ .ObjectTypeVar }}.AttrTypes), diags + } + + item := *in + + var m {{ .ModelType }} +{{ range .Assignments }} + {{ . }} +{{- end }} + + out, d2 := types.ObjectValueFrom(ctx, {{ .ObjectTypeVar }}.AttrTypes, m) + diags.Append(d2...) + + return out, diags +} +{{- end }} +{{ end }} +{{- end }} diff --git a/internal/templates/datasource.go.tmpl b/internal/templates/datasource.go.tmpl new file mode 100644 index 00000000..a5216481 --- /dev/null +++ b/internal/templates/datasource.go.tmpl @@ -0,0 +1,62 @@ +{{ .Header }} + +package {{ .Package }} + +import ( +{{ .Imports.DataSource }} +) + +const ( + // DataSourceName is the Terraform type this data source registers as. + DataSourceName = "{{ .DataSourceName }}" + + // ReadTimeout is the default read deadline in seconds. A practitioner overrides it + // with the data source's timeouts block. Read is the only operation a data source + // has, so it is the only deadline there is. + ReadTimeout = {{ .ReadTimeout }} +) + +// Compile-time assertions that this data source implements what it claims to. They +// turn a missing method into a build failure rather than a runtime surprise. +var ( +{{- range .Interfaces }} + {{ . }} +{{- end }} +) + +// {{ .ConstructorFn }} returns the {{ .DataSourceName }} data source. +func {{ .ConstructorFn }}() datasource.DataSource { + return &{{ .GoTypeName }}{} +} + +// {{ .GoTypeName }} implements the {{ .DataSourceName }} data source. +type {{ .GoTypeName }} struct { + client {{ .SDKClientType }} +} + +// Metadata returns the data source type name. +func (d *{{ .GoTypeName }}) Metadata(_ context.Context, _ datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = DataSourceName +} + +// Configure receives the configured SDK client from the provider. +func (d *{{ .GoTypeName }}) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = client.ForDataSource(ctx, req, resp, DataSourceName) +} + +// Schema returns the data source schema. +func (d *{{ .GoTypeName }}) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ +{{- if .MarkdownDescription }} + MarkdownDescription: {{ printf "%q" .MarkdownDescription }}, +{{- end }} + Attributes: map[string]schema.Attribute{ +{{- range .SchemaAttributes }} + {{ . }}, +{{- end }} + }, + Blocks: map[string]schema.Block{ + "timeouts": commonschema.DataSourceTimeouts(ctx), + }, + } +} diff --git a/internal/templates/datasource_model.go.tmpl b/internal/templates/datasource_model.go.tmpl new file mode 100644 index 00000000..1a34ee48 --- /dev/null +++ b/internal/templates/datasource_model.go.tmpl @@ -0,0 +1,45 @@ +{{ .Header }} +{{ if .DocRefComment }} +{{ .DocRefComment }} +{{- end }} +package {{ .Package }} + +import ( +{{- if .NestedModels }} + "github.com/hashicorp/terraform-plugin-framework/attr" +{{- end }} + "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// {{ .ModelTypeName }} is the Terraform state for a {{ .DataSourceName }}. +// +// Field order follows the schema, and the timeouts value is last. +type {{ .ModelTypeName }} struct { +{{- range .ModelFields }} + {{ . }} +{{- end }} +} +{{ range .NestedModels }} +// {{ .GoTypeName }} is one element of a nested attribute. +// +// It is a sibling of the data source model rather than an inner type, because the +// framework decodes collection elements into a named type. +type {{ .GoTypeName }} struct { +{{- range .Fields }} + {{ . }} +{{- end }} +} + +// {{ .AttrTypesVar }} describes {{ .GoTypeName }} to the framework. +// +// It is declared once and referenced by the conversion helper, so the shape cannot +// drift between the declaration and the code that populates it. +var {{ .AttrTypesVar }} = map[string]attr.Type{ +{{- range .AttrTypeEntries }} + {{ . }} +{{- end }} +} + +var {{ .ObjectTypeVar }} = types.ObjectType{AttrTypes: {{ .AttrTypesVar }}} +{{ end }} diff --git a/internal/templates/datasource_read.go.tmpl b/internal/templates/datasource_read.go.tmpl new file mode 100644 index 00000000..aa899f7c --- /dev/null +++ b/internal/templates/datasource_read.go.tmpl @@ -0,0 +1,51 @@ +{{ .Header }} + +package {{ .Package }} + +import ( +{{ .Imports.Read }} +) + +{{ with .Read }} +// Read fetches {{ $.DataSourceName }} from the API. +// +// A data source reads its arguments from configuration rather than from prior state: +// there is no prior state to read, because nothing here was created by Terraform. +func (d *{{ $.GoTypeName }}) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data {{ $.ModelTypeName }} + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "reading data source", map[string]any{"dataSource": DataSourceName}) + + ctx, cancel := crud.HandleTimeout(ctx, data.Timeouts.Read, ReadTimeout*time.Second, &resp.Diagnostics) + if cancel == nil { + return + } + defer cancel() + + {{ .Assign }} {{ .Call }} + if err != nil { + // Unlike a resource read, a missing object is an error rather than a signal to + // drop state: the practitioner named something that is not there, and silently + // returning nothing would leave whatever referenced it failing further on with + // no indication of why. + errors.Handle(&resp.Diagnostics, DataSourceName, {{ .ErrorOp }}, err) + return + } + +{{- if $.State.NeedsDiagnostics }} + resp.Diagnostics.Append(mapRemoteStateToTerraform(ctx, &data, {{ .ResultVar }})...) + if resp.Diagnostics.HasError() { + return + } +{{- else }} + mapRemoteStateToTerraform(ctx, &data, {{ .ResultVar }}) +{{- end }} + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} +{{ end }} diff --git a/internal/templates/datasource_state.go.tmpl b/internal/templates/datasource_state.go.tmpl new file mode 100644 index 00000000..cb1275d0 --- /dev/null +++ b/internal/templates/datasource_state.go.tmpl @@ -0,0 +1,46 @@ +{{ .Header }} + +package {{ .Package }} + +import ( +{{ .Imports.State }} +) + +// mapRemoteStateToTerraform maps the API's response onto Terraform state. +// +// This function replaces what a reflection-based provider does at runtime. Being +// generated code, a field whose conversion is wrong is a compile error rather +// than a surprise during apply. +{{- if .State.NeedsDiagnostics }} +func mapRemoteStateToTerraform(ctx context.Context, data *{{ .ModelTypeName }}, remote *{{ .State.ResponseType }}) diag.Diagnostics { + var ( + diags diag.Diagnostics + d diag.Diagnostics + ) + + if remote == nil { + tflog.Debug(ctx, "remote object is nil, leaving state untouched", map[string]any{ + "dataSource": DataSourceName, + }) + return diags + } +{{- else }} +func mapRemoteStateToTerraform(ctx context.Context, data *{{ .ModelTypeName }}, remote *{{ .State.ResponseType }}) { + if remote == nil { + tflog.Debug(ctx, "remote object is nil, leaving state untouched", map[string]any{ + "dataSource": DataSourceName, + }) + return + } +{{- end }} +{{ range .State.Assignments }} + {{ . }} +{{- end }} + + tflog.Debug(ctx, "mapped remote state", map[string]any{"dataSource": DataSourceName}) +{{- if .State.NeedsDiagnostics }} + + return diags +{{- end }} +} +{{ template "nestedFlattenHelpers" .State }} diff --git a/internal/templates/state.go.tmpl b/internal/templates/state.go.tmpl index 50e2d4d9..f6262e3f 100644 --- a/internal/templates/state.go.tmpl +++ b/internal/templates/state.go.tmpl @@ -43,57 +43,4 @@ func mapRemoteStateToTerraform(ctx context.Context, data *{{ .ModelTypeName }}, return diags {{- end }} } -{{ range .State.NestedObject }} -// {{ .FuncName }} converts SDK structs into the framework value state holds. -{{- if .IsCollection }} -func {{ .FuncName }}(ctx context.Context, in []{{ .SDKType }}) ({{ .FrameworkType }}, diag.Diagnostics) { - var diags diag.Diagnostics -{{- if .NeedsDiagnostics }} - var d diag.Diagnostics -{{- end }} - - // A nil slice is null and an empty slice is empty. Conflating them produces a - // diff no configuration change can resolve. - if in == nil { - return types.SetNull({{ .ObjectTypeVar }}), diags - } - - models := make([]{{ .ModelType }}, 0, len(in)) - for _, item := range in { - var m {{ .ModelType }} -{{ range .Assignments }} - {{ . }} -{{- end }} - models = append(models, m) - } - - out, d2 := types.SetValueFrom(ctx, {{ .ObjectTypeVar }}, models) - diags.Append(d2...) - - return out, diags -} -{{- else }} -func {{ .FuncName }}(ctx context.Context, in *{{ .SDKType }}) ({{ .FrameworkType }}, diag.Diagnostics) { - var diags diag.Diagnostics -{{- if .NeedsDiagnostics }} - var d diag.Diagnostics -{{- end }} - - if in == nil { - return types.ObjectNull({{ .ObjectTypeVar }}.AttrTypes), diags - } - - item := *in - - var m {{ .ModelType }} -{{ range .Assignments }} - {{ . }} -{{- end }} - - out, d2 := types.ObjectValueFrom(ctx, {{ .ObjectTypeVar }}.AttrTypes, m) - diags.Append(d2...) - - return out, diags -} -{{- end }} -{{ end }} +{{ template "nestedFlattenHelpers" .State }} diff --git a/interop-specs/thousandeyes/provider-code-spec.json b/interop-specs/thousandeyes/provider-code-spec.json index e267576b..0c9913e2 100644 --- a/interop-specs/thousandeyes/provider-code-spec.json +++ b/interop-specs/thousandeyes/provider-code-spec.json @@ -1,4 +1,392 @@ { + "datasources": [ + { + "name": "tag", + "schema": { + "attributes": [ + { + "name": "id", + "string": { + "computed_optional_required": "required", + "description": "The identifier of the tag to look up." + } + }, + { + "name": "key", + "string": { + "computed_optional_required": "computed", + "description": "The tag's key. Together with `value` this forms the label applied to assigned objects." + } + }, + { + "name": "value", + "string": { + "computed_optional_required": "computed", + "description": "The tag's value.\n\n\nThe API enforces this field's presence, which the specification does not declare.\n" + } + }, + { + "name": "color", + "string": { + "computed_optional_required": "computed", + "description": "The tag's display colour as a hex string. Computed as well as optional because the API assigns one when it is omitted; this has not yet been confirmed by probing.\n\n\nObserved: the API assigns \"#A7EB10\" when this is omitted.\n" + } + }, + { + "name": "description", + "string": { + "computed_optional_required": "computed", + "description": "A human-readable description of the tag." + } + }, + { + "name": "icon", + "string": { + "computed_optional_required": "computed", + "description": "The tag's icon.\n\n\nObserved: the API assigns \"LABEL\" when this is omitted.\n" + } + }, + { + "name": "object_type", + "string": { + "computed_optional_required": "computed", + "description": "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated because the API's enumerations are open: an undocumented value must not be rejected by the provider.\n\n\nValues accepted here: `test`, `dashboard`, `endpoint-test`, `v-agent`, `connected-devices-test`.\nThe specification documents `endpoint-agent`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n" + } + }, + { + "name": "access_type", + "string": { + "computed_optional_required": "computed", + "description": "The tag's access level. Documented values are `all`, `partner` and `system`.\n\n\nValues accepted here: `all`.\nThe specification documents `system`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n" + } + }, + { + "name": "match_type", + "string": { + "computed_optional_required": "computed", + "description": "How the tag's filters combine when it is assigned dynamically.\n\n\nValues accepted here: `and`, `or`.\n" + } + }, + { + "name": "type", + "string": { + "computed_optional_required": "computed", + "description": "The tag's type, assigned by the API." + } + }, + { + "name": "built_in", + "bool": { + "computed_optional_required": "computed", + "description": "Whether the tag is built in rather than user-created." + } + }, + { + "name": "account_group_id", + "int64": { + "computed_optional_required": "computed", + "description": "The account group the tag belongs to. Computed rather than configurable: the provider scopes every request through its own `account_group_id` setting, so accepting a second value here would let the two disagree." + } + }, + { + "name": "create_date", + "string": { + "computed_optional_required": "computed", + "description": "When the tag was created." + } + }, + { + "name": "modified_date", + "string": { + "computed_optional_required": "computed", + "description": "When the tag was last modified." + } + }, + { + "name": "legacy_id", + "float64": { + "computed_optional_required": "computed", + "description": "The tag's identifier in the v6 API. Typed as a number because the specification declares it as one, although observed values are integral; probing will settle whether this should be an integer." + } + }, + { + "name": "assignments", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "associated_external_type": { + "type": "tags.Assignment" + }, + "attributes": [ + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The identifier of the object the tag is assigned to." + } + }, + { + "name": "type", + "string": { + "computed_optional_required": "computed", + "description": "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`." + } + } + ] + }, + "description": "Objects this tag is assigned to. The API returns assignments only when the request asks for them to be expanded, which this data source does not, so this is null rather than empty." + } + }, + { + "name": "filters", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "associated_external_type": { + "type": "tags.TagFilter" + }, + "attributes": [ + { + "name": "key", + "string": { + "computed_optional_required": "computed", + "description": "The filter key used for matching." + } + }, + { + "name": "mode", + "string": { + "computed_optional_required": "computed", + "description": "How the filter values are matched." + } + }, + { + "name": "scope", + "string": { + "computed_optional_required": "computed", + "description": "The scope the filter applies within." + } + }, + { + "name": "values", + "set": { + "computed_optional_required": "computed", + "element_type": { + "string": {} + }, + "description": "The values the filter matches against." + } + } + ] + }, + "description": "Filters that dynamically assign this tag to endpoint agents." + } + } + ], + "markdown_description": "Looks up a single ThousandEyes tag by its identifier." + } + }, + { + "name": "tags", + "schema": { + "attributes": [ + { + "name": "tags", + "list_nested": { + "computed_optional_required": "computed", + "nested_object": { + "associated_external_type": { + "type": "tags.Tag" + }, + "attributes": [ + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The tag's unique identifier, assigned by the API." + } + }, + { + "name": "key", + "string": { + "computed_optional_required": "computed", + "description": "The tag's key. Together with `value` this forms the label applied to assigned objects." + } + }, + { + "name": "value", + "string": { + "computed_optional_required": "computed", + "description": "The tag's value.\n\n\nThe API enforces this field's presence, which the specification does not declare.\n" + } + }, + { + "name": "color", + "string": { + "computed_optional_required": "computed", + "description": "The tag's display colour as a hex string. Computed as well as optional because the API assigns one when it is omitted; this has not yet been confirmed by probing.\n\n\nObserved: the API assigns \"#A7EB10\" when this is omitted.\n" + } + }, + { + "name": "description", + "string": { + "computed_optional_required": "computed", + "description": "A human-readable description of the tag." + } + }, + { + "name": "icon", + "string": { + "computed_optional_required": "computed", + "description": "The tag's icon.\n\n\nObserved: the API assigns \"LABEL\" when this is omitted.\n" + } + }, + { + "name": "object_type", + "string": { + "computed_optional_required": "computed", + "description": "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated because the API's enumerations are open: an undocumented value must not be rejected by the provider.\n\n\nValues accepted here: `test`, `dashboard`, `endpoint-test`, `v-agent`, `connected-devices-test`.\nThe specification documents `endpoint-agent`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n" + } + }, + { + "name": "access_type", + "string": { + "computed_optional_required": "computed", + "description": "The tag's access level. Documented values are `all`, `partner` and `system`.\n\n\nValues accepted here: `all`.\nThe specification documents `system`, which the API rejected.\nThe API enforces this field's presence, which the specification does not declare.\n" + } + }, + { + "name": "match_type", + "string": { + "computed_optional_required": "computed", + "description": "How the tag's filters combine when it is assigned dynamically.\n\n\nValues accepted here: `and`, `or`.\n" + } + }, + { + "name": "type", + "string": { + "computed_optional_required": "computed", + "description": "The tag's type, assigned by the API." + } + }, + { + "name": "built_in", + "bool": { + "computed_optional_required": "computed", + "description": "Whether the tag is built in rather than user-created." + } + }, + { + "name": "account_group_id", + "int64": { + "computed_optional_required": "computed", + "description": "The account group the tag belongs to. Computed rather than configurable: the provider scopes every request through its own `account_group_id` setting, so accepting a second value here would let the two disagree." + } + }, + { + "name": "create_date", + "string": { + "computed_optional_required": "computed", + "description": "When the tag was created." + } + }, + { + "name": "modified_date", + "string": { + "computed_optional_required": "computed", + "description": "When the tag was last modified." + } + }, + { + "name": "legacy_id", + "float64": { + "computed_optional_required": "computed", + "description": "The tag's identifier in the v6 API. Typed as a number because the specification declares it as one, although observed values are integral; probing will settle whether this should be an integer." + } + }, + { + "name": "assignments", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "associated_external_type": { + "type": "tags.Assignment" + }, + "attributes": [ + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The identifier of the object the tag is assigned to." + } + }, + { + "name": "type", + "string": { + "computed_optional_required": "computed", + "description": "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, `dashboard` and `connected-devices-test`." + } + } + ] + }, + "description": "Objects this tag is assigned to. A set rather than a list because the API does not preserve ordering." + } + }, + { + "name": "filters", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "associated_external_type": { + "type": "tags.TagFilter" + }, + "attributes": [ + { + "name": "key", + "string": { + "computed_optional_required": "computed", + "description": "The filter key used for matching." + } + }, + { + "name": "mode", + "string": { + "computed_optional_required": "computed", + "description": "How the filter values are matched." + } + }, + { + "name": "scope", + "string": { + "computed_optional_required": "computed", + "description": "The scope the filter applies within." + } + }, + { + "name": "values", + "set": { + "computed_optional_required": "computed", + "element_type": { + "string": {} + }, + "description": "The values the filter matches against." + } + } + ] + }, + "description": "Filters that dynamically assign this tag to endpoint agents." + } + } + ] + }, + "description": "Every tag visible to the account group, in the order the API returned them. A list rather than a set because the API's ordering is the only ordering there is, and a set would discard it." + } + } + ], + "markdown_description": "Lists every ThousandEyes tag visible to the configured account group." + } + } + ], "provider": { "name": "thousandeyes" }, diff --git a/pilot/thousandeyes/.tfpluginframeworkgen/manifest.json b/pilot/thousandeyes/.tfpluginframeworkgen/manifest.json index 896cd815..ffd0dc41 100644 --- a/pilot/thousandeyes/.tfpluginframeworkgen/manifest.json +++ b/pilot/thousandeyes/.tfpluginframeworkgen/manifest.json @@ -4,37 +4,77 @@ "files": [ { "path": "internal/provider/datasources.go", - "sha256": "0d4b3c778c016df66c913a3badc554bb609c00c634d6dd76da35cc9fa650890a", + "sha256": "b1784f9d8bf2e06e6328f741ae4f3c7f94cb1019639b890eb7c14d5f0ba8881b", "blueprint": "blueprints/thousandeyes" }, { "path": "internal/provider/resources.go", - "sha256": "c9ec65b4e1cc9b1dd1f799778dd5f0ca7ea6a2b4152ce08fb1ede8e26af57543", + "sha256": "91d67b5911dc19d896900fba47c4505ad1e59aac43262dd999fb76fcb5cc3c04", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tag/datasource.go", + "sha256": "ee506f07ff2408af79d8fc254bfe5b653efe195f9e2cba7cf5bcf9231a517eee", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tag/model.go", + "sha256": "930ab520220fd08ff600953c46eab4c4c1ea5fe6c71c576634c5aec61788eb89", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tag/read.go", + "sha256": "7ca1cbe9d7702a31ffb9f8bbe9f241630813b8248e00c24adf06990ba9a4c061", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tag/state.go", + "sha256": "1681ad5303170dc73c9ddda20bc07378cfd9882e0f46123570fca89c29a5ee8a", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tags/datasource.go", + "sha256": "c9ac55a32c6871cf4b9a2766884b3d80589177d5b2f437936d114930b5737503", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tags/model.go", + "sha256": "d8b73448993cf51f019fd4fcc43a183b1a9616fa9bac834eb7e0e5140535d92b", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tags/read.go", + "sha256": "9de40574bbe824e675a1e31eab6aa442dc9fc8bc41cb9144af74185a6a827d83", + "blueprint": "blueprints/thousandeyes" + }, + { + "path": "internal/services/datasources/tags/v7/tags/state.go", + "sha256": "663f3d5e8eba1d4ea23bac9ceb63959e6ebc5874771d038dc859f243e21d6f12", "blueprint": "blueprints/thousandeyes" }, { "path": "internal/services/resources/tags/v7/tag/construct.go", - "sha256": "ec0a198e7ef2348745a5bf0eb09aaa8bbf265194e65bcda2643f58ececb1bea0", + "sha256": "18f0cb1dd4b3d6bdaba737bf67c5212ada350bca4a8f30be598872b7afc6115d", "blueprint": "blueprints/thousandeyes" }, { "path": "internal/services/resources/tags/v7/tag/crud.go", - "sha256": "6cc89572df3093d6e2794d4c458796745c4ed3bb3644571949259495813d5964", + "sha256": "52210e7980c9d201e330b98a6cc75cd7d2f6b0fe760b04995fc61a9bd450d8b3", "blueprint": "blueprints/thousandeyes" }, { "path": "internal/services/resources/tags/v7/tag/model.go", - "sha256": "1d776e1a75a870a672956068fe8688ecab55d017857709e65e53249e88aaed77", + "sha256": "a1d665afb7b730cde8088f6ba72501e8128513097fdd0d35a2669043754a743f", "blueprint": "blueprints/thousandeyes" }, { "path": "internal/services/resources/tags/v7/tag/resource.go", - "sha256": "aa4fbb62b7b7042dab523df38af0a4eb80eca6ed1851a8bb0520b6642b6bcfaf", + "sha256": "39f35fc37ed9feffa06b28e0f037a054632d5ed3a4a7be95bd49e0ad0f167cd0", "blueprint": "blueprints/thousandeyes" }, { "path": "internal/services/resources/tags/v7/tag/state.go", - "sha256": "836de836c8346eaf8346a34c3ab086c515d9be623155eb206ede7e2f18420233", + "sha256": "c8360b5792c1be7ab3bfb712668ae79eb8766b2c3cd8f333222b905bafb34ecd", "blueprint": "blueprints/thousandeyes" } ] diff --git a/pilot/thousandeyes/internal/provider/datasources.go b/pilot/thousandeyes/internal/provider/datasources.go index 0f273ac0..90e5fd98 100644 --- a/pilot/thousandeyes/internal/provider/datasources.go +++ b/pilot/thousandeyes/internal/provider/datasources.go @@ -1,5 +1,5 @@ // Code generated by tfpluginframeworkgen from blueprints/thousandeyes -// (sha256:e6a06bf9882dda9ab067568251ef6c118c2066420c898520b0bd759af6d80f07). DO NOT EDIT. +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. package provider @@ -7,6 +7,9 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/datasource" + + v7TagData "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/datasources/tags/v7/tag" + v7TagsData "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/datasources/tags/v7/tags" ) // DataSources returns every data source this provider serves. @@ -14,5 +17,8 @@ import ( // This file is generated in full. Add a data source by adding a blueprint and // regenerating, not by editing here. func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{} + return []func() datasource.DataSource{ + v7TagData.NewTagDataSource, + v7TagsData.NewTagsDataSource, + } } diff --git a/pilot/thousandeyes/internal/provider/resources.go b/pilot/thousandeyes/internal/provider/resources.go index 50a31053..92e9d8aa 100644 --- a/pilot/thousandeyes/internal/provider/resources.go +++ b/pilot/thousandeyes/internal/provider/resources.go @@ -1,5 +1,5 @@ // Code generated by tfpluginframeworkgen from blueprints/thousandeyes -// (sha256:e6a06bf9882dda9ab067568251ef6c118c2066420c898520b0bd759af6d80f07). DO NOT EDIT. +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. package provider diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/datasource.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/datasource.go new file mode 100644 index 00000000..0e216616 --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/datasource.go @@ -0,0 +1,188 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +package tag + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + thousandeyes "github.com/deploymenttheory/go-sdk-thousandeyes/thousandeyes" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/client" + commonschema "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/schema" +) + +const ( + // DataSourceName is the Terraform type this data source registers as. + DataSourceName = "thousandeyes_tag" + + // ReadTimeout is the default read deadline in seconds. A practitioner overrides it + // with the data source's timeouts block. Read is the only operation a data source + // has, so it is the only deadline there is. + ReadTimeout = 180 +) + +// Compile-time assertions that this data source implements what it claims to. They +// turn a missing method into a build failure rather than a runtime surprise. +var ( + _ datasource.DataSource = &TagDataSource{} + _ datasource.DataSourceWithConfigure = &TagDataSource{} +) + +// NewTagDataSource returns the thousandeyes_tag data source. +func NewTagDataSource() datasource.DataSource { + return &TagDataSource{} +} + +// TagDataSource implements the thousandeyes_tag data source. +type TagDataSource struct { + client *thousandeyes.Client +} + +// Metadata returns the data source type name. +func (d *TagDataSource) Metadata(_ context.Context, _ datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = DataSourceName +} + +// Configure receives the configured SDK client from the provider. +func (d *TagDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = client.ForDataSource(ctx, req, resp, DataSourceName) +} + +// Schema returns the data source schema. +func (d *TagDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Looks up a single ThousandEyes tag by its identifier.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The identifier of the tag to look up.", + }, + "key": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's key. Together with `value` this forms the label applied to assigned objects.", + }, + "value": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's value. The API enforces this field's " + + "presence, which the specification does not declare. ", + }, + "color": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's display colour as a hex string. Computed as well as optional because the API " + + "assigns one when it is omitted; this has not yet been confirmed by probing. Observed: the API assigns \"#A7EB10\" when this is omitted. " + + "", + }, + "description": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "A human-readable description of the tag.", + }, + "icon": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's icon. Observed: the API assigns \"LABEL\" " + + "when this is omitted. ", + }, + "object_type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, " + + "`endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated " + + "because the API's enumerations are open: an undocumented value must not be rejected by the " + + "provider. Values accepted here: `test`, `dashboard`, " + + "`endpoint-test`, `v-agent`, `connected-devices-test`. The specification documents " + + "`endpoint-agent`, which the API rejected. The API enforces this field's presence, which " + + "the specification does not declare. ", + }, + "access_type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's access level. Documented values are `all`, `partner` and `system`. Values accepted here: `all`. The specification documents " + + "`system`, which the API rejected. The API enforces this field's presence, which the " + + "specification does not declare. ", + }, + "match_type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "How the tag's filters combine when it is assigned dynamically. Values accepted here: `and`, `or`. ", + }, + "type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's type, assigned by the API.", + }, + "built_in": schema.BoolAttribute{ + Computed: true, + MarkdownDescription: "Whether the tag is built in rather than user-created.", + }, + "account_group_id": schema.Int64Attribute{ + Computed: true, + MarkdownDescription: "The account group the tag belongs to. Computed rather than configurable: the provider " + + "scopes every request through its own `account_group_id` setting, so accepting a second " + + "value here would let the two disagree.", + }, + "create_date": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "When the tag was created.", + }, + "modified_date": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "When the tag was last modified.", + }, + "legacy_id": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The tag's identifier in the v6 API. Typed as a number because the specification declares " + + "it as one, although observed values are integral; probing will settle whether this should " + + "be an integer.", + }, + "assignments": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The identifier of the object the tag is assigned to.", + }, + "type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, " + + "`dashboard` and `connected-devices-test`.", + }, + }, + }, + Computed: true, + MarkdownDescription: "Objects this tag is assigned to. The API returns assignments only when the request asks " + + "for them to be expanded, which this data source does not, so this is null rather than " + + "empty.", + }, + "filters": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "key": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The filter key used for matching.", + }, + "mode": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "How the filter values are matched.", + }, + "scope": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The scope the filter applies within.", + }, + "values": schema.SetAttribute{ + ElementType: types.StringType, + Computed: true, + MarkdownDescription: "The values the filter matches against.", + }, + }, + }, + Computed: true, + MarkdownDescription: "Filters that dynamically assign this tag to endpoint agents.", + }, + }, + Blocks: map[string]schema.Block{ + "timeouts": commonschema.DataSourceTimeouts(ctx), + }, + } +} diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/model.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/model.go new file mode 100644 index 00000000..1f7fd5e9 --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/model.go @@ -0,0 +1,79 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +// REF: https://developer.cisco.com/docs/thousandeyes/get-tag/ +package tag + +import ( + "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// TagDataSourceModel is the Terraform state for a thousandeyes_tag. +// +// Field order follows the schema, and the timeouts value is last. +type TagDataSourceModel struct { + ID types.String `tfsdk:"id"` + Key types.String `tfsdk:"key"` + Value types.String `tfsdk:"value"` + Color types.String `tfsdk:"color"` + Description types.String `tfsdk:"description"` + Icon types.String `tfsdk:"icon"` + ObjectType types.String `tfsdk:"object_type"` + AccessType types.String `tfsdk:"access_type"` + MatchType types.String `tfsdk:"match_type"` + Type types.String `tfsdk:"type"` + BuiltIn types.Bool `tfsdk:"built_in"` + AccountGroupID types.Int64 `tfsdk:"account_group_id"` + CreateDate types.String `tfsdk:"create_date"` + ModifiedDate types.String `tfsdk:"modified_date"` + LegacyID types.Float64 `tfsdk:"legacy_id"` + Assignments types.Set `tfsdk:"assignments"` + Filters types.Set `tfsdk:"filters"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +// TagAssignmentModel is one element of a nested attribute. +// +// It is a sibling of the data source model rather than an inner type, because the +// framework decodes collection elements into a named type. +type TagAssignmentModel struct { + ID types.String `tfsdk:"id"` + Type types.String `tfsdk:"type"` +} + +// tagAssignmentAttrTypes describes TagAssignmentModel to the framework. +// +// It is declared once and referenced by the conversion helper, so the shape cannot +// drift between the declaration and the code that populates it. +var tagAssignmentAttrTypes = map[string]attr.Type{ + "id": types.StringType, + "type": types.StringType, +} + +var tagAssignmentObjectType = types.ObjectType{AttrTypes: tagAssignmentAttrTypes} + +// TagFilterModel is one element of a nested attribute. +// +// It is a sibling of the data source model rather than an inner type, because the +// framework decodes collection elements into a named type. +type TagFilterModel struct { + Key types.String `tfsdk:"key"` + Mode types.String `tfsdk:"mode"` + Scope types.String `tfsdk:"scope"` + Values types.Set `tfsdk:"values"` +} + +// tagFilterAttrTypes describes TagFilterModel to the framework. +// +// It is declared once and referenced by the conversion helper, so the shape cannot +// drift between the declaration and the code that populates it. +var tagFilterAttrTypes = map[string]attr.Type{ + "key": types.StringType, + "mode": types.StringType, + "scope": types.StringType, + "values": types.SetType{ElemType: types.StringType}, +} + +var tagFilterObjectType = types.ObjectType{AttrTypes: tagFilterAttrTypes} diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/read.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/read.go new file mode 100644 index 00000000..b955fb99 --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/read.go @@ -0,0 +1,52 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +package tag + +import ( + "context" + "time" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/crud" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/errors" +) + +// Read fetches thousandeyes_tag from the API. +// +// A data source reads its arguments from configuration rather than from prior state: +// there is no prior state to read, because nothing here was created by Terraform. +func (d *TagDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data TagDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "reading data source", map[string]any{"dataSource": DataSourceName}) + + ctx, cancel := crud.HandleTimeout(ctx, data.Timeouts.Read, ReadTimeout*time.Second, &resp.Diagnostics) + if cancel == nil { + return + } + defer cancel() + + remote, _, err := d.client.API.Tags.GetTag(ctx, data.ID.ValueString()) + if err != nil { + // Unlike a resource read, a missing object is an error rather than a signal to + // drop state: the practitioner named something that is not there, and silently + // returning nothing would leave whatever referenced it failing further on with + // no indication of why. + errors.Handle(&resp.Diagnostics, DataSourceName, errors.OpRead, err) + return + } + resp.Diagnostics.Append(mapRemoteStateToTerraform(ctx, &data, remote)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/state.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/state.go new file mode 100644 index 00000000..eaf94e0b --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tag/state.go @@ -0,0 +1,112 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +package tag + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/deploymenttheory/go-sdk-thousandeyes/thousandeyes/thousandeyes_api/tags" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/convert" +) + +// mapRemoteStateToTerraform maps the API's response onto Terraform state. +// +// This function replaces what a reflection-based provider does at runtime. Being +// generated code, a field whose conversion is wrong is a compile error rather +// than a surprise during apply. +func mapRemoteStateToTerraform(ctx context.Context, data *TagDataSourceModel, remote *tags.Tag) diag.Diagnostics { + var ( + diags diag.Diagnostics + d diag.Diagnostics + ) + + if remote == nil { + tflog.Debug(ctx, "remote object is nil, leaving state untouched", map[string]any{ + "dataSource": DataSourceName, + }) + return diags + } + + data.ID = convert.PtrStringToFramework(remote.ID) + data.Key = convert.PtrStringToFramework(remote.Key) + data.Value = convert.PtrStringToFramework(remote.Value) + data.Color = convert.PtrStringToFramework(remote.Color) + data.Description = convert.PtrStringToFramework(remote.Description) + data.Icon = convert.PtrStringToFramework(remote.Icon) + data.ObjectType = convert.EnumToFramework(remote.ObjectType) + data.AccessType = convert.EnumToFramework(remote.AccessType) + data.MatchType = convert.EnumToFramework(remote.MatchType) + data.Type = convert.EnumToFramework(remote.Type) + data.BuiltIn = convert.PtrBoolToFramework(remote.BuiltIn) + data.AccountGroupID = convert.PtrInt64ToFramework(remote.AID) + data.CreateDate = convert.PtrStringToFramework(remote.CreateDate) + data.ModifiedDate = convert.PtrStringToFramework(remote.ModifiedDate) + data.LegacyID = convert.PtrFloat64ToFramework(remote.LegacyID) + data.Assignments, d = flattenTagAssignments(ctx, remote.Assignments) + diags.Append(d...) + data.Filters, d = flattenTagFilters(ctx, remote.Filters) + diags.Append(d...) + + tflog.Debug(ctx, "mapped remote state", map[string]any{"dataSource": DataSourceName}) + + return diags +} + +// flattenTagAssignments converts SDK structs into the framework value state holds. +func flattenTagAssignments(ctx context.Context, in []tags.Assignment) (types.Set, diag.Diagnostics) { + var diags diag.Diagnostics + + // A nil slice is null and an empty slice is empty. Conflating them produces a + // diff no configuration change can resolve. + if in == nil { + return types.SetNull(tagAssignmentObjectType), diags + } + + models := make([]TagAssignmentModel, 0, len(in)) + for _, item := range in { + var m TagAssignmentModel + + m.ID = convert.PtrStringToFramework(item.ID) + m.Type = convert.EnumToFramework(item.Type) + models = append(models, m) + } + + out, d2 := types.SetValueFrom(ctx, tagAssignmentObjectType, models) + diags.Append(d2...) + + return out, diags +} + +// flattenTagFilters converts SDK structs into the framework value state holds. +func flattenTagFilters(ctx context.Context, in []tags.TagFilter) (types.Set, diag.Diagnostics) { + var diags diag.Diagnostics + var d diag.Diagnostics + + // A nil slice is null and an empty slice is empty. Conflating them produces a + // diff no configuration change can resolve. + if in == nil { + return types.SetNull(tagFilterObjectType), diags + } + + models := make([]TagFilterModel, 0, len(in)) + for _, item := range in { + var m TagFilterModel + + m.Key = convert.PtrStringToFramework(item.Key) + m.Mode = convert.EnumToFramework(item.Mode) + m.Scope = convert.EnumToFramework(item.Scope) + m.Values, d = convert.StringSliceToFrameworkSet(ctx, item.Values) + diags.Append(d...) + models = append(models, m) + } + + out, d2 := types.SetValueFrom(ctx, tagFilterObjectType, models) + diags.Append(d2...) + + return out, diags +} diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/datasource.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/datasource.go new file mode 100644 index 00000000..fb01e9eb --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/datasource.go @@ -0,0 +1,197 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +package tags + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + thousandeyes "github.com/deploymenttheory/go-sdk-thousandeyes/thousandeyes" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/client" + commonschema "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/schema" +) + +const ( + // DataSourceName is the Terraform type this data source registers as. + DataSourceName = "thousandeyes_tags" + + // ReadTimeout is the default read deadline in seconds. A practitioner overrides it + // with the data source's timeouts block. Read is the only operation a data source + // has, so it is the only deadline there is. + ReadTimeout = 180 +) + +// Compile-time assertions that this data source implements what it claims to. They +// turn a missing method into a build failure rather than a runtime surprise. +var ( + _ datasource.DataSource = &TagsDataSource{} + _ datasource.DataSourceWithConfigure = &TagsDataSource{} +) + +// NewTagsDataSource returns the thousandeyes_tags data source. +func NewTagsDataSource() datasource.DataSource { + return &TagsDataSource{} +} + +// TagsDataSource implements the thousandeyes_tags data source. +type TagsDataSource struct { + client *thousandeyes.Client +} + +// Metadata returns the data source type name. +func (d *TagsDataSource) Metadata(_ context.Context, _ datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = DataSourceName +} + +// Configure receives the configured SDK client from the provider. +func (d *TagsDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = client.ForDataSource(ctx, req, resp, DataSourceName) +} + +// Schema returns the data source schema. +func (d *TagsDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Lists every ThousandEyes tag visible to the configured account group.", + Attributes: map[string]schema.Attribute{ + "tags": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's unique identifier, assigned by the API.", + }, + "key": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's key. Together with `value` this forms the label applied to assigned objects.", + }, + "value": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's value. The API enforces this field's " + + "presence, which the specification does not declare. ", + }, + "color": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's display colour as a hex string. Computed as well as optional because the API " + + "assigns one when it is omitted; this has not yet been confirmed by probing. Observed: the API assigns \"#A7EB10\" when this is omitted. " + + "", + }, + "description": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "A human-readable description of the tag.", + }, + "icon": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's icon. Observed: the API assigns \"LABEL\" " + + "when this is omitted. ", + }, + "object_type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The kind of object the tag may be assigned to. Documented values are `test`, `v-agent`, " + + "`endpoint-test`, `dashboard` and `connected-devices-test`. No validator is generated " + + "because the API's enumerations are open: an undocumented value must not be rejected by the " + + "provider. Values accepted here: `test`, `dashboard`, " + + "`endpoint-test`, `v-agent`, `connected-devices-test`. The specification documents " + + "`endpoint-agent`, which the API rejected. The API enforces this field's presence, which " + + "the specification does not declare. ", + }, + "access_type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's access level. Documented values are `all`, `partner` and `system`. Values accepted here: `all`. The specification documents " + + "`system`, which the API rejected. The API enforces this field's presence, which the " + + "specification does not declare. ", + }, + "match_type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "How the tag's filters combine when it is assigned dynamically. Values accepted here: `and`, `or`. ", + }, + "type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The tag's type, assigned by the API.", + }, + "built_in": schema.BoolAttribute{ + Computed: true, + MarkdownDescription: "Whether the tag is built in rather than user-created.", + }, + "account_group_id": schema.Int64Attribute{ + Computed: true, + MarkdownDescription: "The account group the tag belongs to. Computed rather than configurable: the provider " + + "scopes every request through its own `account_group_id` setting, so accepting a second " + + "value here would let the two disagree.", + }, + "create_date": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "When the tag was created.", + }, + "modified_date": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "When the tag was last modified.", + }, + "legacy_id": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The tag's identifier in the v6 API. Typed as a number because the specification declares " + + "it as one, although observed values are integral; probing will settle whether this should " + + "be an integer.", + }, + "assignments": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The identifier of the object the tag is assigned to.", + }, + "type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The kind of object assigned. Documented values are `test`, `v-agent`, `endpoint-test`, " + + "`dashboard` and `connected-devices-test`.", + }, + }, + }, + Computed: true, + MarkdownDescription: "Objects this tag is assigned to. A set rather than a list because the API does not " + + "preserve ordering.", + }, + "filters": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "key": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The filter key used for matching.", + }, + "mode": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "How the filter values are matched.", + }, + "scope": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The scope the filter applies within.", + }, + "values": schema.SetAttribute{ + ElementType: types.StringType, + Computed: true, + MarkdownDescription: "The values the filter matches against.", + }, + }, + }, + Computed: true, + MarkdownDescription: "Filters that dynamically assign this tag to endpoint agents.", + }, + }, + }, + Computed: true, + MarkdownDescription: "Every tag visible to the account group, in the order the API returned them. A list rather " + + "than a set because the API's ordering is the only ordering there is, and a set would " + + "discard it.", + }, + }, + Blocks: map[string]schema.Block{ + "timeouts": commonschema.DataSourceTimeouts(ctx), + }, + } +} diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/model.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/model.go new file mode 100644 index 00000000..a34719a2 --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/model.go @@ -0,0 +1,113 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +// REF: https://developer.cisco.com/docs/thousandeyes/list-tags/ +package tags + +import ( + "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// TagsDataSourceModel is the Terraform state for a thousandeyes_tags. +// +// Field order follows the schema, and the timeouts value is last. +type TagsDataSourceModel struct { + Tags types.List `tfsdk:"tags"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +// TagSummaryModel is one element of a nested attribute. +// +// It is a sibling of the data source model rather than an inner type, because the +// framework decodes collection elements into a named type. +type TagSummaryModel struct { + ID types.String `tfsdk:"id"` + Key types.String `tfsdk:"key"` + Value types.String `tfsdk:"value"` + Color types.String `tfsdk:"color"` + Description types.String `tfsdk:"description"` + Icon types.String `tfsdk:"icon"` + ObjectType types.String `tfsdk:"object_type"` + AccessType types.String `tfsdk:"access_type"` + MatchType types.String `tfsdk:"match_type"` + Type types.String `tfsdk:"type"` + BuiltIn types.Bool `tfsdk:"built_in"` + AccountGroupID types.Int64 `tfsdk:"account_group_id"` + CreateDate types.String `tfsdk:"create_date"` + ModifiedDate types.String `tfsdk:"modified_date"` + LegacyID types.Float64 `tfsdk:"legacy_id"` + Assignments types.Set `tfsdk:"assignments"` + Filters types.Set `tfsdk:"filters"` +} + +// tagSummaryAttrTypes describes TagSummaryModel to the framework. +// +// It is declared once and referenced by the conversion helper, so the shape cannot +// drift between the declaration and the code that populates it. +var tagSummaryAttrTypes = map[string]attr.Type{ + "id": types.StringType, + "key": types.StringType, + "value": types.StringType, + "color": types.StringType, + "description": types.StringType, + "icon": types.StringType, + "object_type": types.StringType, + "access_type": types.StringType, + "match_type": types.StringType, + "type": types.StringType, + "built_in": types.BoolType, + "account_group_id": types.Int64Type, + "create_date": types.StringType, + "modified_date": types.StringType, + "legacy_id": types.Float64Type, + "assignments": types.SetType{ElemType: tagSummaryAssignmentObjectType}, + "filters": types.SetType{ElemType: tagSummaryFilterObjectType}, +} + +var tagSummaryObjectType = types.ObjectType{AttrTypes: tagSummaryAttrTypes} + +// TagSummaryAssignmentModel is one element of a nested attribute. +// +// It is a sibling of the data source model rather than an inner type, because the +// framework decodes collection elements into a named type. +type TagSummaryAssignmentModel struct { + ID types.String `tfsdk:"id"` + Type types.String `tfsdk:"type"` +} + +// tagSummaryAssignmentAttrTypes describes TagSummaryAssignmentModel to the framework. +// +// It is declared once and referenced by the conversion helper, so the shape cannot +// drift between the declaration and the code that populates it. +var tagSummaryAssignmentAttrTypes = map[string]attr.Type{ + "id": types.StringType, + "type": types.StringType, +} + +var tagSummaryAssignmentObjectType = types.ObjectType{AttrTypes: tagSummaryAssignmentAttrTypes} + +// TagSummaryFilterModel is one element of a nested attribute. +// +// It is a sibling of the data source model rather than an inner type, because the +// framework decodes collection elements into a named type. +type TagSummaryFilterModel struct { + Key types.String `tfsdk:"key"` + Mode types.String `tfsdk:"mode"` + Scope types.String `tfsdk:"scope"` + Values types.Set `tfsdk:"values"` +} + +// tagSummaryFilterAttrTypes describes TagSummaryFilterModel to the framework. +// +// It is declared once and referenced by the conversion helper, so the shape cannot +// drift between the declaration and the code that populates it. +var tagSummaryFilterAttrTypes = map[string]attr.Type{ + "key": types.StringType, + "mode": types.StringType, + "scope": types.StringType, + "values": types.SetType{ElemType: types.StringType}, +} + +var tagSummaryFilterObjectType = types.ObjectType{AttrTypes: tagSummaryFilterAttrTypes} diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/read.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/read.go new file mode 100644 index 00000000..f14d7c55 --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/read.go @@ -0,0 +1,52 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +package tags + +import ( + "context" + "time" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/crud" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/errors" +) + +// Read fetches thousandeyes_tags from the API. +// +// A data source reads its arguments from configuration rather than from prior state: +// there is no prior state to read, because nothing here was created by Terraform. +func (d *TagsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data TagsDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "reading data source", map[string]any{"dataSource": DataSourceName}) + + ctx, cancel := crud.HandleTimeout(ctx, data.Timeouts.Read, ReadTimeout*time.Second, &resp.Diagnostics) + if cancel == nil { + return + } + defer cancel() + + remote, _, err := d.client.API.Tags.GetTags(ctx) + if err != nil { + // Unlike a resource read, a missing object is an error rather than a signal to + // drop state: the practitioner named something that is not there, and silently + // returning nothing would leave whatever referenced it failing further on with + // no indication of why. + errors.Handle(&resp.Diagnostics, DataSourceName, errors.OpRead, err) + return + } + resp.Diagnostics.Append(mapRemoteStateToTerraform(ctx, &data, remote)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/state.go b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/state.go new file mode 100644 index 00000000..7a2068b6 --- /dev/null +++ b/pilot/thousandeyes/internal/services/datasources/tags/v7/tags/state.go @@ -0,0 +1,138 @@ +// Code generated by tfpluginframeworkgen from blueprints/thousandeyes +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. + +package tags + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/deploymenttheory/go-sdk-thousandeyes/thousandeyes/thousandeyes_api/tags" + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes/internal/services/common/convert" +) + +// mapRemoteStateToTerraform maps the API's response onto Terraform state. +// +// This function replaces what a reflection-based provider does at runtime. Being +// generated code, a field whose conversion is wrong is a compile error rather +// than a surprise during apply. +func mapRemoteStateToTerraform(ctx context.Context, data *TagsDataSourceModel, remote *tags.ResourceTags) diag.Diagnostics { + var ( + diags diag.Diagnostics + d diag.Diagnostics + ) + + if remote == nil { + tflog.Debug(ctx, "remote object is nil, leaving state untouched", map[string]any{ + "dataSource": DataSourceName, + }) + return diags + } + + data.Tags, d = flattenTagSummaries(ctx, remote.Tags) + diags.Append(d...) + + tflog.Debug(ctx, "mapped remote state", map[string]any{"dataSource": DataSourceName}) + + return diags +} + +// flattenTagSummaries converts SDK structs into the framework value state holds. +func flattenTagSummaries(ctx context.Context, in []tags.Tag) (types.List, diag.Diagnostics) { + var diags diag.Diagnostics + var d diag.Diagnostics + + // A nil slice is null and an empty slice is empty. Conflating them produces a + // diff no configuration change can resolve. + if in == nil { + return types.ListNull(tagSummaryObjectType), diags + } + + models := make([]TagSummaryModel, 0, len(in)) + for _, item := range in { + var m TagSummaryModel + + m.ID = convert.PtrStringToFramework(item.ID) + m.Key = convert.PtrStringToFramework(item.Key) + m.Value = convert.PtrStringToFramework(item.Value) + m.Color = convert.PtrStringToFramework(item.Color) + m.Description = convert.PtrStringToFramework(item.Description) + m.Icon = convert.PtrStringToFramework(item.Icon) + m.ObjectType = convert.EnumToFramework(item.ObjectType) + m.AccessType = convert.EnumToFramework(item.AccessType) + m.MatchType = convert.EnumToFramework(item.MatchType) + m.Type = convert.EnumToFramework(item.Type) + m.BuiltIn = convert.PtrBoolToFramework(item.BuiltIn) + m.AccountGroupID = convert.PtrInt64ToFramework(item.AID) + m.CreateDate = convert.PtrStringToFramework(item.CreateDate) + m.ModifiedDate = convert.PtrStringToFramework(item.ModifiedDate) + m.LegacyID = convert.PtrFloat64ToFramework(item.LegacyID) + m.Assignments, d = flattenTagSummaryAssignments(ctx, item.Assignments) + diags.Append(d...) + m.Filters, d = flattenTagSummaryFilters(ctx, item.Filters) + diags.Append(d...) + models = append(models, m) + } + + out, d2 := types.ListValueFrom(ctx, tagSummaryObjectType, models) + diags.Append(d2...) + + return out, diags +} + +// flattenTagSummaryAssignments converts SDK structs into the framework value state holds. +func flattenTagSummaryAssignments(ctx context.Context, in []tags.Assignment) (types.Set, diag.Diagnostics) { + var diags diag.Diagnostics + + // A nil slice is null and an empty slice is empty. Conflating them produces a + // diff no configuration change can resolve. + if in == nil { + return types.SetNull(tagSummaryAssignmentObjectType), diags + } + + models := make([]TagSummaryAssignmentModel, 0, len(in)) + for _, item := range in { + var m TagSummaryAssignmentModel + + m.ID = convert.PtrStringToFramework(item.ID) + m.Type = convert.EnumToFramework(item.Type) + models = append(models, m) + } + + out, d2 := types.SetValueFrom(ctx, tagSummaryAssignmentObjectType, models) + diags.Append(d2...) + + return out, diags +} + +// flattenTagSummaryFilters converts SDK structs into the framework value state holds. +func flattenTagSummaryFilters(ctx context.Context, in []tags.TagFilter) (types.Set, diag.Diagnostics) { + var diags diag.Diagnostics + var d diag.Diagnostics + + // A nil slice is null and an empty slice is empty. Conflating them produces a + // diff no configuration change can resolve. + if in == nil { + return types.SetNull(tagSummaryFilterObjectType), diags + } + + models := make([]TagSummaryFilterModel, 0, len(in)) + for _, item := range in { + var m TagSummaryFilterModel + + m.Key = convert.PtrStringToFramework(item.Key) + m.Mode = convert.EnumToFramework(item.Mode) + m.Scope = convert.EnumToFramework(item.Scope) + m.Values, d = convert.StringSliceToFrameworkSet(ctx, item.Values) + diags.Append(d...) + models = append(models, m) + } + + out, d2 := types.SetValueFrom(ctx, tagSummaryFilterObjectType, models) + diags.Append(d2...) + + return out, diags +} diff --git a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/construct.go b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/construct.go index f0666a82..9d0d6b3b 100644 --- a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/construct.go +++ b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/construct.go @@ -1,5 +1,5 @@ // Code generated by tfpluginframeworkgen from blueprints/thousandeyes -// (sha256:e6a06bf9882dda9ab067568251ef6c118c2066420c898520b0bd759af6d80f07). DO NOT EDIT. +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. package tag diff --git a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/crud.go b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/crud.go index 98c19b37..7aad1b74 100644 --- a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/crud.go +++ b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/crud.go @@ -1,5 +1,5 @@ // Code generated by tfpluginframeworkgen from blueprints/thousandeyes -// (sha256:e6a06bf9882dda9ab067568251ef6c118c2066420c898520b0bd759af6d80f07). DO NOT EDIT. +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. package tag diff --git a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/model.go b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/model.go index e23d7e52..d77633f9 100644 --- a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/model.go +++ b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/model.go @@ -1,5 +1,5 @@ // Code generated by tfpluginframeworkgen from blueprints/thousandeyes -// (sha256:e6a06bf9882dda9ab067568251ef6c118c2066420c898520b0bd759af6d80f07). DO NOT EDIT. +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. // REF: https://developer.cisco.com/docs/thousandeyes/list-tags/ package tag diff --git a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/resource.go b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/resource.go index 13ce589d..9aed5d5d 100644 --- a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/resource.go +++ b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/resource.go @@ -1,5 +1,5 @@ // Code generated by tfpluginframeworkgen from blueprints/thousandeyes -// (sha256:e6a06bf9882dda9ab067568251ef6c118c2066420c898520b0bd759af6d80f07). DO NOT EDIT. +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. package tag diff --git a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/state.go b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/state.go index 1952d015..2c38eaa4 100644 --- a/pilot/thousandeyes/internal/services/resources/tags/v7/tag/state.go +++ b/pilot/thousandeyes/internal/services/resources/tags/v7/tag/state.go @@ -1,5 +1,5 @@ // Code generated by tfpluginframeworkgen from blueprints/thousandeyes -// (sha256:e6a06bf9882dda9ab067568251ef6c118c2066420c898520b0bd759af6d80f07). DO NOT EDIT. +// (sha256:0fcd34daa80f04e7677fee2b7bd232d05c802f61577769fa89da88da55b9ae4a). DO NOT EDIT. package tag