471 lines
15 KiB
Go
471 lines
15 KiB
Go
// Package workspaceops implements the closed host-side workspace preprocessing contract.
|
|
package workspaceops
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
)
|
|
|
|
var workspacePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{2,62}$`)
|
|
|
|
type Runner interface {
|
|
Run(context.Context, []string, io.Reader) (compose.Result, error)
|
|
}
|
|
|
|
type Request interface {
|
|
workspaceRequest()
|
|
workspaceID() string
|
|
JSONMode() bool
|
|
operatorCommand() string
|
|
stdinEnvelope() (requestEnvelope, error)
|
|
}
|
|
|
|
type baseRequest struct {
|
|
Workspace string
|
|
JSON bool
|
|
}
|
|
|
|
func (b baseRequest) workspaceID() string { return b.Workspace }
|
|
func (b baseRequest) JSONMode() bool { return b.JSON }
|
|
|
|
type InspectRequest struct{ baseRequest }
|
|
|
|
type RunRequest struct{ baseRequest }
|
|
type ClearRequest struct{ baseRequest }
|
|
|
|
func (InspectRequest) workspaceRequest() {}
|
|
func (RunRequest) workspaceRequest() {}
|
|
func (ClearRequest) workspaceRequest() {}
|
|
|
|
func (InspectRequest) operatorCommand() string { return "inspect" }
|
|
func (RunRequest) operatorCommand() string { return "preprocess-run" }
|
|
func (ClearRequest) operatorCommand() string { return "preprocess-clear" }
|
|
|
|
func (r InspectRequest) stdinEnvelope() (requestEnvelope, error) {
|
|
return requestEnvelope{SchemaVersion: 1, WorkspaceID: r.Workspace}, nil
|
|
}
|
|
|
|
func (r RunRequest) stdinEnvelope() (requestEnvelope, error) {
|
|
return requestEnvelope{SchemaVersion: 1, WorkspaceID: r.Workspace}, nil
|
|
}
|
|
|
|
func (r ClearRequest) stdinEnvelope() (requestEnvelope, error) {
|
|
return requestEnvelope{SchemaVersion: 1, WorkspaceID: r.Workspace}, nil
|
|
}
|
|
|
|
type requestEnvelope struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
WorkspaceID string `json:"workspaceId"`
|
|
}
|
|
|
|
type Result struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Status string `json:"status"`
|
|
Code string `json:"code"`
|
|
WorkspaceID string `json:"workspaceId"`
|
|
WorkspaceRevision string `json:"workspaceRevision"`
|
|
DescriptorBlob string `json:"descriptorBlob"`
|
|
Operation string `json:"operation"`
|
|
RunID string `json:"runId,omitempty"`
|
|
ChildRuns map[string]string `json:"childRuns,omitempty"`
|
|
CompletedStages []string `json:"completedStages"`
|
|
Counts map[string]int `json:"counts,omitempty"`
|
|
ArtifactIdentities []ArtifactIdentity `json:"artifactIdentities,omitempty"`
|
|
EffectiveConfigIdentity string `json:"effectiveConfigIdentity,omitempty"`
|
|
ConfigFingerprint string `json:"configFingerprint,omitempty"`
|
|
InputFingerprint string `json:"inputFingerprint,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
}
|
|
|
|
type ArtifactIdentity struct {
|
|
Kind string `json:"kind"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
type operationResponse struct {
|
|
Result
|
|
}
|
|
|
|
type Stage string
|
|
|
|
type ExitClass string
|
|
|
|
const (
|
|
StageRenderedConfig Stage = "rendered-config"
|
|
StageImageInspect Stage = "image-inspect"
|
|
StageComposeRun Stage = "compose-run"
|
|
|
|
ExitClassNonzero ExitClass = "nonzero-exit"
|
|
ExitClassUnavailable ExitClass = "unavailable"
|
|
ExitClassTimeout ExitClass = "timeout"
|
|
ExitClassInvocation ExitClass = "invocation-failure"
|
|
)
|
|
|
|
type OperationError struct {
|
|
stage Stage
|
|
class ExitClass
|
|
detail string
|
|
}
|
|
|
|
func (e *OperationError) Error() string {
|
|
detail := strings.TrimSpace(e.detail)
|
|
if len(detail) > 0 {
|
|
// Bounded, sanitized operator/container detail so operators can diagnose failures
|
|
// without leaking secrets; the full renderer sanitizes further before output.
|
|
if len(detail) > 2048 {
|
|
detail = detail[:2048]
|
|
}
|
|
return fmt.Sprintf("stage=%s class=%s: %s", e.stage, e.class, detail)
|
|
}
|
|
return fmt.Sprintf("stage=%s class=%s", e.stage, e.class)
|
|
}
|
|
|
|
func (e *OperationError) Stage() Stage { return e.stage }
|
|
func (e *OperationError) Class() ExitClass { return e.class }
|
|
func (e *OperationError) Detail() string { return e.detail }
|
|
|
|
func Parse(args []string) (Request, error) {
|
|
if len(args) == 0 {
|
|
return nil, errors.New("workspace requires a subcommand")
|
|
}
|
|
switch args[0] {
|
|
case "inspect":
|
|
parsed, err := parseInspect(args[1:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parsed, nil
|
|
case "preprocess":
|
|
return parsePreprocess(args[1:])
|
|
default:
|
|
return nil, fmt.Errorf("unknown workspace command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func Execute(ctx context.Context, installation config.Installation, runner Runner, request Request) (Result, error) {
|
|
envelope, err := request.stdinEnvelope()
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
rendered, err := runDocker(ctx, runner, StageRenderedConfig, installation.ComposeArgs("config", "--format", "json"))
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
imageReference, err := selectedCoreImage(rendered.Stdout)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
imageID, err := immutableImageID(ctx, runner, imageReference)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
override, cleanup, err := maintenanceOverride(installation, imageID)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
defer cleanup()
|
|
stdin, err := encodeEnvelope(envelope)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
args, err := installation.ComposeArgsWithFinalOverride(
|
|
override,
|
|
"run", "--rm", "--no-deps", "--no-TTY", "--name", ownedContainerName(installation, request), "workspace-maintenance", request.operatorCommand(),
|
|
)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
result, err := runDocker(ctx, runner, StageComposeRun, args, bytes.NewReader(stdin))
|
|
// The operator emits one authoritative JSON result on stdout and encodes its status in the
|
|
// exit code (0 success, 3 operator checkpoint/block, 1 operational failure). A nonzero
|
|
// exit is therefore still a valid machine result whenever stdout parses; only a missing or
|
|
// malformed payload becomes an error.
|
|
response, parseErr := parseResponse(result.Stdout)
|
|
if parseErr != nil {
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{}, parseErr
|
|
}
|
|
return response.Result, nil
|
|
}
|
|
|
|
func encodeEnvelope(envelope requestEnvelope) ([]byte, error) {
|
|
encoded, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
return nil, errors.New("workspace request could not be encoded")
|
|
}
|
|
if len(encoded) > 1<<20 {
|
|
return nil, errors.New("workspace request exceeds the bounded stdin contract")
|
|
}
|
|
return append(encoded, '\n'), nil
|
|
}
|
|
|
|
func parseResponse(document string) (operationResponse, error) {
|
|
decoder := json.NewDecoder(strings.NewReader(document))
|
|
decoder.DisallowUnknownFields()
|
|
var response operationResponse
|
|
if err := decoder.Decode(&response); err != nil {
|
|
return operationResponse{}, errors.New("workspace maintenance returned invalid JSON")
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
|
return operationResponse{}, errors.New("workspace maintenance returned trailing output")
|
|
}
|
|
if err := validateResult(response.Result); err != nil {
|
|
return operationResponse{}, err
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func validateResult(result Result) error {
|
|
if result.SchemaVersion != 1 {
|
|
return errors.New("workspace maintenance returned an unsupported schema version")
|
|
}
|
|
if !workspacePattern.MatchString(result.WorkspaceID) {
|
|
return errors.New("workspace maintenance returned an invalid workspace identity")
|
|
}
|
|
if result.Status != "failed" {
|
|
if len(result.WorkspaceRevision) != 40 || !isLowerHex(result.WorkspaceRevision) {
|
|
return errors.New("workspace maintenance returned an invalid workspace revision")
|
|
}
|
|
if !strings.HasPrefix(result.DescriptorBlob, "sha256:") || len(result.DescriptorBlob) != len("sha256:")+64 || !isLowerHex(strings.TrimPrefix(result.DescriptorBlob, "sha256:")) {
|
|
return errors.New("workspace maintenance returned an invalid descriptor digest")
|
|
}
|
|
}
|
|
validStatuses := map[string]struct{}{"succeeded": {}, "unchanged": {}, "dry_run": {}, "blocked": {}, "failed": {}}
|
|
if _, ok := validStatuses[result.Status]; !ok {
|
|
return errors.New("workspace maintenance returned an invalid status")
|
|
}
|
|
if strings.TrimSpace(result.Code) == "" || strings.TrimSpace(result.Operation) == "" || result.CompletedStages == nil {
|
|
return errors.New("workspace maintenance omitted required fields")
|
|
}
|
|
if result.Status != "failed" {
|
|
for _, digest := range result.ArtifactIdentities {
|
|
if strings.TrimSpace(digest.Kind) == "" || !strings.HasPrefix(digest.Digest, "sha256:") {
|
|
return errors.New("workspace maintenance returned an invalid artifact identity")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isLowerHex(value string) bool {
|
|
for _, r := range value {
|
|
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return value != ""
|
|
}
|
|
|
|
func parseInspect(args []string) (InspectRequest, error) {
|
|
base, err := parseBaseFlags(args)
|
|
if err != nil {
|
|
return InspectRequest{}, err
|
|
}
|
|
return InspectRequest{baseRequest: base}, nil
|
|
}
|
|
|
|
func parsePreprocess(args []string) (Request, error) {
|
|
if len(args) == 0 {
|
|
return nil, errors.New("workspace preprocess requires run or clear")
|
|
}
|
|
switch args[0] {
|
|
case "run":
|
|
base, err := parseBaseFlags(args[1:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return RunRequest{baseRequest: base}, nil
|
|
case "clear":
|
|
base, err := parseBaseFlags(args[1:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ClearRequest{baseRequest: base}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown workspace preprocess command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func parseBaseFlags(args []string) (baseRequest, error) {
|
|
request := baseRequest{}
|
|
workspaceSeen := false
|
|
jsonSeen := false
|
|
for len(args) > 0 {
|
|
flag := args[0]
|
|
if flag == "--" {
|
|
return baseRequest{}, errors.New("passthrough separators are not supported")
|
|
}
|
|
switch flag {
|
|
case "--workspace":
|
|
if len(args) < 2 {
|
|
return baseRequest{}, errors.New("--workspace requires a value")
|
|
}
|
|
if workspaceSeen {
|
|
return baseRequest{}, errors.New("--workspace must be supplied exactly once")
|
|
}
|
|
workspace := args[1]
|
|
if !workspacePattern.MatchString(workspace) {
|
|
return baseRequest{}, errors.New("--workspace must match [a-z][a-z0-9-]{2,62}")
|
|
}
|
|
request.Workspace, workspaceSeen, args = workspace, true, args[2:]
|
|
case "--json":
|
|
if jsonSeen {
|
|
return baseRequest{}, errors.New("--json may be supplied once")
|
|
}
|
|
request.JSON, jsonSeen, args = true, true, args[1:]
|
|
default:
|
|
return baseRequest{}, fmt.Errorf("unknown workspace option %q", flag)
|
|
}
|
|
}
|
|
if !workspaceSeen {
|
|
return baseRequest{}, errors.New("--workspace is required")
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func selectedCoreImage(document string) (string, error) {
|
|
var rendered struct {
|
|
Services map[string]struct {
|
|
Image string `json:"image"`
|
|
} `json:"services"`
|
|
}
|
|
if err := json.Unmarshal([]byte(document), &rendered); err != nil {
|
|
return "", errors.New("rendered Compose configuration is invalid")
|
|
}
|
|
core, exists := rendered.Services["core"]
|
|
if !exists || strings.TrimSpace(core.Image) == "" {
|
|
return "", errors.New("selected core image is unavailable")
|
|
}
|
|
return core.Image, nil
|
|
}
|
|
|
|
func immutableImageID(ctx context.Context, runner Runner, reference string) (string, error) {
|
|
result, err := runDocker(ctx, runner, StageImageInspect, []string{"image", "inspect", "--format", "{{.Id}}", reference})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
id := strings.TrimSpace(result.Stdout)
|
|
if !strings.HasPrefix(id, "sha256:") || len(id) != len("sha256:")+64 || !isLowerHex(strings.TrimPrefix(id, "sha256:")) {
|
|
return "", errors.New("selected core image did not resolve to an immutable sha256 image id")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func maintenanceOverride(installation config.Installation, imageID string) (string, func(), error) {
|
|
control := installation.ControlDirectory()
|
|
if err := os.MkdirAll(control, 0o700); err != nil {
|
|
return "", func() {}, errors.New("workspace maintenance control directory could not be created")
|
|
}
|
|
info, err := os.Lstat(control)
|
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return "", func() {}, errors.New("workspace maintenance control directory is unsafe")
|
|
}
|
|
directory, err := os.MkdirTemp(control, "workspace-maintenance-")
|
|
if err != nil {
|
|
return "", func() {}, errors.New("workspace maintenance override directory could not be created")
|
|
}
|
|
cleanup := func() {
|
|
_ = os.Remove(filepath.Join(directory, "override.yaml"))
|
|
_ = os.Remove(directory)
|
|
}
|
|
path := filepath.Join(directory, "override.yaml")
|
|
contents := []string{
|
|
"services:",
|
|
" core:",
|
|
" image: " + strconvQuote(imageID),
|
|
" pull_policy: never",
|
|
" workspace-maintenance:",
|
|
" image: " + strconvQuote(imageID),
|
|
" pull_policy: never",
|
|
"",
|
|
}
|
|
if err := os.WriteFile(path, []byte(strings.Join(contents, "\n")), 0o600); err != nil {
|
|
cleanup()
|
|
return "", func() {}, errors.New("workspace maintenance override could not be written")
|
|
}
|
|
return path, cleanup, nil
|
|
}
|
|
|
|
func strconvQuote(value string) string {
|
|
encoded, _ := json.Marshal(value)
|
|
return string(encoded)
|
|
}
|
|
|
|
func ownedContainerName(installation config.Installation, request Request) string {
|
|
parts := []string{installation.ProjectName(), request.workspaceID(), request.operatorCommand()}
|
|
for index, value := range parts {
|
|
parts[index] = strings.NewReplacer("/", "-", ":", "-", "@", "-", "_", "-").Replace(value)
|
|
}
|
|
return strings.Join(parts, "-")
|
|
}
|
|
|
|
func isExpectedOperatorExit(result compose.Result, err error) bool {
|
|
if err == nil || result.ExitCode != 3 {
|
|
return false
|
|
}
|
|
var operationErr *OperationError
|
|
return errors.As(err, &operationErr) && operationErr.class == ExitClassNonzero
|
|
}
|
|
|
|
func runDocker(ctx context.Context, runner Runner, stage Stage, args []string, stdin ...io.Reader) (compose.Result, error) {
|
|
var input io.Reader
|
|
if len(stdin) > 0 {
|
|
input = stdin[0]
|
|
}
|
|
result, err := runner.Run(ctx, args, input)
|
|
if err != nil {
|
|
class := ExitClassInvocation
|
|
switch {
|
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
|
class = ExitClassTimeout
|
|
case result.ExitCode == 127:
|
|
class = ExitClassUnavailable
|
|
case result.ExitCode != 0:
|
|
class = ExitClassNonzero
|
|
}
|
|
detail := result.Stderr
|
|
if strings.TrimSpace(detail) == "" {
|
|
detail = err.Error()
|
|
}
|
|
return result, &OperationError{stage: stage, class: class, detail: detail}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func Human(result Result) string {
|
|
lines := []string{
|
|
fmt.Sprintf("workspace: %s", result.WorkspaceID),
|
|
fmt.Sprintf("operation: %s", result.Operation),
|
|
fmt.Sprintf("status: %s", result.Status),
|
|
fmt.Sprintf("code: %s", result.Code),
|
|
fmt.Sprintf("revision: %s", result.WorkspaceRevision),
|
|
}
|
|
if result.RunID != "" {
|
|
lines = append(lines, fmt.Sprintf("run: %s", result.RunID))
|
|
}
|
|
if len(result.CompletedStages) > 0 {
|
|
stages := append([]string(nil), result.CompletedStages...)
|
|
sort.Strings(stages)
|
|
lines = append(lines, fmt.Sprintf("completed: %s", strings.Join(stages, ", ")))
|
|
}
|
|
for _, warning := range result.Warnings {
|
|
lines = append(lines, fmt.Sprintf("warning: %s", warning))
|
|
}
|
|
return strings.Join(lines, "\n") + "\n"
|
|
}
|