Skip to content

feat: add the meshStack CLI and the API client it shares with the Terraform provider - #4

Closed
grubmeshi wants to merge 212 commits into
feature/cli-satellitefrom
feature/scaffold-cli
Closed

feat: add the meshStack CLI and the API client it shares with the Terraform provider#4
grubmeshi wants to merge 212 commits into
feature/cli-satellitefrom
feature/scaffold-cli

Conversation

@grubmeshi

@grubmeshi grubmeshi commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Imports client/ from the Terraform provider as a git subtree, split at the provider's v0.25.3, so both tools share one client instead of each carrying its own. The CLI itself follows on top.

Stacked on #3. Paired by branch name with meshcloud/meshfed-release#11009 (the acceptance suite needs the credentials it publishes) and with meshcloud/terraform-provider-meshstack#299, which consumes this.

henryde and others added 30 commits June 20, 2024 13:49
meshStack enforces the Accept header soon, so we have to make sure to always provide it
includes adaptations from PR remarks
grubmeshi and others added 26 commits August 12, 2026 16:09
The meshTenant API deprecated the list-form spec.quotas in favour of the
spec.requested_quotas map, which v0.24.3 added together with the computed
status.applied_quotas. This drops the deprecated field from meshstack_tenant,
its data source and meshstack_tenants, so the provider stops sending and
modelling it.

Only the unsuffixed, ref-based resource is touched. The deprecated
meshstack_tenant_v4 keeps its own spec.quotas: it is removed wholesale once the
meshTenant API goes GA.

Existing state migrates automatically (schema version 1 -> 2): a quota recorded
under spec.quotas is translated into spec.requested_quotas rather than dropped,
so a configuration that restates the same quotas in the map form plans no
change. That matters because a quota change on an existing tenant is rejected --
the meshTenant API cannot update one. The `moved` mover from
meshstack_tenant_v4 translates the same way.

Because this only stops using a field the API still accepts, it needs no newer
meshStack version and the version floor stays at 2026.30.0.

Issue: CU-86c0j0r7q
Send the GA `v4` meshTenant media type instead of `v4-preview` from
meshstack_tenant and meshstack_tenants, and drop the preview disclaimer from
their documentation. This requires a meshStack backend that has promoted
meshTenant v4 to GA; a backend that only serves the preview media type answers
with HTTP 415.

With the unsuffixed resource on the GA API, the deprecated meshstack_tenant_v4
resource and data source go away, together with their client
(client.MeshTenantV4), models, mocks, builder, examples and docs, and their
registration in the provider. The meshstack_tenant MoveState/moveFromV4
migration path goes with them, because the type it migrates from no longer
exists -- so apply that `moved` block on v0.24.x before you upgrade.

client/tenant_v4_test.go becomes client/tenant_v4_deletion_test.go, since the
deletion helpers are all it still covers. The client package keeps the v4 in its
file names, because it is the API version it talks to.

The version floor moves to 2026.34.0, the first release that can still carry the
backend flip: v2026.33.0 was tagged 2026-08-12 while the backend PR was open.
docs/index.md is regenerated from the __MIN_MESHSTACK_VERSION__ placeholder in
templates/index.md.tmpl rather than edited by hand.

Issue: CU-86c0j0r7q
The opt-out was evaluated inside the version-mismatch branch, so it was only
reachable once GET /mesh/info had already succeeded. Setting the flag therefore
never skipped the request — it only suppressed a version mismatch.

/mesh/info is a GET on the retrying client, so an unavailable meshStack made
every provider configure block for the client's full retry budget (~4 minutes)
and then fail, with no way to opt out. Move the check to the top of
checkMeshVersion so the flag short-circuits before the request is built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A building block plan converted spec out of tfsdk.Plan, which fails whenever an
attribute is wired to a resource the same plan creates or replaces. ModifyPlan
now walks the planned spec for unknowns and schedules a run instead, matching
what it already did for an unknown definition version ref.

meshstack_tenant_v4 also lost spec.platform_identifier and
spec.landing_zone_identifier against a meshStack that serves meshTenant v4 in
its ref shape. Both force replacement, so a refresh planned the recreation of a
live tenant. They are recovered from spec.landingZoneRef.name and from
status.tenantName, without relying on the dropped flat identifiers.

Together these unblock destroying a building block composition created before
the backend moved meshTenant v4 to refs.
Resolutions:
- CHANGELOG.md: keep both sections, v0.25.0 above the v0.24.5 patch.
- client/tenant_v4.go and its tests: take main's removal. The meshTenant v4 ref
  read fix only applies to meshstack_tenant_v4, which v0.25.0 drops; it stays on
  the v0.24.5 tag. The unknown-spec fix in ModifyPlan carries forward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e_flags

Replaces the is_four_eyes_enabled bool with a more general enabled_feature_flags
set-of-strings attribute (currently only four_eyes_role_approval), per PR review.
…grations

New spec.config.entraid.idp_alias adopts an identity provider that already
exists in a meshStack instance, instead of always creating one. Optional and
Computed, so an existing integration plans no change.

The alias is immutable. meshStack deletes the identity provider when the
integration is deleted, so expressing a change as a destroy and recreate would
delete a provider the customer may have configured themselves. A plan modifier
rejects the change instead, as version_spec does in
building_block_definition_resource.go, and rejects a change it cannot verify
when state carries no alias to compare against. The framework ships nothing
equivalent: stringplanmodifier has only RequiresReplace variants and
UseStateForUnknown, and a validator cannot see prior state.
meshStack derives the redirect URL and returns it inside spec, which
configuration writes. A computed value there is unreachable under provider
mocks and cannot be supplied by override_* either, so any module wiring that
reads it is untestable (#272). It now lives at status.entraid.redirect_url,
a fully computed container, nested per integration type the way
status.workload_identity_federation nests per cloud.

That also retires a trap: the attribute was Optional as well as Computed, but
meshStack ignores a supplied value, so a configuration that set it failed the
apply with "Provider produced inconsistent result after apply" — after the
integration had been created.

Derived through a local model struct rather than a json:"-" field on the client
type, per the computed-only output field pattern in the resource-development
skill. integrationStatus is shared by the resource and the data source.
A building block definition can now carry a display_name_template, so meshStack
names each ordered building block after the values it was ordered with while the
definition keeps a clean display_name.

Without the attribute a building block is named after display_name, and an empty
string means the same thing. meshStack has to serve the field: an older one drops
it from its response, so an apply that sets it fails Terraform's consistency check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The meshBuildingBlockDefinition meshObject API now carries the meshPanel
Policies tab in spec.approvalPolicies and spec.schedule. They default to no
approval gate and no schedule, which is what meshStack stores for a new
definition.

meshStack validates both against the implementation type of the
definition's latest version, and it rejects a version implementation-type
change while the stored policies are incompatible with the new type. So a
policy the current type cannot honour has to be written after version_spec,
and one the new type cannot honour has to be gone before it. Neutral
policies satisfy every implementation type, so Create writes the definition
neutral, writes the version, then writes the planned policies, and Update
passes through neutral in between when the implementation type changes.
That makes a combined implementation-type and policy change apply in one
step.

Two object validators repeat meshStack's rules at plan time, so an
unsupported combination fails before anything is written. The mock client
learns the same rules, so a unit-test run catches an ordering regression
without a live backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
meshStack lets a Building Block Definition input name a tag instead of asking
users to re-type metadata meshStack already governs. Declaring one takes the new
TAG assignment type, a CODE input type and an argument holding the
`<target>.<tagKey>` reference, so the only code change is the enum entry that the
assignment_type validator builds its allowed set from.

The rules that come with it - which targets a definition may read, and why the
input type is fixed - are documented on the two attributes that carry them, and
the terraform example shows the argument referencing a meshstack_tag_definition
rather than a literal key, which also gets the destroy order right.

The meshTagDefinition delete now refuses while a building block reads the tag, so
the tag definition resource says so too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An operator can now mark a definition input optional via version_spec.inputs.*.is_optional, so
whoever fills the Building Block in may leave it blank and the implementation falls back to the
default declared in its own code — a Terraform variable's default, a workflow input's default.
Mirrors meshcloud/meshfed-release#10701.

isOptional is sent with omitempty. false is the backend's default for an absent field, so a
non-optional input serialises exactly as it did before the field existed: every already-stored
content hash stays byte-identical (no currentHashVersion bump, so no released Building Block
re-runs), and a backend that does not know the field still sees the same payload for configs that
do not use the feature. A new fixture pins that equality so a later change cannot silently break it.

MinMeshStackVersion deliberately stays put. Unlike a field the provider always sends, this one only
reaches the backend for someone who opted in, so gating every user of the provider would cost more
than it protects.

ValidateConfig re-imposes the backend's four rules at plan time rather than letting them surface as
a 400 during apply: no optional input on a MANUAL implementation (a person carries the block out, so
there is no code to fall back to), only for the assignment types a person supplies, never for
BOOLEAN (an unset boolean is indistinguishable from false where it is consumed), and never together
with a meshStack default value. Checks whose value is unknown at plan are skipped, keeping the
backend the authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two halves belong in one commit because the linter refuses to run when they
are split: a golangci-lint built by go 1.26 rejects a module targeting 1.27 with
"the Go language version (go1.26) used to build golangci-lint is lower than the
targeted Go version (1.27)".

Lint therefore runs differently now. golangci-lint's formatters use the
go/format compiled into the binary, so the formatting they enforce comes from
the Go release that BUILT the linter rather than the toolchain on PATH. Its
version was pinned in two places that could drift - flake.nix took whatever
nixpkgs packaged, CI asked for latest - and neither was tied to go.mod's Go. It
joins tfplugindocs and gotestsum in the tool block instead, so go.mod and go.sum
are the single pin and `task lint` runs `go tool golangci-lint run`, which
builds it with the same Go the code is written against. That also works outside
`nix develop`, which it did not before, so flake.nix no longer carries the
package - one pin fewer to keep in step.

CI keeps golangci-lint-action, because it annotates the pull request diff and a
bare `run:` does not. It installs nothing though: the step before it runs
`go install` for the pinned package and the action uses install-mode none, so
the version it runs is the one in go.mod. The job also moves off
`go-version: stable` onto go.mod's Go, for the same reason.

Rebuilding the linter with go 1.27 changed one alignment group in
client/internal/retry_test.go, which is the behaviour described above showing
up in practice rather than an unrelated edit.

The changelog entry opens a new v0.25.3 section. v0.25.2 is already tagged, so
per the changelog-management skill a pending entry needs a new top section
rather than an amendment to a released one.

Two knock-on changes:

- flake.lock had to move forward, because the June nixpkgs it pinned carries no
  go_1_27 attribute and `nix develop` failed outright with "undefined variable
  'go_1_27'".
- testify goes 1.11.1 -> 1.12.1, raised by MVS because golangci-lint requires
  at least that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A definition input can now declare type JSON_SCHEMA together with the schema its value follows,
matching the new backend input type. meshPanel renders a form from the schema, and the value
reaches the building block as JSON text, exactly like a CODE input.

JSON_SCHEMA is deliberately not an entry of MeshBuildingBlockIOTypes: that enum describes the
type of a value, and a building block's own inputs keep reporting CODE for these. Only the
definition input schema offers it, via MeshBuildingBlockDefinitionInputTypes.

ValidateConfig re-imposes the pairing the framework cannot express — json_schema is required for
the type and rejected for every other — so a mismatch fails at plan time rather than as a 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list of types a definition input may declare is the full IO type set plus
JSON_SCHEMA, so spell it that way instead of repeating all nine entries. A new
Enum.With copies via slices.Concat rather than appending, so the package-level
MeshBuildingBlockIOTypes can never be written into through its shared backing
array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wording led with the value the input produces, which reads as "CODE with a
schema attached" and hides what the type actually does: it gives the input a
form of its own, declared by json_schema, that meshPanel renders in place of a
single field. Lead with the form everywhere the type is described — the `type`
attribute, `json_schema`, the ValidateConfig messages, the example and the
changelog — and keep the CODE equivalence as the follow-up it is, since that is
only true of what the form produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the backend rename: the type names the value the input holds, and the schema
stays a separate argument on it. The type ships first in meshStack 2026.37.0, which the
changelog entry now states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client was the Terraform provider's own package. The CLI needs the same calls, and a
public repository is the only place both can depend on it, so the client's home is here now
and the provider consumes it as a module.

This is a merge rather than a copy so that the client keeps the history it has in the
provider: `git log` and `git blame` on these files still reach every change made there, and
`git subtree` can still move a change either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

git-subtree-dir: client
git-subtree-mainline: f2ba137
git-subtree-split: cf78ea1
The client's imports name this module now, and the HTTP machinery it was built on sits at the
module root as internal/http rather than under client/internal: pkg/oidc and pkg/auth need the
same client, and Go's internal rule closes client/internal to both.

internal/http is tested from the outside, through the types its callers parse answers into, so
its test reaches for pkg/oidc/jwt and pulls pkg/meshstack and pkg/oidc/scope along. Those three
land here instead of with the rest of the CLI so that this commit's own suite runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
meshstack authenticates against meshStack — a browser login, an API key, an API token, or the
local dev stack's own published credentials — keeps the result in a named profile, and lists the
workspaces that credential reaches. Every setting is declared once beside the domain it belongs
to, so the CLI's flags and the Terraform provider's block resolve the same thing the same way.

The plumbing that ships it comes along: goreleaser builds the archives, a container image goes to
GHCR, and the dependency policy that keeps the provider's dependency tree small sits in the linter
configuration, where widening it is a deliberate edit rather than a lint fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@grubmeshi

Copy link
Copy Markdown
Collaborator Author

Reusing #2 for this branch instead. Closing here also keeps satellite-resolve working: with two open pull requests on one head branch it warns and falls back to the branch tip, so no merge commit is tested and no coverage comment is posted.

@grubmeshi grubmeshi closed this Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.