Skip to content

feat: lock the OpenTofu state of a building block while tfstate exec runs tofu - #26

Merged
grubmeshi merged 12 commits into
mainfrom
feature/tfstate-locking
Oct 7, 2026
Merged

grubmeshi merged 12 commits into
mainfrom
feature/tfstate-locking

Conversation

@grubmeshi

@grubmeshi grubmeshi commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Written by AI agent

Why

Two writers must not change the same OpenTofu state in meshStack at the same time: runs of the
tf-block-runner, and people (or AI agents) who use meshstack bb tfstate exec --mode readwrite to
debug or repair a building block's state. meshStack now locks the state (meshfed-release#11278) and
the tf-block-runner takes that lock (building-block-runner#77); both are merged. This PR makes the
CLI take part in the lock, and makes tfstate exec usable for cleanups and repairs with tofu's
meshstack provider.

What changes for users

  • tfstate exec --mode readwrite holds meshStack's lock while tofu runs. A run of the block
    waits for it; a second exec fails and names the holder. --mode read never blocks a run.
  • New meshstack bb tfstate force-unlock <uuid> shows who holds the lock and releases it after
    the user types yes. It refuses a lock of a pending or running run unless --force.
  • tofu's meshstack provider works under exec with the CLI's login, any provider version,
    as long as the provider block sets no endpoint or credentials. In --mode read the provider can
    only read.
  • Every command names its auth scope when meshStack answers 403, so the user sees which profile and workspace were refused.
  • tfstate exec --override-backend adds the http backend while the command runs, so a module needs no backend file.
  • tfstate exec --mode readwrite keeps backups only with --backup-dir <dir>. It no longer
    copies the stored state into the configuration directory before each write; with the flag it
    writes each copy into <dir> with mode 0600.

How to review

The commits are meant to be read one by one; each message explains its part.

Commit Look at
refactor: move the bb tfstate commands to cmd/buildingblock/tfstate pure move, no behaviour change
refactor: check the building block's runs in internal/tfstate … the commands as front ends over internal/tfstate: OpenStore, NoRunOf
chore: ignore scratch/ … –
fix: name the auth scope when meshStack answers 403 Authorization.Scope(), and the wrap of every 403 in AuthorizedClient.DoRequest
refactor(client): build every HTTP client with the user agent of its front end http.UserAgent embedded in ResolveSessionOptions, so the provider's options literal compiles unchanged; client.New now takes an http.Client
docs: move the notes on how the code is built from AGENTS.md into a development skill what moved, and the front-end/internal/ rule the skill adds
feat: lock the state in meshStack while tofu runs under bb tfstate exec the proxy's lock passthrough per mode, and which meshStack errors fail exec versus go to tofu
feat: add meshstack bb tfstate force-unlock … the confirmation and the refusal for live runs
test: run tofu against the state of a building block the acceptance suite bootstraps the bootstrapped block, and the case with four concurrent applies that must not lose a write
feat: let the command of bb tfstate exec reach the meshStack API with the CLI's login the security boundary: only a request with the per-exec token gets the CLI's login, and read mode forwards only GET/HEAD; internal/tfstate/apiproxy, the one package besides internal/http that touches the transport, and the User-Agent it forwards
feat: save state backups of bb tfstate exec only into the directory --backup-dir names no backup without the flag, and the 0600 mode of each backup
feat: let bb tfstate exec add the http backend with --override-backend the override file is removed however the command ends, and never replaces a file of the user's

The acceptance tests need tofu on the PATH and run in meshfed-release's satellite CI.

Related PRs

🤖 Generated with Claude Code

@meshcloud-gh-actions

meshcloud-gh-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Coverage of the acceptance run against the meshStack backend, on 0bf620c9c66b56803f74894fc589cab287fdc419.

Scope Coverage
Unit tests 79.5%
Acceptance tests 54.5%
Combined 83.8%
Uncovered functions
client/api_key.go:55: meshApiKeyClient.Create 0.0%
client/api_key.go:59: meshApiKeyClient.Read 0.0%
client/api_key.go:63: meshApiKeyClient.Update 0.0%
client/api_key.go:71: meshApiKeyClient.Delete 0.0%
client/api_key_permissions.go:20: ApiKeyPermissions.AllCodes 0.0%
client/api_key_permissions.go:33: ApiKeyPermissions.WorkspaceCodes 0.0%
client/api_key_permissions.go:295: AllApiKeyPermissions 0.0%
client/api_key_permissions.go:299: WorkspacePermissionCodes 0.0%
client/building_block_definition.go:71: MeshBuildingBlockDefinitionApprovalPolicies.NothingRequiresApproval 0.0%
client/building_block_definition.go:82: DisabledSchedule 0.0%
client/building_block_definition.go:89: MeshBuildingBlockDefinitionSchedule.IsDisabled 0.0%
client/building_block_definition.go:93: MeshBuildingBlockDefinitionSpec.HasNeutralPolicies 0.0%
client/building_block_definition.go:97: MeshBuildingBlockDefinitionSpec.WithNeutralPolicies 0.0%
client/building_block_definition.go:166: meshBuildingBlockDefinitionClient.List 0.0%
client/building_block_definition.go:173: meshBuildingBlockDefinitionClient.Read 0.0%
client/building_block_definition.go:177: meshBuildingBlockDefinitionClient.Create 0.0%
client/building_block_definition.go:181: meshBuildingBlockDefinitionClient.Update 0.0%
client/building_block_definition.go:185: meshBuildingBlockDefinitionClient.Delete 0.0%
client/building_block_definition_version.go:100: MeshBuildingBlockType.TagInputTargets 0.0%
client/building_block_definition_version.go:245: meshBuildingBlockDefinitionVersionClient.Create 0.0%
client/building_block_definition_version.go:254: meshBuildingBlockDefinitionVersionClient.Update 0.0%
client/building_block_definition_version_implementation.go:80: MeshBuildingBlockDefinitionImplementation.InferType 0.0%
client/building_block_definition_version_implementation.go:94: MeshBuildingBlockDefinitionImplementation.MarshalJSON 0.0%
client/building_block_definition_version_implementation.go:107: *MeshBuildingBlockDefinitionImplementation.UnmarshalJSON 0.0%
client/building_block_runner.go:82: meshBuildingBlockRunnerClient.Create 0.0%
... and 125 more

covdata func names a method without its receiver, so an entry can belong to an implementation nothing selects rather than to a function the tests never reached. Open the file and line before reading one as a coverage gap.

@grubmeshi
grubmeshi force-pushed the feature/tfstate-locking branch 3 times, most recently from d79568e to 82e662c Compare October 7, 2026 08:49
The package now sits below the command it belongs to, and is named after
its subcommand. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@grubmeshi
grubmeshi force-pushed the feature/tfstate-locking branch 3 times, most recently from 305d967 to 68a91ab Compare October 7, 2026 09:45
Comment thread cmd/buildingblock/tfstate/exec.go Outdated
Comment thread cmd/api/api.go Outdated
Comment thread AGENTS.md Outdated
Comment thread cmd/buildingblock/tfstate/exec.go Outdated
Comment thread cmd/buildingblock/tfstate/force_unlock.go Outdated
Comment thread internal/tfstate/proxy.go Outdated
Comment thread internal/tfstate/apiproxy/apiproxy.go Outdated
grubmeshi and others added 10 commits October 7, 2026 13:13
…than in bb tfstate

The commands stay front ends: which workspace holds the state, and whether
a run of the building block writes it as well, are internal/tfstate's to
decide. exec adds only the hint to --force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
meshStack grants access by the workspace the login works in, not by a
workspace in the path, so a bare 403 did not say why. Every 403 now
names the profile, and for a browser login the workspace it works in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…front end

http.NewClient takes an http.UserAgent, which holds the GitHubRepo and
Version that ResolveSessionOptions had as fields of its own, and refuses
one that does not validate. ResolveSessionOptions embeds it, so a front end
still sets GitHubRepo and Version in the options literal, and the release
check reads the same two. client.New takes the session's http.Client in
place of a user agent string. The API docs download through a client from
cmd/internal's options, rather than naming the CLI a second time.
forbidigo keeps http.UserAgent to those options, so that no request
leaves without the front end's name, or with one that drifts from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…evelopment skill

AGENTS.md is loaded into every session, while the package layout, the
command tree, the rules of client/ and internal/http, and the settings
matter only while Go code changes. The skill also states that a command
is a front end over an internal/ package, and points to the
modern-go-guidelines plugin for Go idioms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tfstate exec now points tofu's lock calls at its proxy. In --mode readwrite
the proxy passes them on to meshStack, so a run of the building block waits
while tofu holds the lock, and a second exec fails with the holder's lock
info. The proxy sends tofu's lock ID with every write, as meshStack refuses a
write without it while a lock is held. A write that meshStack refuses for
another holder's lock goes back to tofu as the 423 it is, and the log names
the holder, because tofu prints only the status code. A 5xx of meshStack goes
to tofu as well, which retries it, and is logged rather than failing exec. In
--mode read the proxy answers the lock calls itself, so a plan never makes a
run wait.

--force now also stores a state that does not follow the stored one, so that
tofu state push -force gets through. exec warns when tofu has held the lock
for 5 minutes, the time a waiting run waits before it fails.

Workspace Owner and Workspace Manager now have the MANAGED_TFSTATE rights, so
the help no longer sends every user to an API key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…releases

tofu force-unlock needs a module directory with the backend and the lock ID.
force-unlock needs only the building block: it reads the lock from meshStack,
names its holder, and releases it by the lock ID it found, so a lock taken in
the meantime stays. It refuses to release the lock of a run that is pending or
in progress, unless --force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uite bootstraps

The building blocks of the local stack are manual ones and never reach the
tf-block-runner. TestAccTfstate applies a tofu config that creates a workspace,
binds a test user as Workspace Manager, and orders the noop building block of
meshstack-hub, which the tf-block-runner applies with meshStack's state
backend. It then runs real tofu through exec in both modes, checks that a
second exec and a run wait for the lock that an exec holds, that force-unlock
releases a lock left behind, and that the Workspace Manager's browser login
reads, locks and writes the state. It destroys what it created at the end.

The tests that picked a building block of the local stack move to the
bootstrapped one. They fail when tofu is not on the PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the CLI's login

The proxy of exec now passes every request outside the state on to the
meshStack API, with the session's token in place of the one the command
sent, so that tofu's meshstack provider works with the CLI's login,
which the CLI renews. The command gets MESHSTACK_ENDPOINT of the proxy
and a MESHSTACK_API_TOKEN that only the proxy accepts, so any provider
version works, v0.24.5 included, as long as the provider block sets no
endpoint or credentials. --mode read passes only GET and HEAD on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-backup-dir names

exec --mode readwrite no longer copies the stored state into the
configuration directory before each write. With --backup-dir it copies it
into that directory, which it creates with mode 0700, as files with mode
0600, since a state can hold secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A building block's module has no backend block, since the runner adds one
only while it runs, and OpenTofu cannot take the backend type from the
environment. With --override-backend, exec writes zz_meshstack_override.tf
into the working directory while the command runs. As an override file it
adds the http backend to a module without one, and replaces the module's
own backend. exec refuses to replace a file of that name. The help and the
warning for a command that asked for no state now point to the flag rather
than to a backend file the user adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@grubmeshi
grubmeshi force-pushed the feature/tfstate-locking branch from 68a91ab to 0bf620c Compare October 7, 2026 11:24
@grubmeshi
grubmeshi marked this pull request as ready for review October 7, 2026 11:55
@grubmeshi
grubmeshi merged commit 0bf620c into main Oct 7, 2026
11 checks passed
@grubmeshi
grubmeshi deleted the feature/tfstate-locking branch October 7, 2026 11:55
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.

1 participant