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)
+ }
+ })
+ }
+}