diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b180329 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# The build only needs the Go sources, go.mod and go.sum. Everything below would +# otherwise be copied into the build context and invalidate its cache. +.git/ +.github/ +dist/ +.nix-go/ +meshstack +.env +.vscode/ +.idea/ +*.md diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..7bcddd7 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,84 @@ +# Builds the meshstack container image and pushes it to GHCR only. Modelled on +# meshcloud/building-block-runner's build-images.yml, minus the Docker Hub push. +name: Build Image + +env: + REGISTRY: ghcr.io + IMAGE_NAMESPACE: ${{ github.repository_owner }} + IMAGE_NAME: meshstack-cli + +on: + # Called by the release workflow, so a tagged release publishes the matching image. + workflow_call: + inputs: + version: + description: "Release version to tag the image with, e.g. v1.2.3" + required: true + type: string + # A push to main refreshes :main, which is what makes the image usable before the + # first release exists. + push: + branches: + - main + # Pull requests build the image but do not push it, so a broken Dockerfile fails + # review rather than main. + pull_request: + paths: + - 'Dockerfile' + - '.github/workflows/build-image.yml' + - 'go.mod' + - 'go.sum' + - '**/*.go' + +jobs: + build: + name: Build and push image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Tags are computed here rather than with docker/metadata-action, to keep the + # set of pinned actions small. + - name: Determine version and tags + id: meta + run: | + if [ -n "${{ inputs.version }}" ]; then + version="${{ inputs.version }}" + tags="${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:${version}" + tags="${tags},${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:latest" + elif [ "${{ github.ref }}" = "refs/heads/main" ]; then + version="main-$(git rev-parse --short HEAD)" + tags="${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:main" + tags="${tags},${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:${version}" + else + version="pr-${{ github.event.number }}" + tags="${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:${version}" + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0a71d8b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +# Releases the meshstack CLI when a tag matching "v*" is pushed. +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + goreleaser: + name: GoReleaser + runs-on: ubuntu-latest + permissions: + # Creating a release and uploading its assets counts as writing contents. + contents: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Let goreleaser read older tags, which it needs for the changelog. + fetch-depth: 0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: 'go.mod' + cache: true + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + args: release --clean + env: + # GitHub sets GITHUB_TOKEN automatically. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Publishes the image for the same tag. Separate job so a failing image build does + # not take the archives down with it. + image: + name: Image + needs: [ goreleaser ] + permissions: + contents: read + packages: write + uses: ./.github/workflows/build-image.yml + with: + version: ${{ github.ref_name }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..97c73d6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,83 @@ +# meshStack CLI build, lint and test workflow. +name: Tests + +on: + pull_request: + paths-ignore: + - 'README.md' + push: + branches: + - main + paths-ignore: + - 'README.md' + +# Testing only needs permissions to read the repository contents. +permissions: + contents: read + +# Cancel superseded runs on the same ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Go Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: 'go.mod' + cache: true + - run: go mod tidy + - run: go build -v ./... + - name: git diff + run: | + git diff --compact-summary --exit-code || \ + (echo; echo "Unexpected difference in directories after 'go mod tidy'. Run 'go mod tidy' command and commit."; exit 1) + + golangci: + needs: [ build ] + name: Go Lint and Format Check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # The repository's pinned Go, because it is what builds the linter below. + go-version-file: 'go.mod' + - name: golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + # golangci-lint's formatters use the go/format compiled into the binary, so the + # formatting they enforce comes from the Go release that BUILT the linter, not + # from the toolchain on PATH. The published binaries are built with whatever Go + # was current at release time, which made 'version: latest' with install-mode + # 'binary' enforce a different gofmt than the pinned Go: 1.27 widens end-of-line + # comment alignment groups, and 1.26 rejects the result, so the two disagree with + # no formatting that satisfies both. Building the linter here with go.mod's Go + # ties formatting to the version the code is written against. + install-mode: goinstall + version: v2.13.0 + # Deliberately no only-new-issues: this repository starts clean and CI keeps it + # that way, so filtering to changed code cannot help and can only hide a finding. + # It hid this one, and still hides it in the provider's identical job. + - name: Suggest fix command on failure + if: failure() + run: | + echo "::error::Linting or formatting issues detected. Run 'task lint -- --fix' locally to automatically fix these issues, then commit the changes." + + test: + needs: [ build ] + name: Go Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: 'go.mod' + cache: true + - run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8ce84b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Binary produced by 'task build' +/meshstack + +# Release artifacts produced by goreleaser +/dist/ + +# Go environment created by the Nix dev shell (flake.nix shellHook) +/.nix-go/ + +# Local meshStack credentials, read by the Taskfile's dotenv +.env + +# Editor and IDE directories +.vscode/ +.idea/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..2bfefe4 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,106 @@ +# Visit https://golangci-lint.run/ for usage documentation +# and information on other useful linters. +# +# Kept deliberately close to the meshStack Terraform provider's configuration, so +# that code moving between the two repositories does not trip a different linter set. +version: "2" +issues: + max-same-issues: 0 + +formatters: + enable: + - gci + - gofmt + settings: + gci: + sections: + - standard # Go standard library + - default # All other external dependencies + - localmodule # This repository's modules + +linters: + default: none + enable: + - depguard + - durationcheck + - errcheck + - copyloopvar + - forcetypeassert + - godot + - ineffassign + - makezero + - misspell + - nilerr + - predeclared + - staticcheck + - usetesting + - unconvert + - unparam + - unused + - govet + - testifylint + - thelper + settings: + # This repository is allowed exactly one external dependency, cobra, and only the + # cmd/ tree may use it. Everything else stays on the standard library, with testify + # permitted in tests. The rules below are what enforces that; read them as the + # dependency policy rather than as lint configuration. + depguard: + rules: + # client/ must stay free of external dependencies. The meshStack Terraform + # provider consumes this package, so anything added here lands in the + # provider's dependency tree as well. + client: + files: + # Both patterns are needed: '**/dir/**/*.go' only matches files in + # subdirectories of dir, never files directly inside it. + - "**/client/*.go" + - "**/client/**/*.go" + - "!$test" + list-mode: strict + allow: + - $gostd + - github.com/meshcloud/meshstack-cli/client + + # pkg/ holds logic reusable outside a CLI process — the Terraform provider + # imports pkg/login — so cobra must not reach it. + pkg: + files: + - "**/pkg/*.go" + - "**/pkg/**/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: github.com/spf13/cobra + desc: cobra belongs in cmd/; pkg/ is also consumed by the Terraform provider + allow: + - $gostd + - github.com/meshcloud/meshstack-cli/client + - github.com/meshcloud/meshstack-cli/pkg + + # cmd/ builds the command tree, and is the only place cobra is used. + cmd: + files: + - "**/cmd/*.go" + - "**/cmd/**/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: log # as $gostd is allowed + desc: Write user-facing output through the command's own streams + allow: + - $gostd + - github.com/meshcloud/meshstack-cli + - github.com/spf13/cobra + + # Tests may additionally use testify, which is what the client's moved tests + # are written against. + tests: + files: + - "$test" + list-mode: strict + allow: + - $gostd + - github.com/meshcloud/meshstack-cli + - github.com/spf13/cobra + - github.com/stretchr/testify diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..847cd96 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,67 @@ +# Visit https://goreleaser.com for documentation on how to customize this behavior. +version: 2 + +# Everything published carries the repository name, meshstack-cli: the archives, the +# checksum file and the container image. The binary inside them is meshstack, which +# is why the build below names it explicitly instead of inheriting project_name. +project_name: meshstack-cli + +before: + hooks: + - go mod tidy + +builds: + - main: ./cmd/meshstack + binary: meshstack + env: + # A statically linked binary runs in the distroless container image and on any + # glibc version. + - CGO_ENABLED=0 + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath + ldflags: + - '-s -w -X main.Version={{ .Version }}' + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + # There is no 32-bit x86 or arm64 Windows target worth publishing. + ignore: + - goos: windows + goarch: arm64 + +archives: + - formats: + - tar.gz + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' + format_overrides: + - goos: windows + formats: + - zip + +checksum: + name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' + algorithm: sha256 + +changelog: + # Conventional Commits, so group the notes by type and drop the noise. + use: github + sort: asc + groups: + - title: Features + regexp: '^feat(\(.+\))?!?:' + order: 0 + - title: Fixes + regexp: '^fix(\(.+\))?!?:' + order: 1 + - title: Others + order: 99 + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..023b84f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,157 @@ +# AGENTS.md — meshStack CLI + + +You are an expert Go engineer working on the meshStack CLI: the `meshstack` binary, and the Go +client for the meshStack API that the +[meshStack Terraform provider](https://github.com/meshcloud/terraform-provider-meshstack) imports as +a library. This file is the always-on source of truth for both AI agents and humans. + + +> **This repository is public.** Write everything here so an external contributor with no meshcloud +> access can follow it. Tag meshcloud-internal shortcuts clearly as internal, and never let +> understanding a rule *depend* on them. + +## Naming + +- **`meshstack`** — the binary, so every invocation reads `meshstack buildingblock list`. +- **meshStack CLI** — the product name, used in prose and docs. +- `github.com/meshcloud/meshstack-cli` — the repository and Go module. + +Everything *published* carries the repository name — the release archives, the checksum file and the +container image are all `meshstack-cli` — while the binary inside them is `meshstack`. + +The binary gets its name from its directory, `cmd/meshstack`, which is why there is no `-o` flag +anywhere: `go build ./cmd/meshstack` and +`go install github.com/meshcloud/meshstack-cli/cmd/meshstack@latest` both produce `meshstack`. **Do +not add a `main.go` at the repository root**; that would name the binary after the module and bring +the flag back. + +## Package layout + +| Path | Holds | +|---|---| +| `cmd/meshstack/` | `package main`: `main()` and the root command. The only main package. | +| `cmd//` | One package per subcommand of the cobra command tree. | +| `pkg/` | Logic that does not need a CLI process, and that the Terraform provider can import. | +| `client/` | The meshStack API client. Path-identical to the provider's former `client/`. | + + +In `cmd/`, **the package name is the subcommand and the file name is the leaf command**: +`cmd/buildingblock/list.go` holds `meshstack buildingblock list`. Each package exports a `New` +function returning its `*cobra.Command`, and `cmd/meshstack` wires children in with `AddCommand`. + +`cmd/meshstack` is the one exception to that rule, and is not a subcommand: it is the binary's +`package main`, holding `main()` and the root command together. + +Register commands **explicitly in `cmd/meshstack`, never from `init()`**, so the command tree reads in +one place and a command cannot appear in the binary just because its package was imported for some +other reason. + + +## Dependency policy + +The CLI is allowed **exactly one external dependency, cobra**, and only `cmd/` may use it. Everything +else is standard library, with `testify` permitted in tests. + +This is not austerity for its own sake. The Terraform provider imports `client/` and `pkg/login`, so +every dependency added here lands in the provider's dependency tree, and from there in the public +checksum database. `depguard` in `.golangci.yml` enforces the boundaries per directory; read those +rules as the policy. Widening them is a deliberate decision, not a lint fix. + + +`client/` is a **git subtree** imported from +[terraform-provider-meshstack](https://github.com/meshcloud/terraform-provider-meshstack), where it +used to live, and it keeps the `client` path prefix it had there. Carry changes across with +`git subtree`, not by copying files: + +```shell +git subtree pull --prefix=client https://github.com/meshcloud/terraform-provider-meshstack.git main +git subtree push --prefix=client https://github.com/meshcloud/terraform-provider-meshstack.git +``` + +A pull conflicts only where a file genuinely diverged, because the one local edit the move needed was +rewriting the client's own import path. + +Reading the pre-import history takes both paths, since the split history carries the files at the +repository root and the import merge re-roots them under `client/`: + +```shell +git log -- client/client.go client.go # a path-limited log from client/ alone stops at the merge +git blame client/client.go # traverses the merge on its own +``` + +The login exchange lives in `client/internal/auth.go`, which posts to `/api/login`, caches the access +token and refreshes it before expiry. Go's internal rule keeps that code inside `client/`; reach it +through `client.NewApiKeyAuthorization`. Do **not** write a second login exchange elsewhere — a +hand-rolled one gets a static token and starts returning 401 once it expires. + +`pkg/login` owns credential resolution only: it reads the environment and returns a +`client.Authorization`. It is also the single place that constructs one, which is where token caching +on disk will hook in later. + + +## Always-on rules + + + +- **Lean comments.** A comment earns its place only by saying what the code cannot — the *why*, a + trade-off, a non-obvious constraint. Don't restate what a name, type or signature already conveys; + prefer one sharp line over a paragraph. +- **Lint only via `task lint`** (golangci-lint, which also enforces gci import ordering and gofmt). + It already runs `govet`, so **do not run `go vet` separately**. Auto-fix with `task lint -- --fix`. + CI builds golangci-lint from source with `go.mod`'s Go (`install-mode: goinstall`) instead of + downloading a release binary, and that is not incidental. The formatters use the `go/format` + compiled into the linter, so a binary built with a newer Go enforces a different gofmt than the one + the code is written against — and the two can disagree with no formatting that satisfies both. + Switching CI to the faster binary install brings that back. +- **Conventional Commits** for messages (`feat:`, `fix:`, `docs:`, `chore:`, `feat!:` for breaking). +- **Stress-test a plan before writing code.** For any non-trivial change, walk each branch of the + decision tree and settle every open question with a recommended answer first. Catching a wrong turn + at the plan stage is far cheaper than after the code and tests exist. (*meshcloud-internal*: the + `grill-me` skill in `meshfed-release/.agents/skills/`.) + + + +## Commands + +Everything runs through the Taskfile, inside `nix develop`: + +```shell +task build # ./meshstack +task test # go test ./... +task lint # golangci-lint run +task tidy # go mod tidy +task release:check # validate .goreleaser.yml +task release:snapshot # build the release artifacts into dist/ without publishing +task image # docker build -t meshstack:dev +``` + +The Go version is pinned in `go.mod` and in `flake.nix`; **keep them in lock-step when bumping**, and +keep them aligned with the Terraform provider, which consumes this module. + +## Authentication + +`MESHSTACK_ENDPOINT`, `MESHSTACK_API_KEY` and `MESHSTACK_API_SECRET`, with `MESHSTACK_API_TOKEN` as +an alternative to the key and secret pair. The names are exported as consts from `pkg/login`, so the +provider and the CLI share one definition — use those consts rather than the string literals. The +Taskfile reads a git-ignored `.env` for local runs. + +`MESHSTACK_SKIP_VERSION_CHECK=true` skips the minimum backend version check in `client/client.go`. + +## Releasing + +Pushing a `v*` tag runs goreleaser, which publishes the archives and checksums, and then builds the +container image for the same tag. The image goes to GHCR only, as +`ghcr.io/meshcloud/meshstack-cli`, and its entrypoint is the `meshstack` binary, so +`docker run ghcr.io/meshcloud/meshstack-cli buildingblock list` reads like the local invocation. A +push to `main` refreshes `:main`, so an image exists before the first release does. + + +The version comes from the git tag through an ldflag on `main.Version` in `cmd/meshstack`, in two +places that must agree: `.goreleaser.yml` and the `Dockerfile`. A build without the ldflag reports +`dev`, which is correct for a working copy but must never reach a published artifact — check with +`meshstack --version` after `task release:snapshot`. + + +Pin every GitHub Action by commit SHA with the version in a trailing comment, as the existing +workflows do. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aac96bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Runs on the build platform and cross-compiles for TARGETOS/TARGETARCH, so a +# multi-platform build needs no emulation. buildx sets those two args itself. +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build + +WORKDIR /src + +# Copied on their own so the module download layer survives any source change. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ + -trimpath \ + -ldflags "-s -w -X main.Version=${VERSION}" \ + -o /out/meshstack ./cmd/meshstack + +# distroless static: no shell and no package manager, which is all a single static +# binary needs. 'nonroot' runs as uid 65532. +FROM gcr.io/distroless/static-debian12:nonroot + +# The image is named after the repository, meshstack-cli, while the binary it carries +# is meshstack. So `docker run ghcr.io/meshcloud/meshstack-cli buildingblock list` +# reads the same as the local `meshstack buildingblock list`. +COPY --from=build /out/meshstack /usr/local/bin/meshstack + +ENTRYPOINT ["/usr/local/bin/meshstack"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2d7be93 --- /dev/null +++ b/LICENSE @@ -0,0 +1,375 @@ +Copyright (c) 2026 meshcloud GmbH + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9b6d47 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# meshStack CLI + +`meshstack` is the command line interface for [meshStack](https://www.meshcloud.io/). + +## Install + +```shell +go install github.com/meshcloud/meshstack-cli/cmd/meshstack@latest +``` + +## Development + +The Nix dev shell provides Go, `golangci-lint`, `goreleaser` and `task`: + +```shell +nix develop +task build # ./meshstack +task test # go test ./... +task lint # golangci-lint run, add -- --fix to apply fixes +task release:snapshot # build the release artifacts without publishing +``` + +The Go version is pinned in `go.mod` and in `flake.nix`, and is kept in lock-step with the +[meshStack Terraform provider](https://github.com/meshcloud/terraform-provider-meshstack), which +imports this repository's client package. diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..5d89c7c --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,50 @@ +version: '3' + +dotenv: ['.env'] + +tasks: + build: + desc: Build the meshstack binary + cmds: + - go build {{.CLI_ARGS}} ./cmd/meshstack + + install: + desc: Install the meshstack binary into GOBIN + cmds: + - go install {{.CLI_ARGS}} ./cmd/meshstack + + test: + desc: Run unit tests + cmds: + - go test ./... {{.CLI_ARGS}} + + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run {{.CLI_ARGS}} + + tidy: + desc: Tidy go.mod and go.sum + cmds: + - go mod tidy + + release:check: + desc: Validate .goreleaser.yml + cmds: + - goreleaser check + + release:snapshot: + desc: Build the release artifacts into dist/ without publishing them + cmds: + - goreleaser release --snapshot --clean {{.CLI_ARGS}} + + image: + desc: Build the container image locally + cmds: + - docker build -t meshstack:dev {{.CLI_ARGS}} . + + clean: + desc: Remove build artifacts + cmds: + - rm -f meshstack + - rm -rf dist diff --git a/client/api_key.go b/client/api_key.go new file mode 100644 index 0000000..f134e6e --- /dev/null +++ b/client/api_key.go @@ -0,0 +1,61 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" +) + +type MeshApiKey struct { + Metadata MeshApiKeyMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshApiKeySpec `json:"spec" tfsdk:"spec"` + Status *MeshApiKeyStatus `json:"status,omitempty" tfsdk:"status"` +} + +type MeshApiKeyMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshApiKeySpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Permissions types.Set[ApiPermission] `json:"permissions" tfsdk:"permissions"` + ExpiresAt *string `json:"expiresAt,omitempty" tfsdk:"expires_at"` +} + +type MeshApiKeyStatus struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + ClientSecret *string `json:"clientSecret,omitempty" tfsdk:"client_secret"` +} + +type MeshApiKeyClient interface { + Create(ctx context.Context, apiKey *MeshApiKey) (*MeshApiKey, error) + Read(ctx context.Context, uuid string) (*MeshApiKey, error) + Update(ctx context.Context, uuid string, apiKey *MeshApiKey) (*MeshApiKey, error) + Delete(ctx context.Context, uuid string) error +} + +type meshApiKeyClient struct { + meshObject internal.MeshObjectClient[MeshApiKey] +} + +func newApiKeyClient(ctx context.Context, httpClient internal.HttpClient) MeshApiKeyClient { + return meshApiKeyClient{internal.NewMeshObjectClient[MeshApiKey](ctx, httpClient, "v1-preview")} +} + +func (c meshApiKeyClient) Create(ctx context.Context, apiKey *MeshApiKey) (*MeshApiKey, error) { + return c.meshObject.Post(ctx, apiKey) +} + +func (c meshApiKeyClient) Read(ctx context.Context, uuid string) (*MeshApiKey, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshApiKeyClient) Update(ctx context.Context, uuid string, apiKey *MeshApiKey) (*MeshApiKey, error) { + return c.meshObject.Put(ctx, uuid, apiKey) +} + +func (c meshApiKeyClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/client/api_key_permissions.go b/client/api_key_permissions.go new file mode 100644 index 0000000..c2433d9 --- /dev/null +++ b/client/api_key_permissions.go @@ -0,0 +1,232 @@ +package client + +import "strings" + +// API Key Permissions aligned with Kotlin ApiKeyRightMetadataRegistry. +// See https://docs.meshcloud.io/api/authentication/api-permissions/ + +// ApiPermission is a permission shortcode string used for JSON serialization. +type ApiPermission string + +// ApiKeyPermissions is a 3D structure: +// - outer: groups (e.g. "Building Blocks", "Projects") +// - middle: suffix groups within a group (e.g. DELETE, LIST, SAVE variants together) +// - inner: scope variants (e.g. [TENANT_DELETE, ADM_TENANT_DELETE]) +// +// Each permission is listed exactly as it appears in the API, no prefix derivation. +type ApiKeyPermissions [][][]ApiPermission + +// AllCodes returns all valid API key permission shortcodes (flattened). +func (p ApiKeyPermissions) AllCodes() []string { + var codes []string + for _, group := range p { + for _, suffixGroup := range group { + for _, code := range suffixGroup { + codes = append(codes, string(code)) + } + } + } + return codes +} + +// WorkspaceCodes returns only non-ADM_ permission shortcodes (workspace + platform builder scoped). +func (p ApiKeyPermissions) WorkspaceCodes() []string { + var codes []string + for _, group := range p { + for _, suffixGroup := range group { + for _, code := range suffixGroup { + if !strings.HasPrefix(string(code), "ADM_") { + codes = append(codes, string(code)) + } + } + } + } + return codes +} + +// MarkdownString returns an unordered markdown list of all permissions grouped by resource. +// Each bullet shows workspace codes, then MANAGED_ codes, then ADM_ codes separated by " and ". +func (p ApiKeyPermissions) MarkdownString() string { + var lines []string + for _, group := range p { + var workspace, managed, admin []string + for _, suffixGroup := range group { + for _, code := range suffixGroup { + s := string(code) + switch { + case strings.HasPrefix(s, "ADM_"): + admin = append(admin, "`"+s+"`") + case strings.HasPrefix(s, "MANAGED_"): + managed = append(managed, "`"+s+"`") + default: + workspace = append(workspace, "`"+s+"`") + } + } + } + + var parts []string + if len(workspace) > 0 { + parts = append(parts, strings.Join(workspace, "/")) + } + if len(managed) > 0 { + parts = append(parts, strings.Join(managed, "/")) + } + if len(admin) > 0 { + parts = append(parts, strings.Join(admin, "/")) + } + lines = append(lines, " - "+strings.Join(parts, " and ")) + } + return "\n" + strings.Join(lines, "\n") + "\n" +} + +// Permissions is the complete registry of API key permissions, +// aligned 1:1 with the Kotlin ApiKeyRightMetadataRegistry. +var Permissions = ApiKeyPermissions{ + // API Keys + { + {"APIKEY_DELETE", "ADM_APIKEY_DELETE"}, + {"APIKEY_LIST", "ADM_APIKEY_LIST"}, + {"APIKEY_SAVE", "ADM_APIKEY_SAVE"}, + }, + // Building Blocks + { + {"BUILDINGBLOCK_DELETE", "ADM_BUILDINGBLOCK_DELETE"}, + {"BUILDINGBLOCK_LIST", "ADM_BUILDINGBLOCK_LIST", "MANAGED_BUILDINGBLOCK_LIST"}, + {"BUILDINGBLOCK_SAVE", "ADM_BUILDINGBLOCK_SAVE", "MANAGED_BUILDINGBLOCK_SAVE"}, + }, + // Building Block Definitions + { + {"BUILDINGBLOCKDEFINITION_DELETE", "ADM_BUILDINGBLOCKDEFINITION_DELETE"}, + {"BUILDINGBLOCKDEFINITION_LIST", "ADM_BUILDINGBLOCKDEFINITION_LIST"}, + {"BUILDINGBLOCKDEFINITION_SAVE", "ADM_BUILDINGBLOCKDEFINITION_SAVE"}, + {"ADM_REVIEW_PUBLICATION"}, + }, + // Building Block Runs + { + {"MANAGED_BUILDINGBLOCKRUN_LIST", "ADM_BUILDINGBLOCKRUN_LIST"}, + {"MANAGED_BUILDINGBLOCKRUN_SAVE", "ADM_BUILDINGBLOCKRUN_SAVE"}, + {"MANAGED_BUILDINGBLOCKRUNSOURCE_SAVE", "ADM_BUILDINGBLOCKRUNSOURCE_SAVE"}, + }, + // Building Block Runners + { + {"BUILDINGBLOCKRUNNER_DELETE", "ADM_BUILDINGBLOCKRUNNER_DELETE"}, + {"BUILDINGBLOCKRUNNER_LIST", "ADM_BUILDINGBLOCKRUNNER_LIST"}, + {"BUILDINGBLOCKRUNNER_SAVE", "ADM_BUILDINGBLOCKRUNNER_SAVE"}, + }, + // Communication Definitions + { + {"COMMUNICATIONDEFINITION_DELETE", "ADM_COMMUNICATIONDEFINITION_DELETE"}, + {"COMMUNICATIONDEFINITION_LIST", "ADM_COMMUNICATIONDEFINITION_LIST"}, + {"COMMUNICATIONDEFINITION_SAVE", "ADM_COMMUNICATIONDEFINITION_SAVE"}, + }, + // Communications + { + {"COMMUNICATION_DELETE", "ADM_COMMUNICATION_DELETE"}, + {"COMMUNICATION_LIST", "ADM_COMMUNICATION_LIST"}, + {"COMMUNICATION_SAVE", "ADM_COMMUNICATION_SAVE"}, + }, + // Event Logs + { + {"EVENTLOG_LIST", "ADM_EVENTLOG_LIST"}, + }, + // Integrations + { + {"INTEGRATION_DELETE", "ADM_INTEGRATION_DELETE"}, + {"INTEGRATION_LIST", "ADM_INTEGRATION_LIST"}, + {"INTEGRATION_SAVE", "ADM_INTEGRATION_SAVE"}, + }, + // Landing Zones + { + {"LANDINGZONE_DELETE", "ADM_LANDINGZONE_DELETE"}, + {"LANDINGZONE_LIST", "ADM_LANDINGZONE_LIST"}, + {"LANDINGZONE_SAVE", "ADM_LANDINGZONE_SAVE"}, + }, + // Payment Methods + { + {"ADM_PAYMENTMETHOD_DELETE"}, + {"PAYMENTMETHOD_LIST", "ADM_PAYMENTMETHOD_LIST"}, + {"ADM_PAYMENTMETHOD_SAVE"}, + }, + // Platform Instances, Platform Types, Locations + { + {"PLATFORMINSTANCE_DELETE", "ADM_PLATFORMINSTANCE_DELETE"}, + {"PLATFORMINSTANCE_LIST", "ADM_PLATFORMINSTANCE_LIST"}, + {"PLATFORMINSTANCE_SAVE", "ADM_PLATFORMINSTANCE_SAVE"}, + }, + // Project Role Bindings + { + {"PROJECTPRINCIPALROLE_DELETE", "ADM_PROJECTPRINCIPALROLE_DELETE"}, + {"PROJECTPRINCIPALROLE_LIST", "ADM_PROJECTPRINCIPALROLE_LIST"}, + {"PROJECTPRINCIPALROLE_SAVE", "ADM_PROJECTPRINCIPALROLE_SAVE"}, + }, + // Project Roles + { + {"ADM_PROJECTROLE_DELETE"}, + {"ADM_PROJECTROLE_SAVE"}, + }, + // Projects + { + {"PROJECT_DELETE", "ADM_PROJECT_DELETE"}, + {"PROJECT_LIST", "ADM_PROJECT_LIST"}, + {"PROJECT_SAVE", "ADM_PROJECT_SAVE"}, + }, + // Service Instances + { + {"SERVICEINSTANCE_DELETE", "ADM_SERVICEINSTANCE_DELETE"}, + {"SERVICEINSTANCE_LIST", "ADM_SERVICEINSTANCE_LIST"}, + {"SERVICEINSTANCE_SAVE", "ADM_SERVICEINSTANCE_SAVE"}, + }, + // Tag Definitions + { + {"ADM_TAGDEFINITION_DELETE"}, + {"ADM_TAGDEFINITION_LIST"}, + {"ADM_TAGDEFINITION_SAVE"}, + }, + // Tenants + { + {"TENANT_DELETE", "ADM_TENANT_DELETE"}, + {"MANAGED_TENANT_IMPORT", "ADM_TENANT_IMPORT"}, + {"TENANT_LIST", "ADM_TENANT_LIST"}, + {"TENANT_SAVE", "ADM_TENANT_SAVE"}, + }, + // Terraform States + { + {"TFSTATE_DELETE", "ADM_TFSTATE_DELETE", "MANAGED_TFSTATE_DELETE"}, + {"TFSTATE_LIST", "ADM_TFSTATE_LIST", "MANAGED_TFSTATE_LIST"}, + {"TFSTATE_SAVE", "ADM_TFSTATE_SAVE", "MANAGED_TFSTATE_SAVE"}, + }, + // Users + { + {"ADM_USER_DELETE"}, + {"ADM_USER_LIST"}, + {"ADM_USER_SAVE"}, + }, + // Workspace Role Bindings + { + {"WORKSPACEPRINCIPALBINDING_DELETE", "ADM_WORKSPACEPRINCIPALBINDING_DELETE"}, + {"WORKSPACEPRINCIPALBINDING_LIST", "ADM_WORKSPACEPRINCIPALBINDING_LIST"}, + {"WORKSPACEPRINCIPALBINDING_SAVE", "ADM_WORKSPACEPRINCIPALBINDING_SAVE"}, + }, + // Workspace User Groups + { + {"WORKSPACEUSERGROUP_LIST", "ADM_WORKSPACEUSERGROUP_LIST"}, + }, + // Workspaces + { + {"WORKSPACE_DELETE", "ADM_WORKSPACE_DELETE"}, + {"WORKSPACE_LIST", "ADM_WORKSPACE_LIST"}, + {"WORKSPACE_SAVE", "ADM_WORKSPACE_SAVE"}, + }, +} + +// Convenience functions used by consumers. + +// AllApiKeyPermissions returns all valid API key permission shortcodes. +func AllApiKeyPermissions() []string { + return Permissions.AllCodes() +} + +// WorkspacePermissionCodes returns only workspace-scoped permission shortcodes. +func WorkspacePermissionCodes() []string { + return Permissions.WorkspaceCodes() +} diff --git a/client/building_block_definition.go b/client/building_block_definition.go new file mode 100644 index 0000000..17d75d4 --- /dev/null +++ b/client/building_block_definition.go @@ -0,0 +1,108 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type MeshBuildingBlockType string + +var ( + MeshBuildingBlockTypes = enum.Enum[MeshBuildingBlockType]{} + MeshBuildingBlockTypeTenantLevel = MeshBuildingBlockTypes.Entry("TENANT_LEVEL") + MeshBuildingBlockTypeWorkspaceLevel = MeshBuildingBlockTypes.Entry("WORKSPACE_LEVEL") +) + +type MeshBuildingBlockDefinitionMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +type MeshBuildingBlockDefinitionSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + TargetType MeshBuildingBlockType `json:"targetType" tfsdk:"target_type"` + Description string `json:"description" tfsdk:"description"` + Readme *string `json:"readme,omitempty" tfsdk:"readme"` + RunTransparency bool `json:"runTransparency" tfsdk:"run_transparency"` + UseInLandingZonesOnly bool `json:"useInLandingZonesOnly" tfsdk:"use_in_landing_zones_only"` + SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! + NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` + Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + SupportedPlatforms types.Set[NamedRef] `json:"supportedPlatforms" tfsdk:"supported_platforms"` +} + +type MeshBuildingBlockDefinitionStatusVersion struct { + VersionUuid string `json:"versionUuid"` + VersionNumber int64 `json:"versionNumber"` + State MeshBuildingBlockDefinitionVersionState `json:"state"` +} + +type MeshBuildingBlockDefinitionStatus struct { + UsageCount *int64 `json:"usageCount"` + Versions []MeshBuildingBlockDefinitionStatusVersion `json:"versions"` + LatestVersion int64 `json:"latestVersion"` + LatestVersionUuid string `json:"latestVersionUuid"` + LatestReleasedVersion *int64 `json:"latestReleasedVersion"` + LatestReleasedVersionUuid *string `json:"latestReleasedVersionUuid"` +} + +type MeshBuildingBlockDefinition struct { + Metadata MeshBuildingBlockDefinitionMetadata `json:"metadata"` + Spec MeshBuildingBlockDefinitionSpec `json:"spec"` + Status *MeshBuildingBlockDefinitionStatus `json:"status,omitempty"` +} + +type MeshBuildingBlockDefinitionClient interface { + List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) + Read(ctx context.Context, uuid string) (*MeshBuildingBlockDefinition, error) + Create(ctx context.Context, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) + Update(ctx context.Context, uuid string, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) + Delete(ctx context.Context, uuid string) error +} + +type meshBuildingBlockDefinitionClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockDefinition] +} + +func newBuildingBlockDefinitionClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockDefinitionClient { + return meshBuildingBlockDefinitionClient{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockDefinition](ctx, httpClient, "v1-preview"), + } +} + +type meshBuildingBlockDefinitionListQuery struct { + // IncludeAllPublished is always true here: list definitions published across the platform in + // addition to the workspace's own. (A false bool would be dropped by WithUrlQuery, which is fine — + // this endpoint is only ever called with it set.) + IncludeAllPublished bool `json:"includeAllPublished"` + OwnedByWorkspace *string `json:"ownedByWorkspace"` +} + +func (c meshBuildingBlockDefinitionClient) List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(meshBuildingBlockDefinitionListQuery{ + IncludeAllPublished: true, + OwnedByWorkspace: workspaceIdentifier, + })) +} + +func (c meshBuildingBlockDefinitionClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlockDefinition, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshBuildingBlockDefinitionClient) Create(ctx context.Context, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) { + return c.meshObject.Post(ctx, definition) +} + +func (c meshBuildingBlockDefinitionClient) Update(ctx context.Context, uuid string, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) { + return c.meshObject.Put(ctx, uuid, definition) +} + +func (c meshBuildingBlockDefinitionClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/client/building_block_definition_version.go b/client/building_block_definition_version.go new file mode 100644 index 0000000..3d67df6 --- /dev/null +++ b/client/building_block_definition_version.go @@ -0,0 +1,228 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +// Enums + +type MeshBuildingBlockDefinitionVersionState string + +var ( + MeshBuildingBlockDefinitionVersionStates = enum.Enum[MeshBuildingBlockDefinitionVersionState]{} + MeshBuildingBlockDefinitionVersionStateDraft = MeshBuildingBlockDefinitionVersionStates.Entry("DRAFT") + MeshBuildingBlockDefinitionVersionStateReleased = MeshBuildingBlockDefinitionVersionStates.Entry("RELEASED") +) + +type BuildingBlockDeletionMode string + +var ( + BuildingBlockDeletionModes = enum.Enum[BuildingBlockDeletionMode]{} + BuildingBlockDeletionModeDelete = BuildingBlockDeletionModes.Entry("DELETE") + BuildingBlockDeletionModePurge = BuildingBlockDeletionModes.Entry("PURGE") +) + +type MeshBuildingBlockIOType string + +var ( + MeshBuildingBlockIOTypes = enum.Enum[MeshBuildingBlockIOType]{} + MeshBuildingBlockIOTypeString = MeshBuildingBlockIOTypes.Entry("STRING") + MeshBuildingBlockIOTypeCode = MeshBuildingBlockIOTypes.Entry("CODE") + MeshBuildingBlockIOTypeInteger = MeshBuildingBlockIOTypes.Entry("INTEGER") + MeshBuildingBlockIOTypeBoolean = MeshBuildingBlockIOTypes.Entry("BOOLEAN") + MeshBuildingBlockIOTypeFile = MeshBuildingBlockIOTypes.Entry("FILE") + MeshBuildingBlockIOTypeList = MeshBuildingBlockIOTypes.Entry("LIST") + MeshBuildingBlockIOTypeSingleSelect = MeshBuildingBlockIOTypes.Entry("SINGLE_SELECT") + MeshBuildingBlockIOTypeMultiSelect = MeshBuildingBlockIOTypes.Entry("MULTI_SELECT") +) + +var MeshBuildingBlockOutputIOTypes = enum.Of( + MeshBuildingBlockIOTypeString, + MeshBuildingBlockIOTypeCode, + MeshBuildingBlockIOTypeInteger, + MeshBuildingBlockIOTypeBoolean, +) + +type MeshBuildingBlockInputAssignmentType string + +var ( + MeshBuildingBlockInputAssignmentTypes = enum.Enum[MeshBuildingBlockInputAssignmentType]{} + MeshBuildingBlockInputAssignmentTypeAuthor = MeshBuildingBlockInputAssignmentTypes.Entry("AUTHOR") + MeshBuildingBlockInputAssignmentTypeUserInput = MeshBuildingBlockInputAssignmentTypes.Entry("USER_INPUT") + MeshBuildingBlockInputAssignmentTypePlatformOperatorManualInput = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_OPERATOR_MANUAL_INPUT") + MeshBuildingBlockInputAssignmentTypeBuildingBlockOutput = MeshBuildingBlockInputAssignmentTypes.Entry("BUILDING_BLOCK_OUTPUT") + MeshBuildingBlockInputAssignmentTypePlatformTenantID = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_TENANT_ID") + MeshBuildingBlockInputAssignmentTypeMeshstackTenantUuid = MeshBuildingBlockInputAssignmentTypes.Entry("MESHSTACK_TENANT_UUID") + MeshBuildingBlockInputAssignmentTypeWorkspaceIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("WORKSPACE_IDENTIFIER") + MeshBuildingBlockInputAssignmentTypeProjectIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("PROJECT_IDENTIFIER") + MeshBuildingBlockInputAssignmentTypeFullPlatformIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("FULL_PLATFORM_IDENTIFIER") + MeshBuildingBlockInputAssignmentTypeTenantBuildingBlockUuid = MeshBuildingBlockInputAssignmentTypes.Entry("TENANT_BUILDING_BLOCK_UUID") + MeshBuildingBlockInputAssignmentTypeStatic = MeshBuildingBlockInputAssignmentTypes.Entry("STATIC") + MeshBuildingBlockInputAssignmentTypeUserPermissions = MeshBuildingBlockInputAssignmentTypes.Entry("USER_PERMISSIONS") +) + +type MeshBuildingBlockDefinitionOutputAssignmentType string + +var ( + MeshBuildingBlockDefinitionOutputAssignmentTypes = enum.Enum[MeshBuildingBlockDefinitionOutputAssignmentType]{} + MeshBuildingBlockDefinitionOutputAssignmentTypeNone = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("NONE") + MeshBuildingBlockDefinitionOutputAssignmentTypePlatformTenantID = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("PLATFORM_TENANT_ID") + MeshBuildingBlockDefinitionOutputAssignmentTypeSignInURL = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("SIGN_IN_URL") + MeshBuildingBlockDefinitionOutputAssignmentTypeResourceURL = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("RESOURCE_URL") + MeshBuildingBlockDefinitionOutputAssignmentTypeSummary = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("SUMMARY") +) + +// Input and Output types + +type MeshBuildingBlockDefinitionInput struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Type MeshBuildingBlockIOType `json:"type" tfsdk:"type"` + AssignmentType MeshBuildingBlockInputAssignmentType `json:"assignmentType" tfsdk:"assignment_type"` + IsEnvironment bool `json:"isEnvironment" tfsdk:"is_environment"` + IsSensitive bool `json:"isSensitive" tfsdk:"-"` + // If IsSensitive is true, the [types.Variant] (typedef [types.SecretOrAny]) for fields + // MeshBuildingBlockDefinitionInputAdapter.Argument and + // MeshBuildingBlockDefinitionInputAdapter.DefaultValue + // is of [types.Secret] (case [types.Variant.X]). + // Otherwise, the [types.Variant] is of [types.Any] (case [types.Variant.Y]). + // As this is a fallback detection when JSON (un)marshaling, + // types.Any must go second as [types.Variant] intentionally prefers X over Y. + Argument types.SecretOrAny `json:"argument" tfsdk:"argument"` + DefaultValue types.SecretOrAny `json:"defaultValue" tfsdk:"default_value"` + UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` + SelectableValues types.Set[string] `json:"selectableValues,omitempty" tfsdk:"selectable_values"` + Description *string `json:"description,omitempty" tfsdk:"description"` + ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` + ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` + // No omitempty: a 0 (the schema default, and what an unknown plan value collapses to) must be sent so + // the backend stores it verbatim. With omitempty the 0 would be dropped and the backend would assign + // a position itself, making the applied value differ from the plan. + DisplayOrder int64 `json:"displayOrder" tfsdk:"display_order"` +} + +func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { + type wrapped MeshBuildingBlockDefinitionInput + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshBuildingBlockDefinitionInput(target) + switch { + case !m.IsSensitive: + // ensure "any" struct fields never end up in X accidentally, + // as X is only set when IsSensitive is true! + var errs []error + moveXtoYIfPresent := func(v *types.SecretOrAny) { + if v.HasX() { + xJson, err := json.Marshal(v.X) + errs = append(errs, err) + v.X = types.Secret{} + errs = append(errs, json.Unmarshal(xJson, &v.Y)) + } + } + moveXtoYIfPresent(&m.Argument) + moveXtoYIfPresent(&m.DefaultValue) + return errors.Join(errs...) + case m.Argument.HasY(), m.DefaultValue.HasY(): + return fmt.Errorf("got sensitive argument or default_value but variant Y is set instead") + default: + return nil + } +} + +type MeshBuildingBlockDefinitionOutput struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Type MeshBuildingBlockIOType `json:"type" tfsdk:"type"` + AssignmentType MeshBuildingBlockDefinitionOutputAssignmentType `json:"assignmentType" tfsdk:"assignment_type"` + // No omitempty so a 0 is sent, not dropped (see MeshBuildingBlockDefinitionInput.DisplayOrder). + DisplayOrder int64 `json:"displayOrder" tfsdk:"display_order"` +} + +// Main version types + +type MeshBuildingBlockDefinitionVersionMetadata struct { + Uuid string `json:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace"` + CreatedOn string `json:"createdOn"` +} + +type MeshBuildingBlockDefinitionVersionSpec struct { + BuildingBlockDefinitionRef *UuidRef `json:"buildingBlockDefinitionRef" tfsdk:"-"` + OnlyApplyOncePerTenant bool `json:"onlyApplyOncePerTenant" tfsdk:"only_apply_once_per_tenant"` + DeletionMode BuildingBlockDeletionMode `json:"deletionMode" tfsdk:"deletion_mode"` + Permissions types.Set[ApiPermission] `json:"permissions,omitempty" tfsdk:"permissions"` + Outputs map[string]MeshBuildingBlockDefinitionOutput `json:"outputs" tfsdk:"outputs"` + VersionNumber *int64 `json:"versionNumber,omitempty" tfsdk:"version_number"` + State *MeshBuildingBlockDefinitionVersionState `json:"state,omitempty" tfsdk:"state"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` + // Replaces the deprecated bare-UUID dependencyDefinitionUuids; requires a backend serving it. + DependencyDefinitionRefs types.Set[UuidRef] `json:"dependencyDefinitionRefs,omitempty" tfsdk:"dependency_refs"` + Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` + Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` +} + +type MeshBuildingBlockDefinitionVersionStatus struct { + State MeshBuildingBlockDefinitionVersionState `json:"state" tfsdk:"state"` + UsageCount int64 `json:"usageCount" tfsdk:"usage_count"` +} + +type MeshBuildingBlockDefinitionVersion struct { + Metadata MeshBuildingBlockDefinitionVersionMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockDefinitionVersionSpec `json:"spec" tfsdk:"spec"` + Status *MeshBuildingBlockDefinitionVersionStatus `json:"status,omitempty" tfsdk:"status"` +} + +// MeshBuildingBlockDefinitionVersionClient manages a version of a building block definition. +// As such a version is tightly coupled to the definition, there's no single Get or Delete implemented. +// A Get is not required as we always expose all versions of a definition anyway, and a Delete happens together when the definition is deleted. +type MeshBuildingBlockDefinitionVersionClient interface { + List(ctx context.Context, buildingBlockDefinitionUuid string) ([]MeshBuildingBlockDefinitionVersion, error) + Create(ctx context.Context, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) + Update(ctx context.Context, uuid, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) +} + +type meshBuildingBlockDefinitionVersionClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockDefinitionVersion] +} + +func newBuildingBlockDefinitionVersionClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockDefinitionVersionClient { + return meshBuildingBlockDefinitionVersionClient{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockDefinitionVersion](ctx, httpClient, "v1-preview"), + } +} + +type meshBuildingBlockDefinitionVersionListQuery struct { + BuildingBlockDefinitionUuid string `json:"buildingBlockDefinitionUuid"` +} + +func (c meshBuildingBlockDefinitionVersionClient) List(ctx context.Context, buildingBlockDefinitionUuid string) ([]MeshBuildingBlockDefinitionVersion, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(meshBuildingBlockDefinitionVersionListQuery{ + BuildingBlockDefinitionUuid: buildingBlockDefinitionUuid, + })) +} + +func (c meshBuildingBlockDefinitionVersionClient) Create(ctx context.Context, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { + return c.meshObject.Post(ctx, MeshBuildingBlockDefinitionVersion{ + Metadata: MeshBuildingBlockDefinitionVersionMetadata{ + OwnedByWorkspace: ownedByWorkspace, + }, + Spec: versionSpec, + }) +} + +func (c meshBuildingBlockDefinitionVersionClient) Update(ctx context.Context, uuid, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { + return c.meshObject.Put(ctx, uuid, MeshBuildingBlockDefinitionVersion{ + Metadata: MeshBuildingBlockDefinitionVersionMetadata{ + Uuid: uuid, + OwnedByWorkspace: ownedByWorkspace, + }, + Spec: versionSpec, + }) +} diff --git a/client/building_block_definition_version_implementation.go b/client/building_block_definition_version_implementation.go new file mode 100644 index 0000000..e6a9192 --- /dev/null +++ b/client/building_block_definition_version_implementation.go @@ -0,0 +1,118 @@ +package client + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type MeshBuildingBlockImplementationType string + +var ( + MeshBuildingBlockImplementationTypes = enum.Enum[MeshBuildingBlockImplementationType]{} + MeshBuildingBlockImplementationTypeManual = MeshBuildingBlockImplementationTypes.Entry("manual") + MeshBuildingBlockImplementationTypeTerraform = MeshBuildingBlockImplementationTypes.Entry("terraform") + MeshBuildingBlockImplementationTypeGithubWorkflows = MeshBuildingBlockImplementationTypes.Entry("githubWorkflows") + MeshBuildingBlockImplementationTypeGitlabPipeline = MeshBuildingBlockImplementationTypes.Entry("gitlabPipeline") + MeshBuildingBlockImplementationTypeAzureDevOpsPipeline = MeshBuildingBlockImplementationTypes.Entry("azureDevOpsPipeline") +) + +type MeshBuildingBlockDefinitionSshKnownHost struct { + Host string `json:"host" tfsdk:"host"` + KeyType string `json:"keyType" tfsdk:"key_type"` + KeyValue string `json:"keyValue" tfsdk:"key_value"` +} + +type MeshBuildingBlockDefinitionTerraformImplementation struct { + TerraformVersion string `json:"terraformVersion" tfsdk:"terraform_version"` + RepositoryURL string `json:"repositoryUrl" tfsdk:"repository_url"` + Async bool `json:"async" tfsdk:"async"` + RepositoryPath *string `json:"repositoryPath,omitempty" tfsdk:"repository_path"` + RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` + SSHKnownHost *MeshBuildingBlockDefinitionSshKnownHost `json:"sshKnownHost,omitempty" tfsdk:"ssh_known_host"` + UseMeshHTTPBackendFallback bool `json:"useMeshHttpBackendFallback" tfsdk:"use_mesh_http_backend_fallback"` + SSHPrivateKey *types.Secret `json:"sshPrivateKey,omitempty" tfsdk:"ssh_private_key"` + PreRunScript *string `json:"preRunScript,omitempty" tfsdk:"pre_run_script"` +} + +type MeshBuildingBlockDefinitionGitHubWorkflowsImplementation struct { + Repository string `json:"repository" tfsdk:"repository"` + Branch string `json:"branch" tfsdk:"branch"` + ApplyWorkflow string `json:"applyWorkflow" tfsdk:"apply_workflow"` + DestroyWorkflow *string `json:"destroyWorkflow" tfsdk:"destroy_workflow"` + Async bool `json:"async" tfsdk:"async"` + OmitRunObjectInput bool `json:"omitRunObjectInput" tfsdk:"omit_run_object_input"` + IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` +} + +type MeshBuildingBlockDefinitionManualImplementation struct { +} + +type MeshBuildingBlockDefinitionGitLabPipelineImplementation struct { + ProjectID string `json:"projectId" tfsdk:"project_id"` + RefName string `json:"refName" tfsdk:"ref_name"` + IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` + PipelineTriggerToken types.Secret `json:"pipelineTriggerToken" tfsdk:"pipeline_trigger_token"` +} + +type MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation struct { + Project string `json:"project" tfsdk:"project"` + PipelineID string `json:"pipelineId" tfsdk:"pipeline_id"` + RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` + Async bool `json:"async" tfsdk:"async"` + IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` +} + +type MeshBuildingBlockDefinitionImplementation struct { + Type enum.Entry[MeshBuildingBlockImplementationType] `json:"type" tfsdk:"-"` + Manual *MeshBuildingBlockDefinitionManualImplementation `json:"manual,omitempty" tfsdk:"manual"` + GithubWorkflows *MeshBuildingBlockDefinitionGitHubWorkflowsImplementation `json:"githubWorkflows,omitempty" tfsdk:"github_workflows"` + AzureDevOpsPipeline *MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation `json:"azureDevOpsPipeline,omitempty" tfsdk:"azure_devops_pipeline"` + GitlabPipeline *MeshBuildingBlockDefinitionGitLabPipelineImplementation `json:"gitlabPipeline,omitempty" tfsdk:"gitlab_pipeline"` + Terraform *MeshBuildingBlockDefinitionTerraformImplementation `json:"terraform,omitempty" tfsdk:"terraform"` +} + +func (m MeshBuildingBlockDefinitionImplementation) InferTypeFromNonNilField() (result enum.Entry[MeshBuildingBlockImplementationType]) { + setResultIfNotNil := func(implType enum.Entry[MeshBuildingBlockImplementationType], v any) { + // Manual implementation is an empty struct, so carefully check v for nilness using reflection! + if !reflect.ValueOf(v).IsZero() { + if len(result) > 0 && result != implType { + panic(fmt.Errorf("inferred implementation type %s but already set to %s", implType, result)) + } + result = implType + } + } + setResultIfNotNil(MeshBuildingBlockImplementationTypeManual, m.Manual) + setResultIfNotNil(MeshBuildingBlockImplementationTypeTerraform, m.Terraform) + setResultIfNotNil(MeshBuildingBlockImplementationTypeGithubWorkflows, m.GithubWorkflows) + setResultIfNotNil(MeshBuildingBlockImplementationTypeGitlabPipeline, m.GitlabPipeline) + setResultIfNotNil(MeshBuildingBlockImplementationTypeAzureDevOpsPipeline, m.AzureDevOpsPipeline) + if len(result) == 0 { + panic("cannot infer implementation type") + } + return +} + +func (m MeshBuildingBlockDefinitionImplementation) MarshalJSON() ([]byte, error) { + if len(m.Type) == 0 { + m.Type = m.InferTypeFromNonNilField() + } + type wrapped MeshBuildingBlockDefinitionImplementation + return json.Marshal(wrapped(m)) +} + +func (m *MeshBuildingBlockDefinitionImplementation) UnmarshalJSON(bytes []byte) error { + type wrapped MeshBuildingBlockDefinitionImplementation + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshBuildingBlockDefinitionImplementation(target) + if m.Type == MeshBuildingBlockImplementationTypeManual { + m.Manual = &MeshBuildingBlockDefinitionManualImplementation{} + } + return nil +} diff --git a/client/building_block_definition_version_test.go b/client/building_block_definition_version_test.go new file mode 100644 index 0000000..d57e237 --- /dev/null +++ b/client/building_block_definition_version_test.go @@ -0,0 +1,51 @@ +package client + +import ( + "embed" + "encoding/json" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/types" +) + +var ( + //go:embed testdata/building_block_definition_version_input + bbdInputTestdata embed.FS +) + +func TestMeshBuildingBlockDefinitionInput_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + wantSensitive bool + wantArgument types.SecretOrAny + wantDefaultValue types.SecretOrAny + wantErr assert.ErrorAssertionFunc + }{ + {"empty", false, types.SecretOrAny{}, types.SecretOrAny{}, assert.NoError}, + {"not_sensitive", false, types.SecretOrAny{Y: true}, types.SecretOrAny{Y: "some-string"}, assert.NoError}, + {"not_sensitive_but_hash", false, types.SecretOrAny{Y: map[string]any{"hash": "some-hash-looks-like-secret"}}, types.SecretOrAny{}, assert.NoError}, + {"sensitive", true, types.SecretOrAny{}, types.SecretOrAny{X: types.Secret{Hash: new("some-hash")}}, assert.NoError}, + {"sensitive_but_no_hash", true, types.SecretOrAny{Y: map[string]any{}}, types.SecretOrAny{}, func(t assert.TestingT, err error, msgAndArgs ...any) bool { + return assert.ErrorContains(t, err, "got sensitive argument or default_value but variant Y is set instead") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jsonFile, err := bbdInputTestdata.ReadFile(path.Join("testdata/building_block_definition_version_input", path.Base(tt.name)+".json")) + require.NoError(t, err) + var target MeshBuildingBlockDefinitionInput + if tt.wantErr(t, json.Unmarshal(jsonFile, &target)) { + expected := MeshBuildingBlockDefinitionInput{ + IsSensitive: tt.wantSensitive, + Argument: tt.wantArgument, + DefaultValue: tt.wantDefaultValue, + } + assert.Equal(t, expected, target) + } + }) + } +} diff --git a/client/building_block_run.go b/client/building_block_run.go new file mode 100644 index 0000000..c172bb3 --- /dev/null +++ b/client/building_block_run.go @@ -0,0 +1,60 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshBuildingBlockRun struct { + Metadata MeshBuildingBlockRunMetadata `json:"metadata"` + Spec MeshBuildingBlockRunSpec `json:"spec"` + Status string `json:"status"` +} + +type MeshBuildingBlockRunMetadata struct { + Uuid string `json:"uuid"` + CreatedOn string `json:"createdOn"` +} + +type MeshBuildingBlockRunSpec struct { + RunNumber int64 `json:"runNumber"` + Behavior string `json:"behavior"` +} + +// MeshBuildingBlockRunLogs is the response from the download-logs actions endpoint. +type MeshBuildingBlockRunLogs struct { + Steps []MeshBuildingBlockRunStepLog `json:"steps"` +} + +// MeshBuildingBlockRunStepLog represents a single step's log data. +type MeshBuildingBlockRunStepLog struct { + DisplayName string `json:"displayName"` + Status string `json:"status"` + UserMessage *string `json:"userMessage"` + SystemMessage *string `json:"systemMessage"` +} + +type MeshBuildingBlockRunClient interface { + GetLogs(ctx context.Context, runUuid string) (MeshBuildingBlockRunLogs, error) +} + +type meshBuildingBlockRunClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockRun] +} + +func newBuildingBlockRunClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockRunClient { + return meshBuildingBlockRunClient{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockRun](ctx, httpClient, "v1"), + } +} + +func (c meshBuildingBlockRunClient) GetLogs(ctx context.Context, runUuid string) (MeshBuildingBlockRunLogs, error) { + return internal.DoAuthorizedRequest[MeshBuildingBlockRunLogs]( + ctx, + c.meshObject.HttpClient, + "GET", + c.meshObject.ApiUrl.JoinPath(runUuid, "logs"), + internal.WithAccept(c.meshObject.MeshObjectMimeType()), + ) +} diff --git a/client/building_block_runner.go b/client/building_block_runner.go new file mode 100644 index 0000000..aef560b --- /dev/null +++ b/client/building_block_runner.go @@ -0,0 +1,97 @@ +package client + +import ( + "context" + "fmt" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshBuildingBlockRunnerImplementationType string + +const ( + MeshBuildingBlockRunnerImplementationTypeTerraform MeshBuildingBlockRunnerImplementationType = "TERRAFORM" + MeshBuildingBlockRunnerImplementationTypeGithubWorkflow MeshBuildingBlockRunnerImplementationType = "GITHUB_WORKFLOW" + MeshBuildingBlockRunnerImplementationTypeGitlabPipeline MeshBuildingBlockRunnerImplementationType = "GITLAB_PIPELINE" + MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline MeshBuildingBlockRunnerImplementationType = "AZURE_DEVOPS_PIPELINE" + MeshBuildingBlockRunnerImplementationTypeManual MeshBuildingBlockRunnerImplementationType = "MANUAL" + MeshBuildingBlockRunnerImplementationTypeAll MeshBuildingBlockRunnerImplementationType = "ALL" +) + +var MeshBuildingBlockRunnerImplementationTypes = []string{ + string(MeshBuildingBlockRunnerImplementationTypeTerraform), + string(MeshBuildingBlockRunnerImplementationTypeGithubWorkflow), + string(MeshBuildingBlockRunnerImplementationTypeGitlabPipeline), + string(MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline), + string(MeshBuildingBlockRunnerImplementationTypeManual), + string(MeshBuildingBlockRunnerImplementationTypeAll), +} + +type MeshBuildingBlockRunner struct { + Metadata MeshBuildingBlockRunnerMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockRunnerSpec `json:"spec" tfsdk:"spec"` +} + +type MeshBuildingBlockRunnerMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn *string `json:"createdOn,omitempty" tfsdk:"created_on"` + LastSeen *string `json:"lastSeen,omitempty" tfsdk:"last_seen"` +} + +type MeshBuildingBlockRunnerSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + PublicKey string `json:"publicKey" tfsdk:"public_key"` + ImplementationType string `json:"implementationType" tfsdk:"implementation_type"` + Restriction *string `json:"restriction,omitempty" tfsdk:"restriction"` + IsSelfHosted *bool `json:"isSelfHosted,omitempty" tfsdk:"is_self_hosted"` + WorkloadIdentityFederation *MeshRunnerWorkloadIdentityFed `json:"workloadIdentityFederation,omitempty" tfsdk:"workload_identity_federation"` +} + +type MeshRunnerWorkloadIdentityFed struct { + Subject *string `json:"subject,omitempty" tfsdk:"subject"` + Issuer *string `json:"issuer,omitempty" tfsdk:"issuer"` + Gcp *MeshRunnerWifProviderConfig `json:"gcp,omitempty" tfsdk:"gcp"` + Aws *MeshRunnerWifProviderConfig `json:"aws,omitempty" tfsdk:"aws"` + Azure *MeshRunnerWifProviderConfig `json:"azure,omitempty" tfsdk:"azure"` +} + +type MeshRunnerWifProviderConfig struct { + Audience string `json:"audience" tfsdk:"audience"` + TokenPath string `json:"tokenPath" tfsdk:"token_path"` +} + +type MeshBuildingBlockRunnerClient interface { + Create(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) + Read(ctx context.Context, uuid string) (*MeshBuildingBlockRunner, error) + Update(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) + Delete(ctx context.Context, uuid string) error +} + +type meshBuildingBlockRunnerClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockRunner] +} + +func newBuildingBlockRunnerClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockRunnerClient { + return meshBuildingBlockRunnerClient{internal.NewMeshObjectClient[MeshBuildingBlockRunner](ctx, httpClient, "v1-preview")} +} + +func (c meshBuildingBlockRunnerClient) Create(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) { + return c.meshObject.Post(ctx, runner) +} + +func (c meshBuildingBlockRunnerClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlockRunner, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshBuildingBlockRunnerClient) Update(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) { + if runner.Metadata.Uuid == nil || *runner.Metadata.Uuid == "" { + return nil, fmt.Errorf("missing metadata.uuid") + } + + return c.meshObject.Put(ctx, *runner.Metadata.Uuid, runner) +} + +func (c meshBuildingBlockRunnerClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/client/building_block_v2.go b/client/building_block_v2.go new file mode 100644 index 0000000..8a03c44 --- /dev/null +++ b/client/building_block_v2.go @@ -0,0 +1,406 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type BuildingBlockLifecycleState string + +var ( + BuildingBlockLifecycleStates = enum.Enum[BuildingBlockLifecycleState]{} + BuildingBlockLifecycleStateActive = BuildingBlockLifecycleStates.Entry("ACTIVE") + BuildingBlockLifecycleStateMarkedForDeletion = BuildingBlockLifecycleStates.Entry("MARKED_FOR_DELETION") + BuildingBlockLifecycleStateDeleted = BuildingBlockLifecycleStates.Entry("DELETED") +) + +type BuildingBlockStatus string + +var ( + BuildingBlockStatuses = enum.Enum[BuildingBlockStatus]{} + BuildingBlockStatusWaitingForDependentInput = BuildingBlockStatuses.Entry("WAITING_FOR_DEPENDENT_INPUT") + BuildingBlockStatusWaitingForOperatorInput = BuildingBlockStatuses.Entry("WAITING_FOR_OPERATOR_INPUT") + BuildingBlockStatusWaitingForUserInput = BuildingBlockStatuses.Entry("WAITING_FOR_USER_INPUT") + BuildingBlockStatusWaitingForApproval = BuildingBlockStatuses.Entry("WAITING_FOR_APPROVAL") + BuildingBlockStatusPending = BuildingBlockStatuses.Entry("PENDING") + BuildingBlockStatusInProgress = BuildingBlockStatuses.Entry("IN_PROGRESS") + BuildingBlockStatusSucceeded = BuildingBlockStatuses.Entry("SUCCEEDED") + BuildingBlockStatusFailed = BuildingBlockStatuses.Entry("FAILED") + BuildingBlockStatusAborted = BuildingBlockStatuses.Entry("ABORTED") +) + +type MeshBuildingBlockV2 struct { + Metadata MeshBuildingBlockV2Metadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` + Status *MeshBuildingBlockV2Status `json:"status" tfsdk:"status"` +} + +type MeshBuildingBlockV2Metadata struct { + Uuid *string `json:"uuid" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshBuildingBlockV2Spec struct { + BuildingBlockDefinitionVersionRef MeshBuildingBlockV2DefinitionVersionRef `json:"buildingBlockDefinitionVersionRef" tfsdk:"building_block_definition_version_ref"` + TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef" tfsdk:"target_ref"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + + // Inputs as pointer MeshBuildingBlockInput to support mocking secret responses. + Inputs map[string]*MeshBuildingBlockInput `json:"inputs" tfsdk:"inputs"` + ParentBuildingBlockRefs types.Set[UuidRef] `json:"parentBuildingBlockRefs" tfsdk:"parent_building_block_refs"` + + // ParentBuildingBlocks holds the deprecated parentBuildingBlocks field. MarshalJSON and + // UnmarshalJSON put it on the wire and take it off again, and the deprecated + // meshstack_building_block_v2 surfaces read it for the definition uuid they report. + ParentBuildingBlocks types.Set[MeshBuildingBlockV2Parent] `json:"-" tfsdk:"-"` +} + +// MeshBuildingBlockV2Parent is an entry of the deprecated parentBuildingBlocks field. +type MeshBuildingBlockV2Parent struct { + UuidRef + + // BuildingBlockUuid identifies the parent and always holds the same value as Uuid. + BuildingBlockUuid string `json:"buildingBlockUuid"` + // DefinitionUuid is the parent's building block definition. The backend derives it from the + // referenced block, so every response carries it and a request never does. + DefinitionUuid string `json:"definitionUuid,omitempty"` +} + +// UnmarshalJSON fills Uuid from the deprecated buildingBlockUuid, which is where a response carries +// the parent's identity. +func (p *MeshBuildingBlockV2Parent) UnmarshalJSON(data []byte) error { + type wire MeshBuildingBlockV2Parent + var target wire + if err := json.Unmarshal(data, &target); err != nil { + return err + } + + *p = MeshBuildingBlockV2Parent(target) + if p.Uuid == "" { + p.Uuid = p.BuildingBlockUuid + } + p.BuildingBlockUuid = p.Uuid + if p.Kind == "" { + p.Kind = MeshObjectKind.BuildingBlock + } + + return nil +} + +// MarshalJSON sends the parents under both field names: parentBuildingBlockRefs, and the deprecated +// parentBuildingBlocks for a backend that does not know the new field yet. A newer backend accepts +// both as long as they name the same building blocks, and an older one ignores the field it does not +// know, because the meshObject API does not reject unknown properties. +// +// Together with UnmarshalJSON this is the whole compatibility window. Once every backend still in use +// knows parentBuildingBlockRefs, both methods can go. +func (s MeshBuildingBlockV2Spec) MarshalJSON() ([]byte, error) { + type wire MeshBuildingBlockV2Spec + if len(s.ParentBuildingBlockRefs) == 0 { + s.ParentBuildingBlockRefs = parentRefsFromDeprecated(s.ParentBuildingBlocks) + } + + encoded, err := json.Marshal(wire(s)) + if err != nil { + return nil, err + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + return nil, err + } + + // The deprecated entry sends only buildingBlockUuid: every backend in the supported range reads + // the parent from it, and the definition uuid is always derived from the referenced block. + parents := make([]struct { + BuildingBlockUuid string `json:"buildingBlockUuid"` + }, 0, len(s.ParentBuildingBlockRefs)) + for _, ref := range s.ParentBuildingBlockRefs { + parents = append(parents, struct { + BuildingBlockUuid string `json:"buildingBlockUuid"` + }{BuildingBlockUuid: ref.Uuid}) + } + if fields["parentBuildingBlocks"], err = json.Marshal(parents); err != nil { + return nil, err + } + + return json.Marshal(fields) +} + +func parentRefsFromDeprecated(parents types.Set[MeshBuildingBlockV2Parent]) types.Set[UuidRef] { + refs := make(types.Set[UuidRef], 0, len(parents)) + for _, parent := range parents { + refs = append(refs, UuidRef{Uuid: parent.Uuid, Kind: MeshObjectKind.BuildingBlock}) + } + return refs +} + +// UnmarshalJSON reads the parents from parentBuildingBlockRefs, or from the deprecated +// parentBuildingBlocks when a backend does not serve the new field yet. Terraform then sees the same +// elements against either backend and set hashing stays stable. +func (s *MeshBuildingBlockV2Spec) UnmarshalJSON(data []byte) error { + type wire MeshBuildingBlockV2Spec + var target struct { + wire + ParentBuildingBlocks types.Set[MeshBuildingBlockV2Parent] `json:"parentBuildingBlocks"` + } + if err := json.Unmarshal(data, &target); err != nil { + return err + } + + *s = MeshBuildingBlockV2Spec(target.wire) + s.ParentBuildingBlocks = target.ParentBuildingBlocks + if len(s.ParentBuildingBlockRefs) == 0 { + s.ParentBuildingBlockRefs = parentRefsFromDeprecated(s.ParentBuildingBlocks) + } + for i := range s.ParentBuildingBlockRefs { + if s.ParentBuildingBlockRefs[i].Kind == "" { + s.ParentBuildingBlockRefs[i].Kind = MeshObjectKind.BuildingBlock + } + } + + return nil +} + +type MeshBuildingBlockInput struct { + Value types.SecretOrAny `json:"value" tfsdk:"value"` + ValueType *enum.Entry[MeshBuildingBlockIOType] `json:"valueType,omitempty" tfsdk:"-"` + AssignmentType enum.Entry[MeshBuildingBlockInputAssignmentType] `json:"assignmentType,omitempty" tfsdk:"-"` + + // If IsSensitive is true, the [types.Variant] (typedef [types.SecretOrAny]) for Value field + // is of [types.Secret] (case [types.Variant.X]). + // Otherwise, the [types.Variant] is of [types.Any] (case [types.Variant.Y]). + // As this is a fallback detection when JSON (un)marshaling, + // types.Any must go second as [types.Variant] intentionally prefers X over Y. + IsSensitive bool `json:"isSensitive" tfsdk:"-"` +} + +func (m *MeshBuildingBlockInput) UnmarshalJSON(bytes []byte) error { + type wrapped MeshBuildingBlockInput + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshBuildingBlockInput(target) + switch { + case !m.IsSensitive: + // ensure "any" struct fields never end up in X accidentally, + // as X is only set when IsSensitive is true! + var errs []error + moveXtoYIfPresent := func(v *types.SecretOrAny) { + if v.HasX() { + xJson, err := json.Marshal(v.X) + errs = append(errs, err) + v.X = types.Secret{} + errs = append(errs, json.Unmarshal(xJson, &v.Y)) + } + } + moveXtoYIfPresent(&m.Value) + return errors.Join(errs...) + case m.Value.HasY(): + return fmt.Errorf("got sensitive argument or default_value but variant Y is set instead") + default: + return nil + } +} + +type MeshBuildingBlockV2DefinitionVersionRef struct { + UuidRef + // ContentHash is a Terraform-only field (json:"-", never sent to or returned by the backend). + // It lets a config signal that the referenced version's content changed so a rerun is triggered + // even though the version uuid is unchanged. The building_block (v3) resource honors it via the + // shared rerunNeeded predicate used by both ModifyPlan and Update. + ContentHash *string `json:"-" tfsdk:"content_hash"` +} + +type MeshBuildingBlockV2TargetRef struct { + Kind string `json:"kind" tfsdk:"kind"` + Uuid *string `json:"uuid" tfsdk:"uuid"` + Name *string `json:"name" tfsdk:"name"` +} + +type MeshBuildingBlockV2Lifecycle struct { + State enum.Entry[BuildingBlockLifecycleState] `json:"state" tfsdk:"state"` +} + +type MeshBuildingBlockV2Status struct { + Status enum.Entry[BuildingBlockStatus] `json:"status" tfsdk:"status"` + Outputs map[string]MeshBuildingBlockOutput `json:"outputs" tfsdk:"outputs"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"-"` + // LatestRunUuid is nil if permissions don't allow reading the run (e.g. because run_transparency is false). + // It tracks the latest *modifying* (apply/destroy) run and excludes dry runs. + LatestRunUuid *string `json:"latestRunUuid" tfsdk:"latest_run_uuid"` + // LatestDryRunUuid is the latest dry (DETECT) run, but only when it is the newest run; nil otherwise. + // Same permission gating and nullability caveat as LatestRunUuid. + LatestDryRunUuid *string `json:"latestDryRunUuid" tfsdk:"latest_dry_run_uuid"` +} + +type MeshBuildingBlockOutput struct { + Value types.Any `json:"value" tfsdk:"value"` + ValueType enum.Entry[MeshBuildingBlockIOType] `json:"valueType" tfsdk:"value_type"` + AssignmentType enum.Entry[MeshBuildingBlockDefinitionOutputAssignmentType] `json:"assignmentType" tfsdk:"assignment_type"` +} + +// MeshBuildingBlockV2ListFilter holds the optional query filters for listing building blocks +// via the v2-preview list endpoint. All scalar fields are nil when unset (omitted from the +// query). The backend returns only active building blocks; soft-deleted ones are not listed. +// MeshBuildingBlockV2ListFilter holds the optional filters for the V2 building block list endpoint. +// The json tags are the query param names and must match the backend fetchBuildingBlocksV2 +// @RequestParam names exactly; a typo silently disables the filter. +type MeshBuildingBlockV2ListFilter struct { + WorkspaceIdentifier *string `json:"workspaceIdentifier"` + ProjectIdentifier *string `json:"projectIdentifier"` + PlatformIdentifier *string `json:"platformIdentifier"` + Name *string `json:"name"` + // DefinitionUuid filters by the owning building block definition's UUID (not a version). + DefinitionUuid *string `json:"definitionUuid"` + // VersionUuid filters by a specific building block definition version UUID. + VersionUuid *string `json:"versionUuid"` + // VersionNumber filters by the literal definition version number. The backend parses it + // leniently, so both "v1" and "1" match version 1. + VersionNumber *string `json:"versionNumber"` + TenantUuid *string `json:"tenantUuid"` + // TargetKind filters by target ref kind, one of meshTenant or meshWorkspace. + TargetKind *string `json:"targetRefKind"` + Status *string `json:"status"` + // ManagedByWorkspaceIdentifier and ManagedByDefinitionUuid select the platform-operator + // (managed) permission scope: building blocks created from definitions owned by the given + // workspace / definition. Requires the MANAGED_BUILDINGBLOCK_LIST authority. + ManagedByWorkspaceIdentifier *string `json:"managedByWorkspaceIdentifier"` + ManagedByDefinitionUuid *string `json:"managedByDefinitionUuid"` +} + +type MeshBuildingBlockV2Client interface { + Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) + ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) + List(ctx context.Context, filter MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) + Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) + Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) + Delete(ctx context.Context, uuid string, purge bool) error + TriggerRun(ctx context.Context, uuid string) error +} + +type meshBuildingBlockV2Client struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockV2] +} + +func newBuildingBlockV2Client(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockV2Client { + return meshBuildingBlockV2Client{internal.NewMeshObjectClient[MeshBuildingBlockV2](ctx, httpClient, "v2-preview")} +} + +func (c meshBuildingBlockV2Client) Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { + return c.ReadFunc(uuid)(ctx) +} + +func (c meshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) { + return func(ctx context.Context) (*MeshBuildingBlockV2, error) { + return c.meshObject.Get(ctx, uuid) + } +} + +func (c meshBuildingBlockV2Client) List(ctx context.Context, filter MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(filter)) +} + +func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { + return c.meshObject.Post(ctx, bb) +} + +func (c meshBuildingBlockV2Client) Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { + if bb.Metadata.Uuid == nil { + return nil, fmt.Errorf("cannot update building block without UUID") + } + return c.meshObject.Put(ctx, *bb.Metadata.Uuid, bb) +} + +func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purge bool) error { + var options []internal.RequestOption + if purge { + options = append(options, internal.WithPathElems("purge")) + } + return c.meshObject.Delete(ctx, uuid, options...) +} + +// IsWaitingForInput reports whether the building block run is paused awaiting +// human input, a dependency, or an approval. Such a run will not progress on its +// own, so polling callers treat it as a terminal (but non-fatal) state and surface +// a warning. +func (bb *MeshBuildingBlockV2) IsWaitingForInput() bool { + return bb.Status.Status == BuildingBlockStatusWaitingForOperatorInput || + bb.Status.Status == BuildingBlockStatusWaitingForUserInput || + bb.Status.Status == BuildingBlockStatusWaitingForDependentInput || + bb.Status.Status == BuildingBlockStatusWaitingForApproval +} + +// bbUuidOrUnknown returns the building block UUID for diagnostic messages, or "" if nil. +func bbUuidOrUnknown(bb *MeshBuildingBlockV2) string { + if bb != nil && bb.Metadata.Uuid != nil { + return *bb.Metadata.Uuid + } + return "" +} + +func (bb *MeshBuildingBlockV2) CreateSuccessful() (done bool, err error) { + switch { + case bb == nil: + err = fmt.Errorf("building block not found after creation") + case bb.Status == nil: + // no status yet — keep polling + case bb.Status.Status == BuildingBlockStatusFailed, + bb.Status.Status == BuildingBlockStatusAborted: + err = fmt.Errorf("building block %s reached %s state, check run logs in meshStack", bbUuidOrUnknown(bb), bb.Status.Status) + case bb.IsWaitingForInput(): + // Paused awaiting input — stop polling so the caller can surface a warning. + done = true + case bb.Status.Status == BuildingBlockStatusSucceeded: + done = true + case !slices.Contains(BuildingBlockStatuses, bb.Status.Status): + // Unrecognized status: fail fast instead of polling to the timeout — the backend returned a + // status this provider version does not know about (provider may be out of date). + err = fmt.Errorf("unknown building block status %q for building block %s; provider may be out of date", bb.Status.Status, bbUuidOrUnknown(bb)) + } + return +} + +func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { + switch { + case bb == nil: + // 404: the block was hard-removed (e.g. its definition was deleted too); treat as done. + done = true + case bb.Status != nil && bb.Status.Lifecycle.State == BuildingBlockLifecycleStateDeleted: + // Soft delete: once deletion completes the backend keeps returning the block with lifecycle + // DELETED (it does not 404), so treat DELETED as done. While deletion is still in progress the + // block is returned with MARKED_FOR_DELETION, which falls through as not-yet-done so we keep polling. + done = true + case bb.Status != nil && bb.Status.Status == BuildingBlockStatusFailed: + // A force-purge (definition deletion_mode = PURGE, or an admin purge) deletes the block + // regardless of its delete run's outcome, so a FAILED status here is transient — the + // lifecycle still proceeds to DELETED. Keep polling instead of erroring on that transient + // FAILED. Only a FAILED delete that is NOT being force-purged is a genuine stuck deletion. + if !bb.Status.ForcePurge { + err = fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", bbUuidOrUnknown(bb)) + } + } + return +} + +func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) error { + // trigger-run returns an empty 2xx body; use DoAuthorizedRequest[any] to signal no body expected. + // No body is sent, so the backend triggers a normal (non-dry) apply run. + _, err := internal.DoAuthorizedRequest[any]( + ctx, + c.meshObject.HttpClient, + "POST", + c.meshObject.ApiUrl.JoinPath(bbUuid, "trigger-run"), + internal.WithAccept(c.meshObject.MeshObjectMimeType()), + ) + return err +} diff --git a/client/building_block_v2_test.go b/client/building_block_v2_test.go new file mode 100644 index 0000000..87bc246 --- /dev/null +++ b/client/building_block_v2_test.go @@ -0,0 +1,307 @@ +package client + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +const ( + testParentUuid = "11111111-1111-1111-1111-111111111111" + testParentDefinitionUuid = "22222222-2222-2222-2222-222222222222" + // testParentRef is the shape this provider sends for a parent in parentBuildingBlockRefs. + testParentRef = `{"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}` + // testDeprecatedParent is what the provider sends alongside it, for a backend that does not know + // parentBuildingBlockRefs yet. + testDeprecatedParent = `{"buildingBlockUuid": "` + testParentUuid + `"}` +) + +func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { + tests := []struct { + name string + bb *MeshBuildingBlockV2 + wantDone bool + wantErr bool + }{ + { + name: "nil (404 — hard deletion / purge)", + bb: nil, + wantDone: true, + wantErr: false, + }, + { + name: "lifecycle state DELETED (soft delete completed, block still returned)", + bb: &MeshBuildingBlockV2{ + Status: &MeshBuildingBlockV2Status{ + Lifecycle: MeshBuildingBlockV2Lifecycle{State: BuildingBlockLifecycleStateDeleted}, + }, + }, + wantDone: true, + wantErr: false, + }, + { + name: "status FAILED during deletion", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{ + Status: BuildingBlockStatusFailed, + }, + }, + wantDone: false, + wantErr: true, + }, + { + name: "status FAILED but force-purged keeps polling (transient, will reach DELETED)", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{ + Status: BuildingBlockStatusFailed, + ForcePurge: true, + }, + }, + wantDone: false, + wantErr: false, + }, + { + name: "status FAILED with nil Uuid does not panic", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: nil}, + Status: &MeshBuildingBlockV2Status{ + Status: BuildingBlockStatusFailed, + }, + }, + wantDone: false, + wantErr: true, + }, + { + name: "still in progress (MARKED_FOR_DELETION lifecycle, non-failed status)", + bb: &MeshBuildingBlockV2{ + Status: &MeshBuildingBlockV2Status{ + Lifecycle: MeshBuildingBlockV2Lifecycle{State: BuildingBlockLifecycleStateMarkedForDeletion}, + }, + }, + wantDone: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := tt.bb.DeletionSuccessful() + assert.Equal(t, tt.wantDone, done) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMeshBuildingBlockV2_CreateSuccessful(t *testing.T) { + tests := []struct { + name string + bb *MeshBuildingBlockV2 + wantDone bool + wantErr bool + errContains string + }{ + { + name: "nil (not found after creation)", + bb: nil, + wantDone: false, + wantErr: true, + }, + { + name: "no status yet — keep polling", + bb: &MeshBuildingBlockV2{Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}}, + wantDone: false, + wantErr: false, + }, + { + name: "SUCCEEDED", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusSucceeded}, + }, + wantDone: true, + wantErr: false, + }, + { + name: "FAILED", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusFailed}, + }, + wantDone: false, + wantErr: true, + }, + { + name: "ABORTED", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusAborted}, + }, + wantDone: false, + wantErr: true, + }, + { + name: "WAITING_FOR_USER_INPUT — terminal but non-fatal", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusWaitingForUserInput}, + }, + wantDone: true, + wantErr: false, + }, + { + name: "WAITING_FOR_APPROVAL — terminal but non-fatal", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusWaitingForApproval}, + }, + wantDone: true, + wantErr: false, + }, + { + name: "FAILED with nil Uuid does not panic", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: nil}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusFailed}, + }, + wantDone: false, + wantErr: true, + errContains: "", + }, + { + name: "unknown status — fail fast", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: enum.Entry[BuildingBlockStatus]("SOMETHING_NEW")}, + }, + wantDone: false, + wantErr: true, + errContains: "unknown building block status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := tt.bb.CreateSuccessful() + assert.Equal(t, tt.wantDone, done) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestMeshBuildingBlockV2Parent_UnmarshalJSON covers every response shape. Terraform has to see the +// same {kind, uuid} against every backend, so that set hashing and UseStateForUnknown stay stable. +func TestMeshBuildingBlockV2Parent_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + response string + wantDefinitionUuid string + }{ + { + // An older backend reports the parent inside a buildingBlockRef envelope, which this provider + // does not read, so only the deprecated field carries the uuid. + name: "enveloped response without a top-level uuid", + response: `{ + "buildingBlockRef": {"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}, + "buildingBlockUuid": "` + testParentUuid + `", + "definitionUuid": "` + testParentDefinitionUuid + `" + }`, + wantDefinitionUuid: testParentDefinitionUuid, + }, + { + name: "flattened response that also carries a top-level uuid", + response: `{ + "kind": "meshBuildingBlock", + "uuid": "` + testParentUuid + `", + "buildingBlockUuid": "` + testParentUuid + `", + "definitionUuid": "` + testParentDefinitionUuid + `" + }`, + wantDefinitionUuid: testParentDefinitionUuid, + }, + { + name: "flattened response once the deprecated fields are gone", + response: `{"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var parent MeshBuildingBlockV2Parent + require.NoError(t, json.Unmarshal([]byte(tt.response), &parent)) + assert.Equal(t, MeshBuildingBlockV2Parent{ + UuidRef: UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, + BuildingBlockUuid: testParentUuid, + DefinitionUuid: tt.wantDefinitionUuid, + }, parent) + }) + } +} + +// TestMeshBuildingBlockV2Spec_ParentsRoundTrip goes through the whole spec, so a change to the json +// tag of parentBuildingBlockRefs or to the Set element type is caught too. +func TestMeshBuildingBlockV2Spec_ParentsRoundTrip(t *testing.T) { + const response = `{ + "buildingBlockDefinitionVersionRef": {"kind": "meshBuildingBlockDefinitionVersion", "uuid": "33333333-3333-3333-3333-333333333333"}, + "targetRef": {"kind": "meshWorkspace", "name": "my-workspace"}, + "displayName": "child", + "inputs": {}, + "parentBuildingBlockRefs": [` + testParentRef + `], + "parentBuildingBlocks": [{"buildingBlockUuid": "` + testParentUuid + `", "definitionUuid": "` + testParentDefinitionUuid + `"}] + }` + + var spec MeshBuildingBlockV2Spec + require.NoError(t, json.Unmarshal([]byte(response), &spec)) + require.Len(t, spec.ParentBuildingBlockRefs, 1) + assert.Equal(t, UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, spec.ParentBuildingBlockRefs[0]) + require.Len(t, spec.ParentBuildingBlocks, 1) + assert.Equal(t, testParentDefinitionUuid, spec.ParentBuildingBlocks[0].DefinitionUuid) + + assertSentUnderBothFieldNames(t, spec) +} + +// TestMeshBuildingBlockV2Spec_ParentsFromDeprecatedFieldOnly covers a backend that does not serve +// parentBuildingBlockRefs yet, and the deprecated meshstack_building_block_v2 surfaces, which fill +// only the deprecated field. +func TestMeshBuildingBlockV2Spec_ParentsFromDeprecatedFieldOnly(t *testing.T) { + const response = `{ + "buildingBlockDefinitionVersionRef": {"kind": "meshBuildingBlockDefinitionVersion", "uuid": "33333333-3333-3333-3333-333333333333"}, + "targetRef": {"kind": "meshWorkspace", "name": "my-workspace"}, + "displayName": "child", + "inputs": {}, + "parentBuildingBlocks": [{"buildingBlockUuid": "` + testParentUuid + `", "definitionUuid": "` + testParentDefinitionUuid + `"}] + }` + + var spec MeshBuildingBlockV2Spec + require.NoError(t, json.Unmarshal([]byte(response), &spec)) + require.Len(t, spec.ParentBuildingBlockRefs, 1) + assert.Equal(t, UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, spec.ParentBuildingBlockRefs[0]) + + assertSentUnderBothFieldNames(t, spec) +} + +func assertSentUnderBothFieldNames(t *testing.T, spec MeshBuildingBlockV2Spec) { + t.Helper() + + out, err := json.Marshal(spec) + require.NoError(t, err) + var request map[string]json.RawMessage + require.NoError(t, json.Unmarshal(out, &request)) + assert.JSONEq(t, "["+testParentRef+"]", string(request["parentBuildingBlockRefs"])) + assert.JSONEq(t, "["+testDeprecatedParent+"]", string(request["parentBuildingBlocks"])) +} diff --git a/client/buildingblock.go b/client/buildingblock.go new file mode 100644 index 0000000..a2e2f18 --- /dev/null +++ b/client/buildingblock.go @@ -0,0 +1,96 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +const ( + MESH_BUILDING_BLOCK_IO_TYPE_STRING = "STRING" + MESH_BUILDING_BLOCK_IO_TYPE_INTEGER = "INTEGER" + MESH_BUILDING_BLOCK_IO_TYPE_BOOLEAN = "BOOLEAN" + MESH_BUILDING_BLOCK_IO_TYPE_SINGLE_SELECT = "SINGLE_SELECT" + MESH_BUILDING_BLOCK_IO_TYPE_MULTI_SELECT = "MULTI_SELECT" + MESH_BUILDING_BLOCK_IO_TYPE_FILE = "FILE" + MESH_BUILDING_BLOCK_IO_TYPE_LIST = "LIST" + MESH_BUILDING_BLOCK_IO_TYPE_CODE = "CODE" +) + +type MeshBuildingBlock struct { + Metadata MeshBuildingBlockMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` + Status MeshBuildingBlockStatus `json:"status" tfsdk:"status"` +} + +type MeshBuildingBlockMetadata struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` + DefinitionVersion int64 `json:"definitionVersion" tfsdk:"definition_version"` + TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` + MarkedForDeletionBy *string `json:"markedForDeletionBy" tfsdk:"marked_for_deletion_by"` +} + +type MeshBuildingBlockSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Inputs []MeshBuildingBlockIO `json:"inputs" tfsdk:"inputs"` + ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` +} + +type MeshBuildingBlockIO struct { + Key string `json:"key" tfsdk:"key"` + Value any `json:"value" tfsdk:"value"` + ValueType string `json:"valueType" tfsdk:"value_type"` +} + +// MeshBuildingBlockParent is the v1 API's flat parent shape. The v2 API identifies a parent by +// reference instead — see MeshBuildingBlockV2Parent. +type MeshBuildingBlockParent struct { + BuildingBlockUuid string `json:"buildingBlockUuid" tfsdk:"buildingblock_uuid"` + DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` +} + +type MeshBuildingBlockStatus struct { + Status string `json:"status" tfsdk:"status"` + Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` +} + +type MeshBuildingBlockCreate struct { + Metadata MeshBuildingBlockCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` +} + +type MeshBuildingBlockCreateMetadata struct { + DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` + DefinitionVersion int64 `json:"definitionVersion" tfsdk:"definition_version"` + TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` +} + +type MeshBuildingBlockClient interface { + Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) + Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) + Delete(ctx context.Context, uuid string) error +} + +type meshBuildingBlockClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlock] +} + +func newBuildingBlockClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockClient { + return meshBuildingBlockClient{internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1")} +} + +func (c meshBuildingBlockClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshBuildingBlockClient) Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { + return c.meshObject.Post(ctx, bb) +} + +func (c meshBuildingBlockClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..a9e293e --- /dev/null +++ b/client/client.go @@ -0,0 +1,124 @@ +package client + +import ( + "context" + "fmt" + "net/url" + "os" + "time" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/version" +) + +var MinMeshStackVersion = version.MustParse("2026.34.0") + +// HttpError represents an HTTP error response with status code. +// This error is returned when an HTTP request fails with a non-2XX status code. +type HttpError = internal.HttpError + +type Client struct { + ApiKey MeshApiKeyClient + BuildingBlock MeshBuildingBlockClient + BuildingBlockV2 MeshBuildingBlockV2Client + BuildingBlockRun MeshBuildingBlockRunClient + BuildingBlockDefinition MeshBuildingBlockDefinitionClient + BuildingBlockDefinitionVersion MeshBuildingBlockDefinitionVersionClient + BuildingBlockRunner MeshBuildingBlockRunnerClient + Integration MeshIntegrationClient + LandingZone MeshLandingZoneClient + Location MeshLocationClient + MeshInfo MeshInfoClient + PaymentMethod MeshPaymentMethodClient + Platform MeshPlatformClient + PlatformType MeshPlatformTypeClient + Project MeshProjectClient + ProjectGroupBinding MeshProjectGroupBindingClient + ProjectUserBinding MeshProjectUserBindingClient + ServiceInstance MeshServiceInstanceClient + TagDefinition MeshTagDefinitionClient + Tenant MeshTenantClient + Workspace MeshWorkspaceClient + WorkspaceGroupBinding MeshWorkspaceGroupBindingClient + WorkspaceUserBinding MeshWorkspaceUserBindingClient +} + +type Authorization = internal.Authorization + +func NewApiTokenAuthorization(apiToken string) Authorization { + return internal.BearerTokenAuthorization{Token: apiToken} +} + +const apiLoginPath = "/api/login" + +func NewApiKeyAuthorization(apiKey, apiSecret string) Authorization { + return internal.NewClientSecretAuthorization(apiLoginPath, apiKey, apiSecret) +} + +func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authorization) (Client, error) { + httpClient := internal.WithRetry( + internal.NewHttpClient(rootUrl, userAgent, auth), + internal.RetryOptions{ + // Sized to ride out a full meshStack backend restart (e.g. an OOMKill followed by a + // Spring Boot cold start), which can leave the gateway returning 503 for ~2-3 minutes — + // well beyond the previous ~75s budget. This backoff sequence sums to ~4 minutes: + // 1+2+4+8+16+30*7 seconds. + MaxRetries: 12, + Backoff: internal.ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 30 * time.Second}, + WhitelistedPaths: map[string][]string{"POST": {apiLoginPath}}, + }, + ) + + meshInfoClient := newMeshInfoClient(httpClient) + if err := checkMeshVersion(ctx, meshInfoClient); err != nil { + return Client{}, err + } + + return Client{ + ApiKey: newApiKeyClient(ctx, httpClient), + BuildingBlock: newBuildingBlockClient(ctx, httpClient), + BuildingBlockV2: newBuildingBlockV2Client(ctx, httpClient), + BuildingBlockRun: newBuildingBlockRunClient(ctx, httpClient), + BuildingBlockDefinition: newBuildingBlockDefinitionClient(ctx, httpClient), + BuildingBlockDefinitionVersion: newBuildingBlockDefinitionVersionClient(ctx, httpClient), + BuildingBlockRunner: newBuildingBlockRunnerClient(ctx, httpClient), + Integration: newIntegrationClient(ctx, httpClient), + LandingZone: newLandingZoneClient(ctx, httpClient), + Location: newLocationClient(ctx, httpClient), + MeshInfo: meshInfoClient, + PaymentMethod: newPaymentMethodClient(ctx, httpClient), + Platform: newPlatformClient(ctx, httpClient), + PlatformType: newPlatformTypeClient(ctx, httpClient), + Project: newProjectClient(ctx, httpClient), + ProjectGroupBinding: newProjectGroupBindingClient(ctx, httpClient), + ProjectUserBinding: newProjectUserBindingClient(ctx, httpClient), + ServiceInstance: newServiceInstanceClient(ctx, httpClient), + TagDefinition: newTagDefinitionClient(ctx, httpClient), + Tenant: newTenantClient(ctx, httpClient), + Workspace: newWorkspaceClient(ctx, httpClient), + WorkspaceGroupBinding: newWorkspaceGroupBindingClient(ctx, httpClient), + WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, httpClient), + }, nil +} + +func checkMeshVersion(ctx context.Context, meshInfoClient MeshInfoClient) error { + // Skip before the request, not just before the comparison: /mesh/info is a GET on the retrying + // client, so an unavailable backend blocks provider configuration for the whole retry budget + // (~4 minutes) and then fails it. Opting out of the check has to opt out of that too. + if os.Getenv("MESHSTACK_SKIP_VERSION_CHECK") == "true" { + return nil + } + + info, err := meshInfoClient.Read(ctx) + if err != nil { + return err + } + meshVersion, err := version.Parse(info.Version) + if err != nil { + return fmt.Errorf("failed to parse meshStack version %q: %w", info.Version, err) + } + if meshVersion.Less(MinMeshStackVersion) { + return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshVersion, MinMeshStackVersion) + } + return nil +} diff --git a/client/client_kind.go b/client/client_kind.go new file mode 100644 index 0000000..3264d46 --- /dev/null +++ b/client/client_kind.go @@ -0,0 +1,52 @@ +package client + +// meshObjectKind provides typed constants for meshObject kind strings used across the provider. +type meshObjectKind struct { + ApiKey string + BuildingBlock string + BuildingBlockRun string + BuildingBlockDefinition string + BuildingBlockDefinitionVersion string + BuildingBlockRunner string + Integration string + LandingZone string + Location string + PaymentMethod string + Platform string + PlatformType string + Project string + ProjectGroupBinding string + ProjectRole string + ProjectUserBinding string + ServiceInstance string + TagDefinition string + Tenant string + Workspace string + WorkspaceGroupBinding string + WorkspaceUserBinding string +} + +var MeshObjectKind = meshObjectKind{ + ApiKey: "meshApiKey", + BuildingBlock: "meshBuildingBlock", + BuildingBlockRun: "meshBuildingBlockRun", + BuildingBlockDefinition: "meshBuildingBlockDefinition", + BuildingBlockDefinitionVersion: "meshBuildingBlockDefinitionVersion", + BuildingBlockRunner: "meshBuildingBlockRunner", + Integration: "meshIntegration", + LandingZone: "meshLandingZone", + Location: "meshLocation", + PaymentMethod: "meshPaymentMethod", + Platform: "meshPlatform", + PlatformType: "meshPlatformType", + Project: "meshProject", + ProjectGroupBinding: "meshProjectGroupBinding", + ProjectRole: "meshProjectRole", + ProjectUserBinding: "meshProjectUserBinding", + ServiceInstance: "meshServiceInstance", + TagDefinition: "meshTagDefinition", + Tenant: "meshTenant", + Workspace: "meshWorkspace", + WorkspaceGroupBinding: "meshWorkspaceGroupBinding", + WorkspaceUserBinding: "meshWorkspaceUserBinding", +} diff --git a/client/client_kind_test.go b/client/client_kind_test.go new file mode 100644 index 0000000..7d19572 --- /dev/null +++ b/client/client_kind_test.go @@ -0,0 +1,33 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +func TestKind(t *testing.T) { + // verify hardcoded kind strings match InferKind for all client types + assert.Equal(t, internal.InferKind[MeshApiKey](), MeshObjectKind.ApiKey) + assert.Equal(t, internal.InferKind[MeshBuildingBlock](), MeshObjectKind.BuildingBlock) + assert.Equal(t, internal.InferKind[MeshBuildingBlockV2](), MeshObjectKind.BuildingBlock) + assert.Equal(t, internal.InferKind[MeshBuildingBlockDefinition](), MeshObjectKind.BuildingBlockDefinition) + assert.Equal(t, internal.InferKind[MeshBuildingBlockDefinitionVersion](), MeshObjectKind.BuildingBlockDefinitionVersion) + assert.Equal(t, internal.InferKind[MeshIntegration](), MeshObjectKind.Integration) + assert.Equal(t, internal.InferKind[MeshLandingZone](), MeshObjectKind.LandingZone) + assert.Equal(t, internal.InferKind[MeshLocation](), MeshObjectKind.Location) + assert.Equal(t, internal.InferKind[MeshPaymentMethod](), MeshObjectKind.PaymentMethod) + assert.Equal(t, internal.InferKind[MeshPlatform](), MeshObjectKind.Platform) + assert.Equal(t, internal.InferKind[MeshPlatformType](), MeshObjectKind.PlatformType) + assert.Equal(t, internal.InferKind[MeshProject](), MeshObjectKind.Project) + assert.Equal(t, internal.InferKind[MeshProjectGroupBinding](), MeshObjectKind.ProjectGroupBinding) + assert.Equal(t, internal.InferKind[MeshProjectUserBinding](), MeshObjectKind.ProjectUserBinding) + assert.Equal(t, internal.InferKind[MeshServiceInstance](), MeshObjectKind.ServiceInstance) + assert.Equal(t, internal.InferKind[MeshTagDefinition](), MeshObjectKind.TagDefinition) + assert.Equal(t, internal.InferKind[MeshTenant](), MeshObjectKind.Tenant) + assert.Equal(t, internal.InferKind[MeshWorkspace](), MeshObjectKind.Workspace) + assert.Equal(t, internal.InferKind[MeshWorkspaceGroupBinding](), MeshObjectKind.WorkspaceGroupBinding) + assert.Equal(t, internal.InferKind[MeshWorkspaceUserBinding](), MeshObjectKind.WorkspaceUserBinding) +} diff --git a/client/client_logging.go b/client/client_logging.go new file mode 100644 index 0000000..e5811b8 --- /dev/null +++ b/client/client_logging.go @@ -0,0 +1,11 @@ +package client + +import "github.com/meshcloud/meshstack-cli/client/internal" + +// Logger exposes logging for client operations within this package (including internal). +type Logger = internal.Logger + +// SetLogger allows setting the client logger. By default, no logging happens. +func SetLogger(logger Logger) { + internal.Log = logger +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..d0b9c65 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,44 @@ +package client + +import ( + "errors" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type erroringRoundTripper struct{ calls int } + +func (rt *erroringRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + rt.calls++ + return nil, errors.New("no server is available to handle this request") +} + +func TestCheckMeshVersion_SkipsRequestWhenOptedOut(t *testing.T) { + newUnreachableClient := func() (internal.HttpClient, *erroringRoundTripper) { + transport := new(erroringRoundTripper) + httpClient := internal.NewHttpClient(&url.URL{Scheme: "https", Host: "meshstack.invalid"}, "test-agent", nil) + httpClient.Transport = transport + return httpClient, transport + } + + t.Run("MESHSTACK_SKIP_VERSION_CHECK=true skips the /mesh/info request entirely", func(t *testing.T) { + t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "true") + httpClient, transport := newUnreachableClient() + require.NoError(t, checkMeshVersion(t.Context(), newMeshInfoClient(httpClient))) + assert.Zero(t, transport.calls, "opting out of the version check must not send a request that can block on retries") + }) + + t.Run("without the opt-out an unreachable /mesh/info fails", func(t *testing.T) { + t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "") + httpClient, transport := newUnreachableClient() + err := checkMeshVersion(t.Context(), newMeshInfoClient(httpClient)) + require.ErrorContains(t, err, "failed to retrieve meshStack instance information") + assert.Equal(t, 1, transport.calls) + }) +} diff --git a/client/integration.go b/client/integration.go new file mode 100644 index 0000000..b46055c --- /dev/null +++ b/client/integration.go @@ -0,0 +1,81 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshIntegration struct { + Metadata MeshIntegrationMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshIntegrationSpec `json:"spec" tfsdk:"spec"` + Status *MeshIntegrationStatus `json:"status" tfsdk:"status"` +} + +type MeshIntegrationMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshIntegrationSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Config MeshIntegrationConfig `json:"config" tfsdk:"config"` +} + +type MeshIntegrationStatus struct { + IsBuiltIn bool `json:"isBuiltIn" tfsdk:"is_built_in"` + WorkloadIdentityFederation *MeshWorkloadIdentityFederation `json:"workloadIdentityFederation" tfsdk:"workload_identity_federation"` +} + +type MeshWorkloadIdentityFederation struct { + Issuer string `json:"issuer" tfsdk:"issuer"` + Subject string `json:"subject" tfsdk:"subject"` + Gcp *MeshWifProvider `json:"gcp" tfsdk:"gcp"` + Aws *MeshAwsWifProvider `json:"aws" tfsdk:"aws"` + Azure *MeshWifProvider `json:"azure" tfsdk:"azure"` +} + +type MeshWifProvider struct { + Audience string `json:"audience" tfsdk:"audience"` +} + +type MeshAwsWifProvider struct { + Audience string `json:"audience" tfsdk:"audience"` + Thumbprint string `json:"thumbprint" tfsdk:"thumbprint"` +} + +type MeshIntegrationClient interface { + Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) + Read(ctx context.Context, uuid string) (*MeshIntegration, error) + Update(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) + Delete(ctx context.Context, uuid string) error + List(ctx context.Context) ([]MeshIntegration, error) +} + +type meshIntegrationClientImpl struct { + meshObject internal.MeshObjectClient[MeshIntegration] +} + +func newIntegrationClient(ctx context.Context, httpClient internal.HttpClient) MeshIntegrationClient { + return meshIntegrationClientImpl{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1")} +} + +func (c meshIntegrationClientImpl) Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { + return c.meshObject.Post(ctx, integration) +} + +func (c meshIntegrationClientImpl) Read(ctx context.Context, uuid string) (*MeshIntegration, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshIntegrationClientImpl) Update(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { + return c.meshObject.Put(ctx, *integration.Metadata.Uuid, integration) +} + +func (c meshIntegrationClientImpl) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} + +func (c meshIntegrationClientImpl) List(ctx context.Context) ([]MeshIntegration, error) { + return c.meshObject.List(ctx) +} diff --git a/client/integration_config.go b/client/integration_config.go new file mode 100644 index 0000000..c4a92fc --- /dev/null +++ b/client/integration_config.go @@ -0,0 +1,91 @@ +package client + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type MeshIntegrationConfigType string + +var ( + MeshIntegrationConfigTypes = enum.Enum[MeshIntegrationConfigType]{} + MeshIntegrationConfigTypeGithub = MeshIntegrationConfigTypes.Entry("github") + MeshIntegrationConfigTypeGitlab = MeshIntegrationConfigTypes.Entry("gitlab") + MeshIntegrationConfigTypeAzureDevops = MeshIntegrationConfigTypes.Entry("azuredevops") + MeshIntegrationConfigTypeEntraId = MeshIntegrationConfigTypes.Entry("entraid") +) + +type MeshIntegrationGithubConfig struct { + Owner string `json:"owner" tfsdk:"owner"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + AppId string `json:"appId" tfsdk:"app_id"` + AppPrivateKey types.Secret `json:"appPrivateKey" tfsdk:"app_private_key"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +type MeshIntegrationGitlabConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +type MeshIntegrationAzureDevopsConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + Organization string `json:"organization" tfsdk:"organization"` + PersonalAccessToken types.Secret `json:"personalAccessToken" tfsdk:"personal_access_token"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +type MeshIntegrationEntraIdConfig struct { + TenantId string `json:"tenantId" tfsdk:"tenant_id"` + ClientId string `json:"clientId" tfsdk:"client_id"` + ClientSecret types.Secret `json:"clientSecret" tfsdk:"client_secret"` + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` +} + +type MeshIntegrationConfig struct { + Type enum.Entry[MeshIntegrationConfigType] `json:"type" tfsdk:"-"` + Github *MeshIntegrationGithubConfig `json:"github,omitempty" tfsdk:"github"` + Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitempty" tfsdk:"gitlab"` + AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitempty" tfsdk:"azuredevops"` + EntraId *MeshIntegrationEntraIdConfig `json:"entraid,omitempty" tfsdk:"entraid"` +} + +func (m MeshIntegrationConfig) InferTypeFromNonNilField() (result enum.Entry[MeshIntegrationConfigType]) { + setResultIfNotNil := func(implType enum.Entry[MeshIntegrationConfigType], v any) { + if !reflect.ValueOf(v).IsZero() { + if len(result) > 0 && result != implType { + panic(fmt.Errorf("inferred config type %s but already set to %s", implType, result)) + } + result = implType + } + } + setResultIfNotNil(MeshIntegrationConfigTypeGithub, m.Github) + setResultIfNotNil(MeshIntegrationConfigTypeGitlab, m.Gitlab) + setResultIfNotNil(MeshIntegrationConfigTypeAzureDevops, m.AzureDevops) + setResultIfNotNil(MeshIntegrationConfigTypeEntraId, m.EntraId) + if len(result) == 0 { + panic("cannot infer config type") + } + return +} + +func (m MeshIntegrationConfig) MarshalJSON() ([]byte, error) { + m.Type = m.InferTypeFromNonNilField() + // Using wrapped type avoids calling MarshalJSON recursively! + type wrapped MeshIntegrationConfig + return json.Marshal(wrapped(m)) +} + +func (m *MeshIntegrationConfig) UnmarshalJSON(bytes []byte) error { + type wrapped MeshIntegrationConfig + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshIntegrationConfig(target) + return nil +} diff --git a/client/internal/auth.go b/client/internal/auth.go new file mode 100644 index 0000000..8634100 --- /dev/null +++ b/client/internal/auth.go @@ -0,0 +1,77 @@ +package internal + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" +) + +type Authorization interface { + Header(ctx context.Context, client HttpClient) (string, error) +} + +func NewClientSecretAuthorization(loginApiPath, clientId, clientSecret string) Authorization { + return &clientSecretAuthorization{ + LoginApiPath: loginApiPath, + ClientId: clientId, + ClientSecret: clientSecret, + } +} + +type BearerTokenAuthorization struct { + Token string +} + +func (auth BearerTokenAuthorization) Header(_ context.Context, _ HttpClient) (string, error) { + return fmt.Sprintf("Bearer %s", auth.Token), nil +} + +type clientSecretAuthorization struct { + BearerTokenAuthorization + LoginApiPath string + ClientId string + ClientSecret string + ExpiresAt time.Time + mu sync.Mutex +} + +func (auth *clientSecretAuthorization) Header(ctx context.Context, client HttpClient) (string, error) { + auth.mu.Lock() + defer auth.mu.Unlock() + if err := auth.ensureValidToken(ctx, client); err != nil { + return "", err + } + return auth.BearerTokenAuthorization.Header(ctx, client) +} + +func (auth *clientSecretAuthorization) ensureValidToken(ctx context.Context, client HttpClient) error { + const minimumTokenLifetime = 30 * time.Second + if auth.Token != "" && time.Until(auth.ExpiresAt) > minimumTokenLifetime { + return nil + } + + loginApiUrl := client.RootUrl.JoinPath(auth.LoginApiPath) + + type loginRequest struct { + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } + + type loginResponse struct { + Token string `json:"access_token"` + ExpireSec int `json:"expires_in"` + } + + loginResult, err := DoRequest[loginResponse](ctx, client, http.MethodPost, loginApiUrl, + withPayload(loginRequest{ClientId: auth.ClientId, ClientSecret: auth.ClientSecret}, "application/json"), + ) + if err != nil { + return fmt.Errorf("login at %s with client id '%s' failed: %w", loginApiUrl, auth.ClientId, err) + } + auth.Token = loginResult.Token + auth.ExpiresAt = time.Now().Add(time.Duration(loginResult.ExpireSec) * time.Second) + Log.Debug(ctx, "login successful", "url", loginApiUrl, "clientId", auth.ClientId, "expiresAt", auth.ExpiresAt) + return nil +} diff --git a/client/internal/http_client.go b/client/internal/http_client.go new file mode 100644 index 0000000..f7cecd9 --- /dev/null +++ b/client/internal/http_client.go @@ -0,0 +1,133 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "slices" + "time" +) + +// NewHttpClient creates a new client with an underlying http.Client being a pointer to be modified by WithRetry. +func NewHttpClient(rootUrl *url.URL, userAgent string, auth Authorization) HttpClient { + return HttpClient{&http.Client{Timeout: 5 * time.Minute}, rootUrl, userAgent, auth} +} + +// HttpClient wraps [http.Client] with convenient request handling thanks to RequestOption. +type HttpClient struct { + *http.Client + RootUrl *url.URL + UserAgent string + Authorization Authorization +} + +func DoAuthorizedRequest[R any](ctx context.Context, c HttpClient, method string, url *url.URL, options ...RequestOption) (result R, err error) { + if c.Authorization == nil { + return result, fmt.Errorf("cannot do authorized request with unconfigured authorization") + } + authHeader, err := c.Authorization.Header(ctx, c) + if err != nil { + return result, err + } + return DoRequest[R](ctx, c, method, url, append(options, withHeader("Authorization", authHeader))...) +} + +func DoRequest[R any](ctx context.Context, c HttpClient, method string, url *url.URL, options ...RequestOption) (result R, err error) { + var body []byte + body, err = c.doRequest(ctx, method, url, options) + if err != nil { + return + } + if len(body) == 0 { + // An empty body is expected only for no-content calls, which are typed DoRequest[any] (e.g. + // trigger-run, delete) and ignore the result. For a call that expects an object (a pointer or a + // concrete struct), an empty 2xx body is unexpected — fail loudly instead of returning a nil/zero + // value that the caller would dereference or mistake for a 404/"not found". + if t := reflect.TypeFor[R](); t.Kind() == reflect.Interface && t.NumMethod() == 0 { + return + } + err = fmt.Errorf("unexpected empty response body from %s %s", method, url) + return + } + err = json.Unmarshal(body, &result) + return +} + +func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, options []RequestOption) ([]byte, error) { + options = slices.Insert(options, 0, + withHeader("User-Agent", c.UserAgent), + ) + opts := requestOptions{} + for _, option := range options { + option(&opts) + } + if opts.optionErr != nil { + return nil, opts.optionErr + } + req, err := c.buildRequest(ctx, method, *url, opts) + if err != nil { + return nil, err + } + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + return c.readBodyAndCheckSuccess(ctx, res) +} + +func (c HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Response) ([]byte, error) { + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) + } + Log.Debug(ctx, "response", "status", res.StatusCode, "body", loggedBody{bytes.NewBuffer(responseBody)}) + + if res.StatusCode >= 200 && res.StatusCode <= 299 { + return responseBody, nil + } + + return responseBody, HttpError{ + StatusCode: res.StatusCode, + ResponseBody: responseBody, + } +} + +func (c HttpClient) buildRequest(ctx context.Context, method string, url url.URL, opts requestOptions) (*http.Request, error) { + if len(opts.extraPathElems) > 0 { + url = *url.JoinPath(opts.extraPathElems...) + } + + if len(opts.urlQueryParams) > 0 { + query := url.Query() + for k, v := range opts.urlQueryParams { + query.Set(k, v) + } + url.RawQuery = query.Encode() + } + + var requestBody io.ReadWriter + if opts.requestPayload != nil { + requestBody = new(bytes.Buffer) + if err := json.NewEncoder(requestBody).Encode(opts.requestPayload); err != nil { + return nil, fmt.Errorf("failed to encode request body payload: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, url.String(), requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for _, requestModifier := range opts.requestModifiers { + requestModifier(req) + } + Log.Debug(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header), "body", loggedBody{requestBody}) + return req, err +} diff --git a/client/internal/http_client_test.go b/client/internal/http_client_test.go new file mode 100644 index 0000000..baceaec --- /dev/null +++ b/client/internal/http_client_test.go @@ -0,0 +1,386 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHttpClient(t *testing.T) { + t.Run("DoRequest success", func(t *testing.T) { + testLogger := installTestLogger(t) + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`"some-answer"`)) + assert.Equal(t, "/get", req.URL.Path) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "test-agent", req.Header.Get("User-Agent")) + }) + resp, err := DoRequest[string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) + require.NoError(t, err) + assert.Equal(t, "some-answer", resp) + assert.Equal(t, []string{ + fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.RootUrl), + `response [status 200 body "some-answer"]`, + }, testLogger.Debugs) + assert.Empty(t, testLogger.Warns) + }) + + t.Run("DoRequest object call with empty 2xx body errors", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + }) + _, err := DoRequest[*string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) + require.Error(t, err) + assert.ErrorContains(t, err, "unexpected empty response body") + }) + + t.Run("DoRequest no-content call (any) tolerates an empty 2xx body", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusAccepted) // empty body by design (trigger-run/delete) + }) + _, err := DoRequest[any](t.Context(), client, http.MethodPost, client.RootUrl.JoinPath("trigger-run")) + require.NoError(t, err) + }) + + t.Run("DoRequest with successful retry", func(t *testing.T) { + for _, retryableStatusCode := range []int{429, 502, 503, 504} { + t.Run(fmt.Sprintf("after code %d", retryableStatusCode), func(t *testing.T) { + nowUTC := mockTimeNowAsUTC(t) + + testLogger := installTestLogger(t) + retryTestBackoff := retryTestBackoff{WaitTime: 1 * time.Second} + retried := false + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + if !retried { + if retryableStatusCode == 429 { + resp.Header().Set("Retry-After", nowUTC.Add(1*time.Second).Format(http.TimeFormat)) + } + resp.WriteHeader(retryableStatusCode) + retried = true + return + } + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`{}`)) + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff}) + + _, err := DoRequest[any](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) + require.NoError(t, err) + if retryableStatusCode == 429 { + assert.Equal(t, 0, retryTestBackoff.Called) + } else { + assert.Equal(t, 1, retryTestBackoff.Called) + } + assert.Equal(t, []string{ + fmt.Sprintf("retrying request [status %d method GET path /get attempt 1/3 waitTime 1s]", retryableStatusCode), + }, testLogger.Warns) + }) + } + }) + + t.Run("DoRequest with 2 retries exhausted", func(t *testing.T) { + testLogger := installTestLogger(t) + retryTestBackoff := retryTestBackoff{} + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(502) + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff}) + _, err := DoRequest[any](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) + var httpErr HttpError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, 502, httpErr.StatusCode) + assert.Equal(t, 2, retryTestBackoff.Called) + assert.Equal(t, []string{ + "retrying request [status 502 method GET path /get attempt 1/2 waitTime 0s]", + "retrying request [status 502 method GET path /get attempt 2/2 waitTime 0s]", + }, testLogger.Warns) + assert.Equal(t, []string{ + fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.RootUrl), + "response [status 502 body ]", + }, testLogger.Debugs) + + }) + + t.Run("DoRequest with context cancelled during backoff", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(502) + cancel() // cancel context so the backoff wait is interrupted + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) + _, err := DoRequest[any](ctx, client, http.MethodGet, client.RootUrl.JoinPath("get")) + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("DoRequest with PATCH (not retried)", func(t *testing.T) { + attempts := 0 + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + attempts++ + resp.WriteHeader(502) + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) + _, err := DoRequest[any](t.Context(), client, http.MethodPatch, client.RootUrl) + require.Error(t, err) + assert.Equal(t, 1, attempts, "PATCH must not be retried") + }) + + t.Run("DoRequest with DELETE (retried, idempotent)", func(t *testing.T) { + attempts := 0 + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + attempts++ + if attempts == 1 { + resp.WriteHeader(503) + return + } + resp.WriteHeader(http.StatusNoContent) + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) + _, err := DoRequest[any](t.Context(), client, http.MethodDelete, client.RootUrl.JoinPath("delete")) + require.NoError(t, err) + assert.Equal(t, 2, attempts, "DELETE must be retried after a 503") + }) + + t.Run("DoRequest with PUT replays body on retry", func(t *testing.T) { + attempt := 0 + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + assert.JSONEq(t, `{"key":"value"}`, string(body)) + attempt++ + if attempt == 1 { + resp.WriteHeader(502) + return + } + resp.WriteHeader(200) + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff{}}) + _, err := DoRequest[any](t.Context(), client, http.MethodPut, client.RootUrl, withPayload(map[string]string{"key": "value"}, "application/json")) + require.NoError(t, err) + assert.Equal(t, 2, attempt) + }) + + t.Run("DoAuthorizedRequest with BearerTokenAuthorization", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + assert.Equal(t, "Bearer my-static-token", req.Header.Get("Authorization")) + resp.WriteHeader(http.StatusAccepted) + }) + client.Authorization = BearerTokenAuthorization{Token: "my-static-token"} + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPost, client.RootUrl.JoinPath("create"), withPayload("content", "text/plain")) + require.NoError(t, err) + }) + + t.Run("DoAuthorizedRequest with clientSecretAuthorization and retries", func(t *testing.T) { + t.Run("succeeds after second attempt", func(t *testing.T) { + retryTestBackoff := retryTestBackoff{} + requestsSeen := map[string]int{} // key is request path + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + defer func() { + requestsSeen[req.URL.Path]++ + }() + if requestsSeen[req.URL.Path] == 0 { + resp.WriteHeader(502) + return + } + switch req.URL.Path { + case "/login": + resp.WriteHeader(http.StatusOK) + // expires_in must be less than minimumTokenLifetime to trigger relogin on second doAuthorizedRequest call + _, _ = resp.Write([]byte(`{"access_token":"some-token", "expires_in": 10}`)) + case "/edit": + assert.Equal(t, "Bearer some-token", req.Header.Get("Authorization")) + resp.WriteHeader(http.StatusAccepted) + default: + t.Fatal("unexpected request", req.URL.Path) + } + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) + client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") + resp, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) + require.NoError(t, err) + _ = resp + assert.Equal(t, map[string]int{ + "/login": 2, + "/edit": 2, + }, requestsSeen) + + t.Run("expired token is refreshed with relogin", func(t *testing.T) { + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) + require.NoError(t, err) + assert.Equal(t, 2, retryTestBackoff.Called) + assert.Equal(t, map[string]int{ + "/login": 3, + "/edit": 3, + }, requestsSeen) + }) + + // two different paths with one retry each, so backoff called twice in total + assert.Equal(t, 2, retryTestBackoff.Called) + }) + + t.Run("succeeds after redirect and retries", func(t *testing.T) { + retryTestBackoff := retryTestBackoff{} + requestsSeen := map[string]int{} + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + defer func() { + requestsSeen[req.URL.Path]++ + }() + if requestsSeen[req.URL.Path] == 0 { + resp.WriteHeader(502) + return + } + switch req.URL.Path { + case "/login": + body, _ := io.ReadAll(req.Body) + assert.JSONEq(t, `{"clientId":"test-client","clientSecret":"test-client-secret"}`, string(body)) + http.Redirect(resp, req, "/login-target", http.StatusTemporaryRedirect) + case "/login-target": + body, _ := io.ReadAll(req.Body) + assert.JSONEq(t, `{"clientId":"test-client","clientSecret":"test-client-secret"}`, string(body)) + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`{"access_token":"redirected-token", "expires_in": 3600}`)) + case "/edit": + assert.Equal(t, "Bearer redirected-token", req.Header.Get("Authorization")) + resp.WriteHeader(http.StatusAccepted) + default: + t.Fatal("unexpected request", req.URL.Path) + } + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) + client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) + require.NoError(t, err) + assert.Equal(t, map[string]int{ + "/login": 2, // 1st: 502, 2nd: 307 redirect + "/login-target": 2, // 1st: 502, 2nd: 200 + "/edit": 2, // 1st: 502, 2nd: 202 + }, requestsSeen) + assert.Equal(t, 3, retryTestBackoff.Called) // one retry each for /login, /login-target, /edit + }) + + t.Run("fails constantly at login", func(t *testing.T) { + retryTestBackoff := retryTestBackoff{} + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, r *http.Request) { + resp.WriteHeader(503) + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) + client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) + require.ErrorContains(t, err, fmt.Sprintf("login at %s/login with client id 'test-client' failed", client.RootUrl)) + var httpErr HttpError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, 503, httpErr.StatusCode) + assert.Equal(t, 2, retryTestBackoff.Called) + }) + + }) +} + +func TestUrlQueryOptions(t *testing.T) { + queryFrom := func(t *testing.T, query any) url.Values { + t.Helper() + var gotQuery url.Values + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + gotQuery = req.URL.Query() + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`"ok"`)) + }) + _, err := DoRequest[string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("list"), + WithUrlQuery(query), + ) + require.NoError(t, err) + return gotQuery + } + + t.Run("a map is sent verbatim", func(t *testing.T) { + got := queryFrom(t, map[string]string{"definitionUuid": "abc", "status": "SUCCEEDED"}) + assert.Equal(t, "abc", got.Get("definitionUuid")) + assert.Equal(t, "SUCCEEDED", got.Get("status")) + }) + + t.Run("map values are kept even when zero", func(t *testing.T) { + got := queryFrom(t, map[string]any{"page": 0}) + assert.Equal(t, "0", got.Get("page")) + }) + + t.Run("struct fields are named by json tag and zero fields are dropped", func(t *testing.T) { + type filter struct { + Identifier *string `json:"identifier"` + Name string `json:"name"` + Restricted *bool `json:"restricted"` + } + got := queryFrom(t, filter{Identifier: new("abc")}) + assert.Equal(t, "abc", got.Get("identifier")) + assert.False(t, got.Has("name"), "zero string field must be dropped") + assert.False(t, got.Has("restricted"), "nil pointer field must be dropped") + }) + + t.Run("a zero-value struct adds no params", func(t *testing.T) { + type filter struct { + Identifier *string `json:"identifier"` + } + got := queryFrom(t, &filter{}) + assert.Empty(t, got) + }) +} + +func mockTimeNowAsUTC(t *testing.T) time.Time { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + timeNow = func() time.Time { return now } + t.Cleanup(func() { + timeNow = time.Now + }) + return now +} + +func newTestClientWithServer(t *testing.T, handlerFunc http.HandlerFunc) HttpClient { + t.Helper() + server := httptest.NewServer(handlerFunc) + t.Cleanup(server.Close) + rootUrl, err := url.Parse(server.URL) + require.NoError(t, err) + client := server.Client() + return HttpClient{ + Client: client, + RootUrl: rootUrl, + UserAgent: "test-agent", + } +} + +func installTestLogger(t *testing.T) *testLogger { + t.Helper() + testLogger := &testLogger{} + previousLog := Log + Log = testLogger + t.Cleanup(func() { + Log = previousLog + }) + return testLogger +} + +type testLogger struct { + Debugs []string + Infos []string + Warns []string +} + +func (c *testLogger) Debug(_ context.Context, msg string, args ...any) { + c.Debugs = append(c.Debugs, fmt.Sprintf("%s %v", msg, args)) +} + +func (c *testLogger) Info(_ context.Context, msg string, args ...any) { + c.Infos = append(c.Infos, fmt.Sprintf("%s %v", msg, args)) +} + +func (c *testLogger) Warn(_ context.Context, msg string, args ...any) { + c.Warns = append(c.Warns, fmt.Sprintf("%s %v", msg, args)) +} + +type retryTestBackoff struct { + WaitTime time.Duration + Called int +} + +func (b *retryTestBackoff) Calculate(int) time.Duration { + b.Called++ + return b.WaitTime +} diff --git a/client/internal/http_error.go b/client/internal/http_error.go new file mode 100644 index 0000000..55cc32c --- /dev/null +++ b/client/internal/http_error.go @@ -0,0 +1,32 @@ +package internal + +import ( + "fmt" + "net/http" +) + +// HttpError represents an HTTP error response with status code. +// This error is returned when an HTTP request fails with a non-2XX status code. +type HttpError struct { + StatusCode int + ResponseBody []byte +} + +func (e HttpError) Error() string { + return fmt.Sprintf("http error %d, response '%s'", e.StatusCode, string(e.ResponseBody)) +} + +// IsForbidden returns true if the error is a 403 Forbidden response. +func (e HttpError) IsForbidden() bool { + return e.StatusCode == http.StatusForbidden +} + +// IsNotFound returns true if the error is a 404 Not Found response. +func (e HttpError) IsNotFound() bool { + return e.StatusCode == http.StatusNotFound +} + +// IsConflict returns true if the error is a 409 Conflict response. +func (e HttpError) IsConflict() bool { + return e.StatusCode == http.StatusConflict +} diff --git a/client/internal/logging.go b/client/internal/logging.go new file mode 100644 index 0000000..d82cda8 --- /dev/null +++ b/client/internal/logging.go @@ -0,0 +1,84 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strings" +) + +var Log Logger = noopLogger{} + +// Logger supports Debug, Info, and Warn log levels. +// Note that msg is a short, descriptive statement what is logged, and args are key value pairs (values are string or implement fmt.Stringer). +type Logger interface { + Debug(ctx context.Context, msg string, args ...any) + Info(ctx context.Context, msg string, args ...any) + Warn(ctx context.Context, msg string, args ...any) +} + +type noopLogger struct{} + +func (n noopLogger) Debug(context.Context, string, ...any) { + // do nothing +} + +func (n noopLogger) Info(context.Context, string, ...any) { + // do nothing +} + +func (n noopLogger) Warn(context.Context, string, ...any) { + // do nothing +} + +type loggedHeaders http.Header + +var _ fmt.Stringer = loggedHeaders(nil) + +func (l loggedHeaders) String() string { + var lines []string + for _, k := range slices.Sorted(maps.Keys(l)) { + for _, v := range l[k] { + // Avoid printing that longish JWT Bearer token (which is also a secret) + if k == "Authorization" { + v = "[REDACTED]" + } + lines = append(lines, fmt.Sprintf("%s=%s", k, v)) + } + } + return strings.Join(lines, "\n") +} + +type loggedBody struct { + io.Reader +} + +var _ fmt.Stringer = loggedBody{} + +func (l loggedBody) String() string { + if buffer, ok := l.Reader.(*bytes.Buffer); ok { + return bytesToPrettyJson(buffer.Bytes()) + } else if buffer == nil { + return "" + } + return fmt.Sprintf(" %v", l.Reader) +} + +func bytesToPrettyJson(data []byte) string { + if len(data) == 0 { + return "" + } + var decoded any + if err := json.Unmarshal(data, &decoded); err == nil { + if indented, err := json.MarshalIndent(decoded, "", " "); err == nil { + return string(indented) + } + } + // should never happen as we should only transfer JSON in request/responses + return fmt.Sprintf(" %s", len(data), string(data)) +} diff --git a/client/internal/mesh_object_client.go b/client/internal/mesh_object_client.go new file mode 100644 index 0000000..9cf8e5f --- /dev/null +++ b/client/internal/mesh_object_client.go @@ -0,0 +1,163 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "reflect" + "regexp" + "slices" + "strings" + "unicode" +) + +// MeshObjectClient provides typed CRUD operations for meshStack API objects. +// It embeds [HttpClient] and adds meshObject-specific functionality including automatic +// MIME type handling and pagination. +// Also handles authentication in doAuthorizedRequest using the ApiKey/ApiSecret values, +// which are embedded in HttpClient for convenient construction with NewMeshObjectClient. +type MeshObjectClient[M any] struct { + HttpClient + Kind string + ApiVersion string + ApiUrl *url.URL +} + +// NewMeshObjectClient creates a new [MeshObjectClient] for a specific meshObject type with automatic URL path inference. +// The meshObject kind is inferred from type M. T +// The API URL is constructed from explicitApiPathElems if provided, +// otherwise the pluralized and lowercased kind is used as a single element. +func NewMeshObjectClient[M any](ctx context.Context, httpClient HttpClient, apiVersion string, explicitApiPathElems ...string) MeshObjectClient[M] { + kind := InferKind[M]() + + if len(explicitApiPathElems) == 0 { + explicitApiPathElems = []string{strings.ToLower(pluralizeKind(kind))} + } + explicitApiPathElems = slices.Insert(explicitApiPathElems, 0, "/api/meshobjects") + apiUrl := httpClient.RootUrl.JoinPath(explicitApiPathElems...) + Log.Info(ctx, fmt.Sprintf("initialized %s client", reflect.TypeFor[M]().Name()), "url", apiUrl.String(), "kind", kind, "version", apiVersion) + return MeshObjectClient[M]{httpClient, kind, apiVersion, apiUrl} +} + +var versionSuffixRe = regexp.MustCompile(`V\d+$`) + +// InferKind infers the meshObject kind from a struct type name using the same convention +// as the meshObject API: MeshWorkspace → "meshWorkspace", MeshBuildingBlockV2 → "meshBuildingBlock". +// Version suffixes (V\d+) are stripped. +// Tested when client.Kind is statically initialized. +func InferKind[M any]() string { + typeName := reflect.TypeFor[M]().Name() + + runes := []rune(typeName) + runes[0] = unicode.ToLower(runes[0]) + kind := string(runes) + + return versionSuffixRe.ReplaceAllString(kind, "") +} + +var pluralExceptions = map[string]string{ + // Add exceptions here as needed, e.g. "meshPolicy": "meshPolicies" +} + +func pluralizeKind(kind string) string { + if plural, ok := pluralExceptions[kind]; ok { + return plural + } + return kind + "s" +} + +func (c MeshObjectClient[M]) MeshObjectMimeType() string { + return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Kind, c.ApiVersion) +} + +// Get retrieves a meshObject by ID. Returns nil if not found. +func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (resp *M, err error) { + resp, err = DoAuthorizedRequest[*M](ctx, c.HttpClient, http.MethodGet, c.ApiUrl.JoinPath(id), WithAccept(c.MeshObjectMimeType())) + if httpErr, ok := errors.AsType[HttpError](err); ok && httpErr.IsNotFound() { + return nil, nil + } + return +} + +// Post creates a new meshObject with the given payload. +// Automatically injects apiVersion and kind into the JSON payload. +func (c MeshObjectClient[M]) Post(ctx context.Context, payload any, options ...RequestOption) (*M, error) { + return DoAuthorizedRequest[*M]( + ctx, + c.HttpClient, + http.MethodPost, + c.ApiUrl, + append(options, c.withMeshObjectPayload(payload))..., + ) +} + +// Put updates an existing meshObject by ID with the given payload. +// Automatically injects apiVersion and kind into the JSON payload. +func (c MeshObjectClient[M]) Put(ctx context.Context, id string, payload any) (*M, error) { + return DoAuthorizedRequest[*M](ctx, c.HttpClient, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload)) +} + +// withMeshObjectPayload returns a RequestOption that sets the payload with apiVersion and kind injected, +// using the meshObject MIME type for content negotiation. +// Panics on marshal errors which indicates a programming error (payload is always a well-typed struct). +// +// The double marshal/unmarshal round-trip converts the typed struct to a map[string]any so we can +// inject the top-level apiVersion and kind fields without coupling the struct type to those fields. +func (c MeshObjectClient[M]) withMeshObjectPayload(payload any) RequestOption { + intermediate, err := json.Marshal(payload) + if err != nil { + panic(fmt.Sprintf("failed to marshal %T: %v", payload, err)) + } + + var m map[string]any + if err := json.Unmarshal(intermediate, &m); err != nil { + panic(fmt.Sprintf("failed to unmarshal %T to map: %v", payload, err)) + } + + m["apiVersion"] = c.ApiVersion + m["kind"] = c.Kind + + return withPayload(m, c.MeshObjectMimeType()) +} + +// Delete removes a meshObject by ID. +func (c MeshObjectClient[M]) Delete(ctx context.Context, id string, options ...RequestOption) (err error) { + _, err = DoAuthorizedRequest[any](ctx, c.HttpClient, http.MethodDelete, c.ApiUrl.JoinPath(id), append(options, WithAccept(c.MeshObjectMimeType()))...) + return +} + +// List retrieves all meshObjects with automatic pagination handling. +// Accepts optional [RequestOption] parameters for filtering and querying. +func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) ([]M, error) { + var result []M + embeddedKey := pluralizeKind(c.Kind) + pageNumber := 0 + + for { + type paginatedResponse struct { + Embedded map[string][]M `json:"_embedded"` + Page struct { + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + response, err := DoAuthorizedRequest[paginatedResponse](ctx, c.HttpClient, http.MethodGet, c.ApiUrl, append(options, + WithAccept(c.MeshObjectMimeType()), + WithUrlQuery(map[string]any{"page": pageNumber}), + )...) + if err != nil { + return result, fmt.Errorf("error getting page %d: %w", pageNumber, err) + } else if items, ok := response.Embedded[embeddedKey]; !ok { + return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) + } else { + result = append(result, items...) + } + if response.Page.Number >= response.Page.TotalPages-1 { + return result, nil + } + pageNumber++ + } +} diff --git a/client/internal/options.go b/client/internal/options.go new file mode 100644 index 0000000..c06e8ba --- /dev/null +++ b/client/internal/options.go @@ -0,0 +1,95 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "reflect" +) + +type ( + // RequestOption is a functional option for configuring HTTP requests. + RequestOption func(opts *requestOptions) + + requestOptions struct { + urlQueryParams map[string]string + extraPathElems []string + requestPayload any + requestModifiers []requestModifier + // optionErr holds the first error produced while applying options (e.g. an unmarshalable + // query); doRequest surfaces it instead of building a request from partial options. + optionErr error + } + requestModifier func(req *http.Request) +) + +// WithUrlQuery adds URL query parameters from a query value. +// +// The value is JSON-marshalled and decoded into a flat map, so each field becomes a query param +// named by its `json` tag. A struct passed by value is the common case: its zero-value fields are +// dropped (an implicit `omitempty`), so an unset filter needs neither a pointer nor an `omitempty` +// tag and a zero-value struct adds no params at all. A map[string]string / map[string]any is taken +// verbatim — every entry is sent, including deliberate zero values such as page=0. +// +// Values are stringified with fmt.Sprintf("%v", ...); nested objects or arrays are not supported. +func WithUrlQuery(query any) RequestOption { + return func(opts *requestOptions) { + data, err := json.Marshal(query) + if err != nil { + opts.optionErr = fmt.Errorf("cannot marshal url query of type %T: %w", query, err) + return + } + // UseNumber keeps integers (e.g. page) from becoming float64 and gaining a ".0" or exponent. + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var params map[string]any + if err := decoder.Decode(¶ms); err != nil { + opts.optionErr = fmt.Errorf("cannot decode url query of type %T into a flat map: %w", query, err) + return + } + // Drop zero-value fields only for a struct (passed by value, not by pointer); a map is + // passed through as given. + skipZero := reflect.ValueOf(query).Kind() == reflect.Struct + for key, value := range params { + if value == nil || (skipZero && reflect.ValueOf(value).IsZero()) { + continue + } + if opts.urlQueryParams == nil { + opts.urlQueryParams = map[string]string{} + } + opts.urlQueryParams[key] = fmt.Sprintf("%v", value) + } + } +} + +// WithPathElems appends path elements to the request URL path. +func WithPathElems(pathElems ...string) RequestOption { + return func(opts *requestOptions) { + opts.extraPathElems = append(opts.extraPathElems, pathElems...) + } +} + +func appendRequestModifier(modifier requestModifier) RequestOption { + return func(opts *requestOptions) { + opts.requestModifiers = append(opts.requestModifiers, modifier) + } +} + +func WithAccept(accept string) RequestOption { + return withHeader("Accept", accept) +} + +func withHeader(key, value string) RequestOption { + return appendRequestModifier(func(req *http.Request) { + req.Header.Set(key, value) + }) +} + +func withPayload(payload any, contentType string) RequestOption { + return func(opts *requestOptions) { + WithAccept(contentType)(opts) + withHeader("Content-Type", contentType)(opts) + opts.requestPayload = payload + } +} diff --git a/client/internal/retry.go b/client/internal/retry.go new file mode 100644 index 0000000..5d38278 --- /dev/null +++ b/client/internal/retry.go @@ -0,0 +1,270 @@ +package internal + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "strconv" + "sync" + "time" +) + +// WithRetry sets up the given client to retry certain requests. +// The idempotent methods GET, PUT and DELETE are retried by default, POST only if the path is +// explicitly whitelisted. See RetryOptions. +func WithRetry(c HttpClient, options RetryOptions) HttpClient { + next := http.DefaultTransport + if c.Transport != nil { + next = c.Transport + } + whitelistedByMethodAndUrl := func() (m map[string]*sync.Map) { + m = make(map[string]*sync.Map) + for method, paths := range options.WhitelistedPaths { + m[method] = new(sync.Map) + for _, path := range paths { + m[method].Store(c.RootUrl.JoinPath(path).String(), nil) + } + } + return + }() + c.Transport = &retryRoundTripper{ + Next: next, + MaxRetries: options.MaxRetries, + // ShouldRetryRequest checks if the request method/path is eligible for retry. + ShouldRetryRequest: func(req *http.Request) (retry bool) { + if options.Backoff == nil { + return false + } + switch req.Method { + case http.MethodGet, http.MethodPut, http.MethodDelete: + // Idempotent methods are safe to retry: replaying them cannot create duplicate + // side effects. A DELETE that actually succeeded server-side before a proxy 503 + // simply yields a 404 on replay, which delete handlers already treat as done. + return true + } + if whitelisted, found := whitelistedByMethodAndUrl[req.Method]; found { + _, retry = whitelisted.Load(req.URL.String()) + } + return + }, + // ShouldRetryResponse returns the backoff policy if the response/error indicates a retryable condition, + // otherwise nil is returned to indicate no retry. + ShouldRetryResponse: func(resp *http.Response, err error) RetryBackoff { + if err != nil { + return options.Backoff + } + switch resp.StatusCode { + case http.StatusTooManyRequests, http.StatusServiceUnavailable: + return retryAfterBackoff{Response: resp, Fallback: options.Backoff} + case http.StatusBadGateway, http.StatusGatewayTimeout: + return options.Backoff + case http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + if locationRedirectUrl, _ := resp.Request.URL.Parse(resp.Header.Get("Location")); locationRedirectUrl != nil { + if whitelisted, found := whitelistedByMethodAndUrl[resp.Request.Method]; found { + whitelisted.Store(locationRedirectUrl.String(), nil) + } + } + return nil + default: + return nil + } + }, + } + return c // for fluent API +} + +// RetryOptions configure WithRetry. +type RetryOptions struct { + // MaxRetries limits the attempts to retries. If zero, retries will never be attempted. + MaxRetries int + // Backoff to use when retrying. If nil, retries will never be attempted. + Backoff RetryBackoff + // WhitelistedPaths allow methods beyond GET and PUT to be retried as well, see WithRetry. + WhitelistedPaths map[string][]string +} + +// RetryBackoff calculates the duration to wait before the next retry attempt. +type RetryBackoff interface { + Calculate(attempt int) time.Duration +} + +// ExponentialBackoff increases the backoff exponentially: minWait * 2^(attempt-1). +type ExponentialBackoff struct { + MinWait, MaxWait time.Duration +} + +func (b ExponentialBackoff) Calculate(attempt int) time.Duration { + nextWait := time.Duration(math.Pow(2, float64(attempt-1))) * b.MinWait + if b.MaxWait > 0 && nextWait > b.MaxWait { + return b.MaxWait + } + return nextWait +} + +var timeNow = time.Now + +type retryAfterBackoff struct { + Response *http.Response + Fallback RetryBackoff +} + +func (b retryAfterBackoff) Calculate(attempt int) (waitTime time.Duration) { + defer func() { + const maxRetryAfterWaitTime = 5 * time.Minute + if waitTime < 0 { + waitTime = b.Fallback.Calculate(attempt) + } else if waitTime > maxRetryAfterWaitTime { + waitTime = maxRetryAfterWaitTime + } + }() + + // Parse the Retry-After header from a response. + // It supports both delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). + + header := b.Response.Header.Get("Retry-After") + if header == "" { + return -1 + } + + // Try as delay-seconds first. + if seconds, err := strconv.ParseInt(header, 10, 64); err == nil { + return time.Duration(seconds) * time.Second + } + + // Try as HTTP-date (RFC 7231). + if date, err := http.ParseTime(header); err == nil { + return date.Sub(timeNow()) + } + return -1 +} + +// retryRoundTripper wraps an http.RoundTripper to retry failed requests. +// See WithRetry for which methods are retried. +type retryRoundTripper struct { + Next http.RoundTripper + MaxRetries int + ShouldRetryRequest func(req *http.Request) bool + ShouldRetryResponse func(resp *http.Response, err error) RetryBackoff +} + +func (r *retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if !r.ShouldRetryRequest(req) { + return r.Next.RoundTrip(req) + } + req = makeRequestBodyRetryable(req) + for attempt := 1; ; attempt++ { + resp, err := r.Next.RoundTrip(req) + if errors.Is(err, errRetryableBodyClose) { + return resp, err + } + backoff := r.ShouldRetryResponse(resp, err) + // No retry needed or no more retries left — return as-is. + if backoff == nil || attempt > r.MaxRetries { + return resp, err + } + drainAndCloseResponseBody(req.Context(), resp) + if req.GetBody != nil { + if body, err := req.GetBody(); err != nil { + return nil, err + } else { + req.Body = body + } + } + waitTime := backoff.Calculate(attempt) + Log.Warn(req.Context(), "retrying request", append( + func() []any { + if err != nil { + return []any{"error", err.Error()} + } + return []any{"status", resp.StatusCode} + }(), + "method", req.Method, + "path", req.URL.Path, + "attempt", fmt.Sprintf("%d/%d", attempt, r.MaxRetries), + "waitTime", waitTime, + )...) + timer := time.NewTimer(waitTime) + select { + case <-req.Context().Done(): + timer.Stop() + return nil, req.Context().Err() + case <-timer.C: + } + } +} + +func makeRequestBodyRetryable(req *http.Request) *http.Request { + if req.Body == nil { + return req + } + // If GetBody already returns independent readers (e.g. set by http.NewRequestWithContext + // for *bytes.Buffer, *bytes.Reader, *strings.Reader), use it as-is for retries. + if req.GetBody != nil { + return req + } + body := retryableBody{Closer: req.Body} + body.Reader = io.TeeReader(req.Body, &body.Buffer) + result := req.Clone(req.Context()) + result.Body = &body + result.GetBody = nil + return result +} + +// retryableBody lazily captures request body bytes on the first read and replays them on retries. +// Buffer is filled via TeeReader as the transport reads during the first request. On Close, the +// source is released and subsequent reads replay from Buffer via bytes.NewReader. +type retryableBody struct { + io.Reader + io.Closer + Buffer appendWriter +} + +var errRetryableBodyClose = errors.New("retryableBody failed to close") + +func (b *retryableBody) Close() error { + // Drain remaining bytes through the TeeReader to ensure Buffer captures the full body, + // even if the transport only partially read it (e.g. connection reset mid-write). + if _, err := io.Copy(io.Discard, b.Reader); err != nil { + return errors.Join(err, errRetryableBodyClose) + } + // On first close, close the Body and use the b.Buffer from now on + if b.Closer != nil { + if err := b.Closer.Close(); err != nil { + return errors.Join(err, errRetryableBodyClose) + } + } + b.Closer = nil + b.Reader = bytes.NewReader(b.Buffer) + return nil +} + +// appendWriter is an io.Writer that appends to a []byte slice. +// Helper for retryableBody.Buffer. +type appendWriter []byte + +func (w *appendWriter) Write(p []byte) (int, error) { + *w = append(*w, p...) + return len(p), nil +} + +// drainAndCloseResponseBody reads up to maxBytes from the response body before closing it. +// Draining enables Go's http.Transport to reuse the underlying TCP connection for +// subsequent requests. The maxBytes limit prevents getting stuck on large or slow +// responses — if the body exceeds this limit, the connection won't be reused, but +// we won't block indefinitely either. +func drainAndCloseResponseBody(ctx context.Context, resp *http.Response) { + const maxBytes = 16 * 1024 + if resp != nil && resp.Body != nil { + drainedBytes, err := io.CopyN(io.Discard, resp.Body, maxBytes) + if err != nil && !errors.Is(err, io.EOF) { + Log.Debug(ctx, fmt.Sprintf("failed to drain response body: %s", err.Error())) + } + if err := resp.Body.Close(); err != nil { + Log.Debug(ctx, fmt.Sprintf("failed to close response body after draining %d bytes: %s", drainedBytes, err.Error())) + } + } +} diff --git a/client/internal/retry_test.go b/client/internal/retry_test.go new file mode 100644 index 0000000..a643edd --- /dev/null +++ b/client/internal/retry_test.go @@ -0,0 +1,64 @@ +package internal + +import ( + "fmt" + "net/http" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestExponentialBackoff_Calculate(t *testing.T) { + tests := []struct { + attempt int + want time.Duration + }{ + {1, 1 * time.Second}, + {2, 2 * time.Second}, + {3, 4 * time.Second}, + {4, 5 * time.Second}, + {5, 5 * time.Second}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("attempt %d", tt.attempt), func(t *testing.T) { + b := ExponentialBackoff{ + MinWait: 1 * time.Second, + MaxWait: 5 * time.Second, + } + assert.Equalf(t, tt.want, b.Calculate(tt.attempt), "Calculate(%v)", tt.attempt) + }) + } +} + +func TestRetryAfterBackoff(t *testing.T) { + // synctest bubble starts at 2000-01-01T00:00:00Z + bubbleStart := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + fallback := ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 10 * time.Second} + + tests := []struct { + name string + header string + want time.Duration + }{ + {"delay-seconds", "30", 30 * time.Second}, + {"zero seconds", "0", 0}, // RFC: retry immediately + {"capped at 5 minutes", "600", 5 * time.Minute}, // capped + {"empty header", "", 1 * time.Second}, // falls back + {"unparseable header", "not-a-number-or-date", 1 * time.Second}, // falls back + {"HTTP-date in the past", bubbleStart.Add(-10 * time.Second).Format(http.TimeFormat), 1 * time.Second}, // falls back + {"HTTP-date in the future", bubbleStart.Add(45 * time.Second).Format(http.TimeFormat), 45 * time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + b := retryAfterBackoff{ + Response: &http.Response{Header: http.Header{"Retry-After": {tt.header}}}, + Fallback: fallback, + } + assert.Equal(t, tt.want, b.Calculate(1)) + }) + }) + } +} diff --git a/client/landingzone.go b/client/landingzone.go new file mode 100644 index 0000000..5af9273 --- /dev/null +++ b/client/landingzone.go @@ -0,0 +1,109 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshLandingZone struct { + Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` + Status MeshLandingZoneStatus `json:"status" tfsdk:"status"` +} + +type MeshLandingZoneMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +type MeshLandingZoneSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` + AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` + // Nullable in the API, where absent means "keep the stored value" — hence no `,omitempty`: the + // schema defaults this to false, so the provider always states the value it wants and never + // asks the backend to keep whatever is stored. + Restricted bool `json:"restricted" tfsdk:"restricted"` + InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` + Quotas []MeshLandingZoneQuota `json:"quotas" tfsdk:"quotas"` + MandatoryBuildingBlockRefs []UuidRef `json:"mandatoryBuildingBlockRefs" tfsdk:"mandatory_building_block_refs"` + RecommendedBuildingBlockRefs []UuidRef `json:"recommendedBuildingBlockRefs" tfsdk:"recommended_building_block_refs"` +} + +type MeshLandingZoneStatus struct { + Disabled bool `json:"disabled" tfsdk:"disabled"` + Restricted bool `json:"restricted" tfsdk:"restricted"` +} + +type MeshLandingZonePlatformProperties struct { + Type string `json:"type" tfsdk:"type"` + Aws *AwsPlatformProperties `json:"aws" tfsdk:"aws"` + Aks *AksPlatformProperties `json:"aks" tfsdk:"aks"` + Azure *AzurePlatformProperties `json:"azure" tfsdk:"azure"` + AzureRg *AzureRgPlatformProperties `json:"azurerg" tfsdk:"azurerg"` + Custom *CustomPlatformProperties `json:"custom" tfsdk:"custom"` + Gcp *GcpPlatformProperties `json:"gcp" tfsdk:"gcp"` + Kubernetes *KubernetesPlatformProperties `json:"kubernetes" tfsdk:"kubernetes"` + OpenShift *OpenShiftPlatformProperties `json:"openshift" tfsdk:"openshift"` +} + +type MeshLandingZoneQuota struct { + Key string `json:"key" tfsdk:"key"` + Value int64 `json:"value" tfsdk:"value"` +} + +type MeshLandingZoneCreate struct { + Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` +} + +// MeshLandingZoneListQuery holds the optional filters for the V1 landing zone list endpoint. The +// json tags name the query params; unset (nil/zero) fields are dropped by WithUrlQuery. +type MeshLandingZoneListQuery struct { + PlatformUuid *string `json:"platformUuid"` + Identifier *string `json:"identifier"` + DisplayName *string `json:"displayName"` + Restricted *bool `json:"restricted"` + OwnedByWorkspace *string `json:"ownedByWorkspace"` +} + +type MeshLandingZoneClient interface { + Read(ctx context.Context, name string) (*MeshLandingZone, error) + List(ctx context.Context, query MeshLandingZoneListQuery) ([]MeshLandingZone, error) + Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) + Update(ctx context.Context, name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) + Delete(ctx context.Context, name string) error +} + +type meshLandingZoneClient struct { + meshObject internal.MeshObjectClient[MeshLandingZone] +} + +func newLandingZoneClient(ctx context.Context, httpClient internal.HttpClient) MeshLandingZoneClient { + return meshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1")} +} + +func (c meshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLandingZone, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshLandingZoneClient) List(ctx context.Context, query MeshLandingZoneListQuery) ([]MeshLandingZone, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(query)) +} + +func (c meshLandingZoneClient) Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + return c.meshObject.Post(ctx, landingZone) +} + +func (c meshLandingZoneClient) Update(ctx context.Context, name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + return c.meshObject.Put(ctx, name, landingZone) +} + +func (c meshLandingZoneClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/client/location.go b/client/location.go new file mode 100644 index 0000000..c2935ca --- /dev/null +++ b/client/location.go @@ -0,0 +1,69 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshLocation struct { + Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` + Status MeshLocationStatus `json:"status" tfsdk:"status"` +} + +type MeshLocationMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid string `json:"uuid" tfsdk:"uuid"` +} + +type MeshLocationSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` +} + +type MeshLocationStatus struct { + IsPublic bool `json:"isPublic" tfsdk:"is_public"` +} + +type MeshLocationCreate struct { + Metadata MeshLocationCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` +} + +type MeshLocationCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshLocationClient interface { + Read(ctx context.Context, name string) (*MeshLocation, error) + Create(ctx context.Context, location *MeshLocationCreate) (*MeshLocation, error) + Update(ctx context.Context, name string, location *MeshLocationCreate) (*MeshLocation, error) + Delete(ctx context.Context, name string) error +} + +type meshLocationClient struct { + meshObject internal.MeshObjectClient[MeshLocation] +} + +func newLocationClient(ctx context.Context, httpClient internal.HttpClient) MeshLocationClient { + return meshLocationClient{internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1")} +} + +func (c meshLocationClient) Read(ctx context.Context, name string) (*MeshLocation, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshLocationClient) Create(ctx context.Context, location *MeshLocationCreate) (*MeshLocation, error) { + return c.meshObject.Post(ctx, location) +} + +func (c meshLocationClient) Update(ctx context.Context, name string, location *MeshLocationCreate) (*MeshLocation, error) { + return c.meshObject.Put(ctx, name, location) +} + +func (c meshLocationClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/client/mesh_info.go b/client/mesh_info.go new file mode 100644 index 0000000..682463d --- /dev/null +++ b/client/mesh_info.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "fmt" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +// FeatureFlagFourEyesRoleApproval is the only feature flag /mesh/info can currently report in +// MeshInfo.EnabledFeatureFlags: whether the four-eyes principle (role approval) is enabled. +const FeatureFlagFourEyesRoleApproval = "four_eyes_role_approval" + +// MeshInfo describes the meshStack instance the provider is configured against: the endpoint from +// the provider configuration, plus metadata from the public, unauthenticated /mesh/info endpoint. +type MeshInfo struct { + Endpoint string `tfsdk:"endpoint" json:"-"` + Version string `tfsdk:"version" json:"version"` + IsFourEyesEnabled bool `tfsdk:"-" json:"is4EPEnabled"` + EnabledFeatureFlags []string `tfsdk:"enabled_feature_flags" json:"-"` + Metadata map[string]string `tfsdk:"metadata" json:"metadata"` + AdminWorkspaceIdentifier string `tfsdk:"admin_workspace_identifier" json:"adminWorkspaceIdentifier"` +} + +type MeshInfoClient interface { + Read(ctx context.Context) (*MeshInfo, error) +} + +type meshInfoClient struct { + httpClient internal.HttpClient +} + +func newMeshInfoClient(httpClient internal.HttpClient) MeshInfoClient { + return meshInfoClient{httpClient: httpClient} +} + +func (c meshInfoClient) Read(ctx context.Context) (*MeshInfo, error) { + meshInfoEndpoint := c.httpClient.RootUrl.JoinPath("/mesh/info") + info, err := internal.DoRequest[MeshInfo](ctx, c.httpClient, "GET", meshInfoEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to retrieve meshStack instance information from %s endpoint: %w", meshInfoEndpoint, err) + } + + info.Endpoint = c.httpClient.RootUrl.String() + info.EnabledFeatureFlags = []string{} + if info.IsFourEyesEnabled { + info.EnabledFeatureFlags = append(info.EnabledFeatureFlags, FeatureFlagFourEyesRoleApproval) + } + + return &info, nil +} diff --git a/client/payment_method.go b/client/payment_method.go new file mode 100644 index 0000000..443c10a --- /dev/null +++ b/client/payment_method.go @@ -0,0 +1,67 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshPaymentMethod struct { + Metadata MeshPaymentMethodMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPaymentMethodMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` +} + +type MeshPaymentMethodSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + ExpirationDate *string `json:"expirationDate,omitempty" tfsdk:"expiration_date"` + Amount *int64 `json:"amount,omitempty" tfsdk:"amount"` + Tags map[string][]string `json:"tags,omitempty" tfsdk:"tags"` +} + +type MeshPaymentMethodCreate struct { + Metadata MeshPaymentMethodCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPaymentMethodCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshPaymentMethodClient interface { + Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) + Create(ctx context.Context, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) + Update(ctx context.Context, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) + Delete(ctx context.Context, identifier string) error +} + +type meshPaymentMethodClient struct { + meshObject internal.MeshObjectClient[MeshPaymentMethod] +} + +func newPaymentMethodClient(ctx context.Context, httpClient internal.HttpClient) MeshPaymentMethodClient { + return meshPaymentMethodClient{internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2")} +} + +func (c meshPaymentMethodClient) Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) { + return c.meshObject.Get(ctx, identifier) +} + +func (c meshPaymentMethodClient) Create(ctx context.Context, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + return c.meshObject.Post(ctx, paymentMethod) +} + +func (c meshPaymentMethodClient) Update(ctx context.Context, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + return c.meshObject.Put(ctx, identifier, paymentMethod) +} + +func (c meshPaymentMethodClient) Delete(ctx context.Context, identifier string) error { + return c.meshObject.Delete(ctx, identifier) +} diff --git a/client/platform.go b/client/platform.go new file mode 100644 index 0000000..cc303c5 --- /dev/null +++ b/client/platform.go @@ -0,0 +1,128 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" +) + +type MeshPlatform struct { + Metadata MeshPlatformMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPlatformMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` +} + +type MeshPlatformSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + Endpoint string `json:"endpoint" tfsdk:"endpoint"` + SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + AccessInformation *string `json:"accessInformation,omitempty" tfsdk:"access_information"` + LocationRef NamedRef `json:"locationRef" tfsdk:"location_ref"` + ContributingWorkspaces types.Set[string] `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` + Availability PlatformAvailability `json:"availability" tfsdk:"availability"` + // Config is nullable in responses: redacted (omitted) for marketplace-consumer callers. Required on write. + Config *PlatformConfig `json:"config,omitempty" tfsdk:"config"` + QuotaDefinitions types.Set[QuotaDefinition] `json:"quotaDefinitions" tfsdk:"quota_definitions"` +} + +type QuotaDefinition struct { + QuotaKey string `json:"quotaKey" tfsdk:"quota_key"` + MinValue int64 `json:"minValue" tfsdk:"min_value"` + MaxValue int64 `json:"maxValue" tfsdk:"max_value"` + Unit string `json:"unit" tfsdk:"unit"` + AutoApprovalThreshold int64 `json:"autoApprovalThreshold" tfsdk:"auto_approval_threshold"` + Description string `json:"description" tfsdk:"description"` + Label string `json:"label" tfsdk:"label"` +} + +type PlatformAvailability struct { + Restriction string `json:"restriction" tfsdk:"restriction"` + PublicationState string `json:"publicationState" tfsdk:"publication_state"` + RestrictedToWorkspaces types.Set[string] `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` +} + +type PlatformConfig struct { + Type string `json:"type" tfsdk:"type"` + Custom *CustomPlatformConfig `json:"custom,omitempty" tfsdk:"custom"` + Aws *AwsPlatformConfig `json:"aws,omitempty" tfsdk:"aws"` + Aks *AksPlatformConfig `json:"aks,omitempty" tfsdk:"aks"` + Azure *AzurePlatformConfig `json:"azure,omitempty" tfsdk:"azure"` + AzureRg *AzureRgPlatformConfig `json:"azurerg,omitempty" tfsdk:"azurerg"` + Gcp *GcpPlatformConfig `json:"gcp,omitempty" tfsdk:"gcp"` + Kubernetes *KubernetesPlatformConfig `json:"kubernetes,omitempty" tfsdk:"kubernetes"` + OpenShift *OpenShiftPlatformConfig `json:"openshift,omitempty" tfsdk:"openshift"` +} + +type MeshPlatformMeteringProcessingConfig struct { + CompactTimelinesAfterDays int64 `json:"compactTimelinesAfterDays" tfsdk:"compact_timelines_after_days"` + DeleteRawDataAfterDays int64 `json:"deleteRawDataAfterDays" tfsdk:"delete_raw_data_after_days"` +} + +type MeshTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers types.Set[TagMapper] `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type TagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +// MeshPlatformListQuery holds the optional filters for the V2 platform list endpoint. The json tags +// name the query params; unset (nil/zero) fields are dropped by WithUrlQuery. +type MeshPlatformListQuery struct { + OwnedByWorkspace *string `json:"ownedByWorkspace"` + Identifier *string `json:"identifier"` + LocationIdentifier *string `json:"locationIdentifier"` + DisplayName *string `json:"displayName"` + Restriction *string `json:"restriction"` + PublicationState *string `json:"publicationState"` + ContributingWorkspace *string `json:"contributingWorkspace"` + // PlatformTypeIdentifier filters by the platform type's identifier (matched backend-side); the type + // is not carried in the response, and spec.config is redacted for marketplace consumers anyway. + PlatformTypeIdentifier *string `json:"platformTypeIdentifier"` +} + +type MeshPlatformClient interface { + Read(ctx context.Context, uuid string) (*MeshPlatform, error) + List(ctx context.Context, query MeshPlatformListQuery) ([]MeshPlatform, error) + Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) + Update(ctx context.Context, uuid string, platform MeshPlatform) (*MeshPlatform, error) + Delete(ctx context.Context, uuid string) error +} + +type meshPlatformClient struct { + meshObject internal.MeshObjectClient[MeshPlatform] +} + +func newPlatformClient(ctx context.Context, httpClient internal.HttpClient) MeshPlatformClient { + return meshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2")} +} + +func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatform, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshPlatformClient) List(ctx context.Context, query MeshPlatformListQuery) ([]MeshPlatform, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(query)) +} + +func (c meshPlatformClient) Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) { + return c.meshObject.Post(ctx, platform) +} + +func (c meshPlatformClient) Update(ctx context.Context, uuid string, platform MeshPlatform) (*MeshPlatform, error) { + return c.meshObject.Put(ctx, uuid, platform) +} + +func (c meshPlatformClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/client/platform_config_aks.go b/client/platform_config_aks.go new file mode 100644 index 0000000..56d0722 --- /dev/null +++ b/client/platform_config_aks.go @@ -0,0 +1,36 @@ +package client + +import "github.com/meshcloud/meshstack-cli/client/types" + +type AksPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *AksReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *AksMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type AksReplicationConfig struct { + AccessToken types.Secret `json:"accessToken" tfsdk:"access_token"` + NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + ServicePrincipal AksServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + AksSubscriptionId string `json:"aksSubscriptionId" tfsdk:"aks_subscription_id"` + AksClusterName string `json:"aksClusterName" tfsdk:"aks_cluster_name"` + AksResourceGroup string `json:"aksResourceGroup" tfsdk:"aks_resource_group"` + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` + UserLookupStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` +} + +type AksServicePrincipalConfig struct { + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` + ObjectId string `json:"objectId" tfsdk:"object_id"` + ClientId string `json:"clientId" tfsdk:"client_id"` + Auth AzureAuthConfig `json:"auth" tfsdk:"auth"` +} + +type AksMeteringConfig struct { + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/client/platform_config_aws.go b/client/platform_config_aws.go new file mode 100644 index 0000000..dfd64e1 --- /dev/null +++ b/client/platform_config_aws.go @@ -0,0 +1,90 @@ +package client + +import "github.com/meshcloud/meshstack-cli/client/types" + +type AwsPlatformConfig struct { + Region string `json:"region,omitempty" tfsdk:"region"` + Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` + Metering *AwsMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type AwsReplicationConfig struct { + AccessConfig AwsAccessConfig `json:"accessConfig" tfsdk:"access_config"` + WaitForExternalAvm bool `json:"waitForExternalAvm" tfsdk:"wait_for_external_avm"` + AutomationAccountRole string `json:"automationAccountRole" tfsdk:"automation_account_role"` + AutomationAccountExternalId *string `json:"automationAccountExternalId,omitempty" tfsdk:"automation_account_external_id"` + AccountAccessRole string `json:"accountAccessRole" tfsdk:"account_access_role"` + AccountAliasPattern string `json:"accountAliasPattern" tfsdk:"account_alias_pattern"` + EnforceAccountAlias bool `json:"enforceAccountAlias" tfsdk:"enforce_account_alias"` + AccountEmailPattern string `json:"accountEmailPattern" tfsdk:"account_email_pattern"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` + AwsIdentityStore *AwsIdentityStoreConfig `json:"awsIdentityStore,omitempty" tfsdk:"aws_identity_store"` + EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` + SelfDowngradeAccessRole bool `json:"selfDowngradeAccessRole" tfsdk:"self_downgrade_access_role"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + AllowHierarchicalOrganizationalUnitAssignment bool `json:"allowHierarchicalOrganizationalUnitAssignment" tfsdk:"allow_hierarchical_organizational_unit_assignment"` +} + +type AwsAccessConfig struct { + OrganizationRootAccountRole string `json:"organizationRootAccountRole" tfsdk:"organization_root_account_role"` + OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitempty" tfsdk:"organization_root_account_external_id"` + Auth AwsAuth `json:"auth" tfsdk:"auth"` +} + +type AwsAuth struct { + Type string `json:"type" tfsdk:"type"` + Credential *AwsServiceUserCredential `json:"credential,omitempty" tfsdk:"credential"` + WorkloadIdentity *AwsWorkloadIdentityCredential `json:"workloadIdentity,omitempty" tfsdk:"workload_identity"` +} + +type AwsServiceUserCredential struct { + AccessKey string `json:"accessKey" tfsdk:"access_key"` + SecretKey types.Secret `json:"secretKey" tfsdk:"secret_key"` +} + +type AwsWorkloadIdentityCredential struct { + RoleArn string `json:"roleArn" tfsdk:"role_arn"` +} + +type AwsSsoConfig struct { + ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` + Arn string `json:"arn" tfsdk:"arn"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + SsoAccessToken types.Secret `json:"ssoAccessToken" tfsdk:"sso_access_token"` + AwsRoleMappings types.Set[AwsSsoRoleMapping] `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` + SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` +} + +type AwsSsoRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` +} + +type AwsEnrollmentConfiguration struct { + ManagementAccountId string `json:"managementAccountId" tfsdk:"management_account_id"` + AccountFactoryProductId string `json:"accountFactoryProductId" tfsdk:"account_factory_product_id"` +} + +type AwsIdentityStoreConfig struct { + IdentityStoreId string `json:"identityStoreId" tfsdk:"identity_store_id"` + Arn string `json:"arn" tfsdk:"arn"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + AwsRoleMappings types.Set[AwsIdentityStoreRoleMapping] `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` + SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` +} + +type AwsIdentityStoreRoleMapping struct { + ProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` +} + +type AwsMeteringConfig struct { + AccessConfig AwsAccessConfig `json:"accessConfig" tfsdk:"access_config"` + Filter string `json:"filter" tfsdk:"filter"` + ReservedInstanceFairChargeback bool `json:"reservedInstanceFairChargeback" tfsdk:"reserved_instance_fair_chargeback"` + SavingsPlanFairChargeback bool `json:"savingsPlanFairChargeback" tfsdk:"savings_plan_fair_chargeback"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/client/platform_config_azure.go b/client/platform_config_azure.go new file mode 100644 index 0000000..1c60058 --- /dev/null +++ b/client/platform_config_azure.go @@ -0,0 +1,86 @@ +package client + +import "github.com/meshcloud/meshstack-cli/client/types" + +type AzurePlatformConfig struct { + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` + Replication *AzureReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` + Metering *AzureMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type AzureReplicationConfig struct { + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + UpdateSubscriptionName bool `json:"updateSubscriptionName" tfsdk:"update_subscription_name"` + Provisioning *AzureSubscriptionProvisioningConfig `json:"provisioning,omitempty" tfsdk:"provisioning"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + SubscriptionNamePattern string `json:"subscriptionNamePattern" tfsdk:"subscription_name_pattern"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + AzureRoleMappings types.Set[AzureRoleMapping] `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` +} + +type AzureServicePrincipalConfig struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + ObjectId string `json:"objectId" tfsdk:"object_id"` + Auth AzureAuthConfig `json:"auth" tfsdk:"auth"` +} + +type AzureAuthConfig struct { + Type string `json:"type" tfsdk:"type"` + Credential *types.Secret `json:"credential,omitempty" tfsdk:"credential"` +} + +type AzureGraphApiCredentials struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + Auth AzureAuthConfig `json:"auth" tfsdk:"auth"` +} + +type AzureSubscriptionProvisioningConfig struct { + SubscriptionOwnerObjectIds types.Set[string] `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` + EnterpriseEnrollment *AzureEnterpriseEnrollmentConfig `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` + CustomerAgreement *AzureCustomerAgreementConfig `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` + PreProvisioned *AzurePreProvisionedSubscriptionConfig `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` +} + +type AzureEnterpriseEnrollmentConfig struct { + EnrollmentAccountId string `json:"enrollmentAccountId" tfsdk:"enrollment_account_id"` + SubscriptionOfferType string `json:"subscriptionOfferType" tfsdk:"subscription_offer_type"` + UseLegacySubscriptionEnrollment bool `json:"useLegacySubscriptionEnrollment" tfsdk:"use_legacy_subscription_enrollment"` + SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` +} + +type AzureCustomerAgreementConfig struct { + SourceServicePrincipal AzureGraphApiCredentials `json:"sourceServicePrincipal" tfsdk:"source_service_principal"` + DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` + SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` + BillingScope string `json:"billingScope" tfsdk:"billing_scope"` + SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` +} + +type AzurePreProvisionedSubscriptionConfig struct { + UnusedSubscriptionNamePrefix string `json:"unusedSubscriptionNamePrefix" tfsdk:"unused_subscription_name_prefix"` +} + +type AzureInviteB2BUserConfig struct { + RedirectUrl string `json:"redirectUrl" tfsdk:"redirect_url"` + SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` +} + +type AzureRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureRole AzureRole `json:"azureRole" tfsdk:"azure_role"` +} + +type AzureRole struct { + Alias string `json:"alias" tfsdk:"alias"` + Id string `json:"id" tfsdk:"id"` +} + +type AzureMeteringConfig struct { + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/client/platform_config_azurerg.go b/client/platform_config_azurerg.go new file mode 100644 index 0000000..dab2f73 --- /dev/null +++ b/client/platform_config_azurerg.go @@ -0,0 +1,18 @@ +package client + +type AzureRgPlatformConfig struct { + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` + Replication *AzureRgReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` +} + +type AzureRgReplicationConfig struct { + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + Subscription string `json:"subscription" tfsdk:"subscription"` + ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` + UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` +} diff --git a/client/platform_config_custom.go b/client/platform_config_custom.go new file mode 100644 index 0000000..03a632d --- /dev/null +++ b/client/platform_config_custom.go @@ -0,0 +1,10 @@ +package client + +type CustomPlatformConfig struct { + PlatformTypeRef NamedRef `json:"platformTypeRef" tfsdk:"platform_type_ref"` + Metering *CustomMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type CustomMeteringConfig struct { + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} diff --git a/client/platform_config_gcp.go b/client/platform_config_gcp.go new file mode 100644 index 0000000..fa6f709 --- /dev/null +++ b/client/platform_config_gcp.go @@ -0,0 +1,50 @@ +package client + +import "github.com/meshcloud/meshstack-cli/client/types" + +type GcpPlatformConfig struct { + Replication *GcpReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` + Metering *GcpMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type GcpReplicationConfig struct { + ServiceAccount GcpServiceAccountConfig `json:"serviceAccount" tfsdk:"service_account"` + Domain string `json:"domain" tfsdk:"domain"` + CustomerId string `json:"customerId" tfsdk:"customer_id"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` + ProjectIdPattern string `json:"projectIdPattern" tfsdk:"project_id_pattern"` + BillingAccountId string `json:"billingAccountId" tfsdk:"billing_account_id"` + UserLookupStrategy string `json:"userLookupStrategy" tfsdk:"user_lookup_strategy"` + UsedExternalIdType *string `json:"usedExternalIdType,omitempty" tfsdk:"used_external_id_type"` + GcpRoleMappings types.Set[GcpPlatformRoleMapping] `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` + AllowHierarchicalFolderAssignment bool `json:"allowHierarchicalFolderAssignment" tfsdk:"allow_hierarchical_folder_assignment"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` +} + +type GcpServiceAccountConfig struct { + Type string `json:"type" tfsdk:"type"` + Credential *types.Secret `json:"credential,omitempty" tfsdk:"credential"` + WorkloadIdentity *GcpServiceAccountWorkloadIdentityConfig `json:"workloadIdentity,omitempty" tfsdk:"workload_identity"` +} + +type GcpServiceAccountWorkloadIdentityConfig struct { + Audience string `json:"audience" tfsdk:"audience"` + ServiceAccountEmail string `json:"serviceAccountEmail" tfsdk:"service_account_email"` +} + +type GcpPlatformRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` +} + +type GcpMeteringConfig struct { + ServiceAccount GcpServiceAccountConfig `json:"serviceAccount" tfsdk:"service_account"` + BigqueryTable string `json:"bigqueryTable" tfsdk:"bigquery_table"` + BigqueryTableForCarbonFootprint *string `json:"bigqueryTableForCarbonFootprint,omitempty" tfsdk:"bigquery_table_for_carbon_footprint"` + CarbonFootprintDataCollectionStartMonth *string `json:"carbonFootprintDataCollectionStartMonth,omitempty" tfsdk:"carbon_footprint_data_collection_start_month"` + PartitionTimeColumn string `json:"partitionTimeColumn" tfsdk:"partition_time_column"` + AdditionalFilter *string `json:"additionalFilter,omitempty" tfsdk:"additional_filter"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/client/platform_config_kubernetes.go b/client/platform_config_kubernetes.go new file mode 100644 index 0000000..cc7ad9a --- /dev/null +++ b/client/platform_config_kubernetes.go @@ -0,0 +1,24 @@ +package client + +import "github.com/meshcloud/meshstack-cli/client/types" + +type KubernetesPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *KubernetesReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *KubernetesMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type KubernetesReplicationConfig struct { + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` +} + +type KubernetesClientConfig struct { + AccessToken types.Secret `json:"accessToken" tfsdk:"access_token"` +} + +type KubernetesMeteringConfig struct { + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/client/platform_config_openshift.go b/client/platform_config_openshift.go new file mode 100644 index 0000000..565c1b0 --- /dev/null +++ b/client/platform_config_openshift.go @@ -0,0 +1,29 @@ +package client + +import "github.com/meshcloud/meshstack-cli/client/types" + +type OpenShiftPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *OpenShiftReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *OpenShiftMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type OpenShiftReplicationConfig struct { + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` + ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` + OpenshiftRoleMappings types.Set[OpenShiftPlatformRoleMapping] `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` + IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` +} + +type OpenShiftMeteringConfig struct { + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} + +type OpenShiftPlatformRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + OpenshiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` +} diff --git a/client/platform_properties_aks.go b/client/platform_properties_aks.go new file mode 100644 index 0000000..04870c3 --- /dev/null +++ b/client/platform_properties_aks.go @@ -0,0 +1,10 @@ +package client + +type AksPlatformProperties struct { + KubernetesRoleMappings []KubernetesRoleMapping `json:"kubernetesRoleMappings" tfsdk:"kubernetes_role_mappings"` +} + +type KubernetesRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` +} diff --git a/client/platform_properties_aws.go b/client/platform_properties_aws.go new file mode 100644 index 0000000..41a747a --- /dev/null +++ b/client/platform_properties_aws.go @@ -0,0 +1,14 @@ +package client + +type AwsPlatformProperties struct { + AwsTargetOrgUnitId string `json:"awsTargetOrgUnitId" tfsdk:"aws_target_org_unit_id"` + AwsEnrollAccount bool `json:"awsEnrollAccount" tfsdk:"aws_enroll_account"` + AwsLambdaArn *string `json:"awsLambdaArn" tfsdk:"aws_lambda_arn"` + AwsRoleMappings []AwsRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` +} + +type AwsRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRole string `json:"platformRole" tfsdk:"platform_role"` + Policies []string `json:"policies" tfsdk:"policies"` +} diff --git a/client/platform_properties_azure.go b/client/platform_properties_azure.go new file mode 100644 index 0000000..2309857 --- /dev/null +++ b/client/platform_properties_azure.go @@ -0,0 +1,17 @@ +package client + +type AzurePlatformProperties struct { + AzureRoleMappings []AzureRoleMappingProperty `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` + AzureManagementGroupId string `json:"azureManagementGroupId" tfsdk:"azure_management_group_id"` +} + +type AzureRoleMappingProperty struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` + AzureRoleDefinitions []AzureRoleDefinition `json:"azureRoleDefinitions" tfsdk:"azure_role_definitions"` +} + +type AzureRoleDefinition struct { + AzureRoleDefinitionId string `json:"azureRoleDefinitionId" tfsdk:"azure_role_definition_id"` + AbacCondition *string `json:"abacCondition" tfsdk:"abac_condition"` +} diff --git a/client/platform_properties_azurerg.go b/client/platform_properties_azurerg.go new file mode 100644 index 0000000..4bc2b70 --- /dev/null +++ b/client/platform_properties_azurerg.go @@ -0,0 +1,18 @@ +package client + +type AzureRgPlatformProperties struct { + AzureRgLocation string `json:"azureRgLocation" tfsdk:"azure_rg_location"` + AzureRgRoleMappings []AzureRgRoleMapping `json:"azureRgRoleMappings" tfsdk:"azure_rg_role_mappings"` + AzureFunction *AzureFunction `json:"azureFunction,omitempty" tfsdk:"azure_function"` +} + +type AzureRgRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` + AzureRoleDefinitionIds []string `json:"azureRoleDefinitionIds" tfsdk:"azure_role_definition_ids"` +} + +type AzureFunction struct { + AzureFunctionUrl string `json:"azureFunctionUrl" tfsdk:"azure_function_url"` + AzureFunctionScope string `json:"azureFunctionScope" tfsdk:"azure_function_scope"` +} diff --git a/client/platform_properties_custom.go b/client/platform_properties_custom.go new file mode 100644 index 0000000..0d721af --- /dev/null +++ b/client/platform_properties_custom.go @@ -0,0 +1,5 @@ +package client + +type CustomPlatformProperties struct { + // Intentionally left empty, as custom platforms do not have any properties. +} diff --git a/client/platform_properties_gcp.go b/client/platform_properties_gcp.go new file mode 100644 index 0000000..f10ae34 --- /dev/null +++ b/client/platform_properties_gcp.go @@ -0,0 +1,12 @@ +package client + +type GcpPlatformProperties struct { + GcpCloudFunctionUrl *string `json:"gcpCloudFunctionUrl,omitempty" tfsdk:"gcp_cloud_function_url"` + GcpFolderId *string `json:"gcpFolderId,omitempty" tfsdk:"gcp_folder_id"` + GcpRoleMappings []GcpRoleMapping `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` +} + +type GcpRoleMapping struct { + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` +} diff --git a/client/platform_properties_kubernetes.go b/client/platform_properties_kubernetes.go new file mode 100644 index 0000000..b48c338 --- /dev/null +++ b/client/platform_properties_kubernetes.go @@ -0,0 +1,5 @@ +package client + +type KubernetesPlatformProperties struct { + KubernetesRoleMappings []KubernetesRoleMapping `json:"kubernetesRoleMappings" tfsdk:"kubernetes_role_mappings"` +} diff --git a/client/platform_properties_openshift.go b/client/platform_properties_openshift.go new file mode 100644 index 0000000..68a1578 --- /dev/null +++ b/client/platform_properties_openshift.go @@ -0,0 +1,5 @@ +package client + +type OpenShiftPlatformProperties struct { + // Intentionally left empty, as OpenShift platform properties were removed from the meshStack API. +} diff --git a/client/platform_type.go b/client/platform_type.go new file mode 100644 index 0000000..c9cc33c --- /dev/null +++ b/client/platform_type.go @@ -0,0 +1,88 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshPlatformType struct { + Metadata MeshPlatformTypeMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` + Status MeshPlatformTypeStatus `json:"status" tfsdk:"status"` +} + +type MeshPlatformTypeStatus struct { + Lifecycle MeshPlatformTypeLifecycle `json:"lifecycle" tfsdk:"lifecycle"` +} + +type MeshPlatformTypeLifecycle struct { + State string `json:"state" tfsdk:"state"` +} + +type MeshPlatformTypeMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` +} + +type MeshPlatformTypeSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Category string `json:"category" tfsdk:"category"` + DefaultEndpoint *string `json:"defaultEndpoint,omitempty" tfsdk:"default_endpoint"` + Icon string `json:"icon" tfsdk:"icon"` +} + +type MeshPlatformTypeCreate struct { + Metadata MeshPlatformTypeCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPlatformTypeCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshPlatformTypeClient interface { + Create(ctx context.Context, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) + Read(ctx context.Context, identifier string) (*MeshPlatformType, error) + Update(ctx context.Context, name string, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) + Delete(ctx context.Context, name string) error + List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) +} + +type meshPlatformTypeClient struct { + meshObject internal.MeshObjectClient[MeshPlatformType] +} + +func newPlatformTypeClient(ctx context.Context, httpClient internal.HttpClient) MeshPlatformTypeClient { + return meshPlatformTypeClient{internal.NewMeshObjectClient[MeshPlatformType](ctx, httpClient, "v1")} +} + +func (c meshPlatformTypeClient) Create(ctx context.Context, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { + return c.meshObject.Post(ctx, platformType) +} + +func (c meshPlatformTypeClient) Read(ctx context.Context, identifier string) (*MeshPlatformType, error) { + return c.meshObject.Get(ctx, identifier) +} + +func (c meshPlatformTypeClient) Update(ctx context.Context, name string, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { + return c.meshObject.Put(ctx, name, platformType) +} + +func (c meshPlatformTypeClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} + +type meshPlatformTypeListQuery struct { + Category *string `json:"category"` + LifecycleStatus *string `json:"lifecycleStatus"` +} + +func (c meshPlatformTypeClient) List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(meshPlatformTypeListQuery{ + Category: category, + LifecycleStatus: lifecycleStatus, + })) +} diff --git a/client/project.go b/client/project.go new file mode 100644 index 0000000..434688f --- /dev/null +++ b/client/project.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshProject struct { + Metadata MeshProjectMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshProjectSpec `json:"spec" tfsdk:"spec"` +} + +type MeshProjectMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` +} + +type MeshProjectSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` + PaymentMethodIdentifier *string `json:"paymentMethodIdentifier" tfsdk:"payment_method_identifier"` + SubstitutePaymentMethodIdentifier *string `json:"substitutePaymentMethodIdentifier" tfsdk:"substitute_payment_method_identifier"` +} + +type MeshProjectCreate struct { + Metadata MeshProjectCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshProjectSpec `json:"spec" tfsdk:"spec"` +} + +type MeshProjectCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshProjectClient interface { + Read(ctx context.Context, workspace string, name string) (*MeshProject, error) + List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) + Create(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) + Update(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) + Delete(ctx context.Context, workspace string, name string) error +} + +type meshProjectClient struct { + meshObject internal.MeshObjectClient[MeshProject] +} + +func newProjectClient(ctx context.Context, httpClient internal.HttpClient) MeshProjectClient { + return meshProjectClient{internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2")} +} + +func (c meshProjectClient) projectId(workspace string, name string) string { + return workspace + "." + name +} + +func (c meshProjectClient) Read(ctx context.Context, workspace string, name string) (*MeshProject, error) { + return c.meshObject.Get(ctx, c.projectId(workspace, name)) +} + +type meshProjectListQuery struct { + WorkspaceIdentifier string `json:"workspaceIdentifier"` + PaymentIdentifier *string `json:"paymentIdentifier"` +} + +func (c meshProjectClient) List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(meshProjectListQuery{ + WorkspaceIdentifier: workspaceIdentifier, + PaymentIdentifier: paymentMethodIdentifier, + })) +} + +func (c meshProjectClient) Create(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { + return c.meshObject.Post(ctx, project) +} + +func (c meshProjectClient) Update(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { + return c.meshObject.Put(ctx, c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) +} + +func (c meshProjectClient) Delete(ctx context.Context, workspace string, name string) error { + return c.meshObject.Delete(ctx, c.projectId(workspace, name)) +} diff --git a/client/project_binding.go b/client/project_binding.go new file mode 100644 index 0000000..df1529d --- /dev/null +++ b/client/project_binding.go @@ -0,0 +1,27 @@ +package client + +type MeshProjectBinding struct { + Metadata MeshProjectBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshSubject `json:"subject" tfsdk:"subject"` +} + +type MeshProjectBindingMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +// Deprecated: Use NamedRef if possible. The convention is to also provide the `kind`, +// so this struct should only be used for meshobjects that violate our API conventions. +type MeshProjectRoleRef struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshProjectTargetRef struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshSubject struct { + Name string `json:"name" tfsdk:"name"` +} diff --git a/client/project_group_binding.go b/client/project_group_binding.go new file mode 100644 index 0000000..85872ef --- /dev/null +++ b/client/project_group_binding.go @@ -0,0 +1,37 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshProjectGroupBinding struct { + MeshProjectBinding +} + +type MeshProjectGroupBindingClient interface { + Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) + Create(ctx context.Context, binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshProjectGroupBindingClient struct { + meshObject internal.MeshObjectClient[MeshProjectGroupBinding] +} + +func newProjectGroupBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshProjectGroupBindingClient { + return meshProjectGroupBindingClient{internal.NewMeshObjectClient[MeshProjectGroupBinding](ctx, httpClient, "v3", "meshprojectbindings", "groupbindings")} +} + +func (c meshProjectGroupBindingClient) Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshProjectGroupBindingClient) Create(ctx context.Context, binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { + return c.meshObject.Post(ctx, binding) +} + +func (c meshProjectGroupBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/client/project_user_binding.go b/client/project_user_binding.go new file mode 100644 index 0000000..2b5b418 --- /dev/null +++ b/client/project_user_binding.go @@ -0,0 +1,37 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshProjectUserBinding struct { + MeshProjectBinding +} + +type MeshProjectUserBindingClient interface { + Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) + Create(ctx context.Context, binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshProjectUserBindingClient struct { + meshObject internal.MeshObjectClient[MeshProjectUserBinding] +} + +func newProjectUserBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshProjectUserBindingClient { + return meshProjectUserBindingClient{internal.NewMeshObjectClient[MeshProjectUserBinding](ctx, httpClient, "v3", "meshprojectbindings", "userbindings")} +} + +func (c meshProjectUserBindingClient) Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshProjectUserBindingClient) Create(ctx context.Context, binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { + return c.meshObject.Post(ctx, binding) +} + +func (c meshProjectUserBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/client/refs.go b/client/refs.go new file mode 100644 index 0000000..0f68eb2 --- /dev/null +++ b/client/refs.go @@ -0,0 +1,19 @@ +package client + +// NamedRef is the client-side DTO for a meshObject reference that identifies its +// target by name. It is the counterpart to the meshRefByName schema builder in +// internal/provider (schema_utils.go): every {name, kind} reference block on the +// wire deserializes into this struct. Refs that carry extra fields embed it. +type NamedRef struct { + Name string `json:"name" tfsdk:"name"` + Kind string `json:"kind" tfsdk:"kind"` +} + +// UuidRef is the client-side DTO for a meshObject reference that identifies its +// target by uuid. It is the counterpart to the meshRefByUuid schema builder in +// internal/provider (schema_utils.go): every {uuid, kind} reference block on the +// wire deserializes into this struct. Refs that carry extra fields embed it. +type UuidRef struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + Kind string `json:"kind" tfsdk:"kind"` +} diff --git a/client/service_instance.go b/client/service_instance.go new file mode 100644 index 0000000..0113b44 --- /dev/null +++ b/client/service_instance.go @@ -0,0 +1,57 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" +) + +type MeshServiceInstance struct { + Metadata MeshServiceInstanceMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshServiceInstanceSpec `json:"spec" tfsdk:"spec"` +} + +type MeshServiceInstanceMetadata struct { + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + MarketplaceIdentifier string `json:"marketplaceIdentifier" tfsdk:"marketplace_identifier"` + InstanceId string `json:"instanceId" tfsdk:"instance_id"` +} + +type MeshServiceInstanceSpec struct { + Creator string `json:"creator" tfsdk:"creator"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + PlanId string `json:"planId" tfsdk:"plan_id"` + ServiceId string `json:"serviceId" tfsdk:"service_id"` + Parameters map[string]types.Any `json:"parameters" tfsdk:"parameters"` +} + +type MeshServiceInstanceClient interface { + Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) + List(ctx context.Context, filter MeshServiceInstanceFilter) ([]MeshServiceInstance, error) +} + +type meshServiceInstanceClient struct { + meshObject internal.MeshObjectClient[MeshServiceInstance] +} + +type MeshServiceInstanceFilter struct { + WorkspaceIdentifier *string `json:"workspaceIdentifier"` + ProjectIdentifier *string `json:"projectIdentifier"` + MarketplaceIdentifier *string `json:"marketplaceIdentifier"` + ServiceIdentifier *string `json:"serviceIdentifier"` + PlanIdentifier *string `json:"planIdentifier"` +} + +func newServiceInstanceClient(ctx context.Context, httpClient internal.HttpClient) MeshServiceInstanceClient { + return meshServiceInstanceClient{internal.NewMeshObjectClient[MeshServiceInstance](ctx, httpClient, "v2")} +} + +func (c meshServiceInstanceClient) Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) { + return c.meshObject.Get(ctx, instanceId) +} + +func (c meshServiceInstanceClient) List(ctx context.Context, filter MeshServiceInstanceFilter) ([]MeshServiceInstance, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(filter)) +} diff --git a/client/tag_definition.go b/client/tag_definition.go new file mode 100644 index 0000000..f298a39 --- /dev/null +++ b/client/tag_definition.go @@ -0,0 +1,104 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +const API_VERSION_TAG_DEFINITION = "v1" + +type MeshTagDefinition struct { + Metadata MeshTagDefinitionMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTagDefinitionSpec `json:"spec" tfsdk:"spec"` +} + +type MeshTagDefinitionMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshTagDefinitionSpec struct { + TargetKind string `json:"targetKind" tfsdk:"target_kind"` + Key string `json:"key" tfsdk:"key"` + ValueType MeshTagDefinitionValueType `json:"valueType" tfsdk:"value_type"` + Description string `json:"description" tfsdk:"description"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + SortOrder int64 `json:"sortOrder" tfsdk:"sort_order"` + Mandatory bool `json:"mandatory" tfsdk:"mandatory"` + Immutable bool `json:"immutable" tfsdk:"immutable"` + Restricted bool `json:"restricted" tfsdk:"restricted"` + ReplicationKey *string `json:"replicationKey,omitempty" tfsdk:"replication_key"` +} + +type MeshTagDefinitionValueType struct { + String *TagValueString `json:"string,omitempty" tfsdk:"string"` + Email *TagValueEmail `json:"email,omitempty" tfsdk:"email"` + Integer *TagValueInteger `json:"integer,omitempty" tfsdk:"integer"` + Number *TagValueNumber `json:"number,omitempty" tfsdk:"number"` + SingleSelect *TagValueSingleSelect `json:"singleSelect,omitempty" tfsdk:"single_select"` + MultiSelect *TagValueMultiSelect `json:"multiSelect,omitempty" tfsdk:"multi_select"` +} + +type TagValueString struct { + DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` + ValidationRegex *string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` +} + +type TagValueEmail struct { + DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` + ValidationRegex *string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` +} + +type TagValueInteger struct { + DefaultValue *int64 `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +type TagValueNumber struct { + DefaultValue *float64 `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +type TagValueSingleSelect struct { + Options []string `json:"options,omitempty" tfsdk:"options"` + DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +type TagValueMultiSelect struct { + Options []string `json:"options,omitempty" tfsdk:"options"` + DefaultValue *[]string `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +type MeshTagDefinitionClient interface { + List(ctx context.Context) ([]MeshTagDefinition, error) + Read(ctx context.Context, name string) (*MeshTagDefinition, error) + Create(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) + Update(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) + Delete(ctx context.Context, name string) error +} + +type meshTagDefinitionClient struct { + meshObject internal.MeshObjectClient[MeshTagDefinition] +} + +func newTagDefinitionClient(ctx context.Context, httpClient internal.HttpClient) MeshTagDefinitionClient { + return meshTagDefinitionClient{internal.NewMeshObjectClient[MeshTagDefinition](ctx, httpClient, "v1")} +} + +func (c meshTagDefinitionClient) List(ctx context.Context) ([]MeshTagDefinition, error) { + return c.meshObject.List(ctx) +} + +func (c meshTagDefinitionClient) Read(ctx context.Context, name string) (*MeshTagDefinition, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshTagDefinitionClient) Create(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + return c.meshObject.Post(ctx, tagDefinition) +} + +func (c meshTagDefinitionClient) Update(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + return c.meshObject.Put(ctx, tagDefinition.Metadata.Name, tagDefinition) +} + +func (c meshTagDefinitionClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/client/tenant_v4.go b/client/tenant_v4.go new file mode 100644 index 0000000..3aa48a9 --- /dev/null +++ b/client/tenant_v4.go @@ -0,0 +1,190 @@ +package client + +import ( + "context" + "fmt" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type TenantLifecycleState string + +var ( + TenantLifecycleStates = enum.Enum[TenantLifecycleState]{} + TenantLifecycleStateActive = TenantLifecycleStates.Entry("ACTIVE") + TenantLifecycleStateMarkedForDeletion = TenantLifecycleStates.Entry("MARKED_FOR_DELETION") + TenantLifecycleStateDeleted = TenantLifecycleStates.Entry("DELETED") +) + +type MeshTenantLifecycle struct { + State enum.Entry[TenantLifecycleState] `json:"state" tfsdk:"-"` + MarkedForDeletion *MeshTenantLifecycleAction `json:"markedForDeletion" tfsdk:"-"` +} + +type MeshTenantLifecycleAction struct { + Timestamp string `json:"timestamp" tfsdk:"-"` +} + +type MeshTenant struct { + Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` + Status MeshTenantStatus `json:"status" tfsdk:"status"` +} + +type MeshTenantMetadata struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshTenantSpec struct { + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` + // RequestedQuotas is the preferred key->value form for requesting quotas at creation, e.g. + // {"limits.cpu": {"value": 4}}. The backend does not return it on read (it is a create-time input), + // so the resource echoes the configured value from state. + RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas" tfsdk:"requested_quotas"` +} + +type MeshTenantStatus struct { + TenantName string `json:"tenantName" tfsdk:"tenant_name"` + PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` + PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` + // AppliedQuotas are the effective quotas meshStack applied to the tenant as a key->value map, each + // value a structured object (e.g. `{"limits.cpu": {"value": 4}}`). spec.requested_quotas carries + // only the values requested at create (create-only); the effective quotas here can differ once + // landing-zone defaults are merged in or an operator adjusts them, so drift is tracked against these. + AppliedQuotas map[string]AppliedQuotaValue `json:"appliedQuotas" tfsdk:"applied_quotas"` + Lifecycle MeshTenantLifecycle `json:"lifecycle" tfsdk:"-"` +} + +// MeshTenantQuota is the {key, value} element of the removed list-form spec.quotas. The schema version 1 +// prior state still declares that attribute, so the state upgrader needs this shape to read it. +type MeshTenantQuota struct { + Key string `json:"key" tfsdk:"key"` + Value int64 `json:"value" tfsdk:"value"` +} + +// RequestQuotaValue is a tenant quota value as requested at create time. The scalar is wrapped in an +// object (rather than a bare number) so the v4 API can grow per-quota fields — e.g. a unit — without a +// breaking change to the requested_quotas map shape. +// +// Its shape is identical to AppliedQuotaValue, deliberately so: the resource must echo the configured +// request in spec while reading effective values from status, and separate types turn mixing the two +// into a compile error rather than the requested-vs-applied conflation this map form fixes. +type RequestQuotaValue struct { + Value int64 `json:"value" tfsdk:"value"` +} + +// AppliedQuotaValue is a tenant quota value as actually applied by the backend. See RequestQuotaValue +// for why the two are not a single type. +type AppliedQuotaValue struct { + Value int64 `json:"value" tfsdk:"value"` +} + +type MeshTenantCreate struct { + Metadata MeshTenantCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantCreateSpec `json:"spec" tfsdk:"spec"` +} + +type MeshTenantCreateMetadata struct { + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshTenantCreateSpec struct { + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` +} + +type MeshTenantQuery struct { + Workspace string `json:"workspaceIdentifier"` + Project *string `json:"projectIdentifier"` + Platform *string `json:"platformIdentifier"` + PlatformType *string `json:"platformTypeIdentifier"` + LandingZone *string `json:"landingZoneIdentifier"` + PlatformTenant *string `json:"platformTenantId"` +} + +type MeshTenantClient interface { + Read(ctx context.Context, uuid string) (*MeshTenant, error) + ReadFunc(uuid string) func(ctx context.Context) (*MeshTenant, error) + List(ctx context.Context, query MeshTenantQuery) ([]MeshTenant, error) + Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) + Delete(ctx context.Context, uuid string) error +} + +type meshTenantClient struct { + meshObject internal.MeshObjectClient[MeshTenant] +} + +func newTenantClient(ctx context.Context, httpClient internal.HttpClient) MeshTenantClient { + return meshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v4")} +} + +func (c meshTenantClient) Read(ctx context.Context, uuid string) (*MeshTenant, error) { + return c.ReadFunc(uuid)(ctx) +} + +func (c meshTenantClient) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenant, error) { + return func(ctx context.Context) (*MeshTenant, error) { + return c.meshObject.Get(ctx, uuid) + } +} + +func (c meshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) { + return c.meshObject.Post(ctx, tenant) +} + +func (c meshTenantClient) List(ctx context.Context, query MeshTenantQuery) ([]MeshTenant, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(query)) +} + +func (c meshTenantClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} + +func (tenant *MeshTenant) CreationSuccessful() (done bool, err error) { + switch { + case tenant == nil: + err = fmt.Errorf("tenant not found after creation") + case tenant.Spec.PlatformTenantId != nil && *tenant.Spec.PlatformTenantId != "": + // Creation is complete (platformTenantId is set and not empty) + done = true + } + return +} + +func (tenant *MeshTenant) DeletionSuccessful() (done bool, err error) { + return tenant == nil || tenant.Status.Lifecycle.State == TenantLifecycleStateDeleted, nil +} + +func (tenant *MeshTenant) DeletionState() string { + if tenant == nil { + return tenantNotObserved + } + return tenantDeletionState(tenant.Status.Lifecycle) +} + +const tenantNotObserved = "no successful read after the delete request" + +func tenantDeletionState(lifecycle MeshTenantLifecycle) string { + switch { + case lifecycle.State == TenantLifecycleStateDeleted: + return "DELETED" + case lifecycle.State == TenantLifecycleStateMarkedForDeletion && lifecycle.MarkedForDeletion != nil: + return fmt.Sprintf( + "MARKED_FOR_DELETION since %s, awaiting deletion approval, cleanup of the tenant's resources, or the platform deletion the replicator confirms", + lifecycle.MarkedForDeletion.Timestamp, + ) + case lifecycle.State == TenantLifecycleStateMarkedForDeletion: + return "MARKED_FOR_DELETION, awaiting deletion approval, cleanup of the tenant's resources, or the platform deletion the replicator confirms" + default: + return fmt.Sprintf("%s, meshStack accepted the delete request but has not acted on it", lifecycle.State) + } +} diff --git a/client/tenant_v4_deletion_test.go b/client/tenant_v4_deletion_test.go new file mode 100644 index 0000000..25e60bc --- /dev/null +++ b/client/tenant_v4_deletion_test.go @@ -0,0 +1,87 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMeshTenant_DeletionSuccessful(t *testing.T) { + tests := []struct { + name string + tenant *MeshTenant + wantDone bool + }{ + { + name: "nil (404 — tenant purged)", + tenant: nil, + wantDone: true, + }, + { + name: "lifecycle DELETED (deletion completed, tenant still returned)", + tenant: &MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateDeleted}, + }}, + wantDone: true, + }, + { + name: "lifecycle MARKED_FOR_DELETION (deletion still running)", + tenant: &MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{ + State: TenantLifecycleStateMarkedForDeletion, + MarkedForDeletion: &MeshTenantLifecycleAction{Timestamp: "2026-07-30T16:14:14Z"}, + }, + }}, + wantDone: false, + }, + { + name: "lifecycle ACTIVE", + tenant: &MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateActive}, + }}, + wantDone: false, + }, + { + name: "no lifecycle reported", + tenant: &MeshTenant{Metadata: MeshTenantMetadata{Uuid: "test-uuid"}}, + wantDone: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := tt.tenant.DeletionSuccessful() + assert.Equal(t, tt.wantDone, done) + assert.NoError(t, err) + }) + } +} + +func TestTenantDeletionState(t *testing.T) { + assert.Equal(t, tenantNotObserved, (*MeshTenant)(nil).DeletionState()) + + assert.Equal(t, "DELETED", + (&MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateDeleted}, + }}).DeletionState(), + ) + assert.Contains(t, + (&MeshTenant{Status: MeshTenantStatus{Lifecycle: MeshTenantLifecycle{ + State: TenantLifecycleStateMarkedForDeletion, + MarkedForDeletion: &MeshTenantLifecycleAction{Timestamp: "2026-07-30T16:14:14Z"}, + }}}).DeletionState(), + "MARKED_FOR_DELETION since 2026-07-30T16:14:14Z", + ) + assert.Contains(t, + (&MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateMarkedForDeletion}, + }}).DeletionState(), + "MARKED_FOR_DELETION, awaiting", + ) + assert.Contains(t, + (&MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateActive}, + }}).DeletionState(), + "has not acted on it", + ) +} diff --git a/client/testdata/building_block_definition_version_input/empty.json b/client/testdata/building_block_definition_version_input/empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/client/testdata/building_block_definition_version_input/empty.json @@ -0,0 +1 @@ +{} diff --git a/client/testdata/building_block_definition_version_input/not_sensitive.json b/client/testdata/building_block_definition_version_input/not_sensitive.json new file mode 100644 index 0000000..4bf4ef4 --- /dev/null +++ b/client/testdata/building_block_definition_version_input/not_sensitive.json @@ -0,0 +1,5 @@ +{ + "isSensitive": false, + "argument": true, + "defaultValue": "some-string" +} diff --git a/client/testdata/building_block_definition_version_input/not_sensitive_but_hash.json b/client/testdata/building_block_definition_version_input/not_sensitive_but_hash.json new file mode 100644 index 0000000..49d2b6d --- /dev/null +++ b/client/testdata/building_block_definition_version_input/not_sensitive_but_hash.json @@ -0,0 +1,6 @@ +{ + "isSensitive": false, + "argument": { + "hash": "some-hash-looks-like-secret" + } +} diff --git a/client/testdata/building_block_definition_version_input/sensitive.json b/client/testdata/building_block_definition_version_input/sensitive.json new file mode 100644 index 0000000..861803d --- /dev/null +++ b/client/testdata/building_block_definition_version_input/sensitive.json @@ -0,0 +1,6 @@ +{ + "isSensitive": true, + "defaultValue": { + "hash": "some-hash" + } +} diff --git a/client/testdata/building_block_definition_version_input/sensitive_but_no_hash.json b/client/testdata/building_block_definition_version_input/sensitive_but_no_hash.json new file mode 100644 index 0000000..c9fc4ed --- /dev/null +++ b/client/testdata/building_block_definition_version_input/sensitive_but_no_hash.json @@ -0,0 +1,4 @@ +{ + "isSensitive": true, + "argument": {} +} diff --git a/client/types/clienttypes.go b/client/types/clienttypes.go new file mode 100644 index 0000000..f2a1737 --- /dev/null +++ b/client/types/clienttypes.go @@ -0,0 +1,42 @@ +package types + +import ( + "reflect" + "strings" + + "github.com/meshcloud/meshstack-cli/client/types/variant" +) + +type ( + Set[T any] []T + + Secret struct { + // Plaintext is optionally set if secret is initially created (or rotated later) + Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` + // Hash is always present in responses (Plaintext is never returned) and set in requests if secret is supposed to be kept. + Hash *string `json:"hash,omitempty" tfsdk:"-"` + } + + SecretOrAny = variant.Variant[Secret, any] + + Any any +) + +// IsSet returns true if the given type uses the generic Set type, ignoring the concrete container type T. +func IsSet(other reflect.Type) bool { + var ( + setType = reflect.TypeFor[Set[any]]() + ) + if other.PkgPath() == setType.PkgPath() { + stripGenerics := func(s string) string { + if startIdx := strings.Index(s, "["); startIdx > 0 { + return s[0 : startIdx-1] + } + return s + } + if stripGenerics(other.Name()) == stripGenerics(setType.Name()) { + return true + } + } + return false +} diff --git a/client/types/clienttypes_test.go b/client/types/clienttypes_test.go new file mode 100644 index 0000000..569bf72 --- /dev/null +++ b/client/types/clienttypes_test.go @@ -0,0 +1,75 @@ +package types + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecretOrAny(t *testing.T) { + type testCase struct { + name string + json string + v SecretOrAny + + wantX, wantY bool + } + tests := []testCase{ + {"empty", `null`, SecretOrAny{}, false, false}, + {"X plaintext", `{"plaintext":"some-secret"}`, SecretOrAny{X: Secret{Plaintext: new("some-secret")}}, true, false}, + {"Y string", `"some-string"`, SecretOrAny{Y: "some-string"}, false, true}, + {"Y bool", `true`, SecretOrAny{Y: true}, false, true}, + {"Y number", `1.23123`, SecretOrAny{Y: 1.23123}, false, true}, + {"Y empty string", `""`, SecretOrAny{Y: ""}, false, true}, + {"Y other struct", `{"A":"aa","B":"bb"}`, SecretOrAny{Y: map[string]any{"A": "aa", "B": "bb"}}, false, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Run("unmarshal", func(t *testing.T) { + var unmarshalled SecretOrAny + require.NoError(t, json.Unmarshal([]byte(tt.json), &unmarshalled)) + assert.Equal(t, tt.v, unmarshalled) + assert.Equal(t, tt.wantX, unmarshalled.HasX()) + assert.Equal(t, tt.wantY, unmarshalled.HasY()) + }) + + t.Run("marshal", func(t *testing.T) { + marshalled, err := json.Marshal(tt.v) + require.NoError(t, err) + assert.Equal(t, tt.json, string(marshalled)) + }) + }) + } +} + +func TestIsSet(t *testing.T) { + type ( + someStruct struct { + A string + } + someString string + someSet Set[someString] + ) + tests := []struct { + name string + t reflect.Type + want bool + }{ + {"bool", reflect.TypeFor[bool](), false}, + {"any", reflect.TypeFor[any](), false}, + {"int", reflect.TypeFor[any](), false}, + {"some set (not supported)", reflect.TypeFor[someSet](), false}, + {"set of string", reflect.TypeFor[Set[string]](), true}, + {"set of int", reflect.TypeFor[Set[string]](), true}, + {"set of struct", reflect.TypeFor[Set[someStruct]](), true}, + {"set of some string", reflect.TypeFor[Set[someString]](), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, IsSet(tt.t), "IsSet(%v)", tt.t) + }) + } +} diff --git a/client/types/enum/enum.go b/client/types/enum/enum.go new file mode 100644 index 0000000..0f07fef --- /dev/null +++ b/client/types/enum/enum.go @@ -0,0 +1,51 @@ +package enum + +import ( + "fmt" + "strings" +) + +func Of[T ~string](entries ...Entry[T]) Enum[T] { + return entries +} + +type Enum[T ~string] []Entry[T] + +func (e *Enum[T]) Entry(v string) (ee Entry[T]) { + ee = Entry[T](v) + *e = append(*e, ee) + return +} + +func (e Enum[T]) to(mapper func(entry Entry[T]) string) (result []string) { + for _, ee := range e { + result = append(result, mapper(ee)) + } + return +} + +func (e Enum[T]) Strings() []string { + return e.to(Entry[T].String) +} + +func (e Enum[T]) Markdown() string { + return strings.Join(e.to(Entry[T].Markdown), ", ") +} + +type Entry[T ~string] string + +func (ee Entry[T]) Ptr() *T { + return new(ee.Unwrap()) +} + +func (ee Entry[T]) Unwrap() T { + return T(ee) +} + +func (ee Entry[T]) String() string { + return string(ee) +} + +func (ee Entry[T]) Markdown() string { + return fmt.Sprintf("`%s`", ee) +} diff --git a/client/types/variant/variant.go b/client/types/variant/variant.go new file mode 100644 index 0000000..a7f3f66 --- /dev/null +++ b/client/types/variant/variant.go @@ -0,0 +1,95 @@ +package variant + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" +) + +// A Variant represents a single JSON map entry having two different Go type representations X and Y. +// After JSON unmarshalling you can check with HasX, HasY which field has been detected, while X is preferred. +// An example usage is a Client DTO response which can either be struct representing a secret hash, +// or a simple string response if that's a non-sensitive value. +type Variant[X, Y any] struct { + X X + Y Y +} + +var ( + _ json.Unmarshaler = (*Variant[int, string])(nil) + _ json.Marshaler = Variant[int, string]{} +) + +func (v Variant[X, Y]) MarshalJSON() ([]byte, error) { + if v.HasX() { + return json.Marshal(v.X) + } else if v.HasY() { + return json.Marshal(v.Y) + } else { + return json.Marshal(nil) + } +} + +func has[T any](xy any) bool { + v := reflect.ValueOf(xy) + kind := reflect.TypeFor[T]().Kind() + if kind != reflect.Interface { + // T is not any (aka as a valid 'zero' representation) + return !v.IsZero() + } else { + // T is any, so we only check for validness + return v.IsValid() + } +} + +func (v Variant[X, Y]) HasX() bool { + return has[X](v.X) +} + +func (v Variant[X, Y]) HasY() bool { + return has[Y](v.Y) +} + +func (v Variant[X, Y]) WithX(action func(x *X)) { + if v.HasX() { + action(&v.X) + } else { + action(nil) + } +} + +func (v Variant[X, Y]) WithY(action func(y *Y)) { + if v.HasY() { + action(&v.Y) + } else { + action(nil) + } +} + +func (v *Variant[X, Y]) UnmarshalJSON(bytes []byte) error { + errX := json.Unmarshal(bytes, &v.X) + errY := json.Unmarshal(bytes, &v.Y) + switch { + case v.HasX() && v.HasY(): + // Explicitly prefer X over Y and set Y to zero even if unmarshalling has also worked, + // this supports having Y with catch-all type 'any' + var zeroY Y + v.Y = zeroY + return errX + case v.HasX(): + return errX + case v.HasY(): + return errY + default: + var nothing any + if err := json.Unmarshal(bytes, ¬hing); err != nil { + return fmt.Errorf("cannot unmarshal to any: %w", err) + } + if nothing == nil { + // support optional unmarshalling aka neither X nor Y is set + return nil + } + return errors.Join(fmt.Errorf("variant[%T, %T]: cannot unmarshal '%s' to any field", v.X, v.Y, string(bytes)), errX, errY) + } +} diff --git a/client/version/version.go b/client/version/version.go new file mode 100644 index 0000000..5a25958 --- /dev/null +++ b/client/version/version.go @@ -0,0 +1,75 @@ +package version + +import ( + "cmp" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +type Version struct { + Major, Minor, Patch int +} + +func Parse(s string) (Version, error) { + parts := strings.Split(s, ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("cannot parse '%s' as version: expected 3, got %d fields separated by '.'", s, len(parts)) + } + var errs []error + partTo := func(i int, target *int) { + parsed, err := strconv.Atoi(parts[i]) + if err == nil && parsed < 0 { + err = fmt.Errorf("negative number '%d' not allowed", parsed) + } + if err != nil { + errs = append(errs, fmt.Errorf("part i=%d: %w", i, err)) + } else { + *target = parsed + } + } + var result Version + partTo(0, &result.Major) + partTo(1, &result.Minor) + partTo(2, &result.Patch) + if len(errs) > 0 { + return Version{}, fmt.Errorf("cannot parse '%s' as version: %w", s, errors.Join(errs...)) + } + return result, nil +} + +func MustParse(s string) Version { + version, err := Parse(s) + if err != nil { + panic(err) + } + return version +} + +func (v Version) Compare(other Version) int { + if major := cmp.Compare(v.Major, other.Major); major != 0 { + return major + } else if minor := cmp.Compare(v.Minor, other.Minor); minor != 0 { + return minor + } + return cmp.Compare(v.Patch, other.Patch) +} + +func (v Version) Less(other Version) bool { + return v.Compare(other) < 0 +} + +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +func (v *Version) UnmarshalJSON(bytes []byte) (err error) { + var s string + if err = json.Unmarshal(bytes, &s); err != nil { + return + } + *v, err = Parse(s) + return +} diff --git a/client/version/version_test.go b/client/version/version_test.go new file mode 100644 index 0000000..aa7911b --- /dev/null +++ b/client/version/version_test.go @@ -0,0 +1,94 @@ +package version + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + assertErrorContainsAllOf := func(contains ...string) assert.ErrorAssertionFunc { + return func(t assert.TestingT, err error, msgAndArgs ...any) bool { + assert.NotEmpty(t, contains) + allOk := true + for _, contain := range contains { + ok := assert.ErrorContains(t, err, contain, msgAndArgs...) + allOk = allOk && ok + } + return allOk + } + } + tests := []struct { + name string + s string + want Version + wantErr assert.ErrorAssertionFunc + }{ + {"valid 1.0.0", "1.0.0", Version{1, 0, 0}, assert.NoError}, + {"valid 1.3.2", "1.3.2", Version{1, 3, 2}, assert.NoError}, + {"not enough parts", "1.1", Version{}, assertErrorContainsAllOf("cannot parse '1.1' as version: expected 3, got 2 fields separated by '.'")}, + {"negative minor", "1.-1.0", Version{}, assertErrorContainsAllOf("cannot parse '1.-1.0' as version: part i=1: negative number '-1' not allowed")}, + {"not a number", "1.1.x", Version{}, assertErrorContainsAllOf(`cannot parse '1.1.x' as version: part i=2: strconv.Atoi: parsing "x": invalid syntax`)}, + {"number too large", "100000000000000000000.1.0", Version{}, assertErrorContainsAllOf(`cannot parse '100000000000000000000.1.0' as version: part i=0: strconv.Atoi: parsing "100000000000000000000": value out of range`)}, + {"multiple errors", "y.x.1", Version{}, assertErrorContainsAllOf(`part i=0: strconv.Atoi: parsing "y": invalid syntax`, `part i=1: strconv.Atoi: parsing "x": invalid syntax`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotV, err := Parse(tt.s) + if !tt.wantErr(t, err, fmt.Sprintf("Parse(%v)", tt.s)) { + return + } + assert.Equalf(t, tt.want, gotV, "Parse(%v)", tt.s) + }) + } +} + +func TestMustParse(t *testing.T) { + assert.NotPanics(t, func() { + MustParse("1.0.0") + }) + assert.Panics(t, func() { + MustParse("1.x.0") + }) +} + +func TestVersion_Compare(t *testing.T) { + tests := []struct { + v, other string + want int + }{ + {"0.0.0", "0.0.0", 0}, + {"0.1.0", "0.1.0", 0}, + {"1.1.0", "0.1.0", 1}, + {"1.1.12312331222", "2.1.0", -1}, + {"1.2.0", "1.3.0", -1}, + {"1.2.1", "1.2.0", 1}, + } + for _, tt := range tests { + symbol := "==" + if tt.want < 0 { + symbol = "<" + } else if tt.want > 0 { + symbol = ">" + } + t.Run(fmt.Sprintf("%s %s %s", tt.v, symbol, tt.other), func(t *testing.T) { + v, err := Parse(tt.v) + require.NoError(t, err) + other, err := Parse(tt.other) + require.NoError(t, err) + cmp := v.Compare(other) + assert.Equal(t, tt.want, cmp) + if cmp < 0 { + assert.True(t, v.Less(other)) + } else { + assert.False(t, v.Less(other)) + } + }) + } +} + +func TestVersion_String(t *testing.T) { + assert.Equal(t, "1.2.3", Version{1, 2, 3}.String()) +} diff --git a/client/workspace.go b/client/workspace.go new file mode 100644 index 0000000..36f9b17 --- /dev/null +++ b/client/workspace.go @@ -0,0 +1,64 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshWorkspace struct { + Metadata MeshWorkspaceMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` +} + +type MeshWorkspaceMetadata struct { + Name string `json:"name" tfsdk:"name"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +type MeshWorkspaceSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + PlatformBuilderAccessEnabled *bool `json:"platformBuilderAccessEnabled,omitempty" tfsdk:"platform_builder_access_enabled"` +} + +type MeshWorkspaceCreate struct { + Metadata MeshWorkspaceCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` +} +type MeshWorkspaceCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +type MeshWorkspaceClient interface { + Read(ctx context.Context, name string) (*MeshWorkspace, error) + Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) + Update(ctx context.Context, name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) + Delete(ctx context.Context, name string) error +} + +type meshWorkspaceClient struct { + meshObject internal.MeshObjectClient[MeshWorkspace] +} + +func newWorkspaceClient(ctx context.Context, httpClient internal.HttpClient) meshWorkspaceClient { + return meshWorkspaceClient{internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2")} +} + +func (c meshWorkspaceClient) Read(ctx context.Context, name string) (*MeshWorkspace, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshWorkspaceClient) Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + return c.meshObject.Post(ctx, workspace) +} + +func (c meshWorkspaceClient) Update(ctx context.Context, name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + return c.meshObject.Put(ctx, name, workspace) +} + +func (c meshWorkspaceClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/client/workspace_binding.go b/client/workspace_binding.go new file mode 100644 index 0000000..30d744e --- /dev/null +++ b/client/workspace_binding.go @@ -0,0 +1,25 @@ +package client + +type MeshWorkspaceBinding struct { + Metadata MeshWorkspaceBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` + ExpiryDate *string `json:"expiryDate,omitempty" tfsdk:"expiry_date"` +} + +type MeshWorkspaceBindingMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshWorkspaceRoleRef struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshWorkspaceTargetRef struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshWorkspaceSubject struct { + Name string `json:"name" tfsdk:"name"` +} diff --git a/client/workspace_group_binding.go b/client/workspace_group_binding.go new file mode 100644 index 0000000..cc56cd3 --- /dev/null +++ b/client/workspace_group_binding.go @@ -0,0 +1,37 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshWorkspaceGroupBinding struct { + MeshWorkspaceBinding +} + +type MeshWorkspaceGroupBindingClient interface { + Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) + Create(ctx context.Context, binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshWorkspaceGroupBindingClient struct { + meshObject internal.MeshObjectClient[MeshWorkspaceGroupBinding] +} + +func newWorkspaceGroupBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshWorkspaceGroupBindingClient { + return meshWorkspaceGroupBindingClient{internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](ctx, httpClient, "v2", "meshworkspacebindings", "groupbindings")} +} + +func (c meshWorkspaceGroupBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshWorkspaceGroupBindingClient) Create(ctx context.Context, binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { + return c.meshObject.Post(ctx, binding) +} + +func (c meshWorkspaceGroupBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/client/workspace_user_binding.go b/client/workspace_user_binding.go new file mode 100644 index 0000000..1b9cdd2 --- /dev/null +++ b/client/workspace_user_binding.go @@ -0,0 +1,37 @@ +package client + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client/internal" +) + +type MeshWorkspaceUserBinding struct { + MeshWorkspaceBinding +} + +type MeshWorkspaceUserBindingClient interface { + Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) + Create(ctx context.Context, binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshWorkspaceUserBindingClient struct { + meshObject internal.MeshObjectClient[MeshWorkspaceUserBinding] +} + +func newWorkspaceUserBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshWorkspaceUserBindingClient { + return meshWorkspaceUserBindingClient{internal.NewMeshObjectClient[MeshWorkspaceUserBinding](ctx, httpClient, "v2", "meshworkspacebindings", "userbindings")} +} + +func (c meshWorkspaceUserBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) { + return c.meshObject.Get(ctx, name) +} + +func (c meshWorkspaceUserBindingClient) Create(ctx context.Context, binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { + return c.meshObject.Post(ctx, binding) +} + +func (c meshWorkspaceUserBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} diff --git a/cmd/meshstack/main.go b/cmd/meshstack/main.go new file mode 100644 index 0000000..943b695 --- /dev/null +++ b/cmd/meshstack/main.go @@ -0,0 +1,49 @@ +// Command meshstack is the command line interface for meshStack. +// +// This package holds the root command. Every other package under cmd/ follows one +// rule: the package name is the subcommand and the file name is the leaf command, +// so cmd/buildingblock/list.go holds `meshstack buildingblock list`. Each of those +// packages exports a New function returning its *cobra.Command, and this package +// wires them in with AddCommand. Registration is explicit rather than done from +// init(), so the whole command tree can be read in one place and a command cannot +// appear in the binary just because its package was imported for another reason. +// +// The directory is named meshstack, not meshstack-cli, because `go build` and +// `go install` name the binary after it. +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +// Version identifies this build. A release overrides it with +// -ldflags "-X main.Version=", and it also identifies the CLI to the meshStack +// API through the client's user agent. +var Version = "dev" + +func main() { + if err := newRootCommand().Execute(); err != nil { + // cobra has already written the error to stderr. + os.Exit(1) + } +} + +func newRootCommand() *cobra.Command { + return &cobra.Command{ + Use: "meshstack", + Short: "Command line interface for meshStack", + // Running `meshstack` on its own prints the help text. RunE also has to be set + // for cobra to render the usage block at all: its help template skips usage + // while the command is neither runnable nor a parent of subcommands. + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + Version: Version, + // A command that fails prints its error, not the whole help text. The user asks + // for help explicitly. + SilenceUsage: true, + } +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f9c1f21 --- /dev/null +++ b/flake.nix @@ -0,0 +1,52 @@ +{ + description = "meshStack CLI"; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { + pkgs = import nixpkgs { inherit system; }; + }); + in + { + devShells = forEachSupportedSystem ({ pkgs }: { + default = pkgs.mkShell { + packages = with pkgs; [ + # go 1.26 (pinned, in lock-step with go.mod — and with the meshStack + # Terraform provider, which consumes this repository's client package) + go_1_26 + + # goimports, godoc, etc. + gotools + + # https://github.com/golangci/golangci-lint + golangci-lint + + # https://taskfile.dev + go-task + + # https://goreleaser.com — task release:check / release:snapshot + goreleaser + ]; + + shellHook = '' + # Explicitly set GOROOT to Nix-installed Go + export GOROOT="${pkgs.go_1_26}/share/go" + + # Isolate Go environment from system + export GOPATH="$PWD/.nix-go" + export GOCACHE="$PWD/.nix-go/cache" + export GOMODCACHE="$PWD/.nix-go/mod" + export GOBIN="$PWD/.nix-go/bin" + export PATH="$GOBIN:$PATH" + + mkdir -p "$GOPATH" "$GOCACHE" "$GOMODCACHE" "$GOBIN" + ''; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cbebf6a --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/meshcloud/meshstack-cli + +go 1.26 // keep flake.nix's pinned Go (go_1_26 + GOROOT) in lock-step when bumping + +require ( + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.12.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3f5d306 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/login/apikey.go b/pkg/login/apikey.go new file mode 100644 index 0000000..1487093 --- /dev/null +++ b/pkg/login/apikey.go @@ -0,0 +1,106 @@ +// Package login resolves meshStack API credentials and turns them into a +// client.Authorization. +// +// It is shared by the meshStack CLI and the meshStack Terraform provider, so both +// read the same environment variables and run the same login exchange. The +// exchange itself is not here: it lives in client/internal, which Go's internal +// rule keeps inside client/, and is reached through client.NewApiKeyAuthorization. +// A second, hand-rolled exchange would get a static token and start returning 401 +// once it expired. +package login + +import ( + "fmt" + "net/url" + "os" + + "github.com/meshcloud/meshstack-cli/client" +) + +// Environment variables holding meshStack API credentials. They are exported so +// that callers can name them in their own error messages — the Terraform provider +// does, because its diagnostics have to mention both the provider attribute and +// the variable — and so that both repositories share one definition. +const ( + EnvKeyEndpoint = "MESHSTACK_ENDPOINT" + EnvKeyApiKey = "MESHSTACK_API_KEY" + EnvKeyApiSecret = "MESHSTACK_API_SECRET" + EnvKeyApiToken = "MESHSTACK_API_TOKEN" +) + +// Credentials addresses one meshStack API. The fields are unvalidated: an empty +// field means "not configured", which lets a caller merge several sources before +// deciding whether anything is missing. +type Credentials struct { + Endpoint string + // ApiKey and ApiSecret are exchanged for an access token at every client + // creation. This is the pair to prefer, because the client refreshes the + // token it receives. + ApiKey string + ApiSecret string + // ApiToken is an access token that is already valid, and skips the exchange. + // It takes precedence over ApiKey and ApiSecret. Nothing can refresh it, so it + // expires during long-running work. + ApiToken string +} + +// FromEnv reads credentials from the environment. Variables that are unset yield +// empty fields rather than an error, so a caller with another source of +// configuration can fill them in. +func FromEnv() Credentials { + return Credentials{ + Endpoint: os.Getenv(EnvKeyEndpoint), + ApiKey: os.Getenv(EnvKeyApiKey), + ApiSecret: os.Getenv(EnvKeyApiSecret), + ApiToken: os.Getenv(EnvKeyApiToken), + } +} + +// Merge returns c with every non-empty field of override applied on top. Callers +// that read credentials from more than one place use this to rank the sources: +// the Terraform provider merges its provider block attributes over FromEnv, so an +// explicitly configured attribute wins over the environment. +func (c Credentials) Merge(override Credentials) Credentials { + if override.Endpoint != "" { + c.Endpoint = override.Endpoint + } + if override.ApiKey != "" { + c.ApiKey = override.ApiKey + } + if override.ApiSecret != "" { + c.ApiSecret = override.ApiSecret + } + if override.ApiToken != "" { + c.ApiToken = override.ApiToken + } + return c +} + +// EndpointURL parses the endpoint into the form client.New expects. +func (c Credentials) EndpointURL() (*url.URL, error) { + if c.Endpoint == "" { + return nil, fmt.Errorf("meshStack endpoint is not configured, set the %s environment variable", EnvKeyEndpoint) + } + endpoint, err := url.Parse(c.Endpoint) + if err != nil { + return nil, fmt.Errorf("meshStack endpoint %q is not a valid URL: %w", c.Endpoint, err) + } + return endpoint, nil +} + +// Authorization builds the authorization to hand to client.New. It reports what is +// missing rather than producing an authorization that fails on first use. +func (c Credentials) Authorization() (client.Authorization, error) { + if c.ApiToken != "" { + return client.NewApiTokenAuthorization(c.ApiToken), nil + } + switch { + case c.ApiKey == "" && c.ApiSecret == "": + return nil, fmt.Errorf("meshStack API credentials are not configured, set the %s and %s environment variables", EnvKeyApiKey, EnvKeyApiSecret) + case c.ApiKey == "": + return nil, fmt.Errorf("meshStack API key is not configured, set the %s environment variable", EnvKeyApiKey) + case c.ApiSecret == "": + return nil, fmt.Errorf("meshStack API secret is not configured, set the %s environment variable", EnvKeyApiSecret) + } + return client.NewApiKeyAuthorization(c.ApiKey, c.ApiSecret), nil +} diff --git a/pkg/login/apikey_test.go b/pkg/login/apikey_test.go new file mode 100644 index 0000000..44ccff6 --- /dev/null +++ b/pkg/login/apikey_test.go @@ -0,0 +1,231 @@ +package login_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/pkg/login" +) + +func TestFromEnv(t *testing.T) { + for name, testCase := range map[string]struct { + environment map[string]string + want login.Credentials + }{ + "all four variables set": { + environment: map[string]string{ + login.EnvKeyEndpoint: "https://api.my.meshstack.io", + login.EnvKeyApiKey: "key", + login.EnvKeyApiSecret: "secret", + login.EnvKeyApiToken: "token", + }, + want: login.Credentials{ + Endpoint: "https://api.my.meshstack.io", + ApiKey: "key", + ApiSecret: "secret", + ApiToken: "token", + }, + }, + "key and secret without a token": { + environment: map[string]string{ + login.EnvKeyEndpoint: "https://api.my.meshstack.io", + login.EnvKeyApiKey: "key", + login.EnvKeyApiSecret: "secret", + }, + want: login.Credentials{ + Endpoint: "https://api.my.meshstack.io", + ApiKey: "key", + ApiSecret: "secret", + }, + }, + "an empty variable reads as unset": { + environment: map[string]string{login.EnvKeyEndpoint: ""}, + want: login.Credentials{}, + }, + "nothing set": { + environment: map[string]string{}, + want: login.Credentials{}, + }, + } { + t.Run(name, func(t *testing.T) { + // Clear all four first, so a variable left over from the developer's own + // shell cannot make a case pass. + for _, key := range []string{login.EnvKeyEndpoint, login.EnvKeyApiKey, login.EnvKeyApiSecret, login.EnvKeyApiToken} { + t.Setenv(key, "") + } + for key, value := range testCase.environment { + t.Setenv(key, value) + } + + assert.Equal(t, testCase.want, login.FromEnv()) + }) + } +} + +func TestMerge(t *testing.T) { + environment := login.Credentials{ + Endpoint: "https://from-env.meshstack.io", + ApiKey: "env-key", + ApiSecret: "env-secret", + ApiToken: "env-token", + } + + for name, testCase := range map[string]struct { + override login.Credentials + want login.Credentials + }{ + "an empty override changes nothing": { + override: login.Credentials{}, + want: environment, + }, + "a non-empty field wins": { + override: login.Credentials{Endpoint: "https://explicit.meshstack.io"}, + want: login.Credentials{ + Endpoint: "https://explicit.meshstack.io", + ApiKey: "env-key", + ApiSecret: "env-secret", + ApiToken: "env-token", + }, + }, + "empty fields of the override do not clear the receiver": { + override: login.Credentials{ApiKey: "explicit-key"}, + want: login.Credentials{ + Endpoint: "https://from-env.meshstack.io", + ApiKey: "explicit-key", + ApiSecret: "env-secret", + ApiToken: "env-token", + }, + }, + "every field overridden at once": { + override: login.Credentials{ + Endpoint: "https://explicit.meshstack.io", + ApiKey: "explicit-key", + ApiSecret: "explicit-secret", + ApiToken: "explicit-token", + }, + want: login.Credentials{ + Endpoint: "https://explicit.meshstack.io", + ApiKey: "explicit-key", + ApiSecret: "explicit-secret", + ApiToken: "explicit-token", + }, + }, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, testCase.want, environment.Merge(testCase.override)) + }) + } +} + +func TestEndpointURL(t *testing.T) { + for name, testCase := range map[string]struct { + endpoint string + want string + wantErr string + }{ + "a valid URL": { + endpoint: "https://api.my.meshstack.io", + want: "https://api.my.meshstack.io", + }, + "a URL with a port and path": { + endpoint: "http://localhost:8080/api", + want: "http://localhost:8080/api", + }, + "an empty endpoint names its variable": { + endpoint: "", + wantErr: login.EnvKeyEndpoint, + }, + "an unparseable endpoint is reported": { + endpoint: "://no-scheme", + wantErr: "not a valid URL", + }, + } { + t.Run(name, func(t *testing.T) { + endpoint, err := login.Credentials{Endpoint: testCase.endpoint}.EndpointURL() + + if testCase.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, testCase.want, endpoint.String()) + }) + } +} + +func TestAuthorization(t *testing.T) { + // Header cannot be called from outside client/: it takes a + // client/internal.HttpClient, which no other package can name. So an + // authorization is identified by comparing it against one built from a single + // credential source. + fromToken, err := login.Credentials{ApiToken: "token"}.Authorization() + require.NoError(t, err) + fromKeySecret, err := login.Credentials{ApiKey: "key", ApiSecret: "secret"}.Authorization() + require.NoError(t, err) + + t.Run("the two credential sources build different authorizations", func(t *testing.T) { + // Guards the comparisons below: if both sources produced the same value, the + // precedence cases would pass for the wrong reason. + assert.NotEqual(t, fromToken, fromKeySecret) + }) + + for name, testCase := range map[string]struct { + credentials login.Credentials + want login.Credentials + }{ + "a token alone": { + credentials: login.Credentials{ApiToken: "token"}, + want: login.Credentials{ApiToken: "token"}, + }, + "a token outranks a key and secret": { + credentials: login.Credentials{ApiKey: "key", ApiSecret: "secret", ApiToken: "token"}, + want: login.Credentials{ApiToken: "token"}, + }, + "a key and secret alone": { + credentials: login.Credentials{ApiKey: "key", ApiSecret: "secret"}, + want: login.Credentials{ApiKey: "key", ApiSecret: "secret"}, + }, + } { + t.Run(name, func(t *testing.T) { + want, err := testCase.want.Authorization() + require.NoError(t, err) + + got, err := testCase.credentials.Authorization() + + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } +} + +func TestAuthorizationNamesTheMissingVariable(t *testing.T) { + for name, testCase := range map[string]struct { + credentials login.Credentials + wants []string + }{ + "nothing configured": { + credentials: login.Credentials{}, + wants: []string{login.EnvKeyApiKey, login.EnvKeyApiSecret}, + }, + "a secret without a key": { + credentials: login.Credentials{ApiSecret: "secret"}, + wants: []string{login.EnvKeyApiKey}, + }, + "a key without a secret": { + credentials: login.Credentials{ApiKey: "key"}, + wants: []string{login.EnvKeyApiSecret}, + }, + } { + t.Run(name, func(t *testing.T) { + _, err := testCase.credentials.Authorization() + + require.Error(t, err) + for _, want := range testCase.wants { + assert.Contains(t, err.Error(), want) + } + }) + } +}