feat: thothctl workspace preprocessing CLI and file-ingress contracts (P2)
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/output"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/serverops"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/workspaceops"
|
||||
)
|
||||
|
||||
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
|
||||
@@ -50,6 +51,14 @@ Commands:
|
||||
pi maintenance recover --yes
|
||||
Verify a terminal installation, remove stale lifecycle files, and clear maintenance.
|
||||
pi logs Show the latest 200 sanitized core log lines (bounded; no follow mode).
|
||||
workspace inspect --workspace ID [--json]
|
||||
Inspect the active registry snapshot for one workspace.
|
||||
workspace preprocess dwh --workspace ID [--resume RUN] [--json]
|
||||
workspace schema suggest-fks --workspace ID [--from-sql FILE]... [--assume COLUMN=TABLE]... [--output FILE] [--json]
|
||||
workspace schema check --workspace ID [--annotations FILE --reviewed-candidates sha256:HEX] [--json]
|
||||
workspace index-schema --workspace ID [--json]
|
||||
workspace preprocess evidence --workspace ID [--dry-run] [--resume RUN] [--json]
|
||||
workspace preprocess run --workspace ID [--resume RUN] [--json]
|
||||
`
|
||||
|
||||
func main() {
|
||||
@@ -154,6 +163,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
fmt.Fprintf(stdout, "Removed %d stopped app containers; verified %d preserved paths.\n", len(removal.Targets), removal.Preserved)
|
||||
return 0
|
||||
case "workspace":
|
||||
return workspaceCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
|
||||
default:
|
||||
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
||||
}
|
||||
@@ -171,6 +182,47 @@ func writeRemovalTargets(outputWriter io.Writer, project string, targets []serve
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
|
||||
request, err := workspaceops.Parse(args)
|
||||
if err != nil {
|
||||
return commandUsageError(stderr, err.Error())
|
||||
}
|
||||
result, err := workspaceops.Execute(ctx, installation, runner, request)
|
||||
if err != nil {
|
||||
return workspaceFailure(stderr, err, secretValues)
|
||||
}
|
||||
if request.JSONMode() {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if encodeErr := encoder.Encode(result); encodeErr != nil {
|
||||
fmt.Fprintln(stderr, "thothctl: workspace result could not be written")
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
fmt.Fprint(stdout, workspaceops.Human(result))
|
||||
}
|
||||
switch result.Status {
|
||||
case "blocked":
|
||||
return 3
|
||||
case "failed":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||
message := output.Sanitize(err.Error(), secretValues)
|
||||
var operationErr *workspaceops.OperationError
|
||||
if errors.As(err, &operationErr) && operationErr.Detail() != "" {
|
||||
detail := output.SanitizeDetail(operationErr.Detail(), secretValues)
|
||||
fmt.Fprintf(stderr, "thothctl: %s: %s\n", message, detail)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "thothctl: %s\n", message)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func serverOperationFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||
message := output.Sanitize(err.Error(), secretValues)
|
||||
var operationErr *serverops.OperationError
|
||||
|
||||
@@ -664,6 +664,128 @@ func TestRunPiMaintenanceStatusAndRecoverConfirmationContract(t *testing.T) {
|
||||
assertDockerNotInvoked(t, second)
|
||||
}
|
||||
|
||||
func TestRunWorkspaceInspectDispatchesThroughTheMaintenanceService(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"abc","workspaceRevision":"1234567890abcdef1234567890abcdef12345678","descriptorBlob":"sha256:`+strings.Repeat("a", 64)+`","operation":"inspect","completedStages":[]}`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "abc", "--json"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"workspaceId":"abc"`) || !strings.Contains(stdout.String(), `"operation":"inspect"`) {
|
||||
t.Fatalf("stdout = %q", stdout.String())
|
||||
}
|
||||
assertInvocationContains(t, fixture.invocations(t), "run", "--rm", "--no-deps", "--no-TTY", "--name")
|
||||
assertInvocationContains(t, fixture.invocations(t), "workspace-maintenance", "inspect")
|
||||
}
|
||||
|
||||
func TestRunWorkspaceBlockedResultsExitThreeAndRenderHumanOutput(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"blocked","code":"manual_review_required","workspaceId":"abc","workspaceRevision":"1234567890abcdef1234567890abcdef12345678","descriptorBlob":"sha256:`+strings.Repeat("b", 64)+`","operation":"schema-suggest-fks","completedStages":["dwh"],"warnings":["review required"]}`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "schema", "suggest-fks", "--workspace", "abc"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 3 {
|
||||
t.Fatalf("run() exit code = %d, want 3", exitCode)
|
||||
}
|
||||
for _, expected := range []string{"workspace: abc", "status: blocked", "warning: review required"} {
|
||||
if !strings.Contains(stdout.String(), expected) {
|
||||
t.Fatalf("stdout = %q, missing %q", stdout.String(), expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout.String(), "descriptorBlob") || strings.Contains(stdout.String(), strings.Repeat("b", 64)) {
|
||||
t.Fatalf("stdout leaked non-allowlisted fields: %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRequiresWorkspaceFlagBeforeDocker(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(context.Background(), []string{
|
||||
"--installation", fixture.installationPath,
|
||||
"workspace", "inspect",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--workspace") || !strings.Contains(stderr.String(), "required") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRejectsDuplicateWorkspaceFlagsBeforeDocker(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(context.Background(), []string{
|
||||
"--installation", fixture.installationPath,
|
||||
"workspace", "inspect", "--workspace", "abc", "--workspace", "def",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--workspace") || !strings.Contains(stderr.String(), "exactly once") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRejectsInvalidResumeRunIDsBeforeDocker(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(context.Background(), []string{
|
||||
"--installation", fixture.installationPath,
|
||||
"workspace", "preprocess", "dwh", "--workspace", "abc", "--resume", "not-a-run-id",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--resume") || !strings.Contains(stderr.String(), "32 lowercase hex") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunWorkspaceSchemaCheckRequiresReviewedCandidatesWithAnnotations(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
annotations := filepath.Join(fixture.root, "annotations.yaml")
|
||||
if err := os.WriteFile(annotations, []byte("reviewed: []\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(context.Background(), []string{
|
||||
"--installation", fixture.installationPath,
|
||||
"workspace", "schema", "check", "--workspace", "abc", "--annotations", annotations,
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--reviewed-candidates") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunPiStatusPreservesDockerExitCodeAndRedactsDiagnostics(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "PI_TOKEN_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "pi-secret")
|
||||
@@ -744,6 +866,7 @@ case " $* " in
|
||||
fi
|
||||
printf '%s\n' '{"applied":[],"drifted":[],"pending":[]}' ;;
|
||||
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
|
||||
*" image inspect --format {{.Id}} "*) printf '%s\n' "${THOTHCTL_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
||||
*"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;;
|
||||
*"PI_VERSION"*) printf '%s\n' '0.80.3' ;;
|
||||
*" pi --version "*) printf '%s\n' '0.80.3' ;;
|
||||
@@ -752,6 +875,7 @@ case " $* " in
|
||||
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
||||
*"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
||||
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
|
||||
*" run --rm --no-deps --no-TTY "*" workspace-maintenance "*) printf '%s\n' "$THOTHCTL_FAKE_WORKSPACE_RESULT" ;;
|
||||
esac
|
||||
if [ "${THOTHCTL_FAKE_FAIL_ON:-}" = "version" ]; then
|
||||
printf '%s\n' "${THOTHCTL_FAKE_FAILURE:-fake Docker failure}" >&2
|
||||
@@ -792,6 +916,8 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
||||
t.Setenv("THOTHCTL_FAKE_CONFIG", "")
|
||||
t.Setenv("THOTHCTL_FAKE_MIGRATION_FAILURE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_MIGRATION_EXIT", "0")
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "")
|
||||
t.Setenv("THOTHCTL_FAKE_IMAGE_ID", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||
}
|
||||
|
||||
func (f cliFixture) setProfile(t *testing.T, profile string) {
|
||||
|
||||
Reference in New Issue
Block a user