diff --git a/Justfile b/Justfile index 8e0ee247..1f611aab 100644 --- a/Justfile +++ b/Justfile @@ -150,22 +150,14 @@ env-namespace: # file list failed to build reports a clean tree, which is the one answer a # check like this must never give by accident. listing=$(mktemp) - scanned=$(mktemp) - trap 'rm -f "${listing}" "${scanned}"' EXIT + trap 'rm -f "${listing}"' EXIT git ls-files -z > "${listing}" if [ ! -s "${listing}" ]; then echo "no tracked files listed — the scan checked nothing" >&2 exit 1 fi - # The migration table in the environment-variables guide is the one place - # the old names are allowed, because naming them is the whole point of it. - grep -zZv '^site/src/content/docs/guides/environment-variables.mdx$' < "${listing}" > "${scanned}" || true - if [ ! -s "${scanned}" ]; then - echo "the exemption matched every tracked file — the scan checked nothing" >&2 - exit 1 - fi set +e - stray=$(xargs -0 grep -nHE '\bOB_[A-Z0-9_]+' < "${scanned}") + stray=$(xargs -0 grep -nHE '\bOB_[A-Z0-9_]+' < "${listing}") status=$? set -e case "${status}" in diff --git a/README.md b/README.md index fe49b469..187cb889 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ Starting from an existing Compose project, `ob init` writes the first draft. This is a complete single-workload project: ```yaml -# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha1/application.schema.json -apiVersion: onebox.run/v1alpha1 +# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha2/application.schema.json +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha2/application.schema.json similarity index 94% rename from api/application/v1alpha1/application.schema.json rename to api/application/v1alpha2/application.schema.json index 8581545d..3d1b945e 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha2/application.schema.json @@ -1,11 +1,11 @@ { - "$id": "https://onebox.run/schemas/application/v1alpha1/application.schema.json", + "$id": "https://onebox.run/schemas/application/v1alpha2/application.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "One application, its workloads, the services it needs, and how a release rolls out.", "properties": { "apiVersion": { - "const": "onebox.run/v1alpha1", + "const": "onebox.run/v1alpha2", "description": "Authored Application API identity.", "type": "string" }, @@ -1299,35 +1299,6 @@ "additionalProperties": { "additionalProperties": false, "allOf": [ - { - "if": { - "required": [ - "execution" - ] - }, - "then": { - "not": { - "required": [ - "compose" - ] - }, - "properties": { - "dataEffect": { - "const": "None" - }, - "deploymentPhase": { - "const": "None" - }, - "operatorRun": { - "const": "Allowed" - } - }, - "required": [ - "schedule", - "dataEffect" - ] - } - }, { "oneOf": [ { @@ -1474,11 +1445,6 @@ "required": [ "inputs" ] - }, - { - "required": [ - "execution" - ] } ] } @@ -1690,104 +1656,6 @@ }, "type": "array" }, - "execution": { - "additionalProperties": false, - "description": "Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume.", - "properties": { - "retention": { - "default": "168h", - "description": "Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d.", - "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", - "type": "string" - }, - "steps": { - "description": "Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps.", - "items": { - "additionalProperties": false, - "properties": { - "command": { - "description": "Argument vector passed to the job image's entrypoint. No shell evaluation is performed.", - "items": { - "type": "string" - }, - "maxItems": 128, - "minItems": 1, - "type": "array" - }, - "id": { - "description": "Unique stable step identifier, used by output references. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", - "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", - "type": "string" - }, - "inputs": { - "additionalProperties": { - "type": "string" - }, - "description": "Environment variables populated from a preceding step's declared output, written as step.OUTPUT.", - "propertyNames": { - "pattern": "^[A-Z][A-Z0-9_]*$" - }, - "type": "object" - }, - "outputs": { - "description": "Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total.", - "items": { - "description": "Expects upper-case letters, digits and underscores, starting with a letter.", - "pattern": "^[A-Z][A-Z0-9_]*$", - "type": "string" - }, - "maxItems": 32, - "type": "array", - "uniqueItems": true - }, - "retry": { - "additionalProperties": false, - "description": "Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout.", - "properties": { - "attempts": { - "default": 1, - "description": "Total attempts including the first, 1 to 10.", - "examples": [ - 3 - ], - "maximum": 10, - "minimum": 1, - "type": "integer" - }, - "backoff": { - "default": "30s", - "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", - "examples": [ - "1m" - ], - "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", - "type": "string" - }, - "maxBackoff": { - "default": "10m", - "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.", - "examples": [ - "30m" - ], - "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "id", - "command" - ], - "type": "object" - }, - "maxItems": 32, - "type": "array" - } - }, - "type": "object" - }, "extraHosts": { "description": "Additional host-to-address entries added to the container.", "items": { @@ -2562,6 +2430,6 @@ "metadata", "spec" ], - "title": "Onebox Application (onebox.run/v1alpha1)", + "title": "Onebox Application (onebox.run/v1alpha2)", "type": "object" } diff --git a/api/application/v1alpha1/schema.go b/api/application/v1alpha2/schema.go similarity index 60% rename from api/application/v1alpha1/schema.go rename to api/application/v1alpha2/schema.go index 896dcd99..1d5ec45c 100644 --- a/api/application/v1alpha1/schema.go +++ b/api/application/v1alpha2/schema.go @@ -1,9 +1,9 @@ -// Package applicationv1alpha1 embeds the public Onebox Application contract. -package applicationv1alpha1 +// Package applicationv1alpha2 embeds the public Onebox Application contract. +package applicationv1alpha2 import _ "embed" -// Schema is the exact JSON Schema published for onebox.run/v1alpha1. +// Schema is the exact JSON Schema published for onebox.run/v1alpha2. // //go:embed application.schema.json var Schema []byte diff --git a/api/testdata/application/invalid/extension.yml b/api/testdata/application/invalid/extension.yml index 6920ee7d..94afcd42 100644 --- a/api/testdata/application/invalid/extension.yml +++ b/api/testdata/application/invalid/extension.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: diff --git a/api/testdata/application/invalid/previous-alpha.yml b/api/testdata/application/invalid/previous-alpha.yml new file mode 100644 index 00000000..7ab00f31 --- /dev/null +++ b/api/testdata/application/invalid/previous-alpha.yml @@ -0,0 +1,15 @@ +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: root@example.invalid + workloads: + nightly: + role: Job + image: busybox + dataEffect: None + schedule: + cron: "0 2 * * *" diff --git a/api/testdata/application/invalid/retired-job-execution.yml b/api/testdata/application/invalid/retired-job-execution.yml new file mode 100644 index 00000000..8a16c462 --- /dev/null +++ b/api/testdata/application/invalid/retired-job-execution.yml @@ -0,0 +1,17 @@ +apiVersion: onebox.run/v1alpha2 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: root@example.invalid + workloads: + nightly: + role: Job + image: busybox + dataEffect: None + schedule: + cron: "0 2 * * *" + execution: + retention: 168h diff --git a/api/testdata/application/invalid/root-shorthand.yml b/api/testdata/application/invalid/root-shorthand.yml index e3831037..ddf5a940 100644 --- a/api/testdata/application/invalid/root-shorthand.yml +++ b/api/testdata/application/invalid/root-shorthand.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: diff --git a/api/testdata/application/invalid/snake-case.yml b/api/testdata/application/invalid/snake-case.yml index 3f87e49b..3b013df2 100644 --- a/api/testdata/application/invalid/snake-case.yml +++ b/api/testdata/application/invalid/snake-case.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: diff --git a/api/testdata/application/valid/minimal.yml b/api/testdata/application/valid/minimal.yml index 234707ee..0b3f7a1f 100644 --- a/api/testdata/application/valid/minimal.yml +++ b/api/testdata/application/valid/minimal.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/cmd/ob-docgen/main.go b/cmd/ob-docgen/main.go index 0e6a1339..211570f4 100644 --- a/cmd/ob-docgen/main.go +++ b/cmd/ob-docgen/main.go @@ -9,7 +9,7 @@ // // So this program is the only writer of `site/src/content/docs/reference/` // — the field pages, `drivers.mdx`, `errors.mdx` and `cli.mdx` — and of the -// schema published below `site/public/schemas/application/v1alpha1/`. Those pages carry +// schema published below `site/public/schemas/application/v1alpha2/`. Those pages carry // a generated marker, // which `--check` reads in both directions: it fails when a page differs from // what this binary would produce, and when a marked page survives that no @@ -67,7 +67,7 @@ func main() { os.Exit(1) } schema = append(schema, '\n') - publicFiles := map[string]string{"schemas/application/v1alpha1/application.schema.json": string(schema)} + publicFiles := map[string]string{"schemas/application/v1alpha2/application.schema.json": string(schema)} if check { if err := verify(out, files); err != nil { diff --git a/cmd/ob-docgen/main_test.go b/cmd/ob-docgen/main_test.go index dc9ef5cc..9cbbe153 100644 --- a/cmd/ob-docgen/main_test.go +++ b/cmd/ob-docgen/main_test.go @@ -330,12 +330,12 @@ func TestPublishedSchemaMatchesTheCheckedInCopy(t *testing.T) { } // Skipping on a read failure would turn "someone moved the file" into a // passing test, which is the drift this exists to catch. - onDisk, err := os.ReadFile(filepath.Join("..", "..", "api", "application", "v1alpha1", "application.schema.json")) + onDisk, err := os.ReadFile(filepath.Join("..", "..", "api", "application", "v1alpha2", "application.schema.json")) if err != nil { t.Fatalf("the checked-in schema must be readable: %v", err) } if strings.TrimSpace(string(generated)) != strings.TrimSpace(string(onDisk)) { - t.Error("the published schema differs from api/application/v1alpha1/application.schema.json") + t.Error("the published schema differs from api/application/v1alpha2/application.schema.json") } } diff --git a/cmd/ob/commands_test.go b/cmd/ob/commands_test.go index d7b5f153..ea26a391 100644 --- a/cmd/ob/commands_test.go +++ b/cmd/ob/commands_test.go @@ -62,7 +62,7 @@ func TestConfirmInteractiveDeployRequiresConfirmationWithoutPolicyApproval(t *te func writeProject(t *testing.T) string { t.Helper() dir := t.TempDir() - obYAML := `apiVersion: onebox.run/v1alpha1 + obYAML := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo @@ -103,7 +103,7 @@ func TestValidateOK(t *testing.T) { func TestPreflightBlocksDeploy(t *testing.T) { dir := writeProject(t) - obYAML := `apiVersion: onebox.run/v1alpha1 + obYAML := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo diff --git a/cmd/ob/execution.go b/cmd/ob/execution.go deleted file mode 100644 index 34b222fe..00000000 --- a/cmd/ob/execution.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "text/tabwriter" - - "github.com/labstack/onebox/internal/onebox" - "github.com/spf13/cobra" -) - -func addExecutionCommands(root *cobra.Command, g *globalFlags) { - executionCmd := &cobra.Command{ - Use: "execution", Short: "inspect and recover durable job executions", - Long: "Inspect durable job checkpoints saved on the managed host. Resume uses the original inputs and successful step outputs, and requires the original release and compatible runtime. Applications remain responsible for idempotency when an interrupted attempt is repeated.", - Args: cobra.NoArgs, RunE: showCommandHelp, - } - var count int - listCmd := &cobra.Command{ - Use: "list", Short: "list durable executions, newest first", Args: cobra.NoArgs, - Long: "List the newest durable executions saved on the managed host, including their job, state, original release and provisional resume eligibility. Reads checkpoint files independently of journal retention and the current job declarations.", - RunE: func(cmd *cobra.Command, _ []string) error { - if count < 1 || count > 1000 { - return writeStructuredCommandFailure(cmd, g, "execution_count_invalid", "execution count must be between 1 and 1000", fmt.Errorf("count must be between 1 and 1000")) - } - cfg, p, err := loadAllLenient(cmd.Context(), g) - if err != nil { - return writeStructuredReadFailure(cmd, g, err) - } - e, cleanup, err := connect(cmd, g, cfg, p, newUI(cmd, g)) - if err != nil { - return writeStructuredReadFailure(cmd, g, err) - } - defer cleanup() - records, err := e.ExecutionList(cmd.Context(), count) - if err != nil { - return writeStructuredCommandFailure(cmd, g, "execution_list_failed", "durable executions could not be listed", err) - } - if isStructuredOutput(g) { - return writeFiniteSuccess(cmd, g, map[string]any{"executions": records}) - } - if len(records) == 0 { - _, err := fmt.Fprintln(cmd.OutOrStdout(), "no durable executions recorded") - return err - } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "EXECUTION\tJOB\tSTATE\tRELEASE\tRESUMABLE") - for _, record := range records { - fmt.Fprintf(w, "%v\t%v\t%v\t%v\t%v\n", record["id"], record["job"], record["state"], record["release"], record["resumable"]) - } - return w.Flush() - }, - } - listCmd.Flags().IntVarP(&count, "count", "n", 20, "number of newest executions to show (1–1000)") - inspectCmd := &cobra.Command{ - Use: "inspect ", Short: "show saved inputs, step outcomes, and resume eligibility", Args: cobra.ExactArgs(1), - Long: "Read a durable execution by its stable ID, including original inputs, committed outputs, step attempts and systemd invocation IDs. Works independently of the current job declaration. Resume eligibility is provisional: the runner rechecks compatibility and ownership under locks before running.", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, p, err := loadAllLenient(cmd.Context(), g) - if err != nil { - return writeStructuredReadFailure(cmd, g, err) - } - e, cleanup, err := connect(cmd, g, cfg, p, newUI(cmd, g)) - if err != nil { - return writeStructuredReadFailure(cmd, g, err) - } - defer cleanup() - record, err := e.ExecutionInspect(cmd.Context(), args[0]) - if err != nil { - return writeStructuredCommandFailure(cmd, g, "execution_inspect_failed", "durable execution could not be read", err) - } - if isStructuredOutput(g) { - return writeFiniteSuccess(cmd, g, map[string]any{"execution": record}) - } - encoder := json.NewEncoder(cmd.OutOrStdout()) - encoder.SetIndent("", " ") - return encoder.Encode(record) - }, - } - var wait, resumeBreakLock bool - resumeCmd := &cobra.Command{ - Use: "resume ", Short: "resume an unsuccessful execution from saved checkpoints", - Long: "Resume an unsuccessful, unexpired execution with its original inputs and saved outputs. Completed steps are skipped. The host refuses resume while prior work is active or the original release, workflow definition, image, or service runtime is incompatible. An interrupted step may repeat effects; use the stable execution and step IDs for application deduplication.", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runMutation(cmd, g, onebox.ExecuteRequest{Kind: onebox.KindExecutionResume, ExecutionID: args[0], Wait: wait, BreakLock: resumeBreakLock}, "execution resume") - }, - } - resumeCmd.Flags().BoolVar(&wait, "wait", false, "wait for the resumed activation to finish") - resumeCmd.Flags().BoolVar(&resumeBreakLock, "break-lock", false, "break a stale operation lock after inspecting its holder") - var abandonBreakLock bool - abandonCmd := &cobra.Command{ - Use: "abandon ", Short: "end resumability and release an execution's retention hold", - Long: "Mark an inactive execution abandoned. It can no longer be resumed and stops protecting its release from cleanup. Saved execution evidence remains available for inspection. Active work must stop before it can be abandoned.", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runMutation(cmd, g, onebox.ExecuteRequest{Kind: onebox.KindExecutionAbandon, ExecutionID: args[0], BreakLock: abandonBreakLock}, "execution abandon") - }, - } - abandonCmd.Flags().BoolVar(&abandonBreakLock, "break-lock", false, "break a stale operation lock after inspecting its holder") - executionCmd.AddCommand(listCmd, inspectCmd, resumeCmd, abandonCmd) - root.AddCommand(executionCmd) -} diff --git a/cmd/ob/execution_test.go b/cmd/ob/execution_test.go deleted file mode 100644 index e7e94542..00000000 --- a/cmd/ob/execution_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "bytes" - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestExecutionCommands(t *testing.T) { - root := &cobra.Command{Use: "ob"} - addExecutionCommands(root, &globalFlags{}) - for _, name := range []string{"list", "inspect", "resume", "abandon"} { - t.Run(name, func(t *testing.T) { - cmd, rest, err := root.Find([]string{"execution", name}) - if err != nil || len(rest) != 0 || cmd.Name() != name { - t.Fatalf("command missing: %v %v", rest, err) - } - if cmd.RunE == nil { - t.Fatal("missing implementation") - } - if name == "list" { - if err := cmd.Args(cmd, nil); err != nil { - t.Fatal(err) - } - if err := cmd.Args(cmd, []string{"unexpected"}); err == nil { - t.Fatal("list accepted argument") - } - flag := cmd.Flags().Lookup("count") - if flag == nil || flag.DefValue != "20" || flag.Shorthand != "n" { - t.Fatalf("unexpected count flag: %#v", flag) - } - } else { - if err := cmd.Args(cmd, nil); err == nil { - t.Fatal("missing ID accepted") - } - if err := cmd.Args(cmd, []string{"id"}); err != nil { - t.Fatal(err) - } - if err := cmd.Args(cmd, []string{"id", "extra"}); err == nil { - t.Fatal("extra argument accepted") - } - } - for _, flag := range []string{"input", "release", "job"} { - if cmd.Flags().Lookup(flag) != nil { - t.Fatalf("%s permits changing original %s", name, flag) - } - } - if name == "resume" || name == "abandon" { - if cmd.Flags().Lookup("break-lock") == nil { - t.Fatal("missing break-lock") - } - } - if (cmd.Flags().Lookup("wait") != nil) != (name == "resume") { - t.Fatal("wait must apply only to resume") - } - }) - } -} - -func TestExecutionListRejectsInvalidCountBeforeConnecting(t *testing.T) { - for _, count := range []string{"0", "-1", "1001"} { - t.Run(count, func(t *testing.T) { - root := &cobra.Command{Use: "ob", SilenceErrors: true, SilenceUsage: true} - addExecutionCommands(root, &globalFlags{}) - var output bytes.Buffer - root.SetOut(&output) - root.SetErr(&output) - root.SetArgs([]string{"execution", "list", "--count", count}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "between 1 and 1000") { - t.Fatalf("got %v; output %s", err, output.String()) - } - }) - } -} diff --git a/cmd/ob/init.go b/cmd/ob/init.go index f18ac334..ba0bd6bd 100644 --- a/cmd/ob/init.go +++ b/cmd/ob/init.go @@ -98,7 +98,7 @@ func runInit(ctx context.Context, cmd *cobra.Command, g *globalFlags) error { // errors from the moment the file exists rather than after someone finds // out it could. fmt.Fprintf(&b, "# yaml-language-server: $schema=%s\n", app.SchemaID) - b.WriteString("apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n") + b.WriteString("apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata:\n") fmt.Fprintf(&b, " name: %s\n", application) b.WriteString("spec:\n environments:\n production:\n server: deploy@CHANGE-ME\n") b.WriteString(" workloads:\n") diff --git a/cmd/ob/init_test.go b/cmd/ob/init_test.go index 3b9e10f5..d573a75f 100644 --- a/cmd/ob/init_test.go +++ b/cmd/ob/init_test.go @@ -48,7 +48,7 @@ func TestInitClassifiesAndDoctors(t *testing.T) { } y := string(b) for _, want := range []string{ - "apiVersion: onebox.run/v1alpha1", + "apiVersion: onebox.run/v1alpha2", "server: deploy@CHANGE-ME", "workloads:", "role: Application", diff --git a/cmd/ob/main_test.go b/cmd/ob/main_test.go index 6960bd1e..779b31e2 100644 --- a/cmd/ob/main_test.go +++ b/cmd/ob/main_test.go @@ -10,7 +10,7 @@ import ( "github.com/labstack/onebox/internal/app" ) -const mainTestProject = `apiVersion: onebox.run/v1alpha1 +const mainTestProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo @@ -21,7 +21,7 @@ spec: demo: image: nginx:1.27 ` -const mainTestBuildProject = `apiVersion: onebox.run/v1alpha1 +const mainTestBuildProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo @@ -47,6 +47,18 @@ func TestRootHelpListsVerbs(t *testing.T) { } } +func TestRetiredExecutionCommandsAreRejected(t *testing.T) { + for _, verb := range []string{"list", "inspect", "resume", "abandon"} { + root := newRootCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"execution", verb}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), `unknown command "execution"`) { + t.Fatalf("retired execution %s command accepted: %v", verb, err) + } + } +} + func TestImplicitProjectFileAcceptsYAML(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "ob.yaml"), []byte(mainTestProject), 0o644); err != nil { diff --git a/cmd/ob/ops_contract_test.go b/cmd/ob/ops_contract_test.go index 5ae95689..86ae43e7 100644 --- a/cmd/ob/ops_contract_test.go +++ b/cmd/ob/ops_contract_test.go @@ -24,7 +24,7 @@ func writeOpsContractProject(t *testing.T, dir string, encrypted bool) string { } } path := filepath.Join(dir, "project.yml") - if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha1 + if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -174,7 +174,7 @@ func TestDestroyConfirmationMismatchIsCancelledBeforeTargetContact(t *testing.T) func TestServiceLogsAndExecNDJSONTagChannelsAndTargetKind(t *testing.T) { dir := t.TempDir() config := filepath.Join(dir, "project.yml") - if err := os.WriteFile(config, []byte(`apiVersion: onebox.run/v1alpha1 + if err := os.WriteFile(config, []byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/cmd/ob/output.go b/cmd/ob/output.go index f063f7c9..1f4900f6 100644 --- a/cmd/ob/output.go +++ b/cmd/ob/output.go @@ -76,52 +76,48 @@ const ( // Cobra's native human output. Keeping this list closed makes adding a command // an explicit CLI-contract decision instead of silently inheriting behavior. var cliOutputMatrix = map[string]cliOutputClass{ - "ob abort": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob approve": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob audit": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob bootstrap": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob canonical": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob deploy": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob destroy": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob doctor": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob eject": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob exec": {Class: cliClassOperatorPassthrough, NDJSON: true}, - "ob init": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob backup create": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob backup enable": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob backup disable": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob backup drill": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob backup restore": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob backup prune": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob backup verify": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob backup status": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob job plan": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob job history": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob job logs": {Class: cliClassOperatorPassthrough, JSON: true, NDJSON: true}, - "ob job run": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob logs": {Class: cliClassOperatorPassthrough, JSON: true, NDJSON: true}, - "ob plan": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob preflight": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob preview": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob proxy apply": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob resume": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob rollback": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob schedule apply": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob schedule list": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob schedule pause": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob schedule resume": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob execution list": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob execution inspect": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob execution resume": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob execution abandon": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob schema": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob secrets edit": {Class: cliClassTrustedEditor, JSON: true}, - "ob secrets list": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob secrets push": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob service apply": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, - "ob status": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob validate": {Class: cliClassFiniteEnvelope, JSON: true}, - "ob version": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob abort": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob approve": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob audit": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob bootstrap": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob canonical": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob deploy": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob destroy": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob doctor": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob eject": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob exec": {Class: cliClassOperatorPassthrough, NDJSON: true}, + "ob init": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob backup create": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob backup enable": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob backup disable": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob backup drill": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob backup restore": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob backup prune": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob backup verify": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob backup status": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob job plan": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob job history": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob job logs": {Class: cliClassOperatorPassthrough, JSON: true, NDJSON: true}, + "ob job run": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob logs": {Class: cliClassOperatorPassthrough, JSON: true, NDJSON: true}, + "ob plan": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob preflight": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob preview": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob proxy apply": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob resume": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob rollback": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob schedule apply": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob schedule list": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob schedule pause": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob schedule resume": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob schema": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob secrets edit": {Class: cliClassTrustedEditor, JSON: true}, + "ob secrets list": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob secrets push": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob service apply": {Class: cliClassFiniteStream, JSON: true, NDJSON: true}, + "ob status": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob validate": {Class: cliClassFiniteEnvelope, JSON: true}, + "ob version": {Class: cliClassFiniteEnvelope, JSON: true}, } type cliExitError struct { diff --git a/cmd/ob/output_test.go b/cmd/ob/output_test.go index 940e2bb4..a8f437a2 100644 --- a/cmd/ob/output_test.go +++ b/cmd/ob/output_test.go @@ -314,7 +314,7 @@ func TestStructuredDeployRequiresApprovalArtifactWithoutPrompting(t *testing.T) // failure appears at the consumer rather than here. func TestStructuredOutputCarriesNoDiagnostics(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -352,7 +352,7 @@ spec: // publish outlives the terminal it would have scrolled off. func TestStructuredOutputCarriesNoPlaintextSecret(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -432,7 +432,7 @@ func TestCommandGroupsValidateOutputBeforeRenderingHelp(t *testing.T) { func TestEjectStructuredOutputIsVersioned(t *testing.T) { for _, mode := range []string{"json"} { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -468,7 +468,7 @@ spec: func TestStructuredReadFailuresEmitTypedSafeRecords(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -506,52 +506,48 @@ spec: func TestLeafOutputMatrixIsClosedAndHasNoAliases(t *testing.T) { wantMatrix := map[string]cliOutputClass{ - "ob abort": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob approve": {Class: "finite_envelope", JSON: true}, - "ob audit": {Class: "finite_envelope", JSON: true}, - "ob bootstrap": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob canonical": {Class: "finite_envelope", JSON: true}, - "ob deploy": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob destroy": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob doctor": {Class: "finite_envelope", JSON: true}, - "ob eject": {Class: "finite_envelope", JSON: true}, - "ob exec": {Class: "operator_passthrough", NDJSON: true}, - "ob init": {Class: "finite_envelope", JSON: true}, - "ob job plan": {Class: "finite_envelope", JSON: true}, - "ob job history": {Class: "finite_envelope", JSON: true}, - "ob job logs": {Class: "operator_passthrough", JSON: true, NDJSON: true}, - "ob backup create": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob backup enable": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob backup disable": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob backup drill": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob backup restore": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob backup prune": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob backup verify": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob backup status": {Class: "finite_envelope", JSON: true}, - "ob job run": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob logs": {Class: "operator_passthrough", JSON: true, NDJSON: true}, - "ob plan": {Class: "finite_envelope", JSON: true}, - "ob preflight": {Class: "finite_envelope", JSON: true}, - "ob preview": {Class: "finite_envelope", JSON: true}, - "ob proxy apply": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob resume": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob rollback": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob schedule apply": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob schedule list": {Class: "finite_envelope", JSON: true}, - "ob schedule pause": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob schedule resume": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob execution list": {Class: "finite_envelope", JSON: true}, - "ob execution inspect": {Class: "finite_envelope", JSON: true}, - "ob execution resume": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob execution abandon": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob schema": {Class: "finite_envelope", JSON: true}, - "ob secrets edit": {Class: "trusted_editor", JSON: true}, - "ob secrets list": {Class: "finite_envelope", JSON: true}, - "ob secrets push": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob service apply": {Class: "finite_stream", JSON: true, NDJSON: true}, - "ob status": {Class: "finite_envelope", JSON: true}, - "ob validate": {Class: "finite_envelope", JSON: true}, - "ob version": {Class: "finite_envelope", JSON: true}, + "ob abort": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob approve": {Class: "finite_envelope", JSON: true}, + "ob audit": {Class: "finite_envelope", JSON: true}, + "ob bootstrap": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob canonical": {Class: "finite_envelope", JSON: true}, + "ob deploy": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob destroy": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob doctor": {Class: "finite_envelope", JSON: true}, + "ob eject": {Class: "finite_envelope", JSON: true}, + "ob exec": {Class: "operator_passthrough", NDJSON: true}, + "ob init": {Class: "finite_envelope", JSON: true}, + "ob job plan": {Class: "finite_envelope", JSON: true}, + "ob job history": {Class: "finite_envelope", JSON: true}, + "ob job logs": {Class: "operator_passthrough", JSON: true, NDJSON: true}, + "ob backup create": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob backup enable": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob backup disable": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob backup drill": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob backup restore": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob backup prune": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob backup verify": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob backup status": {Class: "finite_envelope", JSON: true}, + "ob job run": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob logs": {Class: "operator_passthrough", JSON: true, NDJSON: true}, + "ob plan": {Class: "finite_envelope", JSON: true}, + "ob preflight": {Class: "finite_envelope", JSON: true}, + "ob preview": {Class: "finite_envelope", JSON: true}, + "ob proxy apply": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob resume": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob rollback": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob schedule apply": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob schedule list": {Class: "finite_envelope", JSON: true}, + "ob schedule pause": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob schedule resume": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob schema": {Class: "finite_envelope", JSON: true}, + "ob secrets edit": {Class: "trusted_editor", JSON: true}, + "ob secrets list": {Class: "finite_envelope", JSON: true}, + "ob secrets push": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob service apply": {Class: "finite_stream", JSON: true, NDJSON: true}, + "ob status": {Class: "finite_envelope", JSON: true}, + "ob validate": {Class: "finite_envelope", JSON: true}, + "ob version": {Class: "finite_envelope", JSON: true}, } if !reflect.DeepEqual(cliOutputMatrix, wantMatrix) { t.Fatalf("CLI output matrix changed:\ngot %#v\nwant %#v", cliOutputMatrix, wantMatrix) diff --git a/cmd/ob/preview.go b/cmd/ob/preview.go index a7c64f3a..e66ed9f5 100644 --- a/cmd/ob/preview.go +++ b/cmd/ob/preview.go @@ -31,7 +31,7 @@ func addPreviewCommand(root *cobra.Command, g *globalFlags) { cmd := &cobra.Command{ Use: "preview", Short: "render the runtime the declarative contract generates (no target, no changes)", - Long: "Load a onebox.run/v1alpha1 Application, resolve the environment's overrides, and print\n" + + Long: "Load a onebox.run/v1alpha2 Application, resolve the environment's overrides, and print\n" + "the Compose runtime Onebox would generate, with its content digest.\n\n" + "Nothing is contacted and nothing is written. Environment values are redacted:\n" + "a preview must never put a secret on a terminal.", diff --git a/cmd/ob/preview_test.go b/cmd/ob/preview_test.go index 545c91d6..0001dbdf 100644 --- a/cmd/ob/preview_test.go +++ b/cmd/ob/preview_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -const previewProject = `apiVersion: onebox.run/v1alpha1 +const previewProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo @@ -82,7 +82,7 @@ func TestPreviewAppliesEnvironmentOverrides(t *testing.T) { // wrong, where, and what to run. func TestPreviewFailureIsActionable(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo @@ -139,7 +139,7 @@ func dirEntries(t *testing.T, dir string) int { func TestEjectPicksAFreeName(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "compose.yaml", "services:\n db: {image: postgres}\n") - writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger diff --git a/cmd/ob/schedule.go b/cmd/ob/schedule.go index a50c1e73..e611be24 100644 --- a/cmd/ob/schedule.go +++ b/cmd/ob/schedule.go @@ -13,7 +13,6 @@ import ( // list reads what the host recorded. The read command connects directly, // like `ob status`; it holds no lock and writes nothing. func addScheduleCommands(root *cobra.Command, g *globalFlags) { - addExecutionCommands(root, g) scheduleCmd := &cobra.Command{Use: "schedule", Short: "manage host timers for scheduled jobs", Long: "Manage the systemd timers generated for scheduled jobs.\n\n" + "Timers outlive the Onebox process and the package installed on the operator\n" + diff --git a/cmd/ob/schema.go b/cmd/ob/schema.go index 3f0c192e..76e6c6d5 100644 --- a/cmd/ob/schema.go +++ b/cmd/ob/schema.go @@ -23,7 +23,7 @@ func addSchemaCommand(root *cobra.Command, g *globalFlags) { cmd := &cobra.Command{ Use: "schema", Short: "print the JSON Schema for the project file, for editors", - Long: "Write the JSON Schema for the `onebox.run/v1alpha1` Application resource.\n\n" + + Long: "Write the JSON Schema for the `onebox.run/v1alpha2` Application resource.\n\n" + "Reference it from the first line of a project so an editor can offer\n" + "completion, hover documentation and inline errors:\n\n" + " # yaml-language-server: $schema=" + app.SchemaID + "\n\n" + diff --git a/docs/README.md b/docs/README.md index 59744e3c..fc0cfb10 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,9 +6,10 @@ the repository rather than to a reader. | Path | What it is | |---|---| -| [`api/application/v1alpha1/application.schema.json`](../api/application/v1alpha1/application.schema.json) | The current JSON Schema for the authored Application resource. Generated from the Go model by `ob schema`, tested byte-for-byte against it, and published at the stable URL written by `ob init`. | +| [`api/application/v1alpha2/application.schema.json`](../api/application/v1alpha2/application.schema.json) | The current JSON Schema for the authored Application resource. Generated from the Go model by `ob schema`, tested byte-for-byte against it, and published at the stable URL written by `ob init`. | | [`product.md`](product.md) | Product direction. Not an implementation contract, and not a capability list. | -| [`decisions/2026-09-20-application-v1alpha1.md`](decisions/2026-09-20-application-v1alpha1.md) | Accepted decision and complete cutover inventory for the breaking `onebox.run/v1alpha1` Application baseline. It governs the staged implementation but does not claim the current binary has cut over. | +| [`decisions/2026-09-20-application-v1alpha1.md`](decisions/2026-09-20-application-v1alpha1.md) | Historical Application baseline decision and naming specification. | +| [`decisions/2026-10-04-job-schedule-simplification.md`](decisions/2026-10-04-job-schedule-simplification.md) | Current job boundary: single-command jobs, application-owned workflows, and the v1alpha2 Application contract. | ## Where the user documentation went diff --git a/docs/decisions/2026-10-04-job-schedule-simplification.md b/docs/decisions/2026-10-04-job-schedule-simplification.md new file mode 100644 index 00000000..39a6f027 --- /dev/null +++ b/docs/decisions/2026-10-04-job-schedule-simplification.md @@ -0,0 +1,46 @@ +# Jobs run commands; applications own workflows + +- Status: accepted and implemented +- Date: 2026-10-04 +- Scope: authored Application `onebox.run/v1alpha2`, job runtime, CLI, documentation +- Supersedes: [durable job executions](../plans/2026-09-06-durable-job-executions.md) + +## Decision + +A Onebox job runs one container command. A schedule adds a host systemd timer, +bounded retries, timeout, deployment coordination, and run records. Applications +own multi-step workflows, checkpoint recovery, output handoff, and effect +deduplication. Onebox does not provide a second workflow engine. + +Remove `workloads..execution`, its retention and step definitions, the +Python checkpoint runner, and `ob execution list/inspect/resume/abandon`. +Remove execution-specific compatibility invalidation and checkpoint-based +release pinning. Keep ordinary job inputs, notifications, history/logs, +pause/resume of timers, sealed job approvals, migration result evidence, +deployment recovery, and optional pinned scheduled jobs with live release leases. + +The workflow feature was added for the Monk sync/index sequence and Goal refresh +use cases in [#160](https://github.com/labstack/onebox/issues/160). Their current +manifests declare no `execution` block, and their Porter applications own their +workflow machinery. These examples demonstrate application needs; they do not +justify a general workflow contract in Onebox. This audit examined the current +checkouts, not their deployed host state. A temporary loader audit confirmed +that both manifests load with only the Application API identity updated. + +## Contract + +The Application contract is `onebox.run/v1alpha2`. Project declarations and +immutable release snapshots use the same strict loader and schema. + +Machine artifact schema identities remain independent. Deployment recovery, +migration evidence, backups, and approvals keep their wire formats. Job run +records continue to use the host journal as their outcome authority. + +## Scope kept small + +This change removes behavior. It does not reorganize all schedule options, +introduce another runner abstraction, require Python for ordinary jobs, or +replace the manual request protocol. Those would expand the alpha change +without establishing a user need. Pinned jobs remain because deployment should +not have to wait hours for a read-only command; the existing live lease protects +the immutable release while it runs. diff --git a/docs/plans/2026-09-06-durable-job-executions.md b/docs/plans/2026-09-06-durable-job-executions.md index 59b2d548..3930b506 100644 --- a/docs/plans/2026-09-06-durable-job-executions.md +++ b/docs/plans/2026-09-06-durable-job-executions.md @@ -1,5 +1,8 @@ # Durable job executions +Status: retired by [the job simplification decision](../decisions/2026-10-04-job-schedule-simplification.md). +This document records the original design; its execution API is no longer supported. + Date: 2026-09-06 Scope: [issue #160](https://github.com/labstack/onebox/issues/160). diff --git a/docs/plans/2026-10-04-job-schedule-simplification.md b/docs/plans/2026-10-04-job-schedule-simplification.md new file mode 100644 index 00000000..429c29ee --- /dev/null +++ b/docs/plans/2026-10-04-job-schedule-simplification.md @@ -0,0 +1,102 @@ +# Job and schedule simplification + +Date: 2026-10-04 +Status: feature reduction implemented; validation results below. +Decision: [Jobs run commands; applications own workflows](../decisions/2026-10-04-job-schedule-simplification.md). + +## Conclusion + +The overengineering was the durable workflow layer on top of an already useful +job scheduler. Remove that feature, not just rearrange its implementation. +Onebox should run a command at a specified time and make its outcome easy to +inspect. The application should decide how its work progresses and recovers. + +The smaller alpha scope needs no new Python requirement, runner framework, or +option migration. + +## Before and after + +| Area | Before | After | +| --- | --- | --- | +| What a job runs | One command or a declared sequence of steps | One container command | +| Workflow definition | Step IDs, commands, inputs from earlier outputs, output declarations | Application code owns the sequence | +| Recovery | Host JSON checkpoints, execution retention, same-release resume and abandon | Application owns progress and recovery; a Onebox retry starts the command again | +| Runtime | Shell runner plus an embedded Python workflow runner | Existing shell runner; no Python requirement for scheduled jobs | +| Operator surface | Job/schedule commands plus four `ob execution` commands | Job/schedule commands only | +| Retention | Live release leases plus persistent execution references | Live leases and container mounts | +| Application model | Optional workflow definition | `onebox.run/v1alpha2` with one command per job | + +## Due diligence + +Reviewed schema/defaults/validation, generated systemd units and shell scripts, +manual job admission, history/notifier behavior, workflow checkpoints, +deployment and release-retention interactions, CLI registrations, documentation, +and test coverage. The audit covered the source at +`c0d20fc6dbe40cced3eab92064acab2f42c60eeb` and the current sibling manifests. + +- [Issue #160](https://github.com/labstack/onebox/issues/160) introduced durable + executions around Monk's sync/index sequence and Goal's refresh work. Their + current `ob.yml` files contain no `execution` declaration. Their Porter + applications use Temporal and own their application workflows. No live host + was inspected. A temporary loader audit confirmed that both current + sibling manifests load after changing only the Application API identity; the + editor schema URL should also be updated. +- Ordinary jobs are used: Monk declares a weekly phase-none + job; both repositories declare deployment jobs and migration data effects. + These need scheduling and deployment safety, not Onebox step/output schemas. +- [Issue #133](https://github.com/labstack/onebox/issues/133) documents a separate + need for long jobs to coexist with deployments. Keep optional pinned jobs and + their live release leases; removing them would make deployments wait for the + entire command. +- Existing tests cover ordinary retries, timeouts, locks, inputs, notifications, + release leases, manual approval bindings, and outcome records. The retired + Python protocol had Go-integrated tests. Its separate server workflow test + was not selected by the normal server CI matrix; it is removed with the + feature rather than presented as verification of the remaining scheduler. + +## What changed + +1. Removed the execution field/types, step validator/defaults/schema rules, + checkpoint package, Python runtime/tests, durable runner generator, and + feature-specific server test. +2. Removed execution recovery methods, operation kinds/request fields, CLI + commands, output registrations, and their diagnostic codes. Removed checkpoint + compatibility invalidation from ordinary jobs and exec. +3. Kept single-command schedules, whole-command retries, bounded timeout and + shutdown grace, non-secret inputs, notifications, history/logs, timer pause and + resume, exclusive/pinned deployment coordination, and release lease safety. + Deployment resume/abort and migration result evidence remain supported. +4. Kept one strict Application model for project declarations and immutable + release snapshots. +5. Updated the Application schema, fixtures, examples, CLI generation, product + scope, capability inventory, and scheduling guide for v1alpha2. + +## Using the simpler model + +Declare an Application with `apiVersion: onebox.run/v1alpha2`. Give each job one +container command, its data effect, and a schedule when it should run on a timer. +Set retries and a timeout where useful. Keep progress, ordering, and recovery in +the application. Inspect runs with `ob job history` and `ob job logs`. + +See the [schedule guide](../../site/src/content/docs/guides/schedule-a-job.mdx) +for complete examples. Sibling repositories were not modified. + +## Validation + +- `just check`, lint, vulnerability, and environment-namespace checks pass: + module/format checks, Go vet and tests, generated-doc/schema checks, and the + documentation site build. Local workflow lint did not finish; workflow parsing + passes with its ShellCheck integration disabled. +- Docker E2E covers rolling deployment, unchanged immutable snapshots, + payload digests, and HTTP continuity under the same Application contract. +- Regression tests enforce the single Application identity, reject workflow + fields before deployment staging, and retain ordinary scheduling controls. +- Both current sibling manifests passed a temporary loader audit after only + the API identity update. The temporary audit test was removed from the project. +- All 16 rendered landing-page manifest combinations validate. The proxy DNS + challenge example uses the schema's `dnsChallenge` field. +- Embedded and website schemas match; local report links resolve, whitespace + checks pass, and the updated prose passes the typo check. + +Not run: real-server/systemd E2E, reboot recovery, or live-host testing. No +production host or sibling repository was changed. diff --git a/docs/product.md b/docs/product.md index 442dfc48..02c8a89f 100644 --- a/docs/product.md +++ b/docs/product.md @@ -56,6 +56,11 @@ unattended full restore drills, and log rotation.** Onebox says so rather than implying otherwise — `ob doctor` reports every durable workload or service that has no executable backup contract, because silence there would read as approval. +Scheduled jobs run one application container command with a host timer, a +timeout, bounded retries, deployment coordination, and run records. Application +code owns multi-step workflows, progress checkpoints, and output handoff. +Onebox keeps that boundary small; it does not supply a workflow engine. + PostgreSQL backup is delivered as part of the pinned Onebox PostgreSQL image: the image owns the compatible WAL-G executable; Onebox owns policy, generated credential adaptation, scheduling, verification, and recovery orchestration. diff --git a/e2e/apps/README.md b/e2e/apps/README.md index 13053406..fd404e42 100644 --- a/e2e/apps/README.md +++ b/e2e/apps/README.md @@ -1,6 +1,6 @@ # Deployable application fixtures -Self-contained `onebox.run/v1alpha1` Applications for real open-source applications, +Self-contained `onebox.run/v1alpha2` Applications for real open-source applications, chosen for the shape people normally build rather than for being exotic. Each declares everything it needs, so it renders and runs without a Compose reference. diff --git a/e2e/apps/authentik.yml b/e2e/apps/authentik.yml index 5af6deb1..e616ab38 100644 --- a/e2e/apps/authentik.yml +++ b/e2e/apps/authentik.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: authentik diff --git a/e2e/apps/ghost.yml b/e2e/apps/ghost.yml index ba7cb7e7..3d370769 100644 --- a/e2e/apps/ghost.yml +++ b/e2e/apps/ghost.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ghost diff --git a/e2e/apps/gitea.yml b/e2e/apps/gitea.yml index 97530f42..fb936c4d 100644 --- a/e2e/apps/gitea.yml +++ b/e2e/apps/gitea.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: gitea diff --git a/e2e/apps/immich.yml b/e2e/apps/immich.yml index 5fe7c508..e95eb292 100644 --- a/e2e/apps/immich.yml +++ b/e2e/apps/immich.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: immich diff --git a/e2e/apps/n8n.yml b/e2e/apps/n8n.yml index d9844148..e667edc7 100644 --- a/e2e/apps/n8n.yml +++ b/e2e/apps/n8n.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: n8n diff --git a/e2e/apps/paperless.yml b/e2e/apps/paperless.yml index 51195021..77fe1433 100644 --- a/e2e/apps/paperless.yml +++ b/e2e/apps/paperless.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: paperless diff --git a/e2e/apps/penpot.yml b/e2e/apps/penpot.yml index 46aeef51..3d4e54e6 100644 --- a/e2e/apps/penpot.yml +++ b/e2e/apps/penpot.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: penpot diff --git a/e2e/apps/rocketchat.yml b/e2e/apps/rocketchat.yml index 8ab1b1a3..7d4bb955 100644 --- a/e2e/apps/rocketchat.yml +++ b/e2e/apps/rocketchat.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: rocketchat diff --git a/e2e/apps/umami.yml b/e2e/apps/umami.yml index 50bbfa4b..681176d1 100644 --- a/e2e/apps/umami.yml +++ b/e2e/apps/umami.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: umami diff --git a/e2e/apps/uptime-kuma.yml b/e2e/apps/uptime-kuma.yml index 9a6bd6fe..4e2e3766 100644 --- a/e2e/apps/uptime-kuma.yml +++ b/e2e/apps/uptime-kuma.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: uptime-kuma diff --git a/e2e/apps/vaultwarden.yml b/e2e/apps/vaultwarden.yml index 51f61159..9819f360 100644 --- a/e2e/apps/vaultwarden.yml +++ b/e2e/apps/vaultwarden.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: vaultwarden diff --git a/e2e/destroy_test.go b/e2e/destroy_test.go index 3379639f..5654f55b 100644 --- a/e2e/destroy_test.go +++ b/e2e/destroy_test.go @@ -27,7 +27,7 @@ func TestDestroyUsesReleaseRecordedInterpolationEnvironment(t *testing.T) { releaseID := "20260821-120000-legacy" volume := application + "_legacy_data" - currentBody := fmt.Sprintf(`apiVersion: onebox.run/v1alpha1 + currentBody := fmt.Sprintf(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: %s} spec: diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index c6066f2a..e8453bdd 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -87,7 +87,8 @@ func TestZeroDowntimeDeploy(t *testing.T) { return err } staging := t.TempDir() - if err := release.Stage(staging, rendered.Bytes, releaseSnapshot(t, dir, "ob.yml", base)); err != nil { + snapshot := releaseSnapshot(t, dir, "ob.yml", base) + if err := release.Stage(staging, rendered.Bytes, snapshot); err != nil { return err } lastResolved, lastStaging, lastID = resolved, staging, id @@ -132,6 +133,7 @@ func TestZeroDowntimeDeploy(t *testing.T) { assertContainerNames(t, "obe2e", "web", "obe2e-web-1") waitBody(t, "http://localhost:18080/", "v1\n", 30*time.Second) assertPayloadDigestsAgree("after v1") + previousSnapshot := filepath.Join(base, "app", "releases", lastID, "onebox.snapshot.yml") // hammer the edge during the v2 deploy; count failures var failures, total atomic.Int64 @@ -172,6 +174,10 @@ func TestZeroDowntimeDeploy(t *testing.T) { t.Fatalf("deploy v2: %v", err) } assertContainerNames(t, "obe2e", "web", "obe2e-web-1") + oldSnapshot, err := os.ReadFile(previousSnapshot) + if err != nil || !strings.Contains(string(oldSnapshot), app.APIVersion) { + t.Fatalf("deploy changed the predecessor snapshot identity: %v", err) + } // The redeploy case matters more than the first: this release directory now // sits alongside a predecessor and carries the manifest the lifecycle wrote diff --git a/e2e/network_ownership_test.go b/e2e/network_ownership_test.go index efd6eb74..17c46ea1 100644 --- a/e2e/network_ownership_test.go +++ b/e2e/network_ownership_test.go @@ -24,7 +24,7 @@ func TestApplicationNetworkOwnershipAndExternalLifecycle(t *testing.T) { application := fmt.Sprintf("obnet%d", os.Getpid()) network := application + "_default" - projectBody := fmt.Sprintf(`apiVersion: onebox.run/v1alpha1 + projectBody := fmt.Sprintf(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: %s} spec: diff --git a/e2e/server_execution_test.go b/e2e/server_execution_test.go deleted file mode 100644 index 13a59546..00000000 --- a/e2e/server_execution_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package e2e - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/labstack/onebox/internal/app" -) - -// Exercise the public CLI through SSH, generated systemd units, real Docker, -// and persistent host files. The fixture owns only its unique app/base path. -func TestServerDurableExecutions(t *testing.T) { - s := requireServer(t) - s.requireDocker(t) - name := fmt.Sprintf("durable%d", time.Now().UnixNano()) - base := "/tmp/onebox-" + name - root := base + "/app" - // This fixture shares the server with the suite's own application, so it - // needs host state of its own; one host holds one owner record otherwise. - t.Setenv(app.TestHostStateDirEnv, base+"/_host") - unit := "onebox-job-refresh" - dir := t.TempDir() - s.run(t, "mkdir -p "+base+"/data") - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _, _ = s.output(ctx, "systemctl disable --now "+unit+".timer >/dev/null 2>&1; systemctl stop "+unit+".service >/dev/null 2>&1; docker rm -f "+name+"-refresh-1 >/dev/null 2>&1; rm -f /etc/systemd/system/"+unit+".*; systemctl daemon-reload; rm -rf "+base) - }) - project := fmt.Sprintf(`apiVersion: onebox.run/v1alpha1 -kind: Application -metadata: {name: %s} -spec: - basePath: %s - environments: {production: {server: %s}} - proxy: {managed: false} - workloads: - refresh: - role: Job - image: docker.io/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 - command: ["true"] - dataEffect: None - inputs: - SOURCE: {enum: [catalog, custom], default: catalog} - volumes: - - {source: %s/data, path: /data} - schedule: {cron: "0 0 1 1 *", timeout: 30s, catchUp: false} - execution: - retention: 168h - steps: - - id: sync - command: - - sh - - -c - - | - echo "$SOURCE" >> /data/sync.log - printf '{"RELEASE":"release-123"}' > "$ONEBOX_OUTPUT_FILE" - outputs: [RELEASE] - - id: index - inputs: {RELEASE_ID: sync.RELEASE} - command: - - sh - - -c - - | - echo "$ONEBOX_STEP_ID $ONEBOX_ATTEMPT_ID $RELEASE_ID" >> /data/index.log - while test -f /data/hold; do touch /data/started; sleep 1; done - test -f /data/allow -`, name, base, s.target, base) - if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(project), 0o600); err != nil { - t.Fatal(err) - } - s.deploy(t, dir) - if out, err := s.obInput(t, dir, s.obHome(t), "y\n", "job", "run", "refresh", "--input", "SOURCE=custom"); err == nil { - t.Fatalf("index should fail before allow marker: %s", out) - } - list := s.mustOb(t, dir, "execution", "list", "--output", "json") - var envelope struct { - Data struct { - Executions []map[string]any `json:"executions"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(list), &envelope); err != nil { - t.Fatal(err) - } - if len(envelope.Data.Executions) != 1 { - t.Fatalf("expected one execution: %s", list) - } - record := envelope.Data.Executions[0] - id, _ := record["id"].(string) - if id == "" || record["state"] != "failed" { - t.Fatalf("wrong execution evidence: %s", list) - } - s.run(t, "touch "+base+"/data/allow") - s.mustOb(t, dir, "execution", "resume", id, "--wait") - if got := strings.TrimSpace(s.run(t, "cat "+base+"/data/sync.log")); got != "custom" { - t.Fatalf("sync repeated or original input lost: %q", got) - } - lines := strings.Split(strings.TrimSpace(s.run(t, "cat "+base+"/data/index.log")), "\n") - if len(lines) != 2 { - t.Fatalf("expected original index and resumed attempt: %q", lines) - } - first, second := strings.Fields(lines[0]), strings.Fields(lines[1]) - if len(first) != 3 || len(second) != 3 || first[0] != second[0] || first[1] == second[1] || second[2] != "release-123" { - t.Fatalf("attempt identities/output handoff failed: %q", lines) - } - inspect := s.mustOb(t, dir, "execution", "inspect", id, "--output", "json") - if !strings.Contains(inspect, `"state": "succeeded"`) { - t.Fatalf("execution did not finish: %s", inspect) - } - if out, err := s.ob(t, dir, "execution", "resume", id, "--wait"); err == nil { - t.Fatalf("terminal resume succeeded: %s", out) - } - // Interrupted work is retained independently of the host journal. A new - // failed execution can be inspected after its runner and notifier exit. - s.run(t, "rm -f "+base+"/data/allow") - _, _ = s.obInput(t, dir, s.obHome(t), "y\n", "job", "run", "refresh") - pins := s.run(t, "/usr/bin/python3 "+root+"/schedule/execution-v1.py pins "+root) - if strings.TrimSpace(pins) == "" { - t.Fatal("failed execution lost its durable release reference") - } - // A killed activation must be inspectable and resume only its interrupted - // step. Concurrent resume and deployment coordination must refuse live work. - s.run(t, "touch "+base+"/data/hold") - if out, err := s.obInput(t, dir, s.obHome(t), "y\n", "job", "run", "refresh", "--detach"); err != nil { - t.Fatalf("detached execution failed: %v\n%s", err, out) - } - deadline := time.Now().Add(15 * time.Second) - for s.try(t, "test -f "+base+"/data/started") != nil { - if time.Now().After(deadline) { - t.Fatal("index never started") - } - time.Sleep(100 * time.Millisecond) - } - list = s.mustOb(t, dir, "execution", "list", "--output", "json") - if err := json.Unmarshal([]byte(list), &envelope); err != nil { - t.Fatal(err) - } - id = envelope.Data.Executions[0]["id"].(string) - if out, err := s.ob(t, dir, "execution", "resume", id); err == nil { - t.Fatalf("resumed active work: %s", out) - } - if out, err := s.ob(t, dir, "schedule", "apply"); err == nil { - t.Fatalf("exclusive execution allowed schedule apply: %s", out) - } - // systemd can report ESRCH for an auxiliary process that exits while the - // cgroup is being killed. The terminal unit state below is the assertion. - _, _ = s.output(t.Context(), "systemctl kill --kill-whom=all --signal=KILL "+unit+".service") - deadline = time.Now().Add(10 * time.Second) - for { - active := strings.TrimSpace(s.run(t, "systemctl show "+unit+".service --property=ActiveState --value")) - if active == "failed" || active == "inactive" { - break - } - if time.Now().After(deadline) { - t.Fatal("killed execution did not stop") - } - time.Sleep(100 * time.Millisecond) - } - inspect = s.mustOb(t, dir, "execution", "inspect", id, "--output", "json") - if !strings.Contains(inspect, `"state": "interrupted"`) { - t.Fatalf("killed execution not interrupted: %s", inspect) - } - s.run(t, "rm -f "+base+"/data/hold; touch "+base+"/data/allow") - s.mustOb(t, dir, "execution", "resume", id, "--wait") - if got := strings.Fields(s.run(t, "cat "+base+"/data/sync.log")); len(got) != 3 { - t.Fatalf("crash resume repeated sync: %v", got) - } -} diff --git a/e2e/testdata/app/ob.yml b/e2e/testdata/app/ob.yml index 94d4f830..fe78ec93 100644 --- a/e2e/testdata/app/ob.yml +++ b/e2e/testdata/app/ob.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: obe2e diff --git a/e2e/testdata/postgres/ob.yml.tmpl b/e2e/testdata/postgres/ob.yml.tmpl index b29e0785..9901a97c 100644 --- a/e2e/testdata/postgres/ob.yml.tmpl +++ b/e2e/testdata/postgres/ob.yml.tmpl @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: observer diff --git a/e2e/testdata/worker/broken.yml b/e2e/testdata/worker/broken.yml index 8d34da5f..a7208d87 100644 --- a/e2e/testdata/worker/broken.yml +++ b/e2e/testdata/worker/broken.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: obworker diff --git a/e2e/testdata/worker/ob.yml b/e2e/testdata/worker/ob.yml index ec060e23..078c8a98 100644 --- a/e2e/testdata/worker/ob.yml +++ b/e2e/testdata/worker/ob.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: obworker diff --git a/internal/app/application_fixture_test.go b/internal/app/application_fixture_test.go index 09e370aa..a62b58c9 100644 --- a/internal/app/application_fixture_test.go +++ b/internal/app/application_fixture_test.go @@ -11,7 +11,7 @@ import ( "gopkg.in/yaml.v3" ) -// loadFixtureBytes repairs only v1alpha1 test fragments assembled through Go +// loadFixtureBytes repairs only current-API test fragments assembled through Go // string concatenation. Repository-owned YAML files and the shipped loader do // not pass through it. Direct contract tests below call LoadBytes itself. func loadFixtureBytes(body []byte, filename string) (*Spec, error) { @@ -204,10 +204,10 @@ func TestApplicationContractRejectsLegacyForms(t *testing.T) { legacy := []string{ "api_version: onebox.run/v1\napp: shop\nenvironments: {}\nimage: nginx\n", "apiVersion: onebox.run/v1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}}\n", - "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}, base_path: /srv/ob}\n", - "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {web: {image: nginx, role: application}}}\n", - "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}}\nimage: nginx\n", - "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}, x-behavior: true}\n", + "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}, base_path: /srv/ob}\n", + "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {web: {image: nginx, role: application}}}\n", + "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}}\nimage: nginx\n", + "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}, x-behavior: true}\n", } for _, source := range legacy { if _, err := LoadBytes([]byte(source), "legacy.yml"); err == nil { @@ -224,7 +224,7 @@ func TestApplicationContractRejectsMalformedAnnotations(t *testing.T) { "non-string": "{note: 1}", } { t.Run(name, func(t *testing.T) { - source := `apiVersion: onebox.run/v1alpha1 + source := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -246,7 +246,7 @@ spec: } func TestProviderNativeValuesRemainData(t *testing.T) { - source := `apiVersion: onebox.run/v1alpha1 + source := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: diff --git a/internal/app/backup_schema_test.go b/internal/app/backup_schema_test.go index 541d6621..74f970a1 100644 --- a/internal/app/backup_schema_test.go +++ b/internal/app/backup_schema_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -const validBackupProject = `apiVersion: onebox.run/v1alpha1 +const validBackupProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -69,7 +69,7 @@ func TestBackupIntentLoadsAndDefaultsToExactSchedules(t *testing.T) { // The refusal belongs at the point the policy is written, so this is now the // same rejection every other unqualified driver gets. func TestMinIOBackupIntentIsRefusedUntilItsContractRuns(t *testing.T) { - project := `apiVersion: onebox.run/v1alpha1 + project := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -106,7 +106,7 @@ func TestReplicationIntentIsRejected(t *testing.T) { } func TestRunnableUnqualifiedDriverRejectsBackupWithoutFallback(t *testing.T) { - if _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + if _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/canonical_test.go b/internal/app/canonical_test.go index bc8fd025..71ac8181 100644 --- a/internal/app/canonical_test.go +++ b/internal/app/canonical_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -const canonicalProject = `apiVersion: onebox.run/v1alpha1 +const canonicalProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -225,7 +225,7 @@ func TestCanonicalFactsRejectUnsafeObservedValuesWithoutReflectingThem(t *testin // silently absent, and the canonical form — the thing people read to find out // what Onebox understood — did not show it either. func TestEveryDefaultAppearsAsDerived(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/compose_test.go b/internal/app/compose_test.go index 5ffc57cc..5be3c662 100644 --- a/internal/app/compose_test.go +++ b/internal/app/compose_test.go @@ -132,7 +132,7 @@ func TestPathEscapeRefused(t *testing.T) { // TestComposeRefRendersEndToEnd puts the merge through generation. func TestComposeRefRendersEndToEnd(t *testing.T) { - y := `apiVersion: onebox.run/v1alpha1 + y := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -230,7 +230,7 @@ func TestADeclaredHealthCheckReachesAReferencedService(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "ob.yml") - if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha1 + if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/contract_shapes_test.go b/internal/app/contract_shapes_test.go index fa26c1f6..1bb2552c 100644 --- a/internal/app/contract_shapes_test.go +++ b/internal/app/contract_shapes_test.go @@ -39,7 +39,7 @@ func canonicalOf(t *testing.T, body string) string { return string(out) } -const shapeHead = `apiVersion: onebox.run/v1alpha1 +const shapeHead = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/defaults.go b/internal/app/defaults.go index 38a9fd61..c3f4da6c 100644 --- a/internal/app/defaults.go +++ b/internal/app/defaults.go @@ -116,10 +116,6 @@ func applyDefaults(p *Spec, raw map[string]any, derived map[string]Origin) { w.Schedule.Timezone = "UTC" mark(path + ".schedule.timezone") } - if w.Execution != nil && w.Execution.Retention == "" { - w.Execution.Retention = "168h" - mark(path + ".execution.retention") - } if w.Schedule.Timeout == "" { w.Schedule.Timeout = "1h" mark(path + ".schedule.timeout") diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go index 2cda4c84..b76c3bc6 100644 --- a/internal/app/eject_test.go +++ b/internal/app/eject_test.go @@ -9,7 +9,7 @@ import ( "gopkg.in/yaml.v3" ) -const ejectProject = `apiVersion: onebox.run/v1alpha1 +const ejectProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: # Ledger's production contract. @@ -193,7 +193,7 @@ func TestEjectCarriesTheAuthorsNote(t *testing.T) { // shaped by the file. Leaving a health check or a volume in the project would // let someone edit it, see no effect, and get no error. func TestEjectRemovesWhatTheComposeFileNowOwns(t *testing.T) { - dir, _ := ejectInto(t, `apiVersion: onebox.run/v1alpha1 + dir, _ := ejectInto(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -230,7 +230,7 @@ spec: func TestEjectDefaultAvoidsAReferencedFile(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services:\n db: {image: postgres}\n"), 0o600) - os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`apiVersion: onebox.run/v1alpha1 + os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -269,7 +269,7 @@ spec: func TestEjectAfterAnInterruptionCompletes(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/ejection_contract_test.go b/internal/app/ejection_contract_test.go index 26a202d5..6c9a7e9b 100644 --- a/internal/app/ejection_contract_test.go +++ b/internal/app/ejection_contract_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -const ejectContractProject = `apiVersion: onebox.run/v1alpha1 +const ejectContractProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/environment_model_test.go b/internal/app/environment_model_test.go index 9d24457c..e93bc037 100644 --- a/internal/app/environment_model_test.go +++ b/internal/app/environment_model_test.go @@ -55,7 +55,7 @@ func listFor(t *testing.T, r *Resolved, workload string) []string { return out } -const envModelBody = `apiVersion: onebox.run/v1alpha1 +const envModelBody = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -141,7 +141,7 @@ func TestTwoEntriesNeverShareAStagedFile(t *testing.T) { // The withdrawn block has no alias in the reset contract. func TestTheWithdrawnSecretsBlockIsUnknown(t *testing.T) { - _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha1 + _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -163,7 +163,7 @@ spec: // An authored value may not claim a name a connection supplies. func TestAuthoredValuesCannotClaimAConnectionVariable(t *testing.T) { - _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha1 + _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -192,7 +192,7 @@ spec: // A compose-sourced application receives what an image-sourced one receives, // and ejecting then generating does not duplicate the projection. func TestComposeSourcedWorkloadsAreNotASpecialCase(t *testing.T) { - path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 + path := envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -249,7 +249,7 @@ spec: // rolling release waited out its entire budget and then reported the container // unhealthy, naming the container and saying nothing about the port. func TestAnHTTPProbeInheritsTheRoutedPort(t *testing.T) { - r := resolvedFor(t, envModelProject(t, `apiVersion: onebox.run/v1alpha1 + r := resolvedFor(t, envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -281,7 +281,7 @@ spec: // a contract treating "how it is stored" as "who may see it" would let the // commoner form leak. func TestNoEntryValueReachesAnArtifact(t *testing.T) { - path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 + path := envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -326,7 +326,7 @@ spec: // rolling release waits out in full before reporting the container unhealthy // without naming a port. func TestAProbeWithNoPortIsRefused(t *testing.T) { - _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha1 + _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -368,7 +368,7 @@ func composeServiceEnvFiles(t *testing.T, runtime []byte, service string) []stri // adds. Both halves were unguarded — deleting the projection outright left the // suite green. func TestTheProjectionAppendsAndPreservesOrder(t *testing.T) { - path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 + path := envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -409,7 +409,7 @@ spec: // cannot be shadowed by one. Emitting them in the other order passed every // test. func TestConnectionFilesComeAfterDeclaredEntries(t *testing.T) { - path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 + path := envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -445,7 +445,7 @@ spec: // half was tested; this half is a scenario stated twice in the contract and had // no test — making the check unconditionally return nil passed everything. func TestAReferencedServiceCannotClaimAConnectionVariable(t *testing.T) { - _, err := resolvedForErr(t, envModelProject(t, `apiVersion: onebox.run/v1alpha1 + _, err := resolvedForErr(t, envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -497,7 +497,7 @@ func resolvedForErr(t *testing.T, path string) ([]byte, error) { // asked for interpolation. Stopping a correct project from loading is a worse // failure than the one it would prevent. func TestAnEncryptedEntryDoesNotBlockAProjectThatNeedsNoInterpolation(t *testing.T) { - path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 + path := envModelProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -580,7 +580,7 @@ func TestAnOverrideDeclaringNoneIsPreserved(t *testing.T) { // after the release is staged and the old one is coming down. The name is in // the document; there is no reason to find out there. func TestAnEntryNamingAMissingFileIsRefused(t *testing.T) { - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/external_schema_test.go b/internal/app/external_schema_test.go index 268b8e0d..d1b31832 100644 --- a/internal/app/external_schema_test.go +++ b/internal/app/external_schema_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -const validExternalServiceProject = `apiVersion: onebox.run/v1alpha1 +const validExternalServiceProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -40,7 +40,7 @@ func TestExternalServiceFixtures(t *testing.T) { }, { name: "external_service_ambiguous_owner", - yaml: `apiVersion: onebox.run/v1alpha1 + yaml: `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -59,7 +59,7 @@ spec: }, { name: "external_service_lifecycle_field_refused", - yaml: `apiVersion: onebox.run/v1alpha1 + yaml: `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index f4f687e2..cb33cbce 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -11,7 +11,7 @@ import ( // A decent-size project of the shape people actually build: a web application, // a background worker, a migration job, and a database they still author. -const appFixture = `apiVersion: onebox.run/v1alpha1 +const appFixture = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -89,7 +89,7 @@ func TestRenderIsDeterministic(t *testing.T) { } func TestWorkloadRevisionIsReleaseIndependentAndRuntimeSensitive(t *testing.T) { - project := `apiVersion: onebox.run/v1alpha1 + project := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -368,7 +368,7 @@ func TestHasTerminatingTLSDistinguishesPassthrough(t *testing.T) { } func TestWildcardRouteRendersSafeHostRegexpAndDNSResolver(t *testing.T) { - project := `apiVersion: onebox.run/v1alpha1 + project := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: preview @@ -449,7 +449,7 @@ func TestEveryDraftRenders(t *testing.T) { // showed standing between two thirds of services and the declaration. Each // carries no Onebox semantics: it is declared, and it appears. func TestPassthroughFields(t *testing.T) { - y := `apiVersion: onebox.run/v1alpha1 + y := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -494,7 +494,7 @@ spec: // generates into are reserved, so a user label can never silently win. func TestUserLabelsCannotClaimOneboxNamespaces(t *testing.T) { for _, bad := range []string{"onebox.app", "traefik.enable"} { - y := `apiVersion: onebox.run/v1alpha1 + y := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: ledger} spec: @@ -533,7 +533,7 @@ func TestVolumeNamesArePinned(t *testing.T) { // workload that can never be released, so the exec form must reach the runtime // as CMD rather than CMD-SHELL. func TestExecListHealthRunsWithoutAShell(t *testing.T) { - out := render(t, `apiVersion: onebox.run/v1alpha1 + out := render(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -557,7 +557,7 @@ spec: // The string form still runs through a shell, which is what makes `pg_isready // -U x && test -f /ready` work. func TestExecStringHealthKeepsItsShell(t *testing.T) { - out := render(t, `apiVersion: onebox.run/v1alpha1 + out := render(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -581,7 +581,7 @@ func TestShellHealthChecksCarryTheDrainGuard(t *testing.T) { `health: {tcp: true, port: 5432}`, `health: {exec: "test -f /ready"}`, } { - out := string(render(t, `apiVersion: onebox.run/v1alpha1 + out := string(render(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: @@ -602,7 +602,7 @@ spec: // command and stays unquoted; a path is not, and must be one argument. func TestHTTPHealthPathIsQuotedInsideItsShellCheck(t *testing.T) { const injected = "/healthz;id>/tmp/ob-owned" - out := string(render(t, `apiVersion: onebox.run/v1alpha1 + out := string(render(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: diff --git a/internal/app/health_timing_test.go b/internal/app/health_timing_test.go index 9029f4e3..caac4eec 100644 --- a/internal/app/health_timing_test.go +++ b/internal/app/health_timing_test.go @@ -111,7 +111,7 @@ func TestReadyBudgetCoversAtLeastOneFlipCycle(t *testing.T) { // it negative, which expires instantly — the failure the budget exists to // prevent, reached by a route validation could have closed. func TestAbsurdRetriesIsRejected(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -140,7 +140,7 @@ func TestAbsurdHealthDurationsAreRejected(t *testing.T) { "within": "{http: /healthz, within: 100000d}", } { t.Run(name, func(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -218,7 +218,7 @@ func TestParseDurationRejectsOverflowingDayCounts(t *testing.T) { // A day count that wraps int64 must be rejected by validation too, not merely // by the parser: the two together are what make the bound mean something. func TestOverflowingDayCountIsRejectedAtLoad(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -242,7 +242,7 @@ func TestAbsurdDrainDurationsAreRejected(t *testing.T) { "grace": "{grace: 100000d}", } { t.Run(name, func(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger diff --git a/internal/app/job_execution.go b/internal/app/job_execution.go deleted file mode 100644 index ca0001b5..00000000 --- a/internal/app/job_execution.go +++ /dev/null @@ -1,104 +0,0 @@ -package app - -import ( - "strings" - "time" -) - -// validateJobExecution keeps persisted definitions small and limits recovery to -// jobs whose data-effect policy already permits operator-initiated execution. -func validateJobExecution(w Workload, path string) error { - if w.Execution == nil { - return nil - } - p := path + ".execution" - if !w.IsJob() || w.Schedule == nil || (w.DeploymentPhase != "" && w.DeploymentPhase != "none") || (w.OperatorRun != "" && w.OperatorRun != "allowed") || w.DataEffect != DataEffectNone || w.Compose != "" { - return errf("project_invalid", p, "", "durable execution requires a native scheduled operator-runnable phase-none job with data_effect none") - } - if w.Execution.Retention != "" { - d, ok := ParseDuration(w.Execution.Retention) - if !ok || d <= 0 || d > 30*24*time.Hour { - return errf("project_invalid", p+".retention", "", "retention must be a positive duration at most 30d") - } - } - if len(w.Inputs) > 32 { - return errf("project_invalid", path+".inputs", "", "durable execution supports at most 32 original inputs") - } - if len(w.Execution.Steps) > 32 { - return errf("project_invalid", p+".steps", "", "durable execution supports at most 32 steps") - } - previous := map[string]map[string]bool{} - var backoffTotal time.Duration - for i, step := range w.Execution.Steps { - sp := indexed(p+".steps", i) - if err := gIdent.check(sp+".id", step.ID); err != nil { - return err - } - if previous[step.ID] != nil { - return errf("project_invalid", sp+".id", "", "step IDs must be unique") - } - if len(step.Command) == 0 || len(step.Command) > 128 || step.Command[0] == "" { - return errf("project_invalid", sp+".command", "", "command requires a nonempty first argument and at most 128 arguments") - } - total := 0 - for _, arg := range step.Command { - total += len(arg) - if strings.ContainsRune(arg, 0) || len(arg) > 4096 || total > 16384 { - return errf("project_invalid", sp+".command", "", "command arguments must contain no NUL and fit within 4096 bytes each and 16384 bytes total") - } - } - if len(step.Inputs) > 32 || len(step.Outputs) > 32 { - return errf("project_invalid", sp, "", "steps support at most 32 inputs and 32 outputs") - } - for _, name := range sortedKeys(step.Inputs) { - ip := sp + ".inputs." + name - if err := validateExecutionValueName(ip, name); err != nil { - return err - } - _, envClash := w.Env[name] - _, inputClash := w.Inputs[name] - if envClash || inputClash { - return errf("project_invalid", ip, "", "step input collides with a job env key or original input") - } - from, output, ok := strings.Cut(step.Inputs[name], ".") - if !ok || !previous[from][output] { - return errf("project_invalid", ip, "", "reference must be precedingStep.OUTPUT naming a declared output of an earlier step") - } - } - outputs := map[string]bool{} - for j, name := range step.Outputs { - op := indexed(sp+".outputs", j) - if err := validateExecutionValueName(op, name); err != nil { - return err - } - if outputs[name] { - return errf("project_invalid", op, "", "output names must be unique within a step") - } - outputs[name] = true - } - previous[step.ID] = outputs - policy := &JobSchedule{Timeout: w.Schedule.Timeout, Retry: step.Retry} - if policy.Retry == nil { - policy.Retry = w.Schedule.Retry - } - if err := validateJobRetry(policy, sp); err != nil { - return err - } - attempts, backoff, max := policy.retryPolicy() - backoffTotal += scheduleRetryWorstCase(attempts, backoff, max) - } - if backoffTotal >= w.Schedule.scheduleTimeout() { - return errf("project_invalid", p+".steps", "", "combined worst-case step backoff %s must be smaller than schedule timeout %s", backoffTotal, w.Schedule.scheduleTimeout()) - } - return nil -} - -func validateExecutionValueName(path, name string) error { - if err := gInputName.check(path, name); err != nil { - return err - } - if len(name) > 128 || strings.HasPrefix(name, ReservedInputPrefix) { - return errf("project_invalid", path, "", "names must be at most 128 bytes and outside the %s namespace", ReservedInputPrefix) - } - return nil -} diff --git a/internal/app/job_execution_test.go b/internal/app/job_execution_test.go deleted file mode 100644 index c533597b..00000000 --- a/internal/app/job_execution_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package app - -import ( - "fmt" - "strings" - "testing" -) - -func executionWorkload() Workload { - return Workload{ - Role: "job", DeploymentPhase: "none", OperatorRun: "allowed", DataEffect: DataEffectNone, - Schedule: &JobSchedule{Timeout: "1h"}, - Execution: &JobExecution{Steps: []JobStep{ - {ID: "sync", Command: []string{"sync"}, Outputs: []string{"RELEASE"}}, - {ID: "index", Command: []string{"index"}, Inputs: map[string]string{"RELEASE_ID": "sync.RELEASE"}}, - }}, - } -} - -func TestValidateJobExecution(t *testing.T) { - for _, tc := range []struct { - name string - edit func(*Workload) - want string - }{ - {"valid", func(w *Workload) {}, ""}, - {"single command", func(w *Workload) { w.Execution.Steps = nil }, ""}, - {"disabled", func(w *Workload) { w.Execution = nil; w.Role = "application" }, ""}, - {"default when", func(w *Workload) { w.DeploymentPhase = "" }, ""}, - {"retention boundary", func(w *Workload) { w.Execution.Retention = "30d" }, ""}, - {"not job", func(w *Workload) { w.Role = "application" }, "native scheduled operator-runnable phase-none job"}, - {"not scheduled", func(w *Workload) { w.Schedule = nil }, "native scheduled operator-runnable phase-none job"}, - {"release hook", func(w *Workload) { w.DeploymentPhase = "pre_release" }, "native scheduled operator-runnable phase-none job"}, - {"migration", func(w *Workload) { w.DataEffect = DataEffectMigration }, "native scheduled operator-runnable phase-none job"}, - {"compose", func(w *Workload) { w.Compose = "compose.yml#job" }, "native scheduled operator-runnable phase-none job"}, - {"retention invalid", func(w *Workload) { w.Execution.Retention = "forever" }, "retention"}, - {"retention zero", func(w *Workload) { w.Execution.Retention = "0s" }, "retention"}, - {"retention negative", func(w *Workload) { w.Execution.Retention = "-1h" }, "retention"}, - {"retention excessive", func(w *Workload) { w.Execution.Retention = "721h" }, "retention"}, - {"duplicate step", func(w *Workload) { w.Execution.Steps[1].ID = "sync" }, "unique"}, - {"bad id", func(w *Workload) { w.Execution.Steps[0].ID = "../sync" }, ".id"}, - {"missing command", func(w *Workload) { w.Execution.Steps[0].Command = nil }, "command"}, - {"empty command", func(w *Workload) { w.Execution.Steps[0].Command = []string{""} }, "command"}, - {"nul command", func(w *Workload) { w.Execution.Steps[0].Command = []string{"sync\x00"} }, "NUL"}, - {"large argument", func(w *Workload) { w.Execution.Steps[0].Command = []string{strings.Repeat("x", 4097)} }, "4096"}, - {"large command", func(w *Workload) { - w.Execution.Steps[0].Command = []string{strings.Repeat("x", 4096), strings.Repeat("x", 4096), strings.Repeat("x", 4096), strings.Repeat("x", 4096), "x"} - }, "16384"}, - {"many arguments", func(w *Workload) { w.Execution.Steps[0].Command = make([]string, 129) }, "128 arguments"}, - {"forward reference", func(w *Workload) { w.Execution.Steps[0].Inputs = map[string]string{"VALUE": "index.RESULT"} }, "earlier step"}, - {"self reference", func(w *Workload) { w.Execution.Steps[0].Inputs = map[string]string{"VALUE": "sync.RELEASE"} }, "earlier step"}, - {"missing output", func(w *Workload) { w.Execution.Steps[1].Inputs["RELEASE_ID"] = "sync.MISSING" }, "earlier step"}, - {"extra dot", func(w *Workload) { w.Execution.Steps[1].Inputs["RELEASE_ID"] = "sync.RELEASE.extra" }, "earlier step"}, - {"reserved input", func(w *Workload) { w.Execution.Steps[1].Inputs = map[string]string{"ONEBOX_STEP_ID": "sync.RELEASE"} }, "namespace"}, - {"env collision", func(w *Workload) { w.Env = map[string]any{"RELEASE_ID": "value"} }, "collides"}, - {"original input collision", func(w *Workload) { w.Inputs = map[string]JobInput{"RELEASE_ID": {}} }, "collides"}, - {"invalid output", func(w *Workload) { w.Execution.Steps[0].Outputs = []string{"lower"} }, "outputs"}, - {"reserved output", func(w *Workload) { w.Execution.Steps[0].Outputs = []string{"ONEBOX_VALUE"} }, "namespace"}, - {"duplicate output", func(w *Workload) { w.Execution.Steps[0].Outputs = []string{"RELEASE", "RELEASE"} }, "unique"}, - {"too many outputs", func(w *Workload) { w.Execution.Steps[0].Outputs = make([]string, 33) }, "32 outputs"}, - {"too many steps", func(w *Workload) { w.Execution.Steps = make([]JobStep, 33) }, "32 steps"}, - {"too many inputs", func(w *Workload) { - w.Inputs = map[string]JobInput{} - for i := range 33 { - w.Inputs[fmt.Sprintf("INPUT_%d", i)] = JobInput{} - } - }, "32 original inputs"}, - } { - t.Run(tc.name, func(t *testing.T) { - w := executionWorkload() - tc.edit(&w) - err := validateJobExecution(w, "workloads.example") - if tc.want == "" { - if err != nil { - t.Fatal(err) - } - } else if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("got %v, want error containing %q", err, tc.want) - } - }) - } -} - -func TestValidateJobExecutionRetryBudget(t *testing.T) { - attempts := 2 - for _, inherited := range []bool{false, true} { - t.Run(fmt.Sprintf("inherited=%t", inherited), func(t *testing.T) { - w := executionWorkload() - w.Schedule.Timeout = "1m" - retry := &JobRetry{Attempts: &attempts, Backoff: "30s"} - if inherited { - w.Schedule.Retry = retry - } else { - for i := range w.Execution.Steps { - w.Execution.Steps[i].Retry = retry - } - } - if err := validateJobExecution(w, "workloads.example"); err == nil || !strings.Contains(err.Error(), "combined worst-case") { - t.Fatalf("expected combined retry rejection, got %v", err) - } - w.Schedule.Timeout = "61s" - if err := validateJobExecution(w, "workloads.example"); err != nil { - t.Fatal(err) - } - }) - } - w := executionWorkload() - bad := 11 - w.Execution.Steps[0].Retry = &JobRetry{Attempts: &bad} - if err := validateJobExecution(w, "workloads.example"); err == nil || !strings.Contains(err.Error(), "attempts") { - t.Fatalf("expected retry validation, got %v", err) - } -} diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index bad6b24d..d7e9c4af 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - applicationv1alpha1 "github.com/labstack/onebox/api/application/v1alpha1" + applicationv1alpha2 "github.com/labstack/onebox/api/application/v1alpha2" ) // The published schema describes the document an author writes, not the one the @@ -24,12 +24,12 @@ import ( // loader does. A published schema that disagrees teaches something untrue. // SchemaID is both the schema identity and its stable, publicly retrievable -// location. The main-branch path stays fixed across Onebox releases. -const SchemaID = "https://onebox.run/schemas/application/v1alpha1/application.schema.json" +// location. Each authored API version keeps a fixed publication path. +const SchemaID = "https://onebox.run/schemas/application/v1alpha2/application.schema.json" // JSONSchema is the published contract, ready to write. func JSONSchema() ([]byte, error) { - embedded := bytes.TrimSpace(applicationv1alpha1.Schema) + embedded := bytes.TrimSpace(applicationv1alpha2.Schema) return append([]byte(nil), embedded...), nil } @@ -69,7 +69,7 @@ func GenerateJSONSchema() ([]byte, error) { doc := map[string]any{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": SchemaID, - "title": "Onebox Application (onebox.run/v1alpha1)", + "title": "Onebox Application (onebox.run/v1alpha2)", "description": "One application, its workloads, the services it needs, and how a release rolls out.", "type": "object", "properties": map[string]any{ @@ -439,17 +439,6 @@ var schemaConstraints = []struct { {[]string{"workloads", "*", "drain", "wait"}, pattern(gDur)}, {[]string{"workloads", "*", "drain", "grace"}, pattern(gDur)}, {[]string{"workloads", "*", "schedule", "notify", "items"}, enum(eScheduleNotify)}, - {[]string{"workloads", "*", "execution", "retention"}, pattern(gDur)}, - {[]string{"workloads", "*", "execution", "steps"}, map[string]any{"maxItems": 32}}, - {[]string{"workloads", "*", "execution", "steps", "items"}, map[string]any{"required": []any{"id", "command"}}}, - {[]string{"workloads", "*", "execution", "steps", "items", "id"}, pattern(gIdent)}, - {[]string{"workloads", "*", "execution", "steps", "items", "command"}, map[string]any{"minItems": 1, "maxItems": 128}}, - {[]string{"workloads", "*", "execution", "steps", "items", "inputs"}, propertyNames(gInputName)}, - {[]string{"workloads", "*", "execution", "steps", "items", "outputs"}, map[string]any{"maxItems": 32, "uniqueItems": true}}, - {[]string{"workloads", "*", "execution", "steps", "items", "outputs", "items"}, pattern(gInputName)}, - {[]string{"workloads", "*", "execution", "steps", "items", "retry", "attempts"}, map[string]any{"minimum": 1, "maximum": maxRetryAttempts}}, - {[]string{"workloads", "*", "execution", "steps", "items", "retry", "backoff"}, pattern(gDur)}, - {[]string{"workloads", "*", "execution", "steps", "items", "retry", "max_backoff"}, pattern(gDur)}, {[]string{"workloads", "*", "schedule", "retry", "attempts"}, map[string]any{"minimum": 1, "maximum": maxRetryAttempts}}, {[]string{"workloads", "*", "schedule", "retry", "backoff"}, pattern(gDur)}, {[]string{"workloads", "*", "schedule", "retry", "max_backoff"}, pattern(gDur)}, @@ -645,20 +634,8 @@ func applyRoleRules(doc map[string]any) { } sources := []any{"build", "image", "compose"} - jobOnly := []any{"deployment_phase", "operator_run", "data_effect", "schedule", "inputs", "execution"} + jobOnly := []any{"deployment_phase", "operator_run", "data_effect", "schedule", "inputs"} workload["allOf"] = []any{ - map[string]any{ - "if": map[string]any{"required": []any{"execution"}}, - "then": map[string]any{ - "required": []any{"schedule", "data_effect"}, - "properties": map[string]any{ - "data_effect": map[string]any{"const": "none"}, - "deployment_phase": map[string]any{"const": "none"}, - "operator_run": map[string]any{"const": "allowed"}, - }, - "not": map[string]any{"required": []any{"compose"}}, - }, - }, // Exactly one source. A workload with none cannot run and a workload // with two does not say which image it is. map[string]any{"oneOf": anyRequired(sources)}, diff --git a/internal/app/jsonschema_test.go b/internal/app/jsonschema_test.go index 903aea90..4db66e64 100644 --- a/internal/app/jsonschema_test.go +++ b/internal/app/jsonschema_test.go @@ -119,16 +119,23 @@ func TestPublishedSchemaAcceptsEveryRealProject(t *testing.T) { } } -// The shorthand an author actually writes must validate. This is the failure -// mode a schema generated from the model alone would have. -func TestPublishedSchemaRequiresExecutionStepIDAndCommand(t *testing.T) { +// Removed alpha fields must fail explicitly rather than silently lose work. +func TestRetiredJobExecutionIsRejectedBySchemaAndLoader(t *testing.T) { schema := compiledSchema(t) - for _, step := range []string{`{id: sync, command: [echo, ok]}`, `{command: [echo, ok]}`, `{id: sync}`, `{}`} { - y := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: a}\nspec:\n environments: {p: {server: root@h}}\n workloads:\n sync:\n role: Job\n image: busybox\n deploymentPhase: None\n dataEffect: None\n schedule: {cron: '0 * * * *'}\n execution:\n steps: [" + step + "]\n" - err := schema.Validate(asJSON(t, y)) - valid := strings.Contains(step, "id:") && strings.Contains(step, "command:") - if (err == nil) != valid { - t.Errorf("step %s: valid=%v, schema error=%v", step, valid, err) + base := "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata: {name: a}\nspec:\n environments: {p: {server: root@h}}\n workloads:\n sync:\n role: Job\n image: busybox\n dataEffect: None\n schedule: {cron: '0 * * * *'}\n" + if err := schema.Validate(asJSON(t, base)); err != nil { + t.Fatal(err) + } + if _, err := LoadBytes([]byte(base), "ob.yml"); err != nil { + t.Fatal(err) + } + for _, execution := range []string{`{}`, `{retention: 168h}`, `{steps: [{id: sync, command: [echo, ok]}]}`} { + y := base + " execution: " + execution + "\n" + if err := schema.Validate(asJSON(t, y)); err == nil || !strings.Contains(err.Error(), "execution") { + t.Errorf("schema must reject execution %s: %v", execution, err) + } + if _, err := LoadBytes([]byte(y), "ob.yml"); err == nil || !strings.Contains(err.Error(), "execution") { + t.Errorf("loader must reject execution %s: %v", execution, err) } } } @@ -136,7 +143,7 @@ func TestPublishedSchemaRequiresExecutionStepIDAndCommand(t *testing.T) { func TestPublishedSchemaAcceptsAuthoredScalarForms(t *testing.T) { schema := compiledSchema(t) for _, y := range []string{ - `apiVersion: onebox.run/v1alpha1 + `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -145,7 +152,7 @@ spec: workloads: a: image: nginx -`, `apiVersion: onebox.run/v1alpha1 +`, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -153,14 +160,14 @@ spec: environments: {p: {server: root@h}} workloads: {w: {image: nginx}} services: {postgres: 17} -`, `apiVersion: onebox.run/v1alpha1 +`, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a spec: environments: {p: {server: root@h}} workloads: {w: {image: nginx, volumes: [{name: data, path: /data}], needs: [db], command: run}} -`, `apiVersion: onebox.run/v1alpha1 +`, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -168,7 +175,7 @@ spec: environments: {p: {server: root@h}} workloads: {w: {image: nginx}} hooks: {PostDeploy: "echo done"} -`, `apiVersion: onebox.run/v1alpha1 +`, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -187,7 +194,7 @@ spec: // completion and error support the schema exists to provide. func TestPublishedSchemaRefusesAnUndefinedField(t *testing.T) { schema := compiledSchema(t) - y := `apiVersion: onebox.run/v1alpha1 + y := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -204,7 +211,7 @@ spec: func TestPublishedSchemaConstrainsProxyEntrypoints(t *testing.T) { schema := compiledSchema(t) - base := `apiVersion: onebox.run/v1alpha1 + base := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -317,20 +324,20 @@ func TestCheckedInSchemaMatchesGenerator(t *testing.T) { t.Fatal(err) } want := append(append([]byte(nil), body...), '\n') - path := filepath.Join("..", "..", "api", "application", "v1alpha1", "application.schema.json") + path := filepath.Join("..", "..", "api", "application", "v1alpha2", "application.schema.json") got, err := os.ReadFile(path) if err != nil { t.Fatalf("read published schema: %v", err) } if !bytes.Equal(got, want) { - t.Fatalf("%s is stale; regenerate it with `go run ./cmd/ob schema --out api/application/v1alpha1/application.schema.json`", path) + t.Fatalf("%s is stale; regenerate it with `go run ./cmd/ob schema --out api/application/v1alpha2/application.schema.json`", path) } } // The schema is only useful to an author if the guide tells them the URL to // point their editor at. A published identity nobody is told about helps no one. func TestPublishedSchemaURLIsUsedByTheHumanGuide(t *testing.T) { - if SchemaID != "https://onebox.run/schemas/application/v1alpha1/application.schema.json" { + if SchemaID != "https://onebox.run/schemas/application/v1alpha2/application.schema.json" { t.Fatalf("schema identity = %q", SchemaID) } guides := []string{ diff --git a/internal/app/jump_config_test.go b/internal/app/jump_config_test.go index c62f43e2..02282553 100644 --- a/internal/app/jump_config_test.go +++ b/internal/app/jump_config_test.go @@ -6,7 +6,7 @@ import ( ) func projectWithJump(jump string) string { - return `apiVersion: onebox.run/v1alpha1 + return `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger diff --git a/internal/app/load.go b/internal/app/load.go index 4e241119..f3808009 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -17,7 +17,7 @@ import ( ) // APIVersion is the only authoring contract this package accepts. -const APIVersion = "onebox.run/v1alpha1" +const APIVersion = "onebox.run/v1alpha2" // maxDerivedName is an Onebox limit chosen for headroom, not a container-runtime // maximum. An over-long name is refused rather than truncated: truncation with a diff --git a/internal/app/load_test.go b/internal/app/load_test.go index 4c7bb054..42740f9c 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -const base = `apiVersion: onebox.run/v1alpha1 +const base = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -27,6 +27,14 @@ func TestAPIVersionV1IsRequired(t *testing.T) { } } +func TestUnsupportedApplicationIdentityIsRejected(t *testing.T) { + y := strings.Replace(min, APIVersion, "onebox.run/v1alpha1", 1) + _, err := loadFixtureBytes([]byte(y), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "schema_identity_unsupported") || !strings.Contains(err.Error(), APIVersion) { + t.Fatalf("old identity must name the new contract: %v", err) + } +} + func TestRoutedProjectRefusesDefaultAsProxyNetwork(t *testing.T) { for _, network := range []string{"default", "ledger_default", "onebox_services"} { t.Run(network, func(t *testing.T) { @@ -181,7 +189,7 @@ func conformanceCases() []conformanceCase { {"explicit workloads block", wl("web: {image: nginx}"), true}, {"image reference with registry port", wl("web: {image: \"registry.example.com:5000/acme/app:1.2\"}"), true}, {"image reference with uppercase repository", wl("web: {image: \"ghcr.io/Acme/app:1.2\"}"), false}, - {"one-char identifier", `apiVersion: onebox.run/v1alpha1 + {"one-char identifier", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -191,7 +199,7 @@ spec: a: image: nginx `, true}, - {"app starting onebox-", `apiVersion: onebox.run/v1alpha1 + {"app starting onebox-", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: onebox-app @@ -201,7 +209,7 @@ spec: ob-app: image: nginx `, false}, - {"host proxy name", `apiVersion: onebox.run/v1alpha1 + {"host proxy name", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: onebox-proxy @@ -211,7 +219,7 @@ spec: onebox-proxy: image: nginx `, false}, - {"underscore identifier", `apiVersion: onebox.run/v1alpha1 + {"underscore identifier", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: my_app @@ -248,7 +256,7 @@ spec: {"absolute env_file", min + "runtime: {env_files: [/etc/x.env]}\n", false}, {"relative env_file", min + "runtime: {env_files: [.env.production]}\n", true}, {"base_path absolute", min + "base_path: /mnt/data/ob\n", true}, - {"duration in days", `apiVersion: onebox.run/v1alpha1 + {"duration in days", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -258,7 +266,7 @@ spec: a: image: nginx `, true}, - {"non-calver minimum version", `apiVersion: onebox.run/v1alpha1 + {"non-calver minimum version", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -268,7 +276,7 @@ spec: a: image: nginx `, false}, - {"incomplete plan schema", `apiVersion: onebox.run/v1alpha1 + {"incomplete plan schema", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -283,7 +291,7 @@ spec: // a case: an unlisted seam loads and never fires, and refusing a job name // would break the per-job command override the engine reads. {"hook naming an unlisted seam", min + "hooks: {pre_deploy: {run: scripts/backup.sh}}\n", false}, - {"hook naming a declared job", `apiVersion: onebox.run/v1alpha1 + {"hook naming a declared job", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -337,7 +345,7 @@ spec: {"encrypted env file entry", min + "runtime: {env_files: [{file: secrets.env, provider: sops}]}\n", true}, {"unknown env file provider", min + "runtime: {env_files: [{file: s.env, provider: vault}]}\n", false}, {"env file entry without a file", min + "runtime: {env_files: [{provider: sops}]}\n", false}, - {"environment-scoped env files", `apiVersion: onebox.run/v1alpha1 + {"environment-scoped env files", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -388,7 +396,7 @@ spec: {"external lifecycle field", strings.Replace(validExternalServiceProject, " driver: postgres\n", " driver: postgres\n version: 17\n", 1), false}, // Loader-enforced: the schema alone accepts these. - {"no environments", `apiVersion: onebox.run/v1alpha1 + {"no environments", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -403,7 +411,7 @@ spec: {"two sources on a workload", wl("w: {image: nginx, build: .}"), false}, {"workload and service share a name", wl("db: {image: nginx}") + "services: {db: 18}\n", false}, {"unknown prerequisite", wl("w: {image: nginx, needs: [ghost]}"), false}, - {"components is not a field", `apiVersion: onebox.run/v1alpha1 + {"components is not a field", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -560,7 +568,7 @@ func TestRollingWorkloadCannotPublishFixedHostPort(t *testing.T) { // truncating scheme is not injective and volume names are permanent. func TestOverLongNameRefused(t *testing.T) { long := strings.Repeat("a", 40) - y := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n name: " + long + + y := "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata:\n name: " + long + "\nspec:\n environments: {p: {server: h}}\n workloads: {" + strings.Repeat("w", 30) + ": {image: nginx}}\n" _, err := loadFixtureBytes([]byte(y), "ob.yml") @@ -581,7 +589,7 @@ func TestOverLongEscapedContainerNameRefused(t *testing.T) { if len(application)+1+len(workload) > maxDerivedName { t.Fatal("fixture no longer isolates the escaped-name case") } - y := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n name: " + application + + y := "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata:\n name: " + application + "\nspec:\n environments: {p: {server: h}}\n workloads: {" + workload + ": {image: nginx}}\n" _, err := loadFixtureBytes([]byte(y), "ob.yml") var e *Error @@ -594,7 +602,7 @@ func TestOverLongEscapedContainerNameRefused(t *testing.T) { // bounded before anything derives from it: a typo must not build billions of // names while the project loads. func TestReplicasAreBounded(t *testing.T) { - y := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n name: shop\n" + + y := "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata:\n name: shop\n" + "spec:\n environments: {p: {server: h}}\n workloads: {web: {image: nginx, replicas: 2000000000}}\n" _, err := loadFixtureBytes([]byte(y), "ob.yml") if err == nil || !strings.Contains(err.Error(), "replicas") { @@ -646,7 +654,7 @@ func keysOf[V any](m map[string]V) []string { // "dependency failed to start: container has no healthcheck configured". A // default must not describe something the container engine cannot do. func TestNeedConditionResolvesAgainstTheDependency(t *testing.T) { - y := `apiVersion: onebox.run/v1alpha1 + y := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: app @@ -677,7 +685,7 @@ spec: // softened, but an explicit request must be honoured or refused — never // quietly turned into something weaker. func TestExplicitHealthyOnAHealthlessDependencyIsRefused(t *testing.T) { - y := `apiVersion: onebox.run/v1alpha1 + y := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: app @@ -697,7 +705,7 @@ spec: // Two workloads on one address is an outage nobody can explain: the proxy // accepts both and routes to one, chosen by a rule the author never wrote. func TestRouteCollisionIsRefusedNamingBoth(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -719,7 +727,7 @@ spec: // The same host on two listeners is how a project serves HTTP and gRPC side by // side. Refusing that would reject correct projects. func TestSameHostOnDistinctEntrypointsIsAllowed(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -745,7 +753,7 @@ spec: // Different paths on one host are distinct addresses too. func TestSameHostDifferentPathsIsAllowed(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -764,7 +772,7 @@ spec: // `managed: false` silently threw the routes away, and a project that declared // a domain deployed something nothing could reach. func TestRoutesSurviveAnUnmanagedProxy(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -792,7 +800,7 @@ spec: // With nothing to route, a declared route is a promise nobody keeps. func TestRouteWithoutAProxyIsRefused(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -820,7 +828,7 @@ func TestUnknownWorkloadFieldIsRefusedForEveryRole(t *testing.T) { "role: daemon, image: nginx", "role: job, image: nginx, data_effect: none", } { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -859,7 +867,7 @@ spec: // deploy — but it is reviewed, and a value that reads as a timezone while // appending a root command to a scheduling unit defeats the review. func TestHostileValuesAreRefusedAtTheGrammar(t *testing.T) { - base := `apiVersion: onebox.run/v1alpha1 + base := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -890,7 +898,7 @@ spec: // The same fields must still accept what real projects write. func TestOrdinaryValuesStillLoad(t *testing.T) { - base := `apiVersion: onebox.run/v1alpha1 + base := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -922,7 +930,7 @@ spec: // directory — corruption, with nothing in the runtime saying so until the damage // is done. func TestDurableStateCannotBeReplicated(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -947,7 +955,7 @@ spec: // Replicas over a shared volume that is not state — an uploads directory, a // cache — stay legal. Refusing those would reject correct projects. func TestReplicasOverNonDurableStorageAreAllowed(t *testing.T) { - if _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + if _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -967,7 +975,7 @@ spec: // A managed service has no replica count at all: one instance is the only shape // the contract can run. func TestAManagedServiceHasNoReplicaCount(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -995,7 +1003,7 @@ func TestEveryRenamedFieldIsRefusedByItsOldName(t *testing.T) { {"verification", base + "verification: [{url: \"https://x/\", contains: ok}]\n"}, {"workload ports", wl("w: {image: nginx, ports: [{host: 80, container: 80}]}")}, {"volume target", wl("w: {image: nginx, volumes: [{source: ./d, target: /d}]}")}, - {"policy migration_backup_max_age", `apiVersion: onebox.run/v1alpha1 + {"policy migration_backup_max_age", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -1010,7 +1018,7 @@ spec: {"probe maximum_age", base + "external_services: {db: {driver: postgres, probe: {kind: tcp, maximum_age: 5m}}}\n"}, {"drill maximum_age", base + "services: {postgres: {version: 18, backup: {target: t, drill: {maximum_age: 7d}}}}\n"}, {"backup maximum_data_loss", base + "services: {postgres: {version: 18, backup: {target: t, maximum_data_loss: 15m}}}\n"}, - {"policy minimum_onebox_version", `apiVersion: onebox.run/v1alpha1 + {"policy minimum_onebox_version", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -1020,7 +1028,7 @@ spec: a: image: nginx `}, - {"policy minimum_plan_schema", `apiVersion: onebox.run/v1alpha1 + {"policy minimum_plan_schema", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a @@ -1030,7 +1038,7 @@ spec: a: image: nginx `}, - {"policy migrations backup_maximum_age", `apiVersion: onebox.run/v1alpha1 + {"policy migrations backup_maximum_age", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 08492038..5300f304 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -const namesFixture = `apiVersion: onebox.run/v1alpha1 +const namesFixture = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -293,7 +293,7 @@ func TestNoDerivedNameCollidesWithHostScoped(t *testing.T) { // discovery would derive the host proxy's own container names. func TestOneboxContainerNamespaceIsReserved(t *testing.T) { for label, body := range map[string]string{ - "application onebox": `apiVersion: onebox.run/v1alpha1 + "application onebox": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: onebox @@ -302,7 +302,7 @@ spec: workloads: web: {image: nginx} `, - "service proxy": `apiVersion: onebox.run/v1alpha1 + "service proxy": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -313,7 +313,7 @@ spec: services: proxy: {driver: redis, version: 7} `, - "service ingress": `apiVersion: onebox.run/v1alpha1 + "service ingress": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -324,7 +324,7 @@ spec: services: ingress: {driver: redis, version: 7} `, - "service services": `apiVersion: onebox.run/v1alpha1 + "service services": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -335,7 +335,7 @@ spec: services: services: {driver: redis, version: 7} `, - "service discovery": `apiVersion: onebox.run/v1alpha1 + "service discovery": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go index fef9bfb3..c8b6dba7 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -71,7 +71,7 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { // 6.3 — a multi-route workload and a non-HTTP route survive the whole path: // the canonical form describes them, and the generated labels route them. func TestMultiRouteAndNonHTTPRouteEndToEnd(t *testing.T) { - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -138,7 +138,7 @@ spec: } func TestRouteMiddlewareOrderPreservesRepetition(t *testing.T) { - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index f36fdcdd..2f874228 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -59,7 +59,7 @@ func TestPreflightRefusesForeignHostOwner(t *testing.T) { } } -const preflightProject = `apiVersion: onebox.run/v1alpha1 +const preflightProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -330,7 +330,7 @@ func TestInterpolationEnvUsesComposeSemantics(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "ob.yml") - if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha1 + if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -392,7 +392,7 @@ func TestPreflightResolvesAcrossDeclaredFilesInOrder(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "ob.yml") - if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha1 + if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -558,7 +558,7 @@ func TestHostOwnerRecordParsesTheSameForPreflightAndEngine(t *testing.T) { // every mutation after it refuses a record it cannot parse. func TestEnvironmentNamesMustSurviveTheOwnerRecord(t *testing.T) { for _, name := range []string{"Staging", "prod_east", "staging replica", "-lead", "trail-"} { - src := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: sample}\nspec:\n environments:\n \"" + name + + src := "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata: {name: sample}\nspec:\n environments:\n \"" + name + "\": {server: root@h}\n workloads:\n web: {role: Application, image: 'x:1'}\n" if _, err := loadFixtureBytes([]byte(src), "ob.yml"); err == nil { t.Fatalf("environment name %q was accepted by the loader but cannot round-trip the owner record", name) @@ -566,7 +566,7 @@ func TestEnvironmentNamesMustSurviveTheOwnerRecord(t *testing.T) { } // And the ones that are legal stay legal. for _, name := range []string{"production", "staging", "prod-east"} { - src := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n name: sample\n" + + src := "apiVersion: onebox.run/v1alpha2\nkind: Application\nmetadata:\n name: sample\n" + "spec:\n environments:\n " + name + ": {server: root@h}\n" + " workloads:\n web: {role: Application, image: 'x:1'}\n" if _, err := loadFixtureBytes([]byte(src), "ob.yml"); err != nil { diff --git a/internal/app/purity_test.go b/internal/app/purity_test.go index 61a61757..1f53b4d5 100644 --- a/internal/app/purity_test.go +++ b/internal/app/purity_test.go @@ -16,7 +16,7 @@ import ( // one commit disagree, entropy makes a digest meaningless, and an environment // variable makes the result depend on whose shell ran it. -const purityProject = `apiVersion: onebox.run/v1alpha1 +const purityProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -201,7 +201,7 @@ func TestGenerationCannotReachATarget(t *testing.T) { // connect to production. func TestEveryGenerationFailureIsReachableOffline(t *testing.T) { for name, body := range map[string]string{ - "unknown field": `apiVersion: onebox.run/v1alpha1 + "unknown field": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -211,28 +211,28 @@ spec: workloads: shop: image: nginx -`, "no source": `apiVersion: onebox.run/v1alpha1 +`, "no source": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop spec: environments: {p: {server: h}} workloads: {web: {role: Application}} -`, "two sources": `apiVersion: onebox.run/v1alpha1 +`, "two sources": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop spec: environments: {p: {server: h}} workloads: {web: {role: Application, image: nginx, build: .}} -`, "job without effect": `apiVersion: onebox.run/v1alpha1 +`, "job without effect": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop spec: environments: {p: {server: h}} workloads: {j: {role: Job, image: nginx}} -`, "unknown driver": `apiVersion: onebox.run/v1alpha1 +`, "unknown driver": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -242,7 +242,7 @@ spec: workloads: shop: image: nginx -`, "route collision": `apiVersion: onebox.run/v1alpha1 +`, "route collision": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/resolve_test.go b/internal/app/resolve_test.go index 98d89291..fe63c333 100644 --- a/internal/app/resolve_test.go +++ b/internal/app/resolve_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -const overrideFixture = `apiVersion: onebox.run/v1alpha1 +const overrideFixture = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -119,7 +119,7 @@ func TestResolveDoesNotLeakBetweenEnvironments(t *testing.T) { } func TestRouteMiddlewareOverrideRequiresManagedProxyConfig(t *testing.T) { - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -251,7 +251,7 @@ func TestRenderResolvesAutomatically(t *testing.T) { // permitted set would accept an override naming something no service has, and // accepting it silently is how an operator comes to believe a setting applied. func TestOverridingAWithdrawnFieldIsRefused(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -290,7 +290,7 @@ spec: // time — blamed on a `replicas` override nobody wrote. The inference is a // derived read now, and the document is never edited. func TestAProjectThatLoadsAlsoResolves(t *testing.T) { - yaml := `apiVersion: onebox.run/v1alpha1 + yaml := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: a diff --git a/internal/app/route_test.go b/internal/app/route_test.go index 9f65323f..a16f5bc6 100644 --- a/internal/app/route_test.go +++ b/internal/app/route_test.go @@ -68,7 +68,7 @@ func TestRouteJumpDefaultsToPort22(t *testing.T) { // port has to survive into the route, or the connection is attempted against // a hostname with a colon in it. func TestScalarServerPortReachesTheRoute(t *testing.T) { - resolved, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + resolved, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -100,7 +100,7 @@ spec: func TestBracketedIPv6ScalarNormalisesLikeTheObjectForm(t *testing.T) { load := func(server string) Environment { t.Helper() - resolved, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + resolved, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -133,7 +133,7 @@ spec: // now, so the brackets have to come off while the project is read or // JoinHostPort builds [[2001:db8::1]]:22. func TestBracketedIPv6ServerHostNormalises(t *testing.T) { - resolved, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + resolved, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger @@ -164,7 +164,7 @@ func TestInvalidServerAddressIsRejectedAtLoad(t *testing.T) { } for name, server := range invalid { t.Run(name, func(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: ledger diff --git a/internal/app/schedule.go b/internal/app/schedule.go index 1afcc2df..8e82fe84 100644 --- a/internal/app/schedule.go +++ b/internal/app/schedule.go @@ -43,9 +43,7 @@ type ScheduledJob struct { RetryMaxBackoff time.Duration Notify []string // Inputs are the declared parameters an operator run may override. - Inputs map[string]JobInput - Execution *JobExecution - DataEffect DataEffect + Inputs map[string]JobInput } // RetryBackoffBudget is the maximum time this job can spend sleeping between @@ -82,7 +80,7 @@ func (p *Spec) ScheduledJobs() ([]ScheduledJob, error) { Timeout: w.Schedule.Timeout, ShutdownGrace: shutdownGrace, CatchUp: w.Schedule.CatchUp, DeployLock: deployLock, DeploymentPhase: w.DeploymentPhase, OperatorRun: w.OperatorRun, RetryAttempts: attempts, RetryBackoff: backoff, RetryMaxBackoff: maxBackoff, - Notify: w.Schedule.notifyOutcomes(), Inputs: w.Inputs, Execution: w.Execution, DataEffect: w.DataEffect, + Notify: w.Schedule.notifyOutcomes(), Inputs: w.Inputs, }) } return out, nil diff --git a/internal/app/schedule_test.go b/internal/app/schedule_test.go index b1990715..c0bae547 100644 --- a/internal/app/schedule_test.go +++ b/internal/app/schedule_test.go @@ -66,7 +66,7 @@ func TestMalformedCronIsRefused(t *testing.T) { // A job's schedule reaches the host with its timezone; a backup at 2am means // 2am where the operator lives, not wherever the box was imaged. func TestScheduledJobsCarryTimezone(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -94,7 +94,7 @@ spec: } func TestPinnedScheduleEligibilityFailsClosed(t *testing.T) { - valid := `apiVersion: onebox.run/v1alpha1 + valid := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -126,7 +126,7 @@ spec: } func TestScheduledJobRunPolicyIsExplicitAndValidated(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -150,7 +150,7 @@ spec: t.Fatalf("authored run policy was not preserved: %#v", jobs) } - bad := `apiVersion: onebox.run/v1alpha1 + bad := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -165,7 +165,7 @@ spec: } func TestScheduledJobRetryAndNotifyResolveWithDefaults(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -221,7 +221,7 @@ func TestScheduledJobRetryIsBoundedByTheTimeout(t *testing.T) { "unknown notify": {`{cron: "0 * * * *", notify: [warning]}`, "project_invalid"}, } { t.Run(name, func(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: @@ -244,7 +244,7 @@ spec: } func TestJobInputsValidateNamesConstraintsAndDefaults(t *testing.T) { - base := `apiVersion: onebox.run/v1alpha1 + base := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: shop} spec: @@ -287,7 +287,7 @@ spec: } }) } - if _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + if _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -332,7 +332,7 @@ func TestValidateJobInputValuesChecksOverrides(t *testing.T) { } func TestScheduledJobInputDefaultsRenderIntoTheComposeEnvironment(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/app/secrets_graph_test.go b/internal/app/secrets_graph_test.go index 5ea71db2..fc1da978 100644 --- a/internal/app/secrets_graph_test.go +++ b/internal/app/secrets_graph_test.go @@ -19,7 +19,7 @@ func secretGraphProject(t *testing.T, body string) *Resolved { } func TestSecretDeclarationGraphCapturesOrderScopeAndAffectedWorkloads(t *testing.T) { - resolved := secretGraphProject(t, `apiVersion: onebox.run/v1alpha1 + resolved := secretGraphProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -44,7 +44,7 @@ spec: } func TestSecretDeclarationIDsAreStableAndValueFree(t *testing.T) { - resolved := secretGraphProject(t, `apiVersion: onebox.run/v1alpha1 + resolved := secretGraphProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -61,7 +61,7 @@ spec: } func TestSecretDeclarationGraphChangesForEveryRuntimeRelevantDrift(t *testing.T) { - base := `apiVersion: onebox.run/v1alpha1 + base := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -86,7 +86,7 @@ spec: name string body string }{ - {name: "reordered", body: `apiVersion: onebox.run/v1alpha1 + {name: "reordered", body: `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -95,7 +95,7 @@ spec: runtime: {envFiles: [{file: second.enc.env, provider: Sops}, {file: first.enc.env, provider: Sops}]} workloads: {web: {role: Application, image: nginx}, worker: {role: Worker, image: nginx}} `}, - {name: "scope changed", body: `apiVersion: onebox.run/v1alpha1 + {name: "scope changed", body: `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -106,7 +106,7 @@ spec: web: {role: Application, image: nginx, envFiles: [{file: first.enc.env, provider: Sops}, {file: second.enc.env, provider: Sops}]} worker: {role: Worker, image: nginx} `}, - {name: "provider removed", body: `apiVersion: onebox.run/v1alpha1 + {name: "provider removed", body: `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -115,7 +115,7 @@ spec: runtime: {envFiles: [first.enc.env, {file: second.enc.env, provider: Sops}]} workloads: {web: {role: Application, image: nginx}, worker: {role: Worker, image: nginx}} `}, - {name: "affected workload removed", body: `apiVersion: onebox.run/v1alpha1 + {name: "affected workload removed", body: `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -135,7 +135,7 @@ spec: } func TestSecretDeclarationGraphIncludesSortedExternalProjection(t *testing.T) { - resolved := secretGraphProject(t, `apiVersion: onebox.run/v1alpha1 + resolved := secretGraphProject(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/app/service_extensions_test.go b/internal/app/service_extensions_test.go index f5eeb05f..40501582 100644 --- a/internal/app/service_extensions_test.go +++ b/internal/app/service_extensions_test.go @@ -24,7 +24,7 @@ func TestVectorscaleIncludesItsVectorDependency(t *testing.T) { } func TestPostgresExtensionsSelectTheOneboxImage(t *testing.T) { - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: goal @@ -69,7 +69,7 @@ func TestProtectedPostgresMustAdoptTheOneboxImageBeforeExtensions(t *testing.T) } func TestPostgresExtensionsDerivePreloadAndCronSettings(t *testing.T) { - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: goal @@ -105,7 +105,7 @@ spec: } func TestServiceExtensionsArePostgresOnly(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -125,7 +125,7 @@ spec: } func TestPostgresExtensionsRequireThePublishedImageVersion(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -144,7 +144,7 @@ spec: } func TestServiceExtensionNamesAreSafeSQLIdentifiers(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -169,7 +169,7 @@ func TestPgCronSettingsCannotDisableTheManagedContract(t *testing.T) { "cron.database_name: elsewhere", "cron.use_background_workers: off", } { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: sample} spec: diff --git a/internal/app/services_test.go b/internal/app/services_test.go index f5d384e1..cd71ff25 100644 --- a/internal/app/services_test.go +++ b/internal/app/services_test.go @@ -10,7 +10,7 @@ import ( func serviceSpec(t *testing.T, body string) *Spec { t.Helper() - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -92,7 +92,7 @@ func TestNeedingAServiceJoinsItAndReadsItsURL(t *testing.T) { // Guessing an image from an identifier would produce a container that starts // and stores nothing durable. func TestUnknownDriverIsRefusedWithAlternatives(t *testing.T) { - _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -191,7 +191,7 @@ func TestGeneratedDollarsSurviveComposeInterpolation(t *testing.T) { // service is only usable by one that happens to read the names Onebox chose, // which almost none do. func TestAWorkloadCanNameTheConnectionItself(t *testing.T) { - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: n8n @@ -353,7 +353,7 @@ func TestCredentialWritesAreAtomic(t *testing.T) { // behaviour callers depend on is unavailable, and a health-gated rollout // converges onto a dependency that cannot store anything. func TestRedisFamilyHealthChecksProveAWrite(t *testing.T) { - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -419,7 +419,7 @@ func TestEphemeralServicesOwnNoDurableVolume(t *testing.T) { "clickhouse": "25.3", "redis": "8-alpine", "valkey": "8-alpine", "rabbitmq": "4", "meilisearch": "1.10", "nats": "2.10", }[svc] - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -441,7 +441,7 @@ func TestDurableRedisKeepsItsVolumeAndAppendOnlyLog(t *testing.T) { " redis: {version: 8-alpine}\n", " redis: {version: 8-alpine, persistence: {mode: durable}}\n", } { - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -463,7 +463,7 @@ spec: // intends to read back. func TestEphemeralRedisFamilyDisablesBothPersistenceMechanisms(t *testing.T) { for _, svc := range []string{"redis", "valkey"} { - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -486,7 +486,7 @@ spec: // beside it. Appending produced `--appendonly yes --appendonly no`, which is // what made an author compensate for the driver in the first place. func TestAuthoredSettingOverridesTheModeDefaultExactlyOnce(t *testing.T) { - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -519,7 +519,7 @@ spec: // downgrade on the one mode that says the data matters. func TestOnlyEphemeralDisablesServerPersistence(t *testing.T) { for _, mode := range []string{"durable", "external"} { - rendered := renderServices(t, `apiVersion: onebox.run/v1alpha1 + rendered := renderServices(t, `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -548,7 +548,7 @@ spec: // protected-identity record, while nothing ever created or mounted it — the // declaration would be silently ignored rather than refused. func TestEphemeralServiceCannotDeclareVolumes(t *testing.T) { - src := `apiVersion: onebox.run/v1alpha1 + src := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/app/testdata/corpus/README.md b/internal/app/testdata/corpus/README.md index 206528d1..c90581c4 100644 --- a/internal/app/testdata/corpus/README.md +++ b/internal/app/testdata/corpus/README.md @@ -1,6 +1,6 @@ # Conformance corpus -Real projects, authored against `onebox.run/v1alpha1`, used to freeze the contract: +Real projects, authored against `onebox.run/v1alpha2`, used to freeze the contract: each one's accept/reject verdict, error code and generated-runtime digest is recorded in `../contract-verdicts.json` and asserted on every run. diff --git a/internal/app/testdata/corpus/ext-authentik-managed.yml b/internal/app/testdata/corpus/ext-authentik-managed.yml index a72de5bf..5586d80b 100644 --- a/internal/app/testdata/corpus/ext-authentik-managed.yml +++ b/internal/app/testdata/corpus/ext-authentik-managed.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: authentik diff --git a/internal/app/testdata/corpus/ext-authentik.yml b/internal/app/testdata/corpus/ext-authentik.yml index ce3439bf..9e23ddd1 100644 --- a/internal/app/testdata/corpus/ext-authentik.yml +++ b/internal/app/testdata/corpus/ext-authentik.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: authentik diff --git a/internal/app/testdata/corpus/ext-frigate.yml b/internal/app/testdata/corpus/ext-frigate.yml index 66124b6a..b80553e4 100644 --- a/internal/app/testdata/corpus/ext-frigate.yml +++ b/internal/app/testdata/corpus/ext-frigate.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: frigate diff --git a/internal/app/testdata/corpus/ext-gitea.yml b/internal/app/testdata/corpus/ext-gitea.yml index a7a1142e..3bdde22d 100644 --- a/internal/app/testdata/corpus/ext-gitea.yml +++ b/internal/app/testdata/corpus/ext-gitea.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: gitea diff --git a/internal/app/testdata/corpus/ext-immich-sourced.yml b/internal/app/testdata/corpus/ext-immich-sourced.yml index 5721bafc..3f6e6885 100644 --- a/internal/app/testdata/corpus/ext-immich-sourced.yml +++ b/internal/app/testdata/corpus/ext-immich-sourced.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: immich diff --git a/internal/app/testdata/corpus/ext-immich.yml b/internal/app/testdata/corpus/ext-immich.yml index 8dacaf14..bc1dcbbf 100644 --- a/internal/app/testdata/corpus/ext-immich.yml +++ b/internal/app/testdata/corpus/ext-immich.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: immich diff --git a/internal/app/testdata/corpus/ext-n8n.yml b/internal/app/testdata/corpus/ext-n8n.yml index 741202de..714c5d52 100644 --- a/internal/app/testdata/corpus/ext-n8n.yml +++ b/internal/app/testdata/corpus/ext-n8n.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: n8n diff --git a/internal/app/testdata/corpus/ext-paperless.yml b/internal/app/testdata/corpus/ext-paperless.yml index 1092ef07..060b0d8a 100644 --- a/internal/app/testdata/corpus/ext-paperless.yml +++ b/internal/app/testdata/corpus/ext-paperless.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: paperless diff --git a/internal/app/testdata/corpus/ext-plausible.yml b/internal/app/testdata/corpus/ext-plausible.yml index 3dc2205d..f91b467e 100644 --- a/internal/app/testdata/corpus/ext-plausible.yml +++ b/internal/app/testdata/corpus/ext-plausible.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: plausible diff --git a/internal/app/testdata/corpus/ext-umami.yml b/internal/app/testdata/corpus/ext-umami.yml index c149b61e..5ace895f 100644 --- a/internal/app/testdata/corpus/ext-umami.yml +++ b/internal/app/testdata/corpus/ext-umami.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: umami diff --git a/internal/app/testdata/corpus/goal.yml b/internal/app/testdata/corpus/goal.yml index 877323d4..d67015f0 100644 --- a/internal/app/testdata/corpus/goal.yml +++ b/internal/app/testdata/corpus/goal.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: goal diff --git a/internal/app/testdata/corpus/monk.yml b/internal/app/testdata/corpus/monk.yml index cadbf3ac..689a49ef 100644 --- a/internal/app/testdata/corpus/monk.yml +++ b/internal/app/testdata/corpus/monk.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: monk diff --git a/internal/app/testdata/corpus/pursue.yml b/internal/app/testdata/corpus/pursue.yml index 8a21c5b6..05a6b3c1 100644 --- a/internal/app/testdata/corpus/pursue.yml +++ b/internal/app/testdata/corpus/pursue.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: pursue diff --git a/internal/app/testdata/corpus/recast.yml b/internal/app/testdata/corpus/recast.yml index 84db7bba..5118ed93 100644 --- a/internal/app/testdata/corpus/recast.yml +++ b/internal/app/testdata/corpus/recast.yml @@ -1,4 +1,4 @@ -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: recast diff --git a/internal/app/types.go b/internal/app/types.go index fbec2531..5b9835f6 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -1,4 +1,4 @@ -// Package app loads the onebox.run/v1alpha1 declarative authoring contract: one +// Package app loads the onebox.run/v1alpha2 declarative authoring contract: one // application, its workloads, the services it needs, and how a release rolls // out. // @@ -172,20 +172,6 @@ type Workload struct { DataEffect DataEffect `json:"data_effect,omitempty" description:"Job data impact used by rollback and abort gates." example:"migration"` Schedule *JobSchedule `json:"schedule,omitempty" description:"Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy."` Inputs map[string]JobInput `json:"inputs,omitempty" description:"Declared parameters of a scheduled job, exposed as environment variables. Names are upper-case identifiers; each declares exactly one of enum or pattern and a default. A timer firing uses the defaults; ob job run may override them."` - Execution *JobExecution `json:"execution,omitempty" description:"Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume."` -} - -type JobExecution struct { - Retention string `json:"retention" default:"168h" description:"Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected."` - Steps []JobStep `json:"steps,omitempty" description:"Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps."` -} - -type JobStep struct { - ID string `json:"id" description:"Unique stable step identifier, used by output references."` - Command []string `json:"command" description:"Argument vector passed to the job image's entrypoint. No shell evaluation is performed."` - Inputs map[string]string `json:"inputs,omitempty" description:"Environment variables populated from a preceding step's declared output, written as step.OUTPUT."` - Outputs []string `json:"outputs,omitempty" description:"Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total."` - Retry *JobRetry `json:"retry,omitempty" description:"Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout."` } // JobInput is one declared parameter of a scheduled job. The constraint is diff --git a/internal/app/validate.go b/internal/app/validate.go index f3e09592..a2026617 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -249,9 +249,6 @@ func validateEnvironment(e Environment, path string) error { } func validateWorkload(w Workload, path string) error { - if err := validateJobExecution(w, path); err != nil { - return err - } if err := checkEnum(path+".role", w.Role, eRole); err != nil { return err } diff --git a/internal/app/workload_contract_test.go b/internal/app/workload_contract_test.go index ff672126..cf7d5a76 100644 --- a/internal/app/workload_contract_test.go +++ b/internal/app/workload_contract_test.go @@ -54,7 +54,7 @@ func TestSecretInputRevisionsAreScopedByWorkload(t *testing.T) { t.Fatal(err) } } - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -97,7 +97,7 @@ func TestSecretInputRevisionsUseTheProvidedSnapshot(t *testing.T) { if err := os.WriteFile(path, []byte("cipher-before"), 0o600); err != nil { t.Fatal(err) } - spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/durable/durable.go b/internal/durable/durable.go deleted file mode 100644 index 81fafe7e..00000000 --- a/internal/durable/durable.go +++ /dev/null @@ -1,17 +0,0 @@ -// Package durable contains the on-demand host checkpoint helper. systemd and -// the existing schedule runner still own process lifetime and deployment locks. -package durable - -import ( - _ "embed" - "path" -) - -// Script uses only the Python 3 standard library; it is installed only when a -// durable job is declared. Version 1 readers remain compatible with v1 records. -// -//go:embed runner.py -var Script string - -func Helper(root string) string { return path.Join(root, "schedule", "execution-v1.py") } -func Store(root string) string { return path.Join(root, "schedule", "executions") } diff --git a/internal/durable/runner.py b/internal/durable/runner.py deleted file mode 100644 index ba445339..00000000 --- a/internal/durable/runner.py +++ /dev/null @@ -1,757 +0,0 @@ -"""Onebox durable executions v1. Invoked on demand, never a resident service.""" - -import base64 -import contextlib -import fcntl -import hashlib -import json -import math -import os -import re -import signal -import stat -import subprocess -import sys -import tempfile -import time -import uuid -from pathlib import Path - -VERSION = 1 -LIMIT = 4 * 1024 * 1024 -ID = re.compile(r"[0-9a-f]{32}") -RELEASE = re.compile(r"[0-9]{8}-[0-9]{6}-[0-9A-Za-z_-]+") -TERMINAL = {"succeeded", "abandoned"} - - -def require(ok, message): - if not ok: - raise ValueError(message) - - -def encode(value): - return json.dumps( - value, sort_keys=True, separators=(",", ":"), ensure_ascii=True - ).encode() - - -def digest(value): - return hashlib.sha256(encode(value)).hexdigest() - - -def fsync_dir(path): - fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) - try: - os.fsync(fd) - finally: - os.close(fd) - - -def directory(path): - try: - path.mkdir(mode=0o700) - fsync_dir(path.parent) - except FileExistsError: - require( - stat.S_ISDIR(path.lstat().st_mode), - "state directory is not a real directory", - ) - - -def read_json(path, limit=LIMIT): - fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - with os.fdopen(fd, "rb") as stream: - require( - stat.S_ISREG(os.fstat(stream.fileno()).st_mode), - "state/output is not a regular file", - ) - data = stream.read(limit + 1) - require(len(data) <= limit, "state/output exceeds its size limit") - return json.loads( - data, object_pairs_hook=unique_object, parse_constant=invalid_constant - ) - - -def invalid_constant(_value): - raise ValueError("non-finite JSON number") - - -def unique_object(pairs): - obj = {} - for key, value in pairs: - require(key not in obj, "duplicate JSON key") - obj[key] = value - return obj - - -def atomic(path, value): - data = encode(value) - require( - len(data) <= LIMIT, - "execution history limit reached; abandon and start a new execution", - ) - atomic_bytes(path, data) - - -def atomic_bytes(path, data): - fd, tmp = tempfile.mkstemp(prefix=".checkpoint-", dir=path.parent) - try: - with os.fdopen(fd, "wb") as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - os.replace(tmp, path) - fsync_dir(path.parent) - finally: - if os.path.exists(tmp): - os.unlink(tmp) - - -class Store: - def __init__(self, root): - self.root = Path(root) - self.path = self.root / "schedule" / "executions" - - def record_path(self, identity): - require(ID.fullmatch(identity), "invalid execution ID") - return self.path / (identity + ".json") - - @contextlib.contextmanager - def locked(self): - directory(self.path.parent) - directory(self.path) - fd = os.open(self.path / ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - try: - fcntl.flock(fd, fcntl.LOCK_EX) - yield - finally: - os.close(fd) - - def read(self, identity): - value = read_json(self.record_path(identity)) - require( - value.get("version") == VERSION and value.get("id") == identity, - "unsupported or corrupt execution record", - ) - require(RELEASE.fullmatch(value["release"]), "invalid release evidence") - require( - value["definition_digest"] == digest(value["definition"]), - "execution definition changed", - ) - require( - value["state"] - in TERMINAL | {"pending", "running", "failed", "interrupted", "timeout"}, - "invalid execution state", - ) - require( - type(value["expires_at"]) in (int, float) - and math.isfinite(value["expires_at"]), - "invalid execution expiry", - ) - require( - len(value["steps"]) == len(value["definition"]["steps"]), - "invalid step evidence", - ) - for step, definition in zip(value["steps"], value["definition"]["steps"]): - require( - step["name"] == definition["name"] - and step["id"] == identity + ":" + step["name"], - "invalid step identity", - ) - require( - step["state"] - in {"pending", "running", "succeeded", "failed", "interrupted"}, - "invalid step state", - ) - if step["state"] == "succeeded": - validate_outputs(step["outputs"], definition["outputs"]) - return value - - def write(self, value): - value["updated_at"] = time.time() - atomic(self.record_path(value["id"]), value) - - def records(self): - if not os.path.lexists(self.path): - return [] - require(stat.S_ISDIR(self.path.lstat().st_mode), "invalid execution directory") - return [self.read(p.stem) for p in sorted(self.path.glob("*.json"))] - - def prune(self): - # Caller holds the store lock. Never age out uncertain running work: - # only an operator can establish it is safe to abandon after a crash. - records = self.records() - changed = False - for value in records: - if value["state"] != "running" and time.time() >= value["expires_at"]: - self.record_path(value["id"]).unlink() - changed = True - if changed: - fsync_dir(self.path) - - -class ImageNotFound(ValueError): - pass - - -def docker(args, capture=True): - result = subprocess.run( - ["/usr/bin/docker"] + args, - stdout=subprocess.PIPE if capture else None, - stderr=subprocess.PIPE if capture else None, - check=False, - ) - if result.returncode != 0 and capture and args[:2] == ["image", "inspect"]: - # Only Docker's explicit missing-image diagnostic permits a pull. - # Unknown errors fail closed; never expose raw stderr or credentials. - diagnostic = result.stderr.decode(errors="replace").strip() - if ( - diagnostic.startswith( - ( - "Error: No such image: ", - "Error response from daemon: No such image: ", - ) - ) - and "\n" not in diagnostic - ): - raise ImageNotFound("job image is not locally available") - require( - False, - "Docker image inspection failed; check Docker daemon access and permissions", - ) - # Docker errors may contain expanded secret configuration. Keep them in the - # operator's container logs, never in durable public state or error metadata. - require(result.returncode == 0, "Docker operation failed: " + args[0]) - return result.stdout.decode() if capture else "" - - -def container_running(name): - ids = docker(["ps", "-aq", "--filter", "name=^/" + name + "$"]).split() - if not ids: - return False - rows = json.loads(docker(["inspect"] + ids)) - return any( - row["State"].get("Running") or row["State"].get("Restarting") for row in rows - ) - - -def cleanup_container(config, invocation=None, shutdown_grace=30, state_path=None): - ids = docker( - ["ps", "-aq", "--filter", "name=^/" + config["container"] + "$"] - ).split() - if not ids: - return - rows = json.loads(docker(["inspect"] + ids)) - for row in rows: - labels = row["Config"].get("Labels") or {} - require( - labels.get("onebox.execution.job") == config["job"], - "existing container has no matching job ownership; inspect and remove it manually", - ) - if invocation is None: - require( - not (row["State"].get("Running") or row["State"].get("Restarting")), - "previous container is still running", - ) - else: - require( - labels.get("onebox.execution.invocation") == invocation, - "container belongs to another invocation", - ) - running = row["State"].get("Running") or row["State"].get("Restarting") - forced = False - if running: - docker(["kill", "--signal", "TERM", row["Id"]], capture=False) - deadline = time.monotonic() + shutdown_grace - while container_running(config["container"]): - if time.monotonic() >= deadline: - docker(["kill", "--signal", "KILL", row["Id"]], capture=False) - forced = True - break - time.sleep(min(0.1, max(0, deadline - time.monotonic()))) - if forced and state_path is not None: - with open(state_path, "a", encoding="utf-8") as state: - state.write("forced_kill=true\n") - remaining = docker( - ["ps", "-aq", "--filter", "name=^/" + config["container"] + "$"] - ).split() - if row["Id"] in remaining: - docker(["rm", "-f", row["Id"]], capture=False) - continue - # Pre-attempt cleanup never forces removal: Docker must also refuse if - # the stopped container starts after our inspection. - docker(["rm", row["Id"]]) - - -def compatibility(config, release_dir): - # Only public container identities are retained; never docker inspect's env. - services = [] - for name in config["services"]: - rows = json.loads(docker(["inspect", name])) - require( - len(rows) == 1 and rows[0]["State"]["Running"], - "required service is not running", - ) - services.append( - [name, rows[0]["Id"], rows[0]["Image"], rows[0]["State"]["StartedAt"]] - ) - files = [] - for relative in ( - ["compose.yaml"] + config["env_files"] + (config.get("fingerprint_files") or []) - ): - path = release_dir / relative - require(path.is_file(), "required release file is missing") - files.append([relative, hashlib.sha256(path.read_bytes()).hexdigest()]) - generation = ( - release_dir.parent.parent / "schedule" / "executions" / ".data-generation" - ) - data_generation = read_json(generation) if os.path.lexists(generation) else "" - return digest([services, files, data_generation]) - - -def compose(config, release_dir): - args = [ - "compose", - "--profile", - "job", - "-p", - config["application"], - "--project-directory", - str(release_dir), - "-f", - str(release_dir / "compose.yaml"), - ] - for file in config["env_files"]: - args += ["--env-file", str(release_dir / file)] - return args - - -def image_identity(config, release_dir, allow_pull=False): - # Never persist the expanded compose config: it can contain credentials. - model = json.loads( - docker(compose(config, release_dir) + ["config", "--format", "json"]) - ) - reference = model["services"][config["job"]]["image"] - try: - rows = json.loads(docker(["image", "inspect", reference])) - except ImageNotFound: - require(allow_pull, "original job image is not locally available") - docker(["pull", reference]) - rows = json.loads(docker(["image", "inspect", reference])) - require( - len(rows) == 1 and re.fullmatch(r"sha256:[0-9a-f]{64}", rows[0]["Id"]), - "job image is not locally available with an immutable identity", - ) - return rows[0]["Id"] - - -def prepare(store, config, release, invocation, resume, operation, overrides): - require( - ID.fullmatch(invocation), "durable execution requires a systemd invocation ID" - ) - require(RELEASE.fullmatch(release), "invalid starting release") - release_dir = store.root / "releases" / release - require( - (store.root / "current").resolve() == release_dir.resolve(), - "current release changed", - ) - require( - not container_running(config["container"]), - "previous job container is still running; stop it before retrying", - ) - identity = image_identity(config, release_dir, allow_pull=not resume) - observed = compatibility(config, release_dir) - with store.locked(): - if resume: - value = store.read(resume) - require(value["state"] not in TERMINAL, "execution is terminal") - require( - time.time() < value["expires_at"], "execution has expired; abandon it" - ) - require( - value["release"] == release, - "resume requires the original current release", - ) - require( - value["definition_digest"] == digest(config), - "installed workflow definition changed", - ) - require( - value["compatibility"] == observed, - "service identities or release files changed", - ) - require(value["image"] == identity, "job image changed or is unavailable") - require(not overrides, "resume cannot override original inputs") - if value["activations"][-1]["state"] == "running": - value["activations"][-1]["state"] = "interrupted" - for step in value["steps"]: - if step["state"] == "running": - step["state"] = "interrupted" - step["attempts"][-1]["state"] = "interrupted" - else: - store.prune() - inputs = dict(config["defaults"]) - require(set(overrides) <= set(inputs), "undeclared inputs") - inputs.update(overrides) - require(len(encode(inputs)) <= 16384, "inputs exceed size limit") - execution = uuid.uuid4().hex - value = { - "version": VERSION, - "id": execution, - "job": config["job"], - "release": release, - "image": identity, - "definition": config, - "definition_digest": digest(config), - "compatibility": observed, - "inputs": inputs, - "created_at": time.time(), - "expires_at": time.time() + config["retain_seconds"], - "activations": [], - "steps": [ - { - "name": s["name"], - "id": execution + ":" + s["name"], - "state": "pending", - "outputs": {}, - "attempts": [], - } - for s in config["steps"] - ], - } - require( - len(value["activations"]) < 100, - "execution reached 100 activations; abandon it", - ) - value["state"] = "running" - value["invocation"] = invocation - value["activations"].append( - { - "invocation": invocation, - "operation": operation, - "started_at": time.time(), - "state": "running", - } - ) - store.write(value) - return value["id"] - - -def validate_outputs(outputs, declared): - require( - isinstance(outputs, dict) and set(outputs) == set(declared), - "outputs must contain exactly the declared keys", - ) - require(len(encode(outputs)) <= 16384, "outputs exceed aggregate size limit") - for value in outputs.values(): - require( - isinstance(value, str) - and "\x00" not in value - and len(value.encode()) <= 4096, - "outputs must be strings of at most 4096 bytes without NUL", - ) - return outputs - - -def save_owned(store, value, invocation): - with store.locked(): - current = store.read(value["id"]) - require( - current["invocation"] == invocation and current["state"] == "running", - "execution ownership changed", - ) - store.write(value) - - -def update_run_status(store, value, invocation): - path = store.root / "schedule" / (value["job"] + ".state") - if not path.exists(): - return - lines = path.read_text().splitlines() - count = sum( - a["invocation"] == invocation for s in value["steps"] for a in s["attempts"] - ) - lines = [line for line in lines if not line.startswith("attempt=")] - lines.append("attempt=" + str(count)) - atomic_bytes(path, ("\n".join(lines) + "\n").encode()) - - -def execute(store, identity, invocation, shutdown_grace=30): - value = store.read(identity) - require( - value["invocation"] == invocation and value["state"] == "running", - "execution is not owned", - ) - config = value["definition"] - release_dir = store.root / "releases" / value["release"] - deadline = time.monotonic() + config["timeout_seconds"] - - # systemd terminates the complete activation too. This handler ensures a - # graceful timeout is checkpointed; SIGKILL/reboot is reconciled as interrupted. - def interrupted(_signum, _frame): - raise InterruptedError("activation interrupted") - - signal.signal(signal.SIGTERM, interrupted) - signal.signal(signal.SIGINT, interrupted) - print("onebox: execution " + identity, flush=True) - result = 1 - try: - for index, definition in enumerate(config["steps"]): - step = value["steps"][index] - if step["state"] == "succeeded": - continue - backoff = definition["backoff"] - for batch_attempt in range(definition["attempts"]): - if time.monotonic() >= deadline: - raise TimeoutError("activation timeout") - require( - not container_running(config["container"]), - "previous container is still running", - ) - cleanup_container(config) - step["state"] = "running" - attempt = { - "id": uuid.uuid4().hex, - "number": len(step["attempts"]) + 1, - "invocation": invocation, - "state": "running", - "started_at": time.time(), - } - step["attempts"].append(attempt) - save_owned(store, value, invocation) - update_run_status(store, value, invocation) - env = dict(value["inputs"]) - for key, ref in definition["inputs"].items(): - source, output = ref.split(".") - env[key] = next(s for s in value["steps"] if s["name"] == source)[ - "outputs" - ][output] - env.update( - ONEBOX_EXECUTION_ID=identity, - ONEBOX_STEP_ID=step["id"], - ONEBOX_ATTEMPT_ID=attempt["id"], - ONEBOX_OUTPUT_FILE="/onebox-output/result.json", - ) - # This directory is disposable, separate from the host-owned store. - with tempfile.TemporaryDirectory( - prefix="output-", dir=store.path - ) as output_dir: - os.chmod(output_dir, 0o777) - # Override the resolved image with its original content ID and - # disable pulling. A moved tag cannot change a resumed attempt. - override = Path(output_dir) / "compose.json" - override.write_bytes( - encode({"services": {config["job"]: {"image": value["image"]}}}) - ) - os.chmod(override, 0o600) - args = compose(config, release_dir) + [ - "-f", - str(override), - "run", - "--rm", - "--no-deps", - "--pull", - "never", - "--name", - config["container"], - "--label", - "onebox.execution.job=" + config["job"], - "--label", - "onebox.execution.invocation=" + invocation, - "--volume", - output_dir + ":/onebox-output", - ] - for key, val in sorted(env.items()): - args += ["-e", key + "=" + val] - args += [config["job"]] + definition["command"] - print( - "onebox: step " - + step["name"] - + " attempt " - + str(attempt["number"]), - flush=True, - ) - try: - proc = subprocess.run( - ["/usr/bin/docker"] + args, - check=False, - timeout=max(0.01, deadline - time.monotonic()), - ) - attempt["exit_status"] = proc.returncode - outputs = {} - if proc.returncode == 0 and definition["outputs"]: - outputs = read_json(Path(output_dir) / "result.json", 16384) - if proc.returncode == 0: - step["outputs"] = validate_outputs( - outputs, definition["outputs"] - ) - step["state"] = attempt["state"] = "succeeded" - else: - step["state"] = attempt["state"] = "failed" - except InterruptedError: - raise - except (ValueError, OSError) as error: - step["state"] = attempt["state"] = "failed" - attempt["reason"] = "output validation or execution failed" - print("onebox: " + str(error), file=sys.stderr) - finally: - cleanup_container( - config, - invocation, - shutdown_grace, - store.root / "schedule" / (config["job"] + ".state"), - ) - attempt["finished_at"] = time.time() - save_owned(store, value, invocation) - if step["state"] == "succeeded": - break - if batch_attempt + 1 < definition["attempts"]: - if time.monotonic() + backoff >= deadline: - raise TimeoutError("activation timeout during backoff") - time.sleep(backoff) - backoff = min(backoff * 2, definition["max_backoff"]) - if step["state"] != "succeeded": - value["state"] = "failed" - break - else: - value["state"] = "succeeded" - result = 0 - except (TimeoutError, subprocess.TimeoutExpired): - value["state"] = "timeout" - except InterruptedError: - value["state"] = "interrupted" - finally: - for step in value["steps"]: - if step["state"] == "running": - step["state"] = "interrupted" - step["attempts"][-1]["state"] = "interrupted" - if value["state"] == "running": - value["state"] = "interrupted" - value["activations"][-1].update(state=value["state"], finished_at=time.time()) - save_owned(store, value, invocation) - return result - - -def view(value, root): - # A read derives interruption without rewriting evidence or requiring locks. - if value["state"] == "running": - config = value["definition"] - probe = subprocess.run( - [ - "systemctl", - "show", - config["unit"] + ".service", - "--property=InvocationID", - "--property=ActiveState", - ], - capture_output=True, - text=True, - check=False, - ) - require(probe.returncode == 0, "cannot inspect execution unit") - fields = dict( - line.split("=", 1) for line in probe.stdout.splitlines() if "=" in line - ) - active = fields.get("ActiveState") in {"active", "activating", "deactivating"} - if not (active and fields.get("InvocationID") == value["invocation"]): - value["state"] = "interrupted" - for step in value["steps"]: - if step["state"] == "running": - step["state"] = "interrupted" - value["expired"] = time.time() >= value["expires_at"] - value["resumable"] = ( - value["state"] not in TERMINAL | {"running"} and not value["expired"] - ) - value["current_release_matches"] = (root / "current").resolve().name == value[ - "release" - ] - return value - - -def abandon(store, identity): - value = store.read(identity) - path = store.root / "schedule" / (value["job"] + ".lock") - # The Go caller holds the application lock; this mutex also excludes an - # already running pinned job, which intentionally permits that app lock. - fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - try: - try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise ValueError( - "job is locked by an active operation; retry abandonment after it finishes" - ) from None - require( - not container_running(value["definition"]["container"]), - "job container is still running", - ) - with store.locked(): - value = store.read(identity) - require( - value["state"] != "succeeded", - "successful execution cannot be abandoned", - ) - value["state"] = "abandoned" - store.write(value) - finally: - os.close(fd) - - -def main(args): - counts = { - "prepare": 8, - "run": 5, - "inspect": 3, - "list": 3, - "pins": 2, - "abandon": 3, - "invalidate": 2, - } - require( - bool(args) and args[0] in counts, "unknown or missing durable execution command" - ) - require(len(args) == counts[args[0]], "invalid argument count for " + args[0]) - command, root = args[:2] - store = Store(root) - if command == "prepare": - config = json.loads(base64.b64decode(args[2])) - print(prepare(store, config, *args[3:7], json.loads(args[7]))) - elif command == "run": - return execute(store, *args[2:4], float(args[4])) - elif command == "inspect": - print(json.dumps(view(store.read(args[2]), store.root))) - elif command == "list": - values = store.records() - values.sort(key=lambda v: v["created_at"], reverse=True) - print(json.dumps([view(v, store.root) for v in values[: int(args[2])]])) - elif command == "pins": - # Running/interrupted work is conservatively protected even after its - # expiry until explicitly abandoned. Expiry never proves a worker stopped. - print( - "\n".join( - sorted( - { - v["release"] - for v in store.records() - if v["state"] not in TERMINAL - and (v["state"] == "running" or time.time() < v["expires_at"]) - } - ) - ) - ) - elif command == "abandon": - abandon(store, args[2]) - elif command == "invalidate": - with store.locked(): - atomic(store.path / ".data-generation", uuid.uuid4().hex) - else: - raise ValueError("unknown durable execution command") - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main(sys.argv[1:])) - except (ValueError, OSError, KeyError, TypeError, IndexError) as error: - print("onebox: durable execution refused: " + str(error), file=sys.stderr) - sys.exit(1) diff --git a/internal/durable/runner_test.go b/internal/durable/runner_test.go deleted file mode 100644 index ae0a4f40..00000000 --- a/internal/durable/runner_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package durable - -import ( - "os" - "os/exec" - "testing" -) - -func TestHostCheckpointProtocol(t *testing.T) { - python, err := exec.LookPath("python3") - if err != nil { - t.Fatal("durable runner tests require python3") - } - cmd := exec.CommandContext(t.Context(), python, "-m", "unittest", "-v", "runner_test.py") - cmd.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("host checkpoint tests: %v\n%s", err, output) - } -} diff --git a/internal/durable/runner_test.py b/internal/durable/runner_test.py deleted file mode 100644 index 4bad52f5..00000000 --- a/internal/durable/runner_test.py +++ /dev/null @@ -1,445 +0,0 @@ -import copy -import json -import subprocess -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -import runner as r - - -class Checkpoints(unittest.TestCase): - def test_malformed_cli_arguments_refuse_without_traceback(self): - for args in [ - [], - ["unknown"], - ["inspect", "/tmp"], - ["prepare", "/tmp"], - ["run", "/tmp"], - ["pins", "/tmp", "unexpected"], - ]: - with self.subTest(args=args): - result = subprocess.run( - [r.sys.executable, r.__file__] + args, - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(result.returncode, 1) - self.assertEqual(result.stdout, "") - self.assertTrue( - result.stderr.startswith("onebox: durable execution refused: ") - ) - self.assertEqual(len(result.stderr.splitlines()), 1) - - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - self.root = Path(self.tmp.name) - self.release = "20260906-120000-test" - release_dir = self.root / "releases" / self.release - release_dir.mkdir(parents=True) - (release_dir / "compose.yaml").write_text("services: {}") - (self.root / "current").symlink_to(release_dir) - self.store = r.Store(self.root) - self.config = { - "application": "sample", - "job": "refresh", - "unit": "onebox-job-refresh", - "container": "sample-refresh-1", - "defaults": {"SOURCE": "catalog"}, - "env_files": [], - "services": [], - "retain_seconds": 3600, - "timeout_seconds": 60, - "steps": [ - self.step("sync", outputs=["RELEASE"]), - self.step("index", inputs={"RELEASE_ID": "sync.RELEASE"}), - ], - } - self.invocation = "a" * 32 - self.image = "sha256:" + "b" * 64 - self.docker = patch.object(r, "docker", return_value="") - self.docker.start() - self.addCleanup(self.docker.stop) - self.running = patch.object(r, "container_running", return_value=False) - self.running.start() - self.addCleanup(self.running.stop) - self.image_patch = patch.object(r, "image_identity", return_value=self.image) - self.image_patch.start() - self.addCleanup(self.image_patch.stop) - - def step(self, name, inputs=None, outputs=None): - return { - "name": name, - "command": [name], - "inputs": inputs or {}, - "outputs": outputs or [], - "attempts": 1, - "backoff": 1, - "max_backoff": 1, - } - - def prepare(self, resume="", invocation=None, overrides=None): - return r.prepare( - self.store, - self.config, - self.release, - invocation or self.invocation, - resume, - "operation", - overrides or {}, - ) - - def test_prune_preserves_uncertain_running_execution(self): - running = self.prepare() - expired = self.prepare(invocation="c" * 32) - for identity, state in [(running, "running"), (expired, "failed")]: - value = self.store.read(identity) - value["state"] = state - value["expires_at"] = 1 - self.store.write(value) - with self.store.locked(): - self.store.prune() - self.assertTrue(self.store.record_path(running).exists()) - self.assertFalse(self.store.record_path(expired).exists()) - - def test_run_status_counts_only_current_activation_attempts(self): - identity = self.prepare() - value = self.store.read(identity) - path = self.root / "schedule" / "refresh.state" - path.write_text("execution=" + identity + "\nattempt=1\n") - value["steps"][0]["attempts"] = [{"invocation": "previous"}] - value["steps"][1]["attempts"] = [ - {"invocation": self.invocation}, - {"invocation": self.invocation}, - ] - r.update_run_status(self.store, value, self.invocation) - self.assertEqual(path.read_text(), "execution=" + identity + "\nattempt=2\n") - - def fake_run(self, fail_index=False, invalid=False): - def run(argv, **_kwargs): - env = dict( - argv[i + 1].split("=", 1) for i, arg in enumerate(argv) if arg == "-e" - ) - step = argv[-1] - self.calls.append((step, env)) - if step == "sync": - output_dir = argv[argv.index("--volume") + 1].split(":")[0] - Path(output_dir, "result.json").write_text( - json.dumps({"WRONG" if invalid else "RELEASE": "release-123"}) - ) - if step == "index": - self.assertEqual(env["RELEASE_ID"], "release-123") - return subprocess.CompletedProcess( - argv, 1 if step == "index" and fail_index else 0 - ) - - return run - - def test_resume_skips_committed_success_and_keeps_inputs_and_ids(self): - identity = self.prepare(overrides={"SOURCE": "custom"}) - self.calls = [] - with patch.object( - r.subprocess, "run", side_effect=self.fake_run(fail_index=True) - ): - self.assertEqual(r.execute(self.store, identity, self.invocation), 1) - before = self.store.read(identity) - self.assertEqual(before["state"], "failed") - self.assertEqual(before["steps"][0]["outputs"], {"RELEASE": "release-123"}) - invocation = "c" * 32 - self.assertEqual(self.prepare(identity, invocation), identity) - with patch.object(r.subprocess, "run", side_effect=self.fake_run()): - self.assertEqual(r.execute(self.store, identity, invocation), 0) - after = self.store.read(identity) - self.assertEqual([name for name, _ in self.calls], ["sync", "index", "index"]) - self.assertEqual(after["state"], "succeeded") - self.assertEqual(after["inputs"], {"SOURCE": "custom"}) - self.assertEqual( - self.calls[1][1]["ONEBOX_STEP_ID"], self.calls[2][1]["ONEBOX_STEP_ID"] - ) - self.assertNotEqual( - self.calls[1][1]["ONEBOX_ATTEMPT_ID"], self.calls[2][1]["ONEBOX_ATTEMPT_ID"] - ) - with self.assertRaisesRegex(ValueError, "terminal"): - self.prepare(identity) - - def test_invalid_outputs_never_commit_success_or_run_next_step(self): - identity = self.prepare() - self.calls = [] - with patch.object(r.subprocess, "run", side_effect=self.fake_run(invalid=True)): - self.assertEqual(r.execute(self.store, identity, self.invocation), 1) - value = self.store.read(identity) - self.assertEqual(value["steps"][0]["state"], "failed") - self.assertEqual(value["steps"][0]["outputs"], {}) - self.assertEqual(value["steps"][1]["state"], "pending") - - def test_interrupted_attempt_and_activation_reconciled_on_resume(self): - identity = self.prepare() - value = self.store.read(identity) - value["steps"][0].update(state="running", attempts=[{"state": "running"}]) - self.store.write(value) - self.prepare(identity, "c" * 32) - value = self.store.read(identity) - self.assertEqual(value["steps"][0]["state"], "interrupted") - self.assertEqual(value["activations"][0]["state"], "interrupted") - - def test_live_container_refuses_new_execution_and_resume(self): - identity = self.prepare() - with patch.object(r, "container_running", return_value=True): - for resume in ["", identity]: - with self.assertRaisesRegex(ValueError, "still running"): - self.prepare(resume) - - def test_resume_refuses_definition_image_generation_and_input_changes(self): - identity = self.prepare() - with self.assertRaisesRegex(ValueError, "override"): - self.prepare(identity, overrides={"SOURCE": "changed"}) - changed = copy.deepcopy(self.config) - self.config["steps"][0]["command"] = ["different"] - with self.assertRaisesRegex(ValueError, "definition"): - self.prepare(identity) - self.config = changed - with ( - patch.object(r, "image_identity", return_value="sha256:" + "d" * 64), - self.assertRaisesRegex(ValueError, "image changed"), - ): - self.prepare(identity) - r.main(["invalidate", str(self.root)]) - with self.assertRaisesRegex(ValueError, "changed"): - self.prepare(identity) - - def test_expired_and_abandoned_records_cannot_resume(self): - identity = self.prepare() - value = self.store.read(identity) - value["expires_at"] = 0 - self.store.write(value) - with self.assertRaisesRegex(ValueError, "expired"): - self.prepare(identity) - r.abandon(self.store, identity) - self.assertEqual(self.store.read(identity)["state"], "abandoned") - - def test_abandon_locked_job_refuses_without_changing_checkpoint(self): - identity = self.prepare() - before = self.store.read(identity) - path = self.root / "schedule" / "refresh.lock" - with path.open("w") as lock: - r.fcntl.flock(lock, r.fcntl.LOCK_EX | r.fcntl.LOCK_NB) - with self.assertRaisesRegex( - ValueError, "job is locked by an active operation" - ): - r.abandon(self.store, identity) - self.assertEqual(self.store.read(identity), before) - r.abandon(self.store, identity) - self.assertEqual(self.store.read(identity)["state"], "abandoned") - - def image_lookup(self, response, allow_pull): - self.image_patch.stop() - self.docker.stop() - model = json.dumps( - {"services": {"refresh": {"image": "example:latest"}}} - ).encode() - responses = [ - subprocess.CompletedProcess([], 0, model, b""), - response, - subprocess.CompletedProcess([], 0, b"", b""), - subprocess.CompletedProcess( - [], 0, json.dumps([{"Id": self.image}]).encode(), b"" - ), - ] - with patch.object(r.subprocess, "run", side_effect=responses) as commands: - self.image_commands = commands - return r.image_identity(self.config, self.root / "current", allow_pull) - - def test_only_explicit_missing_image_allows_pull(self): - for prefix in [b"Error: ", b"Error response from daemon: "]: - with self.subTest(prefix=prefix): - response = subprocess.CompletedProcess( - [], 1, b"[]", prefix + b"No such image: example:latest\n" - ) - self.assertEqual(self.image_lookup(response, True), self.image) - self.assertEqual( - self.image_commands.call_args_list[2].args[0], - ["/usr/bin/docker", "pull", "example:latest"], - ) - with self.assertRaisesRegex( - ValueError, "original job image is not locally available" - ): - self.image_lookup(response, False) - self.assertEqual(self.image_commands.call_count, 2) - - def test_failed_or_malformed_image_inspection_never_pulls(self): - for allow_pull in [False, True]: - for response, message in [ - ( - subprocess.CompletedProcess( - [], 1, b"", b"daemon unavailable secret-token" - ), - "Docker image inspection failed", - ), - ( - subprocess.CompletedProcess([], 0, b"invalid JSON", b""), - "Expecting value", - ), - ]: - with self.subTest(allow_pull=allow_pull, response=response): - with self.assertRaisesRegex(ValueError, message) as raised: - self.image_lookup(response, allow_pull) - self.assertNotIn("secret-token", str(raised.exception)) - self.assertEqual(self.image_commands.call_count, 2) - - def test_atomic_replace_failure_keeps_previous_checkpoint(self): - identity = self.prepare() - previous = self.store.read(identity) - changed = copy.deepcopy(previous) - changed["state"] = "failed" - with ( - patch.object(r.os, "replace", side_effect=OSError("disk full")), - self.assertRaises(OSError), - ): - self.store.write(changed) - self.assertEqual(self.store.read(identity), previous) - self.assertFalse(list(self.store.path.glob(".checkpoint-*"))) - - def test_invalid_state_and_output_files_fail_closed(self): - identity = self.prepare() - path = self.store.record_path(identity) - path.write_text("{broken") - with self.assertRaises(ValueError): - self.store.records() - path.unlink() - path.symlink_to(self.root / "current" / "compose.yaml") - with self.assertRaises(OSError): - self.store.records() - path.unlink() - path.write_text('{"key":"a","key":"b"}') - with self.assertRaisesRegex(ValueError, "duplicate"): - r.read_json(path) - path.write_text("x" * 20) - with self.assertRaisesRegex(ValueError, "limit"): - r.read_json(path, 10) - - def test_interruption_never_starts_a_retry(self): - self.config["steps"][0]["attempts"] = 3 - identity = self.prepare() - with patch.object( - r.subprocess, "run", side_effect=InterruptedError("SIGTERM") - ) as run: - self.assertEqual(r.execute(self.store, identity, self.invocation), 1) - self.assertEqual(run.call_count, 1) - value = self.store.read(identity) - self.assertEqual(value["state"], "interrupted") - self.assertEqual(len(value["steps"][0]["attempts"]), 1) - - def test_cleanup_refuses_another_invocations_container(self): - row = { - "Id": "container", - "State": {"Running": True}, - "Config": { - "Labels": { - "onebox.execution.job": "refresh", - "onebox.execution.invocation": "other", - } - }, - } - with ( - patch.object( - r, "docker", side_effect=["container", json.dumps([row])] - ) as docker, - self.assertRaisesRegex(ValueError, "another invocation"), - ): - r.cleanup_container(self.config, self.invocation) - self.assertEqual(docker.call_count, 2) - - def test_owned_running_container_gets_term_before_removal(self): - row = { - "Id": "container", - "State": {"Running": True, "Restarting": False}, - "Config": { - "Labels": { - "onebox.execution.job": "refresh", - "onebox.execution.invocation": self.invocation, - } - }, - } - with ( - patch.object( - r, - "docker", - side_effect=["container", json.dumps([row]), "", ""], - ) as docker, - patch.object(r, "container_running", return_value=False), - ): - r.cleanup_container(self.config, self.invocation) - calls = [call.args[0] for call in docker.call_args_list] - self.assertIn(["kill", "--signal", "TERM", "container"], calls) - self.assertNotIn(["kill", "--signal", "KILL", "container"], calls) - - def test_owned_running_container_records_forced_kill_after_grace(self): - row = { - "Id": "container", - "State": {"Running": True, "Restarting": False}, - "Config": { - "Labels": { - "onebox.execution.job": "refresh", - "onebox.execution.invocation": self.invocation, - } - }, - } - state = self.root / "schedule" / "refresh.state" - state.parent.mkdir(parents=True) - state.write_text("phase=stopping\n") - with ( - patch.object( - r, - "docker", - side_effect=["container", json.dumps([row]), "", "", "container", ""], - ) as docker, - patch.object(r, "container_running", return_value=True), - patch.object(r.time, "monotonic", return_value=0), - ): - r.cleanup_container(self.config, self.invocation, 0, state) - calls = [call.args[0] for call in docker.call_args_list] - self.assertLess( - calls.index(["kill", "--signal", "TERM", "container"]), - calls.index(["kill", "--signal", "KILL", "container"]), - ) - self.assertIn("forced_kill=true", state.read_text()) - - def test_nonfinite_retention_evidence_refused(self): - identity = self.prepare() - value = self.store.read(identity) - value["expires_at"] = float("nan") - self.store.record_path(identity).write_text(json.dumps(value)) - with self.assertRaisesRegex(ValueError, "non-finite"): - r.main(["pins", str(self.root)]) - - def test_service_restart_invalidates_resume(self): - self.config["services"] = ["database"] - row = { - "Id": "same-container", - "Image": "same-image", - "State": {"Running": True, "StartedAt": "before"}, - } - with patch.object(r, "docker", return_value=json.dumps([row])): - identity = self.prepare() - row["State"]["StartedAt"] = "after" - with ( - patch.object(r, "docker", return_value=json.dumps([row])), - self.assertRaisesRegex(ValueError, "changed"), - ): - self.prepare(identity) - - def test_output_limits_and_types(self): - for value in [{"VALUE": 1}, {"VALUE": "a" * 4097}, {"VALUE": "\x00"}, {}]: - with self.assertRaises(ValueError): - r.validate_outputs(value, ["VALUE"]) - self.assertEqual( - r.validate_outputs({"VALUE": 'quotes" and\nlines'}, ["VALUE"]), - {"VALUE": 'quotes" and\nlines'}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/internal/engine/backup_identity_test.go b/internal/engine/backup_identity_test.go index 6a0cd9bb..fa67f031 100644 --- a/internal/engine/backup_identity_test.go +++ b/internal/engine/backup_identity_test.go @@ -10,7 +10,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const protectedPostgresProject = `apiVersion: onebox.run/v1alpha1 +const protectedPostgresProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go index 91c97e3c..26141a3a 100644 --- a/internal/engine/deploy_test.go +++ b/internal/engine/deploy_test.go @@ -22,7 +22,7 @@ import ( // exercise the unguardable path in every test. const guardedHealthcheck = `["CMD-SHELL","[ -f /tmp/onebox-drain ] \u0026\u0026 exit 1; curl -fsS 'http://127.0.0.1:80/'"]` -const enginePreviousFrontendProject = `apiVersion: onebox.run/v1alpha1 +const enginePreviousFrontendProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -501,6 +501,55 @@ func TestDeployPhaseOrder(t *testing.T) { } } +func TestDeployRejectsUnsupportedSnapshotIdentity(t *testing.T) { + f := happyFake() + base := f.Dynamic + readPrevious := false + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "readlink") { + return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true + } + result, handled := base(command) + if strings.Contains(command, "/onebox.snapshot.yml") && handled { + readPrevious = true + result.Stdout = strings.Replace(result.Stdout, app.APIVersion, "onebox.run/v1alpha1", 1) + } + return result, handled + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + if err := e.Deploy(t.Context(), engineTestDeployReleaseID, t.TempDir()); err == nil || !strings.Contains(err.Error(), "schema_identity_unsupported") { + t.Fatalf("unsupported snapshot identity was not rejected: %v", err) + } + if !readPrevious || len(f.Uploads) != 0 { + t.Fatal("unsupported snapshot must refuse before staging a new release") + } +} + +func TestDeployRefusesPreviousSnapshotWithRetiredExecution(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "readlink") { + return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true + } + if strings.Contains(command, "/onebox.snapshot.yml") { + snapshot := strings.Replace(engineProject, " migrate:\n", " migrate:\n execution: {}\n", 1) + return transport.Result{Stdout: snapshot}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := e.Deploy(t.Context(), engineTestDeployReleaseID, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "execution") || len(f.Uploads) != 0 { + t.Fatalf("retired snapshot must refuse before staging: %v", err) + } + for _, command := range f.Commands { + if strings.Contains(command, "run --rm --no-deps") || strings.Contains(command, "--scale web=") { + t.Fatalf("retired workflow snapshot reached runtime mutation: %s", command) + } + } +} + func TestDeployRetiresWorkloadRemovedByRenameAfterActivation(t *testing.T) { f := happyFake() base := f.Dynamic diff --git a/internal/engine/fixtures_test.go b/internal/engine/fixtures_test.go index f1f27755..c8185ee8 100644 --- a/internal/engine/fixtures_test.go +++ b/internal/engine/fixtures_test.go @@ -20,7 +20,7 @@ const ( // normalised config. Loading it through the real loader is the point: a test // that assembles the struct directly can assert on a shape the loader would // never produce. -const engineProject = `apiVersion: onebox.run/v1alpha1 +const engineProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/engine/gate.go b/internal/engine/gate.go index c1c285d9..b24b9905 100644 --- a/internal/engine/gate.go +++ b/internal/engine/gate.go @@ -116,15 +116,6 @@ func (e *Engine) runJobPhase(ctx context.Context, jw *journal.Writer, done map[s // rollback-safe (changed=false). Returns (safe, detail, err). func (e *Engine) runOneJob(ctx context.Context, operationID string, epoch int, job, remoteDir, remoteCompose string) (bool, string, error) { safeByDeclaration := e.jobDataEffect(job) == app.DataEffectNone - if !safeByDeclaration { - res, err := e.mutate(ctx, invalidateExecutionCommand(e.names().AppDir())) - if err != nil { - return false, "", err - } - if res.ExitCode != 0 { - return false, "", fmt.Errorf("cannot invalidate durable execution compatibility before data-changing job") - } - } resultDir := remoteDir + "/" + jobResultDirName(job) resultFile := resultDir + "/result" const containerResultFile = "/run/onebox/job-result" diff --git a/internal/engine/host_environment_test.go b/internal/engine/host_environment_test.go index e71c63ad..addc0fed 100644 --- a/internal/engine/host_environment_test.go +++ b/internal/engine/host_environment_test.go @@ -98,7 +98,7 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) { // project's default base_path instead of the environment's. An engine built // without one now takes the environment the project was resolved for. func TestEnvironmentSelectsTheBasePath(t *testing.T) { - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/engine/ops.go b/internal/engine/ops.go index d0a42ff9..2867890f 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -441,15 +441,6 @@ func (e *Engine) ExecInAudited(ctx context.Context, operationID, name, command, err = errors.Join(err, fmt.Errorf("journal exec finish: %w", journalErr)) } }() - // Arbitrary exec has unknown data effects, even when its reason sounds - // read-only. Invalidate saved compatibility before it can change a schema. - res, invalidateErr := e.mutate(ctx, invalidateExecutionCommand(e.names().AppDir())) - if invalidateErr != nil { - return containerID, invalidateErr - } - if res.ExitCode != 0 { - return containerID, errors.New("cannot invalidate durable execution compatibility before exec") - } err = e.mutateStream(ctx, "docker exec "+containerID+" sh -c "+q(command), stdout, stderr) return containerID, err } diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go index c58ffe67..03de74ca 100644 --- a/internal/engine/resume_test.go +++ b/internal/engine/resume_test.go @@ -389,7 +389,7 @@ func TestAbortUsesInterruptedExpandOnlyPolicyAfterConfigEdit(t *testing.T) { testAbortReplaysPreviousRelease(t, "changed=unknown", true) } -const interruptedWebSnapshot = `apiVersion: onebox.run/v1alpha1 +const interruptedWebSnapshot = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/engine/rollback_test.go b/internal/engine/rollback_test.go index 5cc0fef5..c2a2922c 100644 --- a/internal/engine/rollback_test.go +++ b/internal/engine/rollback_test.go @@ -49,7 +49,7 @@ func seedRollbackState(t *testing.T, target *transport.Fake) { // The previous release's snapshot has a DIFFERENT choreography (worker only, // recreate) — rollback must replay THAT, not the current ob.yml. -const oldSnapshot = `apiVersion: onebox.run/v1alpha1 +const oldSnapshot = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/engine/schedule.go b/internal/engine/schedule.go index f093a300..4d167ef4 100644 --- a/internal/engine/schedule.go +++ b/internal/engine/schedule.go @@ -11,7 +11,6 @@ import ( "time" "github.com/labstack/onebox/internal/app" - "github.com/labstack/onebox/internal/durable" "github.com/labstack/onebox/internal/notify" ) @@ -67,21 +66,6 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { if err := e.requireScheduleHost(ctx, jobs); err != nil { return err } - for _, job := range jobs { - if job.Execution != nil { - res, err := e.mutate(ctx, "install -d -m 700 "+q(n.AppDir()+"/schedule")) - if err != nil { - return err - } - if res.ExitCode != 0 { - return errors.New("cannot create durable execution helper directory") - } - if err := e.writeServiceFile(ctx, durable.Helper(n.AppDir()), []byte(durable.Script)); err != nil { - return fmt.Errorf("install durable execution helper: %w", err) - } - break - } - } for _, job := range jobs { unit := n.ScheduledJobUnit(job.Name) wanted[unit] = true @@ -107,12 +91,6 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { runtimeEnvFiles = e.Spec.Runtime.EnvFiles } runner := scheduleRunnerScript(e.Spec.Name, job, n, e.lockPath(), runtimeEnvFiles, e.lockTTL(), e.hasTriggerUnit(ctx)) - if job.Execution != nil { - runner, err = e.durableScheduleRunner(job, runtimeEnvFiles) - if err != nil { - return err - } - } notifier, err := e.scheduleNotifier(job) if err != nil { return fmt.Errorf("job %s: cannot render its failure notifier: %w", job.Name, err) @@ -224,7 +202,7 @@ func scheduleRunnerScript(application string, job app.ScheduledJob, names app.Na projectDir := q(names.CurrentLink()) compose := "/usr/bin/docker compose -p " + q(application) + " --project-directory " + projectDir + " -f " + projectDir + "/" + q("compose.yaml") + scheduleRuntimeEnvArgs(projectDir, runtimeEnvFiles) + - " run --rm --no-deps \"$@\" --label " + q(ExecutionJobLabel+"="+job.Name) + " --name " + q(container) + " " + q(job.Name) + " run --rm --no-deps \"$@\" --name " + q(container) + " " + q(job.Name) lines := []string{ "#!/bin/sh", "# Written by Onebox. Edits are overwritten on the next deploy.", @@ -247,26 +225,18 @@ func scheduleRunnerScript(application string, job app.ScheduledJob, names app.Na ) lines = append(lines, scheduleRunPreamble(triggerUnit)...) lines = append(lines, schedulePlannedBindingLines()...) - if job.DataEffect != app.DataEffectNone { - lines = append(lines, invalidateExecutionCommand(names.AppDir())) - } lines = append(lines, scheduleAttemptLoop(job, compose, container)...) lines = append(lines, "") return strings.Join(lines, "\n") } -// ExecutionJobLabel names the job a one-off container runs. Every scheduled run -// carries it, durable or not, so a container a crash left behind is still -// provably this job's when the durable runner has to reclaim it. -const ExecutionJobLabel = "onebox.execution.job" - func pinnedScheduleRunnerScript(application string, job app.ScheduledJob, names app.Names, applicationLock string, runtimeEnvFiles []app.EnvFile, lockTTL time.Duration, triggerUnit bool) string { scheduleDir := names.AppDir() + "/schedule" container := names.Container(job.Name, 1) projectDir := `"$release_dir"` compose := "/usr/bin/docker compose -p " + q(application) + " --project-directory " + projectDir + " -f " + projectDir + "/" + q("compose.yaml") + scheduleRuntimeEnvArgs(projectDir, runtimeEnvFiles) + - " run --rm --no-deps \"$@\" --label " + q(ExecutionJobLabel+"="+job.Name) + " --name " + q(container) + " " + q(job.Name) + " run --rm --no-deps \"$@\" --name " + q(container) + " " + q(job.Name) lines := []string{ "#!/bin/sh", "# Written by Onebox. Edits are overwritten on the next deploy.", @@ -406,19 +376,6 @@ func (e *Engine) requireScheduleHost(ctx context.Context, jobs []app.ScheduledJo if !e.hasScheduleFlock(ctx) { return errors.New("scheduled jobs require a compatible util-linux flock at /usr/bin/flock so lock contention can be distinguished from host failures; install util-linux or upgrade it and deploy again") } - for _, job := range jobs { - if job.Execution == nil { - continue - } - res, err := e.T.Run(ctx, "/usr/bin/python3 -c 'import sys,fcntl; assert sys.version_info >= (3,8)'") - if err != nil { - return err - } - if res.ExitCode != 0 { - return errors.New("durable jobs require Python 3.8 or newer at /usr/bin/python3; install it and apply schedules again") - } - break - } // Declared inputs are the one feature that cannot work without // TRIGGER_UNIT: the runner would have to guess whether an activation is // the operator's, and guessing wrong hands a timer firing the inputs a @@ -452,7 +409,7 @@ func (e *Engine) hasTriggerUnit(ctx context.Context) bool { func needsTriggerUnit(jobs []app.ScheduledJob) bool { for _, job := range jobs { - if len(job.Inputs) > 0 || job.Execution != nil { + if len(job.Inputs) > 0 { return true } } @@ -525,7 +482,6 @@ func scheduleRunPreamble(triggerUnit bool) []string { func scheduleInputsLines(inputsPath string) []string { return []string{ "operation=''", - "execution=''", "expected_release=''", "expected_runtime=''", "inputs_json=''", @@ -534,7 +490,6 @@ func scheduleInputsLines(inputsPath string) []string { " while IFS= read -r line || [ -n \"$line\" ]; do", " case \"$line\" in", " ONEBOX_OPERATION=*) operation=${line#ONEBOX_OPERATION=} ;;", - " ONEBOX_EXECUTION=*) execution=${line#ONEBOX_EXECUTION=} ;;", " ONEBOX_EXPECTED_RELEASE=*) expected_release=${line#ONEBOX_EXPECTED_RELEASE=} ;;", " ONEBOX_EXPECTED_RUNTIME=*) expected_runtime=${line#ONEBOX_EXPECTED_RUNTIME=} ;;", " [A-Z]*=*) set -- \"$@\" -e \"$line\"; key=${line%%=*}; value=${line#*=}; inputs_json=\"${inputs_json:+$inputs_json,}\\\"$key\\\":\\\"$value\\\"\" ;;", @@ -664,7 +619,7 @@ const scheduleRunIdentifier = "onebox-run" func scheduleRunRecordLines(application, unit, job, state string) []string { return []string{ "state=" + q(state), - "release=''; started_at=''; started_epoch=''; trigger=''; operation=''; attempt=0; inputs=''; skipped=''; execution=''; forced_kill=false", + "release=''; started_at=''; started_epoch=''; trigger=''; operation=''; attempt=0; inputs=''; skipped=''; forced_kill=false", // A run that stood aside left a note under its own invocation. It // never held the job lock, so the state file belongs to whichever run // is still going: read the note and leave that file alone. @@ -683,7 +638,6 @@ func scheduleRunRecordLines(application, unit, job, state string) []string { " while IFS= read -r line || [ -n \"$line\" ]; do", " case \"$line\" in", " release=*) release=${line#release=} ;;", - " execution=*) execution=${line#execution=} ;;", " started_at=*) started_at=${line#started_at=} ;;", " started_epoch=*) started_epoch=${line#started_epoch=} ;;", " trigger=*) trigger=${line#trigger=} ;;", @@ -714,10 +668,8 @@ func scheduleRunRecordLines(application, unit, job, state string) []string { "duration=0", "case \"$started_epoch\" in ''|*[!0-9]*) ;; *) duration=$((now - started_epoch)) ;; esac", "[ -z \"$started_at\" ] && started_at=$finished_at", - "execution_field=''", - "if [ -n \"$execution\" ]; then execution_field=$(printf '\"execution\":\"%s\",' \"$execution\"); fi", - "record=$(printf '{%s\"run\":\"%s\",\"job\":\"%s\",\"trigger\":\"%s\",\"operation\":\"%s\",\"release\":\"%s\",\"started_at\":\"%s\",\"finished_at\":\"%s\",\"duration_s\":%s,\"attempts\":%s,\"exit_status\":%s,\"outcome\":\"%s\",\"forced_kill\":%s,\"reason\":\"%s\",\"inputs\":{%s}}' " + - "\"$execution_field\" \"${INVOCATION_ID:-}\" " + q(job) + " \"$trigger\" \"$operation\" \"$release\" \"$started_at\" \"$finished_at\" \"$duration\" \"$attempt\" \"$status\" \"$outcome\" \"$forced_kill\" \"$skipped\" \"$inputs\")", + "record=$(printf '{\"run\":\"%s\",\"job\":\"%s\",\"trigger\":\"%s\",\"operation\":\"%s\",\"release\":\"%s\",\"started_at\":\"%s\",\"finished_at\":\"%s\",\"duration_s\":%s,\"attempts\":%s,\"exit_status\":%s,\"outcome\":\"%s\",\"forced_kill\":%s,\"reason\":\"%s\",\"inputs\":{%s}}' " + + "\"${INVOCATION_ID:-}\" " + q(job) + " \"$trigger\" \"$operation\" \"$release\" \"$started_at\" \"$finished_at\" \"$duration\" \"$attempt\" \"$status\" \"$outcome\" \"$forced_kill\" \"$skipped\" \"$inputs\")", "printf 'MESSAGE=%s\\nPRIORITY=6\\nSYSLOG_IDENTIFIER=" + scheduleRunIdentifier + "\\nONEBOX_APP=%s\\nONEBOX_UNIT=%s\\nONEBOX_JOB=%s\\n' " + "\"$record\" " + q(application) + " " + q(unit) + " " + q(job) + " | logger --journald || true", } @@ -740,9 +692,6 @@ const scheduleNotificationRun = "__ONEBOX_SCHEDULE_RUN__" // 2am. Only the timestamp and the run id are filled in on the host. func (e *Engine) scheduleNotifier(job app.ScheduledJob) (string, error) { cleanup := scheduleContainerRemove(e.names().Container(job.Name, 1)) - if job.Execution != nil { - cleanup = durableContainerStop(e.names().Container(job.Name, 1), job.ShutdownGrace) - } environment := e.Opts.Environment lines := []string{ "#!/bin/sh", diff --git a/internal/engine/schedule_execution.go b/internal/engine/schedule_execution.go deleted file mode 100644 index 95c13d71..00000000 --- a/internal/engine/schedule_execution.go +++ /dev/null @@ -1,245 +0,0 @@ -package engine - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "github.com/labstack/onebox/internal/app" - "github.com/labstack/onebox/internal/durable" - "github.com/labstack/onebox/internal/journal" -) - -type executionStep struct { - Name string `json:"name"` - Command []string `json:"command"` - Inputs map[string]string `json:"inputs"` - Outputs []string `json:"outputs"` - Attempts int `json:"attempts"` - Backoff int `json:"backoff"` - MaxBackoff int `json:"max_backoff"` -} - -// Invalidate before an effect, not after its result: a crash or failed migration -// can still have changed the schema. Hosts without durable records need no helper. -func invalidateExecutionCommand(root string) string { - return "if [ -e " + q(durable.Store(root)) + " ] || [ -L " + q(durable.Store(root)) + " ]; then /usr/bin/python3 " + q(durable.Helper(root)) + " invalidate " + q(root) + "; fi" -} - -func durableContainerStop(container string, grace time.Duration) string { - return "if [ \"$(/usr/bin/docker inspect --format '{{ index .Config.Labels \"onebox.execution.invocation\" }}' " + q(container) + " 2>/dev/null)\" = \"${INVOCATION_ID:-missing}\" ]; then " + scheduleContainerStop(container, grace) + "; fi" -} - -type executionDefinition struct { - DeployLock string `json:"deploy_lock"` - Notify []string `json:"notify"` - Application string `json:"application"` - Job string `json:"job"` - Unit string `json:"unit"` - Container string `json:"container"` - Defaults map[string]string `json:"defaults"` - Steps []executionStep `json:"steps"` - Services []string `json:"services"` - EnvFiles []string `json:"env_files"` - FingerprintFiles []string `json:"fingerprint_files"` - TimeoutSeconds float64 `json:"timeout_seconds"` - RetainSeconds float64 `json:"retain_seconds"` -} - -func (e *Engine) durableScheduleRunner(job app.ScheduledJob, envFiles []app.EnvFile) (string, error) { - n := e.names() - timeout, ok := app.ParseDuration(job.Timeout) - if !ok { - return "", fmt.Errorf("invalid durable job timeout") - } - retention := 7 * 24 * time.Hour - if job.Execution.Retention != "" { - retention, _ = app.ParseDuration(job.Execution.Retention) - } - definition := executionDefinition{ - DeployLock: job.DeployLock, Notify: job.Notify, - Application: e.Spec.Name, Job: job.Name, Unit: n.ScheduledJobUnit(job.Name), Container: n.Container(job.Name, 1), - Defaults: map[string]string{}, Steps: []executionStep{}, Services: []string{}, EnvFiles: []string{}, - TimeoutSeconds: timeout.Seconds(), RetainSeconds: retention.Seconds(), - } - for key, input := range job.Inputs { - definition.Defaults[key] = input.Default - } - for _, file := range envFiles { - if !file.Encrypted() { - definition.EnvFiles = append(definition.EnvFiles, file.StagedPath()) - } - } - for _, file := range e.Spec.Workloads[job.Name].EnvFiles { - if !file.Encrypted() { - definition.FingerprintFiles = append(definition.FingerprintFiles, file.StagedPath()) - } - } - for _, name := range sortedNames(e.Spec.Services) { - definition.Services = append(definition.Services, n.ServiceContainer(name)) - } - steps := job.Execution.Steps - if len(steps) == 0 { - steps = []app.JobStep{{ID: "main"}} - } - for _, step := range steps { - resolved := executionStep{Name: step.ID, Command: step.Command, Inputs: step.Inputs, Outputs: step.Outputs, - Attempts: job.RetryAttempts, Backoff: app.RetryBackoffSeconds(job.RetryBackoff), MaxBackoff: app.RetryBackoffSeconds(job.RetryMaxBackoff)} - if resolved.Command == nil { - resolved.Command = []string{} - } - if resolved.Inputs == nil { - resolved.Inputs = map[string]string{} - } - if resolved.Outputs == nil { - resolved.Outputs = []string{} - } - if step.Retry != nil { - // Resolve through the same defaults as ordinary schedule retries. - copySpec := *e.Spec - copyWorkload := e.Spec.Workloads[job.Name] - copySchedule := *copyWorkload.Schedule - copySchedule.Retry = step.Retry - copyWorkload.Schedule = ©Schedule - copySpec.Workloads = map[string]app.Workload{job.Name: copyWorkload} - jobs, err := copySpec.ScheduledJobs() - if err != nil { - return "", err - } - resolved.Attempts, resolved.Backoff, resolved.MaxBackoff = jobs[0].RetryAttempts, app.RetryBackoffSeconds(jobs[0].RetryBackoff), app.RetryBackoffSeconds(jobs[0].RetryMaxBackoff) - } - definition.Steps = append(definition.Steps, resolved) - } - encoded, err := json.Marshal(definition) - if err != nil { - return "", err - } - helper := "/usr/bin/python3 " + q(durable.Helper(n.AppDir())) - lines := []string{"#!/bin/sh", "# Written by Onebox. Durable execution protocol v1.", "set -eu", "install -d -m 700 " + q(n.AppDir()+"/schedule")} - lines = append(lines, scheduleInputsLines(n.ScheduledJobRunInputs(job.Name))...) - lines = append(lines, scheduleLockLines(n, job.Name, job.DeployLock, e.lockPath(), e.lockTTL(), scheduleRendezvousWait(job.Timeout))...) - lines = append(lines, - "release_dir=$(readlink -f "+q(n.CurrentLink())+")", - "release=${release_dir##*/}", - "[ \"${release_dir%/*}\" = "+q(n.ReleasesDir())+" ] || exit 1", - "exec 7>>\"$release_dir/.onebox-schedule.lease\"", "chmod 600 \"$release_dir/.onebox-schedule.lease\"", "/usr/bin/flock --shared 7") - lines = append(lines, scheduleRunPreamble(true)...) - lines = append(lines, schedulePlannedBindingLines()...) - lines = append(lines, "phase=running", "write_state 1", - "execution=$("+helper+" prepare "+q(n.AppDir())+" "+q(base64.StdEncoding.EncodeToString(encoded))+" \"$release\" \"${INVOCATION_ID:-}\" \"$execution\" \"$operation\" \"{$inputs_json}\")") - lines = append(lines, "printf 'execution=%s\\n' \"$execution\" >>\"$state\"") - // Publish the durable reference while still inside the retention rendezvous. - if job.DeployLock == "pinned" { - lines = append(lines, "/usr/bin/flock --unlock 8") - } - lines = append(lines, helper+" run "+q(n.AppDir())+" \"$execution\" \"${INVOCATION_ID:-}\" "+strconv.FormatFloat(job.ShutdownGrace.Seconds(), 'f', -1, 64), "") - return strings.Join(lines, "\n"), nil -} - -// ExecutionRecord is public non-secret evidence. The saved definition and step -// attempts remain available in JSON without being coupled to the current spec. -type ExecutionRecord map[string]any - -func (e *Engine) ExecutionInspect(ctx context.Context, id string) (ExecutionRecord, error) { - if !scheduleRunID.MatchString(id) { - return nil, fmt.Errorf("invalid execution ID %q", id) - } - var record ExecutionRecord - err := e.readExecutions(ctx, "inspect "+q(e.names().AppDir())+" "+q(id), &record) - return record, err -} - -func (e *Engine) ExecutionList(ctx context.Context, count int) ([]ExecutionRecord, error) { - if count < 1 || count > 1000 { - return nil, fmt.Errorf("execution count must be between 1 and 1000") - } - var records []ExecutionRecord - err := e.readExecutions(ctx, fmt.Sprintf("list %s %d", q(e.names().AppDir()), count), &records) - return records, err -} - -func (e *Engine) readExecutions(ctx context.Context, args string, result any) error { - if err := e.RequireHostOwner(ctx); err != nil { - return err - } - helper := durable.Helper(e.names().AppDir()) - command := "/usr/bin/python3 " + q(helper) + " " + args - if strings.HasPrefix(args, "list ") { - store := q(durable.Store(e.names().AppDir())) - command = "if [ ! -e " + store + " ] && [ ! -L " + store + " ]; then printf '[]\\n'; else " + command + "; fi" - } - res, err := e.T.Run(ctx, command) - if err != nil { - return err - } - if res.ExitCode != 0 { - return fmt.Errorf("read durable executions: %s", strings.TrimSpace(res.Stderr)) - } - if err := json.Unmarshal([]byte(res.Stdout), result); err != nil { - return fmt.Errorf("invalid execution evidence: %w", err) - } - return nil -} - -func (e *Engine) ExecutionResume(ctx context.Context, operation, id string, wait bool) (ScheduleRunResult, error) { - record, err := e.ExecutionInspect(ctx, id) - if err != nil { - return ScheduleRunResult{}, err - } - job, ok := record["job"].(string) - if !ok { - return ScheduleRunResult{}, fmt.Errorf("execution job evidence is missing") - } - if resumable, _ := record["resumable"].(bool); !resumable { - return ScheduleRunResult{}, fmt.Errorf("execution is active, terminal, or expired") - } - if same, _ := record["current_release_matches"].(bool); !same { - return ScheduleRunResult{}, fmt.Errorf("resume requires the original current release") - } - // Host runner rechecks the saved definition and compatibility under locks. - return e.scheduleRun(ctx, operation, job, nil, wait, id, nil) -} - -func (e *Engine) ExecutionAbandon(ctx context.Context, operation, id string) (err error) { - if !scheduleRunID.MatchString(id) { - return fmt.Errorf("invalid execution ID %q", id) - } - if err := e.RequireHostOwner(ctx); err != nil { - return err - } - epoch, err := e.AcquireLock(ctx, operation, e.Opts.ForceLock) - if err != nil { - return err - } - defer e.ReleaseLock(ctx) - if err := e.WriteFence(ctx, operation, epoch); err != nil { - return err - } - writer := &journal.Writer{T: e.T, Dir: journal.Dir(e.names()), DeployID: operation, Epoch: epoch, - Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} - record := journal.Record{Phase: "execution-abandon", Event: "start", Status: "ok", Target: id, TargetKind: "job"} - if err := writer.Append(ctx, record); err != nil { - return err - } - defer func() { - record.Event = "finish" - if err != nil { - record.Status = "fail" - } - if appendErr := writer.Append(ctx, record); appendErr != nil && err == nil { - err = appendErr - } - }() - res, err := e.mutate(ctx, "/usr/bin/python3 "+q(durable.Helper(e.names().AppDir()))+" abandon "+q(e.names().AppDir())+" "+q(id)) - if err != nil { - return err - } - if res.ExitCode != 0 { - return fmt.Errorf("abandon execution: %s", strings.TrimSpace(res.Stderr)) - } - return nil -} diff --git a/internal/engine/schedule_execution_test.go b/internal/engine/schedule_execution_test.go deleted file mode 100644 index 965ebd3f..00000000 --- a/internal/engine/schedule_execution_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package engine - -import ( - "bytes" - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/labstack/onebox/internal/app" - "github.com/labstack/onebox/internal/transport" -) - -func TestRunJournalIncludesExecutionOnlyWhenSet(t *testing.T) { - for _, id := range []string{"", strings.Repeat("a", 32)} { - t.Run("execution="+id, func(t *testing.T) { - record, _, _ := runNotifier(t, app.ScheduledJob{Name: "nightly"}, nil, - "attempt=1\nexecution="+id+"\n", nil) - value, present := record["execution"] - if id == "" && present { - t.Fatalf("ordinary run contains execution field: %v", record) - } - if id != "" && value != id { - t.Fatalf("durable run execution = %v, want %s", value, id) - } - }) - } -} - -func TestDurableRunnerPublishesCheckpointBeforeReleasingRetentionRendezvous(t *testing.T) { - e := New(testConfig(), testProject(t), &transport.Fake{}, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - runner, err := e.durableScheduleRunner(app.ScheduledJob{Name: "refresh", Timeout: "1h", ShutdownGrace: 30 * time.Second, DeployLock: "pinned", Execution: &app.JobExecution{}}, nil) - if err != nil { - t.Fatal(err) - } - lease := strings.Index(runner, "/usr/bin/flock --shared 7") - binding := strings.Index(runner, sealedManualJobBindingMarker) - prepare := strings.Index(runner, " prepare ") - unlock := strings.Index(runner, "/usr/bin/flock --unlock 8") - run := strings.LastIndex(runner, " run ") - if lease < 0 || binding <= lease || prepare <= binding || unlock <= prepare || run <= unlock { - t.Fatalf("expected live lease, sealed binding, checkpoint, mutex unlock, then execution:\n%s", runner) - } -} - -func TestDurableCleanupCannotRemoveAnotherInvocationsContainer(t *testing.T) { - for _, tc := range []struct { - name, label, invocation string - remove bool - }{ - {"owned", "current", "current", true}, - {"other activation", "previous", "current", false}, - {"unlabelled", "", "current", false}, - {"missing invocation", "current", "", false}, - } { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - removed := filepath.Join(dir, "removed") - stub := filepath.Join(dir, "docker") - body := "#!/bin/sh\ncase \"$1\" in\ninspect) printf '%s\\n' \"$TEST_LABEL\";;\nrm) touch " + q(removed) + ";;\n*) exit 2;;\nesac\n" - if err := os.WriteFile(stub, []byte(body), 0o700); err != nil { - t.Fatal(err) - } - command := exec.CommandContext(t.Context(), "sh", "-c", strings.ReplaceAll(durableContainerStop("job", time.Second), "/usr/bin/docker", q(stub))) - command.Env = append(os.Environ(), "INVOCATION_ID="+tc.invocation, "TEST_LABEL="+tc.label) - if out, err := command.CombinedOutput(); err != nil { - t.Fatalf("cleanup: %v: %s", err, out) - } - _, err := os.Stat(removed) - if (err == nil) != tc.remove { - t.Fatalf("removed=%t, want %t", err == nil, tc.remove) - } - }) - } -} - -func TestDurableNotifierGracefullyStopsOnlyItsInvocation(t *testing.T) { - e := New(testConfig(), testProject(t), &transport.Fake{}, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - script, err := e.scheduleNotifier(app.ScheduledJob{ - Name: "refresh", ShutdownGrace: 12 * time.Second, Execution: &app.JobExecution{}, - }) - if err != nil { - t.Fatal(err) - } - for _, want := range []string{ - `onebox.execution.invocation`, `${INVOCATION_ID:-missing}`, - `docker kill --signal TERM 'sample-refresh-1'`, - `deadline=$(($(date -u '+%s')+12))`, - `docker kill --signal KILL 'sample-refresh-1'`, - } { - if !strings.Contains(script, want) { - t.Fatalf("durable notifier is missing %q:\n%s", want, script) - } - } - state := strings.Index(script, "state='/var/lib/onebox/app/schedule/refresh.state'") - cleanup := strings.Index(script, "onebox.execution.invocation") - if state < 0 || cleanup < 0 || state >= cleanup { - t.Fatalf("durable notifier must initialize state before fallback cleanup:\n%s", script) - } -} - -func TestDurablePythonRequirementDoesNotAffectOrdinarySchedules(t *testing.T) { - for _, durable := range []bool{false, true} { - f := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - switch { - case strings.Contains(command, "command -v flock"): - return transport.Result{Stdout: "ok\n"}, true - case strings.Contains(command, "/usr/bin/python3"): - return transport.Result{ExitCode: 127}, true - default: - return transport.Result{}, false - } - }} - e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}}) - job := app.ScheduledJob{Name: "refresh"} - if durable { - job.Execution = &app.JobExecution{} - } - err := e.requireScheduleHost(context.Background(), []app.ScheduledJob{job}) - if durable && (err == nil || !strings.Contains(err.Error(), "Python 3.8")) { - t.Fatalf("missing Python accepted: %v", err) - } - if !durable && err != nil { - t.Fatalf("ordinary schedule refused: %v", err) - } - if !durable && strings.Contains(strings.Join(f.Commands, "\n"), "python3") { - t.Fatal("ordinary schedule probes Python") - } - } -} - -func TestScheduleHostRejectsIncompatibleFlockBeforeInstall(t *testing.T) { - f := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if strings.Contains(command, "command -v flock") { - for _, option := range []string{"--conflict-exit-code", "--exclusive", "--nonblock", "--shared", "--timeout", "--unlock"} { - if !strings.Contains(command, option) { - t.Fatalf("flock compatibility probe does not require %s: %s", option, command) - } - } - return transport.Result{ExitCode: 1}, true - } - return transport.Result{}, false - }} - e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}}) - err := e.requireScheduleHost(context.Background(), []app.ScheduledJob{{Name: "refresh"}}) - if err == nil || !strings.Contains(err.Error(), "compatible util-linux flock") { - t.Fatalf("incompatible flock accepted: %v", err) - } -} - -func TestScheduleFlockProbeExecutesCapabilityChecks(t *testing.T) { - root := t.TempDir() - flock := filepath.Join(root, "flock") - writeHelp := func(options string) { - t.Helper() - if err := os.WriteFile(flock, []byte("#!/bin/sh\nprintf '%s\\n' "+q(options)+"\n"), 0o700); err != nil { - t.Fatal(err) - } - } - run := func() ([]byte, error) { - command := exec.CommandContext(t.Context(), "sh", "-c", scheduleFlockProbe(flock)) - command.Env = append(os.Environ(), "PATH="+root+":/usr/bin:/bin") - return command.CombinedOutput() - } - - writeHelp("--conflict-exit-code --exclusive --nonblock --shared --timeout --unlock") - if output, err := run(); err != nil || string(output) != "ok\n" { - t.Fatalf("compatible flock rejected: err=%v output=%q", err, output) - } - writeHelp("--conflict-exit-code --exclusive --nonblock --shared --timeout") - if output, err := run(); err == nil || strings.Contains(string(output), "ok") { - t.Fatalf("flock missing --unlock accepted: err=%v output=%q", err, output) - } -} - -func TestDurableResumeRefusesLegacyRunnerBeforePublishingRequest(t *testing.T) { - cfg := testConfig() - cfg.Workloads["refresh"] = app.Workload{Role: app.RoleJob, DeploymentPhase: "none", DataEffect: app.DataEffectNone, - Schedule: &app.JobSchedule{Cron: "0 * * * *", Timezone: "UTC", Timeout: "1h"}, Execution: &app.JobExecution{}} - f := happyFake() - base := f.Dynamic - f.Dynamic = func(command string) (transport.Result, bool) { - switch { - case strings.Contains(command, "command -v flock"): - return transport.Result{Stdout: "ok\n"}, true - case strings.Contains(command, "systemctl is-active"): - return transport.Result{Stdout: "inactive\n"}, true - case strings.Contains(command, "Durable execution protocol v1."): - return transport.Result{ExitCode: 1}, true - } - return base(command) - } - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - _, err := e.scheduleRun(context.Background(), "resume-op", "refresh", nil, false, strings.Repeat("a", 32), nil) - if err == nil || !strings.Contains(err.Error(), "ob schedule apply") { - t.Fatalf("legacy runner accepted: %v", err) - } - for _, command := range f.Commands { - if strings.Contains(command, "refresh.inputs") || strings.Contains(command, "systemctl start") { - t.Fatalf("resume published before runner qualification: %s", command) - } - } -} - -func TestDurableCompatibilityInvalidationMustSucceedBeforeDataChangingJob(t *testing.T) { - for _, effect := range []app.DataEffect{app.DataEffectMigration, app.DataEffectDestructive, app.DataEffectUnknown} { - t.Run(string(effect), func(t *testing.T) { - cfg := testConfig() - cfg.Workloads["change"] = app.Workload{Role: app.RoleJob, DataEffect: effect} - f := happyFake() - base := f.Dynamic - invalidated := false - f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, " invalidate ") { - invalidated = true - return transport.Result{ExitCode: 1, Stderr: "checkpoint store is read-only"}, true - } - return base(command) - } - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - _, _, err := e.runOneJob(context.Background(), "op-1", 1, "change", "/release", "/release/compose.yaml") - if !invalidated || err == nil || !strings.Contains(err.Error(), "invalidate durable execution compatibility") { - t.Fatalf("failed invalidation did not stop data-changing job: invalidated=%t err=%v", invalidated, err) - } - if strings.Contains(strings.Join(f.Commands, "\n"), "ONEBOX_RESULT_FILE") { - t.Fatal("job started before compatibility invalidation succeeded") - } - }) - } -} diff --git a/internal/engine/schedule_history.go b/internal/engine/schedule_history.go index d9906137..0af56d5f 100644 --- a/internal/engine/schedule_history.go +++ b/internal/engine/schedule_history.go @@ -17,7 +17,6 @@ import ( // nothing that can disagree with the unit's own log. type ScheduleRunRecord struct { Run string `json:"run"` - Execution string `json:"execution,omitempty"` Job string `json:"job"` Trigger string `json:"trigger"` Operation string `json:"operation,omitempty"` diff --git a/internal/engine/schedule_host_test.go b/internal/engine/schedule_host_test.go new file mode 100644 index 00000000..280b247c --- /dev/null +++ b/internal/engine/schedule_host_test.go @@ -0,0 +1,74 @@ +package engine + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/labstack/onebox/internal/app" + "github.com/labstack/onebox/internal/transport" +) + +func TestScheduledJobsNeedNoPythonRunner(t *testing.T) { + f := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { + if strings.Contains(command, "command -v flock") { + return transport.Result{Stdout: "ok\n"}, true + } + if strings.Contains(command, "python") { + t.Fatalf("scheduled job probes Python: %s", command) + } + return transport.Result{}, false + }} + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}}) + if err := e.requireScheduleHost(t.Context(), []app.ScheduledJob{{Name: "refresh"}}); err != nil { + t.Fatal(err) + } +} + +func TestScheduleHostRejectsIncompatibleFlockBeforeInstall(t *testing.T) { + f := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { + if strings.Contains(command, "command -v flock") { + for _, option := range []string{"--conflict-exit-code", "--exclusive", "--nonblock", "--shared", "--timeout", "--unlock"} { + if !strings.Contains(command, option) { + t.Fatalf("flock compatibility probe does not require %s: %s", option, command) + } + } + return transport.Result{ExitCode: 1}, true + } + return transport.Result{}, false + }} + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}}) + err := e.requireScheduleHost(context.Background(), []app.ScheduledJob{{Name: "refresh"}}) + if err == nil || !strings.Contains(err.Error(), "compatible util-linux flock") { + t.Fatalf("incompatible flock accepted: %v", err) + } +} + +func TestScheduleFlockProbeExecutesCapabilityChecks(t *testing.T) { + root := t.TempDir() + flock := filepath.Join(root, "flock") + writeHelp := func(options string) { + t.Helper() + if err := os.WriteFile(flock, []byte("#!/bin/sh\nprintf '%s\\n' "+q(options)+"\n"), 0o700); err != nil { + t.Fatal(err) + } + } + run := func() ([]byte, error) { + command := exec.CommandContext(t.Context(), "sh", "-c", scheduleFlockProbe(flock)) + command.Env = append(os.Environ(), "PATH="+root+":/usr/bin:/bin") + return command.CombinedOutput() + } + + writeHelp("--conflict-exit-code --exclusive --nonblock --shared --timeout --unlock") + if output, err := run(); err != nil || string(output) != "ok\n" { + t.Fatalf("compatible flock rejected: err=%v output=%q", err, output) + } + writeHelp("--conflict-exit-code --exclusive --nonblock --shared --timeout") + if output, err := run(); err == nil || strings.Contains(string(output), "ok") { + t.Fatalf("flock missing --unlock accepted: err=%v output=%q", err, output) + } +} diff --git a/internal/engine/schedule_run.go b/internal/engine/schedule_run.go index 406bf78e..87da4de2 100644 --- a/internal/engine/schedule_run.go +++ b/internal/engine/schedule_run.go @@ -20,7 +20,6 @@ type ScheduleRunResult struct { Unit string `json:"unit"` Operation string `json:"operation"` Inputs map[string]string `json:"inputs,omitempty"` - Execution string `json:"execution,omitempty"` Started bool `json:"started"` Record *ScheduleRunRecord `json:"record,omitempty"` } @@ -35,7 +34,7 @@ type ScheduleRunResult struct { // the inputs is the case the sealed plan of `ob job run` exists for, and a // migration or destructive job keeps that path. func (e *Engine) ScheduleRun(ctx context.Context, operationID, name string, inputs map[string]string, wait bool) (_ ScheduleRunResult, err error) { - return e.scheduleRun(ctx, operationID, name, inputs, wait, "", nil) + return e.scheduleRun(ctx, operationID, name, inputs, wait, nil) } // PlannedJobRun submits a sealed operator job plan to the job's installed @@ -46,7 +45,7 @@ func (e *Engine) PlannedJobRun(ctx context.Context, operationID, name string, in if expectedRelease == "" || expectedRuntime == "" { return ScheduleRunResult{}, errors.New("planned job run requires an expected release and runtime digest") } - return e.scheduleRun(ctx, operationID, name, inputs, wait, "", &plannedJobBinding{ + return e.scheduleRun(ctx, operationID, name, inputs, wait, &plannedJobBinding{ release: expectedRelease, runtime: expectedRuntime, }) @@ -57,9 +56,8 @@ type plannedJobBinding struct { runtime string } -func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inputs map[string]string, wait bool, execution string, planned *plannedJobBinding) (_ ScheduleRunResult, err error) { +func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inputs map[string]string, wait bool, planned *plannedJobBinding) (_ ScheduleRunResult, err error) { result := ScheduleRunResult{Job: name, Operation: operationID, Inputs: inputs} - result.Execution = execution if strings.TrimSpace(operationID) == "" { return result, errors.New("schedule run requires an operation id") } @@ -70,16 +68,10 @@ func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inpu return result, err } workload := e.Spec.Workloads[name] - if execution != "" && (workload.Execution == nil || !scheduleRunID.MatchString(execution) || len(inputs) != 0) { - return result, errors.New("resume requires a durable job, a valid execution ID, and no input overrides") - } if workload.DataEffect != app.DataEffectNone && planned == nil { return result, fmt.Errorf("job %s declares data_effect %q; operator-initiated runs of it go through the sealed plan: ob job plan %s, then ob job run", name, workload.DataEffect, name) } - if planned != nil && execution != "" { - return result, errors.New("a planned job run cannot resume a durable execution") - } if err := app.ValidateJobInputValues(workload, inputs); err != nil { return result, err } @@ -126,15 +118,6 @@ func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inpu if err := e.WriteFence(ctx, operationID, epoch); err != nil { return result, err } - if execution != "" { - res, err := e.T.Run(ctx, "grep -Fq 'Durable execution protocol v1.' "+q("/etc/systemd/system/"+unit+".run")) - if err != nil { - return result, err - } - if res.ExitCode != 0 { - return result, errors.New("installed job runner does not support durable resume; run ob schedule apply") - } - } // noclobber: a second operator run before the first is consumed would // otherwise rewrite the file under it and misattribute the inputs. The @@ -145,9 +128,6 @@ func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inpu create := "if [ -e " + q(path) + " ]; then exit 73; fi; " + "umask 077 && install -d -m 700 " + q(e.names().AppDir()+"/schedule") + " && set -C && cat > " + q(path) payload := scheduleInputsFile(operationID, inputs, planned) - if execution != "" { - payload += "ONEBOX_EXECUTION=" + execution + "\n" - } res, err := e.mutateInput(ctx, create, payload) if err != nil { return result, err @@ -174,9 +154,6 @@ func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inpu ApprovedBy: e.Opts.ApprovedBy, ApprovalSource: e.Opts.ApprovalSource, } detail := "inputs: defaults" - if execution != "" { - detail = "resume execution: " + execution - } if len(inputs) > 0 { detail = "inputs: " + scheduleInputsDetail(inputs) } @@ -259,7 +236,6 @@ func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inpu pending = false result.Started = true result.Record = last - result.Execution = last.Execution exit := "-" if last.ExitStatus != nil { exit = fmt.Sprint(*last.ExitStatus) diff --git a/internal/engine/schedule_test.go b/internal/engine/schedule_test.go index 98c43be0..57914d23 100644 --- a/internal/engine/schedule_test.go +++ b/internal/engine/schedule_test.go @@ -200,7 +200,7 @@ func TestScheduledJobUnitContract(t *testing.T) { "--project-directory", "/var/lib/onebox/app/current", "compose.yaml", - `run --rm --no-deps "$@" --label 'onebox.execution.job=nightly' --name 'sample-nightly-1'`, + `run --rm --no-deps "$@" --name 'sample-nightly-1'`, "docker rm -f 'sample-nightly-1'", "docker kill --signal TERM 'sample-nightly-1'", "docker kill --signal KILL 'sample-nightly-1'", @@ -313,7 +313,7 @@ func TestPinnedScheduledJobRunnerLeasesImmutableRelease(t *testing.T) { "--project-directory \"$release_dir\"", "-f \"$release_dir\"/'compose.yaml'", "--env-file \"$release_dir\"/'config/runtime.env'", - `run --rm --no-deps "$@" --label 'onebox.execution.job=refresh' --name 'sample-refresh-1' 'refresh'`, + `run --rm --no-deps "$@" --name 'sample-refresh-1' 'refresh'`, "docker rm -f 'sample-refresh-1'", } { if !strings.Contains(runner, want) { @@ -1398,7 +1398,7 @@ func TestScheduledJobRunnerConsumesManualInputsWithoutShellInterpolation(t *test `ONEBOX_OPERATION=*) operation=${line#ONEBOX_OPERATION=} ;;`, `[A-Z]*=*) set -- "$@" -e "$line"`, `rm -f "$inputs_file"`, - `run --rm --no-deps "$@" --label 'onebox.execution.job=sync' --name 'sample-sync-1' 'sync'`, + `run --rm --no-deps "$@" --name 'sample-sync-1' 'sync'`, } { if !strings.Contains(runner, want) { t.Errorf("runner is missing %q:\n%s", want, runner) @@ -1418,7 +1418,7 @@ func TestScheduledJobRunnerConsumesManualInputsWithoutShellInterpolation(t *test t.Fatalf("inputs are consumed after the lock:\n%s", runner) } exclusive := scheduleRunnerScript("sample", app.ScheduledJob{Name: "sync", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1}, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) - if !strings.Contains(exclusive, "inputs_file=") || !strings.Contains(exclusive, `run --rm --no-deps "$@" --label`) { + if !strings.Contains(exclusive, "inputs_file=") || !strings.Contains(exclusive, `run --rm --no-deps "$@" --name`) { t.Fatalf("exclusive runner does not consume inputs:\n%s", exclusive) } } diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go index aff9d90f..0a6f1877 100644 --- a/internal/engine/secret_generation_rolling_test.go +++ b/internal/engine/secret_generation_rolling_test.go @@ -14,7 +14,7 @@ import ( // web declares a health check, so it defaults to rolling; worker stays a // recreate workload, which is what keeps the two paths visible in one push. -const rollingGenerationProject = `apiVersion: onebox.run/v1alpha1 +const rollingGenerationProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/engine/secret_generation_test.go b/internal/engine/secret_generation_test.go index 62d50355..ac2615c6 100644 --- a/internal/engine/secret_generation_test.go +++ b/internal/engine/secret_generation_test.go @@ -13,7 +13,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const generationProject = `apiVersion: onebox.run/v1alpha1 +const generationProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/engine/secretspush_test.go b/internal/engine/secretspush_test.go index 3401f51b..c62a0c4c 100644 --- a/internal/engine/secretspush_test.go +++ b/internal/engine/secretspush_test.go @@ -11,7 +11,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const secretGraphProject = `apiVersion: onebox.run/v1alpha1 +const secretGraphProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/engine/verify_injection_test.go b/internal/engine/verify_injection_test.go index 103b204e..e3499726 100644 --- a/internal/engine/verify_injection_test.go +++ b/internal/engine/verify_injection_test.go @@ -64,7 +64,7 @@ func TestVerifyHTTPProbeStillCarriesThePortAndPath(t *testing.T) { func verificationProject(t *testing.T, path string) *app.Resolved { t.Helper() spec, err := app.LoadBytes([]byte(` -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: sample} spec: diff --git a/internal/onebox/binding.go b/internal/onebox/binding.go index f107bb85..1b50b9fd 100644 --- a/internal/onebox/binding.go +++ b/internal/onebox/binding.go @@ -32,7 +32,7 @@ func operationUsesInspectionRuntime(kind OperationKind) bool { switch kind { case KindResume, KindAbort, KindRollback, KindBootstrap, KindServiceApply, KindProxyApply, KindScheduleApply, KindScheduleRun, - KindSchedulePause, KindScheduleResume, KindExecutionResume, KindExecutionAbandon, KindSecretsPush, KindDestroy, + KindSchedulePause, KindScheduleResume, KindSecretsPush, KindDestroy, // Backup operates on a service's data, never on the application's // release images, so a placeholder image must not stop a backup. KindBackupEnable, KindBackupDisable, KindBackupCreate, KindBackupPrune, KindAssuranceCheck, diff --git a/internal/onebox/bootstrap_test.go b/internal/onebox/bootstrap_test.go index 91df5f0e..0b0a533b 100644 --- a/internal/onebox/bootstrap_test.go +++ b/internal/onebox/bootstrap_test.go @@ -10,7 +10,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const bootstrapBuildProject = `apiVersion: onebox.run/v1alpha1 +const bootstrapBuildProject = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo diff --git a/internal/onebox/exec_test.go b/internal/onebox/exec_test.go index 37551313..c64b568f 100644 --- a/internal/onebox/exec_test.go +++ b/internal/onebox/exec_test.go @@ -14,7 +14,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const execProjectYAML = `apiVersion: onebox.run/v1alpha1 +const execProjectYAML = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/onebox/execute.go b/internal/onebox/execute.go index d0e54b6e..6ca54b61 100644 --- a/internal/onebox/execute.go +++ b/internal/onebox/execute.go @@ -220,14 +220,6 @@ func (s *Service) Execute(ctx context.Context, request ExecuteRequest) (Operatio var run engine.ScheduleRunResult run, err = e.ScheduleRun(ctx, operationID, request.Job, request.Inputs, request.Wait) result.ScheduleRun = &run - case KindExecutionResume: - result.EvidenceID = request.ExecutionID - var run engine.ScheduleRunResult - run, err = e.ExecutionResume(ctx, operationID, request.ExecutionID, request.Wait) - result.ScheduleRun = &run - case KindExecutionAbandon: - result.EvidenceID = request.ExecutionID - err = e.ExecutionAbandon(ctx, operationID, request.ExecutionID) case KindSecretsPush: entries := encryptedEntries(lp.resolved) externalProjections := externalConnectionProjections(lp.resolved) diff --git a/internal/onebox/execution_types.go b/internal/onebox/execution_types.go index b490bbfb..8dbac5b3 100644 --- a/internal/onebox/execution_types.go +++ b/internal/onebox/execution_types.go @@ -301,10 +301,9 @@ type ExecuteRequest struct { // Job, Inputs and Wait are the schedule_run arguments: a declared // scheduled job, validated input overrides, and whether to block until // the unit exits and return its run record. - Job string - Inputs map[string]string - Wait bool - ExecutionID string + Job string + Inputs map[string]string + Wait bool // Reason is the operator's account of a pause, kept on the host so a job // that is deliberately not running says why. Reason string @@ -368,14 +367,8 @@ func (request ExecuteRequest) Validate() error { if (request.Approval != nil || request.BackupReport != nil || request.MigrationBackupOverride != nil) && request.Kind != KindDeploy && request.Kind != KindJobRun { return errors.New("approval and migration backup authorization are valid only for deploy and job run") } - if request.ExecutionID != "" && request.Kind != KindExecutionResume && request.Kind != KindExecutionAbandon { - return errors.New("execution_id is valid only for execution resume and abandon") - } - if (request.Kind == KindExecutionResume || request.Kind == KindExecutionAbandon) && request.ExecutionID == "" { - return errors.New("execution_id is required") - } - if request.Wait && request.Kind != KindScheduleRun && request.Kind != KindExecutionResume { - return errors.New("wait is valid only for schedule run and execution resume") + if request.Wait && request.Kind != KindScheduleRun { + return errors.New("wait is valid only for schedule run") } if len(request.Inputs) > 0 { if request.Kind != KindScheduleRun { diff --git a/internal/onebox/jump_route_test.go b/internal/onebox/jump_route_test.go index 337c9ef4..44fc502f 100644 --- a/internal/onebox/jump_route_test.go +++ b/internal/onebox/jump_route_test.go @@ -15,7 +15,7 @@ func writeJumpProject(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo @@ -86,7 +86,7 @@ func TestChangingOnlyTheJumpChangesTheBinding(t *testing.T) { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo diff --git a/internal/onebox/load_service_runtime_test.go b/internal/onebox/load_service_runtime_test.go index 3673d595..8b508a17 100644 --- a/internal/onebox/load_service_runtime_test.go +++ b/internal/onebox/load_service_runtime_test.go @@ -16,7 +16,7 @@ func protectedRuntimeProject(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := `apiVersion: onebox.run/v1alpha1 + body := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: example diff --git a/internal/onebox/operation_errors.go b/internal/onebox/operation_errors.go index 7da9caa7..c3386c52 100644 --- a/internal/onebox/operation_errors.go +++ b/internal/onebox/operation_errors.go @@ -104,9 +104,6 @@ var operationFailureDefinitions = map[string]OperationFailure{ Message: "the audited exec could not be completed", Command: "ob status --output json", }, - "execution_count_invalid": {Message: "execution count must be between 1 and 1000"}, - "execution_list_failed": {Message: "durable executions could not be listed"}, - "execution_inspect_failed": {Message: "durable execution could not be read"}, "finalize_refused": { Message: "the release cannot be finalized because the recorded activation evidence disagrees with the live host", Command: "ob status --output json", diff --git a/internal/onebox/operation_graph_test.go b/internal/onebox/operation_graph_test.go index ce95afdd..f8375e50 100644 --- a/internal/onebox/operation_graph_test.go +++ b/internal/onebox/operation_graph_test.go @@ -81,7 +81,7 @@ func TestDeploymentGraphNeverContainsHookBodies(t *testing.T) { func TestDeploymentGraphOmitsAbsentHooksAndJobs(t *testing.T) { t.Parallel() - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -119,7 +119,7 @@ func TestDeploymentClassificationDoesNotOverstateFirstDeployRollback(t *testing. } func operationGraphConfig() *app.Resolved { - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/onebox/operation_types.go b/internal/onebox/operation_types.go index 7b8695e7..c32259f4 100644 --- a/internal/onebox/operation_types.go +++ b/internal/onebox/operation_types.go @@ -30,22 +30,20 @@ type OperationKind string type OperationStatus string const ( - KindDeploy OperationKind = "deploy" - KindResume OperationKind = "resume" - KindAbort OperationKind = "abort" - KindRollback OperationKind = "rollback" - KindBootstrap OperationKind = "bootstrap" - KindServiceApply OperationKind = "service_apply" - KindProxyApply OperationKind = "proxy_apply" - KindScheduleApply OperationKind = "schedule_apply" - KindSecretsPush OperationKind = "secrets_push" - KindDestroy OperationKind = "destroy" - KindJobRun OperationKind = "job_run" - KindScheduleRun OperationKind = "schedule_run" - KindSchedulePause OperationKind = "schedule_pause" - KindScheduleResume OperationKind = "schedule_resume" - KindExecutionResume OperationKind = "execution_resume" - KindExecutionAbandon OperationKind = "execution_abandon" + KindDeploy OperationKind = "deploy" + KindResume OperationKind = "resume" + KindAbort OperationKind = "abort" + KindRollback OperationKind = "rollback" + KindBootstrap OperationKind = "bootstrap" + KindServiceApply OperationKind = "service_apply" + KindProxyApply OperationKind = "proxy_apply" + KindScheduleApply OperationKind = "schedule_apply" + KindSecretsPush OperationKind = "secrets_push" + KindDestroy OperationKind = "destroy" + KindJobRun OperationKind = "job_run" + KindScheduleRun OperationKind = "schedule_run" + KindSchedulePause OperationKind = "schedule_pause" + KindScheduleResume OperationKind = "schedule_resume" KindServiceImagePatch OperationKind = "service_image_patch" KindBackupEnable OperationKind = "backup_enable" @@ -483,7 +481,7 @@ func validOperationKind(kind OperationKind) bool { switch kind { case KindDeploy, KindResume, KindAbort, KindRollback, KindBootstrap, KindJobRun, KindServiceApply, KindProxyApply, KindScheduleApply, KindScheduleRun, - KindSchedulePause, KindScheduleResume, KindExecutionResume, KindExecutionAbandon, KindSecretsPush, KindDestroy, + KindSchedulePause, KindScheduleResume, KindSecretsPush, KindDestroy, KindServiceImagePatch, KindBackupEnable, KindBackupDisable, KindBackupCreate, KindBackupPrune, KindReplayArchive, KindRestoreTest, KindRestorePrepare, KindRestoreCutover, KindRestoreAbort, diff --git a/internal/onebox/secrets_push_test.go b/internal/onebox/secrets_push_test.go index 4b03600c..1be5a892 100644 --- a/internal/onebox/secrets_push_test.go +++ b/internal/onebox/secrets_push_test.go @@ -12,7 +12,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const pushProjectYAML = `apiVersion: onebox.run/v1alpha1 +const pushProjectYAML = `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -175,7 +175,7 @@ func TestSecretsPushRotatesEveryEntry(t *testing.T) { // A project with nothing encrypted is told so, rather than reporting a push. func TestSecretsPushWithNothingEncryptedIsRefused(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`apiVersion: onebox.run/v1alpha1 + if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/onebox/service_test.go b/internal/onebox/service_test.go index d256e73a..06e52c9a 100644 --- a/internal/onebox/service_test.go +++ b/internal/onebox/service_test.go @@ -27,7 +27,7 @@ services: image: ghcr.io/example/postgres:` + testSecret + ` `, "ob.yml": ` -apiVersion: onebox.run/v1alpha1 +apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: demo} spec: @@ -166,7 +166,7 @@ func writeComposeBuildProject(t *testing.T) string { pinnedWeb := "ghcr.io/example/app@sha256:" + strings.Repeat("1", 64) files := map[string]string{ "compose.yaml": "services:\n database:\n build: .\n command: [postgres, -c, shared_buffers=256MB]\n", - "ob.yml": `apiVersion: onebox.run/v1alpha1 + "ob.yml": `apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: demo} spec: @@ -236,7 +236,7 @@ func TestPlanDeployUsesDeployedSecretGraphDuringTransition(t *testing.T) { if workerSecret { workerEnv = "\n envFiles: [{file: worker.enc.env, provider: Sops}]" } - return `apiVersion: onebox.run/v1alpha1 + return `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: demo diff --git a/internal/onebox/staging_secrets_test.go b/internal/onebox/staging_secrets_test.go index 11472a28..b946fef4 100644 --- a/internal/onebox/staging_secrets_test.go +++ b/internal/onebox/staging_secrets_test.go @@ -38,7 +38,7 @@ func twoEncryptedEntries(t *testing.T) string { write("api.enc.env", "TOKEN=api-token\n") write("worker.enc.env", "TOKEN=worker-token\n") write("shared.env", "REGION=eu\n") - write("ob.yml", `apiVersion: onebox.run/v1alpha1 + write("ob.yml", `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop @@ -255,7 +255,7 @@ func TestExternalServiceConnectionIsProjectedLeastPrivilegeIntoRelease(t *testin if err := os.WriteFile(filepath.Join(dir, "secrets", "database.env"), []byte(secret), 0o600); err != nil { t.Fatal(err) } - project := `apiVersion: onebox.run/v1alpha1 + project := `apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: shop diff --git a/internal/onebox/workload_contract_test.go b/internal/onebox/workload_contract_test.go index cf2eb9a6..663799bb 100644 --- a/internal/onebox/workload_contract_test.go +++ b/internal/onebox/workload_contract_test.go @@ -15,7 +15,7 @@ func TestWorkloadContractsScopePlainEnvironmentChanges(t *testing.T) { t.Fatal(err) } } - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -86,7 +86,7 @@ func TestWorkloadContractsTrackRelativeBindMountContent(t *testing.T) { t.Fatal(err) } } - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -127,7 +127,7 @@ spec: } func TestBindMountContractIsIndependentOfWhereTheReleaseIsStaged(t *testing.T) { - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -166,7 +166,7 @@ spec: func TestWorkloadContractsIgnoreVolumesOnAnAdoptedComposeService(t *testing.T) { staging := t.TempDir() - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -189,7 +189,7 @@ spec: } func TestBindMountContractNoticesAnAddedEmptyDirectory(t *testing.T) { - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -231,7 +231,7 @@ spec: func bindMountRevision(t *testing.T, mode os.FileMode) string { t.Helper() - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/onebox/workload_plan_test.go b/internal/onebox/workload_plan_test.go index e43f17ba..7b08855f 100644 --- a/internal/onebox/workload_plan_test.go +++ b/internal/onebox/workload_plan_test.go @@ -15,7 +15,7 @@ import ( func workloadPlanFixture(t *testing.T) (*app.Resolved, string, []OperationStep, engine.HostState) { t.Helper() - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample @@ -166,7 +166,7 @@ func TestPlanDeployRetainsUnchangedWorkerWhenAnotherWorkloadChanges(t *testing.T digestB := strings.Repeat("2", 64) workerDigest := strings.Repeat("3", 64) project := func(apiDigest string) string { - return `apiVersion: onebox.run/v1alpha1 + return `apiVersion: onebox.run/v1alpha2 kind: Application metadata: {name: sample} spec: diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 5bd0f88b..a12d6179 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -164,7 +164,7 @@ func TestManagedWildcardTLSRejectsIncompleteDNSConfiguration(t *testing.T) { } func TestManagedTLSRouterReferencesDefaultStaticResolver(t *testing.T) { - spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha2 kind: Application metadata: name: sample diff --git a/internal/release/durable_retention_test.go b/internal/release/durable_retention_test.go deleted file mode 100644 index 1905a9a9..00000000 --- a/internal/release/durable_retention_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package release - -import ( - "context" - "errors" - "fmt" - "slices" - "strings" - "testing" - "time" - - "github.com/labstack/onebox/internal/app" - "github.com/labstack/onebox/internal/transport" -) - -func TestDurableRetentionReferencesProtectCollectibleRelease(t *testing.T) { - for _, pinned := range []bool{false, true} { - t.Run(map[bool]string{false: "unreferenced", true: "resumable"}[pinned], func(t *testing.T) { - names := app.Names{App: "sample", BasePath: app.DefaultBasePath} - old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - previous, current := "20260101-000000-previous", "20260102-000000-current" - target := &transport.Fake{} - writeRetentionManifest(t, target, names, retentionManifest(t, previous, KindApplication, StateSuperseded, "", old)) - writeRetentionManifest(t, target, names, retentionManifest(t, current, KindApplication, StateServing, "", old)) - base := target.Dynamic - readPins := false - target.Dynamic = func(command string) (transport.Result, bool) { - switch { - case strings.Contains(command, " pins "): - readPins = true - if pinned { - return transport.Result{Stdout: previous + "\n"}, true - } - return transport.Result{}, true - case strings.Contains(command, "ls -1A"): - return transport.Result{Stdout: previous + "\n" + current + "\n"}, true - case strings.Contains(command, "readlink"): - return transport.Result{Stdout: "releases/" + current + "\n"}, true - } - if base != nil { - return base(command) - } - return transport.Result{}, false - } - decision, err := RetentionCandidates(context.Background(), target, names, DefaultRetentionPolicy(1, old.AddDate(0, 8, 0))) - if err != nil { - t.Fatal(err) - } - if !readPins { - t.Fatal("retention did not consult durable references") - } - if slices.Contains(decision.Victims, previous) == pinned { - t.Fatalf("pinned=%t: unexpected victims %v", pinned, decision.Victims) - } - if pinned && !slices.Contains(decision.Preserve, previous) { - t.Fatalf("durable release missing from preserved set: %v", decision.Preserve) - } - }) - } -} - -func TestDurableRetentionUnusableEvidenceRefusesCleanup(t *testing.T) { - for _, tc := range []struct { - name string - result transport.Result - }{ - {"unreadable", transport.Result{ExitCode: 1, Stderr: "permission denied"}}, - {"corrupt checkpoint", transport.Result{ExitCode: 1, Stderr: "unsupported or corrupt execution record"}}, - {"missing helper", transport.Result{ExitCode: 127}}, - {"invalid reference", transport.Result{Stdout: "../../another-application\n"}}, - } { - t.Run(tc.name, func(t *testing.T) { - target := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if strings.Contains(command, "ls -1A") { - return transport.Result{Stdout: "20260101-000000-old\n"}, true - } - if strings.Contains(command, " pins ") { - return tc.result, true - } - return transport.Result{}, false - }} - decision, err := RetentionCandidates(context.Background(), target, app.Names{App: "sample", BasePath: app.DefaultBasePath}, DefaultRetentionPolicy(1, time.Now())) - var evidenceErr *RetentionEvidenceError - if !errors.As(err, &evidenceErr) { - t.Fatalf("expected retention evidence refusal, got %v", err) - } - if len(decision.Victims) != 0 { - t.Fatalf("unusable evidence returned deletion candidates: %v", decision.Victims) - } - if tc.result.ExitCode != 0 && (!strings.Contains(err.Error(), fmt.Sprintf("exit %d", tc.result.ExitCode)) || !strings.Contains(err.Error(), tc.result.Stderr)) { - t.Fatalf("retention refusal lost helper diagnostic: %v", err) - } - }) - } -} diff --git a/internal/release/retention.go b/internal/release/retention.go index d58866f2..353cdc77 100644 --- a/internal/release/retention.go +++ b/internal/release/retention.go @@ -8,7 +8,6 @@ import ( "time" "github.com/labstack/onebox/internal/app" - "github.com/labstack/onebox/internal/durable" "github.com/labstack/onebox/internal/transport" ) @@ -98,22 +97,6 @@ func RetentionCandidates(ctx context.Context, target transport.Transport, names for _, id := range leased { protected[id] = true } - // Live leases disappear on reboot; checkpoint references do not. New - // executions publish them before leaving the schedule/deploy rendezvous. - pins, err := target.Run(ctx, "if [ -e "+q(durable.Store(names.AppDir()))+" ] || [ -L "+q(durable.Store(names.AppDir()))+" ]; then /usr/bin/python3 "+q(durable.Helper(names.AppDir()))+" pins "+q(names.AppDir())+"; fi") - if err != nil { - return RetentionDecision{}, refuseRetention(err, err) - } - if pins.ExitCode != 0 { - return RetentionDecision{}, refuseRetention(nil, fmt.Errorf("durable execution retention evidence is unusable (exit %d): %s", pins.ExitCode, strings.TrimSpace(pins.Stderr))) - } - for _, id := range strings.Fields(pins.Stdout) { - if !IsID(id) { - return RetentionDecision{}, refuseRetention(nil, fmt.Errorf("invalid durable execution release reference")) - } - protected[id] = true - } - // A release a container still mounts is in use even when it is far past // the retained chain: retention bounds what is worth keeping for rollback, // which is a different question from what is load-bearing right now. diff --git a/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha2/application.schema.json similarity index 94% rename from site/public/schemas/application/v1alpha1/application.schema.json rename to site/public/schemas/application/v1alpha2/application.schema.json index 8581545d..3d1b945e 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha2/application.schema.json @@ -1,11 +1,11 @@ { - "$id": "https://onebox.run/schemas/application/v1alpha1/application.schema.json", + "$id": "https://onebox.run/schemas/application/v1alpha2/application.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "One application, its workloads, the services it needs, and how a release rolls out.", "properties": { "apiVersion": { - "const": "onebox.run/v1alpha1", + "const": "onebox.run/v1alpha2", "description": "Authored Application API identity.", "type": "string" }, @@ -1299,35 +1299,6 @@ "additionalProperties": { "additionalProperties": false, "allOf": [ - { - "if": { - "required": [ - "execution" - ] - }, - "then": { - "not": { - "required": [ - "compose" - ] - }, - "properties": { - "dataEffect": { - "const": "None" - }, - "deploymentPhase": { - "const": "None" - }, - "operatorRun": { - "const": "Allowed" - } - }, - "required": [ - "schedule", - "dataEffect" - ] - } - }, { "oneOf": [ { @@ -1474,11 +1445,6 @@ "required": [ "inputs" ] - }, - { - "required": [ - "execution" - ] } ] } @@ -1690,104 +1656,6 @@ }, "type": "array" }, - "execution": { - "additionalProperties": false, - "description": "Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume.", - "properties": { - "retention": { - "default": "168h", - "description": "Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d.", - "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", - "type": "string" - }, - "steps": { - "description": "Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps.", - "items": { - "additionalProperties": false, - "properties": { - "command": { - "description": "Argument vector passed to the job image's entrypoint. No shell evaluation is performed.", - "items": { - "type": "string" - }, - "maxItems": 128, - "minItems": 1, - "type": "array" - }, - "id": { - "description": "Unique stable step identifier, used by output references. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", - "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", - "type": "string" - }, - "inputs": { - "additionalProperties": { - "type": "string" - }, - "description": "Environment variables populated from a preceding step's declared output, written as step.OUTPUT.", - "propertyNames": { - "pattern": "^[A-Z][A-Z0-9_]*$" - }, - "type": "object" - }, - "outputs": { - "description": "Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total.", - "items": { - "description": "Expects upper-case letters, digits and underscores, starting with a letter.", - "pattern": "^[A-Z][A-Z0-9_]*$", - "type": "string" - }, - "maxItems": 32, - "type": "array", - "uniqueItems": true - }, - "retry": { - "additionalProperties": false, - "description": "Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout.", - "properties": { - "attempts": { - "default": 1, - "description": "Total attempts including the first, 1 to 10.", - "examples": [ - 3 - ], - "maximum": 10, - "minimum": 1, - "type": "integer" - }, - "backoff": { - "default": "30s", - "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", - "examples": [ - "1m" - ], - "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", - "type": "string" - }, - "maxBackoff": { - "default": "10m", - "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.", - "examples": [ - "30m" - ], - "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "id", - "command" - ], - "type": "object" - }, - "maxItems": 32, - "type": "array" - } - }, - "type": "object" - }, "extraHosts": { "description": "Additional host-to-address entries added to the container.", "items": { @@ -2562,6 +2430,6 @@ "metadata", "spec" ], - "title": "Onebox Application (onebox.run/v1alpha1)", + "title": "Onebox Application (onebox.run/v1alpha2)", "type": "object" } diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index dcbbd4c0..bf4b4513 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -219,30 +219,33 @@ const render = () => { const lines: string[] = [ - `apiVersion: onebox.run/v1alpha1`, - `app: shop`, - `environments:`, - ` production:`, - ` server: root@203.0.113.10`, - `workloads:`, - ` web:`, - ` image: ghcr.io/acme/shop:1.4.0`, + `apiVersion: onebox.run/v1alpha2`, + `kind: Application`, + `metadata:`, + ` name: shop`, + `spec:`, + ` environments:`, + ` production:`, + ` server: root@203.0.113.10`, + ` workloads:`, + ` web:`, + ` image: ghcr.io/acme/shop:1.4.0`, ]; if (on.replicas) { - lines.push(` replicas: 2`); + lines.push(` replicas: 2`); } if (on.hostname) { - lines.push(` routes:`); - lines.push(` - {hostname: shop.example.com, port: 3000}`); + lines.push(` routes:`); + lines.push(` - {hostname: shop.example.com, port: 3000}`); } if (on.worker) { - lines.push(` worker:`); - lines.push(` role: worker`); - lines.push(` image: ghcr.io/acme/shop:1.4.0`); - lines.push(` command: bundle exec sidekiq`); + lines.push(` worker:`); + lines.push(` role: Worker`); + lines.push(` image: ghcr.io/acme/shop:1.4.0`); + lines.push(` command: bundle exec sidekiq`); } if (on.postgres) { - lines.push(`services: {postgres: 17}`); + lines.push(` services: {postgres: 17}`); } ymlOut.innerHTML = lines.join("\n"); diff --git a/site/src/content/docs/guides/environment-variables.mdx b/site/src/content/docs/guides/environment-variables.mdx index 3131b6a2..9f29fdbf 100644 --- a/site/src/content/docs/guides/environment-variables.mdx +++ b/site/src/content/docs/guides/environment-variables.mdx @@ -1,12 +1,12 @@ --- title: Environment variables -description: Every variable Onebox sets or reads, and how to migrate off the retired OB_ prefix. -summary: The ONEBOX_ namespace — what Onebox injects into hooks and jobs, what it reads from your shell and CI, and the one-line rename from the old OB_ names. +description: Every variable Onebox sets or reads in hooks, jobs, backup helpers, and local tooling. +summary: The ONEBOX_ namespace — what Onebox injects into hooks and jobs, and what it reads from your shell and CI. sidebar: order: 9 read_when: - "Writing a hook or a job that talks back to Onebox" - - "Upgrading from a release that used OB_ variables" + - "Configuring Onebox variables in your shell or CI" --- Onebox owns the `ONEBOX_` prefix. Everything it injects or reads lives there, @@ -81,57 +81,3 @@ refreshing a frozen fixture. Nothing in this namespace reaches a generated runtime. Generation reads no process environment at all, so the same project renders the same Compose on your laptop and in CI, and a digest keeps meaning something. - -## Migrating from `OB_` - -Earlier releases used `OB_`. The rename is mechanical and complete — there are -no aliases, no fallback reads, and no deprecation window: - -| Before | After | -| --- | --- | -| `OB_APP` | `ONEBOX_APP` | -| `OB_HOST` | `ONEBOX_HOST` | -| `OB_SERVER` | `ONEBOX_SERVER` | -| `OB_SSH_USER` | `ONEBOX_SSH_USER` | -| `OB_SSH_PORT` | `ONEBOX_SSH_PORT` | -| `OB_RELEASE_DIR` | `ONEBOX_RELEASE_DIR` | -| `OB_RELEASE_ID` | `ONEBOX_RELEASE_ID` | -| `OB_RESULT_FILE` | `ONEBOX_RESULT_FILE` | -| `OB_REPOSITORY_KEY` | `ONEBOX_REPOSITORY_KEY` | -| `OB_S3_KEY_ENTRY` | `ONEBOX_S3_KEY_ENTRY` | -| `OB_S3_SECRET_ENTRY` | `ONEBOX_S3_SECRET_ENTRY` | -| `OB_S3_SESSION_TOKEN_ENTRY` | `ONEBOX_S3_SESSION_TOKEN_ENTRY` | -| `OB_LOCAL` | `ONEBOX_LOCAL` | -| `OB_INSTALL_DIR` | `ONEBOX_INSTALL_DIR` | -| `OB_BIN_DIR` | `ONEBOX_BIN_DIR` | -| `OB_VERSION` | `ONEBOX_VERSION` | -| `OB_RELEASE_REPOSITORY` | `ONEBOX_RELEASE_REPOSITORY` | -| `OB_E2E`, `OB_E2E_*` | `ONEBOX_E2E`, `ONEBOX_E2E_*` | -| `OB_SERVER_E2E` | `ONEBOX_SERVER_E2E` | -| `OB_UPDATE_VERDICTS` | `ONEBOX_UPDATE_VERDICTS` | - -Update your hook scripts, CI and release settings, and any job writing to the -result file. The executable is still `ob`, the project file is still `ob.yml`, -and the API version is `onebox.run/v1alpha1` — only the environment namespace -changed. - -A stray old name does not fall back: it is simply never read, so a hook reading -`$OB_SERVER` gets an empty string rather than an error — unless your own shell -or CI still exports one, in which case the hook receives that stale value, -because hooks inherit your environment. Grep for `OB_` once across your -repository and CI configuration and the migration is done. - -### Protected databases need `ob backup enable` re-run - -If a service has backups enabled, renaming the key inside your encrypted -credential file is not enough on its own. The decrypted file lives on the host, -written once when backups were enabled, and every later deploy reads it as it -stands: - -```sh -ob backup enable -``` - -Re-running it rewrites the host copy with `ONEBOX_REPOSITORY_KEY`. Until you do, -the wal-g wrapper refuses to run rather than archiving without an encryption -key, so a backup will fail loudly instead of silently landing unencrypted. diff --git a/site/src/content/docs/guides/schedule-a-job.mdx b/site/src/content/docs/guides/schedule-a-job.mdx index f20dcb8c..1b537f5a 100644 --- a/site/src/content/docs/guides/schedule-a-job.mdx +++ b/site/src/content/docs/guides/schedule-a-job.mdx @@ -1,15 +1,13 @@ --- title: Schedule a job -description: Bounded host timers, retries, durable executions with saved step outputs, deployment coordination, and manual recovery. -summary: Declare scheduled jobs, bound retries, recover durable executions from checkpoints, coordinate deployments, and inspect host run records. +description: Run one container command on a host timer with bounded retries, deployment coordination, and useful run records. +summary: Declare a scheduled command, bound retries, coordinate deployments, and inspect host run records. sidebar: order: 4 read_when: - "Adding a nightly or recurring task" - "Working out why a scheduled run failed, or whether it ran at all" - "Running a scheduled job now with different parameters" - - "Resuming a failed job without repeating completed steps" - - "Handing a large result set from one step to the next" - "Understanding why a cron expression was refused" - "Working out how a scheduled job resolves secrets with no Onebox process running" --- @@ -59,12 +57,12 @@ that was busy for a moment, declare a bounded retry: schedule: cron: "0 * * * *" timeout: 45m - retry: {attempts: 3, backoff: 30s, max_backoff: 10m} + retry: {attempts: 3, backoff: 30s, maxBackoff: 10m} ``` `attempts` counts every attempt including the first, so the default of `1` is today's single run. After a non-zero exit the runner sleeps `backoff`, doubles -it after each further failure, and never sleeps longer than `max_backoff`. Every +it after each further failure, and never sleeps longer than `maxBackoff`. Every attempt runs inside the same timer firing, under the same locks and the same `timeout`; when the timeout expires the run ends, with no further attempt. @@ -77,9 +75,8 @@ release lease is held instead and every attempt runs the same release. ## Deployment coordination is exclusive by default An exclusive host-fired job takes an application-wide kernel lock for its whole -container run. An ordinary pinned job takes that rendezvous as a shared reader -until it establishes its immutable release lease; a durable pinned job retains -the reader through checkpoint preparation and publication. Different pinned +container run. A pinned job takes that rendezvous as a shared reader +until it establishes its immutable release lease. Different pinned jobs may still start together. Every operation that establishes Onebox's fenced application lock takes the rendezvous as an exclusive writer while publishing its ownership. Readers and writers wait briefly for a handoff; the wait is @@ -196,14 +193,14 @@ default. ### Notifications per outcome `schedule.notify` selects which outcomes send the configured notifications. The -default is `[failure, timeout]`, which is what the failure notifier always did. -Add `success` for a job whose completion someone waits for, and `skipped` for a +default is `[Failure, Timeout]`, which is what the failure notifier always did. +Add `Success` for a job whose completion someone waits for, and `Skipped` for a job whose firings collide often enough that silence would hide it: ```yaml schedule: cron: "0 2 * * *" - notify: [failure, timeout, skipped] + notify: [Failure, Timeout, Skipped] ``` Each selected outcome sends a bounded, fail-open POST to every webhook whose @@ -236,7 +233,7 @@ cron minutes into its default one-minute wake-up window. ## Jobs still declare a data effect `dataEffect` is required on every job, scheduled or not. A nightly report is -`none`; a nightly prune is `destructive`. The rollback and abort gates read it, +`None`; a nightly prune is `Destructive`. The rollback and abort gates read it, and a job that lies about it defeats them. ## Stopping a job without deleting it @@ -351,168 +348,13 @@ Scheduled jobs still run on those older hosts, unchanged. Two things narrow: The records on such a host say `trigger: unknown`, because the runner cannot observe what started it and will not guess. -## Keep durable checkpoints and resume explicitly +## Keep application workflows in the application -Opt into durable execution when a failed job needs to continue with its original -inputs. Onebox saves orchestration state on the managed host; the application -keeps responsibility for domain checkpoints and idempotent effects. This requires -a native Onebox job with `schedule`, `deploymentPhase: None` (the default), and -`dataEffect: None`. Adopted Compose jobs and release-phase jobs are refused. - -For one command, add `execution: {retention: 168h}` to the job. Onebox treats the -job's existing command as one step with ID `main`. An ingestion job that already -keeps its own domain checkpoints can use this form and keep them where they are. - -For several ordered commands, declare steps: - -```yaml -workloads: - refresh: - role: Job - image: ghcr.io/acme/catalog:1.8.2 - dataEffect: None - schedule: - cron: "0 * * * *" - timeout: 45m - retry: {attempts: 2, backoff: 30s, max_backoff: 10m} - execution: - retention: 168h - steps: - - id: sync - command: ["./catalog", "sync"] - outputs: [RELEASE] - - id: index - command: ["./catalog", "index"] - inputs: {RELEASE_ID: "sync.RELEASE"} - retry: {attempts: 3} -``` - -Each step uses the job's image and entrypoint. `command` is an argument list, -passed without shell evaluation. In this example, `sync` writes the application -release it produced, and `index` reads it through the `RELEASE_ID` environment -variable. That value is separate from Onebox's deployment release ID. - -The application writes a JSON object to the path in `ONEBOX_OUTPUT_FILE`, for -example `{"RELEASE":"catalog-20260906"}`. The object must contain exactly the -declared output keys, and every value must be a string without NUL. Each value -is at most 4096 UTF-8 bytes; the output file and its normalized JSON encoding -must each fit within 16384 bytes. Keep large exports elsewhere and save a small -reference or checksum. **Inputs and outputs are persisted operational metadata: -never put credentials or other secrets in them.** Current credentials are -resolved for an attempt; the execution record does not snapshot them. - -### Pass large results by reference - -These bounds intentionally limit step outputs to small operational metadata, -such as identifiers, cursors, or checksums. They do not limit how much data a -job can process. Thousands of row IDs can exceed the bounds; raising the limits -alone would not solve large payload handoff, because step inputs are delivered -through environment variables and outputs are retained in execution checkpoints. - -For a large result set, the application stores the data in its own database or -artifact storage and returns a small reference: - -```yaml - steps: - - id: sync - command: ["./ingest", "sync"] - # `sync` stores the batch and writes {"BATCH":"batch-id"} - # to ONEBOX_OUTPUT_FILE. - outputs: [BATCH] - - id: index - command: ["./ingest", "index"] - inputs: {BATCH_ID: "sync.BATCH"} -``` - -`index` reads the batch using the `BATCH_ID` environment variable. To resume -against the same data, the application must save the batch durably before -returning its reference and retain it unchanged for as long as the execution -remains resumable. Onebox retains the reference; the application owns the batch's -availability, access control, and cleanup. References must not contain secrets, -such as credentials embedded in a URL. - -A single command is also appropriate when the application already owns recovery -for the whole operation. It can keep its own checkpoints and reconcile pending -work without a separate batch handoff. Retrying that command starts it again, -so its recovery logic must handle completed effects and unfinished work safely. -Use separate steps when independent retries and resume boundaries are useful. - -Step IDs are unique. An input reference names a declared output of an earlier -step as `stepID.OUTPUT`; forward and self references are refused. Input and output -names are upper-case identifiers outside `ONEBOX_`; step input names must not -collide with the job's original inputs or literal `env` keys. There may be at -most 32 steps, 32 original inputs, and 32 inputs and outputs per step. - -A step succeeds only after its exit status and validated outputs have been -saved together. A failed step retries independently, inheriting `schedule.retry` -unless it declares its own retry block. The sum of all steps' worst-case backoff -must fit below `schedule.timeout`. - -### Inspect and recover - -```sh -ob job run refresh # start a new execution -ob execution list # newest executions; -n 50 for more -ob execution inspect # original inputs, steps, attempts, eligibility -ob execution resume --wait # skip completed steps; use saved outputs -ob execution abandon # end resumability and release retention hold -``` - -Resume cannot change inputs. To use different inputs, start a new execution with -`ob job run --input NAME=VALUE`. Timer firings also create new executions; -they do not resume failed work automatically. After a reboot, inspect interrupted -work and request resume explicitly. A pending failed execution does not stop -future timer firings; use `ob schedule pause --reason "..."` if needed. - -Inspection includes each activation's systemd invocation ID. Read its logs with -`ob job logs refresh --run `. The execution ID stays the same -across activations; an invocation ID identifies one activation's journal output. -Run logs keep the journal's retention independently of saved execution state. - -The job receives `ONEBOX_EXECUTION_ID` and `ONEBOX_STEP_ID`, both stable across -retries and resumes, plus a fresh `ONEBOX_ATTEMPT_ID` for each attempt. Use the -stable IDs to deduplicate effects in the application. A crash can occur after -an external effect succeeds but before Onebox saves completion, so that step -can run again. Durable checkpoints provide at-least-once attempts, not an -exactly-once guarantee for application effects. - -### Bounds and compatibility - -`retention` defaults to `168h` and may be at most `30d`. It sets the resume window -from execution creation; resuming does not extend it. An execution can have at -most 100 activations, including its first run. Successful and abandoned -executions cannot resume. Expired inactive executions stop protecting their -release; running or uncertain work is protected conservatively until it can be -safely abandoned. Abandonment refuses active work and keeps the saved evidence -available for inspection until its retention deadline. Starting a new execution -prunes expired records that are not still recorded as running; interrupted -records left running by a crash require explicit abandonment. - -`schedule.timeout` bounds each activation, including every step and retry sleep. -A manual resume starts a new activation with that same bound. Deployment locks, -overlap protection, pinned-release behavior, and notifications cover the whole -activation. A failed execution awaiting manual resume holds its durable release -reference, not a deployment lock. - -Resume requires the original release to still be current, the installed workflow -definition to match, and the original image to be locally available with the -same identity. Onebox also checks release files, managed-service container -identities, images and start times, and its recorded data-change generation. -Data-changing jobs and arbitrary `ob exec` commands invalidate that generation -before running, because Onebox cannot prove an arbitrary command is read-only. -A service restart or host reboot can therefore prevent resume even when release -files survive. These checks cannot establish compatibility after arbitrary -out-of-band schema changes; they do not replace application-level compatibility -checks. There is no cross-release adaptation. - -The managed host needs Python 3.8 or later at `/usr/bin/python3` in addition to -the scheduler's existing systemd and `flock` requirements. Onebox installs a -small standard-library helper invoked only for executions and inspection; it -does not run a resident worker service. Durable opt-in also requires the -systemd 252 trigger support described above. State lives under the application's -`schedule/executions` directory as protected JSON checkpoints and survives a -runner or host restart when the host disk survives. It is not replicated to -another host. +Onebox runs the job's container command. A retry starts that command again; +it does not resume from a completed step. A reboot does not resume interrupted +work. Make repeated attempts safe in the application and store any progress +there. A command can perform several operations internally; the application +owns their ordering, checkpoints, and outputs. ## Running one by hand @@ -533,10 +375,10 @@ with `ob job history ` and `ob job logs `. Jobs without `schedule` keep the direct foreground path. Migration jobs also stay attached because their result evidence is part of the approved operation. -`ob job plan` accepts a job when `operatorRun` is `allowed`. This property is +`ob job plan` accepts a job when `operatorRun` is `Allowed`. This property is independent of deployment: `deploymentPhase` decides whether the job runs in the deploy graph, while `operatorRun` decides whether an operator may invoke -it. By default, phase `none` allows operator runs; release-phase jobs disable +it. By default, phase `None` allows operator runs; release-phase jobs disable them unless explicitly enabled. A declared `deploymentPhase: None` job remains in the digest-pinned release runtime but @@ -562,14 +404,14 @@ For a job that participates automatically in a deploy, choose a release phase: ```yaml deploymentPhase: PreRelease -operatorRun: allowed # optional: also permit ob job plan/run +operatorRun: Allowed # optional: also permit ob job plan/run ``` The three properties are independent. `deploymentPhase` controls deploy hooks, `schedule` controls timer activation, and `operatorRun` controls explicit invocation. A job declaring `schedule:` and `deploymentPhase: PreRelease` runs both at its cron time and on every deploy. For a timer-only job, leave -`deploymentPhase` at `none`; set `operatorRun: Disabled` too if operators +`deploymentPhase` at `None`; set `operatorRun: Disabled` too if operators must not start it explicitly. See [`workloads`](/reference/fields/workloads) for every field. diff --git a/site/src/content/docs/reference/cli.mdx b/site/src/content/docs/reference/cli.mdx index c06111f4..68eb36d0 100644 --- a/site/src/content/docs/reference/cli.mdx +++ b/site/src/content/docs/reference/cli.mdx @@ -58,7 +58,6 @@ Available Commands: doctor check local runner provenance and deployment safety capabilities eject write the generated runtime into the repository and hand it over for good exec run a command inside a workload or service container - execution inspect and recover durable job executions help Help about any command init scaffold ob.yml from the compose file + rollability doctor job plan and run a sealed one-shot operator job @@ -621,109 +620,6 @@ Global Flags: -v, --verbose print every remote command ``` -## ob execution - -``` -Inspect durable job checkpoints saved on the managed host. Resume uses the original inputs and successful step outputs, and requires the original release and compatible runtime. Applications remain responsible for idempotency when an interrupted attempt is repeated. - -Usage: - ob execution [flags] - ob execution [command] - -Available Commands: - abandon end resumability and release an execution's retention hold - inspect show saved inputs, step outcomes, and resume eligibility - list list durable executions, newest first - resume resume an unsuccessful execution from saved checkpoints - -Flags: - -h, --help help for execution - -Global Flags: - -c, --config string path to the project YAML file (default "ob.yml") - -e, --env string environment name (default "production") - --output string output mode for supported commands: human|json|ndjson (see the CLI reference) (default "human") - -v, --verbose print every remote command - -Use "ob execution [command] --help" for more information about a command. -``` - -### ob execution abandon - -``` -Mark an inactive execution abandoned. It can no longer be resumed and stops protecting its release from cleanup. Saved execution evidence remains available for inspection. Active work must stop before it can be abandoned. - -Usage: - ob execution abandon [flags] - -Flags: - --break-lock break a stale operation lock after inspecting its holder - -h, --help help for abandon - -Global Flags: - -c, --config string path to the project YAML file (default "ob.yml") - -e, --env string environment name (default "production") - --output string output mode for supported commands: human|json|ndjson (see the CLI reference) (default "human") - -v, --verbose print every remote command -``` - -### ob execution inspect - -``` -Read a durable execution by its stable ID, including original inputs, committed outputs, step attempts and systemd invocation IDs. Works independently of the current job declaration. Resume eligibility is provisional: the runner rechecks compatibility and ownership under locks before running. - -Usage: - ob execution inspect [flags] - -Flags: - -h, --help help for inspect - -Global Flags: - -c, --config string path to the project YAML file (default "ob.yml") - -e, --env string environment name (default "production") - --output string output mode for supported commands: human|json|ndjson (see the CLI reference) (default "human") - -v, --verbose print every remote command -``` - -### ob execution list - -``` -List the newest durable executions saved on the managed host, including their job, state, original release and provisional resume eligibility. Reads checkpoint files independently of journal retention and the current job declarations. - -Usage: - ob execution list [flags] - -Flags: - -n, --count int number of newest executions to show (1–1000) (default 20) - -h, --help help for list - -Global Flags: - -c, --config string path to the project YAML file (default "ob.yml") - -e, --env string environment name (default "production") - --output string output mode for supported commands: human|json|ndjson (see the CLI reference) (default "human") - -v, --verbose print every remote command -``` - -### ob execution resume - -``` -Resume an unsuccessful, unexpired execution with its original inputs and saved outputs. Completed steps are skipped. The host refuses resume while prior work is active or the original release, workflow definition, image, or service runtime is incompatible. An interrupted step may repeat effects; use the stable execution and step IDs for application deduplication. - -Usage: - ob execution resume [flags] - -Flags: - --break-lock break a stale operation lock after inspecting its holder - -h, --help help for resume - --wait wait for the resumed activation to finish - -Global Flags: - -c, --config string path to the project YAML file (default "ob.yml") - -e, --env string environment name (default "production") - --output string output mode for supported commands: human|json|ndjson (see the CLI reference) (default "human") - -v, --verbose print every remote command -``` - ## ob init ``` @@ -953,7 +849,7 @@ Global Flags: ## ob preview ``` -Load a onebox.run/v1alpha1 Application, resolve the environment's overrides, and print +Load a onebox.run/v1alpha2 Application, resolve the environment's overrides, and print the Compose runtime Onebox would generate, with its content digest. Nothing is contacted and nothing is written. Environment values are redacted: @@ -1190,12 +1086,12 @@ Global Flags: ## ob schema ``` -Write the JSON Schema for the `onebox.run/v1alpha1` Application resource. +Write the JSON Schema for the `onebox.run/v1alpha2` Application resource. Reference it from the first line of a project so an editor can offer completion, hover documentation and inline errors: - # yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha1/application.schema.json + # yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha2/application.schema.json Or keep a copy in the repository with --out, which is what an editor needs when the machine is offline. diff --git a/site/src/content/docs/reference/errors.mdx b/site/src/content/docs/reference/errors.mdx index 57bef979..2382e082 100644 --- a/site/src/content/docs/reference/errors.mdx +++ b/site/src/content/docs/reference/errors.mdx @@ -127,9 +127,6 @@ step to complete rather than a line to run verbatim. | `divergence_detected` | the live application state has diverged from its intended state | diagnostic | `ob audit --output json` | | `doctor_failed` | a local readiness check failed | — | — | | `exec_failed` | the audited exec could not be completed | diagnostic | `ob status --output json` | -| `execution_count_invalid` | execution count must be between 1 and 1000 | — | — | -| `execution_inspect_failed` | durable execution could not be read | — | — | -| `execution_list_failed` | durable executions could not be listed | — | — | | `finalize_refused` | the release cannot be finalized because the recorded activation evidence disagrees with the live host | diagnostic | `ob status --output json` | | `host_environment_mismatch` | this host is claimed by a different environment of the same application, which would share its container and volume names | diagnostic | `ob preflight --output json` | | `host_owner_mismatch` | this host is owned by a different Onebox application, and one host has one owner | diagnostic | `ob preflight --output json` | diff --git a/site/src/content/docs/reference/fields/workloads.mdx b/site/src/content/docs/reference/fields/workloads.mdx index ccb47203..4d5d30d2 100644 --- a/site/src/content/docs/reference/fields/workloads.mdx +++ b/site/src/content/docs/reference/fields/workloads.mdx @@ -23,7 +23,7 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`args` · `attempts` · `backoff` · `bind` · `build` · `catchUp` · `command` · `compose` · `condition` · `container` · `context` · `cpus` · `cron` · `dataEffect` · `default` · `deployLock` · `deploymentPhase` · `description` · `dockerfile` · `drain` · `driver` · `entrypoint` · `enum` · `env` · `envFiles` · `exec` · `execution` · `extraHosts` · `file` · `grace` · `health` · `host` · `hostname` · `http` · `id` · `image` · `init` · `inputs` · `interval` · `labels` · `logging` · `maxBackoff` · `memory` · `middlewares` · `mode` · `name` · `needs` · `notify` · `operatorRun` · `options` · `outputs` · `path` · `pattern` · `persistence` · `port` · `protocol` · `provider` · `publishedPorts` · `pull` · `reference` · `replicas` · `resources` · `retention` · `retries` · `retry` · `role` · `routes` · `schedule` · `scheme` · `shutdownGrace` · `signal` · `source` · `startPeriod` · `stdinOpen` · `steps` · `strategy` · `target` · `tcp` · `timeout` · `timezone` · `tls` · `tty` · `user` · `volumes` · `wait` · `within` · `workingDir` +`args` · `attempts` · `backoff` · `bind` · `build` · `catchUp` · `command` · `compose` · `condition` · `container` · `context` · `cpus` · `cron` · `dataEffect` · `default` · `deployLock` · `deploymentPhase` · `description` · `dockerfile` · `drain` · `driver` · `entrypoint` · `enum` · `env` · `envFiles` · `exec` · `extraHosts` · `file` · `grace` · `health` · `host` · `hostname` · `http` · `image` · `init` · `inputs` · `interval` · `labels` · `logging` · `maxBackoff` · `memory` · `middlewares` · `mode` · `name` · `needs` · `notify` · `operatorRun` · `options` · `path` · `pattern` · `persistence` · `port` · `protocol` · `provider` · `publishedPorts` · `pull` · `reference` · `replicas` · `resources` · `retries` · `retry` · `role` · `routes` · `schedule` · `scheme` · `shutdownGrace` · `signal` · `source` · `startPeriod` · `stdinOpen` · `strategy` · `target` · `tcp` · `timeout` · `timezone` · `tls` · `tty` · `user` · `volumes` · `wait` · `within` · `workingDir` ## Reference @@ -47,17 +47,6 @@ cannot drift from what `ob validate` accepts. | `.envFiles` | list | — | Workload-specific ordered environment-file list. Replaces broader defaults when present. | | `.envFiles[].file` `*` | string | — | Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter. | | `.envFiles[].provider` | `Sops` | — | Decryptor used before staging the file. The supported encrypted provider is sops. | -| `.execution` | object | — | Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume. | -| `.execution.retention` | string | `168h` | Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.execution.steps` | list | — | Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps. | -| `.execution.steps[].command` `*` | list | — | Argument vector passed to the job image's entrypoint. No shell evaluation is performed. | -| `.execution.steps[].id` `*` | string | — | Unique stable step identifier, used by output references. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters. | -| `.execution.steps[].inputs` | map | — | Environment variables populated from a preceding step's declared output, written as step.OUTPUT. | -| `.execution.steps[].outputs` | list | — | Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total. | -| `.execution.steps[].retry` | object | — | Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout. | -| `.execution.steps[].retry.attempts` | integer | `1` | Total attempts including the first, 1 to 10. | -| `.execution.steps[].retry.backoff` | string | `30s` | Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.execution.steps[].retry.maxBackoff` | string | `10m` | Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.extraHosts` | list | — | Additional host-to-address entries added to the container. | | `.health` | object | — | Readiness check used to gate rolling replacement. Also accepts an HTTP health path. | | `.health.exec` | — | — | Health command as a shell string or direct argument list. | diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 1c00ed74..9e02c116 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -65,7 +65,7 @@ creates on a host, inside a container, or for its own use begins with | `onebox-` | named things on the host and in containers | `onebox-postgres`, `onebox-ingress`, `onebox-job-nightly`, `onebox-run`, `/tmp/onebox-drain` | | `onebox__` | names joined from several identifiers, which must stay unambiguous | `onebox_postgres_data`, `onebox_services`, `onebox_web_r0` | | `onebox.` | labels | `onebox.app`, `onebox.release` | -| `ONEBOX_` | environment variables | `ONEBOX_STEP_ID` | +| `ONEBOX_` | environment variables | `ONEBOX_RESULT_FILE` | | `/var/lib/onebox/…` | paths | `/var/lib/onebox/app` | | `.onebox-` | Onebox's own files inside a directory it shares | `.onebox-schedule.lease`, `.onebox-tmp` | diff --git a/site/src/content/docs/reference/policies.mdx b/site/src/content/docs/reference/policies.mdx index 1b33f320..0c31ad03 100644 --- a/site/src/content/docs/reference/policies.mdx +++ b/site/src/content/docs/reference/policies.mdx @@ -103,8 +103,8 @@ redacted. | Class | JSON | NDJSON | Commands | | --- | --- | --- | --- | -| Finite envelope | yes | no | `ob approve` · `ob audit` · `ob backup status` · `ob canonical` · `ob doctor` · `ob eject` · `ob execution list` · `ob execution inspect` · `ob init` · `ob job history` · `ob job plan` · `ob plan` · `ob preflight` · `ob preview` · `ob schedule list` · `ob schema` · `ob secrets list` · `ob status` · `ob validate` · `ob version` | -| Finite operation stream | yes | yes | `ob abort` · `ob backup create` · `ob backup enable` · `ob backup disable` · `ob backup drill` · `ob backup prune` · `ob backup restore` · `ob backup verify` · `ob bootstrap` · `ob deploy` · `ob destroy` · `ob execution resume` · `ob execution abandon` · `ob job run` · `ob proxy apply` · `ob resume` · `ob rollback` · `ob schedule apply` · `ob schedule pause` · `ob schedule resume` · `ob secrets push` · `ob service apply` | +| Finite envelope | yes | no | `ob approve` · `ob audit` · `ob backup status` · `ob canonical` · `ob doctor` · `ob eject` · `ob init` · `ob job history` · `ob job plan` · `ob plan` · `ob preflight` · `ob preview` · `ob schedule list` · `ob schema` · `ob secrets list` · `ob status` · `ob validate` · `ob version` | +| Finite operation stream | yes | yes | `ob abort` · `ob backup create` · `ob backup enable` · `ob backup disable` · `ob backup drill` · `ob backup prune` · `ob backup restore` · `ob backup verify` · `ob bootstrap` · `ob deploy` · `ob destroy` · `ob job run` · `ob proxy apply` · `ob resume` · `ob rollback` · `ob schedule apply` · `ob schedule pause` · `ob schedule resume` · `ob secrets push` · `ob service apply` | | Operator passthrough | finite only | yes | `ob job logs` · `ob logs` | | Operator passthrough | no | yes | `ob exec` | | Trusted editor | yes, after exit | no | `ob secrets edit` | diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index f0fc1370..0865f45a 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -1,7 +1,7 @@ --- title: Project file description: The shape of ob.yml — required keys, shorthand forms, and where each block lives. -summary: Overview of the onebox.run/v1alpha1 Application resource, its envelope, spec blocks, and environment-value precedence rules. +summary: Overview of the onebox.run/v1alpha2 Application resource, its envelope, spec blocks, and environment-value precedence rules. sidebar: order: 1 read_when: @@ -10,23 +10,23 @@ read_when: - "Working out which block a field belongs to" --- -`onebox.run/v1alpha1` is the contract for one application on one host. It goes in +`onebox.run/v1alpha2` is the contract for one application on one host. It goes in `ob.yml` at the root of your repository. `ob.yaml` is accepted automatically when `ob.yml` is absent, and `-c` accepts either spelling or any explicit path. The resource requires `apiVersion`, `kind`, `metadata.name`, and `spec`. Inside `spec`, both `environments` and `workloads` are required. There are no -top-level workload shorthands or legacy aliases. +top-level workload shorthands. Start with the schema reference so your editor can help while you type: ```yaml -# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha1/application.schema.json -apiVersion: onebox.run/v1alpha1 +# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha2/application.schema.json +apiVersion: onebox.run/v1alpha2 ``` Use the published schema at -`https://onebox.run/schemas/application/v1alpha1/application.schema.json` for +`https://onebox.run/schemas/application/v1alpha2/application.schema.json` for editor completion and validation. `ob schema --out onebox.schema.json` writes a local copy, and `ob init` puts the @@ -53,9 +53,12 @@ container port for HTTP health checks; it no longer creates an ingress route. ## The block map +Configuration blocks live under `spec`; the application name lives in +`metadata.name`. + | Block | What it says | Fields | | --- | --- | --- | -| `app` | The application's name. Every derived name carries it. | [Top level](/reference/fields/top-level) | +| `metadata.name` | The application's name. Every derived name carries it. | [Top level](/reference/fields/top-level) | | `environments` | Where it runs, and the policy that governs deploying there. | [environments](/reference/fields/environments) | | `workloads` | The containers that are yours. | [workloads](/reference/fields/workloads) | | `services` | The supporting services Onebox runs for you. | [services](/reference/fields/services) | @@ -68,14 +71,13 @@ container port for HTTP health checks; it no longer creates an ingress route. | `backupTargets` | Off-host repositories a protected service writes its backups to. | [backupTargets](/reference/fields/backup-targets) | | `registries` `notifications` | Named maps. | [registries](/reference/fields/registries) · [notifications](/reference/fields/notifications) | -`externalServices` and `backupTargets` are published in the JSON Schema and -accepted by `ob validate`, but the lifecycle behind them is not shipped: -declaring a backup target creates no repository and no schedule, and an external -service's health probe never runs. See -[Shipped vs proposed](/status/capabilities). +`externalServices` declares dependencies operated outside Onebox; +`backupTargets` declares off-host backup destinations. Declaring a destination +does not enable protection by itself. Executable support depends on the service +and the operation; see [Shipped vs proposed](/status/capabilities). -Any mapping also accepts `x-` keys. They are carried nowhere and never change the -generated runtime. +Unknown fields, including behavior-bearing `x-*` fields, are rejected. Store +opaque notes in `metadata.annotations`; annotations do not change the runtime. ## Shorthand @@ -183,7 +185,7 @@ project-owned static configuration. It must not enable `providers.docker` and must configure `providers.file.directory: /etc/traefik/dynamic`. Watching must remain enabled, and `providers.file.filename` cannot be combined with that directory. A proxy `.env` is accepted with project-owned static configuration -or with `proxy.dns_challenge`; it may carry ordinary DNS-provider credentials, +or with `proxy.dnsChallenge`; it may carry ordinary DNS-provider credentials, but not `TRAEFIK_*` static overrides. In both modes, the filenames `onebox.yml`, `onebox.yaml`, and @@ -202,7 +204,7 @@ convention used by TLS certificates and common ingress APIs: ```yaml proxy: config: traefik - dns_challenge: + dnsChallenge: provider: cloudflare resolvers: ["1.1.1.1:53"] @@ -228,7 +230,7 @@ passthrough TCP route (`protocol: tcp` with `tls: none` or `tls: passthrough`), where Traefik requires ``HostSNI(`*`)``; it overlaps every TCP host claim on the same entrypoint and path. -Terminating TLS for a wildcard requires ACME DNS-01. `proxy.dns_challenge` +Terminating TLS for a wildcard requires ACME DNS-01. `proxy.dnsChallenge` keeps the static Traefik configuration managed by Onebox while selecting the DNS provider. Put the provider's required environment variables in `proxy.config/.env`; the directory may contain only that file. Onebox stages it @@ -240,7 +242,7 @@ The provider names and credential variables are defined by If `proxy.config` contains a project-owned `traefik.yml` or `traefik.yaml` instead, configure DNS-01 under `certificatesResolvers.onebox-wildcard` there and omit -`proxy.dns_challenge`. +`proxy.dnsChallenge`. ### Additional proxy entrypoints @@ -320,20 +322,5 @@ generates the runtime from it. Individual services can still be adopted with [Adopt an existing Compose file](/guides/adopt-compose). ::: -## Evolution - -`apiVersion: onebox.run/v1alpha1` is the pre-baseline contract. Before the -baseline is locked, normalization may be breaking and will invalidate config -digests and any plans or staged artifacts bound to the old bytes. Regenerate -those artifacts after changing the declaration. Within the locked baseline: - -- A field is **added**, never repurposed. -- Only the documented resource shape is accepted; aliases and fallbacks are not - part of the contract. -- A default may be added; an existing default's value **does not change**. -- A constraint is **not tightened** against a project that already loads unless - accepting it can silently lose data or produce an ambiguous runtime. A safety - refusal names the migration required to make the project valid again. - The JSON Schema published by `ob schema` is generated from the same declarations the loader enforces and is checked against the conformance corpus. diff --git a/site/src/content/docs/start/reading-it-back.mdx b/site/src/content/docs/start/reading-it-back.mdx index bb5aede3..5dd3cf7c 100644 --- a/site/src/content/docs/start/reading-it-back.mdx +++ b/site/src/content/docs/start/reading-it-back.mdx @@ -40,12 +40,12 @@ ob schema --out onebox.schema.json Or reference the published copy from the first line of the project: ```yaml -# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha1/application.schema.json -apiVersion: onebox.run/v1alpha1 +# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha2/application.schema.json +apiVersion: onebox.run/v1alpha2 ``` The published schema is available at -`https://onebox.run/schemas/application/v1alpha1/application.schema.json`. +`https://onebox.run/schemas/application/v1alpha2/application.schema.json`. The schema is generated from the same declarations the loader enforces and is checked against the conformance corpus, so what your editor tells you while you diff --git a/site/src/content/docs/status/capabilities.mdx b/site/src/content/docs/status/capabilities.mdx index d30bfd24..2532b8d0 100644 --- a/site/src/content/docs/status/capabilities.mdx +++ b/site/src/content/docs/status/capabilities.mdx @@ -23,8 +23,8 @@ This page is the reconciliation. Three states: ## Shipped -- An explicit `onebox.run/v1alpha1` Application schema. Future contract evolution is - additive. +- An explicit `onebox.run/v1alpha2` Application schema. Each job runs one + command; applications own their workflows and checkpoints. - Compose generation and validation, SSH transport with known-host checking, state-bound plans, image pinning, rendered diffs. - Scheduled jobs as host timers with exact cron translation, bounded retry @@ -32,13 +32,6 @@ This page is the reconciliation. Three states: (`ob job history`, `ob job logs`, `ob status`), per-outcome notifications, and declared inputs for operator-initiated runs (`ob job run`). -- Opt-in durable job executions with named linear steps, declared string outputs, - per-step retries, and host-local checkpoints. `ob execution list`, `inspect`, - `resume`, and `abandon` expose recovery state. Resume requires the original - current release and unchanged observed compatibility evidence; applications own - idempotency and out-of-band schema compatibility. Requires Python 3.8+ only for - durable jobs; no database or resident orchestration service. See - [durable executions](/guides/schedule-a-job#durable-executions). - Per-workload reconciliation that retains proven-unchanged healthy workloads, health-gated rolling or recreate replacement for changed or uncertain ones, traffic drain, verification, versioned releases, retention, rollback. diff --git a/site/src/pages/llms.txt.ts b/site/src/pages/llms.txt.ts index 07d23428..26a11a99 100644 --- a/site/src/pages/llms.txt.ts +++ b/site/src/pages/llms.txt.ts @@ -58,7 +58,7 @@ export const GET: APIRoute = async ({ site }) => { "", `- [Markdown page export](${origin}/start/first-deploy.md): Append \`.md\` to any docs page URL for clean Markdown.`, `- [Full documentation text](${origin}/llms-full.txt): Every page concatenated, for one-shot ingestion.`, - `- [Project file JSON Schema](${origin}/schemas/application/v1alpha1/application.schema.json): The machine contract the loader enforces.`, + `- [Project file JSON Schema](${origin}/schemas/application/v1alpha2/application.schema.json): The machine contract the loader enforces.`, `- [Sitemap](${origin}/sitemap-index.xml): Crawler URL index.`, "", "## Operating Onebox from an agent",