454 lines
13 KiB
Go
454 lines
13 KiB
Go
// Package workspaceops defines the deliberately closed host workspace contract.
|
|
package workspaceops
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/safeio"
|
|
)
|
|
|
|
const CodeRegistryBootstrapRecoveryConflict = "registry_bootstrap_recovery_conflict"
|
|
const (
|
|
maxSQLFile = 1 << 20
|
|
maxSQLTotal = 16 << 20
|
|
maxAssumptions = 256
|
|
maxAssumptionBytes = 256
|
|
maxResult = 1 << 20
|
|
maxCandidate = 700 << 10
|
|
)
|
|
|
|
var workspaceIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{2,62}$`)
|
|
var runIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
|
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
|
|
|
type Command interface{ workspaceCommand() }
|
|
type InspectCommand struct {
|
|
WorkspaceID string
|
|
JSON bool
|
|
}
|
|
type DwhRequest struct {
|
|
WorkspaceID string
|
|
Resume string
|
|
JSON bool
|
|
}
|
|
type SuggestFksRequest struct {
|
|
WorkspaceID string
|
|
FromSQL []string
|
|
Assume []string
|
|
Output string
|
|
JSON bool
|
|
}
|
|
type CheckSchemaRequest struct {
|
|
WorkspaceID string
|
|
Resume string
|
|
Annotations string
|
|
ReviewedCandidates string
|
|
JSON bool
|
|
}
|
|
type IndexSchemaRequest struct {
|
|
WorkspaceID string
|
|
JSON bool
|
|
}
|
|
type EvidenceRequest struct {
|
|
WorkspaceID string
|
|
DryRun bool
|
|
Resume string
|
|
JSON bool
|
|
}
|
|
type RunRequest struct {
|
|
WorkspaceID string
|
|
Resume string
|
|
JSON bool
|
|
}
|
|
|
|
func (InspectCommand) workspaceCommand() {}
|
|
func (DwhRequest) workspaceCommand() {}
|
|
func (SuggestFksRequest) workspaceCommand() {}
|
|
func (CheckSchemaRequest) workspaceCommand() {}
|
|
func (IndexSchemaRequest) workspaceCommand() {}
|
|
func (EvidenceRequest) workspaceCommand() {}
|
|
func (RunRequest) workspaceCommand() {}
|
|
|
|
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"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
}
|
|
type ArtifactIdentity struct {
|
|
Kind string `json:"kind"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
func invalid(msg string) (Command, error) { return nil, errors.New(msg) }
|
|
func requireWorkspace(v string) error {
|
|
if !workspaceIDPattern.MatchString(v) {
|
|
return errors.New("--workspace must be a valid workspace ID")
|
|
}
|
|
return nil
|
|
}
|
|
func requireRun(v string) error {
|
|
if !runIDPattern.MatchString(v) {
|
|
return errors.New("--resume must be a lowercase 32-hex run ID")
|
|
}
|
|
return nil
|
|
}
|
|
func one(args []string, i *int, flag string) (string, error) {
|
|
if *i+1 >= len(args) || strings.HasPrefix(args[*i+1], "--") {
|
|
return "", fmt.Errorf("%s requires a value", flag)
|
|
}
|
|
*i = *i + 1
|
|
return args[*i], nil
|
|
}
|
|
func ParseWorkspaceCommand(args []string) (Command, error) {
|
|
if len(args) < 2 || args[0] != "workspace" {
|
|
return invalid("workspace command is required")
|
|
}
|
|
area, action := args[1], ""
|
|
rest := args[2:]
|
|
if area == "preprocess" || area == "schema" {
|
|
if len(rest) == 0 {
|
|
return invalid("workspace group requires an operation")
|
|
}
|
|
action = rest[0]
|
|
rest = rest[1:]
|
|
} else {
|
|
action = area
|
|
}
|
|
if action != "inspect" && action != "dwh" && action != "suggest-fks" && action != "check" && action != "index-schema" && action != "evidence" && action != "run" {
|
|
return invalid("unknown workspace command")
|
|
}
|
|
var ws, resume, annotations, reviewed, output string
|
|
var jsonOut, dry bool
|
|
var sql, assume []string
|
|
seen := map[string]bool{}
|
|
for i := 0; i < len(rest); i++ {
|
|
f := rest[i]
|
|
if f == "--json" {
|
|
if seen[f] {
|
|
return invalid("duplicate --json")
|
|
}
|
|
seen[f] = true
|
|
jsonOut = true
|
|
continue
|
|
}
|
|
switch f {
|
|
case "--workspace":
|
|
if seen[f] {
|
|
return invalid("duplicate --workspace")
|
|
}
|
|
seen[f] = true
|
|
v, e := one(rest, &i, f)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
ws = v
|
|
case "--resume":
|
|
if seen[f] {
|
|
return invalid("duplicate --resume")
|
|
}
|
|
seen[f] = true
|
|
v, e := one(rest, &i, f)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
resume = v
|
|
case "--from-sql":
|
|
if action != "suggest-fks" {
|
|
return invalid("--from-sql is valid only for schema suggest-fks")
|
|
}
|
|
v, e := one(rest, &i, f)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
if len(sql) >= 32 {
|
|
return invalid("too many --from-sql files")
|
|
}
|
|
sql = append(sql, v)
|
|
case "--assume":
|
|
if action != "suggest-fks" {
|
|
return invalid("--assume is valid only for schema suggest-fks")
|
|
}
|
|
v, e := one(rest, &i, f)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
if len(assume) >= maxAssumptions || len([]byte(v)) > maxAssumptionBytes {
|
|
return invalid("--assume exceeds limit")
|
|
}
|
|
assume = append(assume, v)
|
|
case "--output":
|
|
if action != "suggest-fks" {
|
|
return invalid("--output is valid only for schema suggest-fks")
|
|
}
|
|
if seen[f] {
|
|
return invalid("duplicate --output")
|
|
}
|
|
seen[f] = true
|
|
v, e := one(rest, &i, f)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
output = v
|
|
case "--annotations":
|
|
if action != "check" {
|
|
return invalid("--annotations is valid only for schema check")
|
|
}
|
|
if seen[f] {
|
|
return invalid("duplicate --annotations")
|
|
}
|
|
seen[f] = true
|
|
v, e := one(rest, &i, f)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
annotations = v
|
|
case "--reviewed-candidates":
|
|
if action != "check" {
|
|
return invalid("--reviewed-candidates is valid only for schema check")
|
|
}
|
|
if seen[f] {
|
|
return invalid("duplicate --reviewed-candidates")
|
|
}
|
|
seen[f] = true
|
|
v, e := one(rest, &i, f)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
reviewed = v
|
|
case "--dry-run":
|
|
if action != "evidence" {
|
|
return invalid("--dry-run is valid only for evidence")
|
|
}
|
|
if seen[f] {
|
|
return invalid("duplicate --dry-run")
|
|
}
|
|
seen[f] = true
|
|
dry = true
|
|
default:
|
|
return invalid("unknown workspace option")
|
|
}
|
|
}
|
|
if err := requireWorkspace(ws); err != nil {
|
|
return nil, err
|
|
}
|
|
if action != "inspect" && resume != "" {
|
|
if err := requireRun(resume); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if resume != "" && action != "dwh" && action != "evidence" && action != "run" && action != "check" {
|
|
return invalid("--resume is not valid for this workspace command")
|
|
}
|
|
if action == "inspect" && resume != "" {
|
|
return invalid("inspect does not accept --resume")
|
|
}
|
|
if action == "check" {
|
|
if resume == "" {
|
|
return invalid("schema check requires --resume")
|
|
}
|
|
if (annotations == "") != (reviewed == "") {
|
|
return invalid("--annotations and --reviewed-candidates must be supplied together")
|
|
}
|
|
if reviewed != "" && !digestPattern.MatchString(reviewed) {
|
|
return invalid("--reviewed-candidates must be sha256:<hex>")
|
|
}
|
|
}
|
|
if action != "suggest-fks" && (len(sql) > 0 || len(assume) > 0 || output != "") {
|
|
return invalid("schema options are valid only for suggest-fks")
|
|
}
|
|
switch action {
|
|
case "inspect":
|
|
return InspectCommand{ws, jsonOut}, nil
|
|
case "dwh":
|
|
return DwhRequest{ws, resume, jsonOut}, nil
|
|
case "suggest-fks":
|
|
return SuggestFksRequest{ws, sql, assume, output, jsonOut}, nil
|
|
case "check":
|
|
return CheckSchemaRequest{ws, resume, annotations, reviewed, jsonOut}, nil
|
|
case "index-schema":
|
|
return IndexSchemaRequest{ws, jsonOut}, nil
|
|
case "evidence":
|
|
return EvidenceRequest{ws, dry, resume, jsonOut}, nil
|
|
default:
|
|
return RunRequest{ws, resume, jsonOut}, nil
|
|
}
|
|
}
|
|
|
|
type inputEnvelope struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Operation string `json:"operation"`
|
|
WorkspaceID string `json:"workspaceId"`
|
|
Resume string `json:"resume,omitempty"`
|
|
SQL []sqlInput `json:"sql,omitempty"`
|
|
Assume []string `json:"assume,omitempty"`
|
|
Annotations string `json:"annotations,omitempty"`
|
|
ReviewedCandidates string `json:"reviewedCandidates,omitempty"`
|
|
DryRun bool `json:"dryRun,omitempty"`
|
|
}
|
|
type sqlInput struct {
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
func operationName(c Command) string {
|
|
switch c.(type) {
|
|
case InspectCommand:
|
|
return "inspect"
|
|
case DwhRequest:
|
|
return "dwh"
|
|
case SuggestFksRequest:
|
|
return "suggest-fks"
|
|
case CheckSchemaRequest:
|
|
return "check"
|
|
case IndexSchemaRequest:
|
|
return "index-schema"
|
|
case EvidenceRequest:
|
|
return "evidence"
|
|
default:
|
|
return "run"
|
|
}
|
|
}
|
|
func Run(ctx context.Context, installation config.Installation, runner compose.Runner, command Command, stdin io.Reader) (Result, error) {
|
|
env := inputEnvelope{SchemaVersion: 1, Operation: operationName(command)}
|
|
var outputPath string
|
|
switch c := command.(type) {
|
|
case InspectCommand:
|
|
env.WorkspaceID = c.WorkspaceID
|
|
case DwhRequest:
|
|
env.WorkspaceID, env.Resume = c.WorkspaceID, c.Resume
|
|
case SuggestFksRequest:
|
|
env.WorkspaceID, env.Assume, outputPath = c.WorkspaceID, c.Assume, c.Output
|
|
if outputPath != "" {
|
|
if err := safeio.ValidateCanonicalOutputPath(outputPath); err != nil {
|
|
return Result{}, errors.New("unsafe output file")
|
|
}
|
|
}
|
|
var total int64
|
|
for _, path := range c.FromSQL {
|
|
b, e := safeio.ReadCanonicalUTF8(path, maxSQLFile)
|
|
if e != nil {
|
|
return Result{}, errors.New("unsafe SQL input")
|
|
}
|
|
total += int64(len(b))
|
|
if total > maxSQLTotal {
|
|
return Result{}, errors.New("SQL input exceeds limit")
|
|
}
|
|
env.SQL = append(env.SQL, sqlInput{path, string(b)})
|
|
}
|
|
if len(env.SQL) > 0 {
|
|
var n int
|
|
for _, x := range env.SQL {
|
|
n += len(x.Content)
|
|
}
|
|
if n > maxCandidate {
|
|
return Result{}, errors.New("candidate input exceeds limit")
|
|
}
|
|
}
|
|
case CheckSchemaRequest:
|
|
env.WorkspaceID, env.Resume, env.Annotations, env.ReviewedCandidates = c.WorkspaceID, c.Resume, c.Annotations, c.ReviewedCandidates
|
|
if c.Annotations != "" {
|
|
b, e := safeio.ReadCanonicalUTF8(c.Annotations, 16<<20)
|
|
if e != nil {
|
|
return Result{}, errors.New("unsafe annotation input")
|
|
}
|
|
env.Annotations = string(b)
|
|
}
|
|
case IndexSchemaRequest:
|
|
env.WorkspaceID = c.WorkspaceID
|
|
case EvidenceRequest:
|
|
env.WorkspaceID, env.Resume, env.DryRun = c.WorkspaceID, c.Resume, c.DryRun
|
|
case RunRequest:
|
|
env.WorkspaceID, env.Resume = c.WorkspaceID, c.Resume
|
|
default:
|
|
return Result{}, errors.New("unsupported workspace command")
|
|
}
|
|
payload, e := json.Marshal(env)
|
|
if e != nil {
|
|
return Result{}, e
|
|
}
|
|
if stdin != nil {
|
|
payload, e = io.ReadAll(io.LimitReader(stdin, 1<<20+1))
|
|
if e != nil {
|
|
return Result{}, e
|
|
}
|
|
if len(payload) > 1<<20 {
|
|
return Result{}, errors.New("request exceeds limit")
|
|
}
|
|
var supplied inputEnvelope
|
|
d := json.NewDecoder(bytes.NewReader(payload))
|
|
d.DisallowUnknownFields()
|
|
if e = d.Decode(&supplied); e != nil || supplied.SchemaVersion != 1 || supplied.Operation != env.Operation || supplied.WorkspaceID != env.WorkspaceID {
|
|
return Result{}, errors.New("invalid workspace request")
|
|
}
|
|
}
|
|
|
|
args := installation.ComposeArgs("run", "--rm", "--no-deps", "workspace-maintenance", "--operation", operationName(command), "--workspace", env.WorkspaceID)
|
|
cr, runErr := runner.RunBounded(ctx, args, bytes.NewReader(payload), compose.CaptureLimits{StdoutBytes: 1 << 20, StderrBytes: 64 << 10})
|
|
if runErr != nil && (errors.Is(runErr, compose.ErrOutputLimit) || cr.Stdout == "") {
|
|
return Result{}, runErr
|
|
}
|
|
if len(cr.Stdout) > maxResult {
|
|
return Result{}, errors.New("invalid workspace result")
|
|
}
|
|
var result Result
|
|
dec := json.NewDecoder(strings.NewReader(cr.Stdout))
|
|
dec.DisallowUnknownFields()
|
|
if e := dec.Decode(&result); e != nil {
|
|
return Result{}, errors.New("invalid workspace result")
|
|
}
|
|
var extra any
|
|
if e := dec.Decode(&extra); e != io.EOF {
|
|
return Result{}, errors.New("invalid workspace result")
|
|
}
|
|
if result.SchemaVersion != 1 || result.WorkspaceID != env.WorkspaceID || result.Operation != operationName(command) || !validStatus(result.Status) || !validCode(result.Code) {
|
|
return Result{}, errors.New("invalid workspace result")
|
|
}
|
|
if outputPath != "" {
|
|
if len(cr.Stdout) > maxCandidate {
|
|
return Result{}, errors.New("candidate export exceeds limit")
|
|
}
|
|
if err := safeio.WriteCanonicalExclusive(outputPath, []byte(cr.Stdout), 0o600); err != nil {
|
|
return Result{}, errors.New("unsafe output file")
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
func DigestBytes(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) }
|
|
|
|
func validStatus(v string) bool {
|
|
switch v {
|
|
case "succeeded", "unchanged", "dry_run", "blocked", "failed":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
func validCode(v string) bool {
|
|
switch v {
|
|
case "ok", "workspace_not_found", "workspace_not_activatable", "binding_missing", "preprocessing_conflict", "preprocessing_resume_mismatch", "manual_review_required", "evidence_materialization_required", "effective_config_mismatch", "semantic_index_incompatible", "annotation_invalid", "egress_policy_refused", CodeRegistryBootstrapRecoveryConflict:
|
|
return true
|
|
}
|
|
return false
|
|
}
|