Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 4 additions & 44 deletions client/building_block_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,50 +266,11 @@ func (status MeshBuildingBlockV2Status) RunFailed() bool {
return slices.Contains(buildingBlockActionsRunFailed, status.Action)
}

// RunChangedSince says whether meshStack has reacted to a write since before, by the rules of
// https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run. A zero before is a block that had no status yet.
func (status MeshBuildingBlockV2Status) RunChangedSince(before MeshBuildingBlockV2Status) bool {
switch {
case status.GetLatestRunUuid() != before.GetLatestRunUuid(),
status.GetLatestDryRunUuid() != before.GetLatestDryRunUuid(),
status.RunStartFailedOn != "" && status.RunStartFailedOn != before.RunStartFailedOn:
return true
case before.RunStartFailedOn != "" && status.RunStartFailedOn == "":
// A build that includes the block again only clears the old failure.
return false
default:
return !before.IsRunning() && status.actionOfRun() != before.actionOfRun()
}
}

// actionOfRun ignores UPGRADE_AVAILABLE, which a newly released definition version sets without any run.
func (status MeshBuildingBlockV2Status) actionOfRun() enum.Entry[BuildingBlockAction] {
if status.Action == BuildingBlockActionUpgradeAvailable {
return BuildingBlockActionNoAction
}
return status.Action
}

func (bb *MeshBuildingBlockV2) IsWaitingForInput() bool {
return bb.Status != nil && bb.Status.IsWaitingForInput()
}

// RunEndedSince is done on every action that is not running, a failed or an unknown one included:
// the caller judges the outcome.
func (bb *MeshBuildingBlockV2) RunEndedSince(before MeshBuildingBlockV2Status) (done bool, err error) {
switch {
case bb == nil:
err = errors.New("building block not found")
case bb.Status == nil, !bb.Status.RunChangedSince(before), bb.Status.IsRunning():
default:
done = true
}
return
}

// DeletionSuccessful takes the status the block had before the deletion, because meshStack reports the
// action of the run before until the delete run starts.
func (bb *MeshBuildingBlockV2) DeletionSuccessful(before MeshBuildingBlockV2Status) (done bool, err error) {
func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) {
switch {
case bb == nil:
// A 404: the block is gone, for example together with its definition.
Expand All @@ -318,7 +279,7 @@ func (bb *MeshBuildingBlockV2) DeletionSuccessful(before MeshBuildingBlockV2Stat
// A soft-deleted block does not return 404: the backend keeps returning it with DELETED.
// MARKED_FOR_DELETION means the deletion still runs, so polling goes on.
done = true
case bb.Status == nil, !bb.Status.RunChangedSince(before):
case bb.Status == nil, bb.Status.IsRunning():
case bb.Status.ForcePurge:
// A force purge (definition deletion_mode PURGE, or an admin purge) deletes the block whatever
// its delete run reports, and the lifecycle still reaches DELETED.
Expand All @@ -345,9 +306,8 @@ func (bb *MeshBuildingBlockV2) uuidOrUnknown() string {
return "<unknown>"
}

// TriggerRun answers with the building block as meshStack left it on accepting the run: Action,
// LatestRunUuid, RunStartFailure and RunStartFailedOn still report the request before, because
// meshStack starts the run, or records why it could not, only after it answered.
// TriggerRun answers with the building block in the action PENDING, as meshStack starts the run, or
// records why it could not, only after it answered.
func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) (*MeshBuildingBlockV2, error) {
// dryRun is not optional to the endpoint once a body is sent, so it goes out as false rather
// than being omitted.
Expand Down
108 changes: 2 additions & 106 deletions client/building_block_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,108 +9,7 @@ import (
"github.com/meshcloud/meshstack-cli/client/types/enum"
)

func TestMeshBuildingBlockV2Status_RunChangedSince(t *testing.T) {
const (
failedOn = "2026-10-28T12:00:00Z"
laterFailedOn = "2026-10-28T13:00:00Z"
)
idle := MeshBuildingBlockV2Status{Action: BuildingBlockActionNoAction, LatestRunUuid: new("run-1")}
with := func(base MeshBuildingBlockV2Status, change func(*MeshBuildingBlockV2Status)) MeshBuildingBlockV2Status {
change(&base)
return base
}
tests := []struct {
name string
before MeshBuildingBlockV2Status
now MeshBuildingBlockV2Status
want bool
}{
{"a block without a status before has changed", MeshBuildingBlockV2Status{}, idle, true},
{"a created block that waits for its first run has changed against a zero before", MeshBuildingBlockV2Status{}, MeshBuildingBlockV2Status{Action: BuildingBlockActionPending}, true},
{"the same status has not changed", idle, idle, false},
{"a new latest run has changed", idle, with(idle, func(s *MeshBuildingBlockV2Status) { s.LatestRunUuid = new("run-2") }), true},
{"a new latest dry run has changed", idle, with(idle, func(s *MeshBuildingBlockV2Status) { s.LatestDryRunUuid = new("dry-run-1") }), true},
{"a first run start failure has changed", idle, with(idle, func(s *MeshBuildingBlockV2Status) {
s.Action, s.RunStartFailedOn = BuildingBlockActionReviewErrors, failedOn
}), true},
{
"a newer run start failure has changed",
with(idle, func(s *MeshBuildingBlockV2Status) { s.RunStartFailedOn = failedOn }),
with(idle, func(s *MeshBuildingBlockV2Status) { s.RunStartFailedOn = laterFailedOn }), true,
},
{
"a cleared run start failure alone has not changed",
with(idle, func(s *MeshBuildingBlockV2Status) {
s.Action, s.RunStartFailedOn = BuildingBlockActionReviewErrors, failedOn
}),
idle, false,
},
{
"a cleared run start failure with a new run has changed",
with(idle, func(s *MeshBuildingBlockV2Status) {
s.Action, s.RunStartFailedOn = BuildingBlockActionReviewErrors, failedOn
}),
with(idle, func(s *MeshBuildingBlockV2Status) {
s.Action, s.LatestRunUuid = BuildingBlockActionPending, new("run-2")
}), true,
},
{
"a new action of a block that was not running has changed, as supplied input resumes the same run",
with(idle, func(s *MeshBuildingBlockV2Status) { s.Action = BuildingBlockActionProvideInput }),
with(idle, func(s *MeshBuildingBlockV2Status) { s.Action = BuildingBlockActionPending }), true,
},
{
"a new action of a block that was running has not changed, as it belongs to the run before",
with(idle, func(s *MeshBuildingBlockV2Status) { s.Action = BuildingBlockActionInProgress }),
with(idle, func(s *MeshBuildingBlockV2Status) { s.Action = BuildingBlockActionReviewErrors }), false,
},
{
"UPGRADE_AVAILABLE reads as NO_ACTION, as a released definition version starts no run",
idle, with(idle, func(s *MeshBuildingBlockV2Status) { s.Action = BuildingBlockActionUpgradeAvailable }), false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.now.RunChangedSince(tt.before))
})
}
}

func TestMeshBuildingBlockV2_RunEndedSince(t *testing.T) {
before := MeshBuildingBlockV2Status{Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("run-1")}
block := func(action enum.Entry[BuildingBlockAction], latestRunUuid string) *MeshBuildingBlockV2 {
return &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{Action: action, LatestRunUuid: new(latestRunUuid)}}
}
tests := []struct {
name string
bb *MeshBuildingBlockV2
wantDone bool
}{
{"a block without a status goes on", &MeshBuildingBlockV2{}, false},
{"the outcome of the run before goes on", block(BuildingBlockActionReviewErrors, "run-1"), false},
{"a pending run goes on", block(BuildingBlockActionPending, "run-2"), false},
{"a run in progress goes on", block(BuildingBlockActionInProgress, "run-2"), false},
{"a run that needs no action ends", block(BuildingBlockActionNoAction, "run-2"), true},
{"a failed run ends without an error", block(BuildingBlockActionReviewErrors, "run-2"), true},
{"a run that waits for approval ends", block(BuildingBlockActionApproveRun, "run-2"), true},
{"an action this client does not know ends without an error", block(enum.Entry[BuildingBlockAction]("SOMETHING_NEW"), "run-2"), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
done, err := tt.bb.RunEndedSince(before)
assert.Equal(t, tt.wantDone, done)
assert.NoError(t, err)
})
}

t.Run("a block that is gone fails", func(t *testing.T) {
_, err := (*MeshBuildingBlockV2)(nil).RunEndedSince(MeshBuildingBlockV2Status{})
require.Error(t, err)
})
}

func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) {
before := MeshBuildingBlockV2Status{Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("apply-run")}
deleteRun := func(action enum.Entry[BuildingBlockAction]) *MeshBuildingBlockV2 {
return &MeshBuildingBlockV2{
Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")},
Expand All @@ -129,10 +28,7 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) {
bb: &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{Lifecycle: MeshBuildingBlockV2Lifecycle{State: BuildingBlockLifecycleStateDeleted}}},
wantDone: true,
},
{
name: "REVIEW_ERRORS of the run before the deletion goes on",
bb: &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("apply-run")}},
},
{name: "a delete run that meshStack has not started yet goes on", bb: deleteRun(BuildingBlockActionPending)},
{name: "a delete run in progress goes on", bb: deleteRun(BuildingBlockActionInProgress)},
{name: "a failed delete run fails", bb: deleteRun(BuildingBlockActionReviewErrors), wantErr: "the delete run of building block test-uuid ended with action REVIEW_ERRORS"},
{name: "an aborted delete run fails", bb: deleteRun(BuildingBlockActionReviewAbortedRun), wantErr: "ended with action REVIEW_ABORTED_RUN"},
Expand All @@ -159,7 +55,7 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
done, err := tt.bb.DeletionSuccessful(before)
done, err := tt.bb.DeletionSuccessful()
assert.Equal(t, tt.wantDone, done)
if tt.wantErr == "" {
assert.NoError(t, err)
Expand Down
14 changes: 7 additions & 7 deletions cmd/buildingblock/trigger_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func newTriggerRun() *cobra.Command {

The command waits until meshStack has started the run, and then prints the building block. Its status.latestRunUuid is the run. If the definition requires an approval of the run, status.latestDryRunUuid is the preflight run, whose plan the approver sees. 'meshstack buildingblockrun logs <run-uuid>' shows how far a run got.

With --` + waitFlagName + `, the command waits until the run has ended. This is the same as piping meshStack's response to the run request into 'meshstack buildingblock wait <uuid> --` + beforeFlagName + `=-'.
With --` + waitFlagName + `, the command waits until the run has ended. This is the same as running 'meshstack buildingblock wait <uuid>' afterwards.

'meshstack api-docs --describe buildingblock.trigger-run' shows the fields of the response.`,
Example: ` meshstack buildingblock trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90
Expand All @@ -47,14 +47,14 @@ With --` + waitFlagName + `, the command waits until the run has ended. This is
accepted = *answer.Status
}
if wait {
return awaitRunEnd(ctx, cmd.OutOrStdout(), output.Format, meshStack.Raw, buildingBlockUuid, accepted)
return awaitRunEnd(ctx, cmd.OutOrStdout(), output.Format, meshStack.Raw, buildingBlockUuid)
}
runWait := buildingblock.NewRunWait(meshStack.Raw, buildingBlockUuid)
reacted, block, err := runWait.AwaitReaction(ctx, accepted)
started, block, err := runWait.AwaitStart(ctx)
if err != nil {
return err
}
if err := output.Format.WriteItem(cmd.OutOrStdout(), reacted); err != nil {
if err := output.Format.WriteItem(cmd.OutOrStdout(), started); err != nil {
return err
}
if err := runWait.StartFailure(block); err != nil {
Expand All @@ -73,15 +73,15 @@ With --` + waitFlagName + `, the command waits until the run has ended. This is

const waitFlagName = "wait"

func startedRunMessage(buildingBlockUuid uuid.UUID, accepted, reacted client.MeshBuildingBlockV2Status) string {
switch dryRun, run := reacted.GetLatestDryRunUuid(), reacted.GetLatestRunUuid(); {
func startedRunMessage(buildingBlockUuid uuid.UUID, accepted, started client.MeshBuildingBlockV2Status) string {
switch dryRun, run := started.GetLatestDryRunUuid(), started.GetLatestRunUuid(); {
case dryRun != "" && dryRun != accepted.GetLatestDryRunUuid():
return fmt.Sprintf("meshStack started the preflight run %s of building block %s. The run that makes the changes then waits for approval. "+
"Follow the preflight run with 'meshstack buildingblockrun logs %s'", dryRun, buildingBlockUuid, dryRun)
case run != "" && run != accepted.GetLatestRunUuid():
return fmt.Sprintf("meshStack started run %s of building block %s. Follow it with 'meshstack buildingblockrun logs %s'",
run, buildingBlockUuid, run)
default:
return fmt.Sprintf("Building block %s now has the action %s.", buildingBlockUuid, reacted.Action)
return fmt.Sprintf("Building block %s now has the action %s.", buildingBlockUuid, started.Action)
}
}
13 changes: 5 additions & 8 deletions cmd/buildingblock/trigger_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json/jsontext"
"fmt"
"io"
gohttp "net/http"
"testing"

Expand All @@ -23,15 +22,13 @@ func TestTriggerRunPrintsTheBuildingBlockAndFailsWithTheReasonMeshStackCouldNotS
buildingBlockUuid = "b1d2c3e4-0000-4000-8000-000000000001"
reason = "meshStack could not start a run for this Building Block.\n\nAsk your platform team to look up the details in the meshStack logs."
)
failure := func(failedOn string) string {
return fmt.Sprintf(`{"metadata": {"uuid": %q, "ownedByWorkspace": "my-workspace"}, "spec": {}, `+
`"status": {"action": "PENDING", "latestRunUuid": "a0000000-0000-4000-8000-000000000001", "runStartFailure": %q, "runStartFailedOn": %q}}`,
buildingBlockUuid, reason, failedOn)
}
meshStack := fakemeshstack.Start(t, fakemeshstack.Options{BuildingBlocks: []any{jsontext.Value(failure("2026-10-01T10:05:00Z"))}})
failure := fmt.Sprintf(`{"metadata": {"uuid": %q, "ownedByWorkspace": "my-workspace"}, "spec": {}, `+
`"status": {"action": "REVIEW_ERRORS", "latestRunUuid": "a0000000-0000-4000-8000-000000000001", "runStartFailure": %q, "runStartFailedOn": "2026-10-01T10:05:00Z"}}`,
buildingBlockUuid, reason)
meshStack := fakemeshstack.Start(t, fakemeshstack.Options{BuildingBlocks: []any{jsontext.Value(failure)}})
meshStack.Route("POST /api/meshobjects/meshbuildingblocks/{uuid}/trigger-run", func(w gohttp.ResponseWriter, _ *gohttp.Request) {
w.WriteHeader(gohttp.StatusAccepted)
_, _ = io.WriteString(w, failure("2026-10-01T10:00:00Z"))
_, _ = fmt.Fprintf(w, `{"metadata": {"uuid": %q}, "spec": {}, "status": {"action": "PENDING", "latestRunUuid": "a0000000-0000-4000-8000-000000000001"}}`, buildingBlockUuid)
})
testlogin.LoggedInTo(t, meshStack.URL)

Expand Down
40 changes: 8 additions & 32 deletions cmd/buildingblock/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,62 +18,38 @@ func newWait() *cobra.Command {
var (
output internal.OutputFlag
buildingBlockUuid uuid.UUID
beforeInput internal.JsonInputFlag[client.MeshBuildingBlockV2]
)

cmd := &cobra.Command{
Use: "wait <building-block-uuid> --before <file|->",
Use: "wait <building-block-uuid>",
Short: "Wait until the run of a building block has ended",
Long: `Wait until meshStack has reacted to a write of a building block, and until the run that the write started has ended. Give the building block by its uuid.
Long: `Wait until the run of a building block has ended. Give the building block by its uuid.

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.
After a write that asks for a run, such as a create (POST), an update (PUT) or a trigger-run, meshStack shows the action PENDING until it has started the run, and IN_PROGRESS while the run is going on. The command waits while the action is PENDING or IN_PROGRESS. 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.`,
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=-`,
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`,
Args: internal.UuidArg(&buildingBlockUuid),
RunE: func(cmd *cobra.Command, _ []string) error {
before, err := readBefore(cmd, beforeInput, buildingBlockUuid)
if err != nil {
return err
}
meshStack, err := internal.ResolveClient(cmd.Context())
if err != nil {
return err
}
return awaitRunEnd(cmd.Context(), cmd.OutOrStdout(), output.Format, meshStack.Raw, buildingBlockUuid, before)
return awaitRunEnd(cmd.Context(), cmd.OutOrStdout(), output.Format, meshStack.Raw, buildingBlockUuid)
},
}

output.RegisterForItem(cmd.Flags())
beforeInput.Register(cmd.Flags(), beforeFlagName, "file with the building block from before the write, or - to read it from stdin")
_ = cmd.MarkFlagRequired(beforeFlagName)

return cmd
}

const beforeFlagName = "before"

func readBefore(cmd *cobra.Command, input internal.JsonInputFlag[client.MeshBuildingBlockV2], buildingBlockUuid uuid.UUID) (client.MeshBuildingBlockV2Status, error) {
source := input.Source
before, err := input.Read(cmd)
if err != nil {
return client.MeshBuildingBlockV2Status{}, err
}
switch {
case before.Metadata.Uuid != nil && *before.Metadata.Uuid != buildingBlockUuid.String():
return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--%s %s contains building block %s, but the command waits for %s", beforeFlagName, source, *before.Metadata.Uuid, buildingBlockUuid)
case before.Status == nil:
return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--%s %s contains no status of the building block", beforeFlagName, source)
}
return *before.Status, nil
}

// awaitRunEnd writes the building block once its run has ended, and fails on an outcome someone has
// to act on.
func awaitRunEnd(ctx context.Context, out io.Writer, format internal.OutputFormat, raw *client.RawClient, buildingBlockUuid uuid.UUID, before client.MeshBuildingBlockV2Status) error {
func awaitRunEnd(ctx context.Context, out io.Writer, format internal.OutputFormat, raw *client.RawClient, buildingBlockUuid uuid.UUID) error {
wait := buildingblock.NewRunWait(raw, buildingBlockUuid)
ended, block, err := wait.AwaitEnd(ctx, before)
ended, block, err := wait.AwaitEnd(ctx)
if err != nil {
return err
}
Expand Down
Loading
Loading