From 2819ea534ff027b41da1be54fbd4d1ddce8934b1 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 22 Sep 2026 21:18:29 +0200 Subject: [PATCH] feat: trigger a building block run from the CLI Add `meshstack buildingblock trigger-run [--dry-run]`. It asks meshStack to run a building block and writes the building block that meshStack answers with. --dry-run asks for a DETECT run. This lets agents and scripts start a run without a local OpenTofu, for example after a fix is pushed to a draft definition's branch. The command does not wait for the run and does not approve anything. Approval stays with humans in meshPanel. The client gets TriggerRunWith, which takes the dryRun flag and returns the answered building block. TriggerRun keeps its signature and calls it. Co-Authored-By: Claude Opus 5.5 --- client/building_block_v2.go | 20 ++++++-- client/client.go | 3 ++ client/list.go | 14 ++++++ cmd/buildingblock/buildingblock.go | 1 + cmd/buildingblock/trigger_run.go | 54 ++++++++++++++++++++ cmd/buildingblock/trigger_run_test.go | 72 +++++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 cmd/buildingblock/trigger_run.go create mode 100644 cmd/buildingblock/trigger_run_test.go diff --git a/client/building_block_v2.go b/client/building_block_v2.go index f36520a6..8c0f1460 100644 --- a/client/building_block_v2.go +++ b/client/building_block_v2.go @@ -409,11 +409,21 @@ func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { return } -func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) (err error) { +// MeshBuildingBlockV2TriggerRunRequest is the body of a trigger-run. DryRun asks for a DETECT run, +// which plans the block without changing it. +type MeshBuildingBlockV2TriggerRunRequest struct { // dryRun is not optional to the endpoint once a body is sent, so it goes out as false rather // than being omitted. - _, err = c.meshObject.PostAtPath[any](ctx, struct { - DryRun bool `json:"dryRun"` - }{}, bbUuid, "trigger-run") - return + DryRun bool `json:"dryRun"` +} + +func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) error { + _, err := c.TriggerRunWith(ctx, bbUuid, MeshBuildingBlockV2TriggerRunRequest{}) + return err +} + +// TriggerRunWith returns the building block as meshStack answers the trigger, undecoded. The run itself starts +// asynchronously, so its status.latestRunUuid and status.latestDryRunUuid may still name an older run. +func (c meshBuildingBlockV2Client) TriggerRunWith(ctx context.Context, bbUuid string, request MeshBuildingBlockV2TriggerRunRequest) (jsontext.Value, error) { + return c.meshObject.PostAtPath[jsontext.Value](ctx, request, bbUuid, "trigger-run") } diff --git a/client/client.go b/client/client.go index 8829c4fd..aff739f5 100644 --- a/client/client.go +++ b/client/client.go @@ -46,6 +46,7 @@ type Client struct { WorkspaceGroupBinding MeshWorkspaceGroupBindingClient WorkspaceUserBinding MeshWorkspaceUserBindingClient Listing MeshListingClient + RunTrigger MeshRunTriggerClient // Endpoint is read by the Terraform provider's meshstack_instance data source. Endpoint xurl.URL @@ -94,6 +95,8 @@ func New(ctx context.Context, endpoint xurl.URL, userAgent string, auth Authoriz buildingBlockDefinitionVersion: buildingBlockDefinitionVersion, }, + RunTrigger: meshRunTriggerClient{buildingBlockV2: buildingBlockV2}, + Endpoint: endpoint, } } diff --git a/client/list.go b/client/list.go index f163514f..0581e056 100644 --- a/client/list.go +++ b/client/list.go @@ -60,3 +60,17 @@ func (c meshListingClient) BuildingBlockDefinitionVersions(ctx context.Context, func (c meshListingClient) BuildingBlockRunLogs(ctx context.Context, runUuid string) (jsontext.Value, error) { return c.buildingBlockRun.GetLogsRaw(ctx, runUuid) } + +// MeshRunTriggerClient is a client of the CLI alone: it stays off [MeshBuildingBlockV2Client], whose +// every method the Terraform provider's mocks implement. +type MeshRunTriggerClient interface { + TriggerBuildingBlockRun(ctx context.Context, buildingBlockUuid string, request MeshBuildingBlockV2TriggerRunRequest) (jsontext.Value, error) +} + +type meshRunTriggerClient struct { + buildingBlockV2 meshBuildingBlockV2Client +} + +func (c meshRunTriggerClient) TriggerBuildingBlockRun(ctx context.Context, buildingBlockUuid string, request MeshBuildingBlockV2TriggerRunRequest) (jsontext.Value, error) { + return c.buildingBlockV2.TriggerRunWith(ctx, buildingBlockUuid, request) +} diff --git a/cmd/buildingblock/buildingblock.go b/cmd/buildingblock/buildingblock.go index 2d2eb38b..3398eec4 100644 --- a/cmd/buildingblock/buildingblock.go +++ b/cmd/buildingblock/buildingblock.go @@ -15,6 +15,7 @@ func New() *cobra.Command { } cmd.AddCommand(newList()) + cmd.AddCommand(newTriggerRun()) return cmd } diff --git a/cmd/buildingblock/trigger_run.go b/cmd/buildingblock/trigger_run.go new file mode 100644 index 00000000..52d2c217 --- /dev/null +++ b/cmd/buildingblock/trigger_run.go @@ -0,0 +1,54 @@ +package buildingblock + +import ( + "fmt" + "log/slog" + + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/cmd/internal" +) + +func newTriggerRun() *cobra.Command { + var ( + output internal.OutputFlag + dryRun bool + ) + + cmd := &cobra.Command{ + Use: "trigger-run ", + Short: "Ask meshStack to run a building block", + Long: `Ask meshStack to run a building block, and write the building block meshStack answers with. + +--dry-run asks for a dry (DETECT) run, which plans the block without changing it. + +The command does not wait for the run. meshStack starts it asynchronously, and holds it until a +person approves it in meshPanel if the definition asks for approval. The status.latestRunUuid and +status.latestDryRunUuid of the answer may therefore still name an older run.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + meshStack, err := internal.ResolveClient(ctx) + if err != nil { + return err + } + buildingBlockUuid := args[0] + buildingBlock, err := meshStack.RunTrigger.TriggerBuildingBlockRun(ctx, buildingBlockUuid, client.MeshBuildingBlockV2TriggerRunRequest{DryRun: dryRun}) + if err != nil { + return err + } + if err := internal.WriteItem(cmd.OutOrStdout(), output.Format, buildingBlock); err != nil { + return err + } + slog.InfoContext(ctx, fmt.Sprintf("meshStack accepted a run of building block %s. Follow it with `meshstack buildingblockrun list --building-block %s`", + buildingBlockUuid, buildingBlockUuid)) + return nil + }, + } + + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "ask for a dry (DETECT) run that changes nothing") + output.Register(cmd.Flags()) + + return cmd +} diff --git a/cmd/buildingblock/trigger_run_test.go b/cmd/buildingblock/trigger_run_test.go new file mode 100644 index 00000000..6219bcf4 --- /dev/null +++ b/cmd/buildingblock/trigger_run_test.go @@ -0,0 +1,72 @@ +package buildingblock_test + +import ( + "bytes" + "io" + gohttp "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/cmd/buildingblock" + "github.com/meshcloud/meshstack-cli/pkg/setting" +) + +const ( + buildingBlockUuid = "b1d2c3e4-0000-4000-8000-000000000001" + triggerRunPath = "/api/meshobjects/meshbuildingblocks/" + buildingBlockUuid + "/trigger-run" + // unexpiringJwt is an unsigned JWT that expires in 2100, so the CLI sends it rather than asking for a new one. + unexpiringJwt = "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9." +) + +func TestTriggerRunSendsDryRunAndWritesTheAnsweredBuildingBlock(t *testing.T) { + for _, tt := range []struct { + args []string + wantBody string + }{ + {args: nil, wantBody: `"dryRun":false`}, + {args: []string{"--dry-run"}, wantBody: `"dryRun":true`}, + } { + t.Run(tt.wantBody, func(t *testing.T) { + var requests []string + meshStack := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + requests = append(requests, r.Method+" "+r.URL.Path+" "+string(body)) + if r.Method != gohttp.MethodPost || r.URL.Path != triggerRunPath { + gohttp.NotFound(w, r) + return + } + w.WriteHeader(gohttp.StatusAccepted) + _, _ = io.WriteString(w, `{"metadata":{"uuid":"`+buildingBlockUuid+`"},"status":{"status":"PENDING","latestRunUuid":"r1"}}`) + })) + t.Cleanup(meshStack.Close) + useFakeMeshStack(t, meshStack.URL) + + var stdout bytes.Buffer + cmd := buildingblock.New() + cmd.SetArgs(append([]string{"trigger-run", buildingBlockUuid, "-o", "ndjson"}, tt.args...)) + cmd.SetOut(&stdout) + require.NoError(t, cmd.ExecuteContext(t.Context())) + + require.Len(t, requests, 1, "the command triggers the run and does nothing else, such as waiting for it") + assert.Contains(t, requests[0], "POST "+triggerRunPath) + assert.Contains(t, requests[0], tt.wantBody) + assert.Contains(t, stdout.String(), `"latestRunUuid":"r1"`) + }) + } +} + +func useFakeMeshStack(t *testing.T, endpoint string) { + t.Helper() + t.Setenv("MESHSTACK_CONFIG_DIR", t.TempDir()) + t.Setenv(setting.Endpoint.EnvKey(), endpoint) + t.Setenv(setting.SkipVersionCheck.EnvKey(), "true") + t.Setenv(setting.Profile.EnvKey(), "") + t.Setenv(setting.Workspace.EnvKey(), "") + t.Setenv(setting.ApiKeyClientId.EnvKey(), "") + t.Setenv(setting.ApiKeyClientSecret.EnvKey(), "") + t.Setenv(setting.ApiToken.EnvKey(), unexpiringJwt) +}