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
46 changes: 43 additions & 3 deletions client/building_block_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)

Expand Down Expand Up @@ -198,14 +199,31 @@ 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 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"`
}

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)
Lock(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error)
Unlock(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error)
}

type meshBuildingBlockV2Client struct {
Expand Down Expand Up @@ -234,11 +252,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 {
Expand All @@ -262,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)
}
Expand Down Expand Up @@ -355,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()))
}
28 changes: 28 additions & 0 deletions client/building_block_v2_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions client/internal/mesh_object.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions cmd/buildingblock/buildingblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions cmd/buildingblock/lock.go
Original file line number Diff line number Diff line change
@@ -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 <building-block-uuid>",
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
}
67 changes: 67 additions & 0 deletions cmd/buildingblock/lock_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
35 changes: 35 additions & 0 deletions cmd/buildingblock/unlock.go
Original file line number Diff line number Diff line change
@@ -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 <building-block-uuid>",
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
}
2 changes: 1 addition & 1 deletion cmd/buildingblock/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion cmd/internal/buildingblock/run_wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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():
Expand Down
9 changes: 9 additions & 0 deletions cmd/internal/buildingblock/run_wait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
Loading
Loading