diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..578ccc8
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,15 @@
+{
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Write|Edit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "jq -r '.tool_input.file_path // empty' | { read -r f; [[ \"$f\" == *.go ]] && cd \"$CLAUDE_PROJECT_DIR\" && go tool golangci-lint fmt \"$f\"; } 2>/dev/null || true"
+ }
+ ]
+ }
+ ]
+ }
+}
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/.golangci.yml b/.golangci.yml
index 26b4afd..27084d6 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1 +1,230 @@
+# 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
+ - forbidigo
+ - forcetypeassert
+ - godot
+ - ineffassign
+ - makezero
+ - misspell
+ - nilerr
+ - predeclared
+ - staticcheck
+ - usetesting
+ - unconvert
+ - unparam
+ - unused
+ - govet
+ - testifylint
+ - thelper
+
+ exclusions:
+ rules:
+ # A test that stands up an httptest.Server and points a client at it makes no request
+ # the shipped binary makes, so the rule below has nothing to say about it.
+ - path: _test\.go
+ linters:
+ - forbidigo
+
+ # The one package that may build a client, which is the whole point of the rule.
+ - path: internal/http/
+ linters:
+ - forbidigo
+
+ settings:
+ # One HTTP client for the process, built in internal/http, and nothing else may make an
+ # outbound request of its own. There used to be three: pkg/auth posted to /api/login
+ # through http.DefaultClient, which has no timeout at all; pkg/oidc kept one with a
+ # 30-second one; and the API client built a third with five minutes. Only the last of them
+ # retried, so which policy a request got depended on which package happened to make it.
+ #
+ # It sits at the module root rather than under client/ because Go's internal rule would
+ # otherwise close it to pkg/oidc and pkg/auth, and inside the module's internal tree
+ # because the Terraform provider must not be able to configure it.
+ #
+ # This is forbidigo rather than a depguard deny because depguard bans an import path and
+ # net/http is one package for three unrelated things. Denying it would also deny
+ # http.StatusOK and http.MethodPost, which every caller reading a response needs, and the
+ # http.Server that pkg/oidc/browser runs for the loopback redirect — a server, not a
+ # second client. Naming the identifiers keeps all of that legal.
+ forbidigo:
+ # Match on the type rather than on the written name. It is what makes `pkg` below work,
+ # and it is what this rule needs, because internal/http's own type is also called Client
+ # and is imported under the name http. Without it, every legitimate use would be a
+ # finding and every net/http one behind an alias would not.
+ analyze-types: true
+ forbid:
+ - pattern: \.(Client|DefaultClient|Transport|DefaultTransport|Get|Head|Post|PostForm)$
+ pkg: ^net/http$
+ msg: the process has one HTTP client, built in internal/http; take it from there rather than making another
+
+ # These rules are the dependency policy, not an enforcement of one written down
+ # elsewhere, so widening one is a decision rather than a lint fix. The lists below
+ # are exhaustive by intent: two external dependencies, cobra and charmbracelet/log,
+ # each confined to a smaller area than the module.
+ #
+ # The reason to keep it that tight is outside this repository. The meshStack
+ # Terraform provider imports client/ and pkg/auth, so every dependency reachable
+ # from those packages lands in the provider's dependency tree, and from there in
+ # the public checksum database.
+ depguard:
+ rules:
+ # The package the Terraform provider consumes most directly.
+ 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
+ # The HTTP machinery client/ is built on. It moved out of client/internal so that
+ # pkg/oidc and pkg/auth could reach it too, which is why the meshObject clients
+ # import it from the module root now.
+ - github.com/meshcloud/meshstack-cli/internal/http
+ # The setting mechanism, for MESHSTACK_SKIP_VERSION_CHECK.
+ - github.com/meshcloud/meshstack-cli/internal/setting
+
+ 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
+ - pkg: github.com/meshcloud/meshstack-cli/pkg/oidc/browser
+ desc: only cmd/ and internal/cli may open a browser; the Terraform provider links pkg/auth and must not be able to reach the browser flow
+ allow:
+ - $gostd
+ - github.com/meshcloud/meshstack-cli/client
+ - github.com/meshcloud/meshstack-cli/pkg
+ # The process's one HTTP client. pkg/auth and pkg/oidc each address a different
+ # host — meshStack and the identity provider — and both go through it.
+ - github.com/meshcloud/meshstack-cli/internal/http
+ # The setting mechanism. pkg/setting is the narrower view the Terraform provider
+ # gets: a source it may supply, and a declaration it may read.
+ - github.com/meshcloud/meshstack-cli/internal/setting
+
+ # internal/http is the process's one HTTP client, and it needs nothing but the standard
+ # library to be one. It sits outside client/ because Go's internal rule would otherwise
+ # close it to pkg/, and inside the module's internal tree because the Terraform provider
+ # must not be able to configure it.
+ internal-http:
+ files:
+ - "**/internal/http/*.go"
+ - "!$test"
+ list-mode: strict
+ allow:
+ - $gostd
+
+ # internal/cli implements the CLI's half of auth.Input: flags, stdin, a terminal
+ # prompt and the browser login. Go's internal rule already keeps it out of the
+ # Terraform provider, which is what makes it the one place outside cmd/ that may
+ # reach pkg/oidc/browser. The pkg rule above cannot reach these files — its
+ # files: list names **/pkg/ — so the deny there does not apply to them.
+ internal-cli:
+ files:
+ - "**/internal/cli/*.go"
+ - "!$test"
+ list-mode: strict
+ allow:
+ - $gostd
+ - github.com/meshcloud/meshstack-cli
+
+ # cmd/ builds the command tree, and is the only place cobra is used.
+ cmd:
+ files:
+ - "**/cmd/*.go"
+ - "**/cmd/**/*.go"
+ # Excluding cmd/meshstack is what lets the next rule grant more than this
+ # one does. A file matching two rules has to satisfy both, so overlapping
+ # rules intersect and never widen.
+ - "!**/cmd/meshstack/*.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
+ - log/slog # a longer prefix than the deny above, so it outranks it
+ - github.com/meshcloud/meshstack-cli
+ - github.com/spf13/cobra
+
+ # The one package that configures a logger, and therefore the only one that may
+ # name a logging implementation. Everywhere else logs through log/slog against
+ # the handler installed here.
+ cmd-meshstack:
+ files:
+ - "**/cmd/meshstack/*.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
+ - log/slog
+ - github.com/meshcloud/meshstack-cli
+ - github.com/spf13/cobra
+ - github.com/charmbracelet/log
+
+ # acceptance/ drives the built binary against a live local meshStack, as a
+ # subprocess. It is deny-by-default like everything else, and it needs less than
+ # most: os/exec and net/http out of the standard library, testify, and client/ for
+ # the one struct it shares with the code under test — it decodes /mesh/info into
+ # client.MeshInfo, so the suite fails when that type and the backend disagree.
+ #
+ # It deliberately may not reach pkg/ or internal/. A test that called the CLI's own
+ # resolution to work out where its profile went would pass whenever that resolution
+ # was consistently wrong; asserting on the files and the exit status is the whole
+ # point of running the binary rather than a cobra command in-process.
+ acceptance:
+ files:
+ - "**/acceptance/*.go"
+ list-mode: strict
+ allow:
+ - $gostd
+ - github.com/meshcloud/meshstack-cli/client
+ - github.com/stretchr/testify
+
+ # 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..0119377
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,69 @@
+# 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
+ # The Dockerfile (from its VERSION build arg) and flake.nix set the same ldflag, and
+ # all three have to agree. Nothing fails when one is missing: that binary reports `dev`.
+ 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..0761c51
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,221 @@
+# 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.
+
+A relative path like `../meshfed-release` refers to a **sibling checkout**: meshcloud developers
+clone the `meshcloud` org flat, so every repository in it is a sibling of this one. Write cross-repo
+paths that way rather than bare, so they resolve as written.
+
+
+**This file is loaded into every session, so keep it short.** A rule earns a place here only if it
+has no closer home. Everything else belongs next to what it governs, and this file links to it:
+
+| Belongs in | Rather than here |
+|---|---|
+| `.golangci.yml` | Which dependency may reach which package, and why |
+| `Taskfile.yml` | What a command does, and what surprises it holds — `task --list` prints the list |
+| A doc comment on the code | Why a package, type or command is built the way it is |
+| The file that holds the setting | Why the setting has that value: `flake.nix`, `go.mod`, `.goreleaser.yml`, `Dockerfile` |
+| A skill | A procedure long enough to need its own steps, loaded only when the work starts |
+
+Before adding a section, check whether one of those places already covers it. Before adding a
+*paragraph* of reasoning, move the reasoning to the code or config it explains and leave a link.
+Restating a rule in two places is worse than leaving it in one: the copies drift, and neither one
+looks stale.
+
+
+## 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. |
+| `pkg/setting` | The narrow view of the settings the Terraform provider may read and supply. |
+| `client/` | The meshStack API client. Path-identical to the provider's former `client/`. |
+| `internal/http` | The process's one HTTP client, and the request building both front ends share. |
+| `internal/setting` | The settings mechanism: a declaration, the sources a value comes from, and the resolution. |
+| `internal/cli` | The CLI's settings source over its flags, plus stdin, a terminal prompt and the browser login. |
+| `acceptance/` | The suite that drives the built binary against a live meshStack, run in `meshfed-release`. |
+
+
+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.
+
+Three rules hold the tree together, and `cmd/meshstack/meshstack.go` and `cmd/auth/login.go` carry
+the reasoning for each in their doc comments:
+
+- Register commands **explicitly in `cmd/meshstack`, never from `init()`**.
+- A command with a **top-level shortcut** — `meshstack login` for `meshstack auth login` — is
+ registered twice by calling its constructor twice. `Aliases` cannot do this.
+- A constructor keeps its flag targets in **locals captured by the closure, never package-level
+ vars**, and a **parent command sets `RunE` as well as `Args`**.
+
+
+## Dependency policy
+
+The CLI is allowed **two external dependencies**: cobra, and `charmbracelet/log`. Each is confined to
+a smaller area than the module. Everything else is standard library, with `testify` in tests.
+
+**The policy lives in `.golangci.yml`**, in the comments on the `depguard` rules — which package may
+import what, and why each boundary is where it is. `depguard` does not merely enforce the policy,
+it *is* the policy, so widening a boundary is a deliberate edit rather than 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:
+
+**A pull takes a split, not a branch.** The import was `git subtree split` followed by
+`git subtree add`, so the history this subtree descends from carries the files at the *repository
+root*. Pulling the provider's `main` directly fails with *"refusing to merge unrelated histories"*,
+because there the same files sit under `client/`. Split first, in a checkout of the provider:
+
+```shell
+cd ../terraform-provider-meshstack
+git subtree split --prefix=client -b client-split main
+
+cd ../meshstack-cli
+git subtree pull --prefix=client ../terraform-provider-meshstack client-split
+git subtree push --prefix=client ../terraform-provider-meshstack
+```
+
+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
+```
+
+**`client/` no longer knows how to log in.** `client.Authorization` produces a bearer token and
+replaces one that came back 401, and everything behind it — resolving a credential, minting a token,
+caching it in a profile, refreshing before expiry — is `pkg/auth`. Both front ends build their client
+through `auth.Session.Client`, so the endpoint and the authorization always agree with what was
+resolved. Do **not** add a login exchange here or anywhere else: a second one gets a static token and
+starts returning 401 once it expires, and for a browser login it would end the user's session.
+
+**`client/` no longer owns HTTP either.** The client, the request options and the retry policy are
+`internal/http`, one directory above, because `pkg/oidc` and `pkg/auth` need them and Go's internal
+rule closes `client/internal` to both. Its names carry no `Http` prefix — the package is what says
+that — so it reads `http.Client`, `http.Error`, `http.NewClient`.
+
+**A file that needs both packages imports `net/http` as `gohttp`.** `internal/http` takes the plain
+name, because it is the one a meshStack call goes through; `net/http` is left for the status and
+method constants and for the loopback server. The `forbidigo` rule in `.golangci.yml` matches on the
+type, not on the written name, so it catches `gohttp.Client` and leaves `http.Client` alone.
+
+**`client/` has no logging seam.** It logs through `slog`'s default logger like everything else, so
+there is no `client.SetLogger` any more. `cmd/meshstack` installs a `charmbracelet/log` handler and
+the Terraform provider a `tflog` bridge, each before the first request. Log with the `Context` form —
+`slog.DebugContext` — because the provider's handler reads terraform's logger out of the context, and
+drops a record that arrives without one.
+
+
+## Always-on rules
+
+
+
+- **Self-explanatory code.** Move the fact into a name, a type, a check or a test: the compiler and
+ CI keep those true, while a comment goes stale in silence. A comment stays only when you could not
+ have written it by reading the code, and only when it changes what the reader does — the reason for
+ a decision, a rejected alternative, an external constraint with its source, a link to code this
+ must stay in step with. Judge it by that, not by its length. (*meshcloud-internal*: the
+ `self-explanatory-code` skill in `../meshfed-release/.agents/skills/`.)
+- **Lint and format only via `task lint`**, and **never run `gofmt` or `go vet` separately** — a
+ differently built gofmt enforces different formatting. `Taskfile.yml` and
+ `.github/workflows/test.yml` explain why at the settings that depend on it. A `PostToolUse` hook in
+ `.claude/settings.json` formats every `.go` file an agent writes, so it rarely reaches the gate.
+- **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`. **`task --list` is the list**, and
+`Taskfile.yml` comments the tasks that hold a surprise.
+
+The Go version is pinned in **two** places, `go.mod` and `flake.nix`, which both say so at the pin.
+Keep them in lock-step when bumping, and keep them aligned with the Terraform provider.
+
+`flake.nix` also builds the binary — `nix build .#meshstack` — and exports it as
+`packages..meshstack` and as `overlays.default`, so another flake can put it in a dev shell.
+The comment above that `packages` output shows the two lines a consumer needs.
+
+## Acceptance tests
+
+This repository is a **meshStack satellite**: `acceptance/` drives the built binary against a live
+backend, and a whole meshStack only exists in the *meshcloud-internal* mono repo, so that repository
+runs the suite and there is no acceptance workflow here. `.github/workflows/test-acceptance.yml`
+asks for the run, and `meshstack-satellite.gradle` is everything the run reads from here. The other
+half of the lane belongs to `../meshfed-release` and changes without us: read it there, in
+`.agents/skills/acceptance-testing/satellite-suites.md`, rather than trusting a copy here.
+
+## Authentication
+
+`MESHSTACK_ENDPOINT`, `MESHSTACK_API_KEY` and `MESHSTACK_API_SECRET`, with `MESHSTACK_API_TOKEN` as
+an alternative to the key and secret pair, plus `MESHSTACK_PROFILE`, `MESHSTACK_WORKSPACE`,
+`MESHSTACK_NO_INPUT` and `MESHSTACK_CONFIG_DIR`.
+
+**Each one is declared once, in the domain package it belongs to**, as a `setting.Setting[T]` whose
+`EnvKey` is both the variable name and the setting's identity. `internal/setting` resolves it from
+the sources it is given, and each front end contributes exactly one source over its own flags or
+block attributes. **No front end assembles a sentence out of an imported name**: every message that
+has to mention a variable is produced in the package that owns the declaration. The Taskfile reads a
+git-ignored `.env` for local runs.
+
+`MESHSTACK_SKIP_VERSION_CHECK=true` skips the minimum backend version check in
+`client/mesh_info.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 reaches the binary through an ldflag on `main.Version`, set in **three places that must
+agree**: `.goreleaser.yml`, the `Dockerfile` and `flake.nix`, all of which say so at the ldflag. A
+build without it reports `dev` — 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..a193554
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,32 @@
+# 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.27-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
+# .goreleaser.yml and flake.nix set the same -X main.Version ldflag, and all three have
+# to agree. Nothing fails when one is missing: that binary reports `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/README.md b/README.md
index abde703..a93a76e 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,26 @@
# meshStack CLI
-`meshstack` will be the command line interface for [meshStack](https://www.meshcloud.io/).
+`meshstack` is the command line interface for [meshStack](https://www.meshcloud.io/).
-It is being built. Nothing here does anything useful yet: the binary prints one line and exits, and
-the repository exists so far to carry the build, the linter, the test workflow and the acceptance
-suite that meshStack's own CI runs against a live backend.
+## Install
+
+```shell
+go install github.com/meshcloud/meshstack-cli/cmd/meshstack@latest
+```
## Development
-The Nix dev shell provides Go and `task`:
+The Nix dev shell provides Go, `goreleaser` and `task`. `task lint` builds `golangci-lint` from
+the tool directive in `go.mod`, so the dev shell deliberately does not carry it:
```shell
nix develop
-task build # ./meshstack
-task test # go test ./...
-task lint # golangci-lint run, add -- --fix to apply fixes
+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 both have to be bumped together.
+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
index ac2d536..4c61d3a 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -5,7 +5,7 @@
# `task lint -- --fix` and `task test -- -run TestFoo` work.
version: '3'
-# Git-ignored, and holds the MESHSTACK_* values the acceptance suite reads.
+# Git-ignored, and holds the MESHSTACK_* credentials that AGENTS.md lists.
dotenv: ['.env']
tasks:
@@ -15,20 +15,51 @@ tasks:
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:
+ # The whole gate. This also formats, so there is no separate fmt task: a gofmt built
+ # against another Go release enforces different formatting than this linter does.
desc: Run golangci-lint
cmds:
- # `go tool`, not a binary on PATH: building it from the tool directive in go.mod makes
- # go.mod the one place its version is pinned, and ties it to the same Go release the code
- # is written against.
+ # `go tool`, not a binary on PATH: golangci-lint's formatters use the go/format compiled
+ # into it, so the formatting they enforce comes from the Go that BUILT the linter. Building
+ # it from the tool directive in go.mod ties that to the same Go the code is written against,
+ # and makes go.mod the one place its version is pinned.
- go tool 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:
+ # Check `dist/*/meshstack --version` afterwards: a build that missed the ldflag
+ # reports `dev` rather than failing.
+ 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/acceptance/acceptance_test.go b/acceptance/acceptance_test.go
index 33349e6..c37f59c 100644
--- a/acceptance/acceptance_test.go
+++ b/acceptance/acceptance_test.go
@@ -1,62 +1,224 @@
-// Package acceptance runs against a live local meshStack.
+// Package acceptance drives the built `meshstack` binary against a live local meshStack.
//
-// Two things gate it, and both are deliberate:
+// It is a package of tests and nothing else, because that is what it is testing: everything
+// under cmd/ has unit tests that call a cobra command in-process, and none of those can prove
+// that the binary a user runs logs in, writes files a later invocation reads back, and exits
+// with the right status. So these run it as a subprocess, exactly as a person would.
+//
+// Two things gate them, and both are deliberate:
//
// - MESHSTACK_ACC=1, without which every test skips and says how to run it. It mirrors the
// Terraform provider's TF_ACC, so one habit covers both repositories.
-// - MESHSTACK_ENDPOINT has to name a loopback address. These tests will log in and write
-// objects, and a stray export pointing them at a real meshStack is the accident worth making
+// - MESHSTACK_ENDPOINT has to name a loopback address. These tests log in and write objects,
+// and a stray export pointing them at a real meshStack is the accident worth making
// impossible rather than merely unlikely.
+//
+// Each test gets its own MESHSTACK_CONFIG_DIR under a temporary directory, and the child's
+// environment blanks every other MESHSTACK_* name — the Taskfile
+// loads a developer's .env, and a test that inherited an endpoint or an API key would not be
+// proving that the profile it just wrote is what the next command uses.
package acceptance
import (
+ "encoding/json"
+ "fmt"
"net/http"
"os"
+ "os/exec"
+ "path/filepath"
"strings"
+ "sync"
"testing"
"time"
-)
-const (
- envAcc = "MESHSTACK_ACC"
- envEndpoint = "MESHSTACK_ENDPOINT"
+ "github.com/stretchr/testify/require"
- acceptanceEnabled = "1"
+ "github.com/meshcloud/meshstack-cli/client"
+)
- loopbackHost = "http://localhost"
- loopbackAddressHost = "http://127.0.0.1"
+// The MESHSTACK_* names are literals here for the reason AGENTS.md gives: the CLI exports
+// none of them, because every message that has to name one is produced in the package that
+// consults it. So this suite keeps its own copies of the ones it sets.
+const (
+ envAcc = "MESHSTACK_ACC"
+ envEndpoint = "MESHSTACK_ENDPOINT"
+ envConfigDir = "MESHSTACK_CONFIG_DIR"
+ envSkipVersionCheck = "MESHSTACK_SKIP_VERSION_CHECK"
+ envNoBrowser = "MESHSTACK_NO_BROWSER"
+ envProfile = "MESHSTACK_PROFILE"
+ envWorkspace = "MESHSTACK_WORKSPACE"
+ envApiKey = "MESHSTACK_API_KEY"
+ envApiSecret = "MESHSTACK_API_SECRET"
+ envApiToken = "MESHSTACK_API_TOKEN"
+ loopbackHost = "http://localhost"
+ loopbackAddressHost = "http://127.0.0.1"
+ acceptanceEnabled = "1"
+ skipVersionCheckHint = "true"
)
-// reachabilityTimeout bounds the one request: generous for a loopback GET of a public document,
-// short enough that a stack which is down is reported rather than waited on.
+// reachabilityTimeout bounds the precheck's one request: generous for a loopback GET of a public
+// document, short enough that a stack which is down is reported rather than waited on.
const reachabilityTimeout = 10 * time.Second
-func TestAccMeshInfo(t *testing.T) {
+// meshstack is the binary under test, built once by TestMain.
+var meshstack string
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ if os.Getenv(envAcc) != acceptanceEnabled {
+ // Nothing to build: every test skips on its own, with a message saying how to run it.
+ return m.Run()
+ }
+ dir, err := os.MkdirTemp("", "meshstack-acceptance")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "cannot create a directory for the binary under test:", err)
+ return 1
+ }
+ defer func() { _ = os.RemoveAll(dir) }()
+
+ // -o names the directory, not the binary: it keeps the name `go build ./cmd/meshstack`
+ // gives it, which is the one every message and every invocation in this repository uses.
+ build := exec.Command("go", "build", "-o", filepath.Join(dir, "meshstack"), "./cmd/meshstack")
+ // A test runs in its own package directory, so the module root is one above.
+ build.Dir = ".."
+ build.Stdout, build.Stderr = os.Stdout, os.Stderr
+ if err := build.Run(); err != nil {
+ fmt.Fprintln(os.Stderr, "cannot build the meshstack binary under test:", err)
+ return 1
+ }
+ meshstack = filepath.Join(dir, "meshstack")
+ return m.Run()
+ }())
+}
+
+// requireLocalStack is this suite's precheck, and every test starts with it. It answers all
+// three gating questions at once — is the suite on, is the endpoint loopback, is the backend
+// there — and returns the endpoint with what /mesh/info said, so no test reads the environment
+// itself and none has to run before another to establish that the stack is up.
+func requireLocalStack(t *testing.T) (string, client.MeshInfo) {
+ t.Helper()
if os.Getenv(envAcc) != acceptanceEnabled {
t.Skipf("acceptance tests are off. Bring up a local dev stack and run `%s=%s %s=%s:8080 go test ./acceptance/... -run TestAcc`",
envAcc, acceptanceEnabled, envEndpoint, loopbackHost)
}
-
endpoint := strings.TrimSuffix(os.Getenv(envEndpoint), "/")
- if !strings.HasPrefix(endpoint, loopbackHost) && !strings.HasPrefix(endpoint, loopbackAddressHost) {
- t.Fatalf("%s=%q does not name a loopback address, so this suite refuses to run against it.",
- envEndpoint, os.Getenv(envEndpoint))
- }
+ require.Truef(t,
+ strings.HasPrefix(endpoint, loopbackHost) || strings.HasPrefix(endpoint, loopbackAddressHost),
+ "%s=%q does not name a loopback address. These tests log in and write objects, so they run against a local dev stack and nothing else.",
+ envEndpoint, os.Getenv(envEndpoint))
+ return endpoint, meshInfo(t, endpoint)
+}
+// meshstackCLI is one test's own installation: its own configuration directory, so that two
+// tests never share a profile.
+type meshstackCLI struct {
+ t *testing.T
+ dir string
+}
+
+func newCLI(t *testing.T) *meshstackCLI {
+ t.Helper()
+ return &meshstackCLI{t: t, dir: t.TempDir()}
+}
+
+func (c *meshstackCLI) environ() []string {
+ return append(os.Environ(),
+ envConfigDir+"="+c.dir,
+ // The dev stack reports a version below the client's minimum, which is a statement
+ // about the backend rather than about anything under test here.
+ envSkipVersionCheck+"="+skipVersionCheckHint,
+ // This suite drives keycloak's forms over HTTP, so a browser the CLI launches would
+ // open a real window on whoever's desktop is running the tests, race the login this
+ // test is already completing, and leave a consumed authorization code on screen.
+ envNoBrowser+"=1",
+ // MESHSTACK_NO_INPUT is deliberately not set. It means "nobody is coming", and the
+ // browser login refuses outright rather than waiting when it is — which would defeat
+ // TestAccBrowserLoginHeadless. Nothing here prompts anyway: a prompt needs a terminal
+ // on stdin, and these subprocesses are given none.
+ //
+ // Blanked rather than inherited: see the package comment.
+ envEndpoint+"=",
+ envProfile+"=",
+ envWorkspace+"=",
+ envApiKey+"=",
+ envApiSecret+"=",
+ envApiToken+"=",
+ )
+}
+
+// command builds an invocation with this installation's environment and no stdin at all,
+// because nothing here is a person and the CLI must never wait for one.
+func (c *meshstackCLI) command(args ...string) *exec.Cmd {
+ cmd := exec.CommandContext(c.t.Context(), meshstack, args...)
+ cmd.Env = c.environ()
+ cmd.Stdin = nil
+ return cmd
+}
+
+func (c *meshstackCLI) run(args ...string) (string, error) {
+ c.t.Helper()
+ output, err := c.command(args...).CombinedOutput()
+ c.t.Logf("$ meshstack %s\n%s", strings.Join(args, " "), output)
+ return string(output), err
+}
+
+func (c *meshstackCLI) mustRun(args ...string) string {
+ c.t.Helper()
+ output, err := c.run(args...)
+ require.NoErrorf(c.t, err, "`meshstack %s` failed:\n%s", strings.Join(args, " "), output)
+ return output
+}
+
+// meshInfo reads the endpoint's public document into the very struct the CLI decodes it into,
+// so this suite fails when client.MeshInfo and the backend disagree about it. It is also how the
+// precheck learns the backend is up, which is worth a request of its own: a login discovers that
+// only after the minute internal/http spends retrying, and reports it as a timeout rather than
+// as a stack that is down.
+func meshInfo(t *testing.T, endpoint string) client.MeshInfo {
+ t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, endpoint+"/mesh/info", nil)
- if err != nil {
- t.Fatalf("cannot build the request: %v", err)
- }
+ require.NoError(t, err)
- // Its own client, because the timeout belongs to this request alone and http.DefaultClient is
- // shared with whatever else ends up using it.
+ // Its own client, because the timeout belongs to this request alone: an address that accepts
+ // and never answers has to fail here in seconds, while the browser login waits minutes by
+ // design and http.DefaultClient is shared with whatever else ends up using it.
resp, err := (&http.Client{Timeout: reachabilityTimeout}).Do(req)
- if err != nil {
- t.Fatalf("the backend at %s is not reachable, so nothing in this package can run. Bring the local dev stack up first: %v", endpoint, err)
- }
+ require.NoErrorf(t, err, "the backend at %s is not reachable, so nothing in this package can run. Bring the local dev stack up first.", endpoint)
defer func() { _ = resp.Body.Close() }()
+ require.Equalf(t, http.StatusOK, resp.StatusCode, "%s/mesh/info answered %s", endpoint, resp.Status)
- if resp.StatusCode != http.StatusOK {
- t.Errorf("%s/mesh/info answered %s", endpoint, resp.Status)
+ var info client.MeshInfo
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&info))
+ return info
+}
+
+// requireDevLocalCredentials skips rather than fails while the field is still landing: only
+// meshfed-release's paired feature/scaffold-cli branch serves devLocalCredentials, so a backend
+// built from develop, or a local stack running one, carries nothing to bootstrap from.
+func requireDevLocalCredentials(t *testing.T, endpoint string, info client.MeshInfo) *client.DevLocalCredentials {
+ t.Helper()
+ if info.DevLocalCredentials == nil {
+ t.Skipf("%s serves no devLocalCredentials in /mesh/info, so there is nothing to bootstrap from. This needs the meshfed change that publishes the local dev stack's own credentials there.", endpoint)
}
+ return info.DevLocalCredentials
+}
+
+// syncBuffer collects a subprocess's output while a test reads it. os/exec writes from its own
+// goroutine, so the two need a lock between them.
+type syncBuffer struct {
+ mu sync.Mutex
+ data []byte
+}
+
+func (b *syncBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.data = append(b.data, p...)
+ return len(p), nil
+}
+
+func (b *syncBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return string(b.data)
}
diff --git a/acceptance/login_test.go b/acceptance/login_test.go
new file mode 100644
index 0000000..bec4815
--- /dev/null
+++ b/acceptance/login_test.go
@@ -0,0 +1,190 @@
+package acceptance
+
+import (
+ "html"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAccDevLocalLogin is the .env file's replacement, proved end to end: nothing is exported,
+// nothing is configured, and the endpoint alone is enough to leave the machine able to talk to
+// meshStack.
+func TestAccDevLocalLogin(t *testing.T) {
+ endpoint, info := requireLocalStack(t)
+ dev := requireDevLocalCredentials(t, endpoint, info)
+
+ cli := newCLI(t)
+ cli.mustRun("login", "--dev-local", "--endpoint", endpoint)
+
+ require.FileExists(t, filepath.Join(cli.dir, "config.json"))
+ require.NotEmpty(t, dev.ApiKeys, "the local dev stack published no api keys to bootstrap from")
+
+ for name, key := range dev.ApiKeys {
+ t.Run(name, func(t *testing.T) {
+ // One profile per key, named after the key, so a caller picks the rights it needs
+ // rather than getting whichever key this flag happened to choose.
+ profile := "dev-local-" + name
+ stored, err := os.ReadFile(filepath.Join(cli.dir, "credentials", profile+".json"))
+ require.NoError(t, err)
+ assert.Contains(t, string(stored), key.ClientId)
+ assert.Contains(t, string(stored), key.ClientSecret)
+
+ // The proof itself: these are given nothing but the profile, and work off what the
+ // login wrote. --verify is the one that makes a round trip with the credential.
+ cli.mustRun("auth", "status", "--verify", "--profile", profile)
+ cli.mustRun("workspace", "list", "--profile", profile)
+ })
+ }
+}
+
+// TestAccBrowserLoginHeadless drives the authorization code flow with no browser and no
+// terminal, which is the shape CI has. It works because the CLI prints the authorization URL
+// to stderr and then waits on a loopback listener, so anything that can read stderr and speak
+// HTTP can finish the login — here, keycloak's own forms, posted by an http.Client.
+//
+// ../terraform-provider-meshstack/scratch/headless-login.sh is the shell reference this
+// replicates.
+// Every seeded login gets a subtest, so the ones that differ are covered rather than assumed: a
+// login holding two workspaces, one holding a single workspace, and one holding none, which
+// authenticates and then sees nothing.
+func TestAccBrowserLoginHeadless(t *testing.T) {
+ endpoint, info := requireLocalStack(t)
+ dev := requireDevLocalCredentials(t, endpoint, info)
+ require.NotEmpty(t, dev.Users, "the local dev stack published no seeded logins to log in as")
+
+ for username, user := range dev.Users {
+ t.Run(username, func(t *testing.T) {
+ profileName := "acc-" + strings.NewReplacer("@", "-at-", ".", "-").Replace(username)
+ cli := newCLI(t)
+
+ // Deliberately no --workspace: an unscoped login is what makes the listing below a
+ // discovery test rather than a check that the flag was echoed back.
+ login := cli.command("login", "--profile", profileName, "--endpoint", endpoint)
+ output := &syncBuffer{}
+ login.Stdout, login.Stderr = output, output
+ require.NoError(t, login.Start())
+
+ completeKeycloakLogin(t, awaitAuthorizationURL(t, output, info.Issuer.String()), username, user.Password)
+
+ require.NoErrorf(t, login.Wait(), "the browser login did not finish:\n%s", output.String())
+ assert.Contains(t, output.String(), username, "the login reports who it logged in as")
+
+ // A login is only worth anything if what follows it works.
+ cli.mustRun("auth", "status", "--verify", "--profile", profileName)
+
+ // One is enough. This runs against a stack somebody develops on, where
+ // partner@meshcloud.io may have been added to a workspace by hand, so asserting a
+ // count or a set would report the developer rather than the CLI. A login that holds
+ // no role is only required not to fail.
+ listed := cli.mustRun("workspace", "list", "--profile", profileName)
+ if len(user.Workspaces) > 0 {
+ assert.NotEmptyf(t, strings.Fields(listed),
+ "this login holds a role on %d workspace(s), so discovery should list at least one. It said:\n%s",
+ len(user.Workspaces), listed)
+ }
+ })
+ }
+}
+
+// awaitAuthorizationURL watches the subprocess's output for the URL it wants a person to
+// visit. Polling is what this has to be: the CLI writes the URL and then blocks for ten
+// minutes, so there is no line-oriented end to read up to.
+func awaitAuthorizationURL(t *testing.T, output *syncBuffer, issuer string) string {
+ t.Helper()
+ printed := regexp.MustCompile(regexp.QuoteMeta(issuer) + `/\S+`)
+ deadline := time.Now().Add(time.Minute)
+ for time.Now().Before(deadline) {
+ if found := printed.FindString(output.String()); found != "" {
+ return found
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ t.Fatalf("no authorization URL under %s appeared within a minute. The CLI said:\n%s", issuer, output.String())
+ return ""
+}
+
+// completeKeycloakLogin is the browser's part: fetch the authorization URL, post the forms
+// keycloak answers with, and follow every redirect. The last of those redirects goes to
+// http://127.0.0.1:/callback, and making that request is what hands the CLI its
+// authorization code and ends its wait.
+func completeKeycloakLogin(t *testing.T, authURL, username, password string) {
+ t.Helper()
+ jar, err := cookiejar.New(nil)
+ require.NoError(t, err)
+ // Keycloak carries the authentication session in a cookie, so the jar is not a convenience.
+ browser := &http.Client{Jar: jar, Timeout: 30 * time.Second}
+
+ page := fetch(t, browser, authURL)
+ if strings.Contains(page.body, "kc-form-login") || strings.Contains(page.body, `id="password"`) {
+ page = submitForm(t, browser, page, url.Values{
+ "username": {username},
+ "password": {password},
+ // Posted empty, as keycloak's own form does: leaving the field out picks a
+ // different authenticator.
+ "credentialId": {""},
+ })
+ }
+ // The consent screen appears on a first login for this client and not on later ones, so
+ // this is conditional rather than a step.
+ if strings.Contains(page.body, "login-actions/consent") {
+ page = submitForm(t, browser, page, url.Values{
+ "code": {firstSubmatch(t, page.body, `name="code" value="([^"]*)"`)},
+ "accept": {"Yes"},
+ })
+ }
+ require.NotContainsf(t, page.body, "kc-form-login", "keycloak is still asking for a login, so the credentials were refused:\n%s", page.body)
+}
+
+// htmlPage is one response: what it said, and where it was finally served from — which is what
+// a relative form action has to resolve against after a chain of redirects.
+type htmlPage struct {
+ url *url.URL
+ body string
+}
+
+func fetch(t *testing.T, browser *http.Client, target string) htmlPage {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
+ require.NoError(t, err)
+ return read(t, browser, req)
+}
+
+func submitForm(t *testing.T, browser *http.Client, page htmlPage, values url.Values) htmlPage {
+ t.Helper()
+ action, err := page.url.Parse(html.UnescapeString(firstSubmatch(t, page.body, `action="([^"]*)"`)))
+ require.NoError(t, err)
+
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, action.String(), strings.NewReader(values.Encode()))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return read(t, browser, req)
+}
+
+func read(t *testing.T, browser *http.Client, req *http.Request) htmlPage {
+ t.Helper()
+ resp, err := browser.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ // resp.Request is the last request in the redirect chain, so its URL is the page's own.
+ return htmlPage{url: resp.Request.URL, body: string(body)}
+}
+
+func firstSubmatch(t *testing.T, body, pattern string) string {
+ t.Helper()
+ match := regexp.MustCompile(pattern).FindStringSubmatch(body)
+ require.Lenf(t, match, 2, "nothing matched %s in:\n%s", pattern, body)
+ return match[1]
+}
diff --git a/acceptance/meshinfo_test.go b/acceptance/meshinfo_test.go
new file mode 100644
index 0000000..440ffec
--- /dev/null
+++ b/acceptance/meshinfo_test.go
@@ -0,0 +1,16 @@
+package acceptance
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestAccMeshInfo is the shared precheck's own test: the precheck needs /mesh/info only to
+// answer, and a backend that answers it without naming a version is broken in a way worth
+// failing on by name.
+func TestAccMeshInfo(t *testing.T) {
+ endpoint, info := requireLocalStack(t)
+
+ assert.NotEmptyf(t, info.Version, "%s/mesh/info reported no version", endpoint)
+}
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..dc24c74
--- /dev/null
+++ b/client/building_block_definition.go
@@ -0,0 +1,173 @@
+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"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+type MeshBuildingBlockType string
+
+var (
+ MeshBuildingBlockTypes = enum.Enum[MeshBuildingBlockType]{}
+ MeshBuildingBlockTypeTenantLevel = MeshBuildingBlockTypes.Entry("TENANT_LEVEL")
+ MeshBuildingBlockTypeWorkspaceLevel = MeshBuildingBlockTypes.Entry("WORKSPACE_LEVEL")
+)
+
+type MeshBuildingBlockScheduleMode string
+
+var (
+ MeshBuildingBlockScheduleModes = enum.Enum[MeshBuildingBlockScheduleMode]{}
+ MeshBuildingBlockScheduleModeDisabled = MeshBuildingBlockScheduleModes.Entry("DISABLED")
+ MeshBuildingBlockScheduleModeDriftDetection = MeshBuildingBlockScheduleModes.Entry("DRIFT_DETECTION")
+ MeshBuildingBlockScheduleModeDriftReconciliation = MeshBuildingBlockScheduleModes.Entry("DRIFT_RECONCILIATION")
+)
+
+type MeshBuildingBlockScheduleFrequency string
+
+var (
+ MeshBuildingBlockScheduleFrequencies = enum.Enum[MeshBuildingBlockScheduleFrequency]{}
+ MeshBuildingBlockScheduleFrequencyNone = MeshBuildingBlockScheduleFrequencies.Entry("NONE")
+ MeshBuildingBlockScheduleFrequencyDaily = MeshBuildingBlockScheduleFrequencies.Entry("DAILY")
+ MeshBuildingBlockScheduleFrequencyWeekly = MeshBuildingBlockScheduleFrequencies.Entry("WEEKLY")
+)
+
+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"`
+ DisplayNameTemplate *string `json:"displayNameTemplate,omitempty" tfsdk:"display_name_template"`
+ 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"`
+ ApprovalPolicies MeshBuildingBlockDefinitionApprovalPolicies `json:"approvalPolicies" tfsdk:"approval_policies"`
+ Schedule MeshBuildingBlockDefinitionSchedule `json:"schedule" tfsdk:"schedule"`
+ 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 MeshBuildingBlockDefinitionApprovalPolicies struct {
+ VersionUpgrade bool `json:"versionUpgrade" tfsdk:"version_upgrade"`
+ UserInputChanges bool `json:"userInputChanges" tfsdk:"user_input_changes"`
+ ManualTriggers bool `json:"manualTriggers" tfsdk:"manual_triggers"`
+ BuildingBlockCreation bool `json:"buildingBlockCreation" tfsdk:"building_block_creation"`
+ AnyInputChanges bool `json:"anyInputChanges" tfsdk:"any_input_changes"`
+}
+
+// NothingRequiresApproval reports whether no approval gate is enabled.
+func (a MeshBuildingBlockDefinitionApprovalPolicies) NothingRequiresApproval() bool {
+ return a == MeshBuildingBlockDefinitionApprovalPolicies{}
+}
+
+type MeshBuildingBlockDefinitionSchedule struct {
+ Mode MeshBuildingBlockScheduleMode `json:"mode" tfsdk:"mode"`
+ Frequency MeshBuildingBlockScheduleFrequency `json:"frequency" tfsdk:"frequency"`
+ AutomaticApproval bool `json:"automaticApproval" tfsdk:"automatic_approval"`
+}
+
+// DisabledSchedule is the only schedule meshStack accepts for every implementation.
+func DisabledSchedule() MeshBuildingBlockDefinitionSchedule {
+ return MeshBuildingBlockDefinitionSchedule{
+ Mode: MeshBuildingBlockScheduleModeDisabled.Unwrap(),
+ Frequency: MeshBuildingBlockScheduleFrequencyNone.Unwrap(),
+ }
+}
+
+func (s MeshBuildingBlockDefinitionSchedule) IsDisabled() bool {
+ return s == DisabledSchedule()
+}
+
+// HasNeutralPolicies reports whether the spec asks for no approval gate and no schedule.
+func (s MeshBuildingBlockDefinitionSpec) HasNeutralPolicies() bool {
+ return s.ApprovalPolicies.NothingRequiresApproval() && s.Schedule.IsDisabled()
+}
+
+// WithNeutralPolicies returns a copy of the spec without any required approvals and without a schedule.
+func (s MeshBuildingBlockDefinitionSpec) WithNeutralPolicies() MeshBuildingBlockDefinitionSpec {
+ s.ApprovalPolicies = MeshBuildingBlockDefinitionApprovalPolicies{}
+ s.Schedule = DisabledSchedule()
+ return s
+}
+
+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, http.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..e8bbe81
--- /dev/null
+++ b/client/building_block_definition_version.go
@@ -0,0 +1,269 @@
+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"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+// 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")
+
+ // A definition input declaring this type describes a form of its own, through the accompanying
+ // JsonSchema. That makes it a declaration-side type only, so deliberately not an entry of
+ // MeshBuildingBlockIOTypes: what the form produces is JSON text, which a building block's own inputs
+ // report as CODE.
+ MeshBuildingBlockIOTypeJson = enum.Entry[MeshBuildingBlockIOType]("JSON")
+)
+
+// The types a definition input may declare.
+var MeshBuildingBlockDefinitionInputTypes = MeshBuildingBlockIOTypes.With(MeshBuildingBlockIOTypeJson)
+
+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")
+ MeshBuildingBlockInputAssignmentTypeTag = MeshBuildingBlockInputAssignmentTypes.Entry("TAG")
+)
+
+// MeshBuildingBlockTagInputTarget names the meshObject a tag input reads its tag from. It is the first
+// half of the input's argument, `.`.
+type MeshBuildingBlockTagInputTarget string
+
+var (
+ MeshBuildingBlockTagInputTargets = enum.Enum[MeshBuildingBlockTagInputTarget]{}
+ MeshBuildingBlockTagInputTargetWorkspace = MeshBuildingBlockTagInputTargets.Entry("WORKSPACE")
+ MeshBuildingBlockTagInputTargetProject = MeshBuildingBlockTagInputTargets.Entry("PROJECT")
+ MeshBuildingBlockTagInputTargetPaymentMethod = MeshBuildingBlockTagInputTargets.Entry("PAYMENT_METHOD")
+ MeshBuildingBlockTagInputTargetLandingZone = MeshBuildingBlockTagInputTargets.Entry("LANDING_ZONE")
+)
+
+// TagInputTargetSeparator splits the target from the tag key. Only the first one separates them,
+// because a tag key may contain a dot itself.
+const TagInputTargetSeparator = "."
+
+// TagInputTargetsFor answers which tags a building block of this target type can read. A workspace
+// building block only ever runs in the context of a workspace; a tenant building block additionally
+// sees its project, and that project's payment method and landing zone.
+func TagInputTargetsFor(targetType MeshBuildingBlockType) enum.Enum[MeshBuildingBlockTagInputTarget] {
+ if targetType == MeshBuildingBlockTypeWorkspaceLevel.Unwrap() {
+ return enum.Of(MeshBuildingBlockTagInputTargetWorkspace)
+ }
+ return MeshBuildingBlockTagInputTargets
+}
+
+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"`
+ IsOptional bool `json:"isOptional,omitempty" tfsdk:"is_optional"`
+ 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"`
+ // The form this input is filled in through, as a JSON Schema string. Only for MeshBuildingBlockIOTypeJson.
+ JsonSchema *string `json:"jsonSchema,omitempty" tfsdk:"json_schema"`
+ Condition *string `json:"condition,omitempty" tfsdk:"condition"`
+ // 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, http.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..f57c66e
--- /dev/null
+++ b/client/building_block_run.go
@@ -0,0 +1,54 @@
+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 c.meshObject.GetAtPath[MeshBuildingBlockRunLogs](ctx, runUuid, "logs")
+}
diff --git a/client/building_block_runner.go b/client/building_block_runner.go
new file mode 100644
index 0000000..f0053c2
--- /dev/null
+++ b/client/building_block_runner.go
@@ -0,0 +1,98 @@
+package client
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+)
+
+type MeshBuildingBlockRunnerImplementationType string
+
+// TODO Turn this into enum!
+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..eceeb2d
--- /dev/null
+++ b/client/building_block_v2.go
@@ -0,0 +1,398 @@
+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"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+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, http.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 {
+ if purge {
+ return c.meshObject.DeleteAtPath(ctx, uuid, "purge")
+ }
+ return c.meshObject.Delete(ctx, uuid)
+}
+
+// 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) (err error) {
+ _, err = c.meshObject.PostAtPath[any](ctx, nil, bbUuid, "trigger-run")
+ return
+}
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..3058e83
--- /dev/null
+++ b/client/client.go
@@ -0,0 +1,97 @@
+package client
+
+import (
+ "context"
+ "net/url"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/client/version"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+var MinMeshStackVersion = version.MustParse("2026.36.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 = http.Error
+
+type Client struct {
+ // Endpoint is the meshStack this client was built against. It is the one thing here that is
+ // not a sub-client, and it is here because nothing else keeps it: pkg/auth resolves it from a
+ // block, the environment or a profile, and the sub-clients below only carry the API URLs they
+ // derived from it. The Terraform provider renders it as meshstack_instance.endpoint.
+ Endpoint string
+ 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
+}
+
+// NewMeshInfoClient is a little adapter for pkg/oidc to build the oidc.Client after discovering OIDC config from meshstack instance.
+func NewMeshInfoClient(ctx context.Context, rootUrl *url.URL, httpClient http.Client) MeshInfoClient {
+ return newMeshInfoClient(internal.HttpClient{RootUrl: rootUrl, Client: httpClient})
+}
+
+// Authorization produces the (cached) bearer token for each request (and keeps it refreshed transparently).
+type Authorization = http.Authorization
+
+// NewApiTokenAuthorization carries a token somebody else obtained. Nothing refreshes it, so it
+// might expire during long-running work.
+func NewApiTokenAuthorization(apiToken string) Authorization {
+ return http.BearerTokenAuthorization{Token: apiToken}
+}
+
+func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authorization) (Client, error) {
+ httpClient := internal.HttpClient{RootUrl: rootUrl, Client: http.NewClient(userAgent, auth)}
+
+ infoClient := newMeshInfoClient(httpClient)
+ if err := infoClient.checkMeshVersion(ctx); err != nil {
+ return Client{}, err
+ }
+
+ return Client{
+ Endpoint: rootUrl.String(),
+ 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: infoClient,
+ 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
+}
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_test.go b/client/client_test.go
new file mode 100644
index 0000000..034677e
--- /dev/null
+++ b/client/client_test.go
@@ -0,0 +1,49 @@
+package client
+
+import (
+ "errors"
+ gohttp "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+type erroringRoundTripper struct{ calls int }
+
+func (rt *erroringRoundTripper) RoundTrip(*gohttp.Request) (*gohttp.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)
+ // A client of its own rather than http.NewClient: that one hands out the process-wide
+ // shared client, and replacing its transport would take the retries away from every
+ // other test in this binary.
+ return internal.HttpClient{
+ Client: http.Client{Client: &gohttp.Client{Transport: transport}, UserAgent: "test-agent"},
+ RootUrl: &url.URL{Scheme: "https", Host: "meshstack.invalid"},
+ }, 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, newMeshInfoClient(httpClient).checkMeshVersion(t.Context()))
+ 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 := newMeshInfoClient(httpClient).checkMeshVersion(t.Context())
+ require.ErrorContains(t, err, "https://meshstack.invalid/mesh/info")
+ 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..adcad72
--- /dev/null
+++ b/client/integration_config.go
@@ -0,0 +1,94 @@
+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"`
+ IdpAlias *string `json:"idpAlias,omitempty" tfsdk:"idp_alias"`
+ // meshStack derives this and returns it inside spec, which configuration writes. A computed value
+ // there is unreachable under provider mocks (issue #272), so Terraform reads it from status instead.
+ RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"-"`
+}
+
+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/mesh_object.go b/client/internal/mesh_object.go
new file mode 100644
index 0000000..2a87c8c
--- /dev/null
+++ b/client/internal/mesh_object.go
@@ -0,0 +1,185 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "reflect"
+ "regexp"
+ "slices"
+ "strings"
+ "unicode"
+
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+type HttpClient struct {
+ http.Client
+ // RootUrl allows convenient passing of the endpoint URL to the typed meshObject clients shared by all Public API calls.
+ // See NewMeshObjectClient.
+ RootUrl *url.URL
+}
+
+// MeshObjectClient provides typed CRUD operations for meshStack API objects.
+// It embeds [http.Client] and adds meshObject-specific functionality including automatic
+// MIME type handling and pagination.
+// Authorization comes with the embedded client, which carries the Authorization pkg/auth
+// resolved, so every request here is made as whoever the command was configured to be.
+type MeshObjectClient[M any] struct {
+ http.Client
+ 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.
+// The API URL is constructed from explicitApiPathElems if provided,
+// otherwise the pluralized and lowercased kind is used as a single element,
+// which follows conventions only broken by workspace/project user/group bindings API.
+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...)
+ slog.InfoContext(ctx, fmt.Sprintf("initialized %s client", reflect.TypeFor[M]().Name()), "url", apiUrl.String(), "kind", kind, "version", apiVersion)
+ return MeshObjectClient[M]{httpClient.Client, 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 = c.GetAtPath[*M](ctx, id)
+ if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsNotFound() {
+ return nil, nil
+ }
+ return
+}
+
+func (c MeshObjectClient[M]) GetAtPath[R any](ctx context.Context, id string, extraPath ...string) (R, error) {
+ return c.DoAuthorizedRequest[R](ctx, http.MethodGet, c.ApiUrl.JoinPath(id).JoinPath(extraPath...), http.WithAccept(c.MeshObjectMimeType()))
+}
+
+// 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) (*M, error) {
+ return c.PostAtPath[*M](ctx, payload)
+}
+
+// PostAtPath posts to a sub-path of the meshObject, and sends no body at all for a nil payload:
+// trigger-run is what needs that, and a body would make the backend read it as a dry run.
+func (c MeshObjectClient[M]) PostAtPath[R any](ctx context.Context, payload any, extraPath ...string) (R, error) {
+ options := []http.RequestOption{http.WithAccept(c.MeshObjectMimeType())}
+ if payload != nil {
+ options = append(options, c.withMeshObjectPayload(payload))
+ }
+ return c.DoAuthorizedRequest[R](ctx, http.MethodPost, c.ApiUrl.JoinPath(extraPath...), options...)
+}
+
+// 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 c.DoAuthorizedRequest[*M](ctx, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload), http.Retryable())
+}
+
+// withMeshObjectPayload returns http.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) http.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 http.WithJsonPayload(m, c.MeshObjectMimeType())
+}
+
+// Delete removes a meshObject by ID.
+func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) {
+ return c.DeleteAtPath(ctx, id)
+}
+
+func (c MeshObjectClient[M]) DeleteAtPath(ctx context.Context, id string, extraPath ...string) (err error) {
+ _, err = c.DoAuthorizedRequest[any](ctx, http.MethodDelete, c.ApiUrl.JoinPath(id).JoinPath(extraPath...), http.Retryable(), http.WithAccept(c.MeshObjectMimeType()))
+ return
+}
+
+// List retrieves all meshObjects with automatic pagination handling.
+// Accepts optional [http.RequestOption] parameters for filtering and querying.
+func (c MeshObjectClient[M]) List(ctx context.Context, options ...http.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 := c.DoAuthorizedRequest[paginatedResponse](ctx, http.MethodGet, c.ApiUrl, append(options,
+ http.WithAccept(c.MeshObjectMimeType()),
+ http.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/landingzone.go b/client/landingzone.go
new file mode 100644
index 0000000..e49f965
--- /dev/null
+++ b/client/landingzone.go
@@ -0,0 +1,110 @@
+package client
+
+import (
+ "context"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+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, http.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..6e2bb4e
--- /dev/null
+++ b/client/mesh_info.go
@@ -0,0 +1,119 @@
+package client
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/client/types/enum"
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/client/version"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+)
+
+// MeshFeatureFlag names an optional meshStack capability. /mesh/info reports each one as a
+// boolean of its own, so a consumer that wants a list rather than a set of booleans — the
+// Terraform provider's meshstack_instance data source does — maps them onto these names itself.
+type MeshFeatureFlag string
+
+var (
+ MeshFeatureFlags = enum.Enum[MeshFeatureFlag]{}
+ MeshFeatureFlagFourEyesRoleApproval = MeshFeatureFlags.Entry("four_eyes_role_approval")
+)
+
+// MeshInfo is the public, unauthenticated /mesh/info document, as the endpoint returns it. It
+// describes the meshStack instance the client is configured against.
+type MeshInfo struct {
+ Version string `json:"version" tfsdk:"version"`
+ // Is4EPEnabled means "Is four-eyes principle enabled"
+ Is4EPEnabled bool `json:"is4EPEnabled" tfsdk:"-"`
+ Metadata map[string]string `json:"metadata" tfsdk:"metadata"`
+ AdminWorkspaceIdentifier string `json:"adminWorkspaceIdentifier" tfsdk:"admin_workspace_identifier"`
+ Issuer xurl.URL `json:"issuer" tfsdk:"-"`
+ CliClientId string `json:"cliClientId" tfsdk:"-"`
+ // DevLocalCredentials is nil against every meshStack a user could reach. meshfed serves it
+ // only under the `default` spring profile and only when the endpoints it is configured with
+ // are loopback addresses, so it is present exactly on a developer's own stack — where the
+ // values it carries are the seed data in meshfed's repository rather than secrets.
+ //
+ // It exists so that a tool can bootstrap itself against that stack from the endpoint alone:
+ // `meshstack login --dev-local` reads it instead of an .env file somebody has to maintain.
+ DevLocalCredentials *DevLocalCredentials `json:"devLocalCredentials,omitempty" tfsdk:"-"`
+}
+
+// DevLocalCredentials is the local dev stack's own credentials: every API key it is configured
+// with, and every keycloak login seeded into its realm. Both maps are keyed the way that stack
+// names them, so a consumer picks by name rather than by position.
+type DevLocalCredentials struct {
+ // Keyed by the key's configured name. Which one to use is the caller's choice, and they do
+ // not hold the same rights: the one the Terraform suite runs as holds ADM_, while the one a
+ // runner uses reaches building block runs alone.
+ ApiKeys map[string]DevLocalApiKey `json:"apiKeys"`
+ // Keyed by username.
+ Users map[string]DevLocalUser `json:"users"`
+}
+
+type DevLocalApiKey struct {
+ ClientId string `json:"clientId"`
+ ClientSecret string `json:"clientSecret"`
+}
+
+// DevLocalUser is one seeded login. Workspaces is keyed by workspace identifier and holds the role
+// the login has there; it is empty for a login that holds none, which is a real case rather than a
+// broken entry — such a login authenticates and then sees nothing.
+//
+// Nothing configures a client from Workspaces: a seeded login discovers what it can reach exactly
+// as any other user does. It is here so that a test knows which logins are supposed to see
+// something, and can hold discovery to that without hardcoding another repository's seed data.
+//
+// The identifiers are plain strings because client/ may not import pkg/meshstack — .golangci.yml's
+// depguard rule for this package allows the standard library, client/ itself, internal/http and
+// internal/setting, and nothing else.
+type DevLocalUser struct {
+ Password string `json:"password"`
+ Workspaces map[string]string `json:"workspaces"`
+}
+
+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) {
+ return c.httpClient.DoRequest[MeshInfo](ctx, "GET", c.httpClient.RootUrl.JoinPath("/mesh/info"), http.WithAccept("application/json"))
+}
+
+func (c meshInfoClient) checkMeshVersion(ctx context.Context) 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 minute
+ // internal/http spends retrying, and then fails it. Opting out of the check has to opt out of
+ // that too.
+ skipVersionCheckSetting := setting.Setting[bool]{Env: "MESHSTACK_SKIP_VERSION_CHECK", Parse: setting.ParseBool}
+ if skipVersionCheck, _, err := setting.Resolve(skipVersionCheckSetting); err != nil {
+ return err
+ } else if skipVersionCheck {
+ return nil
+ }
+
+ info, err := c.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/mesh_info_test.go b/client/mesh_info_test.go
new file mode 100644
index 0000000..6ba3cad
--- /dev/null
+++ b/client/mesh_info_test.go
@@ -0,0 +1,76 @@
+package client
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// The devLocalCredentials field is absent from every /mesh/info a user could reach, so the
+// case that matters most is the one where nothing is there: the pointer stays nil and
+// nothing else in the document changes meaning.
+func TestMeshInfoDevLocalCredentials(t *testing.T) {
+ tests := []struct {
+ name string
+ document string
+ want *DevLocalCredentials
+ }{
+ {
+ name: "an ordinary /mesh/info carries none",
+ document: `{"version":"2026.34.0","issuer":"https://login.example.com/auth/realms/meshfed","cliClientId":"meshstack-cli","adminWorkspaceIdentifier":"my-partner"}`,
+ },
+ {
+ name: "an explicit null carries none either",
+ document: `{"version":"2026.34.0","issuer":"https://login.example.com/auth/realms/meshfed","devLocalCredentials":null}`,
+ },
+ {
+ name: "a local dev stack carries every api key and every seeded login",
+ document: `{"version":"2026.34.0","issuer":"http://localhost:5050/auth/realms/meshfed","cliClientId":"meshstack-cli","devLocalCredentials":{"apiKeys":{"terraform-provider-acceptance":{"clientId":"37abbe45-aba7-4617-b87d-93f4cbf95832","clientSecret":"eUp1jPMfM2RyNOjdVRuLmHGOYCvzZrN5"},"meshcloud-hosted-runner":{"clientId":"00000000-0000-0000-0000-000000000001","clientSecret":"eUp1jPMfM2RyNOjdVRuLmHGOYCvzZrN5"}},"users":{"partner@meshcloud.io":{"password":"sample123","workspaces":{"demo-partner":"Organization Admin","managed-customer":"Workspace Manager"}},"customer-e@meshcloud.io":{"password":"sample123","workspaces":{}}}}}`,
+ want: &DevLocalCredentials{
+ ApiKeys: map[string]DevLocalApiKey{
+ "terraform-provider-acceptance": {
+ ClientId: "37abbe45-aba7-4617-b87d-93f4cbf95832",
+ ClientSecret: "eUp1jPMfM2RyNOjdVRuLmHGOYCvzZrN5",
+ },
+ "meshcloud-hosted-runner": {
+ ClientId: "00000000-0000-0000-0000-000000000001",
+ ClientSecret: "eUp1jPMfM2RyNOjdVRuLmHGOYCvzZrN5",
+ },
+ },
+ Users: map[string]DevLocalUser{
+ "partner@meshcloud.io": {
+ Password: "sample123",
+ Workspaces: map[string]string{"demo-partner": "Organization Admin", "managed-customer": "Workspace Manager"},
+ },
+ // No workspace attribute in keycloak, so a browser login as this one
+ // authenticates and then sees nothing. Empty, not absent.
+ "customer-e@meshcloud.io": {Password: "sample123", Workspaces: map[string]string{}},
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var info MeshInfo
+ require.NoError(t, json.Unmarshal([]byte(tt.document), &info))
+ assert.Equal(t, tt.want, info.DevLocalCredentials)
+ assert.Equal(t, "2026.34.0", info.Version, "the new field changes nothing about the rest of the document")
+
+ // Round-trip: what this client encodes decodes back to the same thing, and
+ // omitempty keeps an absent field absent rather than turning it into a null.
+ encoded, err := json.Marshal(info)
+ require.NoError(t, err)
+ var fields map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(encoded, &fields))
+ _, present := fields["devLocalCredentials"]
+ assert.Equal(t, tt.want != nil, present)
+
+ var again MeshInfo
+ require.NoError(t, json.Unmarshal(encoded, &again))
+ assert.Equal(t, info, again)
+ })
+ }
+}
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..3fe086a
--- /dev/null
+++ b/client/platform.go
@@ -0,0 +1,129 @@
+package client
+
+import (
+ "context"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/client/types"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+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, http.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..11b121c
--- /dev/null
+++ b/client/platform_type.go
@@ -0,0 +1,89 @@
+package client
+
+import (
+ "context"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+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, http.WithUrlQuery(meshPlatformTypeListQuery{
+ Category: category,
+ LifecycleStatus: lifecycleStatus,
+ }))
+}
diff --git a/client/project.go b/client/project.go
new file mode 100644
index 0000000..898d433
--- /dev/null
+++ b/client/project.go
@@ -0,0 +1,85 @@
+package client
+
+import (
+ "context"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+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, http.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..3b11141
--- /dev/null
+++ b/client/service_instance.go
@@ -0,0 +1,58 @@
+package client
+
+import (
+ "context"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/client/types"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+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, http.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..73028e8
--- /dev/null
+++ b/client/tenant_v4.go
@@ -0,0 +1,191 @@
+package client
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/meshcloud/meshstack-cli/client/internal"
+ "github.com/meshcloud/meshstack-cli/client/types/enum"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+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, http.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..fa0b8f6
--- /dev/null
+++ b/client/types/enum/enum.go
@@ -0,0 +1,57 @@
+package enum
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+)
+
+func Of[T ~string](entries ...Entry[T]) Enum[T] {
+ return entries
+}
+
+type Enum[T ~string] []Entry[T]
+
+// With returns a copy of the enum extended by entries, leaving the receiver untouched.
+func (e Enum[T]) With(entries ...Entry[T]) Enum[T] {
+ return slices.Concat(e, entries)
+}
+
+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/types/xurl/url.go b/client/types/xurl/url.go
new file mode 100644
index 0000000..36b894c
--- /dev/null
+++ b/client/types/xurl/url.go
@@ -0,0 +1,55 @@
+package xurl
+
+import (
+ "encoding"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+var (
+ _ encoding.TextUnmarshaler = &URL{}
+ _ encoding.TextMarshaler = URL{}
+)
+
+type URL struct {
+ *url.URL
+}
+
+// UnmarshalText lowers the host, so that the parsed and the stored form are canonical.
+// ParseRequestURI already lowers the scheme; a path stays as it is, being case-sensitive.
+func (u *URL) UnmarshalText(text []byte) (err error) {
+ u.URL, err = url.ParseRequestURI(string(text))
+ if err != nil {
+ return
+ }
+ if !u.IsAbs() {
+ return fmt.Errorf("unmarshaled URL '%s' is not absolute", u)
+ }
+ u.Host = strings.ToLower(u.Host)
+ return
+}
+
+// Equal compares both URLs whole, path included: an endpoint is a root URL, and several
+// meshStacks can sit on one host under different paths.
+func (u URL) Equal(other URL) bool {
+ if u.URL == nil || other.URL == nil {
+ return u.URL == other.URL
+ }
+ return u.String() == other.String()
+}
+
+func (u URL) MarshalText() ([]byte, error) {
+ if u.URL == nil {
+ return nil, errors.New("a zero URL cannot be marshaled; declare an optional URL field as *URL")
+ }
+ return []byte(u.String()), nil
+}
+
+func MustParsef(format string, args ...any) (result URL) {
+ if err := result.UnmarshalText([]byte(fmt.Sprintf(format, args...))); err != nil {
+ panic(err.Error())
+ }
+ return
+}
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..2eb60ed
--- /dev/null
+++ b/client/workspace.go
@@ -0,0 +1,71 @@
+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 {
+ // List returns every workspace the credential can see. An unscoped user token reaches
+ // this and almost nothing else, which is what `meshstack auth login` prompts from.
+ List(ctx context.Context) ([]MeshWorkspace, error)
+ 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) List(ctx context.Context) ([]MeshWorkspace, error) {
+ return c.meshObject.List(ctx)
+}
+
+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/auth/auth.go b/cmd/auth/auth.go
new file mode 100644
index 0000000..05d78b8
--- /dev/null
+++ b/cmd/auth/auth.go
@@ -0,0 +1,54 @@
+// Package auth holds `meshstack auth` and its leaves. It is also where the two render
+// helpers below live, because every table this package prints has the same shape and a
+// helper file would not be a leaf command.
+package auth
+
+import (
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+)
+
+func New(in *cli.Input) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "auth",
+ Short: "Manage authentication with meshStack",
+ // Both are needed together. Cobra returns flag.ErrHelp for a command that is
+ // not runnable, before it reaches ValidateArgs, so dropping RunE would make
+ // `meshstack auth bogus` print help and exit 0.
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ }
+
+ cmd.AddCommand(NewLogin(in))
+ cmd.AddCommand(newStatus(in))
+ cmd.AddCommand(newLogout(in))
+
+ return cmd
+}
+
+// row prints one label, its value, and an optional parenthetical saying where the value
+// came from. Fixed columns rather than a tabwriter: every table here has the same two
+// columns, and a column that never moves stays greppable from a script.
+func row(out io.Writer, label, value, detail string) {
+ if detail == "" {
+ _, _ = fmt.Fprintf(out, "%-9s %s\n", label, value)
+ return
+ }
+ _, _ = fmt.Fprintf(out, "%-9s %-28s (%s)\n", label, value, detail)
+}
+
+// humanDuration renders a deadline the way a person reads one, and says "ago" rather than
+// printing a negative number.
+func humanDuration(d time.Duration) string {
+ if d < 0 {
+ return d.Abs().Round(time.Second).String() + " ago"
+ }
+ return "in " + d.Round(time.Second).String()
+}
diff --git a/cmd/auth/devlocal_test.go b/cmd/auth/devlocal_test.go
new file mode 100644
index 0000000..233b8ab
--- /dev/null
+++ b/cmd/auth/devlocal_test.go
@@ -0,0 +1,194 @@
+package auth
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/pkg/auth"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+const (
+ devApiKeyName = "terraform-provider-acceptance"
+ devApiKeyClientId = "37abbe45-aba7-4617-b87d-93f4cbf95832"
+ devApiKeyClientSecret = "eUp1jPMfM2RyNOjdVRuLmHGOYCvzZrN5"
+ devAdminWorkspace = "demo-partner"
+)
+
+// --dev-local bootstraps a profile out of a document nobody configured, so the assertions are
+// about what reached disk: the profile in config.json, and the credential the CLI can use next.
+func TestDevLocalLogin(t *testing.T) {
+ dir := isolateAt(t)
+ stack := devLocalStack(t, true)
+
+ cmd := loginWithRootFlags(cli.New())
+ require.NoError(t, run(cmd, "--dev-local", "--endpoint", stack))
+
+ config, err := os.ReadFile(filepath.Join(dir, "config.json"))
+ require.NoError(t, err)
+
+ t.Run("one profile per published api key, named after the key", func(t *testing.T) {
+ assert.Contains(t, string(config), string(auth.DevLocalProfile)+"-"+devApiKeyName)
+ assert.Contains(t, string(config), stack)
+
+ credentials, err := os.ReadFile(
+ filepath.Join(dir, "credentials", string(auth.DevLocalProfile)+"-"+devApiKeyName+".json"))
+ require.NoError(t, err)
+ assert.Contains(t, string(credentials), devApiKeyClientId)
+ assert.Contains(t, string(credentials), devApiKeyClientSecret)
+ assert.Contains(t, string(credentials), `"current": "apiKey"`)
+ })
+
+ t.Run("one profile per seeded login, with the address made readable", func(t *testing.T) {
+ assert.Contains(t, string(config), string(auth.DevLocalProfile)+"-partner-at-meshcloud-io")
+ assert.Contains(t, string(config), string(auth.DevLocalProfile)+"-customer-e-at-meshcloud-io")
+ })
+
+ t.Run("a login profile carries no default workspace, so it discovers like any other user", func(t *testing.T) {
+ assert.NotContains(t, string(config), devAdminWorkspace)
+ })
+
+ t.Run("a login profile carries no credential, because the browser exchange still has to happen", func(t *testing.T) {
+ _, err := os.Stat(filepath.Join(dir, "credentials", string(auth.DevLocalProfile)+"-partner-at-meshcloud-io.json"))
+ assert.ErrorIs(t, err, os.ErrNotExist)
+ })
+}
+
+func TestDevLocalProfileName(t *testing.T) {
+ tests := map[string]profile.Name{
+ "terraform-provider-acceptance": "dev-local-terraform-provider-acceptance",
+ "partner@meshcloud.io": "dev-local-partner-at-meshcloud-io",
+ "customer-e@meshcloud.io": "dev-local-customer-e-at-meshcloud-io",
+ "Mixed.Case@Example.IO": "dev-local-mixed-case-at-example-io",
+ }
+
+ for name, want := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := auth.DevLocalProfileName(name)
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+ })
+ }
+}
+
+// Every meshStack a user could reach answers without the field, so this is the message most
+// people who try the flag will ever see.
+func TestDevLocalLoginAgainstAnOrdinaryMeshStack(t *testing.T) {
+ isolateAt(t)
+ stack := devLocalStack(t, false)
+
+ err := run(loginWithRootFlags(cli.New()), "--dev-local", "--endpoint", stack)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), stack, "the message names the endpoint that was asked")
+ assert.Contains(t, err.Error(), "local dev stack")
+ assert.Contains(t, err.Error(), "--api-key", "and points at the alternative")
+}
+
+func TestDevLocalAndApiKeyAreMutuallyExclusive(t *testing.T) {
+ isolateAt(t)
+
+ err := run(NewLogin(cli.New()), "--dev-local", "--api-key")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "dev-local")
+ assert.Contains(t, err.Error(), "api-key")
+ assert.Contains(t, err.Error(), "none of the others can be")
+}
+
+// loginWithRootFlags gives `login` the two persistent flags cmd/meshstack binds on the root
+// command, so a test can drive it on its own and still say --endpoint and --profile.
+func loginWithRootFlags(in *cli.Input) *cobra.Command {
+ cmd := NewLogin(in)
+ cmd.Flags().StringVar(&in.Endpoint, "endpoint", "", "meshStack API endpoint")
+ cmd.Flags().StringVar(&in.Profile, "profile", "", "configuration profile to use")
+ return cmd
+}
+
+// isolateAt points the CLI at an empty configuration directory and clears every MESHSTACK_*
+// variable, so that no test reads a developer's real profile — the Taskfile loads .env into
+// `task test`, so these are often set. It returns the directory, for a test that asserts on
+// the files a command wrote.
+func isolateAt(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("MESHSTACK_CONFIG_DIR", dir)
+ for _, key := range []string{
+ "MESHSTACK_ENDPOINT", "MESHSTACK_WORKSPACE", "MESHSTACK_PROFILE",
+ "MESHSTACK_API_KEY", "MESHSTACK_API_SECRET", "MESHSTACK_API_TOKEN",
+ } {
+ t.Setenv(key, "")
+ }
+ t.Setenv("MESHSTACK_NO_INPUT", "1")
+ return dir
+}
+
+// devLocalStack serves the two endpoints --dev-local touches: the public document it reads its
+// credentials out of, and the exchange that turns them into a token.
+func devLocalStack(t *testing.T, withDevLocalCredentials bool) string {
+ t.Helper()
+
+ mux := http.NewServeMux()
+ server := httptest.NewServer(mux)
+ t.Cleanup(server.Close)
+
+ mux.HandleFunc("/mesh/info", func(w http.ResponseWriter, _ *http.Request) {
+ info := map[string]any{
+ "version": "2026.34.0",
+ "issuer": server.URL + "/auth/realms/meshfed",
+ "cliClientId": "meshstack-cli",
+ "adminWorkspaceIdentifier": devAdminWorkspace,
+ }
+ if withDevLocalCredentials {
+ info["devLocalCredentials"] = map[string]any{
+ "apiKeys": map[string]any{
+ devApiKeyName: map[string]any{
+ "clientId": devApiKeyClientId,
+ "clientSecret": devApiKeyClientSecret,
+ },
+ },
+ "users": map[string]any{
+ "partner@meshcloud.io": map[string]any{
+ "password": "sample123",
+ "workspaces": map[string]any{devAdminWorkspace: "Organization Admin"},
+ },
+ "customer-e@meshcloud.io": map[string]any{
+ "password": "sample123",
+ "workspaces": map[string]any{},
+ },
+ },
+ }
+ }
+ writeJSON(w, info)
+ })
+ mux.HandleFunc("/api/login", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, map[string]any{"access_token": devLocalToken(), "expires_in": 300})
+ })
+ return server.URL
+}
+
+func writeJSON(w http.ResponseWriter, body map[string]any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(body)
+}
+
+// devLocalToken is what /api/login answers with. Nothing verifies a signature, but the deadline
+// has to be real: pkg/auth reads it out of the exp claim to decide the token is usable.
+func devLocalToken() string {
+ claims, _ := json.Marshal(map[string]any{
+ "jti": "dev-local-token",
+ "exp": time.Now().Add(5 * time.Minute).Unix(),
+ })
+ return "x." + base64.RawURLEncoding.EncodeToString(claims) + ".y"
+}
diff --git a/cmd/auth/login.go b/cmd/auth/login.go
new file mode 100644
index 0000000..6ee9318
--- /dev/null
+++ b/cmd/auth/login.go
@@ -0,0 +1,359 @@
+package auth
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/pkg/auth"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+// bareApiKey is what --api-key means without a value: keep the id already in the profile.
+//
+// pflag needs a non-empty NoOptDefVal before it will accept a flag without a value, and it
+// renders that value in the usage line, so the sentinel is the placeholder the
+// documentation already uses and the help reads `--api-key[=]`. An id is a UUID, so
+// nothing a user could legitimately pass collides with it.
+const bareApiKey = ""
+
+// apiKeyId exists only for its Type, which pflag renders into the usage line. A named type
+// would print as `--api-key id[=]` and a string as `--api-key[=""]`; the empty one
+// prints `--api-key[=]`, which is how the flag is documented.
+type apiKeyId string
+
+func (v *apiKeyId) String() string { return string(*v) }
+func (v *apiKeyId) Set(s string) error { *v = apiKeyId(s); return nil }
+func (v *apiKeyId) Type() string { return "" }
+
+// NewLogin returns a fresh command on every call, because cmd/meshstack registers login
+// under two parents. Flag targets have to stay local to this function: a package-level var
+// would be shared by both instances.
+func NewLogin(in *cli.Input) *cobra.Command {
+ var (
+ apiKey apiKeyId
+ apiToken bool
+ devLocal bool
+ force bool
+ secretStdin bool
+ tokenStdin bool
+ method credential.Method
+ )
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Log in to meshStack",
+ Long: `Log in to meshStack and store the credential in a profile.
+
+Without a flag this opens a browser, and re-running it costs nothing: the stored login is
+probed first and reported rather than replaced. --api-key and --api-token select the other
+two methods, and each implies --force, because changing method is explicit by nature.
+
+--dev-local configures a profile for a local dev stack out of what that stack publishes at
+/mesh/info, so running against one needs no credentials of your own. It defaults to the
+endpoint http://localhost:8080 and to the profile dev-local.
+
+No secret and no token is ever a flag value. Both arrive through MESHSTACK_API_SECRET or
+MESHSTACK_API_TOKEN, through stdin, or through a prompt that does not echo.`,
+ Args: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return nil
+ }
+ // --api-key takes an optional value, so pflag resolves it before it looks at
+ // the next argument and `--api-key ` leaves the id behind as a positional
+ // argument. Saying so beats "unknown command".
+ if cmd.Flags().Changed("api-key") {
+ return fmt.Errorf("an API key id needs an equals sign: write `--api-key=%s`. --api-key takes an optional value, so %q was read as a positional argument rather than as the id", args[0], args[0])
+ }
+ return fmt.Errorf("this command takes no arguments: `meshstack auth login` does not take %q. Everything it needs comes from flags and the environment", args[0])
+ },
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ switch {
+ case devLocal:
+ return runDevLocalLogin(cmd, in)
+ case cmd.Flags().Changed("api-key"):
+ if apiKey == "" {
+ return errors.New("the API key id is empty: `--api-key=` was given without an id. Leave the value off entirely to reuse the id already in the profile")
+ }
+ method = credential.MethodApiKey
+ if apiKey != bareApiKey {
+ in.ApiKey = string(apiKey)
+ }
+ force = true
+ case apiToken:
+ method = credential.MethodManual
+ force = true
+ default:
+ // Every form names the method it wants, and bare means the browser login.
+ // Naming it is what makes `meshstack login` switch a profile back from its
+ // API key rather than logging in again with whatever is current.
+ method = credential.MethodLogin
+ }
+ // Read before the resolution, because a setting.Source has neither a context nor
+ // an error return, so a read that blocked would have nowhere to report itself.
+ if secretStdin {
+ secret, err := in.ReadLine()
+ if err != nil {
+ return err
+ }
+ in.ApiSecret = secret
+ }
+ if tokenStdin {
+ token, err := in.ReadLine()
+ if err != nil {
+ return err
+ }
+ in.ApiToken = token
+ }
+ return runLogin(cmd, in, method, force)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.Var(&apiKey, "api-key", "switch to the apiKey method, with the stored id, MESHSTACK_API_KEY, or a new one")
+ flags.Lookup("api-key").NoOptDefVal = bareApiKey
+ flags.BoolVar(&apiToken, "api-token", false, "store an API token that nothing can refresh")
+ flags.BoolVar(&devLocal, "dev-local", false, "configure a profile from a local dev stack's own published credentials")
+ flags.BoolVar(&force, "force", false, "log in again even if the stored login still works")
+ flags.BoolVar(&secretStdin, "api-secret-stdin", false, "read the API key secret from the first line of stdin")
+ flags.BoolVar(&tokenStdin, "api-token-stdin", false, "read the API token from the first line of stdin")
+ cmd.MarkFlagsMutuallyExclusive("api-key", "api-token", "dev-local")
+ cmd.MarkFlagsMutuallyExclusive("api-secret-stdin", "api-token-stdin")
+
+ return cmd
+}
+
+// runDevLocalLogin parses nothing and decides nothing: the two defaults --dev-local brings and
+// the bootstrap itself are pkg/auth's, so that the Terraform provider's acceptance tests can
+// reach the same behaviour without a CLI process.
+func runDevLocalLogin(cmd *cobra.Command, in *cli.Input) error {
+ ctx := cmd.Context()
+
+ session, err := auth.ResolveForDevLocalLogin(ctx, in.Source())
+ if err != nil {
+ return err
+ }
+ if err := profile.Ensure(session.Profile, &session.Endpoint); err != nil {
+ return err
+ }
+ result, err := session.LoginDevLocal(ctx)
+ if err != nil {
+ return err
+ }
+ printLogin(cmd.OutOrStdout(), result)
+ return nil
+}
+
+func runLogin(cmd *cobra.Command, in *cli.Input, method credential.Method, force bool) error {
+ ctx := cmd.Context()
+
+ session, err := loginSession(ctx, in, method)
+ if err != nil {
+ // This is the one command allowed to ask, and pkg/auth names the three failures a
+ // person could answer. Everything else is reported as it came.
+ if !askAbout(cmd, in, err) {
+ return err
+ }
+ if session, err = loginSession(ctx, in, method); err != nil {
+ return err
+ }
+ }
+
+ if err := profile.Ensure(session.Profile, &session.Endpoint); err != nil {
+ return err
+ }
+
+ options := auth.LoginOptions{Force: force, Browser: in.Browser()}
+ if in.MayPrompt {
+ options.ChooseWorkspace = func(_ context.Context, candidates []string) (string, error) {
+ return chooseWorkspace(cmd, candidates)
+ }
+ }
+
+ result, err := session.Login(ctx, options)
+ if err != nil {
+ return err
+ }
+ printLogin(cmd.OutOrStdout(), result)
+ return nil
+}
+
+// loginSession resolves with the profile's own store, which is what makes `meshstack login
+// --api-key=k` write the secret to disk while an ordinary command with the same environment
+// does not.
+func loginSession(ctx context.Context, in *cli.Input, method credential.Method) (*auth.Session, error) {
+ selection, err := profile.Select(ctx, in.Source())
+ if err != nil {
+ return nil, err
+ }
+ store, err := profile.NewFileStore(selection.Name)
+ if err != nil {
+ return nil, err
+ }
+ return auth.ResolveSession(ctx, auth.ResolveSessionOptions{
+ Settings: in.Source(), DemandMethod: method, Store: store,
+ })
+}
+
+// askAbout puts the failure to the person at the keyboard and reports whether they supplied
+// something worth resolving again with. It no longer guesses which failure happened: the
+// three sentinels say so.
+func askAbout(cmd *cobra.Command, in *cli.Input, failure error) bool {
+ if !in.MayPrompt {
+ return false
+ }
+ ctx := cmd.Context()
+ switch {
+ case errors.Is(failure, auth.ErrNoEndpoint):
+ endpoint, asked := askForEndpoint(ctx, cmd, in)
+ in.Endpoint = endpoint
+ return asked
+ case errors.Is(failure, auth.ErrNoApiSecret):
+ secret, err := in.PromptSecret(ctx, "meshStack API key secret")
+ in.ApiSecret = secret
+ return err == nil && secret != ""
+ case errors.Is(failure, auth.ErrNoApiToken):
+ token, err := in.PromptSecret(ctx, "meshStack API token")
+ in.ApiToken = token
+ return err == nil && token != ""
+ }
+ return false
+}
+
+// askForEndpoint offers the endpoints already configured, because a second profile against a
+// meshStack the machine already knows is the common case. It selects the profile the way
+// everything else does rather than keeping its own copy of that rule.
+func askForEndpoint(ctx context.Context, cmd *cobra.Command, in *cli.Input) (string, bool) {
+ known, err := profile.List()
+ if err != nil {
+ return "", false
+ }
+ selection, err := profile.Select(ctx, in.Source())
+ if err != nil {
+ return "", false
+ }
+
+ out := cmd.ErrOrStderr()
+ _, _ = fmt.Fprintf(out, "Profile %q has no endpoint. Which one?\n", selection.Name)
+ endpoints := knownEndpoints(known)
+ for i, endpoint := range endpoints {
+ _, _ = fmt.Fprintf(out, " %d) %-40s (profile %s)\n", i+1, endpoint.url, strings.Join(endpoint.profiles, ", "))
+ }
+ _, _ = fmt.Fprintf(out, " %d) another endpoint\n", len(endpoints)+1)
+
+ choice, err := ask(cmd, "> ")
+ if err != nil {
+ return "", false
+ }
+ if picked, err := strconv.Atoi(choice); err == nil && picked >= 1 && picked <= len(endpoints) {
+ return endpoints[picked-1].url, true
+ }
+ typed, err := ask(cmd, "Endpoint: ")
+ if err != nil || typed == "" {
+ return "", false
+ }
+ return typed, true
+}
+
+// knownEndpoint is one line of that prompt.
+type knownEndpoint struct {
+ url string
+ profiles []string
+}
+
+func knownEndpoints(known []profile.Summary) []knownEndpoint {
+ var list []knownEndpoint
+ for _, p := range known {
+ if p.Endpoint == "" {
+ continue
+ }
+ if i := slices.IndexFunc(list, func(e knownEndpoint) bool { return e.url == p.Endpoint }); i >= 0 {
+ list[i].profiles = append(list[i].profiles, string(p.Name))
+ continue
+ }
+ list = append(list, knownEndpoint{url: p.Endpoint, profiles: []string{string(p.Name)}})
+ }
+ return list
+}
+
+// chooseWorkspace leaves the profile's default alone on an empty answer, for a user who
+// wants to decide later with `meshstack profile set workspace`.
+func chooseWorkspace(cmd *cobra.Command, candidates []string) (string, error) {
+ out := cmd.ErrOrStderr()
+ if len(candidates) == 0 {
+ _, _ = fmt.Fprintln(out, "This login can see no workspaces yet, so the profile keeps no default one.")
+ return "", nil
+ }
+ _, _ = fmt.Fprintln(out, "Which workspace should this profile use?")
+ for i, candidate := range candidates {
+ _, _ = fmt.Fprintf(out, " %d) %s\n", i+1, candidate)
+ }
+ answer, err := ask(cmd, "> ")
+ if err != nil {
+ return "", err
+ }
+ // An answer that is not a number comes back as 0, which is out of range like any other
+ // wrong number, and gets the same reply.
+ picked, _ := strconv.Atoi(answer)
+ if picked < 1 || picked > len(candidates) {
+ _, _ = fmt.Fprintln(out, "No workspace was chosen, so the profile keeps no default one.")
+ return "", nil
+ }
+ return candidates[picked-1], nil
+}
+
+// ask writes to stderr, so that a command's real output stays pipeable while it is asking.
+func ask(cmd *cobra.Command, prompt string) (string, error) {
+ _, _ = fmt.Fprint(cmd.ErrOrStderr(), prompt)
+ line, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
+ if err != nil && line == "" {
+ return "", err
+ }
+ return strings.TrimSpace(line), nil
+}
+
+func printLogin(out io.Writer, result auth.LoginResult) {
+ who := ""
+ if result.Username != "" {
+ who = " as " + result.Username
+ }
+ switch {
+ case result.AlreadyLoggedIn:
+ _, _ = fmt.Fprintf(out, "Already logged in to %s%s.\n", result.Endpoint, who)
+ case result.SwitchedFrom != "":
+ _, _ = fmt.Fprintf(out, "Switched from the %s and logged in to %s%s.\n",
+ result.SwitchedFrom.Description(), result.Endpoint, who)
+ default:
+ _, _ = fmt.Fprintf(out, "Logged in to %s%s.\n", result.Endpoint, who)
+ }
+
+ if result.Profile != "" {
+ row(out, "Profile", string(result.Profile), "")
+ }
+ row(out, "Method", result.Method.Description(), "")
+ if strings.TrimSpace(result.Workspace) != "" {
+ row(out, "Workspace", result.Workspace, "")
+ }
+ if result.Method != credential.MethodManual {
+ return
+ }
+
+ // An API token is the one credential with a deadline nothing can extend, so the deadline
+ // is part of what happened rather than something to look up later.
+ if result.ExpiryKnown {
+ row(out, "Expires", result.ExpiresAt.Local().Format(time.RFC3339), humanDuration(time.Until(result.ExpiresAt)))
+ } else {
+ row(out, "Expires", "unknown", "the token is not a JWT, so it carries no expiry")
+ }
+ _, _ = fmt.Fprintln(out, "Nothing can refresh an API token. Store a fresh one with `meshstack auth login --api-token` when this one runs out.")
+}
diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go
new file mode 100644
index 0000000..0118d1b
--- /dev/null
+++ b/cmd/auth/login_test.go
@@ -0,0 +1,144 @@
+package auth
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+)
+
+// testSecret has the shape credential.CheckSecret expects: 32 alphanumerics, no whitespace,
+// not a UUID.
+const testSecret = "abcdef0123456789abcdef0123456789"
+
+// --api-key takes an optional value, and pflag resolves such a flag before it looks at the
+// next argument. So the equals form is the only one that can carry an id, and the two ways
+// of getting that wrong each have to say so.
+func TestApiKeyFlagForms(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ wantId string
+ wantErr string
+ }{
+ {
+ name: "bare reuses the id already in the profile",
+ args: []string{"--api-key"},
+ },
+ {
+ name: "the equals form carries a new id",
+ args: []string{"--api-key=0000-0001"},
+ wantId: "0000-0001",
+ },
+ {
+ name: "a separate id is not an argument",
+ args: []string{"--api-key", "0000-0001"},
+ wantErr: "equals sign",
+ },
+ {
+ name: "an empty id is refused",
+ args: []string{"--api-key="},
+ wantErr: "the API key id is empty",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ isolateAt(t)
+ in := cli.New()
+ err := run(NewLogin(in), test.args...)
+
+ require.Error(t, err, "no profile is configured, so every form fails eventually")
+ if test.wantErr != "" {
+ assert.Contains(t, err.Error(), test.wantErr)
+ assert.Empty(t, in.ApiKey, "the flag was refused, so nothing reached the source")
+ return
+ }
+ // The form was accepted; what stopped it is the missing endpoint further on.
+ assert.Contains(t, err.Error(), "endpoint")
+ assert.Equal(t, test.wantId, in.ApiKey)
+ })
+ }
+}
+
+// A secret is never a flag value, so --api-secret-stdin is how one reaches the resolution
+// without landing in shell history, in ps output or in a CI log.
+func TestApiSecretStdinReachesTheProfile(t *testing.T) {
+ dir := isolateAt(t)
+ stack := devLocalStack(t, false)
+ stdinWith(t, testSecret+"\n")
+
+ require.NoError(t, run(loginWithRootFlags(cli.New()),
+ "--endpoint", stack, "--api-key=key-42", "--api-secret-stdin"))
+
+ credentials, err := os.ReadFile(filepath.Join(dir, "credentials", "default.json"))
+ require.NoError(t, err)
+ assert.Contains(t, string(credentials), "key-42")
+ assert.Contains(t, string(credentials), testSecret)
+ assert.Contains(t, string(credentials), `"current": "apiKey"`)
+}
+
+func TestApiTokenStdinReachesTheProfile(t *testing.T) {
+ dir := isolateAt(t)
+ stdinWith(t, devLocalToken()+"\n")
+
+ require.NoError(t, run(loginWithRootFlags(cli.New()),
+ "--endpoint", "https://api.example.com", "--api-token", "--api-token-stdin"))
+
+ credentials, err := os.ReadFile(filepath.Join(dir, "credentials", "default.json"))
+ require.NoError(t, err)
+ assert.Contains(t, string(credentials), `"current": "manual"`)
+}
+
+func TestApiKeyAndApiTokenAreMutuallyExclusive(t *testing.T) {
+ isolateAt(t)
+
+ err := run(NewLogin(cli.New()), "--api-key", "--api-token")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "api-key")
+ assert.Contains(t, err.Error(), "api-token")
+ assert.Contains(t, err.Error(), "none of the others can be")
+}
+
+func TestLoginTakesNoArguments(t *testing.T) {
+ isolateAt(t)
+
+ err := run(NewLogin(cli.New()), "workspace-name")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "does not take")
+}
+
+func run(cmd *cobra.Command, args ...string) error {
+ cmd.SetArgs(args)
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetIn(bytes.NewReader(nil))
+ // A usage dump would drown the assertions, and the real root silences it too.
+ cmd.SilenceUsage = true
+ return cmd.Execute()
+}
+
+// stdinWith replaces the process's stdin, because the two --*-stdin flags read a file rather
+// than cobra's input stream: a terminal check and a prompt both need the real descriptor.
+// cli.New captures it, so this has to run first.
+func stdinWith(t *testing.T, content string) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "stdin")
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
+ file, err := os.Open(path)
+ require.NoError(t, err)
+ previous := os.Stdin
+ os.Stdin = file
+ t.Cleanup(func() {
+ os.Stdin = previous
+ _ = file.Close()
+ })
+}
diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go
new file mode 100644
index 0000000..7e59508
--- /dev/null
+++ b/cmd/auth/logout.go
@@ -0,0 +1,69 @@
+package auth
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/pkg/auth"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+func newLogout(in *cli.Input) *cobra.Command {
+ var revoke bool
+
+ cmd := &cobra.Command{
+ Use: "logout",
+ Short: "Remove this profile's stored credentials",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ // The profile's own store, because this command acts on that file: otherwise a
+ // shell exporting a credential would make logging out a no-op against a memory
+ // store while the file stayed where it was.
+ selection, err := profile.Select(ctx, in.Source())
+ if err != nil {
+ return err
+ }
+ store, err := profile.NewFileStore(selection.Name)
+ if err != nil {
+ return err
+ }
+ // Read from the file rather than from the session, for the same reason: what is
+ // about to be removed is what this command reports, whatever the environment
+ // would have authenticated with.
+ credentials, err := store.Read()
+ if err != nil {
+ return err
+ }
+ was := credentials.Current
+
+ session, err := auth.ResolveSession(ctx, auth.ResolveSessionOptions{Settings: in.Source(), Store: store})
+ if err != nil {
+ return err
+ }
+ if err := session.Logout(ctx, revoke); err != nil {
+ return err
+ }
+
+ out := cmd.OutOrStdout()
+ _, _ = fmt.Fprintf(out, "Removed the credentials of profile %q for %s.\n", session.Profile, session.Endpoint)
+ if revoke {
+ _, _ = fmt.Fprintln(out, "The session at the identity provider was ended too.")
+ return nil
+ }
+ if was == credential.MethodLogin {
+ // The endpoints that list and revoke CLI logins are on meshStack's internal
+ // API, which a CLI token cannot reach, so meshPanel is the other way.
+ _, _ = fmt.Fprintln(out, "The session at the identity provider is untouched. End it with --revoke, or in meshPanel under Profile → CLI Logins.")
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVar(&revoke, "revoke", false, "also end the session at the identity provider")
+
+ return cmd
+}
diff --git a/cmd/auth/status.go b/cmd/auth/status.go
new file mode 100644
index 0000000..bcc98c0
--- /dev/null
+++ b/cmd/auth/status.go
@@ -0,0 +1,123 @@
+package auth
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/pkg/auth"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+)
+
+// newStatus makes no network call unless --verify is given: staying local is what keeps it
+// fast enough for a shell prompt. It deliberately does not require a workspace either, being
+// one of the two commands a user runs in order to pick one.
+func newStatus(in *cli.Input) *cobra.Command {
+ var verify bool
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Show the resolved endpoint, workspace, methods and token",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ session, err := auth.ResolveSession(ctx, auth.ResolveSessionOptions{Settings: in.Source()})
+ if err != nil {
+ return err
+ }
+ status, err := session.Status()
+ if err != nil {
+ return err
+ }
+ out := cmd.OutOrStdout()
+ printStatus(out, status)
+ if !verify {
+ return nil
+ }
+
+ // Revocation is the one thing the stored state cannot know, because a refresh
+ // token carries no expiry, so proving it costs a round trip and gets a flag.
+ api, err := session.Client(ctx, cli.UserAgent)
+ if err != nil {
+ return auth.HintErr(err, session)
+ }
+ if _, err := api.Workspace.List(ctx); err != nil {
+ return auth.HintErr(err, session)
+ }
+ _, _ = fmt.Fprintf(out, "%-9s the credential still works\n", "Verified")
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVar(&verify, "verify", false, "also make one authenticated call to prove the credential still works")
+
+ return cmd
+}
+
+func printStatus(out io.Writer, status auth.Status) {
+ row(out, "Profile", string(status.Profile), status.ConfigPath)
+ row(out, "Endpoint", status.Endpoint, originOf(status, meshstack.Endpoint.EnvKey()))
+ if status.Workspace == "" {
+ row(out, "Workspace", "none", "")
+ } else {
+ row(out, "Workspace", status.Workspace, originOf(status, meshstack.Workspace.EnvKey()))
+ }
+
+ printMethods(out, status)
+ row(out, "Current", status.Current.Description(), "")
+ if token := status.Token; token != nil {
+ where := "scope " + string(token.Scope)
+ switch {
+ case token.ExpiresAt.IsZero():
+ row(out, "Token", where, "no expiry; the server decides")
+ default:
+ row(out, "Token", where, "expires "+humanDuration(token.ExpiresIn))
+ }
+ } else {
+ row(out, "Token", "none cached", "")
+ }
+}
+
+func printMethods(out io.Writer, status auth.Status) {
+ label := "Methods"
+ if login := status.Login; login != nil {
+ row(out, label, "login "+login.Issuer, "logged in "+humanDuration(-login.Age))
+ label = ""
+ }
+ if apiKey := status.ApiKey; apiKey != nil {
+ from := apiKey.SecretFrom
+ if from == "" {
+ from = originOf(status, credential.ApiKeyClientSecret.EnvKey())
+ }
+ if from == "" {
+ // A profile that holds an API key beside the login it is authenticating with:
+ // nothing resolved the secret, so nothing recorded where it came from.
+ from = "the credentials file"
+ }
+ row(out, label, "apiKey "+apiKey.ClientId, "secret from "+from)
+ label = ""
+ }
+ // A pasted token is not a method on disk — nothing can renew it — so it is named here
+ // only when it is what this session authenticates with.
+ if status.Current == credential.MethodManual {
+ row(out, label, "manual an API token", "nothing can refresh it")
+ label = ""
+ }
+ if label != "" {
+ row(out, label, "none stored", "`meshstack login` stores one")
+ }
+}
+
+// originOf finds one setting's origin. `meshstack profile view` prints the whole list; this
+// command wants two of them beside the values they explain.
+func originOf(status auth.Status, key string) string {
+ for _, origin := range status.Origins {
+ if origin.Key == key {
+ return origin.From.String()
+ }
+ }
+ return ""
+}
diff --git a/cmd/meshstack/main.go b/cmd/meshstack/main.go
deleted file mode 100644
index 5e5a909..0000000
--- a/cmd/meshstack/main.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package main
-
-import (
- "fmt"
- "io"
- "os"
-)
-
-const notice = "meshstack has no commands yet. This build only proves that the repository builds, tests and ships."
-
-func run(out io.Writer) error {
- _, err := fmt.Fprintln(out, notice)
- return err
-}
-
-func main() {
- if err := run(os.Stdout); err != nil {
- os.Exit(1)
- }
-}
diff --git a/cmd/meshstack/main_test.go b/cmd/meshstack/main_test.go
deleted file mode 100644
index a26b229..0000000
--- a/cmd/meshstack/main_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package main
-
-import (
- "strings"
- "testing"
-)
-
-func TestRunWritesOneLineToItsWriter(t *testing.T) {
- var out strings.Builder
-
- if err := run(&out); err != nil {
- t.Fatalf("run returned %v", err)
- }
-
- if got, want := out.String(), notice+"\n"; got != want {
- t.Errorf("run wrote %q, want %q", got, want)
- }
-}
diff --git a/cmd/meshstack/meshstack.go b/cmd/meshstack/meshstack.go
new file mode 100644
index 0000000..b9b8b8e
--- /dev/null
+++ b/cmd/meshstack/meshstack.go
@@ -0,0 +1,108 @@
+// 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 (
+ "log/slog"
+ "os"
+
+ clog "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/cmd/auth"
+ "github.com/meshcloud/meshstack-cli/cmd/profile"
+ "github.com/meshcloud/meshstack-cli/cmd/workspace"
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/tty"
+)
+
+// 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 {
+ var debug bool
+
+ // One Input serves the whole tree, because the persistent flags below are what it
+ // carries. `auth login` adds the flags only it owns.
+ cli.UserAgent = "meshstack-cli/" + Version
+ in := cli.New()
+
+ cmd := &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,
+ PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
+ setupLogging(debug)
+ // Resolved here because a command deciding whether it may ask does so at a moment
+ // when the resolution has just failed and there is no Session to ask. A prompt
+ // also needs a terminal to read the answer from; a browser login does not.
+ noInput, _, err := setting.Resolve(tty.NoInput, in.Source())
+ if err != nil {
+ return err
+ }
+ in.NoInput = noInput
+ in.MayPrompt = !noInput && tty.IsTerminal(os.Stdin)
+ return nil
+ },
+ }
+
+ flags := cmd.PersistentFlags()
+ flags.BoolVar(&debug, "debug", false, "log at debug level")
+ flags.StringVar(&in.Profile, "profile", "", "configuration profile to use")
+ flags.StringVar(&in.Endpoint, "endpoint", "", "meshStack API endpoint")
+ flags.StringVar(&in.Workspace, "workspace", "", "workspace to act in, for this invocation only")
+ flags.BoolVar(&in.NoInput, "no-input", false, "never wait for a person; fail instead")
+
+ cmd.AddCommand(auth.New(in))
+ // `meshstack login` is a shortcut for `meshstack auth login`. Cobra matches an
+ // argument against one command's children, so Aliases never reach past siblings
+ // and cannot express this. The second call is deliberate: adding the value
+ // auth.New() already registered would overwrite its parent, and both paths would
+ // then print the same usage line.
+ cmd.AddCommand(auth.NewLogin(in))
+ cmd.AddCommand(profile.New(in))
+ cmd.AddCommand(workspace.New(in))
+
+ return cmd
+}
+
+func setupLogging(debug bool) {
+ options := clog.Options{
+ ReportTimestamp: true,
+ Level: clog.InfoLevel,
+ }
+ if debug {
+ options.Level = clog.DebugLevel
+ }
+ slog.SetDefault(slog.New(clog.NewWithOptions(os.Stderr, options)))
+}
diff --git a/cmd/meshstack/meshstack_test.go b/cmd/meshstack/meshstack_test.go
new file mode 100644
index 0000000..04c8819
--- /dev/null
+++ b/cmd/meshstack/meshstack_test.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// `meshstack login` and `meshstack auth login` have to be two command values. Cobra stores
+// one parent per command, so registering the same value twice would silently re-parent it
+// and both paths would then print the same usage line.
+func TestLoginIsRegisteredTwiceAsTwoCommands(t *testing.T) {
+ root := newRootCommand()
+
+ shortcut := child(t, root, "login")
+ nested := child(t, child(t, root, "auth"), "login")
+
+ assert.NotSame(t, shortcut, nested)
+ assert.Equal(t, "meshstack login", shortcut.CommandPath())
+ assert.Equal(t, "meshstack auth login", nested.CommandPath())
+}
+
+func TestRootRegistersTheWholeTree(t *testing.T) {
+ root := newRootCommand()
+
+ for _, path := range [][]string{
+ {"auth", "login"}, {"auth", "status"}, {"auth", "logout"},
+ {"workspace", "list"},
+ {"profile", "view"}, {"profile", "set"},
+ {"login"},
+ } {
+ cmd := root
+ for _, name := range path {
+ cmd = child(t, cmd, name)
+ }
+ }
+
+ for _, flag := range []string{"profile", "endpoint", "workspace", "no-input", "debug"} {
+ assert.NotNil(t, root.PersistentFlags().Lookup(flag), "root should carry --%s", flag)
+ }
+}
+
+func child(t *testing.T, parent *cobra.Command, name string) *cobra.Command {
+ t.Helper()
+ for _, candidate := range parent.Commands() {
+ if candidate.Name() == name {
+ return candidate
+ }
+ }
+ require.Failf(t, "missing command", "%s has no child %q", parent.CommandPath(), name)
+ return nil
+}
diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go
new file mode 100644
index 0000000..ac9e428
--- /dev/null
+++ b/cmd/profile/profile.go
@@ -0,0 +1,31 @@
+// Package profile holds `meshstack profile` and its leaves.
+//
+// `meshstack profile list` and `meshstack profile use ` are the shape to grow into.
+// They are not built: --profile and MESHSTACK_PROFILE already select one per invocation,
+// and `profile view` covers the rest.
+package profile
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+)
+
+func New(in *cli.Input) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "profile",
+ Short: "Inspect and change the configuration profile",
+ // Both are needed together: cobra returns flag.ErrHelp for a command that is not
+ // runnable before it reaches ValidateArgs, so a parent without RunE would make
+ // `meshstack profile bogus` print help and exit 0.
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ }
+
+ cmd.AddCommand(newView(in))
+ cmd.AddCommand(newSet(in))
+
+ return cmd
+}
diff --git a/cmd/profile/set.go b/cmd/profile/set.go
new file mode 100644
index 0000000..c8307a5
--- /dev/null
+++ b/cmd/profile/set.go
@@ -0,0 +1,52 @@
+package profile
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+// settings are the keys `profile set` accepts, in the order the error lists them.
+var settings = []string{"endpoint", "workspace"}
+
+// newSet never creates a profile: `meshstack auth login` is the one command that does, so a
+// mistyped name is reported rather than quietly configured.
+func newSet(in *cli.Input) *cobra.Command {
+ return &cobra.Command{
+ Use: "set ",
+ Short: "Set this profile's endpoint or default workspace",
+ Args: cobra.ExactArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ key, value := args[0], args[1]
+ // Checked before anything is resolved, so that a typo answers with the list of
+ // keys rather than with whatever the configuration happens to be missing.
+ if !slices.Contains(settings, key) {
+ return fmt.Errorf("unknown profile setting: %q is not a profile setting. The settings are: %s", key, strings.Join(settings, ", "))
+ }
+
+ // No session: this command writes config.json and never authenticates, so
+ // demanding a credential it will not use would refuse to fix a broken profile.
+ selection, err := profile.Select(cmd.Context(), in.Source())
+ if err != nil {
+ return err
+ }
+
+ switch key {
+ case "endpoint":
+ err = profile.SetEndpoint(selection.Name, value)
+ case "workspace":
+ err = profile.SetWorkspace(selection.Name, value)
+ }
+ if err != nil {
+ return err
+ }
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Profile %q now has %s %s.\n", selection.Name, key, value)
+ return nil
+ },
+ }
+}
diff --git a/cmd/profile/set_test.go b/cmd/profile/set_test.go
new file mode 100644
index 0000000..4352c09
--- /dev/null
+++ b/cmd/profile/set_test.go
@@ -0,0 +1,56 @@
+package profile
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+)
+
+// An unknown key is answered with the list of keys that exist, and before anything is
+// resolved: a typo must not be reported as whatever the configuration happens to lack.
+func TestSetRefusesAnUnknownKey(t *testing.T) {
+ isolate(t)
+ cmd := newSet(cli.New())
+ cmd.SetArgs([]string{"workspaces", "my-workspace"})
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SilenceUsage = true
+
+ err := cmd.Execute()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown profile setting")
+ assert.Contains(t, err.Error(), "endpoint, workspace")
+}
+
+func TestSetTakesExactlyTwoArguments(t *testing.T) {
+ isolate(t)
+ cmd := newSet(cli.New())
+ cmd.SetArgs([]string{"workspace"})
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SilenceUsage = true
+
+ require.Error(t, cmd.Execute())
+}
+
+// isolate points the CLI at an empty configuration directory and clears every MESHSTACK_*
+// variable, so that no test reads a developer's real profile — the Taskfile loads .env into
+// `task test`, so these are often set.
+func isolate(t *testing.T) {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("MESHSTACK_CONFIG_DIR", dir)
+ for _, key := range []string{
+ "MESHSTACK_ENDPOINT", "MESHSTACK_WORKSPACE", "MESHSTACK_PROFILE",
+ "MESHSTACK_API_KEY", "MESHSTACK_API_SECRET", "MESHSTACK_API_TOKEN",
+ } {
+ t.Setenv(key, "")
+ }
+ // The test process's own stdin may be a terminal, and nothing here may prompt.
+ t.Setenv("MESHSTACK_NO_INPUT", "1")
+}
diff --git a/cmd/profile/view.go b/cmd/profile/view.go
new file mode 100644
index 0000000..bc9087f
--- /dev/null
+++ b/cmd/profile/view.go
@@ -0,0 +1,55 @@
+package profile
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/pkg/auth"
+)
+
+func newView(in *cli.Input) *cobra.Command {
+ return &cobra.Command{
+ Use: "view",
+ Short: "Show the resolved configuration and where each value came from",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ // Resolved the way an ordinary command is, so that what this prints is what the
+ // next command will use, including a credential that never reaches a profile.
+ session, err := auth.ResolveSession(cmd.Context(), auth.ResolveSessionOptions{Settings: in.Source()})
+ if err != nil {
+ return err
+ }
+ status, err := session.Status()
+ if err != nil {
+ return err
+ }
+
+ out := cmd.OutOrStdout()
+ _, _ = fmt.Fprintf(out, "%-11s %s\n", "Profile", status.Profile)
+ _, _ = fmt.Fprintf(out, "%-11s %s\n", "Endpoint", status.Endpoint)
+ _, _ = fmt.Fprintf(out, "%-11s %s\n", "Workspace", orNone(status.Workspace))
+ _, _ = fmt.Fprintf(out, "%-11s %s\n", "Method", status.Current.Description())
+
+ // In resolution order rather than sorted: that is the order the precedence rules
+ // were applied in, and the order a person reading them wants.
+ _, _ = fmt.Fprintln(out, "\nSources")
+ for _, origin := range status.Origins {
+ _, _ = fmt.Fprintf(out, " %-20s %s\n", origin.Key, origin.From)
+ }
+
+ _, _ = fmt.Fprintln(out, "\nFiles")
+ _, _ = fmt.Fprintf(out, " %-11s %s\n", "config", status.ConfigPath)
+ _, _ = fmt.Fprintf(out, " %-11s %s\n", "credentials", status.CredentialsPath)
+ return nil
+ },
+ }
+}
+
+func orNone(value string) string {
+ if value == "" {
+ return "none"
+ }
+ return value
+}
diff --git a/cmd/workspace/list.go b/cmd/workspace/list.go
new file mode 100644
index 0000000..c2d65d4
--- /dev/null
+++ b/cmd/workspace/list.go
@@ -0,0 +1,41 @@
+package workspace
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+ "github.com/meshcloud/meshstack-cli/pkg/auth"
+)
+
+// newList builds `meshstack workspace list`. It is one of the two commands that do not
+// require a resolved workspace: an unscoped user token reaches this call and almost nothing
+// else, which is exactly what makes it the way to find out which workspace to use.
+func newList(in *cli.Input) *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "List the workspaces this credential can see",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ session, err := auth.ResolveSession(ctx, auth.ResolveSessionOptions{Settings: in.Source()})
+ if err != nil {
+ return err
+ }
+ // Session.Workspaces already annotates its own failures through auth.HintErr.
+ names, err := session.Workspaces(ctx)
+ if err != nil {
+ return err
+ }
+ if len(names) == 0 {
+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "This credential can see no workspaces.")
+ return nil
+ }
+ for _, name := range names {
+ _, _ = fmt.Fprintln(cmd.OutOrStdout(), name)
+ }
+ return nil
+ },
+ }
+}
diff --git a/cmd/workspace/workspace.go b/cmd/workspace/workspace.go
new file mode 100644
index 0000000..b5d42ff
--- /dev/null
+++ b/cmd/workspace/workspace.go
@@ -0,0 +1,26 @@
+// Package workspace holds `meshstack workspace` and its leaves.
+package workspace
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/meshcloud/meshstack-cli/internal/cli"
+)
+
+func New(in *cli.Input) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "workspace",
+ Short: "Work with meshStack workspaces",
+ // Both are needed together: cobra returns flag.ErrHelp for a command that is not
+ // runnable before it reaches ValidateArgs, so a parent without RunE would make
+ // `meshstack workspace bogus` print help and exit 0.
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ }
+
+ cmd.AddCommand(newList(in))
+
+ return cmd
+}
diff --git a/flake.nix b/flake.nix
index 018a8ef..581e087 100644
--- a/flake.nix
+++ b/flake.nix
@@ -32,13 +32,14 @@
# package, so it is the only binary installed either way, and an unrestricted set
# is what lets doCheck below run the whole suite instead of one directory's
# tests. That directory is also what names the binary `meshstack`, which is why
- # no build here or anywhere else passes -o.
+ # no build here or anywhere else passes -o. See AGENTS.md.
# Derived from go.mod and go.sum: when it goes stale the build fails and prints
# the value to paste back in.
- vendorHash = "sha256-LOfCstf4K2SR/kd6NHPLHcjAIOxeKbmDa+ikkzj31YA=";
+ vendorHash = "sha256-vvO0VufdztbH0PCXGwJ1yEfB4Xo1Ot/b5JkrVe0YTE0=";
- # A build without this ldflag reports `dev`.
+ # The third place setting -X main.Version, after .goreleaser.yml and the
+ # Dockerfile; all three have to agree. A build without it reports `dev`.
ldflags = [ "-s" "-w" "-X main.Version=${version}" ];
# The suite passes in the sandbox — every test that wants $HOME, a config file
@@ -90,6 +91,9 @@
# https://taskfile.dev
go-task
+
+ # https://goreleaser.com — task release:check / release:snapshot
+ goreleaser
];
shellHook = ''
diff --git a/go.mod b/go.mod
index 864b0a6..0108416 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,7 @@
module github.com/meshcloud/meshstack-cli
-// Keep flake.nix's pinned Go (go_1_27 + GOROOT) in lock-step when bumping.
+// 1.27 is the floor because internal/http declares generic methods, which no earlier release
+// compiles. Keep flake.nix's pinned Go (go_1_27 + GOROOT) in lock-step when bumping.
go 1.27
// gotestsum is not a convenience: meshfed-release's go-satellite build plugin runs
@@ -10,6 +11,12 @@ tool (
gotest.tools/gotestsum
)
+require (
+ github.com/charmbracelet/log v1.0.0
+ github.com/spf13/cobra v1.10.2
+ github.com/stretchr/testify v1.12.1
+)
+
require (
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
@@ -41,6 +48,7 @@ require (
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bitfield/gotestdox v0.2.2 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
@@ -56,8 +64,10 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -78,6 +88,7 @@ require (
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.21 // indirect
github.com/go-critic/go-critic v0.14.4 // indirect
+ github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
@@ -149,6 +160,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
@@ -181,14 +193,12 @@ require (
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
- github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
- github.com/stretchr/testify v1.12.1 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
@@ -212,6 +222,7 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/sync v0.22.0 // indirect
diff --git a/go.sum b/go.sum
index ac1f2c5..f6f1161 100644
--- a/go.sum
+++ b/go.sum
@@ -102,6 +102,8 @@ github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTi
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM=
github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -137,10 +139,16 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
+github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -210,6 +218,8 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
+github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
@@ -457,6 +467,8 @@ github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKH
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
diff --git a/internal/cli/input.go b/internal/cli/input.go
new file mode 100644
index 0000000..ba6cb0b
--- /dev/null
+++ b/internal/cli/input.go
@@ -0,0 +1,122 @@
+// Package cli is the meshStack CLI's half of resolving a session: its settings source, the
+// secrets it reads from stdin or a prompt, and the browser login.
+//
+// It is internal because Go's own rule is the guarantee that matters here. This package
+// prompts, and a Terraform provider run must never block on a terminal that is not there, so
+// the import is impossible rather than merely discouraged. That is also what makes it the one
+// place outside cmd/ that may reach pkg/oidc/browser; .golangci.yml carries that rule.
+package cli
+
+import (
+ "io"
+ "os"
+
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/auth"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/browser"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+ "github.com/meshcloud/meshstack-cli/pkg/tty"
+)
+
+// UserAgent identifies this CLI to the meshStack API. cmd/meshstack replaces it with the
+// build version at startup, because the commands that build a client cannot import package
+// main to read it there.
+var UserAgent = "meshstack-cli"
+
+// Input is the top of every ranked list. One serves the whole command tree, because the
+// persistent flags feeding it do.
+//
+// ApiSecret and ApiToken hold what --api-secret-stdin and --api-token-stdin read, which is a
+// line of stdin rather than an argv entry. The read happens before the resolution, because a
+// Source has neither a context nor an error return to report one that blocked.
+type Input struct {
+ Profile string
+ Endpoint string
+ Workspace string
+ ApiKey string
+ ApiSecret string
+ ApiToken string
+ NoInput bool
+
+ // MayPrompt is tty.NoInput and a terminal check, resolved once by cmd/meshstack. It is
+ // separate from NoInput, which is the flag alone, because a command has to know whether
+ // it may ask at a moment when the resolution has just failed and there is no Session.
+ MayPrompt bool
+
+ // in and out are fields only so that a test can drive one. in is an *os.File because
+ // deciding whether to prompt at all means asking whether it is a terminal.
+ in *os.File
+ out io.Writer
+}
+
+var _ setting.Source = (*Input)(nil)
+
+func New() *Input {
+ // Prompts go to stderr so that a command's real output stays pipeable.
+ return &Input{in: os.Stdin, out: os.Stderr}
+}
+
+func (i *Input) Lookup(key string) (string, error) {
+ _, value := i.flag(key)
+ return value, nil
+}
+
+func (i *Input) Describe(key string) setting.SourceDescription {
+ name, _ := i.flag(key)
+ if name == "" {
+ return setting.SourceDescription{}
+ }
+ return setting.SourceDescription{Type: "flag", Details: name}
+}
+
+// flag answers both halves of setting.Source from one switch, so an origin naming a flag
+// cannot drift from the value. A key with no flag answers no name.
+func (i *Input) flag(key string) (name, value string) {
+ switch key {
+ case meshstack.Endpoint.EnvKey():
+ return "--endpoint", i.Endpoint
+ case meshstack.Workspace.EnvKey():
+ return "--workspace", i.Workspace
+ case profile.NameSetting.EnvKey():
+ return "--profile", i.Profile
+ case credential.ApiKeyClientId.EnvKey():
+ return "--api-key", i.ApiKey
+ case credential.ApiKeyClientSecret.EnvKey():
+ return "--api-secret-stdin", i.ApiSecret
+ case credential.ApiBearerToken.EnvKey():
+ return "--api-token-stdin", i.ApiToken
+ case tty.NoInput.EnvKey():
+ // A boolean flag left off answers nothing rather than "false", so that it does not
+ // silence MESHSTACK_NO_INPUT below it.
+ if !i.NoInput {
+ return "--no-input", ""
+ }
+ return "--no-input", "true"
+ }
+ return "", ""
+}
+
+// Source is Input as the explicit source, which outranks the environment.
+func (i *Input) Source() setting.ExplicitSource {
+ return setting.ExplicitSource{Source: i}
+}
+
+func (i *Input) Browser() auth.Browser {
+ return browser.Login
+}
+
+func (i *Input) stdin() *os.File {
+ if i.in == nil {
+ return os.Stdin
+ }
+ return i.in
+}
+
+func (i *Input) stderr() io.Writer {
+ if i.out == nil {
+ return os.Stderr
+ }
+ return i.out
+}
diff --git a/internal/cli/input_test.go b/internal/cli/input_test.go
new file mode 100644
index 0000000..e117c57
--- /dev/null
+++ b/internal/cli/input_test.go
@@ -0,0 +1,78 @@
+package cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+ "github.com/meshcloud/meshstack-cli/pkg/tty"
+)
+
+// A setting is identified by its EnvKey, and this source answers every one the CLI can carry.
+// Describe answers the flag, which is what makes an origin readable.
+func TestInputAnswersEverySettingItsFlagsCarry(t *testing.T) {
+ in := &Input{
+ Endpoint: "https://api.example.com",
+ Workspace: "my-workspace",
+ Profile: "dev",
+ ApiKey: "an-id",
+ ApiSecret: "a-secret",
+ ApiToken: "a-token",
+ NoInput: true,
+ }
+
+ tests := []struct {
+ key string
+ want string
+ flag string
+ }{
+ {key: meshstack.Endpoint.EnvKey(), want: "https://api.example.com", flag: "--endpoint"},
+ {key: meshstack.Workspace.EnvKey(), want: "my-workspace", flag: "--workspace"},
+ {key: profile.NameSetting.EnvKey(), want: "dev", flag: "--profile"},
+ {key: credential.ApiKeyClientId.EnvKey(), want: "an-id", flag: "--api-key"},
+ {key: credential.ApiKeyClientSecret.EnvKey(), want: "a-secret", flag: "--api-secret-stdin"},
+ {key: credential.ApiBearerToken.EnvKey(), want: "a-token", flag: "--api-token-stdin"},
+ {key: tty.NoInput.EnvKey(), want: "true", flag: "--no-input"},
+ }
+ for _, test := range tests {
+ t.Run(test.key, func(t *testing.T) {
+ value, err := in.Lookup(test.key)
+ require.NoError(t, err)
+ assert.Equal(t, test.want, value)
+ assert.Equal(t, setting.SourceDescription{Type: "flag", Details: test.flag}, in.Describe(test.key))
+ })
+ }
+}
+
+func TestAnUnsetFlagDoesNotSilenceTheSourceBelowIt(t *testing.T) {
+ t.Setenv(meshstack.Endpoint.EnvKey(), "https://env.example.com")
+ t.Setenv(tty.NoInput.EnvKey(), "true")
+ in := New()
+
+ endpoint, resolution, err := setting.Resolve(meshstack.Endpoint, in.Source())
+ require.NoError(t, err)
+ assert.Equal(t, "https://env.example.com", endpoint.String())
+ assert.Equal(t, setting.SourceDescription{Type: "environment variable", Details: meshstack.Endpoint.EnvKey()},
+ resolution.From.Describe(meshstack.Endpoint.EnvKey()))
+
+ // A boolean is the one that could go wrong: an unset --no-input must answer nothing
+ // rather than "false".
+ noInput, _, err := setting.Resolve(tty.NoInput, in.Source())
+ require.NoError(t, err)
+ assert.True(t, noInput)
+}
+
+// A source that cannot express a setting answers nothing, so that it appears in neither an
+// origin nor a hint.
+func TestInputAnswersNothingForASettingItDoesNotCarry(t *testing.T) {
+ value, err := New().Lookup(profile.ConfigDir.EnvKey())
+
+ require.NoError(t, err)
+ assert.Empty(t, value, "neither front end offers a config-directory flag")
+ assert.Empty(t, New().Describe(profile.ConfigDir.EnvKey()))
+}
diff --git a/internal/cli/secret.go b/internal/cli/secret.go
new file mode 100644
index 0000000..0d0d851
--- /dev/null
+++ b/internal/cli/secret.go
@@ -0,0 +1,79 @@
+package cli
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os/exec"
+ "runtime"
+ "strings"
+)
+
+// ReadLine reads one line of stdin, for --api-secret-stdin and --api-token-stdin. Exactly one,
+// because whatever follows it belongs to the command rather than to the secret; and a last
+// line without a newline is a value too, since a `printf %s "$secret" |` pipeline is the
+// documented way to supply one.
+func (i *Input) ReadLine() (string, error) {
+ line, err := bufio.NewReader(i.stdin()).ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ return "", err
+ }
+ return strings.TrimRight(line, "\r\n"), nil
+}
+
+// PromptSecret asks for a secret without echoing it. It serves `meshstack login` alone: every
+// other command reports the resolution's error rather than opening a login dialogue.
+func (i *Input) PromptSecret(ctx context.Context, prompt string) (string, error) {
+ restore, err := i.echoOff(ctx)
+ if err != nil {
+ _, _ = fmt.Fprintf(i.stderr(), "warning: echo could not be turned off (%v), so what you type will be visible.\n", err)
+ }
+ defer restore()
+
+ _, _ = fmt.Fprintf(i.stderr(), "%s: ", prompt)
+ value, err := i.ReadLine()
+ // The Enter key produced no echo, so the next thing written would land on the prompt.
+ _, _ = fmt.Fprintln(i.stderr())
+ return value, err
+}
+
+// echoOff turns terminal echo off and returns the call that turns it back on.
+//
+// It runs stty rather than importing golang.org/x/term, which would be a fourth external
+// dependency where .golangci.yml allows two — and every dependency this module can reach
+// lands in the Terraform provider's dependency tree and from there in the public checksum
+// database. stty is in POSIX and present on every unix this CLI ships for. The trade-off is
+// Windows, which has no stty: there the prompt is visible and says so, because a warning a
+// user can act on beats a secret echoed silently.
+func (i *Input) echoOff(ctx context.Context) (restore func(), err error) {
+ if runtime.GOOS == "windows" {
+ return func() {}, errors.New("windows has no stty")
+ }
+ if err := i.stty(ctx, "-echo"); err != nil {
+ return func() {}, err
+ }
+ return func() {
+ // A context that cannot be cancelled: the caller's may already be done, and a
+ // terminal left without echo outlives this process.
+ if err := i.stty(context.WithoutCancel(ctx), "echo"); err != nil {
+ slog.Warn("could not turn terminal echo back on; run `stty echo` to fix it", "error", err)
+ }
+ }, nil
+}
+
+func (i *Input) stty(ctx context.Context, arg string) error {
+ cmd := exec.CommandContext(ctx, "stty", arg)
+ // stty acts on the terminal it is given as stdin, which is the one being prompted on.
+ cmd.Stdin = i.stdin()
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ if quoted := strings.TrimSpace(string(output)); quoted != "" {
+ return fmt.Errorf("stty %s: %w: %s", arg, err, quoted)
+ }
+ return fmt.Errorf("stty %s: %w", arg, err)
+ }
+ return nil
+}
diff --git a/internal/cli/secret_test.go b/internal/cli/secret_test.go
new file mode 100644
index 0000000..a79623a
--- /dev/null
+++ b/internal/cli/secret_test.go
@@ -0,0 +1,44 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// --api-secret-stdin reads exactly one line, because whatever follows it on stdin belongs to
+// the command rather than to the secret.
+func TestReadLineTakesOneLine(t *testing.T) {
+ in := New()
+ in.in = fileWith(t, "fromstdin\nthis belongs to the command\n")
+
+ secret, err := in.ReadLine()
+
+ require.NoError(t, err)
+ assert.Equal(t, "fromstdin", secret)
+}
+
+// A pipeline built with `printf %s` supplies no trailing newline, and that is the form the
+// documentation shows.
+func TestReadLineTakesALastLineWithoutANewline(t *testing.T) {
+ in := New()
+ in.in = fileWith(t, "fromstdin")
+
+ secret, err := in.ReadLine()
+
+ require.NoError(t, err)
+ assert.Equal(t, "fromstdin", secret)
+}
+
+func fileWith(t *testing.T, content string) *os.File {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "stdin")
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
+ file, err := os.Open(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = file.Close() })
+ return file
+}
diff --git a/internal/http/auth.go b/internal/http/auth.go
new file mode 100644
index 0000000..de74010
--- /dev/null
+++ b/internal/http/auth.go
@@ -0,0 +1,45 @@
+package http
+
+import (
+ "context"
+)
+
+// Authorization produces the bearer token for each request, renewing the token it holds
+// whenever the token is close to expiry.
+//
+// Minting is deliberately not here. This file used to post to /api/login and cache the
+// resulting token in memory only, which is exactly why it could not stay: it had no way to
+// write the minted token into a profile, so every CLI invocation and every Terraform provider
+// run re-minted one. pkg/auth is now the single place that mints, caches and persists, for all
+// three authentication methods — and because a token no longer needs a Client to produce it,
+// an Authorization can finally be implemented from outside client/.
+type Authorization interface {
+ // BearerToken returns the token to send, without the "Bearer " prefix.
+ BearerToken(ctx context.Context) (string, error)
+
+ // RefreshBearerToken replaces rejected, which the meshStack API answered with a 401, and
+ // returns the token to retry that one request with. DoAuthorizedRequest calls it at most
+ // once per request; returning rejected unchanged means there is nothing new to try, and
+ // then the 401 is what the caller sees.
+ //
+ // rejected is passed rather than implied because a Terraform provider has many requests in
+ // flight at once: several of them can be refused the same token, and only the first report
+ // should cost a new one. An implementation compares rejected with the token it now holds
+ // and hands back the replacement it already has.
+ RefreshBearerToken(ctx context.Context, rejected string) (string, error)
+}
+
+// BearerTokenAuthorization carries a token somebody else obtained. Nothing renews it.
+type BearerTokenAuthorization struct {
+ Token string
+}
+
+func (auth BearerTokenAuthorization) BearerToken(context.Context) (string, error) {
+ return auth.Token, nil
+}
+
+// RefreshBearerToken hands the same token back: a static bearer token has nothing to re-mint,
+// so a 401 on it is the answer the caller gets.
+func (auth BearerTokenAuthorization) RefreshBearerToken(_ context.Context, rejected string) (string, error) {
+ return rejected, nil
+}
diff --git a/internal/http/http_client.go b/internal/http/http_client.go
new file mode 100644
index 0000000..688138a
--- /dev/null
+++ b/internal/http/http_client.go
@@ -0,0 +1,169 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ gohttp "net/http"
+ "net/url"
+ "reflect"
+ "slices"
+ "time"
+)
+
+// sharedClient is the only place where instance of &gohttp.Client is created, allowing for connection pooling a
+// and consistent management of retry config / timeouts.
+var sharedClient = func() (client *gohttp.Client) {
+ client = &gohttp.Client{
+ // Timeout covers the whole request (from connect to receiving response body) and is an upper limit.
+ Timeout: 1 * time.Minute,
+ }
+ RetryOptions{
+ // Sized to ride out a full meshStack backend restart, which can leave the gateway
+ // returning 503 for two to three minutes. This backoff sequence sums to about four
+ // minutes: 1+2+4+8+16+30*7 seconds.
+ MaxRetries: 12,
+ Backoff: ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 30 * time.Second},
+ }.ApplyTo(client)
+ return
+}()
+
+func NewClient(userAgent string, auth Authorization) Client {
+ return Client{sharedClient, userAgent, auth}
+}
+
+type Client struct {
+ *gohttp.Client
+ UserAgent string
+ Authorization Authorization
+}
+
+func (c Client) DoAuthorizedRequest[R any](ctx context.Context, 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")
+ }
+ withAuthBearerToken := func(token string) []RequestOption {
+ return append(options, withHeader("Authorization", "Bearer "+token))
+ }
+
+ cachedToken, tokenErr := c.Authorization.BearerToken(ctx)
+ if tokenErr != nil {
+ return result, tokenErr
+ }
+ result, err = c.DoRequest[R](ctx, method, url, withAuthBearerToken(cachedToken)...)
+
+ // A 401 on a token the authorization believed valid forces exactly one refresh. The
+ // renewal grace window covers a request issued just before expiry and modest clock skew,
+ // but not a clock that is minutes wrong — which containers with a frozen clock really are.
+ // One bounded retry turns that from a confusing failure into a hiccup. Re-running DoRequest
+ // is safe because buildRequest encodes the payload afresh on every call.
+ if httpErr, ok := errors.AsType[Error](err); ok && httpErr.IsUnauthorized() {
+ refreshedToken, refreshErr := c.Authorization.RefreshBearerToken(ctx, cachedToken)
+ switch {
+ case refreshErr != nil:
+ return result, errors.Join(err, fmt.Errorf("cannot renew the rejected token: %w", refreshErr))
+ case refreshedToken == cachedToken:
+ return result, err
+ }
+ slog.DebugContext(ctx, "retrying after 401 with a freshly minted token", "url", url.String(), "method", method)
+ return c.DoRequest[R](ctx, method, url, withAuthBearerToken(refreshedToken)...)
+ }
+ return result, err
+}
+
+// DoRequest sends one request and parses the answer as JSON. A non-2xx status is an Error
+// carrying that status and the response body, so a caller that reads an error document — the OIDC
+// endpoints answer one, and it is the only thing that says which refusal it was — takes it from
+// there rather than from a second request.
+func (c Client) DoRequest[R any](ctx context.Context, 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
+ }
+ if err = json.Unmarshal(body, &result); err != nil {
+ err = fmt.Errorf("parsing response body as JSON failed: %w", err)
+ }
+ return
+}
+
+func (c Client) 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)
+ }
+ 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 Client) readBodyAndCheckSuccess(ctx context.Context, res *gohttp.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)
+ }
+ slog.DebugContext(ctx, "response", "status", res.StatusCode, "body", loggedBody{bytes.NewBuffer(responseBody)})
+
+ if res.StatusCode >= 200 && res.StatusCode <= 299 {
+ return responseBody, nil
+ }
+
+ return responseBody, Error{
+ StatusCode: res.StatusCode,
+ ResponseBody: responseBody,
+ }
+}
+
+func (c Client) buildRequest(ctx context.Context, method string, url *url.URL, opts requestOptions) (*gohttp.Request, error) {
+ var requestBody io.ReadWriter
+ if opts.requestPayload != nil {
+ requestBodyData, err := opts.requestPayload()
+ if err != nil {
+ return nil, fmt.Errorf("cannot build request body data: %w", err)
+ }
+ requestBody = bytes.NewBuffer(requestBodyData)
+ }
+
+ if opts.retryable {
+ ctx = context.WithValue(ctx, retryableKey{}, true)
+ }
+
+ req, err := gohttp.NewRequestWithContext(ctx, method, url.String(), requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ for _, modifier := range opts.requestModifiers {
+ if err := modifier(req); err != nil {
+ return nil, err
+ }
+ }
+ slog.DebugContext(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header), "body", loggedBody{requestBody})
+ return req, err
+}
diff --git a/internal/http/http_client_test.go b/internal/http/http_client_test.go
new file mode 100644
index 0000000..b1e2200
--- /dev/null
+++ b/internal/http/http_client_test.go
@@ -0,0 +1,577 @@
+// Package http_test drives the client from the outside, which is what lets it use the types the
+// callers of this package parse their answers into — internal/http may not import any of them.
+package http_test
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ gohttp "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+)
+
+func TestHttpClient(t *testing.T) {
+ t.Run("DoRequest success", func(t *testing.T) {
+ testLogger := installTestLogger(t)
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`"some-answer"`))
+ assert.Equal(t, "/get", req.URL.Path)
+ assert.Equal(t, gohttp.MethodGet, req.Method)
+ assert.Equal(t, "test-agent", req.Header.Get("User-Agent"))
+ })
+ resp, err := client.DoRequest[string](t.Context(), gohttp.MethodGet, client.ServerUrl.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.ServerUrl),
+ `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 gohttp.ResponseWriter, req *gohttp.Request) {
+ resp.WriteHeader(gohttp.StatusOK)
+ })
+ _, err := client.DoRequest[*string](t.Context(), gohttp.MethodGet, client.ServerUrl.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 gohttp.ResponseWriter, req *gohttp.Request) {
+ resp.WriteHeader(gohttp.StatusAccepted) // empty body by design (trigger-run/delete)
+ })
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.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) {
+ testLogger := installTestLogger(t)
+ retryTestBackoff := retryTestBackoff{WaitTime: 1 * time.Second}
+ retried := false
+ client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ if !retried {
+ if retryableStatusCode == 429 {
+ // In delay-seconds form. Its HTTP-date form is read against a mocked
+ // clock, which only a test inside the package can install, so that
+ // case lives in retry_test.go.
+ resp.Header().Set("Retry-After", "1")
+ }
+ resp.WriteHeader(retryableStatusCode)
+ retried = true
+ return
+ }
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`{}`))
+ }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff})
+
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodGet, client.ServerUrl.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 := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ resp.WriteHeader(502)
+ }), http.RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff})
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("get"))
+ var httpErr http.Error
+ 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.ServerUrl),
+ "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 := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ resp.WriteHeader(502)
+ cancel() // cancel context so the backoff wait is interrupted
+ }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}})
+ _, err := client.DoRequest[any](ctx, gohttp.MethodGet, client.ServerUrl.JoinPath("get"))
+ require.ErrorIs(t, err, context.Canceled)
+ })
+
+ t.Run("DoRequest with PATCH (not retried)", func(t *testing.T) {
+ attempts := 0
+ client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ attempts++
+ resp.WriteHeader(502)
+ }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}})
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodPatch, client.ServerUrl)
+ require.Error(t, err)
+ assert.Equal(t, 1, attempts, "PATCH must not be retried")
+ })
+
+ // A POST is what an OIDC grant and /api/login both are, and only one of them may be
+ // replayed. Retryable is what tells them apart, and it has to survive being turned into a
+ // request: it travels in the context, which is what the transport sees.
+ t.Run("DoRequest with POST", func(t *testing.T) {
+ t.Run("is not retried by default", func(t *testing.T) {
+ attempts := 0
+ client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ attempts++
+ resp.WriteHeader(503)
+ }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}})
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("grant"))
+ require.Error(t, err)
+ assert.Equal(t, 1, attempts, "a POST may create something, so replaying it needs the caller's word")
+ })
+
+ t.Run("is retried when the caller marked it Retryable", func(t *testing.T) {
+ attempts := 0
+ client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ attempts++
+ if attempts == 1 {
+ resp.WriteHeader(503)
+ return
+ }
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`{}`))
+ }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}})
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("login"), http.Retryable())
+ require.NoError(t, err)
+ assert.Equal(t, 2, attempts)
+ })
+ })
+
+ // PUT and DELETE are idempotent, but they are not retried on their own any more: GET is the
+ // only method the client replays unasked. MeshObjectClient marks both with Retryable.
+ t.Run("DoRequest with DELETE marked Retryable", func(t *testing.T) {
+ attempts := 0
+ client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ attempts++
+ if attempts == 1 {
+ resp.WriteHeader(503)
+ return
+ }
+ resp.WriteHeader(gohttp.StatusNoContent)
+ }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}})
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodDelete, client.ServerUrl.JoinPath("delete"), http.Retryable())
+ 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 := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ body, _ := io.ReadAll(req.Body)
+ assert.JSONEq(t, `{"key":"value"}`, string(body))
+ attempt++
+ if attempt == 1 {
+ resp.WriteHeader(502)
+ return
+ }
+ resp.WriteHeader(200)
+ }), http.RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff{}})
+ _, err := client.DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl,
+ http.WithJsonPayload(map[string]string{"key": "value"}, "application/json"), http.Retryable())
+ require.NoError(t, err)
+ assert.Equal(t, 2, attempt)
+ })
+
+ t.Run("DoAuthorizedRequest with BearerTokenAuthorization", func(t *testing.T) {
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ assert.Equal(t, "Bearer my-static-token", req.Header.Get("Authorization"))
+ resp.WriteHeader(gohttp.StatusAccepted)
+ })
+ client.Authorization = http.BearerTokenAuthorization{Token: "my-static-token"}
+ _, err := client.DoAuthorizedRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("create"),
+ http.WithJsonPayload("content", "text/plain"))
+ require.NoError(t, err)
+ })
+
+ t.Run("DoAuthorizedRequest re-mints once on 401", func(t *testing.T) {
+ t.Run("retries with the freshly minted token", func(t *testing.T) {
+ auth := &refreshableAuthorization{token: "stale"}
+ seen := []string{}
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ seen = append(seen, req.Header.Get("Authorization"))
+ if req.Header.Get("Authorization") == "Bearer stale" {
+ resp.WriteHeader(gohttp.StatusUnauthorized)
+ return
+ }
+ resp.WriteHeader(gohttp.StatusAccepted)
+ })
+ client.Authorization = auth
+ _, err := client.DoAuthorizedRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit"))
+ require.NoError(t, err)
+ assert.Equal(t, []string{"Bearer stale", "Bearer fresh"}, seen)
+ assert.Equal(t, []string{"stale"}, auth.rejected, "the token that was refused is what the refresh is told about")
+ })
+
+ t.Run("reports the 401 when the re-mint changes nothing", func(t *testing.T) {
+ auth := &refreshableAuthorization{token: "stale", keepToken: true}
+ attempts := 0
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ attempts++
+ resp.WriteHeader(gohttp.StatusUnauthorized)
+ })
+ client.Authorization = auth
+ _, err := client.DoAuthorizedRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit"))
+ var httpErr http.Error
+ require.ErrorAs(t, err, &httpErr)
+ assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode)
+ assert.Equal(t, 1, attempts, "a re-mint that produced the same token has nothing new to try")
+ })
+
+ t.Run("reports both errors when the re-mint fails", func(t *testing.T) {
+ auth := &refreshableAuthorization{token: "stale", refreshErr: errors.New("the login expired")}
+ attempts := 0
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ attempts++
+ resp.WriteHeader(gohttp.StatusUnauthorized)
+ })
+ client.Authorization = auth
+ _, err := client.DoAuthorizedRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit"))
+ var httpErr http.Error
+ require.ErrorAs(t, err, &httpErr, "the 401 the request ran into must stay reachable")
+ assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode)
+ require.ErrorIs(t, err, auth.refreshErr, "and so must the reason nothing better could be tried")
+ assert.Equal(t, 1, attempts)
+ })
+
+ t.Run("leaves an authorization that cannot re-mint alone", func(t *testing.T) {
+ attempts := 0
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ attempts++
+ resp.WriteHeader(gohttp.StatusUnauthorized)
+ })
+ client.Authorization = http.BearerTokenAuthorization{Token: "static"}
+ _, err := client.DoAuthorizedRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit"))
+ var httpErr http.Error
+ require.ErrorAs(t, err, &httpErr)
+ assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode)
+ assert.Equal(t, 1, attempts)
+ })
+ })
+}
+
+// refreshableAuthorization mints "fresh" once it has been told its token was refused, and
+// records every token it was told about.
+type refreshableAuthorization struct {
+ token string
+ keepToken bool
+ refreshErr error
+ rejected []string
+}
+
+func (a *refreshableAuthorization) BearerToken(context.Context) (string, error) {
+ return a.token, nil
+}
+
+func (a *refreshableAuthorization) RefreshBearerToken(_ context.Context, rejected string) (string, error) {
+ a.rejected = append(a.rejected, rejected)
+ if a.refreshErr != nil {
+ return "", a.refreshErr
+ }
+ if !a.keepToken {
+ a.token = "fresh"
+ }
+ return a.token, nil
+}
+
+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 gohttp.ResponseWriter, req *gohttp.Request) {
+ gotQuery = req.URL.Query()
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`"ok"`))
+ })
+ _, err := client.DoRequest[string](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("list"),
+ http.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"))
+ })
+
+ // MeshObjectClient.List is two of these on one request: the caller's filter, then the page
+ // it is fetching. Replacing rather than merging drops the filter, and a backend that needs
+ // it answers 400.
+ t.Run("a second query adds to the first", func(t *testing.T) {
+ var gotQuery url.Values
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ gotQuery = req.URL.Query()
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`"ok"`))
+ })
+ _, err := client.DoRequest[string](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("list"),
+ http.WithUrlQuery(map[string]string{"buildingBlockDefinitionUuid": "abc"}),
+ http.WithUrlQuery(map[string]any{"page": 2}),
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "abc", gotQuery.Get("buildingBlockDefinitionUuid"))
+ assert.Equal(t, "2", gotQuery.Get("page"))
+ })
+
+ 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)
+ })
+}
+
+// TestFormPayloadOption covers what the OIDC grants send: a struct declared where the grant is
+// built, turned into an url-encoded body. The two content types disagree here where
+// WithJsonPayload has them agree — a grant is a form, and the token endpoint answers JSON.
+func TestFormPayloadOption(t *testing.T) {
+ postForm := func(t *testing.T, payload any) (gohttp.Header, url.Values) {
+ t.Helper()
+ var gotHeader gohttp.Header
+ var gotForm url.Values
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ assert.NoError(t, req.ParseForm())
+ gotHeader, gotForm = req.Header, req.PostForm
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`"ok"`))
+ })
+ _, err := client.DoRequest[string](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token"),
+ http.WithFormPayload(payload),
+ )
+ require.NoError(t, err)
+ return gotHeader, gotForm
+ }
+
+ t.Run("a struct becomes a form named by its json tags", func(t *testing.T) {
+ type refreshGrant struct {
+ GrantType string `json:"grant_type"`
+ RefreshToken string `json:"refresh_token"`
+ ClientId string `json:"client_id"`
+ CodeVerifier string `json:"code_verifier"`
+ }
+ header, got := postForm(t, refreshGrant{
+ GrantType: "refresh_token",
+ RefreshToken: "the-rotating-one",
+ ClientId: "meshstack-cli",
+ })
+ assert.Equal(t, "application/x-www-form-urlencoded", header.Get("Content-Type"))
+ assert.Equal(t, "application/json", header.Get("Accept"))
+ assert.Equal(t, "refresh_token", got.Get("grant_type"))
+ assert.Equal(t, "the-rotating-one", got.Get("refresh_token"))
+ assert.Equal(t, "meshstack-cli", got.Get("client_id"))
+ assert.False(t, got.Has("code_verifier"), "a field the grant does not use must not be sent empty")
+ })
+
+ t.Run("a map is sent verbatim", func(t *testing.T) {
+ _, got := postForm(t, map[string]string{"grant_type": "authorization_code", "code": ""})
+ assert.Equal(t, "authorization_code", got.Get("grant_type"))
+ assert.True(t, got.Has("code"), "a map entry is sent even when empty")
+ })
+
+ t.Run("no payload sends no body", func(t *testing.T) {
+ header, got := postForm(t, nil)
+ assert.Empty(t, got)
+ assert.Empty(t, header.Get("Content-Type"))
+ })
+
+ t.Run("a form sends those types as the text they came from", func(t *testing.T) {
+ type logoutRequest struct {
+ RedirectUri xurl.URL `json:"post_logout_redirect_uri"`
+ IdToken jwt.JWT `json:"id_token_hint"`
+ ClientId string `json:"client_id"`
+ Unset *xurl.URL `json:"unset_uri"`
+ }
+ redirectUri, err := url.Parse("http://127.0.0.1:31234/callback")
+ require.NoError(t, err)
+ var idToken jwt.JWT
+ claims := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"someone"}`))
+ require.NoError(t, idToken.UnmarshalText([]byte("e30."+claims+".not-a-signature")))
+
+ _, got := postForm(t, logoutRequest{
+ RedirectUri: xurl.URL{URL: redirectUri},
+ IdToken: idToken,
+ ClientId: "meshstack-cli",
+ })
+ assert.Equal(t, "http://127.0.0.1:31234/callback", got.Get("post_logout_redirect_uri"))
+ assert.Equal(t, idToken.String, got.Get("id_token_hint"))
+ assert.False(t, got.Has("unset_uri"), "a URL nobody set is dropped like any other zero value")
+ })
+
+ // The answer of a grant is where xurl.URL and jwt.JWT earn their keep. Both parse from a JSON
+ // string through UnmarshalText, so a caller declares the field it wants and reads a *url.URL
+ // or the token's claims, rather than a string every reader has to parse again.
+ t.Run("the answer parses into the types the caller declared", func(t *testing.T) {
+ type tokenResponse struct {
+ Issuer xurl.URL `json:"issuer"`
+ AccessToken jwt.JWT `json:"access_token"`
+ }
+ // Only the middle part is ever read, so the header is {} and the signature is not one.
+ claims := base64.RawURLEncoding.EncodeToString([]byte(`{"MC_CUSTOMER":"my-workspace"}`))
+ accessToken := "e30." + claims + ".not-a-signature"
+
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) {
+ assert.NoError(t, req.ParseForm())
+ assert.Equal(t, "refresh_token", req.PostForm.Get("grant_type"))
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write(fmt.Appendf(nil,
+ `{"issuer":"https://sso.example.com/realms/meshfed","access_token":%q}`, accessToken))
+ })
+ got, err := client.DoRequest[tokenResponse](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token"),
+ http.WithFormPayload(map[string]string{"grant_type": "refresh_token"}),
+ )
+ require.NoError(t, err)
+
+ assert.Equal(t, "https://sso.example.com/realms/meshfed", got.Issuer.String())
+ assert.Equal(t, "sso.example.com", got.Issuer.Host, "the field is a parsed URL, not the text it came from")
+ assert.Equal(t, accessToken, got.AccessToken.String)
+ assert.Equal(t, "my-workspace", jwt.WorkspaceClaim.GetFrom(got.AccessToken),
+ "the claims come with the token, so nothing has to decode it a second time")
+ })
+
+ // One case is enough here: that a declared type refusing the answer fails the whole call.
+ // Which texts jwt.JWT refuses is pinned in the jwt package, against its own testdata.
+ t.Run("an answer that is not what those types accept fails the call", func(t *testing.T) {
+ type tokenResponse struct {
+ AccessToken jwt.JWT `json:"access_token"`
+ }
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) {
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`{"access_token":"an-opaque-token"}`))
+ })
+ _, err := client.DoRequest[tokenResponse](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token"),
+ http.WithFormPayload(map[string]string{"grant_type": "refresh_token"}),
+ )
+ assert.ErrorContains(t, err, "not a JWT")
+ })
+}
+
+type TestClient struct {
+ http.Client
+ ServerUrl *url.URL
+}
+
+func newTestClientWithServer(t *testing.T, handlerFunc gohttp.HandlerFunc) TestClient {
+ t.Helper()
+ server := httptest.NewServer(handlerFunc)
+ t.Cleanup(server.Close)
+ serverUrl, err := url.Parse(server.URL)
+ require.NoError(t, err)
+ client := server.Client()
+ return TestClient{http.Client{Client: client, UserAgent: "test-agent"}, serverUrl}
+}
+
+// withTestRetry gives one test client its own retry policy. Production code has exactly one
+// retrying client and no way to configure it, which is the point; a test needs a backoff it can
+// count and drive, so it builds its own.
+func withTestRetry(c TestClient, options http.RetryOptions) TestClient {
+ options.ApplyTo(c.Client.Client)
+ return c
+}
+
+func installTestLogger(t *testing.T) *testLogger {
+ t.Helper()
+ testLogger := &testLogger{}
+ previous := slog.Default()
+ slog.SetDefault(slog.New(testLogger))
+ t.Cleanup(func() {
+ slog.SetDefault(previous)
+ })
+ return testLogger
+}
+
+// testLogger is the slog handler these tests read records back from. It formats a record the way
+// the old Logger interface did — the message, then the attributes as a bracketed list — so that
+// what a test asserts is still the line a person would read in the terraform log.
+type testLogger struct {
+ Debugs []string
+ Infos []string
+ Warns []string
+}
+
+var _ slog.Handler = (*testLogger)(nil)
+
+func (c *testLogger) Enabled(context.Context, slog.Level) bool { return true }
+
+func (c *testLogger) Handle(_ context.Context, record slog.Record) error {
+ var args []any
+ record.Attrs(func(attr slog.Attr) bool {
+ args = append(args, attr.Key, attr.Value.Any())
+ return true
+ })
+ line := fmt.Sprintf("%s %v", record.Message, args)
+ switch {
+ case record.Level >= slog.LevelWarn:
+ c.Warns = append(c.Warns, line)
+ case record.Level >= slog.LevelInfo:
+ c.Infos = append(c.Infos, line)
+ default:
+ c.Debugs = append(c.Debugs, line)
+ }
+ return nil
+}
+
+func (c *testLogger) WithAttrs([]slog.Attr) slog.Handler { return c }
+func (c *testLogger) WithGroup(string) slog.Handler { return c }
+
+type retryTestBackoff struct {
+ WaitTime time.Duration
+ Called int
+}
+
+func (b *retryTestBackoff) Calculate(int) time.Duration {
+ b.Called++
+ return b.WaitTime
+}
diff --git a/internal/http/http_error.go b/internal/http/http_error.go
new file mode 100644
index 0000000..a2efba4
--- /dev/null
+++ b/internal/http/http_error.go
@@ -0,0 +1,37 @@
+package http
+
+import (
+ "fmt"
+ gohttp "net/http"
+)
+
+// Error represents an HTTP error response with status code.
+// This error is returned when an HTTP request fails with a non-2XX status code.
+type Error struct {
+ StatusCode int
+ ResponseBody []byte
+}
+
+func (e Error) Error() string {
+ return fmt.Sprintf("http error %d, response '%s'", e.StatusCode, string(e.ResponseBody))
+}
+
+// IsUnauthorized returns true if the error is a 401 Unauthorized response.
+func (e Error) IsUnauthorized() bool {
+ return e.StatusCode == gohttp.StatusUnauthorized
+}
+
+// IsForbidden returns true if the error is a 403 Forbidden response.
+func (e Error) IsForbidden() bool {
+ return e.StatusCode == gohttp.StatusForbidden
+}
+
+// IsNotFound returns true if the error is a 404 Not Found response.
+func (e Error) IsNotFound() bool {
+ return e.StatusCode == gohttp.StatusNotFound
+}
+
+// IsConflict returns true if the error is a 409 Conflict response.
+func (e Error) IsConflict() bool {
+ return e.StatusCode == gohttp.StatusConflict
+}
diff --git a/internal/http/logging.go b/internal/http/logging.go
new file mode 100644
index 0000000..a2a69d8
--- /dev/null
+++ b/internal/http/logging.go
@@ -0,0 +1,103 @@
+package http
+
+import (
+ "bytes"
+ "encoding"
+ "encoding/json"
+ "fmt"
+ "io"
+ "maps"
+ gohttp "net/http"
+ "slices"
+ "strings"
+)
+
+// This package logs through slog's default logger, and there is no Logger seam any more. Both
+// front ends install a handler on it before anything makes a request — cmd/meshstack a
+// charmbracelet/log one, the Terraform provider a tflog bridge — so a second interface only
+// meant that one process carried two logging conventions and the provider had to fill both.
+//
+// Three rules follow from the handler being installed late, in the provider's Configure, and
+// from what that handler needs:
+//
+// - Reach the logger through the slog package functions at the point of use, never through a
+// package-level slog.Default() captured at init. The default is still the built-in one when
+// this package is initialised.
+// - Pass the request's context, so use DebugContext rather than Debug. The provider's handler
+// reads terraform's logger out of the context, and a record that arrives without one is
+// dropped.
+// - Render an expensive attribute with fmt.Stringer and encoding.TextMarshaler, never with
+// slog.LogValuer. A handler resolves a LogValuer while handling the record, and the provider's
+// handler handles every record — its Enabled says yes to all of them, because terraform owns
+// the level. So a LogValuer here would pretty-print every request body of every terraform run,
+// including the ones TF_LOG then drops. The two interfaces below are read by the sink instead,
+// which reaches them only for a record it is about to write.
+
+// loggedHeaders is the request's headers with the bearer token taken out. Both methods below
+// produce that redacted form, because both are reached: the meshStack CLI's sink formats with %v
+// and calls String, while terraform's sink encodes the fields with encoding/json — which, without
+// MarshalText, would walk this map itself and write the Authorization header out in full.
+type loggedHeaders gohttp.Header
+
+var (
+ _ fmt.Stringer = loggedHeaders(nil)
+ _ encoding.TextMarshaler = loggedHeaders(nil)
+)
+
+func (l loggedHeaders) MarshalText() ([]byte, error) {
+ return []byte(l.String()), 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")
+}
+
+// loggedBody is a request or response body, pretty-printed when something actually writes it.
+// MarshalText is what carries it into terraform's JSON log; encoding/json would otherwise walk
+// the struct and write {"Reader":{}}.
+type loggedBody struct {
+ io.Reader
+}
+
+var (
+ _ fmt.Stringer = loggedBody{}
+ _ encoding.TextMarshaler = loggedBody{}
+)
+
+func (l loggedBody) MarshalText() ([]byte, error) {
+ return []byte(l.String()), nil
+}
+
+func (l loggedBody) String() string {
+ switch body := l.Reader.(type) {
+ case nil:
+ return ""
+ case *bytes.Buffer:
+ return bytesToPrettyJson(body.Bytes())
+ default:
+ return fmt.Sprintf(" %v", body)
+ }
+}
+
+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)
+ }
+ }
+ return fmt.Sprintf(" %s", len(data), string(data))
+}
diff --git a/internal/http/logging_internal_test.go b/internal/http/logging_internal_test.go
new file mode 100644
index 0000000..f21c288
--- /dev/null
+++ b/internal/http/logging_internal_test.go
@@ -0,0 +1,42 @@
+package http
+
+import (
+ "bytes"
+ "log/slog"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestLogRenderingWaitsForTheSink pins that nothing formats a body until a handler writes it.
+// The default logger is at info level here, so a handler that drops the debug records must never
+// reach String — the pretty-printing of every request and response body is the cost this saves.
+//
+// It stays in package http because it builds a loggedBody directly: the client only ever wraps a
+// bytes.Buffer, which renders no matter who asks, so counting the renders needs a reader of its own.
+func TestLogRenderingWaitsForTheSink(t *testing.T) {
+ previous := slog.Default()
+ slog.SetDefault(slog.New(slog.NewJSONHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelInfo})))
+ t.Cleanup(func() { slog.SetDefault(previous) })
+
+ rendered := 0
+ body := loggedBody{&countingReader{counted: &rendered}}
+ slog.Debug("request", "body", body)
+ assert.Zero(t, rendered, "the dropped record still rendered its body")
+
+ slog.Info("request", "body", body)
+ assert.Equal(t, 1, rendered, "the written record did not render its body")
+}
+
+// countingReader counts how often loggedBody rendered it. It is not a *bytes.Buffer, so it takes
+// the branch that prints the reader itself.
+type countingReader struct {
+ counted *int
+}
+
+func (c *countingReader) Read([]byte) (int, error) { return 0, nil }
+
+func (c *countingReader) String() string {
+ *c.counted++
+ return "counted"
+}
diff --git a/internal/http/logging_test.go b/internal/http/logging_test.go
new file mode 100644
index 0000000..3a5d40b
--- /dev/null
+++ b/internal/http/logging_test.go
@@ -0,0 +1,42 @@
+package http_test
+
+import (
+ "bytes"
+ "log/slog"
+ gohttp "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/internal/http"
+)
+
+// TestJsonLogRedactsTheToken pins the redaction against a handler that encodes attributes as JSON
+// rather than formatting them with %v. That is what the Terraform provider's sink does, and
+// without MarshalText it walks loggedHeaders as the map it is and writes the bearer token in full
+// — into a log a practitioner pastes into a bug report.
+func TestJsonLogRedactsTheToken(t *testing.T) {
+ var written bytes.Buffer
+ previous := slog.Default()
+ slog.SetDefault(slog.New(slog.NewJSONHandler(&written, &slog.HandlerOptions{Level: slog.LevelDebug})))
+ t.Cleanup(func() { slog.SetDefault(previous) })
+
+ client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) {
+ resp.WriteHeader(gohttp.StatusOK)
+ _, _ = resp.Write([]byte(`{"answer":"served"}`))
+ })
+ client.Authorization = http.BearerTokenAuthorization{Token: "supersecret"}
+
+ _, err := client.DoAuthorizedRequest[map[string]string](t.Context(), http.MethodPost, client.ServerUrl,
+ http.WithJsonPayload(map[string]string{"asked": "for"}, "application/json"))
+ require.NoError(t, err)
+
+ logged := written.String()
+ assert.NotContains(t, logged, "supersecret")
+ assert.Contains(t, logged, "[REDACTED]")
+ // The bodies survive the JSON encoding too; a struct with no exported field would arrive
+ // as an empty object and say nothing about the request that was made.
+ assert.Contains(t, logged, `asked`)
+ assert.Contains(t, logged, `served`)
+}
diff --git a/internal/http/method.go b/internal/http/method.go
new file mode 100644
index 0000000..8039b66
--- /dev/null
+++ b/internal/http/method.go
@@ -0,0 +1,16 @@
+package http
+
+import gohttp "net/http"
+
+// The request methods this repository sends, re-exported so that naming one costs no second
+// import. A caller of DoRequest passes a method and nothing else from net/http, so without
+// these it would import net/http under an alias for four string constants.
+//
+// Only the four in use. A verb nothing sends would be a list entry somebody has to keep
+// honest, and net/http is one import away for whoever adds the fifth.
+const (
+ MethodGet = gohttp.MethodGet
+ MethodPost = gohttp.MethodPost
+ MethodPut = gohttp.MethodPut
+ MethodDelete = gohttp.MethodDelete
+)
diff --git a/internal/http/options.go b/internal/http/options.go
new file mode 100644
index 0000000..d207dc6
--- /dev/null
+++ b/internal/http/options.go
@@ -0,0 +1,150 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ gohttp "net/http"
+ "net/url"
+ "reflect"
+)
+
+type (
+ // RequestOption is a functional option for configuring HTTP requests.
+ RequestOption func(opts *requestOptions)
+
+ requestOptions struct {
+ retryable bool
+ requestPayload func() ([]byte, error)
+ requestModifiers []requestModifier
+ }
+ requestModifier func(req *gohttp.Request) error
+)
+
+// Retryable declares that replaying this request cannot do harm, which is the only way a method
+// other than GET is ever retried.
+//
+// meshStack's /api/login is the case it exists for: it mints a token from an id and a secret and
+// invalidates nothing, so a replay after a gateway 503 costs one token. Do not put it on a POST
+// that creates something, and never on an OIDC refresh grant — that one rotates the refresh
+// token, and keycloak ends the whole session when a rotated token is used twice.
+func Retryable() RequestOption {
+ return func(opts *requestOptions) {
+ opts.retryable = true
+ }
+}
+
+// retryableKey marks a request its caller declared safe to replay. It travels in the request
+// context rather than in a list the client holds, because gohttp.Client passes the context on to
+// the request it issues for a redirect, and because one client now serves every caller.
+type retryableKey struct{}
+
+func isRetryable(ctx context.Context) bool {
+ retryable, _ := ctx.Value(retryableKey{}).(bool)
+ return retryable
+}
+
+// WithUrlQuery adds URL query parameters from a query value.
+//
+// The given 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 appendRequestModifier(func(req *gohttp.Request) error {
+ urlValues, err := convertStructOrMapToUrlValues(query)
+ if err != nil {
+ return fmt.Errorf("cannot convert url query: %w", err)
+ }
+ // Merged rather than assigned: MeshObjectClient.List adds its own page parameter to
+ // whatever filter the caller passed, and one overwriting the other drops a required
+ // parameter — buildingBlockDefinitionUuid is the case the backend rejects outright.
+ merged := req.URL.Query()
+ for key, values := range urlValues {
+ merged[key] = values
+ }
+ req.URL.RawQuery = merged.Encode()
+ return nil
+ })
+}
+
+func convertStructOrMapToUrlValues(structOrMap any) (url.Values, error) {
+ data, err := json.Marshal(structOrMap)
+ if err != nil {
+ return nil, fmt.Errorf("cannot marshal type %T: %w", structOrMap, err)
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ // UseNumber keeps integers (e.g. page) from becoming float64 and gaining a ".0" or exponent.
+ decoder.UseNumber()
+ var converted map[string]any
+ if err := decoder.Decode(&converted); err != nil {
+ return nil, fmt.Errorf("cannot decode type %T into a flat map: %w", structOrMap, err)
+ }
+ // Drop zero-value fields only for a struct (passed by value, not by pointer); a map is
+ // passed through as given.
+ skipZero := reflect.ValueOf(structOrMap).Kind() == reflect.Struct
+ result := url.Values{}
+ for key, value := range converted {
+ if value == nil || (skipZero && reflect.ValueOf(value).IsZero()) {
+ continue
+ }
+ result[key] = append(result[key], fmt.Sprintf("%v", value))
+ }
+ return result, nil
+}
+
+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 *gohttp.Request) error {
+ req.Header.Set(key, value)
+ return nil
+ })
+}
+
+// WithJsonPayload sends a value as a JSON body, and both sends and asks for the given content type.
+// meshStack names a meshObject's kind and version in that type, which is why it is a parameter
+// rather than always application/json.
+func WithJsonPayload(payload any, contentType string) RequestOption {
+ return func(opts *requestOptions) {
+ if payload == nil {
+ return
+ }
+ WithAccept(contentType)(opts)
+ withHeader("Content-Type", contentType)(opts)
+ opts.requestPayload = func() ([]byte, error) {
+ return json.Marshal(payload)
+ }
+ }
+}
+
+// WithFormPayload sends the values as an url-encoded form body (converted from struct or map, see
+// WithUrlQuery), and asks for JSON in return, which is what every OIDC grant needs.
+func WithFormPayload(payload any) RequestOption {
+ return func(opts *requestOptions) {
+ if payload == nil {
+ return
+ }
+ WithAccept("application/json")(opts)
+ withHeader("Content-Type", "application/x-www-form-urlencoded")(opts)
+ opts.requestPayload = func() ([]byte, error) {
+ values, err := convertStructOrMapToUrlValues(payload)
+ if err != nil {
+ return nil, fmt.Errorf("cannot convert form payload: %w", err)
+ }
+ return []byte(values.Encode()), nil
+ }
+ }
+}
diff --git a/internal/http/retry.go b/internal/http/retry.go
new file mode 100644
index 0000000..bbf5f28
--- /dev/null
+++ b/internal/http/retry.go
@@ -0,0 +1,240 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "math"
+ gohttp "net/http"
+ "strconv"
+ "time"
+)
+
+// 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
+}
+
+// ApplyTo modifies the given client to retry certain requests. The idempotent method GET is
+// retried by default (if response suggest retrying is worthwhile).
+// Any other method only where the caller marked the request with Retryable RequestOption.
+func (options RetryOptions) ApplyTo(c *gohttp.Client) {
+ next := gohttp.DefaultTransport
+ if c.Transport != nil {
+ next = c.Transport
+ }
+ c.Transport = &retryRoundTripper{
+ Next: next,
+ MaxRetries: options.MaxRetries,
+ // ShouldRetryRequest checks if the request method is eligible for retry.
+ ShouldRetryRequest: func(req *gohttp.Request) bool {
+ if options.Backoff == nil {
+ return false
+ }
+ return req.Method == MethodGet || isRetryable(req.Context())
+ },
+ // ShouldRetryResponse returns the backoff policy if the response/error indicates a retryable condition,
+ // otherwise nil is returned to indicate no retry.
+ ShouldRetryResponse: func(resp *gohttp.Response, err error) RetryBackoff {
+ if err != nil {
+ return options.Backoff
+ }
+ switch resp.StatusCode {
+ case gohttp.StatusTooManyRequests, gohttp.StatusServiceUnavailable:
+ return retryAfterBackoff{Response: resp, Fallback: options.Backoff}
+ case gohttp.StatusBadGateway, gohttp.StatusGatewayTimeout:
+ return options.Backoff
+ default:
+ return nil
+ }
+ },
+ }
+}
+
+// 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 *gohttp.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 := gohttp.ParseTime(header); err == nil {
+ return date.Sub(timeNow())
+ }
+ return -1
+}
+
+// retryRoundTripper wraps an gohttp.RoundTripper to retry failed requests.
+// See withRetry for which methods are retried.
+type retryRoundTripper struct {
+ Next gohttp.RoundTripper
+ MaxRetries int
+ ShouldRetryRequest func(req *gohttp.Request) bool
+ ShouldRetryResponse func(resp *gohttp.Response, err error) RetryBackoff
+}
+
+func (r *retryRoundTripper) RoundTrip(req *gohttp.Request) (*gohttp.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)
+ slog.WarnContext(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 *gohttp.Request) *gohttp.Request {
+ if req.Body == nil {
+ return req
+ }
+ // If GetBody already returns independent readers (e.g. set by gohttp.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 gohttp.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 *gohttp.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) {
+ slog.DebugContext(ctx, fmt.Sprintf("failed to drain response body: %s", err.Error()))
+ }
+ if err := resp.Body.Close(); err != nil {
+ slog.DebugContext(ctx, fmt.Sprintf("failed to close response body after draining %d bytes: %s", drainedBytes, err.Error()))
+ }
+ }
+}
diff --git a/internal/http/retry_test.go b/internal/http/retry_test.go
new file mode 100644
index 0000000..7230247
--- /dev/null
+++ b/internal/http/retry_test.go
@@ -0,0 +1,64 @@
+package http
+
+import (
+ "fmt"
+ gohttp "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(gohttp.TimeFormat), 1 * time.Second}, // falls back
+ {"HTTP-date in the future", bubbleStart.Add(45 * time.Second).Format(gohttp.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: &gohttp.Response{Header: gohttp.Header{"Retry-After": {tt.header}}},
+ Fallback: fallback,
+ }
+ assert.Equal(t, tt.want, b.Calculate(1))
+ })
+ })
+ }
+}
diff --git a/internal/setting/env.go b/internal/setting/env.go
new file mode 100644
index 0000000..90959c9
--- /dev/null
+++ b/internal/setting/env.go
@@ -0,0 +1,23 @@
+package setting
+
+import (
+ "fmt"
+ "os"
+)
+
+type EnvKey string
+
+func (k EnvKey) Lookup(key string) (string, error) {
+ if k != EnvKey(key) {
+ // not self-sourcing a Value is an implementation bug for now
+ // it might become relevant when we migrate env keys and define aliases/fallbacks
+ panic(fmt.Sprintf("env key %s does not match %s", k, key))
+ }
+ return os.Getenv(string(k)), nil
+}
+
+func (k EnvKey) Describe(key string) SourceDescription {
+ return SourceDescription{"environment variable", key}
+}
+
+var _ Source = EnvKey("")
diff --git a/internal/setting/resolve.go b/internal/setting/resolve.go
new file mode 100644
index 0000000..2531378
--- /dev/null
+++ b/internal/setting/resolve.go
@@ -0,0 +1,81 @@
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+)
+
+// Resolution is returned by Resolve.
+type Resolution struct {
+ // SettingEnvKey is Setting.EnvKey for look up / comparison on caller side.
+ SettingEnvKey string
+ // From is non-nil iff Resolve found a value.
+ From Source
+ // Queried is used by Hint and callers may inspect / react on their own to a failed/successful Resolve.
+ Queried Sources
+}
+
+func (r Resolution) Hint(err error) error {
+ errs := []error{err}
+ for _, source := range r.Queried {
+ if _, isDefault := source.(DefaultSource); isDefault {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("try setting %s", source.Describe(r.SettingEnvKey)))
+ }
+ return errors.Join(errs...)
+}
+
+// Resolve returns the first value a source carries, the resolution details, and an error if sth went wrong.
+// Given sources are preceded by Setting.Env, succeeded by Setting.Default (if any), unless ExplicitSource is used, so:
+// [Explicit sources..., Env, other sources..., Default].
+// Resolution.From is nil iff no source provided a value.
+func Resolve[T any](setting Setting[T], sources ...Source) (result T, resolution Resolution, err error) {
+ resolution.SettingEnvKey = setting.EnvKey() // carry this over to link resolution back to setting if caller wants to do that
+
+ sourcesWithEnvAndDefault := slices.Insert[Sources, Source](slices.Clone(sources), 0, setting.Env)
+ if setting.Default != nil {
+ // a nil DefaultSource in a Source is not a nil Source, so the skip below misses it
+ sourcesWithEnvAndDefault = append(sourcesWithEnvAndDefault, setting.Default)
+ }
+ slices.SortStableFunc(sourcesWithEnvAndDefault, byExplicitSourceFirst)
+ for _, source := range sourcesWithEnvAndDefault {
+ if source == nil {
+ // convenient to skip "no default" case, also callers can make use of that
+ continue
+ }
+ resolution.Queried = append(resolution.Queried, source)
+ var text string
+ text, err = source.Lookup(resolution.SettingEnvKey)
+ if err != nil {
+ return result, resolution, fmt.Errorf("value from source '%s' could not be looked up: %w", source.Describe(resolution.SettingEnvKey), err)
+ }
+ text = strings.TrimSpace(text)
+ if text == "" {
+ continue
+ }
+ result, err = setting.Parse(text)
+ if err != nil {
+ return result, resolution, fmt.Errorf("value from source '%s' could not be parsed: %w", source.Describe(resolution.SettingEnvKey), err)
+ }
+ resolution.From = source
+ return // resolution found
+ }
+ return
+}
+
+// byExplicitSourceFirst is used by Resolve. See ExplicitSource.
+func byExplicitSourceFirst(a, b Source) int {
+ _, aIsExplicit := a.(ExplicitSource)
+ _, bIsExplicit := b.(ExplicitSource)
+ switch {
+ case aIsExplicit && !bIsExplicit:
+ return -1
+ case !aIsExplicit && bIsExplicit:
+ return 1
+ default:
+ return 0
+ }
+}
diff --git a/internal/setting/setting.go b/internal/setting/setting.go
new file mode 100644
index 0000000..a55ab91
--- /dev/null
+++ b/internal/setting/setting.go
@@ -0,0 +1,64 @@
+package setting
+
+import (
+ "encoding"
+ "strings"
+)
+
+// Setting represents the (internal) definition of a setting. Env is its identity used as a "key" to tell settings apart,
+// and also requiring the convention that all settings can be controlled via environment variables.
+type Setting[T any] struct {
+ // Env key is also the setting's identity, so there is no second identifier to hold in step.
+ Env EnvKey
+
+ // Short is one line of plain text, for a cobra flag; Long is Markdown, for the Terraform
+ // provider's schema. Both state facts about the setting rather than about a front end, so
+ // neither says "flag", "block" or "attribute".
+ Short string
+ Long string
+
+ // Default source is always used as fallback (if non-nil). See Resolve.
+ Default DefaultSource
+ // Parse parses an always non-empty string to the actual Setting.
+ // See ParseText, ParseBool, ParseTextUnmarshaler.
+ Parse func(v string) (T, error)
+}
+
+// EnvKey is a unique identifier for Source.Lookup and Source.Describe method implementations.
+func (s Setting[T]) EnvKey() string {
+ return string(s.Env)
+}
+
+// Help is Long, or Short where a declaration wrote only the one.
+func (s Setting[T]) Help() string {
+ if s.Long != "" {
+ return s.Long
+ }
+ return s.Short
+}
+
+// ParseText is the Setting.Parse for a plain string setting.
+// Note that the input is already whitespace trimmed, see Resolve.
+func ParseText(s string) (string, error) { return s, nil }
+
+// ParseBool is the Setting.Parse for a boolean-like string.
+// Any string except indicating "no" leads to true (be generous).
+// Note that an empty string is never passed, see Resolve.
+func ParseBool(s string) (bool, error) {
+ switch strings.ToLower(s) {
+ case "n", "no", "0":
+ return false, nil
+ default:
+ return true, nil
+ }
+}
+
+// ParseTextUnmarshaler uses T's implementation of [encoding.TextUnmarshaler] for Setting.Parse.
+func ParseTextUnmarshaler[T any, P interface {
+ *T
+ encoding.TextUnmarshaler
+}](s string) (T, error) {
+ instance := new(T)
+ err := P(instance).UnmarshalText(([]byte)(s))
+ return *instance, err
+}
diff --git a/internal/setting/setting_test.go b/internal/setting/setting_test.go
new file mode 100644
index 0000000..0f2f96c
--- /dev/null
+++ b/internal/setting/setting_test.go
@@ -0,0 +1,21 @@
+package setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type testTextUnmarshaler string
+
+func (u *testTextUnmarshaler) UnmarshalText(text []byte) error {
+ *u = testTextUnmarshaler(text)
+ return nil
+}
+
+func TestParseTextUnmarshaler(t *testing.T) {
+ parsed, err := ParseTextUnmarshaler[testTextUnmarshaler]("test")
+ require.NoError(t, err)
+ assert.Equal(t, testTextUnmarshaler("test"), parsed)
+}
diff --git a/internal/setting/source.go b/internal/setting/source.go
new file mode 100644
index 0000000..5d8b624
--- /dev/null
+++ b/internal/setting/source.go
@@ -0,0 +1,57 @@
+package setting
+
+import (
+ "fmt"
+)
+
+type Source interface {
+ // Lookup returns empty string, no error if nothing can be provided, handled in Resolve.
+ // An error can be returned if a fatal condition is detected, usually used for low-priority sources such as DefaultSource.
+ Lookup(key string) (string, error)
+ // Describe returns SourceDescription with proper SourceDescription.String representation for logging
+ Describe(key string) SourceDescription
+}
+
+type Sources []Source
+
+type SourceDescription struct {
+ // Type is a constant string identifying sources (env, default, custom such as flag/stdin/prompt, tf provider block attribute).
+ Type string
+ // Details is appended with space to Type in SourceDescription.String. If empty, that source is currently a DefaultSource.
+ Details string
+}
+
+func (s SourceDescription) String() string {
+ if s.Details == "" {
+ return s.Type
+ }
+ return fmt.Sprintf("%s %s", s.Type, s.Details)
+}
+
+type LookupFunc func() (string, error)
+
+// StaticLookup constructs a static lookup value. Useful for DefaultSource.
+func StaticLookup(v string) LookupFunc {
+ return func() (string, error) {
+ return v, nil
+ }
+}
+
+// DefaultSource provides Setting.Default source from the given LookupFunc.
+// See also StaticLookup.
+type DefaultSource LookupFunc
+
+var _ Source = DefaultSource(nil)
+
+func (d DefaultSource) Lookup(string) (string, error) {
+ return d()
+}
+
+func (d DefaultSource) Describe(key string) SourceDescription {
+ return SourceDescription{"default value", fmt.Sprintf("for %s", key)}
+}
+
+// ExplicitSource gives the sources a higher precedence than EnvKey source, see Resolve.
+type ExplicitSource struct {
+ Source
+}
diff --git a/pkg/auth/apikey.go b/pkg/auth/apikey.go
new file mode 100644
index 0000000..96ab507
--- /dev/null
+++ b/pkg/auth/apikey.go
@@ -0,0 +1,55 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+)
+
+// apiLoginPath is where meshStack exchanges an API key for an access token. The answer
+// carries expires_in: 300 and no refresh token, so an API key access token also lives five
+// minutes and is renewed by posting the id and the secret again. That is also why an API key
+// can never destroy anything the way a refresh token can: /api/login mints independently of
+// any previous token.
+const apiLoginPath = "/api/login"
+
+func apiLogin(ctx context.Context, endpoint xurl.URL, clientId, clientSecret string) (jwt.JWT, error) {
+ target := endpoint.JoinPath(apiLoginPath)
+ payload := struct {
+ ClientId string `json:"clientId"`
+ ClientSecret string `json:"clientSecret"`
+ }{clientId, clientSecret}
+
+ // Retryable is what rides out a backend that is still starting, and it is safe here for the
+ // same reason /api/login can destroy nothing: the exchange mints from the id and the secret
+ // and invalidates no previous token, so a replay after a gateway 503 costs one token.
+ //
+ // A wrong secret is not retried, because the transport only replays a gateway's own answers
+ // — 429, 502, 503, 504 and a connection that never came up. Which is also why a meshStack
+ // behind a Kubernetes gateway is the case this covers: the gateway answers while the
+ // application behind it restarts.
+ answer, err := http.NewClient(userAgent, nil).DoRequest[struct {
+ // The deadline comes with the token, in its exp claim, rather than from the
+ // expires_in the answer also carries.
+ AccessToken jwt.JWT `json:"access_token"`
+ }](ctx, http.MethodPost, target,
+ http.Retryable(),
+ http.WithJsonPayload(payload, "application/json"),
+ )
+
+ var httpErr http.Error
+ switch {
+ case err == nil && answer.AccessToken.String == "":
+ return jwt.JWT{}, fmt.Errorf("could not log in to meshStack with an API key: %s answered without an access token", target)
+ case err == nil:
+ return answer.AccessToken, nil
+ case errors.As(err, &httpErr) && httpErr.IsUnauthorized():
+ return jwt.JWT{}, fmt.Errorf("meshStack refused this API key: %s answered 401 for key id %s: %w. Check the secret, or issue a new key in meshPanel", target, clientId, err)
+ default:
+ return jwt.JWT{}, fmt.Errorf("could not log in to meshStack with an API key: %s with key id %s failed: %w", target, clientId, err)
+ }
+}
diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go
new file mode 100644
index 0000000..6a8e532
--- /dev/null
+++ b/pkg/auth/auth.go
@@ -0,0 +1,32 @@
+// Package auth answers three questions for both meshStack front ends: how a caller
+// authenticates, which workspace it acts in, and where its configuration comes from.
+//
+// It reports to a front end in exactly two ways: an error return, or an slog record. There
+// is no third channel, because both front ends already route slog somewhere it is read — the
+// meshStack CLI through its charmbracelet/log handler, the Terraform provider through its
+// tflog bridge. The cost is that a warning reaches a Terraform practitioner only under
+// TF_LOG and never in plan output, which is the trade this package takes. Use the Context
+// form, because the provider's bridge takes terraform's logger out of the context and drops
+// a record that arrives without one.
+//
+// It never prompts. A prompt is not a source, so the three failures a person could answer
+// come back as ErrNoEndpoint, ErrNoApiSecret and ErrNoApiToken, and `meshstack auth login` —
+// the one command allowed to ask — resolves again with the answer in its own source.
+//
+// Session.BearerToken then runs before every HTTP request, so a long `terraform apply`
+// renews mid-flight rather than failing halfway through.
+package auth
+
+import (
+ "context"
+
+ "github.com/meshcloud/meshstack-cli/pkg/oidc"
+)
+
+// TODO user agent should be defined from cmd package when building client.
+const userAgent = "meshstack-cli"
+
+// Browser is a parameter rather than a direct call into pkg/oidc/browser because
+// .golangci.yml denies that package to everything under pkg/, so the Terraform provider
+// cannot link a browser flow at all.
+type Browser func(ctx context.Context, client oidc.Client) (oidc.Token, error)
diff --git a/pkg/auth/credential.go b/pkg/auth/credential.go
new file mode 100644
index 0000000..81e4b5e
--- /dev/null
+++ b/pkg/auth/credential.go
@@ -0,0 +1,281 @@
+package auth
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+type resolvedCredential struct {
+ credential credential.Credential
+ // fromProfile decides the store, and with it whether anything this session mints can ever
+ // reach a file.
+ fromProfile bool
+ origins []Origin
+}
+
+// offer is what one source above the profile carries. A source offering nothing is left out.
+type offer struct {
+ from setting.Source
+ id string
+ token string
+ secret string
+}
+
+// resolveCredential walks the ranked sources once. The first one carrying an identity defines
+// the credential; its secret is its own secret slot when it has one, otherwise the first
+// secret offered by a source that carries no competing identity.
+//
+// It is not setting.Resolve, which resolves one value where this rule relates three. It has to
+// be one walk because the alternative is pairing an id from one place with a secret from
+// another, which meshStack answers with a 401 that names neither of them.
+func resolveCredential(ctx context.Context, opts ResolveSessionOptions, selection profile.Selection, endpoint xurl.URL, sources []setting.Source) (resolvedCredential, error) {
+ above, err := offers(sources)
+ if err != nil {
+ return resolvedCredential{}, err
+ }
+
+ // stored opens credentials/.json on first use and not before. A run whose
+ // credential came from the environment must not fail because the default profile on this
+ // machine points at another meshStack, and that check lives behind this read.
+ var read *profile.Credentials
+ stored := func() (profile.Credentials, error) {
+ if read != nil {
+ return *read, nil
+ }
+ store := opts.Store
+ if store == nil {
+ opened, err := profile.NewFileStore(selection.Name)
+ if err != nil {
+ return profile.Credentials{}, err
+ }
+ store = opened
+ }
+ credentials, err := store.Read()
+ if err != nil {
+ return profile.Credentials{}, err
+ }
+ // Whole-file rather than per-method, because the common path uses a cached access
+ // token without consulting a method at all: without this, repointing a profile's
+ // endpoint would send a stored bearer token to a different meshStack.
+ if credentials.Endpoint != nil && !credentials.Endpoint.Equal(endpoint) {
+ return profile.Credentials{}, fmt.Errorf("this credential belongs to a different meshStack: profile %q was logged in to %s, but this command targets %s. Name another profile with %s, or log in again",
+ selection.Name, credentials.Endpoint, endpoint, profile.NameSetting.EnvKey())
+ }
+ read = &credentials
+ return credentials, nil
+ }
+
+ wants := func(method credential.Method) bool {
+ return opts.DemandMethod == "" || opts.DemandMethod == method
+ }
+ for _, carrying := range above {
+ switch {
+ case carrying.token != "" && wants(credential.MethodManual):
+ token, err := jwt.Parse(carrying.token)
+ if err != nil {
+ return resolvedCredential{}, fmt.Errorf("this is not a meshStack API token: what %s supplied could not be read as an access token: %w",
+ carrying.from.Describe(credential.ApiBearerToken.EnvKey()), err)
+ }
+ return resolvedCredential{
+ credential: credential.FromManual(credential.Manual{AccessToken: issuedToken(token)}),
+ origins: []Origin{{Key: credential.ApiBearerToken.EnvKey(),
+ From: carrying.from.Describe(credential.ApiBearerToken.EnvKey())}},
+ }, nil
+ case carrying.id != "" && wants(credential.MethodApiKey):
+ return apiKeyAbove(ctx, above, carrying, selection.Name, stored)
+ }
+ }
+ return fromTheProfile(ctx, opts.DemandMethod, above, selection.Name, stored)
+}
+
+func offers(sources []setting.Source) ([]offer, error) {
+ found := make([]offer, 0, len(sources))
+ for _, source := range sources {
+ if source == nil {
+ continue
+ }
+ id, err := text(source, credential.ApiKeyClientId)
+ if err != nil {
+ return nil, err
+ }
+ token, err := text(source, credential.ApiBearerToken)
+ if err != nil {
+ return nil, err
+ }
+ secret, err := text(source, credential.ApiKeyClientSecret)
+ if err != nil {
+ return nil, err
+ }
+ carrying := offer{from: source, id: id, token: token, secret: secret}
+ if carrying.id != "" && carrying.token != "" {
+ // Two methods rather than two spellings of one thing, so picking one silently
+ // would hand the user an identity they did not choose.
+ return nil, fmt.Errorf("two authentication methods from one place: %s and %s are both set. They are different methods, and a token needs no key; remove one",
+ source.Describe(credential.ApiKeyClientId.EnvKey()), source.Describe(credential.ApiBearerToken.EnvKey()))
+ }
+ if carrying.id != "" || carrying.token != "" || carrying.secret != "" {
+ found = append(found, carrying)
+ }
+ }
+ return found, nil
+}
+
+// apiKeyAbove completes an API key whose id came from above the profile. That is the normal
+// non-interactive setup — an id in the provider block or on --api-key, the secret in the
+// environment — rather than an edge case.
+func apiKeyAbove(ctx context.Context, above []offer, winner offer, name profile.Name, stored func() (profile.Credentials, error)) (resolvedCredential, error) {
+ key := credential.ApiKey{Id: winner.id}
+ origins := []Origin{{
+ Key: credential.ApiKeyClientId.EnvKey(), From: winner.from.Describe(credential.ApiKeyClientId.EnvKey()),
+ }}
+ var secretFrom setting.SourceDescription
+ competing := ""
+
+ for _, carrying := range above {
+ switch {
+ case carrying.secret == "":
+ case carrying.id != "" && carrying.id != winner.id:
+ // A secret sitting beside another id belongs to that id. Pairing it with this
+ // one produces a 401 that names neither.
+ if competing == "" {
+ competing = carrying.id
+ }
+ case key.Secret == "":
+ key.Secret, secretFrom = carrying.secret, carrying.from.Describe(credential.ApiKeyClientSecret.EnvKey())
+ case carrying.secret != key.Secret:
+ slog.WarnContext(ctx, "another API key secret is set and ignored", "detail", fmt.Sprintf(
+ "%s is also set; the secret from %s is the one being used.",
+ carrying.from.Describe(credential.ApiKeyClientSecret.EnvKey()), secretFrom))
+ }
+ }
+
+ // The profile is the bottom of the same list, which is what lets an id exported for this
+ // run pair with a clientSecretCommand the profile already knows how to run.
+ if key.Secret == "" {
+ credentials, err := stored()
+ if err != nil {
+ return resolvedCredential{}, err
+ }
+ if held := credentials.ApiKey; held != nil && held.Id == winner.id && hasSecret(*held) {
+ key, secretFrom = *held, storedIn(name)
+ }
+ }
+ if !hasSecret(key) {
+ return resolvedCredential{}, missingSecret(winner.from.Describe(credential.ApiKeyClientId.EnvKey()), key.Id, competing)
+ }
+ origins = append(origins, Origin{Key: credential.ApiKeyClientSecret.EnvKey(), From: secretFrom})
+ return resolvedCredential{credential: credential.FromApiKey(key), origins: origins}, nil
+}
+
+// fromTheProfile is the bottom of the ranked order. The credential it returns keeps every
+// method the file holds, so that `meshstack login` switches back to a stored browser login
+// without a new browser session; Current is the one this run authenticates with.
+func fromTheProfile(ctx context.Context, demanded credential.Method, above []offer, name profile.Name, stored func() (profile.Credentials, error)) (resolvedCredential, error) {
+ credentials, err := stored()
+ if err != nil {
+ return resolvedCredential{}, err
+ }
+ current := credentials.Current
+ if demanded != "" {
+ current = demanded
+ }
+ if current == "" {
+ // Nothing stored yet. Login is the method `meshstack login` creates, and the error a
+ // command gets from a profile with no credentials names it.
+ current = credential.MethodLogin
+ }
+
+ resolved := resolvedCredential{credential: credentials.Credential, fromProfile: true}
+ resolved.credential.Current = current
+ switch current {
+ case credential.MethodApiKey:
+ if credentials.ApiKey == nil || credentials.ApiKey.Id == "" {
+ // Only a login can demand this method without an id, and its own exchange is
+ // where the message naming --api-key belongs.
+ return resolved, nil
+ }
+ held := *credentials.ApiKey
+ secretFrom := storedIn(name)
+ switch {
+ case hasSecret(held):
+ warnStoredSecretWins(ctx, above, held, name)
+ default:
+ for _, carrying := range above {
+ if carrying.secret == "" || (carrying.id != "" && carrying.id != held.Id) {
+ continue
+ }
+ held.Secret, secretFrom = carrying.secret, carrying.from.Describe(credential.ApiKeyClientSecret.EnvKey())
+ break
+ }
+ }
+ if !hasSecret(held) {
+ return resolvedCredential{}, missingSecret(storedIn(name), held.Id, "")
+ }
+ resolved.credential.ApiKey = &held
+ resolved.origins = []Origin{
+ {Key: credential.ApiKeyClientId.EnvKey(), From: storedIn(name)},
+ {Key: credential.ApiKeyClientSecret.EnvKey(), From: secretFrom},
+ }
+ case credential.MethodManual:
+ if demanded == credential.MethodManual {
+ // A token cannot be renewed, so demanding this method is asking for a new one and
+ // the profile's stored token is not an answer to it.
+ return resolvedCredential{}, fmt.Errorf("%w: set %s, or pipe one in with `meshstack login --api-token --api-token-stdin`. A token is never a flag value, because a flag value lands in shell history, in ps output and in CI logs",
+ ErrNoApiToken, credential.ApiBearerToken.EnvKey())
+ }
+ resolved.origins = []Origin{{Key: credential.ApiBearerToken.EnvKey(), From: storedIn(name)}}
+ }
+ return resolved, nil
+}
+
+// warnStoredSecretWins is the cost of keeping the profile's paired secret: a rotated
+// MESHSTACK_API_SECRET beside a profile still serving the old one looks exactly like a
+// revoked key. Storing the environment's secret instead is rejected — which of the two is
+// newer is not knowable, and it would make every read path take the write lock.
+func warnStoredSecretWins(ctx context.Context, above []offer, held credential.ApiKey, name profile.Name) {
+ if held.Secret == "" {
+ // Comparing against a clientSecretCommand would mean running it during a resolution.
+ return
+ }
+ for _, carrying := range above {
+ if carrying.secret == "" || carrying.secret == held.Secret {
+ continue
+ }
+ slog.WarnContext(ctx, "the stored API key secret is being used", "detail", fmt.Sprintf(
+ "%s is set and differs from the secret stored for %s in profile %q. The stored one is being used. To replace it, run `meshstack login --api-key=%s --api-secret-stdin`.",
+ carrying.from.Describe(credential.ApiKeyClientSecret.EnvKey()), held.Id, name, held.Id))
+ return
+ }
+}
+
+// missingSecret quotes the losing id deliberately: a client id is not a secret, and it is
+// the fact that identifies which stale export to remove.
+func missingSecret(where setting.SourceDescription, id, competing string) error {
+ if competing == "" {
+ return fmt.Errorf("%w: %s names the API key %s, and nothing supplies its secret. Set %s, or run `meshstack login --api-key=%s --api-secret-stdin`",
+ ErrNoApiSecret, where, id, credential.ApiKeyClientSecret.EnvKey(), id)
+ }
+ return fmt.Errorf("%w: %s names the API key %s, and no secret is available for it. %s is set, but it belongs to %s (%s), which %s overrides. Supply the secret beside %s, or unset %s so that %s pairs with it",
+ ErrNoApiSecret, where, id, credential.ApiKeyClientSecret.EnvKey(), credential.ApiKeyClientId.EnvKey(), competing, where,
+ where, credential.ApiKeyClientId.EnvKey(), credential.ApiKeyClientSecret.EnvKey())
+}
+
+func hasSecret(key credential.ApiKey) bool {
+ return key.Secret != "" || len(key.SecretCommand) > 0
+}
+
+func text(source setting.Source, of setting.Setting[string]) (string, error) {
+ value, err := source.Lookup(of.EnvKey())
+ if err != nil {
+ return "", fmt.Errorf("value from source '%s' could not be looked up: %w", source.Describe(of.EnvKey()), err)
+ }
+ return strings.TrimSpace(value), nil
+}
diff --git a/pkg/auth/devlocal.go b/pkg/auth/devlocal.go
new file mode 100644
index 0000000..a05bfc3
--- /dev/null
+++ b/pkg/auth/devlocal.go
@@ -0,0 +1,220 @@
+package auth
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "slices"
+ "strings"
+
+ "github.com/meshcloud/meshstack-cli/client"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+// DevLocalProfile is the name --dev-local resolves through, and the prefix every profile it
+// writes carries. The prefix is reserved, so that a re-run overwrites those profiles without
+// asking. Keeping it off profile.DefaultName is what makes that safe: a developer's own
+// `default` profile points at a real meshStack and must survive.
+const DevLocalProfile profile.Name = "dev-local"
+
+// DevLocalProfileName turns an api key's configured name or a login's username into the profile
+// --dev-local writes it to: dev-local-terraform-provider-acceptance,
+// dev-local-partner-at-meshcloud-io.
+//
+// An `@` becomes `-at-` rather than a plain `-`, so that the two halves of an address stay
+// readable. Everything else outside a-z0-9 becomes a single `-`, which can in principle map two
+// different names onto one profile; that fails here rather than letting the later one silently
+// take the earlier one's credential.
+func DevLocalProfileName(name string) (profile.Name, error) {
+ slug := devLocalSlug(name)
+ if slug == "" {
+ return "", fmt.Errorf("cannot name a profile after this local dev credential: %q has no character a profile name can be built from", name)
+ }
+ claimed, taken := devLocalSlugs[slug]
+ if taken && claimed != name {
+ return "", fmt.Errorf("two local dev credentials want the same profile: %q and %q both become the profile %s-%s. Rename one of them in the dev stack's configuration",
+ claimed, name, DevLocalProfile, slug)
+ }
+ devLocalSlugs[slug] = name
+ return profile.ParseName(string(DevLocalProfile) + "-" + slug)
+}
+
+// devLocalSlugs remembers what each slug was built from, for the collision check above. One login
+// runs one --dev-local, so a process-wide map is the whole of the bookkeeping needed.
+var devLocalSlugs = map[string]string{}
+
+func devLocalSlug(name string) string {
+ var b strings.Builder
+ lastDash := true
+ for _, r := range strings.ToLower(strings.ReplaceAll(name, "@", "-at-")) {
+ switch {
+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
+ b.WriteRune(r)
+ lastDash = false
+ case !lastDash:
+ b.WriteByte('-')
+ lastDash = true
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+// devLocalEndpoint is the only default endpoint anywhere in this package: guessing one for a
+// real meshStack would send a credential somewhere nobody named, while guessing one for a
+// stack on the developer's own machine is what makes the flag configless.
+const devLocalEndpoint = "http://localhost:8080"
+
+// ResolveForDevLocalLogin produces the session `meshstack login --dev-local` works through:
+// ResolveSession with the two defaults that flag brings, and with the profile's own store,
+// because bootstrapping a profile is the whole of what the flag does.
+//
+// The endpoint default is decided here rather than declared, because it sits below the
+// selected profile's own endpoint while the profile name sits above currentProfile. Both
+// would be wrong for every other command, so neither belongs on a declaration.
+func ResolveForDevLocalLogin(ctx context.Context, settings setting.ExplicitSource) (*Session, error) {
+ defaults := devLocalDefaults{name: DevLocalProfile}
+ selection, err := profile.Select(ctx, explicit(settings), defaults)
+ if err != nil {
+ return nil, err
+ }
+ if selection.Endpoint == "" && selection.Entry.Endpoint == nil {
+ defaults.endpoint = devLocalEndpoint
+ }
+ store, err := profile.NewFileStore(selection.Name)
+ if err != nil {
+ return nil, err
+ }
+ return ResolveSession(ctx, ResolveSessionOptions{
+ Settings: settings,
+ Defaults: defaults,
+ DemandMethod: credential.MethodApiKey,
+ Store: store,
+ })
+}
+
+// devLocalDefaults answers those two settings and no others, unlike setting.Default, which
+// answers whatever key it is asked because it is only ever placed in one setting's list.
+type devLocalDefaults struct {
+ name profile.Name
+ endpoint string
+}
+
+func (d devLocalDefaults) Lookup(key string) (string, error) {
+ switch key {
+ case profile.NameSetting.EnvKey():
+ return string(d.name), nil
+ case meshstack.Endpoint.EnvKey():
+ return d.endpoint, nil
+ }
+ return "", nil
+}
+
+func (devLocalDefaults) Describe(key string) setting.SourceDescription {
+ switch key {
+ case profile.NameSetting.EnvKey(), meshstack.Endpoint.EnvKey():
+ return setting.SourceDescription{Type: "the --dev-local default"}
+ }
+ return setting.SourceDescription{}
+}
+
+// LoginDevLocal bootstraps the profile from what a local dev stack publishes in /mesh/info,
+// so running against one needs no .env file and no key issued by hand. It takes no
+// LoginOptions: the exchange happens every time, and the workspace comes out of the same
+// document.
+func (s *Session) LoginDevLocal(ctx context.Context) (LoginResult, error) {
+ return s.login(ctx, credential.MethodApiKey, func(result *LoginResult) error {
+ return s.loginDevLocal(ctx, result)
+ })
+}
+
+func (s *Session) loginDevLocal(ctx context.Context, result *LoginResult) error {
+ // The same unauthenticated fetch pkg/oidc makes during discovery. /mesh/info is public, so
+ // this runs before the session holds any credential at all — which is the point.
+ info, err := client.NewMeshInfoClient(ctx, s.Endpoint.URL, http.NewClient(userAgent, nil)).Read(ctx)
+ if err != nil {
+ return fmt.Errorf("cannot read this meshStack's public information: %s did not answer /mesh/info: %w", s.Endpoint, err)
+ }
+ dev := info.DevLocalCredentials
+ if dev == nil {
+ return fmt.Errorf("this meshStack publishes no local dev credentials: %s serves no devLocalCredentials in /mesh/info. meshStack publishes them only on a local dev stack, so --dev-local cannot bootstrap anything here. Use `meshstack login --api-key=` with a key issued in meshPanel instead",
+ s.Endpoint)
+ }
+ if len(dev.ApiKeys) == 0 {
+ return fmt.Errorf("this meshStack publishes no local dev api keys: %s serves devLocalCredentials in /mesh/info but no apiKeys in it, so there is nothing to log in with",
+ s.Endpoint)
+ }
+
+ names, err := s.bootstrapApiKeyProfiles(ctx, dev)
+ if err != nil {
+ return err
+ }
+ userNames, err := s.bootstrapUserProfiles(dev)
+ if err != nil {
+ return err
+ }
+ result.Username = strings.Join(append(names, userNames...), ", ")
+ return nil
+}
+
+// bootstrapApiKeyProfiles writes one logged-in profile per published key. Every key gets one
+// rather than one being chosen here, because they do not hold the same rights and which is
+// wanted is the caller's question, not this flag's.
+func (s *Session) bootstrapApiKeyProfiles(ctx context.Context, dev *client.DevLocalCredentials) ([]string, error) {
+ written := make([]string, 0, len(dev.ApiKeys))
+ for _, name := range slices.Sorted(maps.Keys(dev.ApiKeys)) {
+ key := dev.ApiKeys[name]
+ profileName, err := DevLocalProfileName(name)
+ if err != nil {
+ return nil, err
+ }
+ token, err := apiLogin(ctx, s.Endpoint, key.ClientId, key.ClientSecret)
+ if err != nil {
+ return nil, err
+ }
+ if err := profile.Ensure(profileName, &s.Endpoint); err != nil {
+ return nil, err
+ }
+ store, err := profile.NewFileStore(profileName)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := store.Update(ctx, func(c profile.Credentials) (profile.Credentials, error) {
+ c.Version = profile.Version
+ c.Endpoint = &s.Endpoint
+ stored := credential.ApiKey{Id: key.ClientId, Secret: key.ClientSecret, AccessToken: issuedToken(token)}
+ c.Credential = switchTo(c.Credential, credential.FromApiKey(stored))
+ return c, nil
+ }); err != nil {
+ return nil, err
+ }
+ written = append(written, string(profileName))
+ }
+ return written, nil
+}
+
+// bootstrapUserProfiles writes one profile per seeded login, carrying the endpoint and nothing
+// else. No credential, because the CLI's keycloak client runs the authorization code flow and has
+// no password grant, so `meshstack login --profile ` still has to do the browser exchange.
+//
+// No default workspace either, deliberately: one of these logs in and discovers what it can reach
+// exactly as any other user does, and a workspace put here by the flag would make that path
+// untested for the one stack where it is easiest to test. What the profile saves is naming the
+// endpoint again, and nothing more.
+func (s *Session) bootstrapUserProfiles(dev *client.DevLocalCredentials) ([]string, error) {
+ written := make([]string, 0, len(dev.Users))
+ for _, username := range slices.Sorted(maps.Keys(dev.Users)) {
+ profileName, err := DevLocalProfileName(username)
+ if err != nil {
+ return nil, err
+ }
+ if err := profile.Ensure(profileName, &s.Endpoint); err != nil {
+ return nil, err
+ }
+ written = append(written, string(profileName))
+ }
+ return written, nil
+}
diff --git a/pkg/auth/hint.go b/pkg/auth/hint.go
new file mode 100644
index 0000000..725a15f
--- /dev/null
+++ b/pkg/auth/hint.go
@@ -0,0 +1,45 @@
+package auth
+
+import (
+ "errors"
+ "log/slog"
+ "strings"
+
+ "github.com/meshcloud/meshstack-cli/client"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+)
+
+// HintErr logs a warning for the one failure whose cause is not in the error text, and
+// returns err untouched. It takes the Authorization because the hint names the scope the token
+// carried.
+//
+// It never retries and never re-scopes: a command acts in the workspace it was told to act
+// in. meshfed does answer an insufficiently scoped request with an insufficient_scope
+// challenge, but only on the internal API's workspace switch — the public API answers a plain
+// forbidden with no scope hint, so there is nothing for the CLI to read even if it wanted to.
+func HintErr(err error, authz client.Authorization) error {
+ if err == nil {
+ return nil
+ }
+ session, _ := authz.(*Session)
+
+ if httpErr, ok := errors.AsType[client.HttpError](err); ok && httpErr.IsForbidden() {
+ slog.Warn(forbiddenHint(session))
+ }
+ return err
+}
+
+func forbiddenHint(session *Session) string {
+ if session == nil {
+ return "the credential this command used does not reach that object."
+ }
+ // A token that carries no workspace scope at all — an API key or a pasted token — gets the
+ // same note without a workspace name, because the credential's own workspace is what
+ // decides what it reaches.
+ if session.Method() != credential.MethodLogin || strings.TrimSpace(session.Workspace) == "" {
+ return "this credential's own workspace is what decides what it reaches, and naming another one cannot widen it."
+ }
+ return "this token is scoped to workspace " + session.Workspace +
+ ". If the object belongs to another workspace, name that one with " + meshstack.Workspace.EnvKey() + "."
+}
diff --git a/pkg/auth/hint_test.go b/pkg/auth/hint_test.go
new file mode 100644
index 0000000..51e63a2
--- /dev/null
+++ b/pkg/auth/hint_test.go
@@ -0,0 +1,105 @@
+package auth
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/client"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+)
+
+// TestHintErrExplainsTheFailureWhoseCauseIsNotInTheErrorText holds the hint table: a 403 is
+// explained by the scope the token carried. The error itself is returned untouched in every case.
+func TestHintErrExplainsTheFailureWhoseCauseIsNotInTheErrorText(t *testing.T) {
+ forbidden := client.HttpError{StatusCode: 403, ResponseBody: []byte("Access denied")}
+
+ t.Run("a 403 with a workspace-scoped login names the workspace", func(t *testing.T) {
+ session := &Session{Workspace: "demo", current: credential.MethodLogin}
+ logs := captureLogs(t)
+
+ // Wrapped, because what a command actually holds is the client's error inside its own.
+ wrapped := fmt.Errorf("listing building blocks: %w", forbidden)
+ require.Equal(t, wrapped, HintErr(wrapped, session), "the error is returned untouched")
+
+ warnings := logs.warnings()
+ require.Len(t, warnings, 1)
+ assert.Contains(t, warnings[0], "scoped to workspace demo")
+ assert.Contains(t, warnings[0], meshstack.Workspace.EnvKey())
+ })
+
+ t.Run("a 403 with any other credential names its own workspace", func(t *testing.T) {
+ session := &Session{Workspace: "demo", current: credential.MethodApiKey}
+ logs := captureLogs(t)
+
+ require.Equal(t, forbidden, HintErr(forbidden, session))
+
+ warnings := logs.warnings()
+ require.Len(t, warnings, 1)
+ assert.Contains(t, warnings[0], "this credential's own workspace is what decides")
+ assert.NotContains(t, warnings[0], "demo", "an API key token is not scoped by a workspace setting")
+ })
+
+ t.Run("a 403 without a session at all", func(t *testing.T) {
+ logs := captureLogs(t)
+
+ require.Equal(t, forbidden, HintErr(forbidden, client.NewApiTokenAuthorization("pasted-token")))
+
+ warnings := logs.warnings()
+ require.Len(t, warnings, 1)
+ assert.Contains(t, warnings[0], "does not reach that object")
+ })
+
+ t.Run("anything else is passed through in silence", func(t *testing.T) {
+ logs := captureLogs(t)
+
+ notFound := client.HttpError{StatusCode: 404}
+ require.Equal(t, notFound, HintErr(notFound, nil))
+ require.NoError(t, HintErr(nil, nil))
+ require.Empty(t, logs.warnings())
+ })
+}
+
+func captureLogs(t *testing.T) *logRecorder {
+ t.Helper()
+ recorder := &logRecorder{}
+ previous := slog.Default()
+ slog.SetDefault(slog.New(recorder))
+ t.Cleanup(func() { slog.SetDefault(previous) })
+ return recorder
+}
+
+type logRecorder struct {
+ mu sync.Mutex
+ records []slog.Record
+}
+
+func (r *logRecorder) Enabled(context.Context, slog.Level) bool { return true }
+
+func (r *logRecorder) Handle(_ context.Context, record slog.Record) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.records = append(r.records, record.Clone())
+ return nil
+}
+
+func (r *logRecorder) WithAttrs([]slog.Attr) slog.Handler { return r }
+
+func (r *logRecorder) WithGroup(string) slog.Handler { return r }
+
+func (r *logRecorder) warnings() (messages []string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ for _, record := range r.records {
+ if record.Level >= slog.LevelWarn {
+ messages = append(messages, record.Message)
+ }
+ }
+ return
+}
diff --git a/pkg/auth/login.go b/pkg/auth/login.go
new file mode 100644
index 0000000..88c13a3
--- /dev/null
+++ b/pkg/auth/login.go
@@ -0,0 +1,328 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/meshcloud/meshstack-cli/client"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+ "github.com/meshcloud/meshstack-cli/pkg/tty"
+)
+
+type LoginOptions struct {
+ // Force skips the probe of the stored login, which is how a half-broken profile is fixed
+ // without deleting files by hand. Choosing a method implies it, because changing method
+ // is explicit by nature.
+ Force bool
+
+ // ChooseWorkspace is nil where the front end cannot ask — the Terraform provider, or a
+ // CLI with no terminal — and the login then leaves the profile's default alone.
+ ChooseWorkspace func(ctx context.Context, candidates []string) (string, error)
+
+ // Browser is nil where the front end has no way to open one, and a login that has to
+ // create a session then fails instead.
+ Browser Browser
+}
+
+// LoginResult is the facts rather than a sentence, so the CLI and the Terraform provider can
+// word them their own way.
+type LoginResult struct {
+ Method credential.Method
+ Endpoint string
+ Profile profile.Name
+ Workspace string
+
+ // AlreadyLoggedIn reports that the stored login still worked, so no browser was opened.
+ AlreadyLoggedIn bool
+ // SwitchedFrom is empty when nothing changed.
+ SwitchedFrom credential.Method
+ // Username is the preferred_username claim of the token that was obtained.
+ Username string
+ // ExpiresAt is the deadline of a stored API token, the one credential with a deadline
+ // nothing can extend. ExpiryKnown is false for a token that carries no exp claim, which
+ // is stored with an unknown expiry rather than a guessed one.
+ ExpiresAt time.Time
+ ExpiryKnown bool
+}
+
+// Login is the only thing that switches method, and switching discards every cached access
+// token, because those tokens carry the old identity.
+func (s *Session) Login(ctx context.Context, options LoginOptions) (LoginResult, error) {
+ demanded := s.Method()
+ return s.login(ctx, demanded, func(result *LoginResult) error {
+ switch demanded {
+ case credential.MethodManual:
+ return s.loginWithApiToken(ctx, result)
+ case credential.MethodApiKey:
+ return s.loginWithApiKey(ctx, result)
+ default:
+ return s.loginWithBrowser(ctx, options, result)
+ }
+ })
+}
+
+// login is the bookkeeping every login shares, around the one exchange that differs.
+//
+// Switching method deliberately does not force a fresh login: switching back to a browser
+// login costs one refresh rather than a new browser session, which is what makes a profile
+// shared between CI and a developer usable. `--api-key` and `--api-token` set Force
+// themselves, because for them forcing only means "do the exchange again".
+func (s *Session) login(ctx context.Context, demanded credential.Method, exchange func(*LoginResult) error) (LoginResult, error) {
+ result := LoginResult{Method: demanded, Endpoint: s.Endpoint.String(), Profile: s.Profile, Workspace: s.Workspace}
+
+ credentials, err := s.currentStore().Read()
+ if err != nil {
+ return result, err
+ }
+ if credentials.Current != "" && credentials.Current != demanded {
+ result.SwitchedFrom = credentials.Current
+ }
+
+ if err := exchange(&result); err != nil {
+ return result, err
+ }
+
+ // Warned only once the switch has happened: a warning after a failed login would send the
+ // user looking for tokens that are still there.
+ if result.SwitchedFrom != "" {
+ slog.WarnContext(ctx, "switching authentication method",
+ "detail", fmt.Sprintf("profile %q was using its %s; switched to the %s and discarded the cached tokens. `meshstack login` switches back.",
+ s.Profile, result.SwitchedFrom.Description(), demanded.Description()))
+ }
+
+ s.mu.Lock()
+ s.current, s.cached = demanded, credential.IssuedToken{}
+ s.mu.Unlock()
+ return result, nil
+}
+
+// loginWithBrowser probes the stored login first: `auth login` belongs in setup scripts and
+// README instructions, so re-running it must not pop a browser every time.
+func (s *Session) loginWithBrowser(ctx context.Context, options LoginOptions, result *LoginResult) error {
+ config, err := s.discover(ctx)
+ if err != nil {
+ return err
+ }
+
+ credentials, err := s.currentStore().Read()
+ if err != nil {
+ return err
+ }
+ if !options.Force && credentials.Login != nil && credentials.Login.RefreshToken != "" {
+ if err := s.probeLogin(ctx, config, result); err == nil {
+ result.AlreadyLoggedIn = true
+ return s.chooseWorkspace(ctx, options, result)
+ } else {
+ slog.Debug("the stored login did not work, opening a browser", "error", err)
+ }
+ }
+
+ if options.Browser == nil {
+ return errors.New("this front end cannot create a login: the stored login is gone and nothing here can start a new one. Run `meshstack login`, or use an API key")
+ }
+ // A terminal is deliberately not required: the URL goes to stderr, which reaches a person
+ // from a pipe as well. Only somebody saying nobody is watching refuses this.
+ if s.noInput {
+ return fmt.Errorf("cannot wait for a browser login: %s says nobody is here to visit the login URL. Use `meshstack auth login --api-key=` instead", tty.NoInput.EnvKey())
+ }
+
+ token, err := options.Browser(ctx, config)
+ if err != nil {
+ return err
+ }
+ result.Username = jwt.UsernameClaim.GetFrom(token.AccessToken)
+
+ // The unscoped access token goes into the store alongside the refresh token in one write,
+ // because keycloak rotates on every refresh and separating the two ends the session.
+ if _, err := s.currentStore().Update(ctx, func(c profile.Credentials) (profile.Credentials, error) {
+ c.Version = profile.Version
+ c.Endpoint = &s.Endpoint
+ c.Credential = switchTo(c.Credential, credential.FromLogin(credential.Login{
+ Issuer: &config.Issuer,
+ RefreshToken: token.RefreshToken,
+ // Nothing in the token says when the login dies, while the server caps the
+ // session at 24 hours. Recording when it happened and reporting its age beats
+ // predicting a deadline from a constant in another repository.
+ ObtainedAt: time.Now().UTC(),
+ AccessTokens: map[scope.Scope]credential.IssuedToken{
+ meshstack.Unscoped: issuedToken(token.AccessToken),
+ },
+ }))
+ return c, nil
+ }); err != nil {
+ return err
+ }
+ return s.chooseWorkspace(ctx, options, result)
+}
+
+func (s *Session) probeLogin(ctx context.Context, config oidc.Client, result *LoginResult) error {
+ var probed jwt.JWT
+ _, err := s.currentStore().Update(ctx, func(c profile.Credentials) (profile.Credentials, error) {
+ token, err := config.Refresh(ctx, c.Login.RefreshToken, "")
+ if err != nil {
+ return c, err
+ }
+ probed = token.AccessToken
+ login := *c.Login
+ login.RefreshToken = token.RefreshToken
+ c.Credential = switchTo(c.Credential, credential.FromLogin(login))
+ return withToken(c, credential.MethodLogin, meshstack.Unscoped, issuedToken(token.AccessToken)), nil
+ })
+ if err != nil {
+ return err
+ }
+ result.Username = jwt.UsernameClaim.GetFrom(probed)
+ return nil
+}
+
+// chooseWorkspace lists the workspaces with the unscoped token the login just obtained,
+// which is the only thing an unscoped user token is good for.
+func (s *Session) chooseWorkspace(ctx context.Context, options LoginOptions, result *LoginResult) error {
+ if s.Workspace != "" {
+ return s.rememberWorkspace(s.Workspace, result)
+ }
+ if options.ChooseWorkspace == nil {
+ return nil
+ }
+ candidates, err := s.Workspaces(ctx)
+ if err != nil {
+ return err
+ }
+ chosen, err := options.ChooseWorkspace(ctx, candidates)
+ if err != nil || chosen == "" {
+ return err
+ }
+ s.Workspace = chosen
+ return s.rememberWorkspace(chosen, result)
+}
+
+func (s *Session) rememberWorkspace(name string, result *LoginResult) error {
+ result.Workspace = name
+ if s.Profile == "" {
+ return nil
+ }
+ return profile.SetWorkspace(s.Profile, name)
+}
+
+func (s *Session) loginWithApiKey(ctx context.Context, result *LoginResult) error {
+ // The resolution already paired an id with a secret, and refused with ErrNoApiSecret if it
+ // could not. What is left here is the exchange and the write.
+ offered := s.resolved.ApiKey
+ if offered == nil || offered.Id == "" {
+ return fmt.Errorf("no API key id: name one with --api-key= or %s. The id is the only part of a credential that may appear on a command line",
+ credential.ApiKeyClientId.EnvKey())
+ }
+ secret, err := offered.Resolve(ctx)
+ if err != nil {
+ return err
+ }
+ if err := credential.CheckSecret(secret); err != nil {
+ return err
+ }
+ token, err := apiLogin(ctx, s.Endpoint, offered.Id, secret)
+ if err != nil {
+ return err
+ }
+
+ // A key whose secret this profile already knows how to produce keeps saying so; anything
+ // else is stored with the secret that just worked, and never with the token it carried in.
+ keeping := &credential.ApiKey{Id: offered.Id, Secret: secret}
+ if len(offered.SecretCommand) > 0 {
+ keeping = &credential.ApiKey{Id: offered.Id, SecretCommand: offered.SecretCommand}
+ }
+ if err := s.storeApiKey(ctx, keeping, token); err != nil {
+ return err
+ }
+ result.Username = offered.Id
+ return nil
+}
+
+// storeApiKey is the one place that decides what an API key credential looks like on disk,
+// and it writes the method and the token it just minted in one update.
+func (s *Session) storeApiKey(ctx context.Context, apiKey *credential.ApiKey, token jwt.JWT) error {
+ _, err := s.currentStore().Update(ctx, func(c profile.Credentials) (profile.Credentials, error) {
+ c.Version = profile.Version
+ c.Endpoint = &s.Endpoint
+ key := *apiKey
+ key.AccessToken = issuedToken(token)
+ c.Credential = switchTo(c.Credential, credential.FromApiKey(key))
+ return c, nil
+ })
+ return err
+}
+
+func (s *Session) loginWithApiToken(ctx context.Context, result *LoginResult) error {
+ // Resolved and parsed already, and refused with ErrNoApiToken if nothing supplied one.
+ if s.resolved.Manual == nil {
+ return ErrNoApiToken
+ }
+ issued := s.resolved.Manual.AccessToken
+ if expiry := jwt.Expiry.GetFrom(issued.Token); expiry != nil {
+ result.ExpiresAt, result.ExpiryKnown = *expiry, true
+ }
+ result.Username = jwt.UsernameClaim.GetFrom(issued.Token)
+
+ _, err := s.currentStore().Update(ctx, func(c profile.Credentials) (profile.Credentials, error) {
+ c.Version = profile.Version
+ c.Endpoint = &s.Endpoint
+ c.Credential = switchTo(c.Credential, credential.FromManual(credential.Manual{AccessToken: issued}))
+ return c, nil
+ })
+ return err
+}
+
+// switchTo keeps the methods it replaces, so that switching back costs one refresh, but drops
+// their cached access tokens: a token carries the identity of the method that minted it. A
+// pasted token is not kept, because the token is the whole of that method.
+func switchTo(previous, selected credential.Credential) credential.Credential {
+ if selected.Login == nil && previous.Login != nil {
+ login := *previous.Login
+ login.AccessTokens = nil
+ selected.Login = &login
+ }
+ if selected.ApiKey == nil && previous.ApiKey != nil {
+ apiKey := *previous.ApiKey
+ apiKey.AccessToken = credential.IssuedToken{}
+ selected.ApiKey = &apiKey
+ }
+ return selected
+}
+
+// Logout removes the profile's credentials file. There is no per-method logout: the file is
+// the login. Plain logout is local only; `--revoke` also ends the session at the provider,
+// because the endpoints that list and revoke CLI logins live on meshStack's internal API and
+// a CLI token cannot reach them — meshPanel's Profile → CLI Logins page is the other way.
+func (s *Session) Logout(ctx context.Context, revoke bool) error {
+ if revoke {
+ credentials, err := s.currentStore().Read()
+ if err != nil {
+ return err
+ }
+ if credentials.Login != nil && credentials.Login.RefreshToken != "" {
+ config, err := s.discover(ctx)
+ if err != nil {
+ return err
+ }
+ if err := config.EndSession(ctx, credentials.Login.RefreshToken); err != nil {
+ // A refusal means the session is already gone, which is what the user asked
+ // for. Anything else leaves it alive, and a silent logout would hide that.
+ if _, refused := errors.AsType[client.HttpError](err); !refused {
+ return err
+ }
+ slog.DebugContext(ctx, "the identity provider reports the session was already gone", "error", err)
+ }
+ }
+ }
+ s.mu.Lock()
+ s.cached, s.current = credential.IssuedToken{}, ""
+ s.mu.Unlock()
+ return s.currentStore().Forget()
+}
diff --git a/pkg/auth/resolve.go b/pkg/auth/resolve.go
new file mode 100644
index 0000000..4167ed6
--- /dev/null
+++ b/pkg/auth/resolve.go
@@ -0,0 +1,218 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "sync"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+ "github.com/meshcloud/meshstack-cli/pkg/tty"
+)
+
+// Origin is where one resolved value came from.
+type Origin struct {
+ Key string
+ From setting.SourceDescription
+}
+
+// storedIn describes a value the profile supplied. The profile is no setting.Source: its
+// credentials file hands over a Login and an unresolved SecretCommand too.
+func storedIn(name profile.Name) setting.SourceDescription {
+ return setting.SourceDescription{Type: "profile", Details: string(name)}
+}
+
+// explicit maps a front end that contributed nothing to nil, which Resolve skips. A zero
+// ExplicitSource is not nil, so Resolve cannot do it.
+func explicit(s setting.ExplicitSource) setting.Source {
+ if s.Source == nil {
+ return nil
+ }
+ return s
+}
+
+// The three failures `meshstack auth login` may answer with a prompt, and the reason
+// ResolveSession names them at all: every other command reports the error as it came,
+// because a command that is not a login should not open a login dialogue.
+var (
+ ErrNoEndpoint = errors.New("no meshStack endpoint")
+ ErrNoApiSecret = errors.New("no API key secret")
+ ErrNoApiToken = errors.New("no meshStack API token")
+)
+
+// Session is the client.Authorization both front ends hand to client.New. It is safe for
+// concurrent use, because a Terraform provider has many requests in flight at once.
+type Session struct {
+ Endpoint xurl.URL
+ Workspace string
+ Profile profile.Name
+
+ // noInput says nobody is here to wait on. It is the resolved tty.NoInput setting rather
+ // than a question asked of the terminal, because a browser login reaches a person
+ // through stderr from a pipe as well.
+ noInput bool
+
+ // resolved is the credential the ranked order settled on. The store holds it too for
+ // every command but a login, where the store is the file the login is about to write.
+ resolved credential.Credential
+
+ origins []Origin
+ store profile.Store
+
+ mu sync.Mutex
+ // current is the method every cached token belongs to. It is read under mu because
+ // Login rewrites it.
+ current credential.Method
+ // cached costs no I/O to check, which is what keeps BearerToken off the filesystem. A
+ // rejected token is not recorded beside it: RefreshBearerToken takes that as an argument,
+ // so "do not hand this one out again" lasts exactly as long as the call that needs it.
+ cached credential.IssuedToken
+ // oidcConfig is discovered at most once per process, and only by the login method.
+ oidcConfig *oidc.Client
+}
+
+// ResolveSessionOptions is a struct rather than bare arguments so that a later addition — a
+// clock, a second source — is a new field instead of a signature change both front ends have
+// to follow.
+type ResolveSessionOptions struct {
+ // Settings is the front end's own source: the CLI's flags, or the provider block. A zero
+ // one is a front end contributing nothing explicit rather than an error.
+ Settings setting.ExplicitSource
+
+ // DemandMethod is not a setting: it has no MESHSTACK_* name and supplies no value. It
+ // filters what every source may offer, so a source carrying another method's identity is
+ // passed over as if it were empty — which is what keeps a bare `meshstack login` from
+ // resolving an exported API key and refusing to open a browser.
+ DemandMethod credential.Method
+
+ // Store is handed in by a command that configures a profile, and saying so has two
+ // effects: the credential reaches that file whatever supplied it, and a profile that
+ // does not exist yet is tolerated rather than reported.
+ //
+ // Left nil, the store is chosen: the profile's credentials file when the credential came
+ // from it, and a memory store when it came from anywhere above. That is what makes a CI
+ // job and a building block run need no files at all.
+ Store profile.Store
+
+ // Defaults sits below the environment. Only --dev-local brings any.
+ Defaults setting.Source
+}
+
+// ResolveSession applies every precedence rule, here and only here, in the order
+// explicit → environment → profile → built-in default.
+//
+// It makes no request: it reads the two configuration files and nothing else. The context is
+// for the log records and for the warnings the credential walk emits.
+func ResolveSession(ctx context.Context, opts ResolveSessionOptions) (*Session, error) {
+ sources := []setting.Source{explicit(opts.Settings), opts.Defaults}
+
+ selection, err := profile.Select(ctx, sources...)
+ if err != nil {
+ return nil, err
+ }
+ // A mistyped --profile must report an unknown profile rather than quietly creating one.
+ if selection.Named && !selection.Exists && opts.Store == nil {
+ return nil, fmt.Errorf("unknown profile: profile %q is not in %s. `meshstack auth login --profile %s` creates it",
+ selection.Name, profile.DescribeConfigPath(), selection.Name)
+ }
+
+ // The endpoint's list is explicit → environment → profile throughout. Select evaluated
+ // the prefix, because the profile is what it was being used to pick; this is the one
+ // remaining source, and re-reading the prefix could not change the answer.
+ session := &Session{Profile: selection.Name, origins: selectionOrigins(selection)}
+ raw := selection.Endpoint
+ if raw == "" && selection.Entry.Endpoint != nil {
+ raw = selection.Entry.Endpoint.String()
+ session.origins = append(session.origins, Origin{
+ Key: meshstack.Endpoint.EnvKey(), From: storedIn(selection.Name),
+ })
+ }
+ if raw == "" {
+ return nil, fmt.Errorf("%w: profile %q names no endpoint. Set it with %s, or with `meshstack profile set endpoint `",
+ ErrNoEndpoint, selection.Name, meshstack.Endpoint.EnvKey())
+ }
+ if session.Endpoint, err = meshstack.Endpoint.Parse(raw); err != nil {
+ return nil, err
+ }
+
+ resolved, err := resolveCredential(ctx, opts, selection, session.Endpoint, sources)
+ if err != nil {
+ return nil, err
+ }
+ session.resolved, session.current = resolved.credential, resolved.credential.Current
+ session.origins = append(session.origins, resolved.origins...)
+
+ // Reported here rather than from Select, because it is the credential that makes an
+ // unconfigured machine a failure: one supplied from above needs no profile at all.
+ if !selection.Exists && resolved.fromProfile && opts.Store == nil {
+ return nil, fmt.Errorf("no profile for this endpoint: no profile in %s is configured for %s, and no credential was supplied through %s and %s. `meshstack auth login --endpoint %s` creates one",
+ profile.DescribeConfigPath(), session.Endpoint, credential.ApiKeyClientId.EnvKey(), credential.ApiKeyClientSecret.EnvKey(), session.Endpoint)
+ }
+
+ workspace, workspaceResolution, err := setting.Resolve(meshstack.Workspace, sources...)
+ if err != nil {
+ return nil, err
+ }
+ switch from := workspaceResolution.From; {
+ case from != nil:
+ session.Workspace = workspace
+ session.origins = append(session.origins, Origin{
+ Key: meshstack.Workspace.EnvKey(), From: from.Describe(meshstack.Workspace.EnvKey()),
+ })
+ case selection.Entry.DefaultWorkspace != "":
+ session.Workspace = selection.Entry.DefaultWorkspace
+ session.origins = append(session.origins, Origin{
+ Key: meshstack.Workspace.EnvKey(), From: storedIn(selection.Name),
+ })
+ }
+
+ noInput, noInputResolution, err := setting.Resolve(tty.NoInput, sources...)
+ if err != nil {
+ return nil, err
+ }
+ session.noInput = noInput
+ if from := noInputResolution.From; from != nil {
+ session.origins = append(session.origins, Origin{
+ Key: tty.NoInput.EnvKey(), From: from.Describe(tty.NoInput.EnvKey()),
+ })
+ }
+
+ switch {
+ case opts.Store != nil:
+ session.store = opts.Store
+ case resolved.fromProfile:
+ if session.store, err = profile.NewFileStore(selection.Name); err != nil {
+ return nil, err
+ }
+ default:
+ // An ephemeral credential — from HCL, the environment or a prompt — lands in a store
+ // that cannot write, so it can never mix its identity into somebody's profile.
+ session.store = profile.NewMemoryStore(profile.Credentials{
+ Endpoint: &session.Endpoint, Credential: resolved.credential,
+ })
+ }
+
+ slog.DebugContext(ctx, "resolved a session",
+ "profile", session.Profile, "method", session.current,
+ "endpoint", session.Endpoint.String(), "workspace", session.Workspace,
+ "store", session.store.Describe())
+ return session, nil
+}
+
+// selectionOrigins turns the profile layer's two answers into origins.
+func selectionOrigins(selection profile.Selection) []Origin {
+ origins := []Origin{{Key: profile.NameSetting.EnvKey(), From: selection.NameFrom}}
+ if selection.EndpointFrom != (setting.SourceDescription{}) {
+ origins = append(origins, Origin{Key: meshstack.Endpoint.EnvKey(), From: selection.EndpointFrom})
+ }
+ return origins
+}
+
+// Origins is where each resolved value came from, in the order the resolution walked them.
+func (s *Session) Origins() []Origin { return s.origins }
diff --git a/pkg/auth/status.go b/pkg/auth/status.go
new file mode 100644
index 0000000..3e29b49
--- /dev/null
+++ b/pkg/auth/status.go
@@ -0,0 +1,97 @@
+package auth
+
+import (
+ "time"
+
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+// Status is everything `meshstack auth status` reports without making a network call. That
+// is the whole stored state except whether the credential was revoked, which nothing local
+// can know because a refresh token carries no expiry — so proving that gets a --verify flag
+// rather than a round trip on every call.
+type Status struct {
+ Profile profile.Name
+ ConfigPath string
+ CredentialsPath string
+ Endpoint string
+ Workspace string
+
+ // Origins is in the order the resolution walked the sources.
+ Origins []Origin
+
+ Current credential.Method
+ Login *LoginStatus
+ ApiKey *ApiKeyStatus
+ Token *TokenStatus
+}
+
+type LoginStatus struct {
+ Issuer string
+ ObtainedAt time.Time
+ // Age is how long ago the login happened. A meshStack CLI login lasts at most 24 hours,
+ // idle and absolute, but that is a server-side constant: reporting the age is always
+ // truthful where predicting the deadline would not be.
+ Age time.Duration
+}
+
+type ApiKeyStatus struct {
+ ClientId string
+ // SecretFrom names the command that prints the secret, and is empty for one held as a
+ // literal — where it came from is the MESHSTACK_API_SECRET origin's job.
+ SecretFrom string
+}
+
+type TokenStatus struct {
+ Scope scope.Scope
+ ExpiresAt time.Time
+ // ExpiresIn is negative for a token that has already expired, and zero when the token
+ // carries no expiry at all.
+ ExpiresIn time.Duration
+}
+
+// Status reads the stored state. It makes no network call, so it stays fast enough for a
+// shell prompt or a script.
+func (s *Session) Status() (Status, error) {
+ credentials, err := s.currentStore().Read()
+ if err != nil {
+ return Status{}, err
+ }
+
+ status := Status{
+ Profile: s.Profile,
+ CredentialsPath: s.currentStore().Describe(),
+ Endpoint: s.Endpoint.String(),
+ Workspace: s.Workspace,
+ Origins: s.origins,
+ Current: s.Method(),
+ }
+ if path, err := profile.ConfigPath(); err == nil {
+ status.ConfigPath = path
+ }
+ if login := credentials.Login; login != nil {
+ status.Login = &LoginStatus{ObtainedAt: login.ObtainedAt}
+ if login.Issuer != nil {
+ status.Login.Issuer = login.Issuer.String()
+ }
+ if !login.ObtainedAt.IsZero() {
+ status.Login.Age = time.Since(login.ObtainedAt)
+ }
+ }
+ if apiKey := credentials.ApiKey; apiKey != nil {
+ status.ApiKey = &ApiKeyStatus{ClientId: apiKey.Id}
+ if len(apiKey.SecretCommand) > 0 {
+ status.ApiKey.SecretFrom = "the command " + apiKey.SecretCommand[0]
+ }
+ }
+ scope := s.Scope()
+ if token, ok := cachedToken(credentials, s.Method(), scope); ok {
+ status.Token = &TokenStatus{Scope: scope, ExpiresAt: token.ExpiresAt}
+ if !token.ExpiresAt.IsZero() {
+ status.Token.ExpiresIn = time.Until(token.ExpiresAt)
+ }
+ }
+ return status, nil
+}
diff --git a/pkg/auth/token.go b/pkg/auth/token.go
new file mode 100644
index 0000000..a39e2fa
--- /dev/null
+++ b/pkg/auth/token.go
@@ -0,0 +1,398 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "maps"
+ "time"
+
+ "github.com/meshcloud/meshstack-cli/internal/http"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+// graceWindow is how much life a token must have left to count as valid. It covers a request
+// issued just moments before expiry as well as modest clock skew against the identity
+// provider. It deliberately does not cover a badly wrong clock — see
+// client.Authorization.RefreshBearerToken for what does.
+const graceWindow = 30 * time.Second
+
+// BearerToken runs before every HTTP request, so it does no I/O while the in-process token is
+// still good. Ruling nothing out is what makes it the common path.
+func (s *Session) BearerToken(ctx context.Context) (string, error) {
+ return s.RefreshBearerToken(ctx, "")
+}
+
+// RefreshBearerToken implements client.Authorization. Ruling out one token as an argument is
+// what saves the session from remembering it: a request refused a token another goroutine has
+// already replaced needs no mint at all. That matters most for a browser login, where every
+// mint spends a refresh grant that rotates the refresh token.
+func (s *Session) RefreshBearerToken(ctx context.Context, rejected string) (string, error) {
+ s.mu.Lock()
+ cached, current := s.cached, s.current
+ s.mu.Unlock()
+ if valid(cached) && cached.Token.String != rejected {
+ return cached.Token.String, nil
+ }
+ if valid(cached) {
+ slog.Debug("the meshStack API rejected a token this process believed valid, re-minting once")
+ }
+
+ tokenScope := s.Scope()
+ renewed, err := s.renew(ctx, tokenScope, current, rejected)
+ // A store that cannot be written degrades to memory. A store that is merely locked by
+ // another process does not: that one holds it for a refresh grant, and minting outside the
+ // lock is the replay keycloak ends the session over.
+ if errors.Is(err, profile.ErrNotWritable) {
+ if err := s.degradeToMemory(err, renewed); err != nil {
+ return "", err
+ }
+ // The store mints before it writes, so the token is usually already in hand when the
+ // write fails. Minting again would spend a second refresh grant on a token the first
+ // one rotated — one process ending its own session, with no second process involved.
+ // Only a failure that came before the grant has nothing to lose by repeating it.
+ if valid(renewed.token) {
+ err = nil
+ } else {
+ renewed, err = s.renew(ctx, tokenScope, current, rejected)
+ }
+ }
+ if err != nil {
+ return "", err
+ }
+ s.mu.Lock()
+ s.cached = renewed.token
+ s.mu.Unlock()
+ return renewed.token.Token.String, nil
+}
+
+// Scope is the key this session's tokens are cached under. Only a browser login is scoped to
+// a workspace: an API key or a pasted token carries whatever workspace its issuer put in it,
+// and nothing re-scopes one.
+func (s *Session) Scope() scope.Scope {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.current != credential.MethodLogin {
+ return meshstack.Unscoped
+ }
+ return meshstack.WorkspaceScope(s.Workspace)
+}
+
+func (s *Session) Method() credential.Method {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.current
+}
+
+// RequireWorkspace is a post-resolution call, made by a command that acts on meshObjects and
+// deliberately not by `meshstack workspace list` or `meshstack auth status` — the two the
+// message itself tells the user to run. Folding it into the resolution would make the escape
+// hatch it names unreachable.
+func (s *Session) RequireWorkspace() error {
+ if s.Method() == credential.MethodLogin && s.Workspace == "" {
+ return meshstack.ErrMissing
+ }
+ return nil
+}
+
+// degradeToMemory keeps a machine with no writable home directory usable. It takes over the
+// credentials the failed write was carrying rather than re-reading the file, because a refresh
+// grant rotates before the write: the file's copy is the one keycloak has already retired.
+//
+// It fails instead when somebody may be watching, because an `auth login` that cannot save has
+// done pointless work. A CI job cannot act on a warning, so it carries on.
+func (s *Session) degradeToMemory(cause error, renewed renewal) error {
+ if !s.noInput {
+ return fmt.Errorf("cannot write this profile's credentials: %s could not be written: %w. Fix the permissions, or supply a credential through %s and %s instead",
+ s.currentStore().Describe(), cause, credential.ApiKeyClientId.EnvKey(), credential.ApiKeyClientSecret.EnvKey())
+ }
+ slog.Debug("keeping tokens in memory only", "store", s.currentStore().Describe(), "cause", cause)
+ credentials := renewed.credentials
+ if credentials == nil {
+ // The lock was never taken, so nothing was minted and the file is still the truth.
+ read, err := s.currentStore().Read()
+ if err != nil {
+ return err
+ }
+ credentials = &read
+ }
+ s.mu.Lock()
+ s.store = profile.NewMemoryStore(*credentials)
+ s.mu.Unlock()
+ return nil
+}
+
+// unscoped shares this session's store and discovered configuration, so that a token it
+// obtains is cached and locked exactly like any other. It is a fresh value rather than a copy
+// because a Session carries a mutex.
+func (s *Session) unscoped() *Session {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return &Session{
+ Endpoint: s.Endpoint,
+ Profile: s.Profile,
+ noInput: s.noInput,
+ resolved: s.resolved,
+ store: s.store,
+ current: s.current,
+ oidcConfig: s.oidcConfig,
+ }
+}
+
+// currentStore reads the store under the mutex, because degradeToMemory can replace it while
+// a Terraform provider has several requests in flight.
+func (s *Session) currentStore() profile.Store {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.store
+}
+
+// renewal carries credentials whether or not the write succeeded, and nil only when the mint
+// never ran: a refresh grant rotates before the write, so what the mint returned is the only
+// copy the identity provider still honours.
+type renewal struct {
+ token credential.IssuedToken
+ credentials *profile.Credentials
+}
+
+func (s *Session) renew(ctx context.Context, tokenScope scope.Scope, current credential.Method, rejected string) (renewal, error) {
+ // Above Update, so that the credentials lock covers the token grant and nothing else: a
+ // hold that outlasts profile's lockStaleAfter is broken by the next process, and the two
+ // then run a refresh grant each on one refresh token.
+ var config oidc.Client
+ if current == credential.MethodLogin {
+ discovered, err := s.discover(ctx)
+ if err != nil {
+ return renewal{}, err
+ }
+ config = discovered
+ }
+
+ var out renewal
+ var mintErr error
+ _, err := s.currentStore().Update(ctx, func(credentials profile.Credentials) (profile.Credentials, error) {
+ // Re-read under the lock: another process may have renewed meanwhile. Only the
+ // rejected token itself is refused here — a 401 says nothing about one minted after it.
+ if stored, ok := cachedToken(credentials, current, tokenScope); ok && valid(stored) && stored.Token.String != rejected {
+ out = renewal{token: stored, credentials: &credentials}
+ return credentials, nil
+ }
+ var updated profile.Credentials
+ updated, out.token, mintErr = s.mint(ctx, config, credentials, current, tokenScope)
+ out.credentials = &updated
+ // A failed mint still writes what it changed: keycloak rotates the refresh token
+ // before anything else can go wrong, so a mint that rotates and then fails the
+ // workspace check must not leave the retired token on disk.
+ return updated, nil
+ })
+ if err == nil {
+ err = mintErr
+ }
+ if err != nil {
+ return out, err
+ }
+ if !valid(out.token) {
+ return out, s.deadMethodError(current)
+ }
+ return out, nil
+}
+
+// mint runs under the credentials lock, so the one request it may make is the grant itself:
+// config is discovered above, and is the zero value for the methods that need none.
+func (s *Session) mint(ctx context.Context, config oidc.Client, credentials profile.Credentials, current credential.Method, tokenScope scope.Scope) (profile.Credentials, credential.IssuedToken, error) {
+ switch current {
+ case credential.MethodManual:
+ // The token was resolved and parsed before this session existed, so there is nothing
+ // left to mint from: an expired one gets the caller's dead-method message.
+ token, _ := cachedToken(credentials, credential.MethodManual, tokenScope)
+ return credentials, token, nil
+ case credential.MethodApiKey:
+ return s.mintApiKey(ctx, credentials, tokenScope)
+ case credential.MethodLogin:
+ return s.mintLogin(ctx, config, credentials, tokenScope)
+ default:
+ return credentials, credential.IssuedToken{}, fmt.Errorf("unknown authentication method: the profile records %q, which this version of the meshStack CLI does not know", current)
+ }
+}
+
+func (s *Session) mintApiKey(ctx context.Context, credentials profile.Credentials, tokenScope scope.Scope) (profile.Credentials, credential.IssuedToken, error) {
+ apiKey := credentials.ApiKey
+ if apiKey == nil || apiKey.Id == "" {
+ return credentials, credential.IssuedToken{}, errors.New("no meshStack API key: this profile's current method is an API key, but it holds no key id. Run `meshstack auth login --api-key=`")
+ }
+ // Resolve rather than read: the resolution paired this id with a secret it may only know
+ // how to produce, and a clientSecretCommand runs here rather than during a resolution.
+ secret, err := apiKey.Resolve(ctx)
+ if err != nil {
+ return credentials, credential.IssuedToken{}, err
+ }
+ if err := credential.CheckSecret(secret); err != nil {
+ return credentials, credential.IssuedToken{}, err
+ }
+ token, err := apiLogin(ctx, s.Endpoint, apiKey.Id, secret)
+ if err != nil {
+ return credentials, credential.IssuedToken{}, err
+ }
+ issued := issuedToken(token)
+ slog.Debug("minted an access token from an API key", "clientId", apiKey.Id, "expiresAt", issued.ExpiresAt)
+ return withToken(credentials, credential.MethodApiKey, tokenScope, issued), issued, nil
+}
+
+func (s *Session) mintLogin(ctx context.Context, config oidc.Client, credentials profile.Credentials, tokenScope scope.Scope) (profile.Credentials, credential.IssuedToken, error) {
+ login := credentials.Login
+ if login == nil || login.RefreshToken == "" {
+ return credentials, credential.IssuedToken{}, s.deadMethodError(credential.MethodLogin)
+ }
+ // Stricter than the endpoint check on the file, and it catches a repointed keycloak behind
+ // an unchanged endpoint — but it exists only where a login method does.
+ if login.Issuer != nil && login.Issuer.String() != config.Issuer.String() {
+ return credentials, credential.IssuedToken{}, fmt.Errorf("this login belongs to a different identity provider: the stored login came from %s, but %s now reports %s. Run `meshstack login` to log in again",
+ login.Issuer, s.Endpoint, config.Issuer)
+ }
+
+ refreshed, err := config.Refresh(ctx, login.RefreshToken, s.Workspace)
+ if err != nil {
+ return credentials, credential.IssuedToken{}, err
+ }
+ // A workspace the user is not in yields a token rather than an error, and the next API
+ // call then fails on permissions. Checking the claim is what turns that into a message
+ // naming the workspace. It is not a security check: no signature is verified.
+ updated := *login
+ updated.RefreshToken = refreshed.RefreshToken
+ credentials.Login = &updated
+ if s.Workspace != "" {
+ if got := jwt.WorkspaceClaim.GetFrom(refreshed.AccessToken); got != s.Workspace {
+ // The rotated refresh token goes back either way — the grant already succeeded, so
+ // keycloak has invalidated the one on disk whatever this check says.
+ return credentials, credential.IssuedToken{}, fmt.Errorf("this login cannot act in that workspace: the identity provider issued a token for %q that carries no membership of it. `meshstack workspace list` shows the workspaces you can use",
+ s.Workspace)
+ }
+ }
+
+ issued := issuedToken(refreshed.AccessToken)
+ slog.Debug("minted an access token from the browser login", "scope", tokenScope, "expiresAt", issued.ExpiresAt)
+ return withToken(credentials, credential.MethodLogin, tokenScope, issued), issued, nil
+}
+
+// discover reads /mesh/info and the identity provider's configuration, at most once per
+// process. Only the login method needs it, so an API key never pays for it.
+func (s *Session) discover(ctx context.Context) (oidc.Client, error) {
+ s.mu.Lock()
+ cached := s.oidcConfig
+ s.mu.Unlock()
+ if cached != nil {
+ return *cached, nil
+ }
+ config, err := oidc.NewClient(ctx, http.NewClient(userAgent, nil), s.Endpoint.URL)
+ if err != nil {
+ return config, err
+ }
+ s.mu.Lock()
+ s.oidcConfig = &config
+ s.mu.Unlock()
+ return config, nil
+}
+
+// deadMethodError names the way out. Renewal never switches method — falling back from a
+// browser login to an API key would change the identity behind the command — so when the
+// current one cannot mint, the command fails and says what to do.
+func (s *Session) deadMethodError(current credential.Method) error {
+ switch current {
+ case credential.MethodManual:
+ return fmt.Errorf("this API token has expired: nothing can refresh an API token. Set %s to a fresh one, or store one with `meshstack auth login --api-token`", credential.ApiBearerToken.EnvKey())
+ case credential.MethodApiKey:
+ clientId := ""
+ if credentials, err := s.currentStore().Read(); err == nil && credentials.ApiKey != nil && credentials.ApiKey.Id != "" {
+ clientId = credentials.ApiKey.Id
+ }
+ return fmt.Errorf("this API key no longer works: the key was deleted, or its secret changed. Run `meshstack auth login --api-key=%s`", clientId)
+ default:
+ return errors.New("this login has expired or was revoked: a meshStack CLI login lasts at most 24 hours. Run `meshstack login`")
+ }
+}
+
+// issuedToken takes the deadline from the token itself. One that states none is stored with a
+// zero expiry, which valid reads as "the server decides".
+func issuedToken(token jwt.JWT) credential.IssuedToken {
+ issued := credential.IssuedToken{Token: token}
+ if expiry := jwt.Expiry.GetFrom(token); expiry != nil {
+ issued.ExpiresAt = *expiry
+ }
+ return issued
+}
+
+// cachedToken and withToken switch over the three shapes here rather than on
+// credential.Credential, because every other branch on the current method is in this package
+// too. Only a browser login keys its tokens by scope; for the other two tokenScope is ignored.
+func cachedToken(credentials profile.Credentials, current credential.Method, tokenScope scope.Scope) (credential.IssuedToken, bool) {
+ switch current {
+ case credential.MethodLogin:
+ if credentials.Login == nil {
+ return credential.IssuedToken{}, false
+ }
+ token, ok := credentials.Login.AccessTokens[tokenScope]
+ return token, ok
+ case credential.MethodApiKey:
+ if credentials.ApiKey == nil || credentials.ApiKey.AccessToken.Token.String == "" {
+ return credential.IssuedToken{}, false
+ }
+ return credentials.ApiKey.AccessToken, true
+ case credential.MethodManual:
+ if credentials.Manual == nil || credentials.Manual.AccessToken.Token.String == "" {
+ return credential.IssuedToken{}, false
+ }
+ return credentials.Manual.AccessToken, true
+ }
+ return credential.IssuedToken{}, false
+}
+
+// withToken copies the method it writes to rather than reaching through the pointer, so that
+// storing a token cannot change credentials another goroutine is holding.
+func withToken(credentials profile.Credentials, current credential.Method, tokenScope scope.Scope, token credential.IssuedToken) profile.Credentials {
+ switch current {
+ case credential.MethodLogin:
+ login := credential.Login{}
+ if credentials.Login != nil {
+ login = *credentials.Login
+ }
+ tokens := make(map[scope.Scope]credential.IssuedToken, len(login.AccessTokens)+1)
+ maps.Copy(tokens, login.AccessTokens)
+ tokens[tokenScope] = token
+ login.AccessTokens = tokens
+ credentials.Login = &login
+ case credential.MethodApiKey:
+ apiKey := credential.ApiKey{}
+ if credentials.ApiKey != nil {
+ apiKey = *credentials.ApiKey
+ }
+ apiKey.AccessToken = token
+ credentials.ApiKey = &apiKey
+ case credential.MethodManual:
+ manual := credential.Manual{}
+ if credentials.Manual != nil {
+ manual = *credentials.Manual
+ }
+ manual.AccessToken = token
+ credentials.Manual = &manual
+ }
+ return credentials
+}
+
+func valid(token credential.IssuedToken) bool {
+ if token.Token.String == "" {
+ return false
+ }
+ // A zero expiry means the token said nothing about its own life — a pasted API token that
+ // is not a JWT is the case. Nothing can renew one, so the server is what decides, and
+ // treating it as valid is the only useful answer.
+ if token.ExpiresAt.IsZero() {
+ return true
+ }
+ return time.Until(token.ExpiresAt) > graceWindow
+}
diff --git a/pkg/auth/workspaces.go b/pkg/auth/workspaces.go
new file mode 100644
index 0000000..4a7b40d
--- /dev/null
+++ b/pkg/auth/workspaces.go
@@ -0,0 +1,40 @@
+package auth
+
+import (
+ "context"
+
+ "github.com/meshcloud/meshstack-cli/client"
+)
+
+// Workspaces lists the workspaces this credential can see. `meshstack auth login` prompts
+// from it, and `meshstack workspace list` prints it.
+//
+// It is the one thing an unscoped user token is good for: with no c: scope the principal
+// holds only the three list rights, so this call works right after a browser login and
+// before any workspace has been chosen.
+func (s *Session) Workspaces(ctx context.Context) ([]string, error) {
+ // Deliberately unscoped. A user has to be able to list the workspaces *before* picking one,
+ // and a scoped exchange for a workspace the user is not in fails with the very message this
+ // list is meant to answer.
+ api, err := s.unscoped().Client(ctx, userAgent)
+ if err != nil {
+ return nil, err
+ }
+ found, err := api.Workspace.List(ctx)
+ if err != nil {
+ return nil, HintErr(err, s)
+ }
+ names := make([]string, 0, len(found))
+ for _, w := range found {
+ names = append(names, w.Metadata.Name)
+ }
+ return names, nil
+}
+
+// Client builds the meshStack API client this session authenticates. Both front ends call it
+// rather than client.New, so the endpoint and the authorization always agree with what was
+// resolved. The user agent stays a parameter, because it is the one thing that genuinely
+// differs: the Terraform provider identifies itself by its own name and version.
+func (s *Session) Client(ctx context.Context, userAgent string) (client.Client, error) {
+ return client.New(ctx, s.Endpoint.URL, userAgent, s)
+}
diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go
new file mode 100644
index 0000000..36dc095
--- /dev/null
+++ b/pkg/credential/credential.go
@@ -0,0 +1,142 @@
+// Package credential says how a caller authenticates against the meshStack API.
+//
+// It is top level rather than a subpackage of either package that uses it. A credential from
+// the environment or a Terraform provider block touches no file, so pkg/profile does not own
+// it; and pkg/profile imports it, so it cannot sit under pkg/auth.
+//
+// The method strings and the json tags are part of the on-disk format. Renaming one breaks
+// every credentials file already written.
+package credential
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+)
+
+// Method's constants carry the prefix because the plain names are taken by the shapes below.
+type Method string
+
+const (
+ MethodLogin Method = "login"
+ MethodApiKey Method = "apiKey"
+ MethodManual Method = "manual"
+)
+
+// Description names the method the way a message to a user should, because "apiKey"
+// on its own reads like a field name rather than a thing the reader has.
+func (m Method) Description() string {
+ switch m {
+ case MethodLogin:
+ return "browser login"
+ case MethodApiKey:
+ return "API key"
+ case MethodManual:
+ return "API token"
+ default:
+ return string(m)
+ }
+}
+
+// Credential holds more than one method at a time, so that `meshstack login` switches back
+// from an API key without asking for the id again.
+//
+// Presence is not selection: switch on Current, never on a nil check. A profile that mints
+// with its API key may still hold a browser login, and `if c.Login != nil` would wrongly
+// demand a workspace of it.
+type Credential struct {
+ Current Method `json:"current,omitzero"`
+ Login *Login `json:"login,omitzero"`
+ ApiKey *ApiKey `json:"apiKey,omitzero"`
+ Manual *Manual `json:"manual,omitzero"`
+}
+
+// Login records ObtainedAt instead of a predicted deadline: the session's ceiling is a
+// server-side constant, so a number copied into the CLI would be wrong wherever a realm
+// configures another one.
+//
+// It is the one method whose tokens are a map, because a browser login mints a token bound to
+// one workspace: one per `c:` scope, plus the unscoped one that lists the workspaces.
+type Login struct {
+ Issuer *xurl.URL `json:"issuer,omitzero"`
+ RefreshToken string `json:"refreshToken,omitzero"`
+ ObtainedAt time.Time `json:"obtainedAt,omitzero"`
+
+ AccessTokens map[scope.Scope]IssuedToken `json:"accessTokens,omitzero"`
+}
+
+// ApiKey leaves Secret absent when SecretCommand is set, so a long-lived secret never has to
+// sit on disk. AccessToken is the unscoped token: an API key carries whatever workspace its
+// issuer put in it, and nothing re-scopes one.
+type ApiKey struct {
+ Id string `json:"clientId,omitzero"`
+ Secret string `json:"clientSecret,omitzero"`
+ SecretCommand []string `json:"clientSecretCommand,omitzero"`
+
+ AccessToken IssuedToken `json:"accessToken,omitzero"`
+}
+
+// Manual is a token somebody pasted in. The token is the whole of it, so an expired one leaves
+// nothing to mint from.
+type Manual struct {
+ AccessToken IssuedToken `json:"accessToken,omitzero"`
+}
+
+type IssuedToken struct {
+ Token jwt.JWT `json:"token,omitzero"`
+ ExpiresAt time.Time `json:"expiresAt,omitzero"`
+}
+
+// FromLogin, FromApiKey and FromManual set Current and its pointer together, so that no caller
+// has to remember both halves of "presence is not selection".
+func FromLogin(l Login) Credential { return Credential{Current: MethodLogin, Login: &l} }
+
+func FromApiKey(k ApiKey) Credential { return Credential{Current: MethodApiKey, ApiKey: &k} }
+
+func FromManual(m Manual) Credential { return Credential{Current: MethodManual, Manual: &m} }
+
+// Validate is for a file somebody edited by hand — the constructors cannot produce a credential
+// that names a method it does not hold. Without it, `current: apiKey` with no `apiKey:` block
+// resolves to a credential nobody selected and fails much later.
+//
+// The zero value is valid: it is the profile nothing has logged in to yet.
+func (c Credential) Validate() error {
+ if c.Current == "" {
+ var held []string
+ if c.Login != nil {
+ held = append(held, string(MethodLogin))
+ }
+ if c.ApiKey != nil {
+ held = append(held, string(MethodApiKey))
+ }
+ if c.Manual != nil {
+ held = append(held, string(MethodManual))
+ }
+ if held == nil {
+ return nil
+ }
+ return fmt.Errorf("this credential selects no authentication method: it holds %s but names none as current. Set `current` to one of them, or log in again",
+ strings.Join(held, " and "))
+ }
+ switch c.Current {
+ case MethodLogin:
+ if c.Login != nil {
+ return nil
+ }
+ case MethodApiKey:
+ if c.ApiKey != nil {
+ return nil
+ }
+ case MethodManual:
+ if c.Manual != nil {
+ return nil
+ }
+ default:
+ return fmt.Errorf("unknown authentication method: the credential records %q, which this version of the meshStack CLI does not know", c.Current)
+ }
+ return fmt.Errorf("this credential selects a method it does not hold: it names %q as current but carries no %q entry. Log in again to store one", c.Current, c.Current)
+}
diff --git a/pkg/credential/credential_test.go b/pkg/credential/credential_test.go
new file mode 100644
index 0000000..8bbfb9d
--- /dev/null
+++ b/pkg/credential/credential_test.go
@@ -0,0 +1,67 @@
+package credential
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestValidateRejectsEveryMismatchedPair is what stops a credential nobody selected from
+// resolving: naming a method the credential does not hold has to fail here.
+func TestValidateRejectsEveryMismatchedPair(t *testing.T) {
+ held := map[Method]func() Credential{
+ MethodLogin: func() Credential { return Credential{Login: &Login{}} },
+ MethodApiKey: func() Credential { return Credential{ApiKey: &ApiKey{}} },
+ MethodManual: func() Credential { return Credential{Manual: &Manual{}} },
+ }
+ for _, current := range []Method{MethodLogin, MethodApiKey, MethodManual} {
+ for present, build := range held {
+ c := build()
+ c.Current = current
+ err := c.Validate()
+ if present == current {
+ require.NoError(t, err, "%s selecting %s", present, current)
+ continue
+ }
+ require.Error(t, err, "%s selecting %s", present, current)
+ assert.Contains(t, err.Error(), string(current))
+ }
+ }
+}
+
+func TestValidateAcceptsTheZeroCredential(t *testing.T) {
+ require.NoError(t, Credential{}.Validate())
+}
+
+// TestValidateRejectsAMethodWithNoSelection is the other half of "presence is not selection".
+// A file holding a method but naming none would otherwise be resolved by whichever caller
+// looked at the pointers first.
+func TestValidateRejectsAMethodWithNoSelection(t *testing.T) {
+ err := Credential{Login: &Login{}, ApiKey: &ApiKey{}}.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "login and apiKey")
+}
+
+func TestValidateRejectsAnUnknownMethod(t *testing.T) {
+ err := Credential{Current: "kerberos", Login: &Login{}}.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "kerberos")
+}
+
+func TestTheConstructorsSelectWhatTheySet(t *testing.T) {
+ login := FromLogin(Login{RefreshToken: "r"})
+ require.NoError(t, login.Validate())
+ assert.Equal(t, MethodLogin, login.Current)
+ assert.Equal(t, "r", login.Login.RefreshToken)
+
+ apiKey := FromApiKey(ApiKey{Id: "k"})
+ require.NoError(t, apiKey.Validate())
+ assert.Equal(t, MethodApiKey, apiKey.Current)
+ assert.Equal(t, "k", apiKey.ApiKey.Id)
+
+ manual := FromManual(Manual{})
+ require.NoError(t, manual.Validate())
+ assert.Equal(t, MethodManual, manual.Current)
+ require.NotNil(t, manual.Manual)
+}
diff --git a/pkg/credential/secret.go b/pkg/credential/secret.go
new file mode 100644
index 0000000..69f4fed
--- /dev/null
+++ b/pkg/credential/secret.go
@@ -0,0 +1,126 @@
+package credential
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+ "unicode"
+)
+
+// secretCommandTimeout bounds clientSecretCommand, so that a secret store which has
+// become unreachable fails rather than hangs. It is generous, because reaching a vault
+// through a tunnel is slow but not minutes slow.
+const secretCommandTimeout = 30 * time.Second
+
+// maxQuotedStderr caps how much of a failed command's stderr reaches the error message.
+// The whole of it already went to the process's stderr, where the user can read it.
+const maxQuotedStderr = 2000
+
+// Resolve returns the API key secret: the stored one, or the output of
+// clientSecretCommand. pkg/auth calls it only at the moment a secret is actually needed,
+// so a command served from a cached token never runs the command.
+func (k ApiKey) Resolve(ctx context.Context) (string, error) {
+ if k.Secret != "" {
+ return k.Secret, nil
+ }
+ if len(k.SecretCommand) == 0 {
+ return "", fmt.Errorf("the API key has no secret: this profile stores neither a secret nor a clientSecretCommand for API key %s. Log in again to store one", k.Id)
+ }
+
+ // The argument list may be logged; its output never is.
+ slog.Debug("running clientSecretCommand", "command", k.SecretCommand)
+
+ ctx, cancel := context.WithTimeout(ctx, secretCommandTimeout)
+ defer cancel()
+
+ // An argument list, never a shell string: no quoting rules and no shell injection.
+ cmd := exec.CommandContext(ctx, k.SecretCommand[0], k.SecretCommand[1:]...)
+ // The caller's environment reaches the command, because that is where a secret store
+ // finds its own configuration: VAULT_ADDR, OP_SERVICE_ACCOUNT_TOKEN.
+ cmd.Env = os.Environ()
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ // stderr passes through as well as being captured, so vault's own "token expired"
+ // message reaches the user while the error can still quote it.
+ cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
+
+ if err := cmd.Run(); err != nil {
+ var detail string
+ if quoted := strings.TrimSpace(stderr.String()); quoted != "" {
+ detail += " It wrote: " + truncate(quoted, maxQuotedStderr)
+ }
+ if ctx.Err() != nil {
+ detail += fmt.Sprintf(" It was given %s to produce the secret on stdout.", secretCommandTimeout)
+ }
+ return "", fmt.Errorf("cannot fetch the API key secret: clientSecretCommand `%s` failed: %w.%s", commandLine(k.SecretCommand), err, detail)
+ }
+
+ secret := strings.TrimSuffix(strings.TrimSuffix(stdout.String(), "\n"), "\r")
+ if err := CheckSecret(secret); err != nil {
+ return "", fmt.Errorf("the API key secret command produced no usable secret: clientSecretCommand `%s` must print the secret and nothing else on stdout: %w",
+ commandLine(k.SecretCommand), err)
+ }
+ return secret, nil
+}
+
+// CheckSecret rejects a value that cannot be an API key secret, wherever it came from —
+// the environment, stdin, a prompt, or clientSecretCommand.
+//
+// It checks shape and not size: a generated secret is 32 alphanumerics, so a length
+// rule would be arbitrary and would miss both real mistakes. A truncated secret already
+// gets a clear 401 from /api/login, which is the real judge.
+func CheckSecret(secret string) error {
+ if strings.TrimSpace(secret) == "" {
+ return errors.New("the API key secret is empty: nothing supplied a secret. A blank line from a secret helper is the usual cause")
+ }
+ if strings.ContainsFunc(secret, unicode.IsSpace) {
+ return errors.New("the API key secret contains whitespace: an API key secret is 32 alphanumeric characters, so a value with a newline or a space in it is usually a secret helper printing a diagnostic rather than a secret")
+ }
+ if isUUID(secret) {
+ return errors.New("that is the API key id, not its secret: the value is a UUID, and an API key secret is 32 alphanumeric characters and never a UUID. The id belongs in --api-key; the secret is the other half meshPanel showed next to it")
+ }
+ return nil
+}
+
+// isUUID matches the 8-4-4-4-12 hex shape by hand, because recognising the id pasted
+// into the secret prompt is not worth a dependency.
+func isUUID(s string) bool {
+ if len(s) != 36 {
+ return false
+ }
+ for i := range len(s) {
+ c := s[i]
+ if i == 8 || i == 13 || i == 18 || i == 23 {
+ if c != '-' {
+ return false
+ }
+ continue
+ }
+ switch {
+ case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// commandLine renders an argument list for a message. It is not shell syntax and is
+// never re-parsed; only the first word is a program name.
+func commandLine(args []string) string {
+ return strings.Join(args, " ")
+}
+
+func truncate(s string, limit int) string {
+ if len(s) <= limit {
+ return s
+ }
+ return s[:limit] + "…"
+}
diff --git a/pkg/credential/secret_test.go b/pkg/credential/secret_test.go
new file mode 100644
index 0000000..ac20673
--- /dev/null
+++ b/pkg/credential/secret_test.go
@@ -0,0 +1,217 @@
+package credential
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckSecret(t *testing.T) {
+ tests := []struct {
+ name string
+ secret string
+ wantErr string // a phrase from the rule that has to fire
+ }{
+ {name: "a generated secret", secret: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO"},
+ {name: "an unfamiliar shape still goes to the server", secret: "short"},
+ {name: "punctuation is not our business", secret: "abc-def.ghi_jkl"},
+
+ {name: "empty", secret: "", wantErr: "empty"},
+ {name: "whitespace only", secret: " ", wantErr: "empty"},
+ {name: "a newline only", secret: "\n", wantErr: "empty"},
+ {name: "a trailing newline", secret: "secret\n", wantErr: "whitespace"},
+ {name: "a diagnostic line", secret: "Error: token expired", wantErr: "whitespace"},
+ {name: "two lines", secret: "secret\nwarning: something", wantErr: "whitespace"},
+ {name: "a tab", secret: "sec\tret", wantErr: "whitespace"},
+
+ {name: "the api key id", secret: "6169f530-0eaa-4f7f-91b7-c4fd4aaf2a74", wantErr: "id"},
+ {name: "an uppercase uuid", secret: "6169F530-0EAA-4F7F-91B7-C4FD4AAF2A74", wantErr: "id"},
+ {name: "a uuid with a non-hex digit is not one", secret: "6169f530-0eaa-4f7f-91b7-c4fd4aaf2a7z"},
+ {name: "a uuid with a dash in the wrong place is not one", secret: "6169f5300-eaa-4f7f-91b7-c4fd4aaf2a74"},
+ {name: "35 characters is not a uuid", secret: "6169f530-0eaa-4f7f-91b7-c4fd4aaf2a7"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := CheckSecret(tc.secret)
+ if tc.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ // The message has to say which rule fired.
+ assert.Contains(t, err.Error(), tc.wantErr)
+ })
+ }
+}
+
+// The secret command is exercised through this test binary re-executed as a helper, so
+// the test needs no shell and runs on Windows as well.
+const (
+ helperEnv = "GO_MESHSTACK_SECRET_HELPER"
+ helperStdout = "GO_MESHSTACK_SECRET_HELPER_STDOUT"
+ helperStderr = "GO_MESHSTACK_SECRET_HELPER_STDERR"
+ helperExit = "GO_MESHSTACK_SECRET_HELPER_EXIT"
+)
+
+func TestSecretHelperProcess(t *testing.T) {
+ if os.Getenv(helperEnv) != "1" {
+ return
+ }
+ // Exit before the testing framework prints its own summary, which would otherwise
+ // land on the stdout the caller reads as the secret.
+ defer os.Exit(0)
+
+ if s := os.Getenv(helperStderr); s != "" {
+ fmt.Fprintln(os.Stderr, s)
+ }
+ if s, ok := os.LookupEnv(helperStdout); ok {
+ _, _ = fmt.Fprint(os.Stdout, s)
+ }
+ if code, err := strconv.Atoi(os.Getenv(helperExit)); err == nil && code != 0 {
+ os.Exit(code)
+ }
+}
+
+// helperCommand builds a clientSecretCommand that re-executes this test binary.
+func helperCommand(t *testing.T, stdout, stderr string, exit int) []string {
+ t.Helper()
+ t.Setenv(helperEnv, "1")
+ t.Setenv(helperStdout, stdout)
+ t.Setenv(helperStderr, stderr)
+ t.Setenv(helperExit, strconv.Itoa(exit))
+ return []string{os.Args[0], "-test.run=^TestSecretHelperProcess$"}
+}
+
+func TestSecretFromStoredValue(t *testing.T) {
+ m := ApiKey{Id: "id", Secret: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO"}
+ got, err := m.Resolve(t.Context())
+ require.NoError(t, err)
+ require.Equal(t, "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO", got)
+}
+
+func TestSecretWithNeitherStoredValueNorCommand(t *testing.T) {
+ m := ApiKey{Id: "6169f530-0eaa-4f7f-91b7-c4fd4aaf2a74"}
+ _, err := m.Resolve(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "6169f530-0eaa-4f7f-91b7-c4fd4aaf2a74")
+}
+
+func TestSecretFromCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ stdout string
+ want string
+ }{
+ {name: "bare", stdout: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO", want: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO"},
+ {name: "one trailing newline is stripped", stdout: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO\n", want: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO"},
+ {name: "a CRLF ending is stripped", stdout: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO\r\n", want: "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ m := ApiKey{Id: "id", SecretCommand: helperCommand(t, tc.stdout, "", 0)}
+ got, err := m.Resolve(t.Context())
+ require.NoError(t, err)
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestSecretFromCommandGoesThroughTheCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ stdout string
+ wantErr string
+ }{
+ {name: "nothing at all", stdout: "", wantErr: "empty"},
+ {name: "a blank line", stdout: "\n", wantErr: "empty"},
+ {name: "a diagnostic", stdout: "Error: no such secret\n", wantErr: "whitespace"},
+ {name: "two lines", stdout: "secret\ntrailing noise\n", wantErr: "whitespace"},
+ {name: "the api key id", stdout: "6169f530-0eaa-4f7f-91b7-c4fd4aaf2a74\n", wantErr: "id"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ m := ApiKey{Id: "id", SecretCommand: helperCommand(t, tc.stdout, "", 0)}
+ _, err := m.Resolve(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.wantErr)
+ assert.Contains(t, err.Error(), "clientSecretCommand")
+ })
+ }
+}
+
+func TestSecretCommandFailureNamesTheCommandAndQuotesStderr(t *testing.T) {
+ command := helperCommand(t, "", "Error: vault token expired", 3)
+ m := ApiKey{Id: "id", SecretCommand: command}
+
+ _, err := m.Resolve(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), command[0])
+ assert.Contains(t, err.Error(), "Error: vault token expired")
+ assert.Contains(t, err.Error(), "exit status 3")
+}
+
+func TestSecretCommandStderrPassesThrough(t *testing.T) {
+ command := helperCommand(t, "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO\n", "Warning: renewing lease", 0)
+ m := ApiKey{Id: "id", SecretCommand: command}
+
+ // vault's own message has to reach the user, so it is written to this process's
+ // stderr rather than being swallowed.
+ captured := captureStderr(t, func() {
+ got, err := m.Resolve(t.Context())
+ require.NoError(t, err)
+ require.Equal(t, "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO", got)
+ })
+ assert.Contains(t, captured, "Warning: renewing lease")
+ // The secret itself never travels with it.
+ assert.NotContains(t, captured, "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO")
+}
+
+func TestSecretCommandHonoursContext(t *testing.T) {
+ command := helperCommand(t, "Ph1nBQ2rTz8kLm4WxYv6Cd0AeJsGuNiO", "", 0)
+ m := ApiKey{Id: "id", SecretCommand: command}
+
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ _, err := m.Resolve(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "clientSecretCommand")
+}
+
+func TestSecretCommandThatDoesNotExist(t *testing.T) {
+ m := ApiKey{Id: "id", SecretCommand: []string{"meshstack-no-such-secret-helper"}}
+ _, err := m.Resolve(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "meshstack-no-such-secret-helper")
+}
+
+// captureStderr replaces the process's stderr for the duration of f.
+func captureStderr(t *testing.T, f func()) string {
+ t.Helper()
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+
+ original := os.Stderr
+ os.Stderr = w
+ done := make(chan string, 1)
+ go func() {
+ var sb strings.Builder
+ _, _ = io.Copy(&sb, r)
+ done <- sb.String()
+ }()
+
+ f()
+
+ os.Stderr = original
+ require.NoError(t, w.Close())
+ out := <-done
+ require.NoError(t, r.Close())
+ return out
+}
diff --git a/pkg/credential/settings.go b/pkg/credential/settings.go
new file mode 100644
index 0000000..5a8b151
--- /dev/null
+++ b/pkg/credential/settings.go
@@ -0,0 +1,40 @@
+package credential
+
+import "github.com/meshcloud/meshstack-cli/internal/setting"
+
+var ApiKeyClientId = setting.Setting[string]{
+ Env: "MESHSTACK_API_KEY",
+ Short: "The client id of a meshStack API key, which mints tokens together with its secret. Also read from MESHSTACK_API_KEY.",
+ Long: "The client id of a meshStack API key, which mints tokens together with its secret, also read from " +
+ "`MESHSTACK_API_KEY`.\n\n" +
+ "An id decides the identity outright: whatever names it first is the credential, and a profile below it " +
+ "contributes nothing but the secret it stores for that same id. Where no secret can be found for it, the " +
+ "run fails rather than falling back to another credential.",
+ Parse: setting.ParseText,
+}
+
+var ApiKeyClientSecret = setting.Setting[string]{
+ Env: "MESHSTACK_API_SECRET",
+ Short: "The client secret belonging to the API key. Also read from MESHSTACK_API_SECRET.",
+ Long: "The client secret belonging to the API key, also read from `MESHSTACK_API_SECRET`.\n\n" +
+ "A secret is paired with the id named beside it, or with an id named above it that brought no secret of " +
+ "its own — which is what makes an id in one place and `MESHSTACK_API_SECRET` in another the ordinary " +
+ "non-interactive setup. It is skipped where a *different* id is set alongside it, because a secret " +
+ "sitting next to another id belongs to that id.\n\n" +
+ "Where the profile supplies the identity as well, its own stored secret wins over a differing " +
+ "`MESHSTACK_API_SECRET`, and a warning names both. Which of the two is newer is not knowable, so a " +
+ "rotated secret is applied by logging in again rather than by exporting it.",
+ Parse: setting.ParseText,
+}
+
+var ApiBearerToken = setting.Setting[string]{
+ Env: "MESHSTACK_API_TOKEN",
+ Short: "A meshStack access token to send as it is. Also read from MESHSTACK_API_TOKEN.",
+ Long: "A meshStack access token to send as it is, also read from `MESHSTACK_API_TOKEN`. A building block run " +
+ "has one injected under that name.\n\n" +
+ "It replaces an API key and its secret, and nothing can mint a replacement from it: once the " +
+ "token has expired, every request fails until another token is configured.\n\n" +
+ "A token is an identity of its own rather than another spelling of an API key, so setting it beside an " +
+ "API key id in the same place is an error naming both, not a precedence contest.",
+ Parse: setting.ParseText,
+}
diff --git a/pkg/meshstack/endpoint.go b/pkg/meshstack/endpoint.go
new file mode 100644
index 0000000..34888bd
--- /dev/null
+++ b/pkg/meshstack/endpoint.go
@@ -0,0 +1,16 @@
+package meshstack
+
+import (
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+)
+
+var Endpoint = setting.Setting[xurl.URL]{
+ Env: "MESHSTACK_ENDPOINT",
+ Short: "The meshStack API to act against, such as https://api.example.meshcloud.io. Also read from MESHSTACK_ENDPOINT.",
+ Long: "The meshStack API to act against, such as `https://api.example.meshcloud.io`, also read from " +
+ "`MESHSTACK_ENDPOINT`.\n\n" +
+ "A profile carries an endpoint of its own, which is used when nothing above it names one. There is no " +
+ "default beyond that, so naming neither an endpoint nor a profile that has one is an error.",
+ Parse: setting.ParseTextUnmarshaler[xurl.URL],
+}
diff --git a/pkg/meshstack/meshstack.go b/pkg/meshstack/meshstack.go
new file mode 100644
index 0000000..186c373
--- /dev/null
+++ b/pkg/meshstack/meshstack.go
@@ -0,0 +1,55 @@
+// Package meshstack holds the names a command needs before it can act: the workspace it
+// acts in, and the endpoint it acts against.
+//
+// The workspace does two jobs, and only the first depends on how the caller authenticated:
+//
+// 1. It is the token scope, for the browser login alone. meshfed builds a user's rights
+// from the MC_CUSTOMER claim, which a keycloak script mapper writes from the
+// c: scope on the token request. So a user access token is bound to one
+// workspace, and another refresh grant is the only way to change it. Nothing re-scopes
+// an API key token.
+//
+// 2. It is a request parameter, for every method. The meshObject API ignores
+// workspaceIdentifier for a workspace-bound principal, and lets it decide what a
+// principal holding the matching ADM_ authority reads.
+package meshstack
+
+import (
+ "errors"
+ "os"
+ "strings"
+
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+)
+
+const (
+ // Unscoped keys the token of a session that acts in no workspace. No grant asks for it:
+ // a request that names no workspace sends no c: scope at all.
+ Unscoped scope.Scope = "unscoped"
+
+ // ClaimKey and scopePrefix both say customer, which is what meshStack called a workspace
+ // before it was renamed.
+ ClaimKey = "MC_CUSTOMER"
+ scopePrefix = "c:"
+
+ // No MESHSTACK_* name is exported anywhere in this module, so every message that names
+ // this one is written here rather than assembled by a front end out of a constant.
+ envKey = "MESHSTACK_WORKSPACE"
+)
+
+func WorkspaceScope(name string) scope.Scope {
+ if strings.TrimSpace(name) == "" {
+ return Unscoped
+ }
+ return scope.Scope(scopePrefix + name)
+}
+
+func WorkspaceFromEnv() string {
+ return strings.TrimSpace(os.Getenv(envKey))
+}
+
+var ErrMissing = errors.New(`a browser login is bound to one workspace, and none is configured.
+Name one with ` + envKey + `, with the meshStack CLI's --workspace flag or the Terraform
+provider's workspace attribute, or make it the profile's default with
+` + "`meshstack profile set workspace `" + `.
+` + "`meshstack workspace list`" + ` shows the ones you can use`)
diff --git a/pkg/meshstack/settings.go b/pkg/meshstack/settings.go
new file mode 100644
index 0000000..2384713
--- /dev/null
+++ b/pkg/meshstack/settings.go
@@ -0,0 +1,16 @@
+package meshstack
+
+import (
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+)
+
+var Workspace = setting.Setting[string]{
+ Env: envKey,
+ Short: "The workspace to act in. Also read from MESHSTACK_WORKSPACE.",
+ Long: "The workspace to act in, also read from `MESHSTACK_WORKSPACE`.\n\n" +
+ "A browser login has to name one, because the workspace is a scope on the token: a user access token is " +
+ "minted for exactly one workspace, and naming another one mints another token. An API key or an API token " +
+ "carries whatever workspace its issuer gave it, and nothing re-scopes that, so neither needs this set.\n\n" +
+ "A profile's default workspace supplies it when nothing above it does.",
+ Parse: setting.ParseText,
+}
diff --git a/pkg/oidc/authcode.go b/pkg/oidc/authcode.go
new file mode 100644
index 0000000..7b34766
--- /dev/null
+++ b/pkg/oidc/authcode.go
@@ -0,0 +1,81 @@
+package oidc
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "fmt"
+ "net/url"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+)
+
+// AuthorizationCodeFlow is one run of the authorization code flow with PKCE. It holds the two
+// values that have to survive from the authorization request to the token request and that must
+// not leave this package: the PKCE verifier and the state parameter. An interactive front end is
+// left with a loopback listener, a way to open a browser, and a page to show when the redirect
+// lands.
+type AuthorizationCodeFlow struct {
+ client Client
+ redirectURI xurl.URL
+ verifier string
+ state string
+}
+
+// NewAuthorizationCode begins a flow that redirects to redirectURI. The caller binds its listener
+// first, because the port is part of the URI and the token request has to echo the URI back
+// unchanged.
+func (c Client) NewAuthorizationCode(redirectURI xurl.URL) AuthorizationCodeFlow {
+ return AuthorizationCodeFlow{client: c, redirectURI: redirectURI, verifier: randomString(), state: randomString()}
+}
+
+// URL is where the person has to go.
+func (a AuthorizationCodeFlow) URL() *url.URL {
+ // The challenge is the hash itself, so it is bytes rather than text, and RFC 7636 says to
+ // write those bytes as base64url without padding. url.Values.Encode() percent-escapes the
+ // string it is handed and cannot stand in for that.
+ challenge := sha256.Sum256([]byte(a.verifier))
+ // scopes never include c:: a login is unscoped and the workspace arrives later
+ scopes := scope.Scopes{scope.OpenId, scope.Profile, scope.Email, scope.OfflineAccess}
+ result := a.client.AuthorizationEndpoint.Clone()
+ result.RawQuery = url.Values{
+ "response_type": {"code"},
+ "client_id": {a.client.CliClientId},
+ "redirect_uri": {a.redirectURI.String()},
+ "scope": {scopes.String()},
+ "state": {a.state},
+ "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
+ "code_challenge_method": {"S256"},
+ }.Encode()
+ return result
+}
+
+// CheckState reports whether a redirect belongs to this flow. It compares in constant time. That
+// is not strictly needed for a value an attacker would have to guess in one shot, and it costs
+// nothing to not have to argue about it.
+func (a AuthorizationCodeFlow) CheckState(state string) error {
+ if subtle.ConstantTimeCompare([]byte(state), []byte(a.state)) != 1 {
+ return fmt.Errorf("the login redirect carried the wrong state parameter, so it did not belong to this login")
+ }
+ return nil
+}
+
+// Exchange trades the authorization code for tokens. It is how the verifier and the redirect URI
+// reach the token request without a caller having to remember either of them.
+func (a AuthorizationCodeFlow) Exchange(ctx context.Context, code string) (Token, error) {
+ return a.client.ExchangeAuthCode(ctx, code, a.redirectURI, a.verifier)
+}
+
+// randomString is the source of both the PKCE verifier and the state parameter: 32 bytes, which
+// is the top of the range RFC 7636 allows for a verifier. crypto/rand.Read fails only when the
+// system has no randomness left to give, which is not a condition a login can carry on through.
+func randomString() string {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ panic(fmt.Sprintf("cannot read random bytes for the login: %s", err.Error()))
+ }
+ return base64.RawURLEncoding.EncodeToString(buf)
+}
diff --git a/pkg/oidc/browser/browser.go b/pkg/oidc/browser/browser.go
new file mode 100644
index 0000000..47a4a97
--- /dev/null
+++ b/pkg/oidc/browser/browser.go
@@ -0,0 +1,172 @@
+// Package browser runs the interactive half of the OIDC protocol: the authorization code
+// flow with PKCE, the loopback listener that catches the redirect, and opening a browser.
+package browser
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc"
+)
+
+// callbackPath is part of the redirect URI registered on the keycloak client. Keycloak
+// matches any port on a loopback address but matches host and path literally, so this and
+// the 127.0.0.1 below are both fixed — localhost and ::1 are rejected.
+const callbackPath = "/callback"
+
+// loginTimeout is how long the listener waits for a person to finish in the browser. The
+// consent screen on a first login makes a short timeout hostile.
+const loginTimeout = 10 * time.Minute
+
+// envNoBrowser suppresses the launch and leaves the printed URL as the whole of the offer.
+// It is not the same statement as MESHSTACK_NO_INPUT, which says nobody is coming and makes
+// the login refuse outright: here somebody is coming, just not through a browser this process
+// started. That covers an ssh session whose $DISPLAY belongs to the wrong machine, a
+// container, and a test driving the login over HTTP — all cases where opening a window is at
+// best useless and at worst somebody else's screen.
+const envNoBrowser = "MESHSTACK_NO_BROWSER"
+
+// Login supplies the two things the authorization code flow needs and pkg/oidc cannot have: a
+// loopback address it can receive the redirect on, and a person in front of a browser. The
+// protocol itself belongs to the flow — see oidc.AuthorizationCodeFlow.
+func Login(ctx context.Context, client oidc.Client) (oidc.Token, error) {
+ // Bound first: the port it happens to get is part of the redirect URI, which the flow has
+ // to put in the authorization request and echo back in the token request.
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return oidc.Token{}, fmt.Errorf("cannot listen on a loopback port for the login redirect: %w", err)
+ }
+ addr, ok := listener.Addr().(*net.TCPAddr)
+ if !ok {
+ // await is what otherwise closes the listener, through the server it hands it to.
+ _ = listener.Close()
+ return oidc.Token{}, fmt.Errorf("cannot determine the port of the loopback listener, got %s", listener.Addr())
+ }
+
+ // callbackPath carries its own leading slash, so the format string must not add one: a
+ // redirect URI of //callback is not the one keycloak has registered.
+ flow := client.NewAuthorizationCode(xurl.MustParsef("http://%s%s", addr, callbackPath))
+
+ code, err := await(ctx, listener, flow)
+ if err != nil {
+ return oidc.Token{}, err
+ }
+ return flow.Exchange(ctx, code)
+}
+
+// callback is what the redirect carried: one of the two fields is always empty.
+type callback struct {
+ code string
+ err error
+}
+
+// await serves the loopback listener until the redirect arrives, and always shuts it down
+// before returning so that no login leaves a port bound behind it.
+func await(ctx context.Context, listener net.Listener, flow oidc.AuthorizationCodeFlow) (string, error) {
+ // Buffered, so the handler never blocks on a caller that has already given up.
+ arrived := make(chan callback, 1)
+ server := &http.Server{
+ Handler: handler(flow, arrived),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ go func() {
+ if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ arrived <- callback{err: fmt.Errorf("the loopback listener for the login redirect failed: %w", err)}
+ }
+ }()
+ defer func() {
+ // A fresh context: the caller's may already be cancelled, and the browser is still
+ // reading the page the handler wrote.
+ shutdown, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := server.Shutdown(shutdown); err != nil {
+ slog.Debug("the loopback listener did not shut down cleanly", "error", err)
+ }
+ }()
+
+ openBrowser(flow.URL())
+
+ select {
+ case result := <-arrived:
+ return result.code, result.err
+ case <-ctx.Done():
+ return "", fmt.Errorf("the browser login was cancelled before the redirect arrived: %w", ctx.Err())
+ case <-time.After(loginTimeout):
+ return "", fmt.Errorf("no login redirect arrived within %s, so the browser login was abandoned", loginTimeout)
+ }
+}
+
+func handler(flow oidc.AuthorizationCodeFlow, arrived chan<- callback) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != callbackPath {
+ http.NotFound(w, r)
+ return
+ }
+ query := r.URL.Query()
+
+ if refused := query.Get("error"); refused != "" {
+ detail := refused
+ if description := query.Get("error_description"); description != "" {
+ detail += ": " + description
+ }
+ page(w, http.StatusBadRequest, "Login failed", detail)
+ arrived <- callback{err: fmt.Errorf("the identity provider refused the login: %s", detail)}
+ return
+ }
+ if err := flow.CheckState(query.Get("state")); err != nil {
+ page(w, http.StatusBadRequest, "Login failed", "The redirect carried the wrong state parameter, so it did not belong to this login.")
+ arrived <- callback{err: err}
+ return
+ }
+ if query.Get("code") == "" {
+ page(w, http.StatusBadRequest, "Login failed", "The redirect carried no authorization code.")
+ arrived <- callback{err: fmt.Errorf("the login redirect carried no authorization code")}
+ return
+ }
+ page(w, http.StatusOK, "You are logged in", "The meshStack CLI has your login. You can close this tab and return to your terminal.")
+ arrived <- callback{code: query.Get("code")}
+ })
+}
+
+// openBrowser asks the desktop to open the authorization URL, and prints it either way: a failure
+// to launch a browser is not a failure to log in, because the user can still paste the URL. A
+// headless machine reaches this path every time.
+//
+// The three lines go straight to stderr rather than through slog. The URL is not a record of what
+// happened, it is the thing the person has to copy, so it must survive any log level and reach the
+// terminal unprefixed and unquoted — which is also how the prompt in internal/cli writes. Only
+// what goes wrong is logged.
+func openBrowser(authURL *url.URL) {
+ suppressed := os.Getenv(envNoBrowser) != ""
+ if suppressed {
+ fmt.Fprintf(os.Stderr, "%s is set, so no browser is opened. Log in to meshStack at:\n", envNoBrowser)
+ } else {
+ fmt.Fprintln(os.Stderr, "Opening your browser to log in to meshStack. If it does not open, visit:")
+ }
+ fmt.Fprintf(os.Stderr, "\n %s\n\n", authURL)
+ // Said out loud because this is where an unattended run stops for ten minutes. The URL above
+ // is the whole of what a person needs, so the login waits whether or not a terminal is
+ // attached — --no-input is how a script says nobody is coming.
+ fmt.Fprintf(os.Stderr, "Waiting up to %s for you to finish. Pass --no-input to fail at once instead.\n", loginTimeout)
+ if suppressed {
+ return
+ }
+
+ // A platform with no execBrowserOpen fails to build rather than falling back to nothing,
+ // because .goreleaser.yml publishes linux, darwin and windows and nothing else. Adding a
+ // target means adding the file that opens a browser on it.
+ cmd := execBrowserOpen(authURL.String())
+ if err := cmd.Start(); err != nil {
+ slog.Debug("cannot open a browser, waiting for a manually opened one instead",
+ "command", strings.Join(cmd.Args, " "), "error", err)
+ }
+}
diff --git a/pkg/oidc/browser/callback.go b/pkg/oidc/browser/callback.go
new file mode 100644
index 0000000..912ca95
--- /dev/null
+++ b/pkg/oidc/browser/callback.go
@@ -0,0 +1,26 @@
+package browser
+
+import (
+ _ "embed"
+ "html/template"
+ "log/slog"
+ "net/http"
+)
+
+// callbackPage is deliberately plain: it is shown once, in a tab the user closes immediately.
+// It is a file of its own so that an editor treats it as the HTML it is.
+//
+//go:embed callback.html
+var callbackPage string
+
+var resultPage = template.Must(template.New("callback").Parse(callbackPage))
+
+// page is the last thing a login shows: whatever the redirect turned out to be, written into
+// the tab the person is still looking at.
+func page(w http.ResponseWriter, status int, title, message string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := resultPage.Execute(w, struct{ Title, Message string }{title, message}); err != nil {
+ slog.Debug("cannot write the login result page", "error", err)
+ }
+}
diff --git a/pkg/oidc/browser/callback.html b/pkg/oidc/browser/callback.html
new file mode 100644
index 0000000..067f3c4
--- /dev/null
+++ b/pkg/oidc/browser/callback.html
@@ -0,0 +1,8 @@
+
+
+meshStack CLI
+
+{{.Title}}
+{{.Message}}
+
+
diff --git a/pkg/oidc/browser/open_darwin.go b/pkg/oidc/browser/open_darwin.go
new file mode 100644
index 0000000..d66b0c1
--- /dev/null
+++ b/pkg/oidc/browser/open_darwin.go
@@ -0,0 +1,7 @@
+package browser
+
+import "os/exec"
+
+func execBrowserOpen(authURL string) *exec.Cmd {
+ return exec.Command("open", authURL)
+}
diff --git a/pkg/oidc/browser/open_linux.go b/pkg/oidc/browser/open_linux.go
new file mode 100644
index 0000000..2c7f560
--- /dev/null
+++ b/pkg/oidc/browser/open_linux.go
@@ -0,0 +1,10 @@
+package browser
+
+import "os/exec"
+
+// xdg-open comes from xdg-utils, a freedesktop.org tool, so it is not a sensible default beyond
+// the desktops that ship it. The BSDs have it too and could share this file, but they are not
+// release targets, so they fail to build here rather than being supported untested.
+func execBrowserOpen(authURL string) *exec.Cmd {
+ return exec.Command("xdg-open", authURL)
+}
diff --git a/pkg/oidc/browser/open_windows.go b/pkg/oidc/browser/open_windows.go
new file mode 100644
index 0000000..c7c7b3d
--- /dev/null
+++ b/pkg/oidc/browser/open_windows.go
@@ -0,0 +1,9 @@
+package browser
+
+import "os/exec"
+
+// url.dll's FileProtocolHandler rather than `start`, which is a cmd.exe builtin and would need a
+// shell — and a shell would treat the & in the query string as a command separator.
+func execBrowserOpen(authURL string) *exec.Cmd {
+ return exec.Command("rundll32", "url.dll,FileProtocolHandler", authURL)
+}
diff --git a/pkg/oidc/client.go b/pkg/oidc/client.go
new file mode 100644
index 0000000..cfed9c2
--- /dev/null
+++ b/pkg/oidc/client.go
@@ -0,0 +1,132 @@
+package oidc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/url"
+
+ "github.com/meshcloud/meshstack-cli/client"
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/internal/http"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+)
+
+type Client struct {
+ http.Client
+ CliClientId string // the public CLI client from /mesh/info
+ ClientConfig
+}
+
+type ClientConfig struct {
+ Issuer xurl.URL `json:"issuer"`
+ AuthorizationEndpoint xurl.URL `json:"authorization_endpoint"`
+ TokenEndpoint xurl.URL `json:"token_endpoint"`
+ // Both are optional in the discovery document, and one of them ends a session.
+ EndSessionEndpoint *xurl.URL `json:"end_session_endpoint"`
+ RevocationEndpoint *xurl.URL `json:"revocation_endpoint"`
+}
+
+// NewClient discovers a new client starting from the /mesh/info endpoint of the meshstack instance.
+func NewClient(ctx context.Context, httpClient http.Client, rootUrl *url.URL) (Client, error) {
+ meshInfo, err := client.NewMeshInfoClient(ctx, rootUrl, httpClient).Read(ctx)
+ if err != nil {
+ return Client{}, err
+ }
+ clientConfig, err := httpClient.DoRequest[ClientConfig](ctx, http.MethodGet, meshInfo.Issuer.JoinPath(".well-known", "openid-configuration"))
+ if err != nil {
+ return Client{}, err
+ }
+ return Client{httpClient, meshInfo.CliClientId, clientConfig}, nil
+}
+
+func (c Client) doPost[R any](ctx context.Context, endpoint *url.URL, payload map[string]any) (result R, err error) {
+ result, err = c.DoRequest[R](ctx, http.MethodPost, endpoint, http.WithFormPayload(payload))
+ if httpErr, ok := errors.AsType[http.Error](err); ok {
+ var protocolErr protocolError
+ if unmarshalErr := json.Unmarshal(httpErr.ResponseBody, &protocolErr); unmarshalErr != nil {
+ return result, errors.Join(httpErr, unmarshalErr)
+ }
+ return result, errors.Join(httpErr, protocolErr)
+ }
+ return
+}
+
+type protocolError struct {
+ Code string `json:"error"`
+ Description string `json:"error_description"`
+}
+
+func (e protocolError) Error() string {
+ return fmt.Sprintf("%s: %s", e.Code, e.Description)
+}
+
+// Token is the token endpoint's successful answer.
+type Token struct {
+ AccessToken jwt.JWT `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ Scope string `json:"scope"`
+}
+
+func (c Client) Refresh(ctx context.Context, refreshToken, workspace string) (resp Token, err error) {
+ scopes := scope.Scopes{scope.OpenId}
+ if asked := meshstack.WorkspaceScope(workspace); asked != meshstack.Unscoped {
+ scopes = append(scopes, asked)
+ }
+ resp, err = c.doPost[Token](ctx, c.TokenEndpoint.URL, map[string]any{
+ "grant_type": "refresh_token",
+ "refresh_token": refreshToken,
+ "client_id": c.CliClientId,
+ "scope": scopes.String(),
+ })
+ if err != nil {
+ return resp, err
+ }
+ if resp.RefreshToken == "" {
+ // Keycloak does rotate refresh token on every call though (also to detect replay attacks)!
+ slog.DebugContext(ctx, "Re-using previous refresh token as identity provider returned empty refresh token")
+ resp.RefreshToken = refreshToken
+ }
+ return
+}
+
+// ExchangeAuthCode is the token request that ends the authorization code flow.
+// AuthorizationCodeFlow.Exchange is what calls it, and holds the verifier and the redirect URI it
+// needs.
+func (c Client) ExchangeAuthCode(ctx context.Context, code string, redirectUri xurl.URL, verifier string) (resp Token, err error) {
+ resp, err = c.doPost[Token](ctx, c.TokenEndpoint.URL, map[string]any{
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirectUri,
+ "client_id": c.CliClientId,
+ "code_verifier": verifier,
+ })
+ if err == nil && resp.RefreshToken == "" {
+ err = fmt.Errorf("the identity provider granted no refresh token, only the scopes %q", resp.Scope)
+ }
+ return
+}
+
+// EndSession ends the login at the identity provider. `meshstack auth logout --revoke` calls it.
+func (c Client) EndSession(ctx context.Context, refreshToken string) error {
+ endpoint, payload := c.EndSessionEndpoint, map[string]any{
+ "client_id": c.CliClientId,
+ "refresh_token": refreshToken,
+ }
+ if endpoint == nil {
+ endpoint, payload = c.RevocationEndpoint, map[string]any{
+ "client_id": c.CliClientId,
+ "token": refreshToken,
+ "token_type_hint": "refresh_token",
+ }
+ }
+ if endpoint == nil {
+ return errors.New("the identity provider advertises neither an end_session_endpoint nor a revocation_endpoint")
+ }
+ _, err := c.doPost[any](ctx, endpoint.URL, payload)
+ return err
+}
diff --git a/pkg/oidc/jwt/claim.go b/pkg/oidc/jwt/claim.go
new file mode 100644
index 0000000..0d5cf1d
--- /dev/null
+++ b/pkg/oidc/jwt/claim.go
@@ -0,0 +1,39 @@
+package jwt
+
+import (
+ "time"
+
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+)
+
+type Claim[V any] struct {
+ key string
+ converter func(v any) V
+}
+
+var (
+ WorkspaceClaim = Claim[string]{key: meshstack.ClaimKey}
+ // A token that says nothing about its own life reads as nil, not as 1970: nothing can
+ // renew one, so the server is what decides when it stops working.
+ Expiry = Claim[*time.Time]{
+ key: "exp",
+ converter: func(v any) *time.Time {
+ // JSON numbers decode as float64, and exp counts seconds since the epoch.
+ seconds, ok := v.(float64)
+ if !ok {
+ return nil
+ }
+ expiry := time.Unix(int64(seconds), 0)
+ return &expiry
+ },
+ }
+ UsernameClaim = Claim[string]{key: "preferred_username"}
+)
+
+func (c Claim[V]) GetFrom(jwt JWT) V {
+ if c.converter != nil {
+ return c.converter(jwt.claims[c.key])
+ }
+ value, _ := jwt.claims[c.key].(V)
+ return value
+}
diff --git a/pkg/oidc/jwt/jwt.go b/pkg/oidc/jwt/jwt.go
new file mode 100644
index 0000000..ce5ab12
--- /dev/null
+++ b/pkg/oidc/jwt/jwt.go
@@ -0,0 +1,46 @@
+package jwt
+
+import (
+ "encoding"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+var (
+ _ encoding.TextUnmarshaler = &JWT{}
+ _ encoding.TextMarshaler = JWT{}
+)
+
+type JWT struct {
+ String string
+ claims map[string]any
+}
+
+// Parse reads a token that arrived as a plain string rather than inside a JSON document. A
+// pasted API token is the case: it may not be a JWT at all, and then it carries no claims.
+func Parse(text string) (token JWT, err error) {
+ err = token.UnmarshalText([]byte(text))
+ return
+}
+
+func (jwt JWT) MarshalText() (text []byte, err error) {
+ return []byte(jwt.String), nil
+}
+
+func (jwt *JWT) UnmarshalText(text []byte) error {
+ jwt.String = string(text)
+ parts := strings.Split(jwt.String, ".")
+ if len(parts) != 3 {
+ return fmt.Errorf("the access token is not a JWT: it has %d dot-separated parts rather than 3", len(parts))
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return fmt.Errorf("cannot decode the JWT payload: %w", err)
+ }
+ if err := json.Unmarshal(payload, &jwt.claims); err != nil {
+ return fmt.Errorf("cannot parse JWT payload as JSON: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/oidc/jwt/jwt_test.go b/pkg/oidc/jwt/jwt_test.go
new file mode 100644
index 0000000..1c9c0f4
--- /dev/null
+++ b/pkg/oidc/jwt/jwt_test.go
@@ -0,0 +1,71 @@
+package jwt
+
+import (
+ _ "embed"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// All fabricated: only the payload is ever real, because nothing here reads the header or
+// verifies the signature.
+var (
+ //go:embed testdata/jwt_workspace
+ workspaceToken string
+ //go:embed testdata/jwt_unscoped
+ unscopedToken string
+ //go:embed testdata/jwt_opaque
+ opaqueToken string
+ //go:embed testdata/jwt_not_base64
+ notBase64Token string
+ //go:embed testdata/jwt_not_json
+ notJsonToken string
+)
+
+func TestClaims(t *testing.T) {
+ expiry := time.Unix(1767225600, 0)
+
+ t.Run("a token scoped to a workspace", func(t *testing.T) {
+ token := parse(t, workspaceToken)
+ assert.Equal(t, "demo", WorkspaceClaim.GetFrom(token))
+ assert.Equal(t, &expiry, Expiry.GetFrom(token))
+ })
+
+ t.Run("an unscoped token", func(t *testing.T) {
+ token := parse(t, unscopedToken)
+ assert.Empty(t, WorkspaceClaim.GetFrom(token), "an unscoped token carries no MC_CUSTOMER")
+ assert.Equal(t, &expiry, Expiry.GetFrom(token))
+ })
+}
+
+// TestRefusedTokens holds the rule that every meshStack access token is a JWT, including one
+// a person pasted: a text that cannot be read as one is refused where it arrives, rather than
+// stored and then found unreadable by a later command.
+func TestRefusedTokens(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ text string
+ wants string
+ }{
+ {"an opaque token", opaqueToken, "not a JWT"},
+ {"a payload that is not base64", notBase64Token, "cannot decode the JWT payload"},
+ {"a payload that is not JSON", notJsonToken, "cannot parse JWT payload as JSON"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ token, err := Parse(strings.TrimSpace(test.text))
+ require.ErrorContains(t, err, test.wants)
+ assert.Equal(t, strings.TrimSpace(test.text), token.String,
+ "the text is kept even when it could not be read, so an error can quote it")
+ })
+ }
+}
+
+func parse(t *testing.T, text string) JWT {
+ t.Helper()
+ var token JWT
+ require.NoError(t, token.UnmarshalText([]byte(strings.TrimSpace(text))))
+ return token
+}
diff --git a/pkg/oidc/jwt/testdata/jwt_not_base64 b/pkg/oidc/jwt/testdata/jwt_not_base64
new file mode 100644
index 0000000..3faab0b
--- /dev/null
+++ b/pkg/oidc/jwt/testdata/jwt_not_base64
@@ -0,0 +1 @@
+bogus.a.bogus
diff --git a/pkg/oidc/jwt/testdata/jwt_not_json b/pkg/oidc/jwt/testdata/jwt_not_json
new file mode 100644
index 0000000..4278eef
--- /dev/null
+++ b/pkg/oidc/jwt/testdata/jwt_not_json
@@ -0,0 +1 @@
+bogus.cGxhaW4.bogus
diff --git a/pkg/oidc/jwt/testdata/jwt_opaque b/pkg/oidc/jwt/testdata/jwt_opaque
new file mode 100644
index 0000000..40d58bf
--- /dev/null
+++ b/pkg/oidc/jwt/testdata/jwt_opaque
@@ -0,0 +1 @@
+an-opaque-token
diff --git a/pkg/oidc/jwt/testdata/jwt_unscoped b/pkg/oidc/jwt/testdata/jwt_unscoped
new file mode 100644
index 0000000..af90160
--- /dev/null
+++ b/pkg/oidc/jwt/testdata/jwt_unscoped
@@ -0,0 +1 @@
+bogus.eyJzdWIiOiJmN2MxIiwiZXhwIjoxNzY3MjI1NjAwLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqYW5lIn0.bogus
diff --git a/pkg/oidc/jwt/testdata/jwt_workspace b/pkg/oidc/jwt/testdata/jwt_workspace
new file mode 100644
index 0000000..548c471
--- /dev/null
+++ b/pkg/oidc/jwt/testdata/jwt_workspace
@@ -0,0 +1 @@
+bogus.eyJzdWIiOiJmN2MxIiwiZXhwIjoxNzY3MjI1NjAwLCJNQ19DVVNUT01FUiI6ImRlbW8ifQ.bogus
diff --git a/pkg/oidc/scope/scope.go b/pkg/oidc/scope/scope.go
new file mode 100644
index 0000000..18abb1a
--- /dev/null
+++ b/pkg/oidc/scope/scope.go
@@ -0,0 +1,31 @@
+// Package scope names an OAuth scope value. It is a package of its own because three
+// unrelated packages need the type and none of them may depend on the others: pkg/meshstack
+// derives the scope a workspace asks for, pkg/profile keys its cached access tokens by it,
+// and pkg/oidc sends a list of them in a grant.
+package scope
+
+import "strings"
+
+// The scopes a login asks for. offline_access is an optional client scope, so it has to be
+// named or the login lasts as long as one access token.
+const (
+ OpenId Scope = "openid"
+ Profile Scope = "profile"
+ Email Scope = "email"
+ OfflineAccess Scope = "offline_access"
+)
+
+type Scope string
+
+func (s Scope) String() string { return string(s) }
+
+type Scopes []Scope
+
+// String renders the scope request parameter, which RFC 6749 defines as space-delimited.
+func (s Scopes) String() string {
+ parts := make([]string, len(s))
+ for i, one := range s {
+ parts[i] = string(one)
+ }
+ return strings.Join(parts, " ")
+}
diff --git a/pkg/profile/credentials.go b/pkg/profile/credentials.go
new file mode 100644
index 0000000..309f14e
--- /dev/null
+++ b/pkg/profile/credentials.go
@@ -0,0 +1,70 @@
+package profile
+
+import (
+ "time"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+)
+
+// Credentials is `credentials/.json`: how one profile authenticates, and the
+// access tokens it has minted so far.
+//
+// The endpoint sits at the top of the file rather than on each method, because a valid
+// cached token is used without ever consulting a method: a per-method check would be
+// skipped exactly when the CLI is about to send a stored bearer token to whatever
+// endpoint the profile now names.
+type Credentials struct {
+ Version int `json:"version"`
+ // A pointer, because the file of a profile nothing has logged in to yet carries no
+ // endpoint, and an xurl.URL that is present has been parsed.
+ Endpoint *xurl.URL `json:"endpoint,omitzero"`
+ credential.Credential
+}
+
+// prune drops access tokens that have expired, from whichever of the three shapes the
+// credential holds. It is what keeps a login's map from growing by one entry per workspace
+// ever used.
+func prune(c credential.Credential, now time.Time) credential.Credential {
+ if c.Login != nil {
+ login := *c.Login
+ login.AccessTokens = pruneTokens(login.AccessTokens, now)
+ c.Login = &login
+ }
+ if c.ApiKey != nil && expired(c.ApiKey.AccessToken, now) {
+ apiKey := *c.ApiKey
+ apiKey.AccessToken = credential.IssuedToken{}
+ c.ApiKey = &apiKey
+ }
+ if c.Manual != nil && expired(c.Manual.AccessToken, now) {
+ manual := *c.Manual
+ manual.AccessToken = credential.IssuedToken{}
+ c.Manual = &manual
+ }
+ return c
+}
+
+func pruneTokens(tokens map[scope.Scope]credential.IssuedToken, now time.Time) map[scope.Scope]credential.IssuedToken {
+ if len(tokens) == 0 {
+ return nil
+ }
+ kept := make(map[scope.Scope]credential.IssuedToken, len(tokens))
+ for key, token := range tokens {
+ if !expired(token, now) {
+ kept[key] = token
+ }
+ }
+ if len(kept) == 0 {
+ return nil
+ }
+ return kept
+}
+
+// expired is false for a zero ExpiresAt, which means "this token said nothing about its own
+// life" rather than "expired at the zero time". An API token that is not a JWT is the case:
+// `meshstack auth login --api-token` stores one with an unknown expiry rather than a guessed
+// one, and dropping it here would make the very next command report it as expired.
+func expired(token credential.IssuedToken, now time.Time) bool {
+ return !token.ExpiresAt.IsZero() && !token.ExpiresAt.After(now)
+}
diff --git a/pkg/profile/lock.go b/pkg/profile/lock.go
new file mode 100644
index 0000000..cdbe3f5
--- /dev/null
+++ b/pkg/profile/lock.go
@@ -0,0 +1,107 @@
+package profile
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// The lock is a file created with O_CREATE|O_EXCL next to the credentials file, rather
+// than syscall.Flock: the CLI releases for Linux, macOS and Windows through goreleaser,
+// and flock does not exist on Windows.
+//
+// It matters that it works everywhere, because the two processes it serialises are a
+// `terraform apply` and a `meshstack` command renewing from the same profile. Keycloak
+// rotates the refresh token on every refresh and ends the whole session once one is
+// reused too often, so two unsynchronised refresh grants cost the user their login.
+const lockSuffix = ".lock"
+
+const (
+ // Short enough that the common case — the other holder is one HTTP round trip from
+ // done — is not noticeably delayed, and backed off so a long hold costs few syscalls.
+ lockRetryInitial = 20 * time.Millisecond
+ lockRetryMax = 250 * time.Millisecond
+
+ // A lock older than this is treated as abandoned. The hold it has to cover is a single
+ // token request — pkg/auth discovers the identity provider before it takes the lock,
+ // precisely so that nothing slower than one grant runs under it — and internal/http
+ // allows a minute per request, so five times that is comfortably clear of an identity
+ // provider having a bad day. Erring long is deliberate: breaking a live lock puts two
+ // refresh grants on one refresh token, which keycloak rotates and then ends the whole
+ // session over, while waiting too long only delays a user whose process was killed
+ // mid-renewal. An API key exchange can hold longer still, because it retries through a
+ // backend restart, but breaking that lock costs one duplicate token and nothing else:
+ // /api/login mints without invalidating anything.
+ lockStaleAfter = 5 * time.Minute
+)
+
+// ErrLockBusy reports that another process held the lock until ctx ran out. It is not a
+// write failure: the holder is running a refresh grant, so minting without the lock would
+// be the replay keycloak ends the session over.
+var ErrLockBusy = errors.New("another meshStack process holds this profile's credentials lock")
+
+// acquireLock takes the exclusive lock for a credentials file, waiting until it can or
+// until ctx is done. The returned function releases it.
+func acquireLock(ctx context.Context, path string) (func(), error) {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, dirMode); err != nil {
+ return nil, fmt.Errorf("%s could not be created: %w", dir, err)
+ }
+
+ backoff := lockRetryInitial
+ for {
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, fileMode)
+ if err == nil {
+ // The holder's pid is written for a human debugging a stuck lock; nothing reads it.
+ _, _ = fmt.Fprintf(f, "pid %d at %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339))
+ _ = f.Close()
+ return func() {
+ if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ slog.Warn("could not release the credentials lock", "file", path, "error", err)
+ }
+ }, nil
+ }
+ if !errors.Is(err, fs.ErrExist) {
+ return nil, fmt.Errorf("cannot lock the credentials: %s could not be created: %w. The credentials directory has to be writable, because a token renewal has to be serialised against other meshStack processes", path, err)
+ }
+ if breakStaleLock(path) {
+ continue
+ }
+
+ select {
+ case <-ctx.Done():
+ return nil, errors.Join(fmt.Errorf("timed out waiting for %s: %w. If no other meshStack process is running, remove the file", path, ctx.Err()), ErrLockBusy)
+ case <-time.After(backoff):
+ }
+ if backoff *= 2; backoff > lockRetryMax {
+ backoff = lockRetryMax
+ }
+ }
+}
+
+// breakStaleLock removes a lock whose holder is long gone, and reports whether it did.
+// Two waiters may break the same lock and then both create one, which is why the window
+// is generous: the loss is a duplicate renewal, not a corrupt file, because the write
+// itself is atomic.
+func breakStaleLock(path string) bool {
+ info, err := os.Stat(path)
+ if err != nil {
+ // Gone already, or unreadable. Either way the caller should just retry the create.
+ return errors.Is(err, fs.ErrNotExist)
+ }
+ age := time.Since(info.ModTime())
+ if age < lockStaleAfter {
+ return false
+ }
+ if err := os.Remove(path); err != nil {
+ slog.Warn("could not break a stale credentials lock", "file", path, "age", age, "error", err)
+ return false
+ }
+ slog.Warn("broke a stale credentials lock left behind by another process", "file", path, "age", age)
+ return true
+}
diff --git a/pkg/profile/paths.go b/pkg/profile/paths.go
new file mode 100644
index 0000000..a24e6a3
--- /dev/null
+++ b/pkg/profile/paths.go
@@ -0,0 +1,34 @@
+package profile
+
+import (
+ "path/filepath"
+
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+)
+
+const DefaultName Name = "default"
+
+// configDir resolves ConfigDir, which no front end offers a flag for.
+func configDir() (string, error) {
+ dir, _, err := setting.Resolve(ConfigDir)
+ return dir, err
+}
+
+func ConfigPath() (string, error) {
+ dir, err := configDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "config.json"), nil
+}
+
+func CredentialsPath(name Name) (string, error) {
+ if _, err := ParseName(string(name)); err != nil {
+ return "", err
+ }
+ dir, err := configDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "credentials", string(name)+".json"), nil
+}
diff --git a/pkg/profile/profile.go b/pkg/profile/profile.go
new file mode 100644
index 0000000..5a19844
--- /dev/null
+++ b/pkg/profile/profile.go
@@ -0,0 +1,149 @@
+// Package profile reads and writes the meshStack CLI's configuration on disk:
+// `config.json`, which describes every profile, and `credentials/.json`,
+// which holds one profile's credentials and its cached access tokens.
+//
+// Both front ends use it. The Terraform provider reads a profile the way the AWS
+// provider reads `~/.aws`, and it has to write rotated refresh tokens back, so the
+// lock a Store takes is cross-tool rather than an internal detail.
+//
+// The two files are separate so that a renewal locks only the profile it renews:
+// `config.json` is never locked, so editing a profile never waits for a network round
+// trip, and a failed credential write can never cost a user their configuration.
+//
+// Nothing here is created until a command actually needs to store something, so a
+// process that only reads leaves no trace in the user's configuration directory.
+package profile
+
+import (
+ "bytes"
+ "encoding/json/jsontext"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+)
+
+// Version is the format version both files carry. A CLI that reads a higher one
+// reports it and stops, because a file it does not understand may hold fields whose
+// absence changes what a write means.
+//
+// Every other field on both structs is left out when its Go zero value means "not set". This
+// one is tagged without `omitzero`, because both readers start from this constant: a file that
+// carried no version would silently claim to be the current one.
+const Version = 1
+
+// writeOptions are shared by both files. Deterministic is what stops them churning: v2 writes
+// map members in Go's randomised iteration order, so without it an hourly token renewal
+// reshuffles accessTokens and the file changes when nothing in it did.
+var writeOptions = json.JoinOptions(jsontext.WithIndent(" "), json.Deterministic(true))
+
+const (
+ dirMode fs.FileMode = 0o700
+ fileMode fs.FileMode = 0o600
+)
+
+// Config is `config.json`: every profile this installation knows about. The map key is a
+// Name, so json/v2 validates every name it reads or writes through Name's TextUnmarshaler.
+type Config struct {
+ Version int `json:"version"`
+ CurrentProfile Name `json:"currentProfile,omitzero"`
+ Profiles map[Name]Profile `json:"profiles,omitzero"`
+}
+
+// Profile is what describes a profile rather than what authenticates it. The
+// credentials live in their own file, which is what keeps a renewal from locking this
+// one.
+type Profile struct {
+ Endpoint *xurl.URL `json:"endpoint,omitzero"`
+ DefaultWorkspace string `json:"defaultWorkspace,omitzero"`
+}
+
+// LoadConfig reads `config.json`. A missing file is an empty configuration rather than
+// an error: that is the state of a fresh install.
+func LoadConfig() (Config, error) {
+ path, err := ConfigPath()
+ if err != nil {
+ return Config{}, err
+ }
+ cfg := Config{Version: Version}
+ data, err := os.ReadFile(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return cfg, nil
+ }
+ if err != nil {
+ return Config{}, fmt.Errorf("config at %s could not be read", path)
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return Config{}, parseFailed("cannot parse the configuration", path, data, err)
+ }
+ if err := checkVersion(cfg.Version, path); err != nil {
+ return Config{}, err
+ }
+ return cfg, nil
+}
+
+// SaveConfig writes `config.json` atomically, creating the directory if it is missing.
+// It needs no lock: only commands that configure write this file, and a renewal never
+// touches it.
+func SaveConfig(cfg Config) error {
+ path, err := ConfigPath()
+ if err != nil {
+ return err
+ }
+ // Stamped rather than trusted: this code can only write the format it implements,
+ // and reading a higher version already stopped before we got here.
+ cfg.Version = Version
+ data, err := json.Marshal(cfg, writeOptions)
+ if err != nil {
+ return fmt.Errorf("%s could not be encoded: %w", path, err)
+ }
+ return writeFileAtomic(path, append(data, '\n'))
+}
+
+func parseFailed(summary, path string, data []byte, cause error) error {
+ if !bytes.HasPrefix(bytes.TrimLeft(data, " \t\r\n"), []byte("{")) {
+ return fmt.Errorf("%s: %s is not JSON: %w. The meshStack CLI stored its configuration as YAML in earlier builds; run `meshstack login` to write it again", summary, path, cause)
+ }
+ return fmt.Errorf("%s: %s is not valid JSON: %w", summary, path, cause)
+}
+
+// checkVersion stops on a file written by a newer CLI, naming the file so the reader
+// knows which one to look at.
+func checkVersion(version int, path string) error {
+ if version <= Version {
+ return nil
+ }
+ return fmt.Errorf("configuration is newer than this CLI: %s is version %d, and this CLI understands version %d. Upgrade the meshStack CLI", path, version, Version)
+}
+
+// writeFileAtomic writes through a temporary file in the same directory and renames it,
+// so a reader sees either the old file or the new one and never a half-written one.
+func writeFileAtomic(path string, data []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, dirMode); err != nil {
+ return fmt.Errorf("%s could not be created: %w", dir, err)
+ }
+ // os.CreateTemp creates with 0600, which is the mode these files need anyway.
+ tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp")
+ if err != nil {
+ return fmt.Errorf("a temporary file could not be created in %s: %w", dir, err)
+ }
+ tmpName := tmp.Name()
+ defer func() { _ = os.Remove(tmpName) }() // a no-op once the rename succeeded
+
+ if _, err := tmp.Write(data); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("%s could not be written: %w", tmpName, err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("%s could not be written: %w", tmpName, err)
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ return fmt.Errorf("%s could not be replaced: %w", path, err)
+ }
+ return nil
+}
diff --git a/pkg/profile/profiles.go b/pkg/profile/profiles.go
new file mode 100644
index 0000000..08cf077
--- /dev/null
+++ b/pkg/profile/profiles.go
@@ -0,0 +1,115 @@
+package profile
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+)
+
+// Summary is one entry of config.json as a listing shows it, for the prompt that asks which
+// endpoint a new profile belongs to.
+type Summary struct {
+ Name Name
+ Endpoint string
+ IsCurrent bool
+}
+
+// List returns the configured profiles, sorted by name.
+func List() ([]Summary, error) {
+ config, err := LoadConfig()
+ if err != nil {
+ return nil, err
+ }
+ known := make([]Summary, 0, len(config.Profiles))
+ for name, entry := range config.Profiles {
+ endpoint := ""
+ if entry.Endpoint != nil {
+ endpoint = entry.Endpoint.String()
+ }
+ known = append(known, Summary{
+ Name: name,
+ Endpoint: endpoint,
+ IsCurrent: name == config.CurrentProfile,
+ })
+ }
+ slices.SortFunc(known, func(a, b Summary) int { return strings.Compare(string(a.Name), string(b.Name)) })
+ return known, nil
+}
+
+// Ensure creates the profile when it does not exist, and is the only thing that ever creates
+// one: a mistyped --profile on an ordinary command must report an unknown profile rather than
+// leave one behind with no endpoint, so the single caller is `meshstack auth login`, whose
+// purpose is to configure.
+func Ensure(name Name, endpoint *xurl.URL) error {
+ config, err := LoadConfig()
+ if err != nil {
+ return err
+ }
+ if config.Profiles == nil {
+ config.Profiles = map[Name]Profile{}
+ }
+ entry, exists := config.Profiles[name]
+ if exists && (endpoint == nil || (entry.Endpoint != nil && entry.Endpoint.Equal(*endpoint))) {
+ return nil
+ }
+ if endpoint == nil {
+ return fmt.Errorf("no endpoint for a new profile: profile %q does not exist. Name its endpoint with --endpoint or %s", name, meshstack.Endpoint.EnvKey())
+ }
+ entry.Endpoint = endpoint
+ config.Version = Version
+ config.Profiles[name] = entry
+ if config.CurrentProfile == "" {
+ config.CurrentProfile = name
+ }
+ return SaveConfig(config)
+}
+
+// SetEndpoint and SetWorkspace back `meshstack profile set`. They refuse an unknown name for
+// the same reason Ensure is the only creator.
+func SetEndpoint(name Name, endpoint string) error {
+ return update(name, func(entry *Profile) error {
+ parsed, err := meshstack.Endpoint.Parse(endpoint)
+ if err != nil {
+ return err
+ }
+ entry.Endpoint = &parsed
+ return nil
+ })
+}
+
+func SetWorkspace(name Name, ws string) error {
+ return update(name, func(entry *Profile) error {
+ entry.DefaultWorkspace = ws
+ return nil
+ })
+}
+
+func update(name Name, change func(*Profile) error) error {
+ config, err := LoadConfig()
+ if err != nil {
+ return err
+ }
+ entry, ok := config.Profiles[name]
+ if !ok {
+ return fmt.Errorf("unknown profile: profile %q is not in %s. `meshstack auth login --profile %s` creates it", name, DescribeConfigPath(), name)
+ }
+ if err := change(&entry); err != nil {
+ return err
+ }
+ config.Version = Version
+ config.Profiles[name] = entry
+ return SaveConfig(config)
+}
+
+// DescribeConfigPath names config.json inside a message about something else, so a path that
+// cannot be resolved degrades to a description rather than replacing that message with its own.
+func DescribeConfigPath() string {
+ path, err := ConfigPath()
+ if err != nil {
+ return "the meshStack CLI configuration"
+ }
+ return path
+}
diff --git a/pkg/profile/select.go b/pkg/profile/select.go
new file mode 100644
index 0000000..e604c7a
--- /dev/null
+++ b/pkg/profile/select.go
@@ -0,0 +1,91 @@
+package profile
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+)
+
+type Selection struct {
+ Name Name
+ Entry Profile
+ Exists bool
+
+ // Named tells a typo from a machine nobody has configured, for the same Exists.
+ Named bool
+
+ // Endpoint is what a source above the profile named, empty when none did.
+ Endpoint string
+
+ NameFrom setting.SourceDescription
+ EndpointFrom setting.SourceDescription
+}
+
+// Select resolves the profile name from the given sources, then the only profile configured
+// for the endpoint they name, then currentProfile, then DefaultName. Exists is reported
+// rather than judged: only the caller knows whether it is about to write the profile.
+func Select(ctx context.Context, sources ...setting.Source) (Selection, error) {
+ config, err := LoadConfig()
+ if err != nil {
+ return Selection{}, err
+ }
+
+ endpoint, endpointResolution, err := setting.Resolve(meshstack.Endpoint, sources...)
+ if err != nil {
+ return Selection{}, err
+ }
+ name, nameResolution, err := setting.Resolve(NameSetting, sources...)
+ if err != nil {
+ return Selection{}, err
+ }
+
+ selection := Selection{Name: name, Named: nameResolution.From != nil}
+ if from := nameResolution.From; from != nil {
+ selection.NameFrom = from.Describe(NameSetting.EnvKey())
+ } else {
+ var matches []Name
+ if endpointResolution.From != nil {
+ for candidate, entry := range config.Profiles {
+ if entry.Endpoint != nil && entry.Endpoint.Equal(endpoint) {
+ matches = append(matches, candidate)
+ }
+ }
+ slices.Sort(matches)
+ }
+
+ switch {
+ case len(matches) > 1:
+ quoted := make([]string, len(matches))
+ for i, match := range matches {
+ quoted[i] = strconv.Quote(string(match))
+ }
+ return Selection{}, fmt.Errorf("several profiles match this endpoint: %s are all configured for %s. Name one with %s",
+ strings.Join(quoted, ", "), endpoint, NameSetting.EnvKey())
+ case len(matches) == 1:
+ selection.Name = matches[0]
+ selection.NameFrom = setting.SourceDescription{Type: "the only profile for", Details: endpoint.String()}
+ slog.WarnContext(ctx, "picked a profile by endpoint",
+ "detail", fmt.Sprintf("profile %q is the only one configured for %s, so this command uses its credentials. Name one with %s to be explicit.",
+ selection.Name, endpoint, NameSetting.EnvKey()))
+ case config.CurrentProfile != "":
+ selection.Name = config.CurrentProfile
+ selection.NameFrom = setting.SourceDescription{Type: "currentProfile in", Details: DescribeConfigPath()}
+ default:
+ selection.Name = DefaultName
+ selection.NameFrom = setting.SourceDescription{Type: "built-in default"}
+ }
+ }
+
+ if from := endpointResolution.From; from != nil {
+ selection.Endpoint = endpoint.String()
+ selection.EndpointFrom = from.Describe(meshstack.Endpoint.EnvKey())
+ }
+ selection.Entry, selection.Exists = config.Profiles[selection.Name]
+ return selection, nil
+}
diff --git a/pkg/profile/settings.go b/pkg/profile/settings.go
new file mode 100644
index 0000000..85c51ea
--- /dev/null
+++ b/pkg/profile/settings.go
@@ -0,0 +1,75 @@
+package profile
+
+import (
+ "encoding"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+)
+
+var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
+
+func ParseName(name string) (Name, error) {
+ if nameRegex.MatchString(name) {
+ return Name(name), nil
+ }
+ return "", fmt.Errorf("a profile name must match %s", nameRegex)
+}
+
+type Name string
+
+func (n Name) MarshalText() ([]byte, error) {
+ if _, err := ParseName(string(n)); err != nil {
+ return nil, err
+ } else {
+ return []byte(n), nil
+ }
+}
+
+//goland:noinspection GoMixedReceiverTypes
+func (n *Name) UnmarshalText(text []byte) (err error) {
+ *n, err = ParseName(string(text))
+ return
+}
+
+var _ encoding.TextMarshaler = Name("")
+var _ encoding.TextUnmarshaler = new(Name(""))
+
+var NameSetting = setting.Setting[Name]{
+ Env: "MESHSTACK_PROFILE",
+ Short: "The profile whose credentials and defaults this run uses. Also read from MESHSTACK_PROFILE.",
+ Long: "The profile whose credentials and defaults this run uses, also read from `MESHSTACK_PROFILE`.\n\n" +
+ "A profile is a named bundle of endpoint, credential and default workspace, written by " +
+ "`meshstack auth login` into the meshStack CLI's configuration directory. It supplies each of those " +
+ "only where nothing above it did, so it is never an override.\n\n" +
+ "With no name given, the profile is the one whose endpoint matches the endpoint in use, else the one " +
+ "`meshstack profile set` last selected, else `default`.",
+ Default: setting.DefaultSource(setting.StaticLookup("default")),
+ Parse: setting.ParseTextUnmarshaler[Name],
+}
+
+var ConfigDir = setting.Setting[string]{
+ Env: "MESHSTACK_CONFIG_DIR",
+ Short: "The directory holding config.json and one credentials file per profile. Also read from MESHSTACK_CONFIG_DIR.",
+ Long: "The directory holding the meshStack CLI's configuration, also read from `MESHSTACK_CONFIG_DIR`.\n\n" +
+ "`config.json` describes every profile, and `credentials/.json` holds that profile's " +
+ "credentials and its cached tokens. The credentials directory is a convention rather than a " +
+ "setting of its own, so it moves with this one and cannot be pointed elsewhere.",
+ Default: setting.DefaultSource(func() (dir string, err error) {
+ // os.UserConfigDir already honours XDG_CONFIG_HOME on Linux. Naming it here is what
+ // makes it win on macOS and Windows too, where the platform directory differs.
+ const (
+ envXDGConfigHome = "XDG_CONFIG_HOME"
+ )
+ if dir = os.Getenv(envXDGConfigHome); dir == "" {
+ if dir, err = os.UserConfigDir(); err != nil {
+ return "", fmt.Errorf("cannot locate a configuration directory: %w", err)
+ }
+ }
+ return filepath.Join(dir, "meshstack"), nil
+ }),
+ Parse: setting.ParseText,
+}
diff --git a/pkg/profile/store.go b/pkg/profile/store.go
new file mode 100644
index 0000000..16e2147
--- /dev/null
+++ b/pkg/profile/store.go
@@ -0,0 +1,207 @@
+package profile
+
+import (
+ "context"
+ json "encoding/json/v2"
+ "errors"
+ "fmt"
+ "io/fs"
+ "maps"
+ "os"
+ "sync"
+ "time"
+)
+
+// Store owns one profile's credentials, including its lock. Both front ends use it: the
+// Terraform provider reads a profile like the AWS provider reads ~/.aws, and it has to
+// write rotated refresh tokens back. The memory implementation satisfies the same
+// interface with no lock and no file.
+type Store interface {
+ // Read returns the credentials without taking the lock.
+ Read() (Credentials, error)
+
+ // Update runs mint while holding the exclusive lock, having re-read first so that a
+ // process which lost the race sees the winner's token instead of minting again.
+ // Whatever mint returns is written in one atomic operation, which is what keeps a
+ // rotated refresh token and the access token it came with from ever being separated.
+ Update(ctx context.Context, mint func(Credentials) (Credentials, error)) (Credentials, error)
+
+ // Forget removes the profile's credentials file.
+ Forget() error
+
+ // Describe names where the credentials live, for `meshstack auth status` and for an
+ // error that has to say which file it means. A memory store says so rather than
+ // naming a path.
+ Describe() string
+
+ // Writable reports whether Update can persist. A memory store is not writable, and
+ // pkg/auth uses this to decide whether a profile degraded to memory.
+ Writable() bool
+}
+
+// ErrNotWritable reports that a file store could not persist: the lock could not be taken,
+// or the atomic write failed. pkg/auth matches on it to degrade a profile to a memory store,
+// which is how a read-only home directory stays usable.
+//
+// It exists because Writable cannot be answered honestly in advance: a probe would create a
+// file a read-only command must not create, and could still disagree with the later write.
+// Reporting the real failure is exact where a probe is a guess.
+var ErrNotWritable = errors.New("this profile's credentials could not be written")
+
+// fileStore is one profile's `credentials/.json` plus the lock next to it.
+type fileStore struct {
+ path string
+ lockPath string
+}
+
+// NewFileStore resolves and validates the path of a profile's credentials file. It
+// creates nothing: a process that only reads leaves no trace, and the directory appears
+// the first time something is actually stored.
+func NewFileStore(name Name) (Store, error) {
+ path, err := CredentialsPath(name)
+ if err != nil {
+ return nil, err
+ }
+ return &fileStore{path: path, lockPath: path + lockSuffix}, nil
+}
+
+func (s *fileStore) Describe() string { return s.path }
+
+// Writable is true for a file store by construction. Whether the write really succeeds
+// is answered by attempting it, not by probing beforehand, because a probe both creates
+// something a read-only command should not create and can disagree with the later write.
+func (s *fileStore) Writable() bool { return true }
+
+// Read returns empty credentials when the file is missing, because a profile in
+// config.json with no credentials file means "not logged in" — the same state as a
+// fresh install, and not an error.
+func (s *fileStore) Read() (Credentials, error) {
+ creds := Credentials{Version: Version}
+ data, err := os.ReadFile(s.path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return creds, nil
+ }
+ if err != nil {
+ return Credentials{}, fmt.Errorf("%s could not be read: %w", s.path, err)
+ }
+ if err := json.Unmarshal(data, &creds); err != nil {
+ return Credentials{}, parseFailed("cannot parse the stored credentials", s.path, data, err)
+ }
+ if err := checkVersion(creds.Version, s.path); err != nil {
+ return Credentials{}, err
+ }
+ // Checked here rather than at the point of use, so that the message can name the file.
+ if err := creds.Validate(); err != nil {
+ return Credentials{}, fmt.Errorf("%s does not describe a usable credential: %w", s.path, err)
+ }
+ return creds, nil
+}
+
+func (s *fileStore) Update(ctx context.Context, mint func(Credentials) (Credentials, error)) (Credentials, error) {
+ release, err := acquireLock(ctx, s.lockPath)
+ if errors.Is(err, ErrLockBusy) {
+ return Credentials{}, err
+ }
+ if err != nil {
+ return Credentials{}, errors.Join(err, ErrNotWritable)
+ }
+ defer release()
+
+ // Re-read under the lock: another process may have renewed while this one waited, and
+ // mint is expected to notice that and hand the credentials back unchanged.
+ current, err := s.Read()
+ if err != nil {
+ return Credentials{}, err
+ }
+ next, err := mint(current)
+ if err != nil {
+ return Credentials{}, err
+ }
+ next.Version = Version
+ next.Credential = prune(next.Credential, time.Now())
+
+ // Written unconditionally, even when mint changed nothing. Credentials holds a map
+ // and two time.Time values, so a comparison is either uncompilable or subtly wrong
+ // about monotonic clocks and locations — and pruning has to reach the file anyway.
+ data, err := json.Marshal(next, writeOptions)
+ if err != nil {
+ return Credentials{}, fmt.Errorf("%s could not be encoded: %w", s.path, err)
+ }
+ if err := writeFileAtomic(s.path, append(data, '\n')); err != nil {
+ return Credentials{}, errors.Join(err, ErrNotWritable)
+ }
+ return next, nil
+}
+
+func (s *fileStore) Forget() error {
+ if err := os.Remove(s.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("%s could not be removed: %w", s.path, err)
+ }
+ return nil
+}
+
+// memoryStore is what a credential that did not come from a profile resolves to: an
+// environment variable, a Terraform provider block, or a profile whose file cannot be
+// written. Its token lives for the process and never lands in somebody's profile.
+type memoryStore struct {
+ mu sync.Mutex
+ creds Credentials
+}
+
+func NewMemoryStore(initial Credentials) Store {
+ initial.Version = Version
+ return &memoryStore{creds: initial}
+}
+
+func (s *memoryStore) Describe() string { return "in memory; nothing is written to disk" }
+
+func (s *memoryStore) Writable() bool { return false }
+
+func (s *memoryStore) Read() (Credentials, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.snapshot(), nil
+}
+
+// Update needs no lock file and no re-read, because nothing outside this process can
+// see these credentials. The mutex is only there because one process may renew from
+// several goroutines.
+func (s *memoryStore) Update(_ context.Context, mint func(Credentials) (Credentials, error)) (Credentials, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ next, err := mint(s.snapshot())
+ if err != nil {
+ return Credentials{}, err
+ }
+ next.Version = Version
+ next.Credential = prune(next.Credential, time.Now())
+ s.creds = next
+ return next, nil
+}
+
+func (s *memoryStore) Forget() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.creds = Credentials{Version: Version}
+ return nil
+}
+
+// snapshot deep-copies as far as a caller can reach, so that mutating what it read cannot
+// change the store. A file store gets that for free.
+func (s *memoryStore) snapshot() Credentials {
+ creds := s.creds
+ if creds.Login != nil {
+ login := *creds.Login
+ login.AccessTokens = maps.Clone(login.AccessTokens)
+ creds.Login = &login
+ }
+ if creds.ApiKey != nil {
+ apiKey := *creds.ApiKey
+ creds.ApiKey = &apiKey
+ }
+ if creds.Manual != nil {
+ manual := *creds.Manual
+ creds.Manual = &manual
+ }
+ return creds
+}
diff --git a/pkg/profile/store_test.go b/pkg/profile/store_test.go
new file mode 100644
index 0000000..7af2246
--- /dev/null
+++ b/pkg/profile/store_test.go
@@ -0,0 +1,514 @@
+package profile
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/meshcloud/meshstack-cli/client/types/xurl"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/jwt"
+ "github.com/meshcloud/meshstack-cli/pkg/oidc/scope"
+)
+
+// isolate points the configuration directory at a temporary one, through the same
+// XDG_CONFIG_HOME the built-in default reads.
+func isolate(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ t.Setenv(ConfigDir.EnvKey(), "")
+ return dir
+}
+
+// unchanged is the mint a caller passes when it only wants the read-lock-write cycle,
+// which the store must still handle: it prunes and writes rather than comparing.
+func unchanged(c Credentials) (Credentials, error) { return c, nil }
+
+func newTestStore(t *testing.T, name Name) Store {
+ t.Helper()
+ isolate(t)
+ store, err := NewFileStore(name)
+ require.NoError(t, err)
+ return store
+}
+
+func TestNewFileStoreCreatesNothing(t *testing.T) {
+ dir := isolate(t)
+
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+ require.Equal(t, filepath.Join(dir, "meshstack", "credentials", "default.json"), store.Describe())
+ require.True(t, store.Writable())
+
+ creds, err := store.Read()
+ require.NoError(t, err)
+ // A profile with no credentials file is "not logged in", not an error.
+ require.Equal(t, Credentials{Version: Version}, creds)
+
+ _, err = os.Stat(filepath.Join(dir, "meshstack"))
+ require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestCredentialsRoundTrip(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ obtained := time.Date(2026, 8, 27, 9, 1, 0, 0, time.UTC)
+ expires := time.Now().UTC().Add(5 * time.Minute).Truncate(time.Second)
+ want := Credentials{
+ Version: Version,
+ Endpoint: mustUrl("https://api.dev.meshcloud.io"),
+ Credential: credential.Credential{
+ Current: credential.MethodLogin,
+ Login: &credential.Login{
+ Issuer: mustUrl("https://sso.dev.meshcloud.io/auth/realms/meshfed"),
+ RefreshToken: "refresh-1",
+ ObtainedAt: obtained,
+ AccessTokens: map[scope.Scope]credential.IssuedToken{
+ meshstack.Unscoped: {Token: fakeJwt("token-unscoped"), ExpiresAt: expires},
+ meshstack.WorkspaceScope("my-workspace"): {Token: fakeJwt("token-scoped"), ExpiresAt: expires},
+ },
+ },
+ ApiKey: &credential.ApiKey{
+ Id: "6169f530-0eaa-4f7f-91b7-c4fd4aaf2a74",
+ SecretCommand: []string{"vault", "kv", "get", "-field=secret", "concourse/meshstack-dev"},
+ },
+ },
+ }
+
+ written, err := store.Update(t.Context(), func(Credentials) (Credentials, error) { return want, nil })
+ require.NoError(t, err)
+ require.Equal(t, want, written)
+
+ raw, err := os.ReadFile(filepath.Join(dir, "meshstack", "credentials", "default.json"))
+ require.NoError(t, err)
+ text := string(raw)
+ assert.Contains(t, text, `"version": 1`)
+ assert.Contains(t, text, `"current": "login"`)
+ // The embedded credential is inlined: its members sit beside the version rather than
+ // under a key named after the type.
+ assert.NotContains(t, text, `"Credential"`)
+ assert.Contains(t, text, `"refreshToken": "refresh-1"`)
+ assert.Contains(t, text, `"clientSecretCommand"`)
+ assert.Contains(t, text, `"c:my-workspace"`)
+ assert.Contains(t, text, `"unscoped"`)
+ // clientSecret is omitted entirely when a command supplies it.
+ assert.NotContains(t, text, `"clientSecret"`)
+
+ got, err := store.Read()
+ require.NoError(t, err)
+ require.Equal(t, want, got)
+}
+
+func TestUpdateWritesOnlyTheVersionOfAnEmptyCredential(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ _, err = store.Update(t.Context(), unchanged)
+ require.NoError(t, err)
+
+ raw, err := os.ReadFile(filepath.Join(dir, "meshstack", "credentials", "default.json"))
+ require.NoError(t, err)
+ require.Equal(t, "{\n \"version\": 1\n}\n", string(raw))
+
+ got, err := store.Read()
+ require.NoError(t, err)
+ require.Equal(t, Credentials{Version: Version}, got)
+}
+
+func TestCredentialsFileModes(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX file modes")
+ }
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ _, err = store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ c.Endpoint = mustUrl("https://api.dev.meshcloud.io")
+ return c, nil
+ })
+ require.NoError(t, err)
+
+ info, err := os.Stat(filepath.Join(dir, "meshstack", "credentials", "default.json"))
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+ dirInfo, err := os.Stat(filepath.Join(dir, "meshstack", "credentials"))
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())
+}
+
+func TestReadRejectsANewerVersion(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ path := filepath.Join(dir, "meshstack", "credentials", "default.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), dirMode))
+ require.NoError(t, os.WriteFile(path, []byte(`{"version": 99, "endpoint": "https://example.com"}`), fileMode))
+
+ _, err = store.Read()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "version 99")
+ assert.Contains(t, err.Error(), path)
+}
+
+func TestUpdatePrunesExpiredTokens(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ fresh := credential.IssuedToken{Token: fakeJwt("fresh"), ExpiresAt: time.Now().Add(5 * time.Minute).UTC()}
+ stale := credential.IssuedToken{Token: fakeJwt("stale"), ExpiresAt: time.Now().Add(-time.Second).UTC()}
+
+ got, err := store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ c.Credential = credential.FromLogin(credential.Login{
+ AccessTokens: map[scope.Scope]credential.IssuedToken{
+ meshstack.Unscoped: fresh,
+ meshstack.WorkspaceScope("gone"): stale,
+ meshstack.WorkspaceScope("also-gone"): stale,
+ },
+ })
+ return c, nil
+ })
+ require.NoError(t, err)
+ require.Equal(t, map[scope.Scope]credential.IssuedToken{meshstack.Unscoped: fresh}, got.Login.AccessTokens)
+
+ raw, err := os.ReadFile(filepath.Join(dir, "meshstack", "credentials", "default.json"))
+ require.NoError(t, err)
+ assert.NotContains(t, string(raw), "stale")
+
+ // The last token expiring leaves the key out of the file rather than an empty map.
+ got, err = store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ c.Login.AccessTokens[meshstack.Unscoped] = stale
+ return c, nil
+ })
+ require.NoError(t, err)
+ require.Nil(t, got.Login.AccessTokens)
+ raw, err = os.ReadFile(filepath.Join(dir, "meshstack", "credentials", "default.json"))
+ require.NoError(t, err)
+ assert.NotContains(t, string(raw), "accessTokens")
+}
+
+// TestUpdateKeepsATokenWithNoExpiry pins the difference between "expired at the zero time" and
+// "said nothing about its own life". `meshstack auth login --api-token` stores a token that is
+// not a JWT with an unknown expiry rather than a guessed one, and pruning it here would make the
+// very next command report it as expired.
+func TestUpdateKeepsATokenWithNoExpiry(t *testing.T) {
+ store := newTestStore(t, "default")
+
+ got, err := store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ c.Credential = credential.FromManual(credential.Manual{
+ AccessToken: credential.IssuedToken{Token: fakeJwt("no-expiry")},
+ })
+ return c, nil
+ })
+ require.NoError(t, err)
+ require.Equal(t, fakeJwt("no-expiry"), got.Manual.AccessToken.Token)
+
+ reread, err := store.Read()
+ require.NoError(t, err)
+ require.Equal(t, fakeJwt("no-expiry"), reread.Manual.AccessToken.Token)
+}
+
+func TestUpdateWritesEvenWhenMintChangesNothing(t *testing.T) {
+ store := newTestStore(t, "default")
+
+ _, err := store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ c.Endpoint = mustUrl("https://api.dev.meshcloud.io")
+ c.Credential = credential.FromLogin(credential.Login{
+ AccessTokens: map[scope.Scope]credential.IssuedToken{
+ meshstack.Unscoped: {Token: fakeJwt("expired"), ExpiresAt: time.Now().Add(-time.Minute)},
+ },
+ })
+ return c, nil
+ })
+ require.NoError(t, err)
+
+ // A mint that hands the credentials straight back still has to reach the file, which
+ // is what prunes the expired token.
+ got, err := store.Update(t.Context(), unchanged)
+ require.NoError(t, err)
+ require.Nil(t, got.Login.AccessTokens)
+
+ reread, err := store.Read()
+ require.NoError(t, err)
+ require.Equal(t, "https://api.dev.meshcloud.io", reread.Endpoint.String())
+ require.Nil(t, reread.Login.AccessTokens)
+}
+
+func TestUpdateSeesAnotherProcessWrite(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+ path := filepath.Join(dir, "meshstack", "credentials", "default.json")
+
+ // Hold the lock the way another process would, so Update has to wait for it.
+ release, err := acquireLock(t.Context(), path+lockSuffix)
+ require.NoError(t, err)
+
+ var seen Credentials
+ done := make(chan error, 1)
+ go func() {
+ _, err := store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ seen = c
+ return c, nil
+ })
+ done <- err
+ }()
+
+ // The winner writes while the loser waits, so the loser must re-read under the lock
+ // instead of minting from what it saw before.
+ require.NoError(t, os.WriteFile(path, []byte(`{"version": 1, "endpoint": "https://written-by-the-winner", "current": "apiKey", "apiKey": {"clientId": "winner"}}`), fileMode))
+ release()
+
+ require.NoError(t, <-done)
+ require.Equal(t, "https://written-by-the-winner", seen.Endpoint.String())
+ require.Equal(t, credential.MethodApiKey, seen.Current)
+ require.Equal(t, "winner", seen.ApiKey.Id)
+}
+
+func TestConcurrentUpdatesSerialise(t *testing.T) {
+ store := newTestStore(t, "default")
+
+ const writers = 8
+ var wg sync.WaitGroup
+ for range writers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ // A read-modify-write that loses an update shows up as a missing count. The
+ // key id is the counter because it is the one plain string in the file.
+ n := 0
+ if c.ApiKey != nil {
+ n, _ = strconv.Atoi(c.ApiKey.Id)
+ }
+ time.Sleep(time.Millisecond) // widen the window a lost update would need
+ c.Credential = credential.FromApiKey(credential.ApiKey{Id: strconv.Itoa(n + 1)})
+ return c, nil
+ })
+ assert.NoError(t, err)
+ }()
+ }
+ wg.Wait()
+
+ got, err := store.Read()
+ require.NoError(t, err)
+ require.Equal(t, strconv.Itoa(writers), got.ApiKey.Id)
+}
+
+func TestUpdateHonoursContext(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ release, err := acquireLock(t.Context(), filepath.Join(dir, "meshstack", "credentials", "default.json")+lockSuffix)
+ require.NoError(t, err)
+ defer release()
+
+ ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
+ defer cancel()
+
+ _, err = store.Update(ctx, unchanged)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "default.json.lock")
+}
+
+func TestLockIsReleasedAndBrokenWhenStale(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "default.json.lock")
+
+ release, err := acquireLock(t.Context(), path)
+ require.NoError(t, err)
+ require.FileExists(t, path)
+ release()
+ require.NoFileExists(t, path)
+
+ // A lock left behind by a process that died is broken rather than waited on forever.
+ require.NoError(t, os.WriteFile(path, []byte("pid 1\n"), fileMode))
+ old := time.Now().Add(-2 * lockStaleAfter)
+ require.NoError(t, os.Chtimes(path, old, old))
+
+ release, err = acquireLock(t.Context(), path)
+ require.NoError(t, err)
+ release()
+}
+
+func TestForget(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ // Forgetting a profile that was never logged in is not an error.
+ require.NoError(t, store.Forget())
+
+ _, err = store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ c.Endpoint = mustUrl("https://api.dev.meshcloud.io")
+ return c, nil
+ })
+ require.NoError(t, err)
+ path := filepath.Join(dir, "meshstack", "credentials", "default.json")
+ require.FileExists(t, path)
+
+ require.NoError(t, store.Forget())
+ require.NoFileExists(t, path)
+
+ creds, err := store.Read()
+ require.NoError(t, err)
+ require.Equal(t, Credentials{Version: Version}, creds)
+}
+
+func TestMemoryStore(t *testing.T) {
+ store := NewMemoryStore(Credentials{
+ Endpoint: mustUrl("https://api.dev.meshcloud.io"),
+ Credential: credential.FromManual(credential.Manual{}),
+ })
+
+ require.False(t, store.Writable())
+ assert.Contains(t, store.Describe(), "memory")
+ // A memory store names no path, so an error about it cannot point at a file.
+ assert.NotContains(t, store.Describe(), string(os.PathSeparator))
+
+ creds, err := store.Read()
+ require.NoError(t, err)
+ require.Equal(t, Version, creds.Version)
+ require.Equal(t, credential.MethodManual, creds.Current)
+
+ got, err := store.Update(t.Context(), func(c Credentials) (Credentials, error) {
+ c.Credential = credential.FromLogin(credential.Login{
+ AccessTokens: map[scope.Scope]credential.IssuedToken{
+ meshstack.Unscoped: {Token: fakeJwt("fresh"), ExpiresAt: time.Now().Add(time.Minute)},
+ meshstack.WorkspaceScope("gone"): {Token: fakeJwt("stale"), ExpiresAt: time.Now().Add(-time.Minute)},
+ },
+ })
+ return c, nil
+ })
+ require.NoError(t, err)
+ require.Len(t, got.Login.AccessTokens, 1)
+
+ // What a caller mutates after reading must not reach into the store.
+ read, err := store.Read()
+ require.NoError(t, err)
+ delete(read.Login.AccessTokens, meshstack.Unscoped)
+ again, err := store.Read()
+ require.NoError(t, err)
+ require.Len(t, again.Login.AccessTokens, 1)
+
+ require.NoError(t, store.Forget())
+ creds, err = store.Read()
+ require.NoError(t, err)
+ require.Equal(t, Credentials{Version: Version}, creds)
+}
+
+func TestMemoryStorePropagatesMintError(t *testing.T) {
+ store := NewMemoryStore(Credentials{})
+ _, err := store.Update(t.Context(), func(Credentials) (Credentials, error) {
+ return Credentials{}, assert.AnError
+ })
+ require.ErrorIs(t, err, assert.AnError)
+}
+
+func TestUpdatePropagatesMintErrorAndWritesNothing(t *testing.T) {
+ dir := isolate(t)
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+
+ _, err = store.Update(t.Context(), func(Credentials) (Credentials, error) {
+ return Credentials{}, assert.AnError
+ })
+ require.ErrorIs(t, err, assert.AnError)
+ require.NoFileExists(t, filepath.Join(dir, "meshstack", "credentials", "default.json"))
+
+ // The lock is released even when mint fails, so the next attempt is not blocked.
+ entries, err := os.ReadDir(filepath.Join(dir, "meshstack", "credentials"))
+ require.NoError(t, err)
+ for _, e := range entries {
+ assert.False(t, strings.HasSuffix(e.Name(), lockSuffix), "lock %s was left behind", e.Name())
+ }
+}
+
+// mustUrl parses a URL a test wrote, and panics on one it wrote wrong, which is a test bug
+// rather than a case worth handling.
+func mustUrl(raw string) *xurl.URL {
+ parsed := &xurl.URL{}
+ if err := parsed.UnmarshalText([]byte(raw)); err != nil {
+ panic(err)
+ }
+ return parsed
+}
+
+// fakeJwt is an access token identified by a name a test can assert on. Every access token is
+// a JWT, so a test cannot use a bare string for one. Nothing verifies a signature, so only the
+// payload is real.
+func fakeJwt(id string) jwt.JWT {
+ payload := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"jti":%q}`, id))
+ parsed, err := jwt.Parse("bogus." + payload + ".bogus")
+ if err != nil {
+ panic(err)
+ }
+ return parsed
+}
+
+func TestBothFilesWriteTheirMapsInASortedOrder(t *testing.T) {
+ dir := isolate(t)
+
+ require.NoError(t, SaveConfig(Config{Profiles: map[Name]Profile{
+ "zulu": {DefaultWorkspace: "z"}, "alpha": {DefaultWorkspace: "a"}, "mike": {DefaultWorkspace: "m"},
+ }}))
+ config, err := os.ReadFile(filepath.Join(dir, "meshstack", "config.json"))
+ require.NoError(t, err)
+ assert.Less(t, indexOf(t, config, `"alpha"`), indexOf(t, config, `"mike"`))
+ assert.Less(t, indexOf(t, config, `"mike"`), indexOf(t, config, `"zulu"`))
+
+ store, err := NewFileStore("default")
+ require.NoError(t, err)
+ tokens := map[scope.Scope]credential.IssuedToken{
+ meshstack.Unscoped: {Token: fakeJwt("u")},
+ meshstack.WorkspaceScope("zulu"): {Token: fakeJwt("z")},
+ meshstack.WorkspaceScope("alpha"): {Token: fakeJwt("a")},
+ }
+ write := func(Credentials) (Credentials, error) {
+ return Credentials{Version: Version, Credential: credential.FromLogin(
+ credential.Login{RefreshToken: "r", AccessTokens: tokens})}, nil
+ }
+ _, err = store.Update(t.Context(), write)
+ require.NoError(t, err)
+ path := filepath.Join(dir, "meshstack", "credentials", "default.json")
+ first, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.Less(t, indexOf(t, first, `"c:alpha"`), indexOf(t, first, `"c:zulu"`))
+ assert.Less(t, indexOf(t, first, `"c:zulu"`), indexOf(t, first, `"unscoped"`))
+
+ // A login renews hourly and rewrites the whole file, so an unstable order would show up
+ // as a change on every renewal.
+ _, err = store.Update(t.Context(), write)
+ require.NoError(t, err)
+ second, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.Equal(t, string(first), string(second))
+}
+
+func indexOf(t *testing.T, data []byte, needle string) int {
+ t.Helper()
+ i := bytes.Index(data, []byte(needle))
+ require.GreaterOrEqual(t, i, 0, "%s is not in the file", needle)
+ return i
+}
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
new file mode 100644
index 0000000..f886c26
--- /dev/null
+++ b/pkg/setting/setting.go
@@ -0,0 +1,28 @@
+package setting
+
+import (
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+ "github.com/meshcloud/meshstack-cli/pkg/credential"
+ "github.com/meshcloud/meshstack-cli/pkg/meshstack"
+ "github.com/meshcloud/meshstack-cli/pkg/profile"
+)
+
+type (
+ Setting interface {
+ EnvKey() string
+ Help() string
+ }
+
+ Source = setting.ExplicitSource
+ SourceDescription = setting.SourceDescription
+)
+
+var (
+ Endpoint Setting = meshstack.Endpoint
+ Profile Setting = profile.NameSetting
+ Workspace Setting = meshstack.Workspace
+ ApiKeyClientId Setting = credential.ApiKeyClientId
+ ApiKeyClientSecret Setting = credential.ApiKeyClientSecret
+ ApiBearerToken Setting = credential.ApiBearerToken
+ // TODO expose other settings used "externally" by CLI cmd package.
+)
diff --git a/pkg/tty/settings.go b/pkg/tty/settings.go
new file mode 100644
index 0000000..c7b9c29
--- /dev/null
+++ b/pkg/tty/settings.go
@@ -0,0 +1,16 @@
+package tty
+
+import (
+ "github.com/meshcloud/meshstack-cli/internal/setting"
+)
+
+var NoInput = setting.Setting[bool]{
+ Env: "MESHSTACK_NO_INPUT",
+ Short: "Never wait for a person. Also read from MESHSTACK_NO_INPUT.",
+ Long: "Never wait for a person, also read from `MESHSTACK_NO_INPUT`.\n\n" +
+ "It covers more than a prompt: a browser login fails at once instead of waiting ten minutes " +
+ "for a callback nobody will complete.\n\n" +
+ "It is the only thing that says nobody is coming. A pipe does not: stderr reaches a person " +
+ "from one just as well as from a terminal.",
+ Parse: setting.ParseBool,
+}
diff --git a/pkg/tty/tty.go b/pkg/tty/tty.go
new file mode 100644
index 0000000..20dbefb
--- /dev/null
+++ b/pkg/tty/tty.go
@@ -0,0 +1,22 @@
+// Package tty declares MESHSTACK_NO_INPUT and answers the one question a declaration
+// cannot: whether a file is a terminal.
+//
+// It holds no state. Whether this process may wait on a person is the NoInput setting,
+// resolved from ranked sources like everything else, and each owner of the answer keeps
+// its own copy — pkg/auth on the Session, the meshStack CLI on its Input.
+package tty
+
+import "os"
+
+// IsTerminal is the standard-library answer, a character-device check, which is wrong only
+// for a character device that is not a terminal — nothing feeds a CLI one on purpose.
+//
+// It is a separate question from NoInput because a prompt needs it and a browser login does
+// not: stderr reaches a person from a pipe just as well as from a terminal.
+func IsTerminal(f *os.File) bool {
+ info, err := f.Stat()
+ if err != nil {
+ return false
+ }
+ return info.Mode()&os.ModeCharDevice != 0
+}