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
4 changes: 2 additions & 2 deletions .claude/skills/development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ git log -- client/client.go client.go # a path-limited log from client/ alone
git blame client/client.go # traverses the merge on its own
```

**`client/` does not log in.** `client.Authorization` produces a bearer token and replaces one that
came back 401; resolving a credential, minting a token, caching it and refreshing it is `pkg/auth`.
**`client/` does not log in.** `client.Authorization` produces a credential and replaces one that came
back 401; resolving a credential, minting a token, caching it and refreshing it is `pkg/auth`.
Both front ends build their client through `auth.ResolveClient`, so the endpoint and the
authorization always agree with what was resolved. Do **not** add a login exchange anywhere else: a
second one gets a static token and starts returning 401 once it expires, and for a browser login it
Expand Down
2 changes: 2 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Client struct {
WorkspaceGroupBinding MeshWorkspaceGroupBindingClient
WorkspaceUserBinding MeshWorkspaceUserBindingClient
Raw *RawClient
Runner MeshRunnerClient

// Endpoint is read by the Terraform provider's meshstack_instance data source.
Endpoint xurl.URL
Expand Down Expand Up @@ -90,6 +91,7 @@ func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Author
with(buildingBlockDefinition.meshObject).
withOwnOrder(buildingBlockDefinitionVersion.meshObject).
with(newEventLogMeshObject(ctx, authorizedClient)),
Runner: newRunnerClient(authorizedClient),

Endpoint: endpoint,
}
Expand Down
362 changes: 362 additions & 0 deletions client/runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,362 @@
package client

import (
"context"
"encoding/json/jsontext"
"errors"
"fmt"
"io"
gohttp "net/http"
"net/url"
"strings"
"time"
"uuid"

"github.com/meshcloud/meshstack-cli/client/internal"
"github.com/meshcloud/meshstack-cli/client/types/xurl"
"github.com/meshcloud/meshstack-cli/internal/http"
"github.com/meshcloud/meshstack-cli/internal/json"
)

type MeshRunnerRun struct {
ApiVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata MeshRunnerRunMetadata `json:"metadata"`
Spec MeshRunnerRunSpec `json:"spec"`
Status string `json:"status"`
Links MeshRunnerRunLinks `json:"_links"`
}

type MeshRunnerRunMetadata struct {
Uuid uuid.UUID `json:"uuid"`
}

type MeshRunnerRunSpec struct {
RunNumber int `json:"runNumber"`
Behavior string `json:"behavior"`
BuildingBlock MeshRunnerRunBuildingBlock `json:"buildingBlock"`
Definition MeshRunnerRunDefinition `json:"buildingBlockDefinition"`
RunToken string `json:"runToken"`
Trigger *MeshRunnerRunTrigger `json:"trigger,omitzero"`
}

type MeshRunnerRunBuildingBlock struct {
Uuid string `json:"uuid"`
Spec MeshRunnerRunBuildingBlockSpec `json:"spec"`
}

type MeshRunnerRunBuildingBlockSpec struct {
DisplayName string `json:"displayName"`
WorkspaceIdentifier string `json:"workspaceIdentifier"`
ProjectIdentifier string `json:"projectIdentifier,omitempty"`
FullPlatformIdentifier string `json:"fullPlatformIdentifier,omitempty"`
Inputs []MeshRunnerRunInput `json:"inputs"`
ParentBuildingBlocks []MeshRunnerRunParentBlock `json:"parentBuildingBlocks"`
}

type MeshRunnerRunParentBlock struct {
BuildingBlockUuid string `json:"buildingBlockUuid"`
DefinitionUuid string `json:"definitionUuid"`
}

type MeshRunnerRunInput struct {
Key string `json:"key"`
// Value stays raw JSON, so that an INTEGER beyond float64 or int64 keeps every digit.
Value jsontext.Value `json:"value"`
Type string `json:"type"`
IsSensitive bool `json:"isSensitive"`
Env bool `json:"isEnvironment"`
}

type MeshRunnerRunDefinition struct {
Uuid string `json:"uuid"`
Spec MeshRunnerRunDefinitionSpec `json:"spec"`
}

type MeshRunnerRunDefinitionSpec struct {
WorkspaceIdentifier string `json:"workspaceIdentifier"`
Version int `json:"version"`
Implementation jsontext.Value `json:"implementation"`
}

func (d MeshRunnerRunDefinitionSpec) ImplementationType() (MeshRunnerImplementationType, error) {
var implementation struct {
Type MeshRunnerImplementationType `json:"type"`
}
if err := json.Unmarshal(d.Implementation, &implementation); err != nil {
return "", fmt.Errorf("cannot read the implementation type: %w", err)
}
return implementation.Type, nil
}

type MeshRunnerRunTrigger struct {
Reason string `json:"reason"`
Author MeshRunnerRunAuthor `json:"author"`
}

type MeshRunnerRunAuthor struct {
Identifier string `json:"identifier"`
Type string `json:"type"`
DisplayName *string `json:"displayName"`
Username *string `json:"username"`
Email *string `json:"email"`
Euid *string `json:"euid"`
}

type MeshRunnerRunLinks struct {
Self MeshRunnerRunLink `json:"self"`
RegisterSource MeshRunnerRunLink `json:"registerSource"`
UpdateSource MeshRunnerRunLink `json:"updateSource"`
MeshstackBaseUrl MeshRunnerRunLink `json:"meshstackBaseUrl"`
// PlanArtifact is set only when an APPLY run applies the plan of its DETECT run.
PlanArtifact MeshRunnerRunLink `json:"planArtifact,omitzero"`
ArtifactUpload MeshRunnerRunLink `json:"artifactUpload,omitzero"`
// TfStateLock is empty on a meshStack that cannot lock the state.
TfStateLock MeshRunnerRunLink `json:"tfStateLock,omitzero"`
}

type MeshRunnerRunLink struct {
Href string `json:"href"`
Templated bool `json:"templated,omitzero"`
}

func (l MeshRunnerRunLink) parse(name string) (*url.URL, error) {
if l.Href == "" {
return nil, fmt.Errorf("the run has no %s link", name)
}
return url.Parse(l.Href)
}

type MeshRunnerImplementationType string

const (
MeshRunnerImplementationTypeManual MeshRunnerImplementationType = "MANUAL"
MeshRunnerImplementationTypeTerraform MeshRunnerImplementationType = "TERRAFORM"
MeshRunnerImplementationTypeGithubWorkflow MeshRunnerImplementationType = "GITHUB_WORKFLOW"
MeshRunnerImplementationTypeGitlabCiCd MeshRunnerImplementationType = "GITLAB_CICD"
MeshRunnerImplementationTypeAzureDevops MeshRunnerImplementationType = "AZURE_DEVOPS"
)

type MeshRunnerTerraformImplementation struct {
Type string `json:"type"`
TerraformVersion string `json:"terraformVersion"`
RepositoryUrl string `json:"repositoryUrl"`
RepositoryPath *string `json:"repositoryPath,omitzero"`
RefName *string `json:"refName,omitzero"`
SshPrivateKey *string `json:"sshPrivateKey,omitzero"`
KnownHost *MeshRunnerKnownHost `json:"knownHost,omitzero"`
Async bool `json:"async"`
UseMeshHttpBackendFallback bool `json:"useMeshHttpBackendFallback"`
PreRunScript *string `json:"preRunScript,omitzero"`
}

type MeshRunnerKnownHost struct {
Host string `json:"host"`
KeyType string `json:"keyType"`
KeyValue string `json:"keyValue"`
}

type MeshRunnerGithubImplementation struct {
Type string `json:"type"`
GithubBaseUrl string `json:"githubBaseUrl"`
Owner string `json:"owner"`
AppId string `json:"appId"`
AppPem string `json:"appPem"`
Repository string `json:"repository"`
Branch string `json:"branch"`
ApplyWorkflow string `json:"applyWorkflow"`
DestroyWorkflow *string `json:"destroyWorkflow,omitzero"`
Async bool `json:"async"`
OmitRunObjectInput bool `json:"omitRunObjectInput"`
}

type MeshRunnerGitlabImplementation struct {
Type string `json:"type"`
GitlabBaseUrl string `json:"gitlabBaseUrl"`
ProjectId string `json:"projectId"`
RefName string `json:"refName"`
PipelineTriggerToken string `json:"pipelineTriggerToken"`
}

type MeshRunnerAzureDevopsImplementation struct {
Type string `json:"type"`
AzureDevOpsBaseUrl string `json:"azureDevOpsBaseUrl"`
Organization string `json:"organization"`
Project string `json:"project"`
PipelineId string `json:"pipelineId"`
PersonalAccessToken string `json:"personalAccessToken"`
Async bool `json:"async"`
RefName *string `json:"refName,omitzero"`
}

type MeshRunnerRunSourceRegistration struct {
Source MeshRunnerRunSource `json:"source"`
Steps []MeshRunnerRunStepRegistration `json:"steps"`
}

type MeshRunnerRunSource struct {
Id uuid.UUID `json:"id"`
ExternalId *string `json:"externalId,omitzero"`
ExternalUrl *string `json:"externalUrl,omitzero"`
}

type MeshRunnerRunStepRegistration struct {
Id string `json:"id"`
DisplayName string `json:"displayName"`
Status *string `json:"status"`
}

type MeshRunnerRunStatusUpdate struct {
BlockRunId uuid.UUID `json:"blockRunId,omitzero"`
Source uuid.UUID `json:"source,omitzero"`
Type MeshRunnerImplementationType `json:"type,omitempty"`
Status *string `json:"status"`
CreatedOn time.Time `json:"createdOn,omitzero"`
Summary *string `json:"summary"`
Steps []MeshRunnerRunStepStatusUpdate `json:"steps"`
ChangesDetected *bool `json:"changesDetected,omitzero"`
}

type MeshRunnerRunStepStatusUpdate struct {
Id string `json:"id"`
DisplayName string `json:"displayName"`
Status *string `json:"status,omitzero"`
UserMessage *string `json:"userMessage,omitzero"`
SystemMessage *string `json:"systemMessage,omitzero"`
Outputs map[string]MeshRunnerRunOutput `json:"outputs,omitempty"`
}

type MeshRunnerRunOutput struct {
Value any `json:"value"`
Type string `json:"type"`
Sensitive bool `json:"isSensitive"`
}

type MeshRunnerRunStatusUpdateResult struct {
Abort bool `json:"runAborted"`
}

const (
runMediaType = "application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json"

maxArtifactBytes = 128 << 20
)

var ErrNoRunToClaim = errors.New("no run to claim")

type MeshRunnerClient interface {
ClaimRun(ctx context.Context, runnerUuid uuid.UUID) (MeshRunnerRun, error)
// ForRun authorizes with the run's token, which every request about the run needs.
ForRun(run MeshRunnerRun) MeshRunnerRunClient
}

type MeshRunnerRunClient interface {
RegisterSource(ctx context.Context, registration MeshRunnerRunSourceRegistration) error
PatchStatus(ctx context.Context, sourceId uuid.UUID, update MeshRunnerRunStatusUpdate) (MeshRunnerRunStatusUpdateResult, error)
DownloadPlanArtifact(ctx context.Context, w io.Writer) error
UploadArtifact(ctx context.Context, path string) error
}

func newRunnerClient(httpClient internal.HttpClient) MeshRunnerClient {
return meshRunnerClient{runnerHttpClient{httpClient.AuthorizedClient}, httpClient.EndpointUrl}
}

type meshRunnerClient struct {
http runnerHttpClient
endpoint xurl.URL
}

func (c meshRunnerClient) ClaimRun(ctx context.Context, runnerUuid uuid.UUID) (MeshRunnerRun, error) {
claimUrl := c.endpoint.JoinPath("api", "meshobjects", "meshbuildingblockruns", "create")
claimUrl.RawQuery = url.Values{"forRunnerUuid": {runnerUuid.String()}}.Encode()
run, err := c.http.do[MeshRunnerRun](ctx, http.MethodPost, claimUrl, http.WithHeaders(gohttp.Header{
"Accept": {runMediaType},
"Content-Type": {runMediaType},
}))
if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsNotFound() {
return run, ErrNoRunToClaim
}
if err == nil && run.Metadata.Uuid == (uuid.UUID{}) {
err = errors.New("the claimed run has no uuid")
}
return run, err
}

func (c meshRunnerClient) ForRun(run MeshRunnerRun) MeshRunnerRunClient {
runToken := http.BearerToken(run.Spec.RunToken)
return meshRunnerRunClient{runnerHttpClient{c.http.WithAuthorization(runToken)}, run.Links}
}

type meshRunnerRunClient struct {
http runnerHttpClient
links MeshRunnerRunLinks
}

func (c meshRunnerRunClient) RegisterSource(ctx context.Context, registration MeshRunnerRunSourceRegistration) error {
registerUrl, err := c.links.RegisterSource.parse("registerSource")
if err != nil {
return err
}
_, err = c.http.do[[]byte](ctx, http.MethodPost, registerUrl, http.WithJsonPayload(registration, runMediaType), http.Retryable())
if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsConflict() {
return nil
}
return err
}

func (c meshRunnerRunClient) PatchStatus(ctx context.Context, sourceId uuid.UUID, update MeshRunnerRunStatusUpdate) (MeshRunnerRunStatusUpdateResult, error) {
updateSource := MeshRunnerRunLink{Href: strings.ReplaceAll(c.links.UpdateSource.Href, "{sourceId}", sourceId.String())}
updateUrl, err := updateSource.parse("updateSource")
if err != nil {
return MeshRunnerRunStatusUpdateResult{}, err
}
return c.http.do[MeshRunnerRunStatusUpdateResult](ctx, http.MethodPatch, updateUrl, http.WithJsonPayload(update, runMediaType), http.Retryable())
}

func (c meshRunnerRunClient) DownloadPlanArtifact(ctx context.Context, w io.Writer) error {
downloadUrl, err := c.links.PlanArtifact.parse("planArtifact")
if err != nil {
return err
}
capped := &cappedWriter{w, maxArtifactBytes}
return c.http.DoStreamingRequest(ctx, http.MethodGet, downloadUrl, capped, c.http.version(), http.WithAccept("application/octet-stream"))
}

func (c meshRunnerRunClient) UploadArtifact(ctx context.Context, path string) error {
uploadUrl, err := c.links.ArtifactUpload.parse("artifactUpload")
if err != nil {
return err
}
_, err = c.http.do[[]byte](ctx, http.MethodPut, uploadUrl, http.WithBodyFile(path), http.WithHeaders(gohttp.Header{
"Accept": {"*/*"},
"Content-Type": {"application/octet-stream"},
}))
return err
}

type runnerHttpClient struct {
http.AuthorizedClient
}

func (c runnerHttpClient) do[R any](ctx context.Context, method string, url *url.URL, options ...http.RequestOption) (R, error) {
return c.DoRequest[R](ctx, method, url, append(options, c.version())...)
}

func (c runnerHttpClient) version() http.RequestOption {
return http.WithHeaders(gohttp.Header{"X-Meshcloud-Runner-Version": {c.UserAgent.Version}})
}

// cappedWriter keeps a misbehaving server from filling the disk.
type cappedWriter struct {
io.Writer

remaining int64
}

func (w *cappedWriter) Write(p []byte) (int, error) {
if int64(len(p)) > w.remaining {
return 0, fmt.Errorf("the artifact exceeds %d bytes", maxArtifactBytes)
}
w.remaining -= int64(len(p))
return w.Writer.Write(p)
}
Loading
Loading