From 92c2b4291a3ed0fd22931862573786db98d157ef Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 6 Nov 2026 08:55:30 +0100 Subject: [PATCH 1/2] feat(client): let a building block update skip its run on a version upgrade MeshBuildingBlockV2Client.Update takes MeshBuildingBlockV2UpdateOptions. Its SkipRunOn set goes out as the repeated query parameter skipRunOn. With VERSION_UPGRADE, meshStack saves an update that upgrades the definition version and starts no run. An empty set sends no parameter, so the client still works against an older meshStack. Co-Authored-By: Claude Opus 5.5 --- client/building_block_v2.go | 20 +++++++++++++++++--- client/building_block_v2_test.go | 28 ++++++++++++++++++++++++++++ client/internal/mesh_object.go | 4 ++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/client/building_block_v2.go b/client/building_block_v2.go index 01d204dc..e82ae513 100644 --- a/client/building_block_v2.go +++ b/client/building_block_v2.go @@ -198,12 +198,26 @@ type MeshBuildingBlockV2ListFilter struct { Sort SortCriteria `json:"sort"` } +type BuildingBlockSkipRunOn string + +var ( + BuildingBlockSkipRunOns = enum.Enum[BuildingBlockSkipRunOn]{} + BuildingBlockSkipRunOnVersionUpgrade = BuildingBlockSkipRunOns.Entry("VERSION_UPGRADE") +) + +// MeshBuildingBlockV2UpdateOptions go out as query parameters, and a zero field as none, so the +// zero value works against a meshStack that does not know them. +type MeshBuildingBlockV2UpdateOptions struct { + // SkipRunOn saves an update that matches one of the values without starting a run. + SkipRunOn []enum.Entry[BuildingBlockSkipRunOn] `json:"skipRunOn"` +} + 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) + Update(ctx context.Context, bb *MeshBuildingBlockV2, options MeshBuildingBlockV2UpdateOptions) (*MeshBuildingBlockV2, error) Delete(ctx context.Context, uuid string, purge bool) error TriggerRun(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) } @@ -234,11 +248,11 @@ func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingB return c.meshObject.Post(ctx, bb) } -func (c meshBuildingBlockV2Client) Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { +func (c meshBuildingBlockV2Client) Update(ctx context.Context, bb *MeshBuildingBlockV2, options MeshBuildingBlockV2UpdateOptions) (*MeshBuildingBlockV2, error) { if bb.Metadata.Uuid == nil { return nil, errors.New("cannot update building block without UUID") } - return c.meshObject.Put(ctx, *bb.Metadata.Uuid, bb) + return c.meshObject.Put(ctx, *bb.Metadata.Uuid, bb, http.WithUrlQuery(options)) } func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purge bool) error { diff --git a/client/building_block_v2_test.go b/client/building_block_v2_test.go index 96663228..1f9b53a1 100644 --- a/client/building_block_v2_test.go +++ b/client/building_block_v2_test.go @@ -1,12 +1,15 @@ package client import ( + "io" + gohttp "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" ) func TestMeshBuildingBlockV2Status_RunChangedSince(t *testing.T) { @@ -170,3 +173,28 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { }) } } + +func TestMeshBuildingBlockV2Client_UpdateSendsSkipRunOnOnlyWhenSet(t *testing.T) { + server := fakemeshstack.Start(t, fakemeshstack.Options{}) + server.Route("/", func(w gohttp.ResponseWriter, _ *gohttp.Request) { _, _ = io.WriteString(w, `{}`) }) + blocks := newBuildingBlockV2Client(t.Context(), newTestHttpClient(server)) + bb := &MeshBuildingBlockV2{Metadata: MeshBuildingBlockV2Metadata{Uuid: new("0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90")}} + unknown := enum.Entry[BuildingBlockSkipRunOn]("SOMETHING_NEW") + + for _, skipRunOn := range [][]enum.Entry[BuildingBlockSkipRunOn]{ + nil, + {}, + {BuildingBlockSkipRunOnVersionUpgrade}, + {BuildingBlockSkipRunOnVersionUpgrade, unknown}, + } { + _, err := blocks.Update(t.Context(), bb, MeshBuildingBlockV2UpdateOptions{SkipRunOn: skipRunOn}) + require.NoError(t, err) + } + + requests := server.TakeRequests() + require.Len(t, requests, 4) + assert.Empty(t, requests[0].URL.RawQuery) + assert.Empty(t, requests[1].URL.RawQuery) + assert.Equal(t, "skipRunOn=VERSION_UPGRADE", requests[2].URL.RawQuery) + assert.Equal(t, "skipRunOn=VERSION_UPGRADE&skipRunOn=SOMETHING_NEW", requests[3].URL.RawQuery) +} diff --git a/client/internal/mesh_object.go b/client/internal/mesh_object.go index 9147f6f0..425addb1 100644 --- a/client/internal/mesh_object.go +++ b/client/internal/mesh_object.go @@ -102,8 +102,8 @@ func (c MeshObjectApi) PostAtPath[R, P any](ctx context.Context, payload P, extr http.WithAccept(c.MeshObjectMimeType()), c.withMeshObjectPayload(payload)) } -func (c MeshObjectClient[M]) Put[P any](ctx context.Context, id string, payload P) (*M, error) { - return c.DoRequest[*M](ctx, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload), http.Retryable()) +func (c MeshObjectClient[M]) Put[P any](ctx context.Context, id string, payload P, options ...http.RequestOption) (*M, error) { + return c.DoRequest[*M](ctx, http.MethodPut, c.ApiUrl.JoinPath(id), append(options, c.withMeshObjectPayload(payload), http.Retryable())...) } func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) { From ef07a6f054bd341f810ba092942d5df1f50c931f Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 6 Nov 2026 14:10:02 +0100 Subject: [PATCH 2/2] feat(client): lock and unlock building blocks meshStack locks a building block when an update with skipRunOn matches, or on POST .../lock, and starts no run of it until POST .../unlock. A locked block has the action UNLOCK_RUNS. - The client gets the action UNLOCK_RUNS, MeshBuildingBlockV2Status.IsLocked, and Lock and Unlock on MeshBuildingBlockV2Client. - "meshstack buildingblock lock " and "meshstack buildingblock unlock " call them and write the building block, as trigger-run does. - "meshstack buildingblock wait" and "trigger-run --wait" stop at a locked block, and fail with the hint to unlock it, where they waited a minute for a run that never starts. Co-Authored-By: Claude Opus 5.5 --- client/building_block_v2.go | 28 +++++++- cmd/buildingblock/buildingblock.go | 2 + cmd/buildingblock/lock.go | 71 +++++++++++++++++++++ cmd/buildingblock/lock_test.go | 67 +++++++++++++++++++ cmd/buildingblock/unlock.go | 35 ++++++++++ cmd/buildingblock/wait.go | 2 +- cmd/internal/buildingblock/run_wait.go | 4 +- cmd/internal/buildingblock/run_wait_test.go | 9 +++ skills/meshstack-cli/SKILL.md | 21 +++++- 9 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 cmd/buildingblock/lock.go create mode 100644 cmd/buildingblock/lock_test.go create mode 100644 cmd/buildingblock/unlock.go diff --git a/client/building_block_v2.go b/client/building_block_v2.go index e82ae513..889b6eeb 100644 --- a/client/building_block_v2.go +++ b/client/building_block_v2.go @@ -46,6 +46,7 @@ var ( BuildingBlockActionUpgradeAvailable = BuildingBlockActions.Entry("UPGRADE_AVAILABLE") BuildingBlockActionReviewErrors = BuildingBlockActions.Entry("REVIEW_ERRORS") BuildingBlockActionReviewAbortedRun = BuildingBlockActions.Entry("REVIEW_ABORTED_RUN") + BuildingBlockActionUnlockRuns = BuildingBlockActions.Entry("UNLOCK_RUNS") BuildingBlockActionNoAction = BuildingBlockActions.Entry("NO_ACTION") ) @@ -208,7 +209,8 @@ var ( // MeshBuildingBlockV2UpdateOptions go out as query parameters, and a zero field as none, so the // zero value works against a meshStack that does not know them. type MeshBuildingBlockV2UpdateOptions struct { - // SkipRunOn saves an update that matches one of the values without starting a run. + // SkipRunOn locks the building block when the update matches one of the values: meshStack saves + // the update and starts no run until Unlock. SkipRunOn []enum.Entry[BuildingBlockSkipRunOn] `json:"skipRunOn"` } @@ -220,6 +222,8 @@ type MeshBuildingBlockV2Client interface { Update(ctx context.Context, bb *MeshBuildingBlockV2, options MeshBuildingBlockV2UpdateOptions) (*MeshBuildingBlockV2, error) Delete(ctx context.Context, uuid string, purge bool) error TriggerRun(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) + Lock(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) + Unlock(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) } type meshBuildingBlockV2Client struct { @@ -276,6 +280,11 @@ func (status MeshBuildingBlockV2Status) IsWaitingForInput() bool { return slices.Contains(buildingBlockActionsWaitingForInput, status.Action) } +// IsLocked says that meshStack starts no run of the block until someone unlocks it. +func (status MeshBuildingBlockV2Status) IsLocked() bool { + return status.Action == BuildingBlockActionUnlockRuns +} + func (status MeshBuildingBlockV2Status) RunFailed() bool { return slices.Contains(buildingBlockActionsRunFailed, status.Action) } @@ -369,3 +378,20 @@ func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string DryRun bool `json:"dryRun"` }{}, bbUuid, "trigger-run") } + +// Lock stops meshStack from starting any further run of a building block until Unlock. A run that +// is going on goes to its end. meshStack answers 200 without a change for a block that is locked. +func (c meshBuildingBlockV2Client) Lock(ctx context.Context, bbUuid string) (*MeshBuildingBlockV2, error) { + return c.postAction(ctx, bbUuid, "lock") +} + +// Unlock lets meshStack decide again whether a locked building block runs. meshStack answers 200 +// without a change for a block that is not locked. +func (c meshBuildingBlockV2Client) Unlock(ctx context.Context, bbUuid string) (*MeshBuildingBlockV2, error) { + return c.postAction(ctx, bbUuid, "unlock") +} + +func (c meshBuildingBlockV2Client) postAction(ctx context.Context, bbUuid, action string) (*MeshBuildingBlockV2, error) { + return c.meshObject.DoRequest[*MeshBuildingBlockV2](ctx, http.MethodPost, c.meshObject.ApiUrl.JoinPath(bbUuid, action), + http.WithAccept(c.meshObject.MeshObjectMimeType())) +} diff --git a/cmd/buildingblock/buildingblock.go b/cmd/buildingblock/buildingblock.go index 084fd853..09d0e534 100644 --- a/cmd/buildingblock/buildingblock.go +++ b/cmd/buildingblock/buildingblock.go @@ -36,6 +36,8 @@ func New() *cobra.Command { cmd.AddCommand(newWait()) cmd.AddCommand(newApproveRun()) cmd.AddCommand(newAbortRun()) + cmd.AddCommand(newLock()) + cmd.AddCommand(newUnlock()) cmd.AddCommand(tfstate.New()) return cmd diff --git a/cmd/buildingblock/lock.go b/cmd/buildingblock/lock.go new file mode 100644 index 00000000..a5f32abd --- /dev/null +++ b/cmd/buildingblock/lock.go @@ -0,0 +1,71 @@ +package buildingblock + +import ( + "context" + "encoding/json/v2" + "fmt" + "log/slog" + "uuid" + + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/cmd/internal" +) + +func newLock() *cobra.Command { + var ( + output internal.OutputFlag + buildingBlockUuid uuid.UUID + ) + + cmd := &cobra.Command{ + Use: "lock ", + Short: "Stop meshStack from running a building block", + Long: `Lock a building block so that meshStack starts no further run of it until you unlock it with 'meshstack buildingblock unlock'. Give the building block by its uuid. A run that is in progress continues to its end. A locked building block has the action UNLOCK_RUNS. + +Locking a building block that is already locked changes nothing. The command prints the building block.`, + Example: ` meshstack buildingblock lock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 + meshstack bb lock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 -o ndjson | jq -r .status.action`, + Args: internal.UuidArg(&buildingBlockUuid), + RunE: func(cmd *cobra.Command, _ []string) error { + return changeLock(cmd, buildingBlockUuid, output.Format, client.MeshBuildingBlockV2Client.Lock, "Locked") + }, + } + + output.RegisterForItem(cmd.Flags()) + + return cmd +} + +// changeLock writes the building block as a read after the change, as trigger-run does, so that it +// carries every field meshStack sends. +func changeLock( + cmd *cobra.Command, + buildingBlockUuid uuid.UUID, + format internal.OutputFormat, + change func(client.MeshBuildingBlockV2Client, context.Context, string) (*client.MeshBuildingBlockV2, error), + done string, +) error { + ctx := cmd.Context() + meshStack, err := internal.ResolveClient(ctx) + if err != nil { + return err + } + if _, err = change(meshStack.BuildingBlockV2, ctx, buildingBlockUuid.String()); err != nil { + return err + } + raw, err := meshStack.Raw.Get[client.MeshBuildingBlockV2](ctx, buildingBlockUuid) + if err != nil { + return err + } + var block linkedBuildingBlock + if err := json.Unmarshal(raw, &block); err != nil { + return err + } + if err := format.WriteItem(cmd.OutOrStdout(), raw); err != nil { + return err + } + slog.InfoContext(ctx, fmt.Sprintf("%s building block %s. It now has the action %s.", done, buildingBlockUuid, block.Status.Action)) + return nil +} diff --git a/cmd/buildingblock/lock_test.go b/cmd/buildingblock/lock_test.go new file mode 100644 index 00000000..64e3b72f --- /dev/null +++ b/cmd/buildingblock/lock_test.go @@ -0,0 +1,67 @@ +package buildingblock_test + +import ( + "bytes" + "fmt" + gohttp "net/http" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/cmd/buildingblock" + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" + "github.com/meshcloud/meshstack-cli/internal/testutil/testlogin" +) + +func TestLockAndUnlock(t *testing.T) { + const buildingBlockUuid = "b1d2c3e4-0000-4000-8000-000000000004" + var action atomic.Pointer[string] + action.Store(new("NO_ACTION")) + writeBlock := func(w gohttp.ResponseWriter, _ *gohttp.Request) { + _, _ = fmt.Fprintf(w, `{"metadata": {"uuid": %q, "ownedByWorkspace": "my-workspace"}, "spec": {}, "status": {"action": %q}}`, + buildingBlockUuid, *action.Load()) + } + meshStack := fakemeshstack.Start(t, fakemeshstack.Options{}) + meshStack.Route("GET /api/meshobjects/meshbuildingblocks/{uuid}", writeBlock) + meshStack.Route("POST /api/meshobjects/meshbuildingblocks/{uuid}/lock", func(w gohttp.ResponseWriter, r *gohttp.Request) { + action.Store(new("UNLOCK_RUNS")) + writeBlock(w, r) + }) + meshStack.Route("POST /api/meshobjects/meshbuildingblocks/{uuid}/unlock", func(w gohttp.ResponseWriter, r *gohttp.Request) { + action.Store(new("PENDING")) + writeBlock(w, r) + }) + testlogin.LoggedInTo(t, meshStack.URL) + + run := func(t *testing.T, command string) string { + t.Helper() + cmd := buildingblock.New() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{command, buildingBlockUuid, "-o", "ndjson"}) + require.NoError(t, cmd.ExecuteContext(t.Context())) + return stdout.String() + } + posted := func(t *testing.T) []string { + t.Helper() + var paths []string + for _, request := range meshStack.TakeRequests() { + if request.Method == gohttp.MethodPost { + paths = append(paths, request.URL.Path) + } + } + return paths + } + + t.Run("lock writes the locked building block", func(t *testing.T) { + assert.Contains(t, run(t, "lock"), `"action":"UNLOCK_RUNS"`) + assert.Equal(t, []string{"/api/meshobjects/meshbuildingblocks/" + buildingBlockUuid + "/lock"}, posted(t)) + }) + + t.Run("unlock writes the building block as meshStack left it", func(t *testing.T) { + assert.Contains(t, run(t, "unlock"), `"action":"PENDING"`) + assert.Equal(t, []string{"/api/meshobjects/meshbuildingblocks/" + buildingBlockUuid + "/unlock"}, posted(t)) + }) +} diff --git a/cmd/buildingblock/unlock.go b/cmd/buildingblock/unlock.go new file mode 100644 index 00000000..8aeb0563 --- /dev/null +++ b/cmd/buildingblock/unlock.go @@ -0,0 +1,35 @@ +package buildingblock + +import ( + "uuid" + + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/cmd/internal" +) + +func newUnlock() *cobra.Command { + var ( + output internal.OutputFlag + buildingBlockUuid uuid.UUID + ) + + cmd := &cobra.Command{ + Use: "unlock ", + Short: "Let meshStack run a locked building block again", + Long: `Unlock a building block whose status.action is UNLOCK_RUNS. Give the building block by its uuid. meshStack starts no run of a locked building block until it is unlocked. An update with skipRunOn locks a building block, and so does 'meshstack buildingblock lock'. + +After the unlock, meshStack decides whether the building block runs, as after an update, and approval policies apply. Unlocking a building block that is not locked changes nothing. The command prints the building block.`, + Example: ` meshstack buildingblock unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 + meshstack bb unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 -o ndjson | jq -r .status.action`, + Args: internal.UuidArg(&buildingBlockUuid), + RunE: func(cmd *cobra.Command, _ []string) error { + return changeLock(cmd, buildingBlockUuid, output.Format, client.MeshBuildingBlockV2Client.Unlock, "Unlocked") + }, + } + + output.RegisterForItem(cmd.Flags()) + + return cmd +} diff --git a/cmd/buildingblock/wait.go b/cmd/buildingblock/wait.go index c420e28f..a2f1d1ab 100644 --- a/cmd/buildingblock/wait.go +++ b/cmd/buildingblock/wait.go @@ -28,7 +28,7 @@ func newWait() *cobra.Command { meshStack responds to a write before it starts the run. So right after the write, the building block still shows the action of the previous run. Pass that response with --` + beforeFlagName + `: the response of a create (POST), an update (PUT) or a trigger-run, or a read taken before the change. The command first waits up to a minute until meshStack has reacted, following the rules of https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run. Then it waits until the run has ended. Press Ctrl-C to stop waiting. -The command prints the building block. It fails and names the action if the run failed, was aborted or could not start, or if the run is waiting for a person or a dependency.`, +The command prints the building block. It fails and names the action if the run failed, was aborted or could not start, if the run is waiting for a person or a dependency, or if the building block is locked.`, Example: ` meshstack api -X PUT /api/meshobjects/meshbuildingblocks/0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --request-json block.json | meshstack bb wait 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --before=-`, Args: internal.UuidArg(&buildingBlockUuid), diff --git a/cmd/internal/buildingblock/run_wait.go b/cmd/internal/buildingblock/run_wait.go index 57ec7780..6b775eb1 100644 --- a/cmd/internal/buildingblock/run_wait.go +++ b/cmd/internal/buildingblock/run_wait.go @@ -41,7 +41,7 @@ func (w RunWait) AwaitReaction(ctx context.Context, before client.MeshBuildingBl w.buildingBlockUuid, w.startWithin)) defer cancel() reacted, block, err := w.poll(ctx, w.startEvery, func(block *client.MeshBuildingBlockV2) (bool, error) { - return block.Status != nil && block.Status.RunChangedSince(before), nil + return block.Status != nil && (block.Status.IsLocked() || block.Status.RunChangedSince(before)), nil }) return reacted, block, withMeshPanelLink(err, block) } @@ -72,6 +72,8 @@ func (w RunWait) Outcome(block *client.MeshBuildingBlockV2) error { switch { case status.RunStartFailure != "": return w.StartFailure(block) + case status.IsLocked(): + err = fmt.Errorf("building block %s is locked, and meshStack starts no run of it until you run 'meshstack bb unlock %s'", w.buildingBlockUuid, w.buildingBlockUuid) case status.RunFailed(): err = fmt.Errorf("the run of building block %s ended with action %s", w.buildingBlockUuid, status.Action) case status.IsWaitingForInput(): diff --git a/cmd/internal/buildingblock/run_wait_test.go b/cmd/internal/buildingblock/run_wait_test.go index 8f5109a8..528071a8 100644 --- a/cmd/internal/buildingblock/run_wait_test.go +++ b/cmd/internal/buildingblock/run_wait_test.go @@ -55,4 +55,13 @@ func TestRunWait(t *testing.T) { require.ErrorContains(t, wait.StartFailure(block), "meshStack could not start the run of building block "+buildingBlockUuid.String()+": no runner") }) + + t.Run("a locked block ends the wait, and someone has to unlock it", func(t *testing.T) { + status.Store(new(`"action": "UNLOCK_RUNS", "latestRunUuid": "run-1"`)) + + _, block, err := wait.AwaitEnd(t.Context(), client.MeshBuildingBlockV2Status{Action: client.BuildingBlockActionUnlockRuns, LatestRunUuid: new("run-1")}) + require.NoError(t, err) + + require.ErrorContains(t, wait.Outcome(block), "building block "+buildingBlockUuid.String()+" is locked") + }) } diff --git a/skills/meshstack-cli/SKILL.md b/skills/meshstack-cli/SKILL.md index 50b87648..47ffbeb5 100644 --- a/skills/meshstack-cli/SKILL.md +++ b/skills/meshstack-cli/SKILL.md @@ -29,6 +29,8 @@ skill and the help disagree, trust the help. | `meshstack buildingblock list` | `bb` | building blocks (deployed instances), newest first | | `meshstack buildingblock trigger-run ` | `bb` | starts a run of one block | | `meshstack buildingblock wait --before ` | `bb` | waits until the run of one block has ended | +| `meshstack buildingblock lock ` | `bb` | stops meshStack from running one block | +| `meshstack buildingblock unlock ` | `bb` | lets meshStack run a locked block again | | `meshstack buildingblock tfstate` | `bb` | reads and writes the OpenTofu state of one block | | `meshstack buildingblockdefinition list` | `bbd` | definitions — what platform teams offer | | `meshstack buildingblockdefinitionversion list --definition ` | `bbdv` | the versions of one definition | @@ -366,12 +368,29 @@ meshstack api -X PUT /api/meshobjects/meshbuildingblocks/ --request-json b meshstack bb wait --before=- ``` -Both fail, and name the action, when the run failed or waits for a person or a dependency. +Both fail, and name the action, when the run failed or waits for a person or a dependency, or when +the block is locked. A block that deletes itself at the end of its run, like a starterkit, is gone before `bb wait` can read the outcome. Poll its runs instead: `meshstack bbrun list --building-block --workspace ` until the `APPLY` run is terminal. +## Locked building blocks + +A locked block has the action `UNLOCK_RUNS`, and meshStack starts no run of it until it is unlocked. +An update with the query parameter `skipRunOn=VERSION_UPGRADE` that upgrades the definition version +locks the block, so that the new version and its inputs are saved without a run. `bb lock` locks +any block; a run that is going on goes to its end. A locked block accepts updates and stays locked, +`trigger-run` fails with 409, and `bb wait` fails with the hint to unlock. + +```sh +meshstack bb list -o ndjson | jq -r 'select(.status.action == "UNLOCK_RUNS") | .metadata.uuid' +meshstack bb unlock +``` + +After the unlock, meshStack decides whether the block runs, as after an update, and approval +policies apply. Both commands change nothing on a block that is already in the state they ask for. + ## Everything else: `meshstack api` `meshstack api` sends an authenticated, `curl`-like request to any path of the API, with the