diff --git a/client/building_block_run.go b/client/building_block_run.go index 3ae2cdb..6ee02b8 100644 --- a/client/building_block_run.go +++ b/client/building_block_run.go @@ -12,6 +12,7 @@ type MeshBuildingBlockRun struct { Metadata MeshBuildingBlockRunMetadata `json:"metadata"` Spec MeshBuildingBlockRunSpec `json:"spec"` Status string `json:"status"` + Links MeshBuildingBlockRunLinks `json:"_links,omitzero"` } type MeshBuildingBlockRunMetadata struct { diff --git a/client/building_block_run_action.go b/client/building_block_run_action.go new file mode 100644 index 0000000..f9a5f3b --- /dev/null +++ b/client/building_block_run_action.go @@ -0,0 +1,78 @@ +package client + +import ( + "context" + "fmt" + "net/url" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/internal/http" +) + +// Link is a HAL link. meshStack sends the link of an action only while the caller may take it. +type Link struct { + Href string `json:"href"` +} + +type MeshBuildingBlockRunLinks struct { + Predecessor *Link `json:"predecessor"` + DownloadLogs *Link `json:"downloadLogs"` + Approve *Link `json:"approve"` + Abort *Link `json:"abort"` +} + +// MeshBuildingBlockV2RunLinks are not a field of MeshBuildingBlockV2, because the Terraform provider +// maps every field of that type to its schema. +type MeshBuildingBlockV2RunLinks struct { + LatestRun *Link `json:"latestRun"` + LatestDryRun *Link `json:"latestDryRun"` +} + +type MeshBuildingBlockRunActionClient struct { + httpClient internal.HttpClient + run internal.MeshObjectApi +} + +func newBuildingBlockRunActionClient(httpClient internal.HttpClient, run meshBuildingBlockRunClient) *MeshBuildingBlockRunActionClient { + return &MeshBuildingBlockRunActionClient{httpClient: httpClient, run: run.meshObject.MeshObjectApi} +} + +func (c *MeshBuildingBlockRunActionClient) ReadRun(ctx context.Context, link Link) (MeshBuildingBlockRun, error) { + return c.doAtLink[MeshBuildingBlockRun](ctx, http.MethodGet, link) +} + +func (c *MeshBuildingBlockRunActionClient) ReadLogs(ctx context.Context, link Link) (MeshBuildingBlockRunLogs, error) { + return c.doAtLink[MeshBuildingBlockRunLogs](ctx, http.MethodGet, link) +} + +// ApproveRun takes the predecessor because meshStack replaces the plan of a run that waits for +// approval when the run is planned again. The predecessor names the plan that is approved. +func (c *MeshBuildingBlockRunActionClient) ApproveRun(ctx context.Context, approve Link, predecessorRunUuid string) error { + _, err := c.doAtLink[[]byte](ctx, http.MethodPost, approve, http.WithJsonPayload(struct { + PredecessorRunUuid string `json:"predecessorRunUuid"` + }{predecessorRunUuid}, c.run.MeshObjectMimeType())) + return err +} + +func (c *MeshBuildingBlockRunActionClient) AbortRun(ctx context.Context, abort Link) error { + _, err := c.doAtLink[[]byte](ctx, http.MethodPost, abort) + return err +} + +// doAtLink sends the request only below the endpoint, so that the bearer token never leaves for +// another host that a link names. +func (c *MeshBuildingBlockRunActionClient) doAtLink[R any](ctx context.Context, method string, link Link, options ...http.RequestOption) (R, error) { + var noResult R + href, err := url.Parse(link.Href) + if err != nil { + return noResult, fmt.Errorf("meshStack sent the link %q, which is no URL: %w", link.Href, err) + } + target := c.httpClient.EndpointUrl.ResolveReference(href) + path, isBelowEndpoint := c.httpClient.EndpointUrl.PathTo(target) + if !isBelowEndpoint { + return noResult, fmt.Errorf("meshStack sent the link %s, which is not below the endpoint %s", target.Redacted(), c.httpClient.EndpointUrl) + } + below := c.httpClient.EndpointUrl.JoinPath(path) + below.RawQuery = target.RawQuery + return c.httpClient.DoRequest[R](ctx, method, below, append(options, http.WithAccept(c.run.MeshObjectMimeType()))...) +} diff --git a/client/building_block_run_action_test.go b/client/building_block_run_action_test.go new file mode 100644 index 0000000..1dfe5c5 --- /dev/null +++ b/client/building_block_run_action_test.go @@ -0,0 +1,37 @@ +package client + +import ( + "encoding/json/jsontext" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" +) + +func TestBuildingBlockRunActionClientSendsTheTokenOnlyBelowTheEndpoint(t *testing.T) { + const runUuid = "a0000000-0000-4000-8000-000000000001" + server := fakemeshstack.Start(t, fakemeshstack.Options{BuildingBlockRuns: []any{ + jsontext.Value(`{"metadata": {"uuid": "` + runUuid + `"}, "status": "IN_PROGRESS"}`), + }}) + httpClient := newTestHttpClient(server) + actions := newBuildingBlockRunActionClient(httpClient, newBuildingBlockRunClient(t.Context(), httpClient)) + + t.Run("a link below the endpoint is followed", func(t *testing.T) { + run, err := actions.ReadRun(t.Context(), Link{Href: server.URL + "/api/meshobjects/meshbuildingblockruns/" + runUuid}) + + require.NoError(t, err) + assert.Equal(t, runUuid, run.Metadata.Uuid) + server.TakeRequests() + }) + + t.Run("a link to another host is refused before anything is sent", func(t *testing.T) { + for _, href := range []string{"https://other.host/api/meshobjects/meshbuildingblockruns/" + runUuid, "//other.host/x"} { + err := actions.AbortRun(t.Context(), Link{Href: href}) + + require.ErrorContains(t, err, "which is not below the endpoint") + } + assert.Empty(t, server.TakeRequests()) + }) +} diff --git a/client/client.go b/client/client.go index 12ceb78..06a9010 100644 --- a/client/client.go +++ b/client/client.go @@ -24,6 +24,7 @@ type Client struct { BuildingBlock MeshBuildingBlockClient BuildingBlockV2 MeshBuildingBlockV2Client BuildingBlockRun MeshBuildingBlockRunClient + BuildingBlockRunAction *MeshBuildingBlockRunActionClient BuildingBlockDefinition MeshBuildingBlockDefinitionClient BuildingBlockDefinitionVersion MeshBuildingBlockDefinitionVersionClient BuildingBlockRunner MeshBuildingBlockRunnerClient @@ -65,6 +66,7 @@ func New(ctx context.Context, endpoint xurl.URL, userAgent string, auth Authoriz BuildingBlock: newBuildingBlockClient(ctx, authorizedClient), BuildingBlockV2: buildingBlockV2, BuildingBlockRun: buildingBlockRun, + BuildingBlockRunAction: newBuildingBlockRunActionClient(authorizedClient, buildingBlockRun), BuildingBlockDefinition: buildingBlockDefinition, BuildingBlockDefinitionVersion: buildingBlockDefinitionVersion, BuildingBlockRunner: newBuildingBlockRunnerClient(ctx, authorizedClient), diff --git a/cmd/buildingblock/abort_run.go b/cmd/buildingblock/abort_run.go new file mode 100644 index 0000000..a3cb7bc --- /dev/null +++ b/cmd/buildingblock/abort_run.go @@ -0,0 +1,86 @@ +package buildingblock + +import ( + "cmp" + "context" + "errors" + "fmt" + "log/slog" + gohttp "net/http" + "uuid" + + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/cmd/internal" +) + +func newAbortRun() *cobra.Command { + var ( + output internal.OutputFlag + buildingBlockUuid uuid.UUID + ) + + cmd := &cobra.Command{ + Use: "abort-run ", + Short: "Abort the latest run of a building block", + Long: `Abort the latest run of a building block, or its dry run when the dry run is newer. + +The command shows the run and asks you to confirm, so it runs only on a terminal. After the abort, +it writes the building block.`, + Example: ` meshstack buildingblock abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 + meshstack bb abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, + Args: internal.UuidArg(&buildingBlockUuid), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + action, err := newRunAction(cmd, buildingBlockUuid, output.Format) + if err != nil { + return err + } + return action.abort(ctx) + }, + } + + output.RegisterForItem(cmd.Flags()) + + return cmd +} + +func (a runAction) abort(ctx context.Context) error { + _, block, err := a.readBuildingBlock(ctx) + if err != nil { + return err + } + latest := cmp.Or(block.Links.LatestDryRun, block.Links.LatestRun) + if latest == nil { + return fmt.Errorf("building block %s has no run to abort", a.buildingBlockUuid) + } + run, err := a.meshStack.BuildingBlockRunAction.ReadRun(ctx, *latest) + if err != nil { + return err + } + if run.Links.Abort == nil { + return fmt.Errorf("building block %s has the status %s. Its run %s has the status %s, and it cannot be aborted, or you may not abort it", + a.buildingBlockUuid, block.Status.Status, run.Metadata.Uuid, run.Status) + } + if err = a.prompt.Printf("Run %d of building block %q is a %s run with the status %s. The building block has the status %s.\n", + run.Spec.RunNumber, run.Spec.BuildingBlock.Spec.DisplayName, run.Spec.Behavior, run.Status, block.Status.Status); err != nil { + return err + } + confirmed, err := a.prompt.Confirm(ctx, "Abort this run?") + if err != nil { + return err + } + if !confirmed { + slog.InfoContext(ctx, fmt.Sprintf("Did not abort run %s.", run.Metadata.Uuid)) + return nil + } + err = a.meshStack.BuildingBlockRunAction.AbortRun(ctx, *run.Links.Abort) + if httpErr, ok := errors.AsType[client.HttpError](err); ok && httpErr.StatusCode == gohttp.StatusBadRequest { + return fmt.Errorf("run %s can no longer be aborted", run.Metadata.Uuid) + } + if err != nil { + return err + } + return a.report(ctx, fmt.Sprintf("Asked meshStack to abort run %s.", run.Metadata.Uuid)) +} diff --git a/cmd/buildingblock/abort_run_internal_test.go b/cmd/buildingblock/abort_run_internal_test.go new file mode 100644 index 0000000..05a3ea8 --- /dev/null +++ b/cmd/buildingblock/abort_run_internal_test.go @@ -0,0 +1,52 @@ +package buildingblock + +import ( + gohttp "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" +) + +func TestAbortRunAbortsTheNewestRun(t *testing.T) { + const ( + latestRun = "a0000000-0000-4000-8000-000000000001" + dryRun = "a0000000-0000-4000-8000-000000000002" + ) + runOf := func(runUuid, behavior string) map[string]any { + return meshObject(t, `{"metadata": {"uuid": "`+runUuid+`"}, "status": "IN_PROGRESS", `+ + `"spec": {"runNumber": 1, "behavior": "`+behavior+`", "buildingBlock": {"uuid": "`+buildingBlockUuid+`", "spec": {"displayName": "my-block"}}}, `+ + `"_links": {"abort": {"href": "`+runsPath+runUuid+`/abort"}}}`) + } + block := meshObject(t, `{"metadata": {"uuid": "`+buildingBlockUuid+`"}, "status": {"status": "IN_PROGRESS"}, `+ + `"_links": {"latestRun": {"href": "`+runsPath+latestRun+`"}, "latestDryRun": {"href": "`+runsPath+dryRun+`"}}}`) + server := fakemeshstack.Start(t, fakemeshstack.Options{ + BuildingBlocks: []any{block}, + BuildingBlockRuns: []any{runOf(latestRun, "APPLY"), runOf(dryRun, "DETECT")}, + }) + var aborted []string + server.Route("POST "+runsPath+"{uuid}/abort", func(w gohttp.ResponseWriter, r *gohttp.Request) { + aborted = append(aborted, r.URL.Path) + w.WriteHeader(gohttp.StatusAccepted) + }) + + t.Run("a dry run that is newer than the latest run is the one aborted", func(t *testing.T) { + action, asked := newTestRunAction(t, server, "y\n") + + require.NoError(t, action.abort(t.Context())) + + assert.Equal(t, []string{runsPath + dryRun + "/abort"}, aborted) + assert.Contains(t, asked.String(), "is a DETECT run with the status IN_PROGRESS") + }) + + t.Run("without a newer dry run, the latest run is the one aborted", func(t *testing.T) { + removeLink(t, block, "latestDryRun") + action, _ := newTestRunAction(t, server, "y\n") + + require.NoError(t, action.abort(t.Context())) + + assert.Equal(t, runsPath+latestRun+"/abort", aborted[len(aborted)-1]) + }) +} diff --git a/cmd/buildingblock/approve_run.go b/cmd/buildingblock/approve_run.go new file mode 100644 index 0000000..8859934 --- /dev/null +++ b/cmd/buildingblock/approve_run.go @@ -0,0 +1,113 @@ +package buildingblock + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + gohttp "net/http" + "uuid" + + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/cmd/buildingblockrun" + "github.com/meshcloud/meshstack-cli/cmd/internal" +) + +func newApproveRun() *cobra.Command { + var ( + output internal.OutputFlag + buildingBlockUuid uuid.UUID + ) + + cmd := &cobra.Command{ + Use: "approve-run ", + Short: "Approve the plan of a building block run that waits for approval", + Long: `Approve the plan of the latest run of a building block, when the run waits for approval. + +The command shows the plan and asks you to approve it, so it runs only on a terminal. It approves +exactly the plan it showed. If meshStack planned the run again in the meantime, the approval fails, +and you can run the command again to review the new plan. + +"meshstack buildingblock list --status WAITING_FOR_APPROVAL" lists the building blocks with a run +that waits for approval. After the approval, the command writes the building block.`, + Example: ` meshstack buildingblock approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 + meshstack bb approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, + Args: internal.UuidArg(&buildingBlockUuid), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + action, err := newRunAction(cmd, buildingBlockUuid, output.Format) + if err != nil { + return err + } + return action.approve(ctx) + }, + } + + output.RegisterForItem(cmd.Flags()) + + return cmd +} + +func (a runAction) approve(ctx context.Context) error { + _, block, err := a.readBuildingBlock(ctx) + if err != nil { + return err + } + if block.Links.LatestRun == nil { + return fmt.Errorf("building block %s has no run to approve", a.buildingBlockUuid) + } + run, err := a.meshStack.BuildingBlockRunAction.ReadRun(ctx, *block.Links.LatestRun) + if err != nil { + return err + } + if run.Links.Approve == nil { + return fmt.Errorf("building block %s has the status %s. Its latest run %s does not wait for approval, or you may not approve it", + a.buildingBlockUuid, block.Status.Status, run.Metadata.Uuid) + } + if run.Links.Predecessor == nil { + return fmt.Errorf("run %s waits for approval, but meshStack names no run that planned it", run.Metadata.Uuid) + } + plan, err := a.meshStack.BuildingBlockRunAction.ReadRun(ctx, *run.Links.Predecessor) + if err != nil { + return err + } + if plan.Links.DownloadLogs == nil { + return fmt.Errorf("meshStack shows you no logs of run %s, which planned run %s", plan.Metadata.Uuid, run.Metadata.Uuid) + } + logs, err := a.meshStack.BuildingBlockRunAction.ReadLogs(ctx, *plan.Links.DownloadLogs) + if err != nil { + return err + } + var shown bytes.Buffer + if err = buildingblockrun.WriteLogs(&shown, logs); err != nil { + return err + } + if err = a.prompt.Printf("Run %d of building block %q waits for approval of the plan of run %s:\n\n%s\n", + run.Spec.RunNumber, run.Spec.BuildingBlock.Spec.DisplayName, plan.Metadata.Uuid, shown.String()); err != nil { + return err + } + approved, err := a.prompt.Confirm(ctx, "Approve this plan?") + if err != nil { + return err + } + if !approved { + slog.InfoContext(ctx, fmt.Sprintf("Did not approve run %s.", run.Metadata.Uuid)) + return nil + } + err = a.meshStack.BuildingBlockRunAction.ApproveRun(ctx, *run.Links.Approve, plan.Metadata.Uuid) + if httpErr, ok := errors.AsType[client.HttpError](err); ok { + switch httpErr.StatusCode { + case gohttp.StatusConflict: + return fmt.Errorf("the plan of run %s changed after you saw it. Run the command again to review the new plan", run.Metadata.Uuid) + case gohttp.StatusBadRequest: + return fmt.Errorf("run %s no longer waits for approval", run.Metadata.Uuid) + } + } + if err != nil { + return err + } + return a.report(ctx, fmt.Sprintf("Approved the plan of run %s.", run.Metadata.Uuid)) +} diff --git a/cmd/buildingblock/approve_run_internal_test.go b/cmd/buildingblock/approve_run_internal_test.go new file mode 100644 index 0000000..3b98530 --- /dev/null +++ b/cmd/buildingblock/approve_run_internal_test.go @@ -0,0 +1,120 @@ +package buildingblock + +import ( + "bytes" + "encoding/json/v2" + "io" + gohttp "net/http" + "strings" + "testing" + "uuid" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/cmd/internal/prompt" + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" + "github.com/meshcloud/meshstack-cli/internal/testutil/testlogin" +) + +const ( + runsPath = "/api/meshobjects/meshbuildingblockruns/" + buildingBlockUuid = "b1d2c3e4-0000-4000-8000-000000000001" +) + +func meshObject(t *testing.T, content string) map[string]any { + t.Helper() + var object map[string]any + require.NoError(t, json.Unmarshal([]byte(content), &object)) + return object +} + +func removeLink(t *testing.T, object map[string]any, rel string) { + t.Helper() + links, ok := object["_links"].(map[string]any) + require.True(t, ok) + delete(links, rel) +} + +// newTestRunAction answers the prompt line by line, as no test has a terminal. +func newTestRunAction(t *testing.T, server *fakemeshstack.Server, answers string) (runAction, *bytes.Buffer) { + t.Helper() + testlogin.LoggedInTo(t, server.URL) + meshStack, err := internal.ResolveClient(t.Context()) + require.NoError(t, err) + var asked bytes.Buffer + return runAction{ + meshStack: meshStack, prompt: prompt.New(strings.NewReader(answers), &asked), buildingBlockUuid: uuid.MustParse(buildingBlockUuid), + out: io.Discard, format: internal.OutputJson, + }, &asked +} + +func TestApproveRunApprovesOnlyThePlanItShowed(t *testing.T) { + const ( + waitingRun = "a0000000-0000-4000-8000-000000000002" + planRun = "a0000000-0000-4000-8000-000000000001" + ) + run := meshObject(t, `{"metadata": {"uuid": "`+waitingRun+`"}, "status": "IN_PROGRESS", `+ + `"spec": {"runNumber": 2, "behavior": "APPLY", "buildingBlock": {"uuid": "`+buildingBlockUuid+`", "spec": {"displayName": "my-block"}}}, `+ + `"_links": {"predecessor": {"href": "`+runsPath+planRun+`"}, "approve": {"href": "`+runsPath+waitingRun+`/approve"}}}`) + server := fakemeshstack.Start(t, fakemeshstack.Options{ + BuildingBlocks: []any{meshObject(t, `{"metadata": {"uuid": "`+buildingBlockUuid+`"}, "status": {"status": "WAITING_FOR_APPROVAL"}, `+ + `"_links": {"latestRun": {"href": "`+runsPath+waitingRun+`"}}}`)}, + BuildingBlockRuns: []any{run, meshObject(t, `{"metadata": {"uuid": "`+planRun+`"}, "status": "SUCCEEDED", `+ + `"_links": {"downloadLogs": {"href": "`+runsPath+planRun+`/logs"}}}`)}, + }) + server.Route("GET "+runsPath+planRun+"/logs", func(w gohttp.ResponseWriter, _ *gohttp.Request) { + _, _ = io.WriteString(w, `{"steps": [{"displayName": "Plan", "status": "SUCCEEDED", "userMessage": "+ resource \"x\" will be created"}]}`) + }) + var approvals []string + approveAnswer := gohttp.StatusAccepted + server.Route("POST "+runsPath+waitingRun+"/approve", func(w gohttp.ResponseWriter, r *gohttp.Request) { + body, _ := io.ReadAll(r.Body) + approvals = append(approvals, string(body)) + w.WriteHeader(approveAnswer) + }) + + t.Run("without a terminal, nothing is asked and nothing is approved", func(t *testing.T) { + testlogin.LoggedInTo(t, server.URL) + cmd := New() + cmd.SetIn(strings.NewReader("y\n")) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"approve-run", buildingBlockUuid}) + + require.EqualError(t, cmd.ExecuteContext(t.Context()), "approve-run asks you before it acts, so it runs only on a terminal") + assert.Empty(t, server.TakeRequests()) + }) + + t.Run("a plan that is not approved stays waiting", func(t *testing.T) { + action, asked := newTestRunAction(t, server, "n\n") + + require.NoError(t, action.approve(t.Context())) + assert.Contains(t, asked.String(), `Plan | + resource "x" will be created`) + assert.True(t, strings.HasSuffix(asked.String(), "Approve this plan? [y/N]: "), asked.String()) + assert.Empty(t, approvals) + }) + + t.Run("an approval names the run whose plan was shown", func(t *testing.T) { + action, _ := newTestRunAction(t, server, "y\n") + + require.NoError(t, action.approve(t.Context())) + require.Len(t, approvals, 1) + assert.JSONEq(t, `{"predecessorRunUuid": "`+planRun+`"}`, approvals[0]) + }) + + t.Run("a plan that changed after it was shown has to be reviewed again", func(t *testing.T) { + approveAnswer = gohttp.StatusConflict + action, _ := newTestRunAction(t, server, "y\n") + + require.EqualError(t, action.approve(t.Context()), "the plan of run "+waitingRun+" changed after you saw it. Run the command again to review the new plan") + }) + + t.Run("a run without an approve link shows no plan", func(t *testing.T) { + removeLink(t, run, "approve") + action, asked := newTestRunAction(t, server, "y\n") + + require.ErrorContains(t, action.approve(t.Context()), "Its latest run "+waitingRun+" does not wait for approval") + assert.Empty(t, asked.String()) + }) +} diff --git a/cmd/buildingblock/buildingblock.go b/cmd/buildingblock/buildingblock.go index 0f0e5c0..41b0c02 100644 --- a/cmd/buildingblock/buildingblock.go +++ b/cmd/buildingblock/buildingblock.go @@ -1,10 +1,20 @@ package buildingblock import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "io" + "log/slog" + "uuid" + "github.com/spf13/cobra" + "github.com/meshcloud/meshstack-cli/client" "github.com/meshcloud/meshstack-cli/cmd/buildingblocktfstate" "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/cmd/internal/prompt" ) func New() *cobra.Command { @@ -23,7 +33,58 @@ func New() *cobra.Command { cmd.AddCommand(newList()) cmd.AddCommand(newTriggerRun()) + cmd.AddCommand(newApproveRun()) + cmd.AddCommand(newAbortRun()) cmd.AddCommand(buildingblocktfstate.New()) return cmd } + +type runAction struct { + meshStack client.Client + prompt prompt.Prompt + buildingBlockUuid uuid.UUID + out io.Writer + format internal.OutputFormat +} + +func newRunAction(cmd *cobra.Command, buildingBlockUuid uuid.UUID, format internal.OutputFormat) (runAction, error) { + p := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()) + // A script cannot approve or abort: the decision belongs to a person who saw the run. + if !p.UsesTerminal() { + return runAction{}, fmt.Errorf("%s asks you before it acts, so it runs only on a terminal", cmd.Name()) + } + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return runAction{}, err + } + return runAction{meshStack: meshStack, prompt: p, buildingBlockUuid: buildingBlockUuid, out: cmd.OutOrStdout(), format: format}, nil +} + +type linkedBuildingBlock struct { + Status client.MeshBuildingBlockV2Status `json:"status"` + Links client.MeshBuildingBlockV2RunLinks `json:"_links"` +} + +func (a runAction) readBuildingBlock(ctx context.Context) (jsontext.Value, linkedBuildingBlock, error) { + var block linkedBuildingBlock + raw, err := a.meshStack.Raw.Get[client.MeshBuildingBlockV2](ctx, a.buildingBlockUuid) + if err != nil { + return nil, block, err + } + return raw, block, json.Unmarshal(raw, &block) +} + +// report reads the building block again and writes it, because a run hides the statuses +// WAITING_FOR_APPROVAL and ABORTED that only the building block shows. +func (a runAction) report(ctx context.Context, done string) error { + raw, block, err := a.readBuildingBlock(ctx) + if err != nil { + return err + } + if err := a.format.WriteItem(a.out, raw); err != nil { + return err + } + slog.InfoContext(ctx, fmt.Sprintf("%s Building block %s now has the status %s.", done, a.buildingBlockUuid, block.Status.Status)) + return nil +} diff --git a/cmd/buildingblockrun/logs.go b/cmd/buildingblockrun/logs.go index 984db8a..bd788e6 100644 --- a/cmd/buildingblockrun/logs.go +++ b/cmd/buildingblockrun/logs.go @@ -182,6 +182,11 @@ func (run runSnapshot) failure(runUuid uuid.UUID) error { return fmt.Errorf("building block run %s failed", runUuid) } +func WriteLogs(w io.Writer, logs client.MeshBuildingBlockRunLogs) error { + var written writtenLogs + return written.writeNew(w, logs.Steps) +} + // writtenLogs is what --follow has written of each step, matched to the next read by position, // because a step in the logs answer has no id. type writtenLogs []writtenStep diff --git a/cmd/internal/prompt/prompt.go b/cmd/internal/prompt/prompt.go index 7a13c63..87ebcc4 100644 --- a/cmd/internal/prompt/prompt.go +++ b/cmd/internal/prompt/prompt.go @@ -112,6 +112,18 @@ func (p Prompt) Ask(ctx context.Context, what, defaultAnswer string, validate fu } } +func (p Prompt) Confirm(ctx context.Context, question string) (bool, error) { + if err := p.Printf("%s [y/N]: ", question); err != nil { + return false, err + } + answer, err := p.Next(ctx, "confirmation") + if err != nil { + return false, err + } + answer = strings.ToLower(answer) + return answer == "y" || answer == "yes", nil +} + func (p Prompt) Printf(format string, args ...any) (err error) { _, err = fmt.Fprintf(p.out, format, args...) return diff --git a/cmd/profile/delete.go b/cmd/profile/delete.go index 89feb23..fee8433 100644 --- a/cmd/profile/delete.go +++ b/cmd/profile/delete.go @@ -3,7 +3,6 @@ package profile import ( "fmt" "log/slog" - "strings" "github.com/spf13/cobra" @@ -34,14 +33,11 @@ deletes, unless --yes goes with --profile, or with an --endpoint that only one p return err } if !yesFlag.Value || !named { - if err := p.Printf("Delete profile '%s' and its stored credentials? [y/N]: ", deleted.Name); err != nil { - return err - } - answer, err := p.Next(ctx, "confirmation") + confirmed, err := p.Confirm(ctx, fmt.Sprintf("Delete profile '%s' and its stored credentials?", deleted.Name)) if err != nil { return err } - if !confirmed(answer) { + if !confirmed { slog.InfoContext(ctx, fmt.Sprintf("Kept profile '%s'.", deleted.Name)) return nil } @@ -57,8 +53,3 @@ deletes, unless --yes goes with --profile, or with an --endpoint that only one p yesFlag.Register(cmd.Flags()) return cmd } - -func confirmed(answer string) bool { - answer = strings.ToLower(strings.TrimSpace(answer)) - return answer == "y" || answer == "yes" -}