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
1 change: 1 addition & 0 deletions client/building_block_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions client/building_block_run_action.go
Original file line number Diff line number Diff line change
@@ -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()))...)
}
37 changes: 37 additions & 0 deletions client/building_block_run_action_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
2 changes: 2 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Client struct {
BuildingBlock MeshBuildingBlockClient
BuildingBlockV2 MeshBuildingBlockV2Client
BuildingBlockRun MeshBuildingBlockRunClient
BuildingBlockRunAction *MeshBuildingBlockRunActionClient
BuildingBlockDefinition MeshBuildingBlockDefinitionClient
BuildingBlockDefinitionVersion MeshBuildingBlockDefinitionVersionClient
BuildingBlockRunner MeshBuildingBlockRunnerClient
Expand Down Expand Up @@ -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),
Expand Down
86 changes: 86 additions & 0 deletions cmd/buildingblock/abort_run.go
Original file line number Diff line number Diff line change
@@ -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 <building-block-uuid>",
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))
}
52 changes: 52 additions & 0 deletions cmd/buildingblock/abort_run_internal_test.go
Original file line number Diff line number Diff line change
@@ -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])
})
}
113 changes: 113 additions & 0 deletions cmd/buildingblock/approve_run.go
Original file line number Diff line number Diff line change
@@ -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 <building-block-uuid>",
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))
}
Loading
Loading