From cd0eaf92b9e2f839f04d2f241b0283a2adbeff16 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 20 Sep 2026 09:37:16 -0700 Subject: [PATCH] feat(spec)!: standardize route hostnames Replace domain and wildcard_suffix with routes[].hostname in the onebox.run/v2 contract, retain immutable v1 schema URLs, and migrate v1 release snapshots only for lifecycle replay. BREAKING CHANGE: onebox.run/v2 removes scalar domain routing and routes[].wildcard_suffix; declare exact and literal wildcard values through routes[].hostname. --- .github/workflows/ci.yml | 2 +- README.md | 8 +- cmd/ob-docgen/main.go | 4 +- cmd/ob-docgen/main_test.go | 6 +- cmd/ob/commands_test.go | 4 +- cmd/ob/doctor_test.go | 4 +- cmd/ob/init.go | 2 +- cmd/ob/init_test.go | 2 +- cmd/ob/main_test.go | 4 +- cmd/ob/ops_contract_test.go | 4 +- cmd/ob/output_test.go | 16 +- cmd/ob/preview.go | 2 +- cmd/ob/preview_test.go | 14 +- cmd/ob/schema.go | 2 +- docs/README.md | 3 +- docs/onebox.run-v2.schema.json | 3119 +++++++++++++++++ e2e/apps/README.md | 2 +- e2e/apps/authentik.yml | 6 +- e2e/apps/ghost.yml | 6 +- e2e/apps/gitea.yml | 6 +- e2e/apps/immich.yml | 6 +- e2e/apps/n8n.yml | 6 +- e2e/apps/one-app-one-host.sh | 2 +- e2e/apps/paperless.yml | 6 +- e2e/apps/penpot.yml | 6 +- e2e/apps/rocketchat.yml | 6 +- e2e/apps/umami.yml | 6 +- e2e/apps/uptime-kuma.yml | 6 +- e2e/apps/vaultwarden.yml | 6 +- e2e/destroy_test.go | 2 +- e2e/network_ownership_test.go | 2 +- e2e/server_execution_test.go | 2 +- e2e/testdata/app/ob.yml | 2 +- e2e/testdata/postgres/ob.yml.tmpl | 2 +- e2e/testdata/worker/ob-broken.yml | 2 +- e2e/testdata/worker/ob.yml | 2 +- internal/app/backup_schema_test.go | 6 +- internal/app/canonical_test.go | 30 +- internal/app/compose_test.go | 8 +- internal/app/constraints.go | 15 +- internal/app/contract_shapes_test.go | 10 +- internal/app/eject_test.go | 22 +- internal/app/ejection_contract_test.go | 6 +- internal/app/environment_model_test.go | 60 +- internal/app/errors.go | 2 - internal/app/external_schema_test.go | 6 +- internal/app/generate.go | 6 +- internal/app/generate_test.go | 27 +- internal/app/health_timing_test.go | 16 +- internal/app/jsonschema.go | 78 +- internal/app/jsonschema_test.go | 20 +- internal/app/jump_config_test.go | 4 +- internal/app/load.go | 142 +- internal/app/load_test.go | 228 +- internal/app/names.go | 47 +- internal/app/names_test.go | 14 +- internal/app/naming_scope_test.go | 14 +- internal/app/preflight_test.go | 22 +- internal/app/purity_test.go | 22 +- internal/app/resolve.go | 1 + internal/app/resolve_test.go | 12 +- internal/app/route_test.go | 16 +- internal/app/schedule_test.go | 18 +- internal/app/secrets_graph_test.go | 16 +- internal/app/service_extensions_test.go | 12 +- internal/app/services_test.go | 20 +- internal/app/testdata/contract-verdicts.json | 17 +- internal/app/testdata/corpus/README.md | 2 +- .../testdata/corpus/ext-authentik-managed.yml | 6 +- .../app/testdata/corpus/ext-authentik.yml | 6 +- internal/app/testdata/corpus/ext-frigate.yml | 6 +- internal/app/testdata/corpus/ext-gitea.yml | 6 +- .../testdata/corpus/ext-immich-sourced.yml | 6 +- internal/app/testdata/corpus/ext-immich.yml | 6 +- internal/app/testdata/corpus/ext-n8n.yml | 6 +- .../app/testdata/corpus/ext-paperless.yml | 6 +- .../app/testdata/corpus/ext-plausible.yml | 6 +- internal/app/testdata/corpus/ext-umami.yml | 6 +- internal/app/testdata/corpus/goal.yml | 6 +- internal/app/testdata/corpus/monk.yml | 6 +- internal/app/testdata/corpus/pursue.yml | 6 +- internal/app/testdata/corpus/recast.yml | 6 +- internal/app/types.go | 28 +- internal/app/validate.go | 51 +- internal/app/workload_contract_test.go | 4 +- internal/engine/backup_identity_test.go | 2 +- internal/engine/deploy_test.go | 2 +- internal/engine/fixtures_test.go | 2 +- internal/engine/host_environment_test.go | 2 +- internal/engine/proxy_test.go | 3 +- internal/engine/recovery.go | 2 +- internal/engine/recovery_test.go | 31 + internal/engine/resume_test.go | 2 +- .../engine/secret_generation_rolling_test.go | 4 +- internal/engine/secret_generation_test.go | 4 +- internal/engine/secretspush_test.go | 4 +- internal/engine/verify_injection_test.go | 2 +- internal/onebox/bootstrap_test.go | 2 +- internal/onebox/exec_test.go | 4 +- internal/onebox/jump_route_test.go | 12 +- internal/onebox/load_service_runtime_test.go | 2 +- internal/onebox/operation_graph_test.go | 4 +- internal/onebox/secrets_push_test.go | 8 +- internal/onebox/service_test.go | 6 +- internal/onebox/staging_secrets_test.go | 6 +- internal/onebox/workload_contract_test.go | 12 +- internal/onebox/workload_plan_test.go | 4 +- internal/proxy/proxy_test.go | 6 +- site/public/onebox.run-v2.schema.json | 3119 +++++++++++++++++ site/src/components/landing/Derivation.astro | 36 +- .../docs/explanation/generated-compose.mdx | 2 +- .../docs/explanation/what-onebox-refuses.mdx | 2 +- .../docs/guides/environment-variables.mdx | 2 +- site/src/content/docs/reference/cli.mdx | 6 +- site/src/content/docs/reference/errors.mdx | 2 - .../docs/reference/fields/top-level.mdx | 14 +- .../docs/reference/fields/workloads.mdx | 12 +- site/src/content/docs/reference/naming.mdx | 2 +- site/src/content/docs/reference/policies.mdx | 4 +- .../content/docs/reference/project-file.mdx | 77 +- .../content/docs/start/reading-it-back.mdx | 4 +- site/src/content/docs/status/capabilities.mdx | 2 +- site/src/pages/llms.txt.ts | 2 +- 123 files changed, 7122 insertions(+), 656 deletions(-) create mode 100644 docs/onebox.run-v2.schema.json create mode 100644 site/public/onebox.run-v2.schema.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 271f1a2d..05058997 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,7 +149,7 @@ jobs: run: | ./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} version ./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} --help - ./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} schema --out "${{ runner.temp }}/onebox.run-v1.schema.json" + ./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} schema --out "${{ runner.temp }}/onebox.run-v2.schema.json" e2e: name: End-to-end (Docker) diff --git a/README.md b/README.md index 910d78f2..d5db7c19 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,15 @@ 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://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.schema.json -api_version: onebox.run/v1 +# yaml-language-server: $schema=https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json +api_version: onebox.run/v2 app: shop environments: production: server: root@203.0.113.10 image: ghcr.io/acme/shop:1.4.0 -domain: shop.example.com -port: 3000 +routes: + - {hostname: shop.example.com, port: 3000} ``` It derives the application container, Traefik routing and TLS, release layout diff --git a/cmd/ob-docgen/main.go b/cmd/ob-docgen/main.go index 9ad4bf5e..4a9efad3 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 at `site/public/onebox.run-v1.schema.json`. Those pages carry +// schema published at `site/public/onebox.run-v2.schema.json`. 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{"onebox.run-v1.schema.json": string(schema)} + publicFiles := map[string]string{"onebox.run-v2.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 640642c9..a591e295 100644 --- a/cmd/ob-docgen/main_test.go +++ b/cmd/ob-docgen/main_test.go @@ -278,7 +278,7 @@ func TestUnclaimedTopLevelKeysDocumentTheirSubtree(t *testing.T) { t.Fatalf("cannot render: %v", err) } page := pages["fields/top-level.mdx"] - for _, path := range []string{"routes[].domain", "health.http", "image.reference", "build.context"} { + for _, path := range []string{"routes[].hostname", "health.http", "image.reference", "build.context"} { if !strings.Contains(page, "`"+path+"`") { t.Errorf("top-level.mdx does not document %q", path) } @@ -318,12 +318,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("..", "..", "docs", "onebox.run-v1.schema.json")) + onDisk, err := os.ReadFile(filepath.Join("..", "..", "docs", "onebox.run-v2.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 docs/onebox.run-v1.schema.json") + t.Error("the published schema differs from docs/onebox.run-v2.schema.json") } } diff --git a/cmd/ob/commands_test.go b/cmd/ob/commands_test.go index 358ef3c6..388a8535 100644 --- a/cmd/ob/commands_test.go +++ b/cmd/ob/commands_test.go @@ -63,7 +63,7 @@ func writeProject(t *testing.T) string { t.Helper() dir := t.TempDir() obYAML := ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: demo environments: { production: { server: deploy@example.invalid } } workloads: @@ -102,7 +102,7 @@ func TestValidateOK(t *testing.T) { func TestPreflightBlocksDeploy(t *testing.T) { dir := writeProject(t) obYAML := ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: demo environments: { production: { server: deploy@example.invalid } } workloads: diff --git a/cmd/ob/doctor_test.go b/cmd/ob/doctor_test.go index 124eb52a..d91565a2 100644 --- a/cmd/ob/doctor_test.go +++ b/cmd/ob/doctor_test.go @@ -31,7 +31,7 @@ func doctorTestDependencies(t *testing.T) doctorDependencies { oldBinary := filepath.Join(oldDir, "ob") currentBinary := filepath.Join(currentDir, "ob") cfg := &app.Spec{ - APIVersion: "onebox.run/v1", + APIVersion: "onebox.run/v2", Name: "demo", Environments: map[string]app.Environment{ "production": { @@ -195,7 +195,7 @@ func TestDoctorReportsIncompatibleProjectPolicy(t *testing.T) { deps := doctorTestDependencies(t) deps.loadConfig = func(string) (*app.Spec, error) { return &app.Spec{ - APIVersion: "onebox.run/v1", + APIVersion: "onebox.run/v2", Environments: map[string]app.Environment{ "production": {Policy: app.Policy{MinOneboxVersion: "v2027.1.0"}}, }, diff --git a/cmd/ob/init.go b/cmd/ob/init.go index 01eb63e6..fdc38ac2 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("api_version: onebox.run/v1\n") + b.WriteString("api_version: onebox.run/v2\n") fmt.Fprintf(&b, "app: %s\n", application) b.WriteString("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 fc181bce..18f9513f 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{ - "api_version: onebox.run/v1", + "api_version: onebox.run/v2", "server: deploy@CHANGE-ME", "workloads:", "role: application", diff --git a/cmd/ob/main_test.go b/cmd/ob/main_test.go index e05d458a..bc6e96f1 100644 --- a/cmd/ob/main_test.go +++ b/cmd/ob/main_test.go @@ -8,14 +8,14 @@ import ( "testing" ) -const mainTestProject = `api_version: onebox.run/v1 +const mainTestProject = `api_version: onebox.run/v2 app: demo environments: {production: {server: deploy@example.invalid}} image: nginx:1.27 proxy: {kind: none} ` -const mainTestBuildProject = `api_version: onebox.run/v1 +const mainTestBuildProject = `api_version: onebox.run/v2 app: demo environments: {production: {server: deploy@example.invalid}} workloads: diff --git a/cmd/ob/ops_contract_test.go b/cmd/ob/ops_contract_test.go index 105019ab..be35bba2 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(`api_version: onebox.run/v1 + if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2 app: shop environments: production: {server: deploy@example.invalid} @@ -169,7 +169,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(`api_version: onebox.run/v1 + if err := os.WriteFile(config, []byte(`api_version: onebox.run/v2 app: shop environments: production: {server: deploy@example.invalid} diff --git a/cmd/ob/output_test.go b/cmd/ob/output_test.go index fe4b718e..d9ec13e5 100644 --- a/cmd/ob/output_test.go +++ b/cmd/ob/output_test.go @@ -314,15 +314,15 @@ 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", `api_version: onebox.run/v1 + writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} runtime: env_files: [.env.production] image: nginx -domain: shop.example.com -port: 3000 +routes: + - {hostname: shop.example.com, port: 3000} `) writeFile(t, dir, ".env.production", "API_TOKEN=super-secret-value\nPUBLIC_MODE=on\n") @@ -347,7 +347,7 @@ port: 3000 // publish outlives the terminal it would have scrolled off. func TestStructuredOutputCarriesNoPlaintextSecret(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1 + writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} @@ -355,8 +355,8 @@ workloads: web: role: application image: nginx - domain: shop.example.com - port: 3000 + routes: + - {hostname: shop.example.com, port: 3000} env: API_TOKEN: super-secret-value `) @@ -424,7 +424,7 @@ func TestCommandGroupsValidateOutputBeforeRenderingHelp(t *testing.T) { func TestEjectStructuredOutputIsVersioned(t *testing.T) { for _, mode := range []string{"json"} { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1 + writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} @@ -455,7 +455,7 @@ image: nginx func TestStructuredReadFailuresEmitTypedSafeRecords(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1 + writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} diff --git a/cmd/ob/preview.go b/cmd/ob/preview.go index 7c2783ea..f9db016e 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 an onebox.run/v1 project, resolve the environment's overrides, and print\n" + + Long: "Load an onebox.run/v2 project, 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 d624c359..edae6afb 100644 --- a/cmd/ob/preview_test.go +++ b/cmd/ob/preview_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -const previewProject = `api_version: onebox.run/v1 +const previewProject = `api_version: onebox.run/v2 app: demo environments: production: {server: root@1.2.3.4} @@ -17,8 +17,8 @@ workloads: role: application image: nginx:1.27 replicas: 3 - domain: demo.example.com - port: 8080 + routes: + - {hostname: demo.example.com, port: 8080} env: {API_TOKEN: super-secret-value, LOG_LEVEL: info} ` @@ -79,10 +79,10 @@ func TestPreviewAppliesEnvironmentOverrides(t *testing.T) { // wrong, where, and what to run. func TestPreviewFailureIsActionable(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1 + writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2 app: demo environments: {production: {server: h}} -workloads: {web: {role: application, build: ., domain: d.example.com, port: 80}} +workloads: {web: {role: application, build: ., routes: [{hostname: d.example.com, port: 80}]}} `) out, err := run(t, dir, "preview") if err == nil { @@ -133,11 +133,11 @@ 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", `api_version: onebox.run/v1 + writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2 app: ledger environments: {production: {server: root@1.2.3.4}} workloads: - web: {role: application, image: nginx, domain: d.example.com, port: 80} + web: {role: application, image: nginx, routes: [{hostname: d.example.com, port: 80}]} db: {role: daemon, compose: "compose.yaml#db"} `) out, err := run(t, dir, "eject") diff --git a/cmd/ob/schema.go b/cmd/ob/schema.go index ab1aa36d..eae87425 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/v1` project file.\n\n" + + Long: "Write the JSON Schema for the `onebox.run/v2` project file.\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 e726fcd7..b93088bb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,8 @@ the repository rather than to a reader. | Path | What it is | |---|---| -| [`onebox.run-v1.schema.json`](onebox.run-v1.schema.json) | The published JSON Schema for the project file. Generated from the Go model by `ob schema` and tested byte-for-byte against it. `app.SchemaID` points at this path on `main`, and `ob init` writes that URL onto the first line of every scaffolded project. | +| [`onebox.run-v2.schema.json`](onebox.run-v2.schema.json) | The current JSON Schema for the project file. Generated from the Go model by `ob schema` and tested byte-for-byte against it. `app.SchemaID` points at this path on `main`, and `ob init` writes that URL onto the first line of every scaffolded project. | +| [`onebox.run-v1.schema.json`](onebox.run-v1.schema.json) | The frozen v1 schema, retained so the stable schema URL in existing v1 projects continues to resolve. | | [`product.md`](product.md) | Product direction. Not an implementation contract, and not a capability list. | ## Where the user documentation went diff --git a/docs/onebox.run-v2.schema.json b/docs/onebox.run-v2.schema.json new file mode 100644 index 00000000..5786c127 --- /dev/null +++ b/docs/onebox.run-v2.schema.json @@ -0,0 +1,3119 @@ +{ + "$id": "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "workloads": { + "minProperties": 1 + } + }, + "required": [ + "workloads" + ] + }, + { + "anyOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + } + ] + } + ], + "description": "One application, its workloads, the services it needs, and how a release rolls out.", + "not": { + "allOf": [ + { + "required": [ + "workloads" + ] + }, + { + "anyOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + }, + { + "required": [ + "port" + ] + }, + { + "required": [ + "health" + ] + }, + { + "required": [ + "routes" + ] + } + ] + } + ] + }, + "patternProperties": { + "^x-": {} + }, + "properties": { + "api_version": { + "const": "onebox.run/v2", + "description": "Project contract version. Must be onebox.run/v2.", + "examples": [ + "onebox.run/v2" + ], + "type": "string" + }, + "app": { + "description": "Stable application name used in generated container, volume, network, and host paths. The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"ob-\" or be a name the host layout reserves.", + "examples": [ + "shop" + ], + "not": { + "anyOf": [ + { + "pattern": "^ob-" + }, + { + "const": "ob" + }, + { + "const": "onebox-proxy" + }, + { + "const": "_host" + } + ] + }, + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "backup_targets": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "bucket": { + "description": "Existing destination bucket used by this target. Expects a lower-case S3-compatible bucket name between 3 and 63 characters.", + "examples": [ + "onebox-backups" + ], + "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", + "type": "string" + }, + "credentials": { + "additionalProperties": false, + "description": "Trusted encrypted-file entries containing destination credentials; values never appear in the project.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "access_key_entry": { + "description": "Variable name containing the destination access key. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "BACKUP_ACCESS_KEY_ID" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "file": { + "description": "Repository-relative encrypted credential file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "secrets/backup.env" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "default": "sops", + "description": "Trusted secret provider. Only sops is currently executable.", + "enum": [ + "sops" + ], + "type": "string" + }, + "secret_key_entry": { + "description": "Variable name containing the destination secret key. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "BACKUP_SECRET_ACCESS_KEY" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "session_token_entry": { + "description": "Optional variable name containing a temporary destination session token. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "BACKUP_SESSION_TOKEN" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + } + }, + "type": "object" + }, + "encryption": { + "additionalProperties": false, + "description": "Required encryption mode for each recovery kind this target may store.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cold": { + "description": "Encryption mode required for cold recovery: client-side or server-side.", + "enum": [ + "client-side", + "server-side" + ], + "type": "string" + }, + "pitr": { + "description": "Encryption mode required for point-in-time recovery: client-side or server-side.", + "enum": [ + "client-side", + "server-side" + ], + "type": "string" + }, + "snapshot": { + "description": "Encryption mode required for snapshot recovery: client-side or server-side.", + "enum": [ + "client-side", + "server-side" + ], + "type": "string" + } + }, + "type": "object" + }, + "endpoint": { + "description": "Destination API endpoint. HTTPS is required unless tls is explicitly insecure. Expects an http or https URL.", + "examples": [ + "https://objects.example.com" + ], + "pattern": "^https?://", + "type": "string" + }, + "failure_domain": { + "additionalProperties": false, + "description": "Operator-declared identity used to prove the destination does not share the protected host.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "host": { + "description": "Destination host identity used to refuse a target on the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.", + "examples": [ + "backup-01.example.net" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$", + "type": "string" + }, + "identity": { + "description": "Stable operator-owned failure-domain identity, distinct from the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.", + "examples": [ + "provider-a/us-east-1/account-42" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$", + "type": "string" + } + }, + "type": "object" + }, + "kind": { + "description": "Destination kind. Only s3-compatible is supported.", + "enum": [ + "s3-compatible" + ], + "examples": [ + "s3-compatible" + ], + "type": "string" + }, + "prefix": { + "description": "Non-secret object prefix reserved for Onebox backup data. Expects a relative object prefix with no empty leading component or shell metacharacter.", + "examples": [ + "production/shop" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$", + "type": "string" + }, + "region": { + "description": "S3-compatible region when the endpoint requires one. Expects a lower-case S3-compatible region of letters, digits and hyphens.", + "examples": [ + "us-east-1" + ], + "pattern": "^[a-z0-9][a-z0-9-]{0,62}$", + "type": "string" + }, + "tls": { + "default": "verify", + "description": "Transport policy: verify, or skip-verify to accept a plaintext http endpoint.", + "enum": [ + "verify", + "skip-verify" + ], + "type": "string" + } + }, + "type": "object" + }, + "description": "User-owned off-host repositories available to service backup policies.", + "type": "object" + }, + "base_path": { + "default": "/var/lib/ob", + "description": "Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/srv/ob" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "build": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Build metadata for development. Production requires a resolved image supplied with --image.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "args": { + "additionalProperties": {}, + "description": "Build arguments supplied by the external build system.", + "type": "object" + }, + "context": { + "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "." + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "dockerfile": { + "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "Dockerfile" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "target": { + "description": "Named Dockerfile stage to build.", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path." + }, + "checks": { + "additionalProperties": false, + "description": "Assertions that must pass before a release becomes current unless marked advisory.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "exec": { + "description": "Commands run inside a named workload.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "run": { + "description": "Shell command verified inside the workload.", + "examples": [ + "test -f /srv/ready" + ], + "type": "string" + }, + "workload": { + "description": "Workload the command runs inside.", + "examples": [ + "web" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "http": { + "description": "HTTP paths probed inside a named workload.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "path": { + "description": "HTTP path verified inside the workload. Expects a path beginning with /.", + "examples": [ + "/healthz" + ], + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "port": { + "description": "Container port to probe.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "workload": { + "description": "Workload the path is probed inside.", + "examples": [ + "web" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "migrations": { + "description": "Migration revisions checked against captured job evidence.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "applied_revisions": { + "description": "Revisions the job must report as applied.", + "items": { + "type": "string" + }, + "type": "array" + }, + "job": { + "description": "Job workload whose captured evidence is checked.", + "examples": [ + "migrate" + ], + "type": "string" + }, + "provider": { + "description": "Migration tool that produced the revisions.", + "examples": [ + "alembic" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "url": { + "description": "External URLs probed from the operator side.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "contains": { + "description": "Text the response body must contain.", + "type": "string" + }, + "json_assertions": { + "description": "Scalar JSON response values that must match exactly.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "equals": { + "description": "Exact scalar value required at path." + }, + "path": { + "description": "Dot-separated path to a scalar value in the JSON response.", + "examples": [ + "service.ready" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "required_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Exact response headers required for success.", + "type": "object" + }, + "status_codes": { + "description": "Allowed response status codes. A successful 2xx response is expected when omitted.", + "items": { + "maximum": 599, + "minimum": 100, + "type": "integer" + }, + "type": "array" + }, + "url": { + "description": "External HTTP or HTTPS URL verified from the operator side. Expects an http or https URL.", + "examples": [ + "https://shop.example.com/healthz" + ], + "pattern": "^https?://", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "compose": { + "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.", + "examples": [ + "docker-compose.yml#web" + ], + "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$", + "type": "string" + }, + "deployment": { + "additionalProperties": false, + "description": "Release ordering, retention, and migration behavior.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "migration_policy": { + "default": "manual", + "description": "Policy for migration jobs during release and recovery.", + "enum": [ + "manual", + "auto", + "expand-only" + ], + "type": "string" + }, + "order": { + "description": "Explicit workload release order. Dependency order is derived when omitted.", + "items": { + "type": "string" + }, + "type": "array" + }, + "retain_releases": { + "default": 5, + "description": "Number of completed release directories retained for inspection and rollback.", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "environments": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "base_path": { + "description": "Environment-specific replacement for the project base_path. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/srv/ob" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "env_files": { + "description": "Default ordered environment-file list for application, worker, and job workloads in this environment.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "description": "Decryptor used before staging the file. The supported encrypted provider is sops.", + "enum": [ + "sops" + ], + "examples": [ + "sops" + ], + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ], + "description": "Also accepts a path to an environment file." + }, + "type": "array" + }, + "jump": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent.", + "examples": [ + "deploy@bastion.example.com" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "host": { + "description": "Jump host name or IP address.", + "examples": [ + "bastion.example.com" + ], + "type": "string" + }, + "port": { + "description": "SSH port on the jump host. The SSH default is used when omitted.", + "examples": [ + 2222 + ], + "type": "integer" + }, + "user": { + "description": "SSH user on the jump host. $USER is used when omitted; ob does not read ~/.ssh/config.", + "examples": [ + "deploy" + ], + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent. Also accepts user@host or user@host:port." + }, + "overrides": { + "additionalProperties": false, + "description": "Environment-specific operational tuning. Overrides cannot change workload identity or data semantics.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "services": { + "additionalProperties": { + "additionalProperties": {}, + "type": "object" + }, + "description": "Allowed service tuning keyed by service name: resources and settings.", + "type": "object" + }, + "workloads": { + "additionalProperties": { + "additionalProperties": {}, + "type": "object" + }, + "description": "Allowed workload tuning keyed by workload name: replicas, resources, env, env_files, strategy, and routes.", + "type": "object" + } + }, + "type": "object" + }, + "policy": { + "additionalProperties": false, + "description": "Approval, runner compatibility, and migration-backup requirements for this environment.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "allow_agent_proposals": { + "default": true, + "description": "Declared permission for agent-authored proposals. The current CLI does not distinguish agent identity; execution remains approval-gated.", + "type": "boolean" + }, + "migrations": { + "additionalProperties": false, + "description": "What this environment requires of a release carrying migration risk.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "backup_key_material": { + "description": "Key-material identities the backup report must name.", + "examples": [ + [ + "BACKUP_ACCESS_KEY_ID" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "backup_max_age": { + "default": "24h", + "description": "Maximum age of a backup report accepted for a migration. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "24h" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "require_backup": { + "default": false, + "description": "Require a plan-bound backup report before a release with migration risk.", + "type": "boolean" + }, + "require_restore_test": { + "default": false, + "description": "Require the backup report to state that a restore test succeeded.", + "type": "boolean" + } + }, + "type": "object" + }, + "min_onebox_version": { + "description": "Oldest released Onebox runner allowed to operate this environment. Expects a CalVer release such as v2026.8.0.", + "examples": [ + "v2026.8.0" + ], + "pattern": "^v([1-9][0-9]{3})\\.([1-9]|1[0-2])\\.(0|[1-9][0-9]{0,18})$", + "type": "string" + }, + "min_plan_schema": { + "description": "Oldest executable plan schema accepted by this environment. Expects a plan schema identity such as onebox.run/executable-deploy-plan/v1alpha2.", + "examples": [ + "onebox.run/executable-deploy-plan/v1alpha2" + ], + "pattern": "^onebox\\.run/executable-deploy-plan/v[1-9][0-9]*((alpha|beta)[1-9][0-9]*)?$", + "type": "string" + }, + "require_approval": { + "default": true, + "description": "Require a plan-bound local confirmation before mutating this environment.", + "type": "boolean" + } + }, + "type": "object" + }, + "server": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "SSH server, written as user@host or as an object with host, user, and port.", + "examples": [ + "root@203.0.113.10" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "host": { + "description": "SSH hostname or IP address.", + "examples": [ + "203.0.113.10" + ], + "type": "string" + }, + "port": { + "description": "SSH port. The SSH default is used when omitted.", + "examples": [ + 2222 + ], + "type": "integer" + }, + "user": { + "description": "SSH user. $USER is used when omitted; ob does not read ~/.ssh/config.", + "examples": [ + "root" + ], + "type": "string" + } + }, + "type": "object" + } + ], + "description": "SSH server, written as user@host or as an object with host, user, and port. Also accepts user@host." + } + }, + "type": "object" + }, + "description": "Named environments, each naming the server it deploys to and the policy applied to it.", + "minProperties": 1, + "type": "object" + }, + "external_services": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "backup_owner": { + "description": "Operator or provider responsible for backup, restore, upgrades, credentials, and durability. Expects a stable operator or provider identity of letters, digits, dots, @, colons, slashes, underscores and hyphens.", + "examples": [ + "platform-team/rds" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@:/-]{0,127}$", + "type": "string" + }, + "connection": { + "additionalProperties": false, + "description": "Trusted connection source and driver-shaped entry mapping.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "entries": { + "additionalProperties": { + "description": "Expects a variable name of letters, digits and underscores, not starting with a digit.", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "description": "Maps driver connection parts such as host, port, user, password, database, or url to variable names in the trusted source.", + "type": "object" + }, + "source": { + "additionalProperties": false, + "description": "Trusted encrypted file containing the connection values.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative encrypted environment file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "secrets/production-db.env" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "default": "sops", + "description": "Trusted secret provider. Only sops is currently executable.", + "enum": [ + "sops" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "driver": { + "description": "Built-in connection shape used to validate and project this dependency.", + "enum": [ + "clickhouse", + "mariadb", + "meilisearch", + "minio", + "mongodb", + "mysql", + "nats", + "postgres", + "rabbitmq", + "redis", + "valkey" + ], + "examples": [ + "postgres" + ], + "type": "string" + }, + "probe": { + "additionalProperties": false, + "description": "Optional bounded read-only health observation; it never creates or repairs provider resources.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "kind": { + "default": "driver-health", + "description": "Read-only observation kind: driver-health.", + "enum": [ + "driver-health" + ], + "type": "string" + }, + "max_age": { + "default": "5m", + "description": "Maximum age of a probe observation bound into a plan. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "timeout": { + "default": "5s", + "description": "Maximum duration of one read-only probe. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "description": "Typed dependencies operated outside Onebox. Their connection projection is trusted, but their lifecycle and backup remain external.", + "type": "object" + }, + "health": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Readiness check used to gate rolling replacement.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "exec": { + "description": "Health command as a shell string or direct argument list." + }, + "http": { + "description": "HTTP path probed inside the container. Expects a path beginning with /.", + "examples": [ + "/healthz" + ], + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "interval": { + "default": "5s", + "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "2s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "port": { + "description": "Container port probed by HTTP or TCP health checks.", + "examples": [ + 8080 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "retries": { + "default": 3, + "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.", + "examples": [ + 3 + ], + "type": "integer" + }, + "start_period": { + "default": "30s", + "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "tcp": { + "default": false, + "description": "Probe the configured port by opening a TCP connection.", + "type": "boolean" + }, + "within": { + "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "120s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path." + }, + "hooks": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "local": { + "default": false, + "description": "Run on the operator machine instead of the server.", + "type": "boolean" + }, + "run": { + "description": "Command executed at the lifecycle seam.", + "examples": [ + "./scripts/notify.sh" + ], + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Also accepts the command to run." + }, + "description": "Lifecycle commands keyed by seam: bootstrap, pre_release, post_release, or post_deploy.", + "type": "object" + }, + "image": { + "anyOf": [ + { + "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + }, + { + "additionalProperties": false, + "description": "Container image source, written as a reference string or an object.", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "pull": { + "default": "missing", + "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.", + "enum": [ + "always", + "missing", + "never" + ], + "type": "string" + }, + "reference": { + "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Container image source, written as a reference string or an object. Also accepts an image reference." + }, + "notifications": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "format": { + "default": "text", + "description": "Notification payload format.", + "enum": [ + "text", + "json" + ], + "type": "string" + }, + "on": { + "default": [ + "success", + "failure" + ], + "description": "Operation outcomes that trigger this notification.", + "items": { + "enum": [ + "success", + "failure" + ], + "type": "string" + }, + "type": "array" + }, + "webhook": { + "description": "HTTP endpoint that receives outcome notifications.", + "examples": [ + "https://hooks.example.com/onebox" + ], + "type": "string" + } + }, + "type": "object" + }, + "description": "Named webhooks that receive selected operation and scheduled-job outcomes.", + "type": "object" + }, + "port": { + "description": "Default container port used by HTTP health checks.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "proxy": { + "additionalProperties": false, + "description": "Ownership and configuration of the host ingress proxy.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "config": { + "description": "Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. A managed DNS challenge may use a directory containing only .env for provider credentials. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter.", + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "dns_challenge": { + "additionalProperties": false, + "description": "Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "provider": { + "description": "Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env. Expects a lower-case Traefik DNS provider name such as cloudflare or route53.", + "examples": [ + "cloudflare" + ], + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "resolvers": { + "description": "DNS resolvers used to verify challenge propagation, written as host:port.", + "examples": [ + [ + "1.1.1.1:53" + ] + ], + "items": { + "description": "Expects a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port.", + "pattern": "^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\\[[0-9A-Fa-f:.]+\\]):[0-9]{1,5}$", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, + "entrypoints": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "port": { + "description": "Host and proxy-container TCP port used by this listener.", + "examples": [ + 4317 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints.", + "propertyNames": { + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" + }, + "type": "object" + }, + "image": { + "description": "Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + }, + "kind": { + "default": "traefik-docker", + "description": "Proxy implementation, or none to disable routing.", + "enum": [ + "traefik-docker", + "none" + ], + "type": "string" + }, + "managed": { + "description": "Let Onebox converge the host-scoped proxy when routes are declared.", + "type": "boolean" + }, + "network": { + "default": "ob-ingress", + "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.", + "type": "string" + } + }, + "type": "object" + }, + "registries": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "password_env": { + "description": "Local environment-variable name containing the registry password or token. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "GHCR_TOKEN" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "server": { + "description": "Registry hostname, optionally with a port. Expects a host with an optional port and path, such as ghcr.io or registry.example.com:5000.", + "examples": [ + "ghcr.io" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9.-]*(:[0-9]{1,5})?(/[A-Za-z0-9._/-]*)?$", + "type": "string" + }, + "username": { + "description": "Registry login username. Expects a username of letters, digits and . _ @ + -.", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", + "type": "string" + } + }, + "type": "object" + }, + "description": "Named container registries and the environment variables holding their credentials.", + "type": "object" + }, + "routes": { + "description": "Ingress routes exposed by this workload.", + "items": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "hostname": { + "const": "*" + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + }, + { + "if": { + "properties": { + "hostname": { + "pattern": "^\\*\\." + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "http" + } + } + } + }, + { + "if": { + "properties": { + "tls": { + "const": "passthrough" + } + }, + "required": [ + "tls" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + } + }, + "required": [ + "protocol" + ] + } + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "entrypoint": { + "default": "websecure", + "description": "Named proxy listener used for the route.", + "type": "string" + }, + "hostname": { + "anyOf": [ + { + "maxLength": 253, + "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$" + }, + { + "const": "*" + } + ], + "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.", + "examples": [ + "shop.example.com" + ], + "type": "string" + }, + "middlewares": { + "description": "Ordered provider-qualified middleware references applied to this route.", + "items": { + "description": "Expects a provider-qualified name such as secure-headers@file.", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$", + "type": "string" + }, + "type": "array" + }, + "path": { + "default": "/", + "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.", + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "port": { + "description": "Container port receiving routed traffic.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "default": "http", + "description": "Routing protocol: http or tcp.", + "enum": [ + "http", + "tcp" + ], + "type": "string" + }, + "scheme": { + "default": "http", + "description": "Backend connection scheme for HTTP routes: http, https, or h2c.", + "enum": [ + "http", + "https", + "h2c" + ], + "type": "string" + }, + "tls": { + "default": "terminate", + "description": "TLS handling: terminate, passthrough, or none.", + "enum": [ + "terminate", + "passthrough", + "none" + ], + "type": "string" + } + }, + "required": [ + "hostname" + ], + "type": "object" + }, + "type": "array" + }, + "runtime": { + "additionalProperties": false, + "description": "Project-wide environment files and local environment-file requirements.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "env_checks": { + "description": "Local environment-file assertions checked before planning or deploying.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative dotenv file whose declared keys are checked. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "present": { + "description": "Environment keys that must be declared but may be empty.", + "items": { + "type": "string" + }, + "type": "array" + }, + "require": { + "description": "Environment keys that must be declared with non-empty values.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "env_files": { + "description": "Project-wide ordered environment-file list for application, worker, and job workloads.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "description": "Decryptor used before staging the file. The supported encrypted provider is sops.", + "enum": [ + "sops" + ], + "examples": [ + "sops" + ], + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ], + "description": "Also accepts a path to an environment file." + }, + "type": "array" + } + }, + "type": "object" + }, + "services": { + "additionalProperties": { + "anyOf": [ + { + "type": [ + "string", + "number", + "integer" + ] + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "backup": { + "additionalProperties": false, + "description": "Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "allow_downtime": { + "default": false, + "description": "Whether recurring backup operations may use the driver-declared stopped-service window.", + "type": "boolean" + }, + "drill": { + "additionalProperties": false, + "description": "Exact isolated restore-test schedule, proof age, and optional staging filesystem.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "max_age": { + "default": "7d", + "description": "Maximum age of the latest passing restore proof. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "7d" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "schedule": { + "additionalProperties": false, + "description": "Exact recurring isolated restore-test schedule.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cron": { + "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.", + "examples": [ + "0 2 * * *" + ], + "pattern": "^[-0-9*/,A-Za-z ]+$", + "type": "string" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.", + "examples": [ + "Europe/Berlin" + ], + "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "max_data_loss": { + "description": "Maximum tolerable interval between the latest recoverable point and failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "15m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "recovery_kind": { + "description": "Required recovery envelope: snapshot, pitr, or cold.", + "enum": [ + "snapshot", + "pitr", + "cold" + ], + "examples": [ + "pitr" + ], + "type": "string" + }, + "retention": { + "additionalProperties": false, + "description": "Portable minimum recovery history that the selected native driver must be able to preserve.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "keep": { + "default": 7, + "description": "Minimum number of independently recoverable base generations to retain.", + "examples": [ + 7 + ], + "minimum": 1, + "type": "integer" + }, + "window": { + "default": "7d", + "description": "Minimum continuous recovery history the native retention mapping must preserve. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "7d" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + }, + "schedule": { + "additionalProperties": false, + "description": "Exact recurring base-backup schedule.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cron": { + "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.", + "examples": [ + "0 2 * * *" + ], + "pattern": "^[-0-9*/,A-Za-z ]+$", + "type": "string" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.", + "examples": [ + "Europe/Berlin" + ], + "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$", + "type": "string" + } + }, + "type": "object" + }, + "target": { + "description": "Name of a project-level backup target. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "examples": [ + "offsite" + ], + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + } + }, + "type": "object" + }, + "driver": { + "description": "Built-in service driver. Defaults to the service map key. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "examples": [ + "postgres" + ], + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "features": { + "additionalProperties": false, + "description": "Capabilities Onebox must establish before application workloads run.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": {}, + "type": "object" + }, + "description": "PostgreSQL extensions Onebox installs in the managed application database before application migrations run.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "type": "object" + } + }, + "type": "object" + }, + "persistence": { + "additionalProperties": false, + "description": "Data-lifetime declaration for this supporting service.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "mode": { + "default": "durable", + "description": "Data lifetime: durable, ephemeral, or external.", + "enum": [ + "durable", + "ephemeral", + "external" + ], + "type": "string" + } + }, + "type": "object" + }, + "resources": { + "additionalProperties": false, + "description": "Memory and CPU limits for this supporting service.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cpus": { + "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.", + "examples": [ + "0.5" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?$", + "type": "string" + }, + "memory": { + "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.", + "examples": [ + "512MB" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$", + "type": "string" + } + }, + "type": "object" + }, + "settings": { + "additionalProperties": {}, + "description": "Driver-specific settings validated by the selected service driver.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "type": "object" + }, + "version": { + "description": "Driver version or image tag to run.", + "examples": [ + "17" + ] + }, + "volumes": { + "description": "Additional driver-defined persistent volume names.", + "items": { + "description": "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" + }, + "type": "array" + } + }, + "type": "object" + } + ], + "description": "Also accepts the version to run." + }, + "description": "Supporting services managed outside application releases, such as databases and caches.", + "type": "object" + }, + "workloads": { + "additionalProperties": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "required": [ + "execution" + ] + }, + "then": { + "not": { + "required": [ + "compose" + ] + }, + "properties": { + "data_effect": { + "const": "none" + }, + "deployment_phase": { + "const": "none" + }, + "operator_run": { + "const": "allowed" + } + }, + "required": [ + "schedule", + "data_effect" + ] + } + }, + { + "oneOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + } + ] + }, + { + "not": { + "allOf": [ + { + "required": [ + "published_ports" + ] + }, + { + "anyOf": [ + { + "properties": { + "strategy": { + "const": "rolling" + } + }, + "required": [ + "strategy" + ] + }, + { + "allOf": [ + { + "not": { + "required": [ + "strategy" + ] + } + }, + { + "required": [ + "health" + ] + }, + { + "anyOf": [ + { + "properties": { + "role": { + "const": "application" + } + }, + "required": [ + "role" + ] + }, + { + "not": { + "required": [ + "role" + ] + } + } + ] + } + ] + } + ] + } + ] + } + }, + { + "if": { + "properties": { + "persistence": { + "anyOf": [ + { + "properties": { + "mode": { + "const": "durable" + } + }, + "required": [ + "mode" + ] + }, + { + "not": { + "required": [ + "mode" + ] + } + } + ] + } + }, + "required": [ + "persistence" + ] + }, + "then": { + "properties": { + "replicas": { + "maximum": 1 + } + } + } + }, + { + "else": { + "not": { + "anyOf": [ + { + "required": [ + "deployment_phase" + ] + }, + { + "required": [ + "operator_run" + ] + }, + { + "required": [ + "data_effect" + ] + }, + { + "required": [ + "schedule" + ] + }, + { + "required": [ + "inputs" + ] + }, + { + "required": [ + "execution" + ] + } + ] + } + }, + "if": { + "properties": { + "role": { + "const": "job" + } + }, + "required": [ + "role" + ] + }, + "then": { + "required": [ + "data_effect" + ] + } + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "build": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Build metadata for development. Production requires a resolved image supplied with --image.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "args": { + "additionalProperties": {}, + "description": "Build arguments supplied by the external build system.", + "type": "object" + }, + "context": { + "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "." + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "dockerfile": { + "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "Dockerfile" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "target": { + "description": "Named Dockerfile stage to build.", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path." + }, + "command": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + { + "description": "Container command as a shell string or argument list.", + "examples": [ + "./bin/server" + ] + } + ], + "description": "Container command as a shell string or argument list. Also accepts a command line or argument list." + }, + "compose": { + "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.", + "examples": [ + "docker-compose.yml#web" + ], + "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$", + "type": "string" + }, + "data_effect": { + "description": "Job data impact used by rollback and abort gates.", + "enum": [ + "none", + "migration", + "destructive", + "unknown" + ], + "examples": [ + "migration" + ], + "type": "string" + }, + "deployment_phase": { + "default": "none", + "description": "Deployment phase for this job: none, pre_release, or post_release.", + "enum": [ + "none", + "pre_release", + "post_release" + ], + "type": "string" + }, + "drain": { + "additionalProperties": false, + "description": "Signal and timing used to remove a container from traffic before stopping it.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "grace": { + "description": "Maximum graceful-shutdown time before forced termination, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "30s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "signal": { + "default": "TERM", + "description": "Signal sent to begin graceful shutdown. Expects a signal name such as TERM or QUIT.", + "pattern": "^[A-Z][A-Z0-9]*$", + "type": "string" + }, + "wait": { + "description": "Maximum drain window before shutdown continues, at most 7d. Recreate workloads continue sooner when every old container exits. Rolling workloads wait the full interval before stopping each container when their health check supports drain guarding. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "10s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + }, + "entrypoint": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + { + "description": "Container entrypoint as a string or argument list." + } + ], + "description": "Container entrypoint as a string or argument list. Also accepts an entrypoint or argument list." + }, + "env": { + "additionalProperties": {}, + "description": "Literal container environment values. Managed-service credential variables cannot be overridden.", + "type": "object" + }, + "env_files": { + "description": "Workload-specific ordered environment-file list. Replaces broader defaults when present.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "description": "Decryptor used before staging the file. The supported encrypted provider is sops.", + "enum": [ + "sops" + ], + "examples": [ + "sops" + ], + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ], + "description": "Also accepts a path to an environment file." + }, + "type": "array" + }, + "execution": { + "additionalProperties": false, + "description": "Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with data_effect none. Stores non-secret checkpoints on the host and permits explicit same-release resume.", + "patternProperties": { + "^x-": {} + }, + "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, + "patternProperties": { + "^x-": {} + }, + "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.", + "patternProperties": { + "^x-": {} + }, + "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" + }, + "max_backoff": { + "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" + }, + "extra_hosts": { + "description": "Additional host-to-address entries added to the container.", + "items": { + "type": "string" + }, + "type": "array" + }, + "health": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Readiness check used to gate rolling replacement.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "exec": { + "description": "Health command as a shell string or direct argument list." + }, + "http": { + "description": "HTTP path probed inside the container. Expects a path beginning with /.", + "examples": [ + "/healthz" + ], + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "interval": { + "default": "5s", + "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "2s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "port": { + "description": "Container port probed by HTTP or TCP health checks.", + "examples": [ + 8080 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "retries": { + "default": 3, + "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.", + "examples": [ + 3 + ], + "type": "integer" + }, + "start_period": { + "default": "30s", + "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "tcp": { + "default": false, + "description": "Probe the configured port by opening a TCP connection.", + "type": "boolean" + }, + "within": { + "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "120s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path." + }, + "hostname": { + "description": "Hostname assigned inside the workload container.", + "type": "string" + }, + "image": { + "anyOf": [ + { + "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + }, + { + "additionalProperties": false, + "description": "Container image source, written as a reference string or an object.", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "pull": { + "default": "missing", + "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.", + "enum": [ + "always", + "missing", + "never" + ], + "type": "string" + }, + "reference": { + "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Container image source, written as a reference string or an object. Also accepts an image reference." + }, + "init": { + "description": "Run a minimal init process as PID 1 inside the container.", + "type": "boolean" + }, + "inputs": { + "additionalProperties": { + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "enum" + ] + }, + { + "required": [ + "pattern" + ] + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "default": { + "description": "Value used by a timer firing and by an operator run that does not override it. Must satisfy the input's own constraint.", + "type": "string" + }, + "description": { + "description": "What the input controls.", + "type": "string" + }, + "enum": { + "description": "Accepted values.", + "examples": [ + [ + "catalog" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "pattern": { + "description": "Regular expression the whole value must match.", + "examples": [ + "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + ], + "type": "string" + } + }, + "required": [ + "default" + ], + "type": "object" + }, + "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.", + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "type": "object" + }, + "labels": { + "additionalProperties": {}, + "description": "Additional container labels outside namespaces reserved by Onebox and the proxy.", + "type": "object" + }, + "logging": { + "additionalProperties": false, + "description": "Container logging driver and driver-specific options.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "driver": { + "description": "Container runtime logging driver. Expects a log driver name such as local, json-file or an org/plugin:tag.", + "examples": [ + "local" + ], + "pattern": "^[a-z0-9][a-z0-9_.-]*(/[a-z0-9][a-z0-9_.-]*)?(:[A-Za-z0-9_.-]+)?$", + "type": "string" + }, + "options": { + "additionalProperties": {}, + "description": "Driver-specific logging options passed to the container runtime.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "type": "object" + } + }, + "type": "object" + }, + "needs": { + "description": "Workload or supporting-service prerequisites and optional connection-variable mappings.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "condition": { + "description": "Prerequisite condition: started, healthy, or completed.", + "enum": [ + "started", + "healthy", + "completed" + ], + "type": "string" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Maps application environment-variable names to service connection parts such as host, port, user, password, database, or url.", + "type": "object" + }, + "name": { + "description": "Name of a workload or supporting service that must start first. 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" + } + }, + "type": "object" + } + ], + "description": "Also accepts the name of a prerequisite." + }, + "type": "array" + }, + "operator_run": { + "description": "Whether an operator may invoke this job outside deployment: allowed or disabled. Defaults to allowed for phase none and disabled otherwise.", + "enum": [ + "allowed", + "disabled" + ], + "type": "string" + }, + "persistence": { + "additionalProperties": false, + "description": "Declares whether this workload holds data that must outlive releases.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "mode": { + "default": "durable", + "description": "Data lifetime: durable, ephemeral, or external.", + "enum": [ + "durable", + "ephemeral", + "external" + ], + "type": "string" + } + }, + "type": "object" + }, + "port": { + "description": "Default container port used by HTTP health checks.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "published_ports": { + "description": "Host ports published outside the proxy. They bind to loopback by default. A rolling workload cannot publish one, because two replicas cannot hold the same host port during a roll: set strategy: recreate, or route through the proxy instead.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "bind": { + "default": "127.0.0.1", + "description": "Host address on which the published port listens.", + "type": "string" + }, + "container": { + "description": "Port receiving traffic inside the container.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "host": { + "description": "Port exposed on the host.", + "examples": [ + 8080 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "default": "tcp", + "description": "Published transport protocol: tcp or udp.", + "enum": [ + "tcp", + "udp" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "replicas": { + "default": 1, + "description": "Desired number of long-running workload containers.", + "examples": [ + 2 + ], + "minimum": 1, + "type": "integer" + }, + "resources": { + "additionalProperties": false, + "description": "Container memory and CPU limits.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cpus": { + "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.", + "examples": [ + "0.5" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?$", + "type": "string" + }, + "memory": { + "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.", + "examples": [ + "512MB" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$", + "type": "string" + } + }, + "type": "object" + }, + "role": { + "description": "Lifecycle role: application, worker, daemon, or job.", + "enum": [ + "application", + "worker", + "daemon", + "job" + ], + "examples": [ + "application" + ], + "type": "string" + }, + "routes": { + "description": "Ingress routes exposed by this workload.", + "items": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "hostname": { + "const": "*" + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + }, + { + "if": { + "properties": { + "hostname": { + "pattern": "^\\*\\." + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "http" + } + } + } + }, + { + "if": { + "properties": { + "tls": { + "const": "passthrough" + } + }, + "required": [ + "tls" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + } + }, + "required": [ + "protocol" + ] + } + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "entrypoint": { + "default": "websecure", + "description": "Named proxy listener used for the route.", + "type": "string" + }, + "hostname": { + "anyOf": [ + { + "maxLength": 253, + "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$" + }, + { + "const": "*" + } + ], + "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.", + "examples": [ + "shop.example.com" + ], + "type": "string" + }, + "middlewares": { + "description": "Ordered provider-qualified middleware references applied to this route.", + "items": { + "description": "Expects a provider-qualified name such as secure-headers@file.", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$", + "type": "string" + }, + "type": "array" + }, + "path": { + "default": "/", + "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.", + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "port": { + "description": "Container port receiving routed traffic.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "default": "http", + "description": "Routing protocol: http or tcp.", + "enum": [ + "http", + "tcp" + ], + "type": "string" + }, + "scheme": { + "default": "http", + "description": "Backend connection scheme for HTTP routes: http, https, or h2c.", + "enum": [ + "http", + "https", + "h2c" + ], + "type": "string" + }, + "tls": { + "default": "terminate", + "description": "TLS handling: terminate, passthrough, or none.", + "enum": [ + "terminate", + "passthrough", + "none" + ], + "type": "string" + } + }, + "required": [ + "hostname" + ], + "type": "object" + }, + "type": "array" + }, + "schedule": { + "additionalProperties": false, + "description": "Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "catch_up": { + "default": true, + "description": "Run once after the host returns if an elapsed schedule was missed while it was offline.", + "type": "boolean" + }, + "cron": { + "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.", + "examples": [ + "0 2 * * *" + ], + "pattern": "^[-0-9*/,A-Za-z ]+$", + "type": "string" + }, + "deploy_lock": { + "default": "exclusive", + "description": "Deployment coordination policy: exclusive blocks application operations for the full run; pinned leases the immutable starting release and permits only deployments without data-changing jobs or untyped hooks.", + "enum": [ + "exclusive", + "pinned" + ], + "examples": [ + "pinned" + ], + "type": "string" + }, + "notify": { + "default": [ + "failure", + "timeout" + ], + "description": "Run outcomes that send the configured notifications: success, failure, timeout, skipped.", + "items": { + "enum": [ + "success", + "failure", + "timeout", + "skipped" + ], + "type": "string" + }, + "type": "array" + }, + "retry": { + "additionalProperties": false, + "description": "Bounded retry inside one timer firing. Attempts run under the same locks and the same timeout; a timeout ends the run.", + "patternProperties": { + "^x-": {} + }, + "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" + }, + "max_backoff": { + "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" + }, + "shutdown_grace": { + "default": "30s", + "description": "Time allowed for graceful container shutdown after the run deadline before Onebox forces removal. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "45s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "timeout": { + "default": "1h", + "description": "Maximum wall time for one scheduled run before systemd terminates it and records failure. 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" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.", + "examples": [ + "Europe/Berlin" + ], + "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$", + "type": "string" + } + }, + "type": "object" + }, + "stdin_open": { + "description": "Keep standard input open for the container.", + "type": "boolean" + }, + "strategy": { + "description": "Replacement strategy for a changed or uncertain workload. An unchanged healthy workload is retained automatically. Defaults to rolling only for an application workload with health; all other workloads default to recreate.", + "enum": [ + "rolling", + "recreate" + ], + "type": "string" + }, + "tty": { + "description": "Allocate a pseudo-TTY for the container.", + "type": "boolean" + }, + "user": { + "description": "User or UID used to run the container process.", + "type": "string" + }, + "volumes": { + "description": "Managed named volumes or bind mounts. Relative bind sources are read-only release content; absolute sources are external host state.", + "items": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "source": { + "pattern": "^[^/]" + } + }, + "required": [ + "source" + ] + }, + "then": { + "properties": { + "mode": { + "const": "ro" + } + }, + "required": [ + "mode" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "name", + "path" + ] + }, + { + "required": [ + "source", + "path" + ] + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "mode": { + "default": "rw", + "description": "Mount access mode: rw or ro. A relative bind source requires ro.", + "enum": [ + "rw", + "ro" + ], + "type": "string" + }, + "name": { + "description": "Stable logical name of a Onebox-managed volume. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "examples": [ + "data" + ], + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "path": { + "description": "Absolute container path where the volume or bind mount is attached. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/var/lib/app" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "source": { + "description": "Bind mount source. An absolute path is external host state that outlives releases. A dot-prefixed repository path is read-only release content, kept for as long as a container still mounts it. Expects an absolute host path or a dot-prefixed path inside the repository, with no colon, control character or shell metacharacter.", + "examples": [ + "./config" + ], + "not": { + "pattern": "(^|/)\\.\\.(/|$)" + }, + "pattern": "^(/[^\\x00-\\x1f'\"$`\\\\:]*|\\.(?:/[^\\x00-\\x1f'\"$`\\\\:]*)?)$", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "working_dir": { + "description": "Absolute working directory for the container process. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/app" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + } + }, + "type": "object" + }, + "description": "Application containers, workers, daemons, and jobs managed as releases.", + "type": "object" + } + }, + "required": [ + "api_version", + "environments" + ], + "title": "Onebox project (onebox.run/v2)", + "type": "object" +} diff --git a/e2e/apps/README.md b/e2e/apps/README.md index 9d3ccf4a..105b2638 100644 --- a/e2e/apps/README.md +++ b/e2e/apps/README.md @@ -1,6 +1,6 @@ # Deployable application fixtures -Self-contained `onebox.run/v1` projects for real open-source applications, +Self-contained `onebox.run/v2` projects 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 fba59cab..49bb9bdb 100644 --- a/e2e/apps/authentik.yml +++ b/e2e/apps/authentik.yml @@ -2,7 +2,7 @@ # application's image — none of which any deployed fixture had, and the bind # mount in particular is the one a converted Compose file almost always brings # with it. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: authentik environments: production: {server: root@TARGET} @@ -12,8 +12,8 @@ workloads: strategy: recreate image: ghcr.io/goauthentik/server:2025.2.4 command: [server] - domain: auth.example.com - port: 9000 + routes: + - {hostname: auth.example.com, port: 9000} needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}] published_ports: [{host: 9000, container: 9000}] volumes: diff --git a/e2e/apps/ghost.yml b/e2e/apps/ghost.yml index e0793ddf..4c3bfd0a 100644 --- a/e2e/apps/ghost.yml +++ b/e2e/apps/ghost.yml @@ -1,7 +1,7 @@ # Ghost on managed MySQL. The point of this fixture is the driver: mysql is in # the catalogue and had never run on a host, so the credential generation, the # connection file and the health check were all unproven for it. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: ghost environments: production: {server: root@TARGET} @@ -9,8 +9,8 @@ workloads: ghost: role: application image: ghost:5-alpine - domain: blog.example.com - port: 2368 + routes: + - {hostname: blog.example.com, port: 2368} needs: - name: mysql env: diff --git a/e2e/apps/gitea.yml b/e2e/apps/gitea.yml index aa388daa..ca6427af 100644 --- a/e2e/apps/gitea.yml +++ b/e2e/apps/gitea.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: gitea environments: production: {server: root@TARGET} @@ -6,8 +6,8 @@ workloads: server: role: application image: docker.gitea.com/gitea:1.22.6 - domain: git.example.com - port: 3000 + routes: + - {hostname: git.example.com, port: 3000} needs: [db] published_ports: [{host: 2222, container: 22}] env: diff --git a/e2e/apps/immich.yml b/e2e/apps/immich.yml index 4186fc23..29e7105a 100644 --- a/e2e/apps/immich.yml +++ b/e2e/apps/immich.yml @@ -1,7 +1,7 @@ # Immich. Its database is Postgres with the pgvector extension, which is not # the image the managed driver runs — so it is a daemon the user owns, which is # exactly the boundary the contract draws. Also the heaviest images in the set. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: immich environments: production: {server: root@TARGET} @@ -9,8 +9,8 @@ workloads: server: role: application image: ghcr.io/immich-app/immich-server:v1.125.7 - domain: photos.example.com - port: 2283 + routes: + - {hostname: photos.example.com, port: 2283} needs: [{name: database, condition: healthy}, {name: redis, condition: healthy}] volumes: [{name: upload, path: /usr/src/app/upload}] env: diff --git a/e2e/apps/n8n.yml b/e2e/apps/n8n.yml index c0c1f405..f45f5ecf 100644 --- a/e2e/apps/n8n.yml +++ b/e2e/apps/n8n.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: n8n environments: production: {server: root@TARGET} @@ -6,8 +6,8 @@ workloads: n8n: role: application image: docker.n8n.io/n8nio/n8n:1.73.1 - domain: n8n.example.com - port: 5678 + routes: + - {hostname: n8n.example.com, port: 5678} needs: [postgres, redis] env: DB_TYPE: postgresdb diff --git a/e2e/apps/one-app-one-host.sh b/e2e/apps/one-app-one-host.sh index 465eb07a..60cab264 100755 --- a/e2e/apps/one-app-one-host.sh +++ b/e2e/apps/one-app-one-host.sh @@ -85,7 +85,7 @@ name, routed = None, None for line in doc.splitlines(): if line.startswith(" ") and line.endswith(":") and not line.startswith(" "): name = line.strip().rstrip(":") - if name and ("domain:" in line or "routes:" in line) and routed is None: + if name and "routes:" in line and routed is None: routed = name print(routed or "") ') diff --git a/e2e/apps/paperless.yml b/e2e/apps/paperless.yml index 6ea69209..fe00581e 100644 --- a/e2e/apps/paperless.yml +++ b/e2e/apps/paperless.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: paperless environments: production: {server: root@TARGET} @@ -6,8 +6,8 @@ workloads: webserver: role: application image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7 - domain: paperless.example.com - port: 8000 + routes: + - {hostname: paperless.example.com, port: 8000} needs: [db, broker, gotenberg, tika] env: PAPERLESS_REDIS: redis://broker:6379 diff --git a/e2e/apps/penpot.yml b/e2e/apps/penpot.yml index 49996858..6c1ee73e 100644 --- a/e2e/apps/penpot.yml +++ b/e2e/apps/penpot.yml @@ -1,7 +1,7 @@ # Penpot. Two routes on one workload's host — the frontend serves the app and # proxies /api to the backend — which is the multi-route shape that only ever # existed in a synthetic fixture until now. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: penpot environments: production: {server: root@TARGET} @@ -10,8 +10,8 @@ workloads: role: application image: penpotapp/frontend:2.4.3 routes: - - {domain: penpot.example.com, path: /, port: 80} - - {domain: penpot.example.com, path: /api, port: 80} + - {hostname: penpot.example.com, path: /, port: 80} + - {hostname: penpot.example.com, path: /api, port: 80} needs: [backend] env: PENPOT_FLAGS: disable-registration disable-email-verification diff --git a/e2e/apps/rocketchat.yml b/e2e/apps/rocketchat.yml index 615c0250..83e7bd12 100644 --- a/e2e/apps/rocketchat.yml +++ b/e2e/apps/rocketchat.yml @@ -10,7 +10,7 @@ # Kept as a rendering and validation case, and as the thing to re-run if the # driver ever configures a replica set. An application that needs one today # wants a daemon workload it owns. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: rocketchat environments: production: {server: root@TARGET} @@ -18,8 +18,8 @@ workloads: rocketchat: role: application image: registry.rocket.chat/rocketchat/rocket.chat:7.3.0 - domain: chat.example.com - port: 3000 + routes: + - {hostname: chat.example.com, port: 3000} needs: - name: mongodb env: {MONGO_URL: url} diff --git a/e2e/apps/umami.yml b/e2e/apps/umami.yml index f8af6f42..8960402a 100644 --- a/e2e/apps/umami.yml +++ b/e2e/apps/umami.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: umami environments: production: {server: root@TARGET} @@ -6,8 +6,8 @@ workloads: umami: role: application image: ghcr.io/umami-software/umami:postgresql-v2.13.2 - domain: analytics.example.com - port: 3000 + routes: + - {hostname: analytics.example.com, port: 3000} needs: [db] env: DATABASE_URL: postgresql://umami:umami@db:5432/umami diff --git a/e2e/apps/uptime-kuma.yml b/e2e/apps/uptime-kuma.yml index 576cf998..53ea0498 100644 --- a/e2e/apps/uptime-kuma.yml +++ b/e2e/apps/uptime-kuma.yml @@ -1,8 +1,8 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: uptime-kuma environments: production: {server: root@TARGET} image: louislam/uptime-kuma:1.23.16 -domain: kuma.example.com -port: 3001 +routes: + - {hostname: kuma.example.com, port: 3001} health: {http: /, port: 3001, interval: 10s, start_period: 30s, retries: 5} diff --git a/e2e/apps/vaultwarden.yml b/e2e/apps/vaultwarden.yml index 4b2da653..85dce82b 100644 --- a/e2e/apps/vaultwarden.yml +++ b/e2e/apps/vaultwarden.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: vaultwarden environments: production: {server: root@TARGET} @@ -6,7 +6,7 @@ workloads: server: role: application image: vaultwarden/server:1.32.7 - domain: vault.example.com - port: 80 + routes: + - {hostname: vault.example.com, port: 80} env: {WEBSOCKET_ENABLED: "true", SIGNUPS_ALLOWED: "false"} volumes: [{name: data, path: /data}] diff --git a/e2e/destroy_test.go b/e2e/destroy_test.go index c9dff16f..de36bc62 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(`api_version: onebox.run/v1 + currentBody := fmt.Sprintf(`api_version: onebox.run/v2 app: %s base_path: %q environments: diff --git a/e2e/network_ownership_test.go b/e2e/network_ownership_test.go index b791a8f7..264949ed 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(`api_version: onebox.run/v1 + projectBody := fmt.Sprintf(`api_version: onebox.run/v2 app: %s environments: production: {server: root@localhost} diff --git a/e2e/server_execution_test.go b/e2e/server_execution_test.go index 1d0e3c4e..ed8094e6 100644 --- a/e2e/server_execution_test.go +++ b/e2e/server_execution_test.go @@ -27,7 +27,7 @@ func TestServerDurableExecutions(t *testing.T) { 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(`api_version: onebox.run/v1 + project := fmt.Sprintf(`api_version: onebox.run/v2 app: %s base_path: %s environments: {production: {server: %s}} diff --git a/e2e/testdata/app/ob.yml b/e2e/testdata/app/ob.yml index a1d17b80..f14928c5 100644 --- a/e2e/testdata/app/ob.yml +++ b/e2e/testdata/app/ob.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: obe2e environments: production: { server: local } # e2e uses the local transport; the value is unused diff --git a/e2e/testdata/postgres/ob.yml.tmpl b/e2e/testdata/postgres/ob.yml.tmpl index 6dc07df8..b66a48f0 100644 --- a/e2e/testdata/postgres/ob.yml.tmpl +++ b/e2e/testdata/postgres/ob.yml.tmpl @@ -4,7 +4,7 @@ # Rendered rather than checked in whole, because two values are only known once # the guest is up: the address ob connects to, and the endpoint the object # store is reachable at from inside the container. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: observer environments: production: diff --git a/e2e/testdata/worker/ob-broken.yml b/e2e/testdata/worker/ob-broken.yml index 370aa171..9205205c 100644 --- a/e2e/testdata/worker/ob-broken.yml +++ b/e2e/testdata/worker/ob-broken.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: obworker environments: { production: { server: local } } workloads: diff --git a/e2e/testdata/worker/ob.yml b/e2e/testdata/worker/ob.yml index 3b8df8c4..abb32764 100644 --- a/e2e/testdata/worker/ob.yml +++ b/e2e/testdata/worker/ob.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: obworker environments: { production: { server: local } } workloads: diff --git a/internal/app/backup_schema_test.go b/internal/app/backup_schema_test.go index 806eaf82..57b876fc 100644 --- a/internal/app/backup_schema_test.go +++ b/internal/app/backup_schema_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -const validBackupProject = `api_version: onebox.run/v1 +const validBackupProject = `api_version: onebox.run/v2 app: shop environments: production: @@ -66,7 +66,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 := `api_version: onebox.run/v1 + project := `api_version: onebox.run/v2 app: shop environments: {production: {server: deploy@app.example.net}} workloads: {web: {image: nginx:1}} @@ -100,7 +100,7 @@ func TestReplicationIntentIsRejected(t *testing.T) { } func TestRunnableUnqualifiedDriverRejectsBackupWithoutFallback(t *testing.T) { - if _, err := LoadBytes([]byte(`api_version: onebox.run/v1 + if _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: deploy@app.example.net}} workloads: {web: {image: nginx:1}} diff --git a/internal/app/canonical_test.go b/internal/app/canonical_test.go index 945c59b8..96251e44 100644 --- a/internal/app/canonical_test.go +++ b/internal/app/canonical_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -const canonicalProject = `api_version: onebox.run/v1 +const canonicalProject = `api_version: onebox.run/v2 app: ledger environments: production: {server: root@1.2.3.4} @@ -13,8 +13,8 @@ environments: server: root@5.6.7.8 overrides: {workloads: {ledger: {replicas: 3}}} build: . -domain: ledger.example.com -port: 8080 +routes: + - {hostname: ledger.example.com, port: 8080} ` func originsFor(t *testing.T, env string) map[string]Origin { @@ -40,14 +40,14 @@ func originsFor(t *testing.T, env string) map[string]Origin { func TestOriginsDistinguishWhatWasWritten(t *testing.T) { o := originsFor(t, "production") for path, want := range map[string]Origin{ - "app": OriginAuthored, - "workloads.ledger.build.context": OriginAuthored, - "workloads.ledger.domain": OriginShorthand, - "workloads.ledger.port": OriginShorthand, - "workloads.ledger.replicas": OriginDefault, - "workloads.ledger.strategy": OriginDefault, - "base_path": OriginDefault, - "proxy.network": OriginDefault, + "app": OriginAuthored, + "workloads.ledger.build.context": OriginAuthored, + "workloads.ledger.routes[0].hostname": OriginAuthored, + "workloads.ledger.routes[0].port": OriginAuthored, + "workloads.ledger.replicas": OriginDefault, + "workloads.ledger.strategy": OriginDefault, + "base_path": OriginDefault, + "proxy.network": OriginDefault, } { if o[path] != want { t.Errorf("%s = %q, want %q", path, o[path], want) @@ -88,8 +88,8 @@ func TestCanonicalAnnotatesOnlyWhatWasNotWritten(t *testing.T) { if !strings.Contains(out, "replicas: 3 # environment-override") { t.Errorf("the override should be marked\n%s", out) } - if !strings.Contains(out, "# default") || !strings.Contains(out, "# shorthand") { - t.Errorf("defaults and shorthand should be marked\n%s", out) + if !strings.Contains(out, "# default") { + t.Errorf("defaults should be marked\n%s", out) } for _, line := range strings.Split(out, "\n") { if strings.HasPrefix(strings.TrimSpace(line), "app: ledger") && strings.Contains(line, "#") { @@ -216,14 +216,14 @@ 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 := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: web: role: application image: nginx - routes: [{domain: shop.example.com, port: 80}] + routes: [{hostname: shop.example.com, port: 80}] volumes: [{name: data, path: /data}] published_ports: [{host: 9000, container: 9000}] persistence: {} diff --git a/internal/app/compose_test.go b/internal/app/compose_test.go index 75f7537c..1cee2347 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 := `api_version: onebox.run/v1 + y := `api_version: onebox.run/v2 app: ledger environments: production: {server: root@1.2.3.4} @@ -140,8 +140,8 @@ workloads: web: role: application image: nginx - domain: ledger.example.com - port: 8080 + routes: + - {hostname: ledger.example.com, port: 8080} db: role: daemon compose: compose.yaml#postgres @@ -227,7 +227,7 @@ func TestADeclaredHealthCheckReachesAReferencedService(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "ob.yml") - if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1 + if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} diff --git a/internal/app/constraints.go b/internal/app/constraints.go index 7b281033..fb3e63ee 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -114,14 +114,13 @@ var ( gDNSResolver = grammar{"DNS resolver", regexp.MustCompile(`^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9A-Fa-f:.]+\]):[0-9]{1,5}$`), "a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port"} - // Exact route hosts predate strict hostname validation. Keep accepting their - // established spellings (including upper-case and a trailing dot), while - // excluding the characters that can escape Traefik's backtick literal. - gRouteHost = grammar{"route host", regexp.MustCompile("^[^\\x00-\\x1f\\x7f`*]+$"), - "an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing"} - - gWildcardSuffix = grammar{"wildcard DNS suffix", regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`), - "a lower-case ASCII or Punycode DNS hostname whose labels contain 1 to 63 characters"} + gRouteHostname = grammar{"route hostname", regexp.MustCompile(`^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`), + "a lower-case ASCII or IDNA A-label hostname, optionally prefixed by the complete wildcard label *."} + // v1 exact route hosts were deliberately permissive. Immutable v1 release + // snapshots must retain that grammar so a v2 runner can still roll them back + // or finish their lifecycle; new authored projects never use it. + gLegacyRouteHost = grammar{"route host", regexp.MustCompile("^[^\\x00-\\x1f\\x7f`*]+$"), + "an exact host with no wildcard, control character or backtick"} gCalVer = grammar{"version", buildinfo.ReleaseVersionPattern, "a CalVer release such as v2026.8.0"} diff --git a/internal/app/contract_shapes_test.go b/internal/app/contract_shapes_test.go index 32b4cc27..2eaea824 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 = "api_version: onebox.run/v1\napp: shop\n" +const shapeHead = "api_version: onebox.run/v2\napp: shop\n" // 3.4 — a scalar shorthand and its object form are the same project. // @@ -54,8 +54,8 @@ func TestEveryShorthandEqualsItsObjectForm(t *testing.T) { "environments: {production: {server: root@h}}\nimage: {reference: nginx}\n", }, "health": { - "environments: {production: {server: root@h}}\nimage: nginx\ndomain: x\nport: 8080\nhealth: /healthz\n", - "environments: {production: {server: root@h}}\nimage: nginx\ndomain: x\nport: 8080\nhealth: {http: /healthz}\n", + "environments: {production: {server: root@h}}\nimage: nginx\nport: 8080\nhealth: /healthz\n", + "environments: {production: {server: root@h}}\nimage: nginx\nport: 8080\nhealth: {http: /healthz}\n", }, "server": { "environments: {production: {server: root@203.0.113.10}}\nimage: nginx\n", @@ -103,7 +103,7 @@ func TestCanonicalOutputIsStableAcrossRuns(t *testing.T) { staging: {server: root@h2} workloads: zebra: {role: worker, image: nginx} - alpha: {role: application, image: nginx, health: /healthz, domain: a.example.com, port: 1} + alpha: {role: application, image: nginx, health: /healthz, routes: [{hostname: a.example.com, port: 1}]} middle: {role: worker, image: nginx} services: redis: "7.4" @@ -128,7 +128,7 @@ notifications: func TestInspectionChangesNothingOnDisk(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := shapeHead + "environments: {production: {server: root@h}}\nimage: nginx\ndomain: shop.example.com\nport: 3000\n" + body := shapeHead + "environments: {production: {server: root@h}}\nimage: nginx\nroutes: [{hostname: shop.example.com, port: 3000}]\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go index 37c9f8b8..9809db35 100644 --- a/internal/app/eject_test.go +++ b/internal/app/eject_test.go @@ -8,7 +8,7 @@ import ( ) const ejectProject = `# Ledger's production contract. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: ledger environments: @@ -19,8 +19,8 @@ workloads: web: role: application image: nginx:1.27 # pinned deliberately - domain: ledger.example.com - port: 8080 + routes: + - {hostname: ledger.example.com, port: 8080} ` func ejectInto(t *testing.T, body string) (dir string, res *EjectResult) { @@ -190,15 +190,15 @@ 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, `api_version: onebox.run/v1 + dir, _ := ejectInto(t, `api_version: onebox.run/v2 app: ledger environments: {production: {server: root@1.2.3.4}} workloads: web: role: application image: nginx - domain: ledger.example.com - port: 8080 + routes: + - {hostname: ledger.example.com, port: 8080} health: {http: /healthz, port: 8080} volumes: [{name: uploads, path: /var/lib/ledger/uploads}] env: {LOG_LEVEL: info} @@ -211,7 +211,7 @@ workloads: } } // What the overlay still derives must stay. - for _, kept := range []string{"role: application", "domain:", "port:", "compose:"} { + for _, kept := range []string{"role: application", "hostname:", "port:", "compose:"} { if !strings.Contains(out, kept) { t.Errorf("%q should have been kept\n%s", kept, out) } @@ -224,11 +224,11 @@ workloads: 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(`api_version: onebox.run/v1 + os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`api_version: onebox.run/v2 app: ledger environments: {production: {server: root@1.2.3.4}} workloads: - web: {role: application, image: nginx, domain: d.example.com, port: 80} + web: {role: application, image: nginx, routes: [{hostname: d.example.com, port: 80}]} db: {role: daemon, compose: "compose.yaml#db"} `), 0o600) @@ -260,12 +260,12 @@ workloads: func TestEjectAfterAnInterruptionCompletes(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := `api_version: onebox.run/v1 + body := `api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} workloads: - web: {role: application, image: nginx, domain: shop.example.com, port: 3000} + web: {role: application, image: nginx, routes: [{hostname: shop.example.com, port: 3000}]} ` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) diff --git a/internal/app/ejection_contract_test.go b/internal/app/ejection_contract_test.go index 22bc2ef6..bdabfb6d 100644 --- a/internal/app/ejection_contract_test.go +++ b/internal/app/ejection_contract_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -const ejectContractProject = `api_version: onebox.run/v1 +const ejectContractProject = `api_version: onebox.run/v2 app: shop environments: production: @@ -22,8 +22,8 @@ workloads: env: API_TOKEN: super-secret-value routes: - - {domain: shop.example.com, path: /, port: 3000} - - {domain: shop.example.com, path: /api, port: 3001} + - {hostname: shop.example.com, path: /, port: 3000} + - {hostname: shop.example.com, path: /api, port: 3001} worker: role: worker image: nginx:1.27 diff --git a/internal/app/environment_model_test.go b/internal/app/environment_model_test.go index 3907824a..116859da 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 = `api_version: onebox.run/v1 +const envModelBody = `api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} @@ -71,7 +71,7 @@ environments: runtime: env_files: [.env] workloads: - web: {role: application, image: nginx, domain: s.example.com, port: 3000} + web: {role: application, image: nginx, routes: [{hostname: s.example.com, port: 3000}]} cron: {role: job, image: nginx, command: ["true"], data_effect: none} quiet: {role: worker, image: nginx, env_files: []} own: {role: worker, image: nginx, env_files: [.env.own]} @@ -138,7 +138,7 @@ func TestTwoEntriesNeverShareAStagedFile(t *testing.T) { // The withdrawn block is refused with direction, not as an unknown field. func TestTheWithdrawnSecretsBlockIsRefusedWithDirection(t *testing.T) { - _, err := Load(envModelProject(t, `api_version: onebox.run/v1 + _, err := Load(envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} image: nginx @@ -163,15 +163,15 @@ secrets: {production: s.yaml} // An authored value may not claim a name a connection supplies. func TestAuthoredValuesCannotClaimAConnectionVariable(t *testing.T) { - _, err := Load(envModelProject(t, `api_version: onebox.run/v1 + _, err := Load(envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: web: role: application image: nginx - domain: s.example.com - port: 3000 + routes: + - {hostname: s.example.com, port: 3000} needs: [postgres] env: {POSTGRES_PASSWORD: mine} services: @@ -189,7 +189,7 @@ services: // 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, `api_version: onebox.run/v1 + path := envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} runtime: @@ -198,8 +198,8 @@ workloads: legacy: role: application compose: legacy.yml#legacy - domain: s.example.com - port: 80 + routes: + - {hostname: s.example.com, port: 80} web: role: worker image: nginx @@ -243,12 +243,12 @@ workloads: // 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, `api_version: onebox.run/v1 + r := resolvedFor(t, envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} image: nginx -domain: s.example.com -port: 3000 +routes: + - {hostname: s.example.com, port: 3000} health: /healthz `, nil), "production") if got := r.Spec.Workloads["shop"].Health.Port; got != 3000 { @@ -270,14 +270,14 @@ health: /healthz // 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, `api_version: onebox.run/v1 + path := envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} runtime: env_files: [.env] image: nginx -domain: s.example.com -port: 3000 +routes: + - {hostname: s.example.com, port: 3000} `, map[string]string{".env": "API_TOKEN=super-secret-value\n"}) r := resolvedFor(t, path, "production") @@ -310,7 +310,7 @@ port: 3000 // rolling release waits out in full before reporting the container unhealthy // without naming a port. func TestAProbeWithNoPortIsRefused(t *testing.T) { - _, err := Load(envModelProject(t, `api_version: onebox.run/v1 + _, err := Load(envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -349,7 +349,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, `api_version: onebox.run/v1 + path := envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} runtime: @@ -358,8 +358,8 @@ workloads: legacy: role: application compose: legacy.yml#legacy - domain: s.example.com - port: 80 + routes: + - {hostname: s.example.com, port: 80} `, map[string]string{ ".env.one": "A=1\n", ".env.two": "B=2\n", @@ -387,7 +387,7 @@ workloads: // cannot be shadowed by one. Emitting them in the other order passed every // test. func TestConnectionFilesComeAfterDeclaredEntries(t *testing.T) { - path := envModelProject(t, `api_version: onebox.run/v1 + path := envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} runtime: @@ -396,8 +396,8 @@ workloads: web: role: application image: nginx - domain: s.example.com - port: 3000 + routes: + - {hostname: s.example.com, port: 3000} needs: [postgres] services: postgres: 17 @@ -420,15 +420,15 @@ services: // 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, `api_version: onebox.run/v1 + _, err := resolvedForErr(t, envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} workloads: legacy: role: application compose: legacy.yml#legacy - domain: s.example.com - port: 80 + routes: + - {hostname: s.example.com, port: 80} needs: [postgres] services: postgres: 17 @@ -469,14 +469,14 @@ 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, `api_version: onebox.run/v1 + path := envModelProject(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} runtime: env_files: [{file: s.enc, provider: sops}] image: nginx -domain: s.example.com -port: 3000 +routes: + - {hostname: s.example.com, port: 3000} health: {http: /healthz, port: 3000} `, map[string]string{"s.enc": "A=1\n"}) // Through the function the callers use. `Load` does not reach it, so a test @@ -547,13 +547,13 @@ 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 := `api_version: onebox.run/v1 + body := `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} runtime: env_files: [.env.absent] workloads: - web: {image: nginx, domain: s.example.com, port: 3000} + web: {image: nginx, routes: [{hostname: s.example.com, port: 3000}]} ` _, err := Load(envModelProject(t, body, nil)) if err == nil { diff --git a/internal/app/errors.go b/internal/app/errors.go index 072edc6d..e0e95cab 100644 --- a/internal/app/errors.go +++ b/internal/app/errors.go @@ -31,8 +31,6 @@ var errorCodes = map[string]string{ "stateful_replicas": "a workload keeping durable state asks for more than one replica", "strategy_ungated": "a rolling release is asked for by a workload with no health check to gate it", "shorthand_and_workloads": "top-level shorthand cannot be combined with a workloads block", - "routing_exclusive": "the domain shorthand and the routes list say the same thing twice", - "routing_incomplete": "domain and port are declared together or not at all", "route_collision": "two workloads claim the same address", "route_without_proxy": "a route is declared with nothing to route it", "identifier_collision": "a name is used by both a workload and a service", diff --git a/internal/app/external_schema_test.go b/internal/app/external_schema_test.go index f5b51203..b08bd29d 100644 --- a/internal/app/external_schema_test.go +++ b/internal/app/external_schema_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -const validExternalServiceProject = `api_version: onebox.run/v1 +const validExternalServiceProject = `api_version: onebox.run/v2 app: shop environments: {production: {server: deploy@app.example.net}} workloads: @@ -37,7 +37,7 @@ func TestExternalServiceFixtures(t *testing.T) { }, { name: "external_service_ambiguous_owner", - yaml: `api_version: onebox.run/v1 + yaml: `api_version: onebox.run/v2 app: shop environments: {production: {server: deploy@app.example.net}} workloads: {web: {image: nginx:1}} @@ -54,7 +54,7 @@ external_services: }, { name: "external_service_lifecycle_field_refused", - yaml: `api_version: onebox.run/v1 + yaml: `api_version: onebox.run/v2 app: shop environments: {production: {server: deploy@app.example.net}} workloads: {web: {image: nginx:1}} diff --git a/internal/app/generate.go b/internal/app/generate.go index 9d2e2574..663bdcea 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -545,8 +545,8 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any { router := n.Router(name, i) kind := "http" rule := fmt.Sprintf("Host(`%s`)", r.HostPattern()) - if r.WildcardSuffix != "" { - suffix := strings.ReplaceAll(r.WildcardSuffix, ".", `\.`) + if r.IsWildcard() { + suffix := strings.ReplaceAll(r.HostSuffix(), ".", `\.`) rule = fmt.Sprintf("HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.%s$`)", suffix) } if r.Protocol == "tcp" { @@ -579,7 +579,7 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any { // this same private identity. if p.Proxy.Managed && r.TLS == "terminate" { resolver := ManagedCertificateResolver - if r.WildcardSuffix != "" { + if r.IsWildcard() { resolver = ManagedWildcardCertificateResolver out[pre+"tls.domains[0].main"] = r.HostPattern() } diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index 4c96a576..83dfa717 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 = `api_version: onebox.run/v1 +const appFixture = `api_version: onebox.run/v2 app: ledger environments: production: {server: root@1.2.3.4} @@ -20,8 +20,8 @@ workloads: role: application image: ghcr.io/acme/ledger:1.4.0 replicas: 2 - domain: ledger.example.com - port: 8080 + routes: + - {hostname: ledger.example.com, port: 8080} health: {http: /healthz, port: 8080, interval: 10s, retries: 3} drain: {grace: 30s} needs: [db] @@ -86,7 +86,7 @@ func TestRenderIsDeterministic(t *testing.T) { } func TestWorkloadRevisionIsReleaseIndependentAndRuntimeSensitive(t *testing.T) { - project := `api_version: onebox.run/v1 + project := `api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -277,8 +277,7 @@ func TestBuildWithoutResolvedImageFailsClosed(t *testing.T) { // drops its route first, because declaring one under `kind: none` is refused // at load — a route nobody would serve is not a runtime question. func TestNoProxyAddsNothing(t *testing.T) { - y := strings.Replace(appFixture, " domain: ledger.example.com\n", "", 1) - y = strings.Replace(y, " port: 8080\n", "", 1) + y := strings.Replace(appFixture, " routes:\n - {hostname: ledger.example.com, port: 8080}\n", "", 1) out := string(render(t, y+"proxy: {kind: none}\n")) if strings.Contains(out, "traefik") { t.Error("no proxy must not add routing labels") @@ -363,13 +362,13 @@ func TestHasTerminatingTLSDistinguishesPassthrough(t *testing.T) { } func TestWildcardRouteRendersSafeHostRegexpAndDNSResolver(t *testing.T) { - project := `api_version: onebox.run/v1 + project := `api_version: onebox.run/v2 app: preview environments: {production: {server: root@example.com}} workloads: web: image: nginx - routes: [{wildcard_suffix: preview.example.com, port: 8080}] + routes: [{hostname: "*.preview.example.com", port: 8080}] proxy: config: traefik dns_challenge: {provider: cloudflare} @@ -441,7 +440,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 := `api_version: onebox.run/v1 + y := `api_version: onebox.run/v2 app: ledger environments: production: {server: root@1.2.3.4} @@ -483,7 +482,7 @@ workloads: // generates into are reserved, so a user label can never silently win. func TestUserLabelsCannotClaimOneboxNamespaces(t *testing.T) { for _, bad := range []string{"ob.app", "traefik.enable"} { - y := `api_version: onebox.run/v1 + y := `api_version: onebox.run/v2 app: ledger environments: {production: {server: h}} workloads: {web: {role: application, image: nginx, labels: {"` + bad + `": x}}} @@ -520,7 +519,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, `api_version: onebox.run/v1 + out := render(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -541,7 +540,7 @@ workloads: // 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, `api_version: onebox.run/v1 + out := render(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -562,7 +561,7 @@ func TestShellHealthChecksCarryTheDrainGuard(t *testing.T) { `health: {tcp: true, port: 5432}`, `health: {exec: "test -f /ready"}`, } { - out := string(render(t, `api_version: onebox.run/v1 + out := string(render(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -581,7 +580,7 @@ workloads: // 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, `api_version: onebox.run/v1 + out := string(render(t, `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: diff --git a/internal/app/health_timing_test.go b/internal/app/health_timing_test.go index c08bd0e3..0acd172e 100644 --- a/internal/app/health_timing_test.go +++ b/internal/app/health_timing_test.go @@ -111,9 +111,9 @@ 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 := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: root@10.0.0.1}}\n"+ - "image: nginx\ndomain: d.example.com\nport: 8080\n"+ + "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+ "health: {http: /healthz, retries: 100000000000}\n"), "ob.yml") if err == nil { t.Fatal("a retries count that overflows the drain budget was accepted") @@ -134,9 +134,9 @@ func TestAbsurdHealthDurationsAreRejected(t *testing.T) { "within": "{http: /healthz, within: 100000d}", } { t.Run(name, func(t *testing.T) { - _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: root@10.0.0.1}}\n"+ - "image: nginx\ndomain: d.example.com\nport: 8080\n"+ + "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+ "health: "+health+"\n"), "ob.yml") if err == nil { t.Fatalf("health %s was accepted", health) @@ -206,9 +206,9 @@ 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 := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: root@10.0.0.1}}\n"+ - "image: nginx\ndomain: d.example.com\nport: 8080\n"+ + "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+ "health: {http: /healthz, interval: 1000000d}\n"), "ob.yml") if err == nil { t.Fatal("an interval that overflows int64 nanoseconds was accepted") @@ -224,9 +224,9 @@ func TestAbsurdDrainDurationsAreRejected(t *testing.T) { "grace": "{grace: 100000d}", } { t.Run(name, func(t *testing.T) { - _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: root@10.0.0.1}}\n"+ - "workloads: {web: {image: nginx, domain: d.example.com, port: 8080, drain: "+drain+"}}\n"), "ob.yml") + "workloads: {web: {image: nginx, routes: [{hostname: d.example.com, port: 8080}], drain: "+drain+"}}\n"), "ob.yml") if err == nil { t.Fatalf("drain %s was accepted", drain) } diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index eb03f542..4474c5dc 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -22,7 +22,7 @@ import ( // SchemaID is both the schema identity and its stable, publicly retrievable // location. The main-branch path stays fixed across Onebox releases. -const SchemaID = "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.schema.json" +const SchemaID = "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json" // JSONSchema is the published contract, ready to write. func JSONSchema() ([]byte, error) { @@ -35,7 +35,7 @@ func JSONSchema() ([]byte, error) { } doc["$schema"] = "https://json-schema.org/draft/2020-12/schema" doc["$id"] = SchemaID - doc["title"] = "Onebox project (onebox.run/v1)" + doc["title"] = "Onebox project (onebox.run/v2)" doc["description"] = "One application, its workloads, the services it needs, and how a release rolls out." // The constraints the loader enforces, so the schema refuses what the @@ -344,7 +344,6 @@ var schemaConstraints = []struct { {[]string{"workloads", "*", "operator_run"}, enum(eJobOperatorRun)}, {[]string{"workloads", "*", "data_effect"}, enum(eDataEffect)}, {[]string{"workloads", "*", "compose"}, pattern(gComposeRef)}, - {[]string{"workloads", "*", "domain"}, pattern(gRouteHost)}, {[]string{"workloads", "*", "port"}, portBounds()}, {[]string{"workloads", "*", "working_dir"}, pattern(gAbsPath)}, {[]string{"workloads", "*", "env_files", "items", "file"}, pattern(gRepoPath)}, @@ -388,32 +387,49 @@ var schemaConstraints = []struct { {[]string{"workloads", "*", "resources", "cpus"}, pattern(gCpus)}, {[]string{"workloads", "*", "persistence", "mode"}, enum(ePersistence)}, {[]string{"workloads", "*", "routes", "items"}, map[string]any{ - "oneOf": []any{ - map[string]any{"required": []any{"domain"}, "not": map[string]any{"required": []any{"wildcard_suffix"}}}, - map[string]any{"required": []any{"wildcard_suffix"}, "not": map[string]any{"required": []any{"domain"}}}, - }, - "allOf": []any{map[string]any{ - "if": map[string]any{ - "required": []any{"domain"}, - "properties": map[string]any{"domain": map[string]any{"const": "*"}}, + "required": []any{"hostname"}, + "allOf": []any{ + map[string]any{ + "if": map[string]any{ + "required": []any{"hostname"}, + "properties": map[string]any{"hostname": map[string]any{"const": "*"}}, + }, + "then": map[string]any{ + "required": []any{"protocol", "tls"}, + "properties": map[string]any{ + "protocol": map[string]any{"const": "tcp"}, + "tls": map[string]any{"enum": []any{"none", "passthrough"}}, + }, + }, }, - "then": map[string]any{ - "required": []any{"protocol", "tls"}, - "properties": map[string]any{ - "protocol": map[string]any{"const": "tcp"}, - "tls": map[string]any{"enum": []any{"none", "passthrough"}}, + map[string]any{ + "if": map[string]any{ + "required": []any{"hostname"}, + "properties": map[string]any{"hostname": map[string]any{"pattern": `^\*\.`}}, + }, + "then": map[string]any{ + "properties": map[string]any{"protocol": map[string]any{"const": "http"}}, }, }, - }}, + map[string]any{ + "if": map[string]any{ + "required": []any{"tls"}, + "properties": map[string]any{"tls": map[string]any{"const": "passthrough"}}, + }, + "then": map[string]any{ + "required": []any{"protocol"}, + "properties": map[string]any{"protocol": map[string]any{"const": "tcp"}}, + }, + }, + }, }}, - {[]string{"workloads", "*", "routes", "items", "domain"}, map[string]any{"anyOf": []any{ - pattern(gRouteHost), + {[]string{"workloads", "*", "routes", "items", "hostname"}, map[string]any{"anyOf": []any{ + map[string]any{ + "pattern": gRouteHostname.pattern.String(), + "maxLength": 253, + }, map[string]any{"const": "*"}, }}}, - {[]string{"workloads", "*", "routes", "items", "wildcard_suffix"}, map[string]any{ - "pattern": gWildcardSuffix.pattern.String(), - "maxLength": 253, - }}, {[]string{"workloads", "*", "routes", "items", "path"}, pattern(gURLPath)}, {[]string{"workloads", "*", "routes", "items", "port"}, portBounds()}, {[]string{"workloads", "*", "routes", "items", "protocol"}, enum(eRouteProtocol)}, @@ -553,7 +569,7 @@ func applyRoleRules(doc map[string]any) { sources := []any{"build", "image", "compose"} doc["not"] = map[string]any{"allOf": []any{ map[string]any{"required": []any{"workloads"}}, - map[string]any{"anyOf": anyRequired(append(append([]any{}, sources...), "domain", "port", "health", "routes"))}, + map[string]any{"anyOf": anyRequired(append(append([]any{}, sources...), "port", "health", "routes"))}, }} // A project must describe something to run: a non-empty workloads block, @@ -583,20 +599,6 @@ func applyRoleRules(doc map[string]any) { // 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)}, - // The domain shorthand and the routes list say the same thing twice, - // and domain without a port does not say where to send the traffic. - map[string]any{"not": map[string]any{"allOf": []any{ - map[string]any{"anyOf": anyRequired([]any{"domain", "port"})}, - map[string]any{"required": []any{"routes"}}, - }}}, - map[string]any{ - "if": map[string]any{"required": []any{"domain"}}, - "then": map[string]any{"required": []any{"port"}}, - }, - map[string]any{ - "if": map[string]any{"required": []any{"port"}}, - "then": map[string]any{"required": []any{"domain"}}, - }, // A published host socket is singular, so it cannot be held by both // sides of a rolling handover. Include the authored default case: // application + health + no strategy is rolling after normalization. diff --git a/internal/app/jsonschema_test.go b/internal/app/jsonschema_test.go index 7d04b1ce..dc1501da 100644 --- a/internal/app/jsonschema_test.go +++ b/internal/app/jsonschema_test.go @@ -124,7 +124,7 @@ func TestPublishedSchemaAcceptsEveryRealProject(t *testing.T) { func TestPublishedSchemaRequiresExecutionStepIDAndCommand(t *testing.T) { schema := compiledSchema(t) for _, step := range []string{`{id: sync, command: [echo, ok]}`, `{command: [echo, ok]}`, `{id: sync}`, `{}`} { - y := "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads:\n sync:\n role: job\n image: busybox\n deployment_phase: none\n data_effect: none\n schedule: {cron: '0 * * * *'}\n execution:\n steps: [" + step + "]\n" + y := "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads:\n sync:\n role: job\n image: busybox\n deployment_phase: none\n data_effect: 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 { @@ -136,11 +136,11 @@ func TestPublishedSchemaRequiresExecutionStepIDAndCommand(t *testing.T) { func TestPublishedSchemaAcceptsAuthoredShorthand(t *testing.T) { schema := compiledSchema(t) for _, y := range []string{ - "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nimage: nginx\n", - "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nservices: {postgres: 17}\n", - "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, volumes: [{name: data, path: /data}], needs: [db], command: run}}\n", - "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nhooks: {post_deploy: \"echo done\"}\n", - "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nx-note: anything\n", + "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nimage: nginx\n", + "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nservices: {postgres: 17}\n", + "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, volumes: [{name: data, path: /data}], needs: [db], command: run}}\n", + "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nhooks: {post_deploy: \"echo done\"}\n", + "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nx-note: anything\n", } { if err := schema.Validate(asJSON(t, y)); err != nil { t.Errorf("authored shorthand rejected:\n%s\n%v", y, err) @@ -152,7 +152,7 @@ func TestPublishedSchemaAcceptsAuthoredShorthand(t *testing.T) { // completion and error support the schema exists to provide. func TestPublishedSchemaRefusesAnUndefinedField(t *testing.T) { schema := compiledSchema(t) - y := "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, replicaz: 3}}\n" + y := "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, replicaz: 3}}\n" if err := schema.Validate(asJSON(t, y)); err == nil { t.Error("the published schema accepted a field the contract does not define") } else if !strings.Contains(err.Error(), "replicaz") { @@ -162,7 +162,7 @@ func TestPublishedSchemaRefusesAnUndefinedField(t *testing.T) { func TestPublishedSchemaConstrainsProxyEntrypoints(t *testing.T) { schema := compiledSchema(t) - base := "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nproxy:\n entrypoints:\n" + base := "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nproxy:\n entrypoints:\n" for _, tc := range []struct { name string @@ -267,13 +267,13 @@ func TestCheckedInSchemaMatchesGenerator(t *testing.T) { t.Fatal(err) } want := append(append([]byte(nil), body...), '\n') - path := filepath.Join("..", "..", "docs", "onebox.run-v1.schema.json") + path := filepath.Join("..", "..", "docs", "onebox.run-v2.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 docs/onebox.run-v1.schema.json`", path) + t.Fatalf("%s is stale; regenerate it with `go run ./cmd/ob schema --out docs/onebox.run-v2.schema.json`", path) } } diff --git a/internal/app/jump_config_test.go b/internal/app/jump_config_test.go index 04a29818..70ea75f2 100644 --- a/internal/app/jump_config_test.go +++ b/internal/app/jump_config_test.go @@ -6,9 +6,9 @@ import ( ) func projectWithJump(jump string) string { - return "api_version: onebox.run/v1\napp: ledger\n" + + return "api_version: onebox.run/v2\napp: ledger\n" + "environments: {production: {server: root@10.20.0.10, jump: " + jump + "}}\n" + - "image: nginx\ndomain: ledger.example.com\nport: 8080\n" + "image: nginx\nroutes: [{hostname: ledger.example.com, port: 8080}]\n" } func TestScalarJumpExpandsToUserHostAndPort(t *testing.T) { diff --git a/internal/app/load.go b/internal/app/load.go index 108c42e0..e3cae4aa 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -16,7 +16,7 @@ import ( ) // APIVersion is the only authoring contract this package accepts. -const APIVersion = "onebox.run/v1" +const APIVersion = "onebox.run/v2" // 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 @@ -67,6 +67,18 @@ func Load(path string) (*Spec, error) { // LoadBytes runs the fixed pipeline: parse, expand, validate, then apply the // cross-field rules the schema cannot express. func LoadBytes(b []byte, filename string) (*Spec, error) { + return loadBytes(b, filename, false) +} + +// LoadReleaseSnapshotBytes loads an immutable project snapshot written by an +// earlier Onebox release. It accepts v1 only on this internal replay boundary, +// migrating its route representation in memory; normal project loading remains +// strictly v2 so an authored file can never opt into retired semantics. +func LoadReleaseSnapshotBytes(b []byte, filename string) (*Spec, error) { + return loadBytes(b, filename, true) +} + +func loadBytes(b []byte, filename string, allowV1Snapshot bool) (*Spec, error) { var raw map[string]any if err := yaml.Unmarshal(b, &raw); err != nil { return nil, errf("project_unparsable", filename, "", "invalid YAML: %v", firstLine(err.Error())) @@ -83,6 +95,13 @@ func LoadBytes(b []byte, filename string) (*Spec, error) { lines = lineIndex(&doc) } + legacyV1Snapshot := false + if allowV1Snapshot && raw["api_version"] == "onebox.run/v1" { + if err := migrateV1ReleaseSnapshot(raw); err != nil { + return nil, err + } + legacyV1Snapshot = true + } if err := checkAPIVersion(raw); err != nil { return nil, err } @@ -122,6 +141,7 @@ func LoadBytes(b []byte, filename string) (*Spec, error) { // because the enumeration then promises a failure that never fires. p.Dir = filepath.Dir(filename) p.file = filename + p.legacyV1Snapshot = legacyV1Snapshot if err := validateSpec(p); err != nil { return nil, err } @@ -147,7 +167,101 @@ func checkAPIVersion(raw map[string]any) error { } // shorthandKeys are the top-level fields that describe a single workload. -var shorthandKeys = []string{"build", "image", "compose", "port", "health", "domain", "routes"} +var shorthandKeys = []string{"build", "image", "compose", "port", "health", "routes"} + +func migrateV1ReleaseSnapshot(raw map[string]any) error { + raw["api_version"] = APIVersion + if err := migrateV1Workload(raw, "workload shorthand"); err != nil { + return err + } + workloads, ok := raw["workloads"].(map[string]any) + if ok { + for name, value := range workloads { + workload, ok := value.(map[string]any) + if !ok { + continue + } + if err := migrateV1Workload(workload, "workloads."+name); err != nil { + return err + } + } + } + environments, ok := raw["environments"].(map[string]any) + if !ok { + return nil + } + for environmentName, value := range environments { + environment, ok := value.(map[string]any) + if !ok { + continue + } + overrides, ok := environment["overrides"].(map[string]any) + if !ok { + continue + } + workloadOverrides, ok := overrides["workloads"].(map[string]any) + if !ok { + continue + } + for workloadName, patch := range workloadOverrides { + workload, ok := patch.(map[string]any) + if !ok { + continue + } + path := "environments." + environmentName + ".overrides.workloads." + workloadName + if err := migrateV1Workload(workload, path); err != nil { + return err + } + } + } + return nil +} + +func migrateV1Workload(workload map[string]any, path string) error { + domain, hasDomain := workload["domain"] + port, hasPort := workload["port"] + _, hasRoutes := workload["routes"] + if hasRoutes && (hasDomain || hasPort) { + return errf("project_invalid", path, "", "v1 snapshot declares both domain/port and routes") + } + if hasDomain != hasPort { + return errf("project_invalid", path, "", "v1 snapshot must declare domain and port together") + } + if hasDomain { + workload["routes"] = []any{map[string]any{"hostname": domain, "port": port}} + delete(workload, "domain") + return nil + } + routes, ok := workload["routes"].([]any) + if !ok { + return nil + } + for i, value := range routes { + route, ok := value.(map[string]any) + if !ok { + continue + } + domain, hasDomain := route["domain"] + suffix, hasSuffix := route["wildcard_suffix"] + _, hasHostname := route["hostname"] + if hasHostname || hasDomain == hasSuffix { + return errf("project_invalid", indexed(path+".routes", i), "", + "v1 snapshot route must declare exactly one of domain or wildcard_suffix") + } + if hasDomain { + route["hostname"] = domain + delete(route, "domain") + } else { + text, ok := suffix.(string) + if !ok { + return errf("project_invalid", indexed(path+".routes", i)+".wildcard_suffix", "", "v1 wildcard suffix must be a string") + } + route["hostname"] = "*." + text + delete(route, "wildcard_suffix") + } + } + return nil +} // expand rewrites shorthand into the normalised form the schema validates. It // runs before validation because the schema requires discriminators — a role @@ -450,16 +564,6 @@ func crossFieldRules(p *Spec) error { name, w.Replicas) } - hasScalar := w.Domain != "" || w.Port != 0 - if hasScalar && len(w.Routes) > 0 { - return errf("routing_exclusive", path, "", - "workload %q declares both the domain/port shorthand and routes; use one", name) - } - if (w.Domain == "") != (w.Port == 0) { - return errf("routing_incomplete", path, "", - "workload %q must declare domain and port together", name) - } - for i, n := range w.Needs { dep, isWorkload := p.Workloads[n.Name] _, isExternal := p.ExternalServices[n.Name] @@ -650,19 +754,19 @@ func routesOverlap(a, b Route) bool { if a.Entrypoint != b.Entrypoint || a.Protocol != b.Protocol || a.Path != b.Path { return false } - if a.WildcardSuffix != "" && b.WildcardSuffix != "" { - return a.WildcardSuffix == b.WildcardSuffix + if a.IsWildcard() && b.IsWildcard() { + return a.HostSuffix() == b.HostSuffix() } - if a.WildcardSuffix == "" && b.WildcardSuffix == "" { - if a.Domain == "*" || b.Domain == "*" { + if !a.IsWildcard() && !b.IsWildcard() { + if a.Hostname == "*" || b.Hostname == "*" { return true } - return canonicalRouteHost(a.Domain) == canonicalRouteHost(b.Domain) + return canonicalRouteHost(a.Hostname) == canonicalRouteHost(b.Hostname) } - if a.WildcardSuffix == "" { + if !a.IsWildcard() { a, b = b, a } - prefix, ok := strings.CutSuffix(canonicalRouteHost(b.Domain), "."+a.WildcardSuffix) + prefix, ok := strings.CutSuffix(canonicalRouteHost(b.Hostname), "."+a.HostSuffix()) return ok && prefix != "" && !strings.Contains(prefix, ".") } diff --git a/internal/app/load_test.go b/internal/app/load_test.go index f2bb043d..8ac008b3 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -8,11 +8,89 @@ import ( "testing" ) -const base = "api_version: onebox.run/v1\napp: ledger\nenvironments: {production: {server: root@1.2.3.4}}\n" -const min = base + "build: .\ndomain: ledger.example.com\nport: 8080\n" +const base = "api_version: onebox.run/v2\napp: ledger\nenvironments: {production: {server: root@1.2.3.4}}\n" +const min = base + "build: .\nroutes: [{hostname: ledger.example.com, port: 8080}]\n" func wl(body string) string { return base + "workloads: {" + body + "}\n" } +func TestAPIVersionV2IsRequired(t *testing.T) { + _, err := LoadBytes([]byte(strings.Replace(min, APIVersion, "onebox.run/v1", 1)), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "schema_identity_unsupported") { + t.Fatalf("v1 project must be rejected with a version error: %v", err) + } +} + +func TestReleaseSnapshotLoaderMigratesV1RoutesOnlyAtReplayBoundary(t *testing.T) { + legacy := `api_version: onebox.run/v1 +app: ledger +environments: {production: {server: root@1.2.3.4}} +image: nginx +domain: Example.COM. +port: 8080 +` + p, err := LoadReleaseSnapshotBytes([]byte(legacy), "/var/lib/ob/ledger/releases/old/ob.snapshot.yml") + if err != nil { + t.Fatalf("load v1 release snapshot: %v", err) + } + routes := p.Workloads["ledger"].NormalisedRoutes() + if p.APIVersion != APIVersion || len(routes) != 1 || routes[0].Hostname != "Example.COM." || routes[0].Port != 8080 { + t.Fatalf("migrated snapshot = version %q, routes %+v", p.APIVersion, routes) + } + resolved, err := p.Resolve("production") + if err != nil { + t.Fatalf("resolve migrated v1 snapshot: %v", err) + } + if got := resolved.Workloads["ledger"].Routes[0].Hostname; got != "Example.COM." { + t.Fatalf("resolved legacy hostname = %q", got) + } + + wildcard := `api_version: onebox.run/v1 +app: ledger +environments: + production: + server: root@1.2.3.4 + overrides: + workloads: + web: + routes: [{wildcard_suffix: branch.example.com, path: /, port: 8081, entrypoint: websecure, protocol: http, scheme: http, tls: none}] +workloads: + web: + image: nginx + routes: [{wildcard_suffix: preview.example.com, port: 8080, tls: none}] +` + p, err = LoadReleaseSnapshotBytes([]byte(wildcard), "ob.snapshot.yml") + if err != nil { + t.Fatalf("load v1 wildcard snapshot: %v", err) + } + if got := p.Workloads["web"].Routes[0].Hostname; got != "*.preview.example.com" { + t.Fatalf("migrated wildcard hostname = %q", got) + } + resolved, err = p.Resolve("production") + if err != nil { + t.Fatalf("resolve v1 wildcard override: %v", err) + } + if got := resolved.Workloads["web"].Routes[0].Hostname; got != "*.branch.example.com" { + t.Fatalf("migrated wildcard override hostname = %q", got) + } + maxV1Suffix := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + + strings.Repeat("c", 63) + "." + strings.Repeat("d", 61) + maxWildcard := strings.Replace(wildcard, "preview.example.com", maxV1Suffix, 1) + if _, err := LoadReleaseSnapshotBytes([]byte(maxWildcard), "ob.snapshot.yml"); err != nil { + t.Fatalf("v1 snapshot wildcard at the v1 suffix limit: %v", err) + } + + for name, body := range map[string]string{ + "unknown version": strings.Replace(legacy, "onebox.run/v1", "onebox.run/v0", 1), + "incomplete route": strings.Replace(legacy, "port: 8080\n", "", 1), + } { + t.Run(name, func(t *testing.T) { + if _, err := LoadReleaseSnapshotBytes([]byte(body), "ob.snapshot.yml"); err == nil { + t.Fatal("invalid legacy snapshot was accepted") + } + }) + } +} + func TestRoutedProjectRefusesDefaultAsProxyNetwork(t *testing.T) { for _, network := range []string{"default", "ledger_default", "ob_ledger"} { t.Run(network, func(t *testing.T) { @@ -53,7 +131,7 @@ func TestManagedGeneratedProxyRequiresDeclaredRouteEntrypoint(t *testing.T) { project := base + `workloads: grpc: image: app:1 - routes: [{domain: grpc.example.com, port: 4317, entrypoint: otlp-grpc, scheme: h2c}] + routes: [{hostname: grpc.example.com, port: 4317, entrypoint: otlp-grpc, scheme: h2c}] ` if _, err := LoadBytes([]byte(project), "ob.yml"); err == nil || !strings.Contains(err.Error(), "proxy.entrypoints") { t.Fatalf("an unknown generated entrypoint must be refused: %v", err) @@ -67,7 +145,7 @@ func TestWildcardRouteContract(t *testing.T) { valid := base + `workloads: web: image: nginx - routes: [{wildcard_suffix: preview.example.com, port: 8080}] + routes: [{hostname: "*.preview.example.com", port: 8080}] proxy: config: traefik dns_challenge: {provider: cloudflare, resolvers: ["1.1.1.1:53"]} @@ -81,17 +159,16 @@ proxy: body string want string }{ - {"missing dns challenge", wl("web: {image: nginx, routes: [{wildcard_suffix: example.com, port: 80}] }"), "dns_challenge"}, - {"both host forms", wl("web: {image: nginx, routes: [{domain: api.example.com, wildcard_suffix: example.com, port: 80}] }"), "exactly one"}, - {"neither host form", wl("web: {image: nginx, routes: [{port: 80}] }"), "exactly one"}, - {"bare wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: '*', port: 80, tls: none}] }"), "DNS hostname"}, - {"embedded wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: '*.example.com', port: 80, tls: none}] }"), "DNS hostname"}, - {"uppercase suffix", wl("web: {image: nginx, routes: [{wildcard_suffix: Example.com, port: 80, tls: none}] }"), "lower-case"}, - {"tcp wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: example.com, port: 80, protocol: tcp, tls: passthrough}] }"), "only for HTTP"}, - {"exact matcher injection", wl("web: {image: nginx, routes: [{domain: 'x`) || Host(`*', port: 80}] }"), "route host"}, - {"wildcard in exact route", wl("web: {image: nginx, routes: [{domain: '*.example.com', port: 80}] }"), "wildcard_suffix"}, - {"wildcard in exact shorthand", wl("web: {image: nginx, domain: '*.example.com', port: 80}"), "wildcard_suffix"}, - {"catch-all exact route", wl("web: {image: nginx, routes: [{domain: '*', port: 80}] }"), "wildcard_suffix"}, + {"missing dns challenge", wl("web: {image: nginx, routes: [{hostname: '*.example.com', port: 80}] }"), "dns_challenge"}, + {"missing hostname", wl("web: {image: nginx, routes: [{port: 80}] }"), "hostname"}, + {"bare wildcard", wl("web: {image: nginx, routes: [{hostname: '*', port: 80, tls: none}] }"), "route hostname"}, + {"embedded wildcard", wl("web: {image: nginx, routes: [{hostname: 'api.*.example.com', port: 80, tls: none}] }"), "route hostname"}, + {"partial wildcard", wl("web: {image: nginx, routes: [{hostname: 'api*.example.com', port: 80, tls: none}] }"), "route hostname"}, + {"uppercase hostname", wl("web: {image: nginx, routes: [{hostname: Example.com, port: 80, tls: none}] }"), "lower-case"}, + {"trailing dot", wl("web: {image: nginx, routes: [{hostname: example.com., port: 80, tls: none}] }"), "route hostname"}, + {"tcp wildcard", wl("web: {image: nginx, routes: [{hostname: '*.example.com', port: 80, protocol: tcp, tls: passthrough}] }"), "only for HTTP"}, + {"matcher injection", wl("web: {image: nginx, routes: [{hostname: 'x`) || Host(`*', port: 80}] }"), "route hostname"}, + {"catch-all HTTP route", wl("web: {image: nginx, routes: [{hostname: '*', port: 80}] }"), "route hostname"}, {"dns challenge needs config", min + "proxy: {dns_challenge: {provider: cloudflare}}\n", "proxy.config"}, {"invalid resolver", min + "proxy: {config: traefik, dns_challenge: {provider: cloudflare, resolvers: [1.1.1.1]}}\n", "host:port"}, {"unmanaged dns challenge", min + "proxy: {managed: false, config: traefik, dns_challenge: {provider: cloudflare}}\n", "managed proxy"}, @@ -103,15 +180,16 @@ proxy: } }) } - for _, domain := range []string{"API.Example.COM", "api.example.com."} { - if _, err := LoadBytes([]byte(wl("web: {image: nginx, routes: [{domain: '"+domain+"', port: 80, tls: none}] }")), "ob.yml"); err != nil { - t.Errorf("existing exact route spelling %q must remain valid: %v", domain, err) + for _, hostname := range []string{"api.example.com", "*.example.com"} { + project := wl("web: {image: nginx, routes: [{hostname: '" + hostname + "', port: 80, tls: none}] }") + if _, err := LoadBytes([]byte(project), "ob.yml"); err != nil { + t.Errorf("standard hostname spelling %q must be valid: %v", hostname, err) } } - if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{domain: '*', protocol: tcp, tls: none, port: 9000}] }")), "ob.yml"); err != nil { + if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{hostname: '*', protocol: tcp, tls: none, port: 9000}] }")), "ob.yml"); err != nil { t.Errorf("existing plaintext TCP catch-all must remain valid: %v", err) } - if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{domain: '*', protocol: tcp, tls: passthrough, port: 9000}] }")), "ob.yml"); err != nil { + if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{hostname: '*', protocol: tcp, tls: passthrough, port: 9000}] }")), "ob.yml"); err != nil { t.Errorf("existing TLS-passthrough TCP catch-all must remain valid: %v", err) } } @@ -126,12 +204,11 @@ func TestWildcardRouteOverlap(t *testing.T) { right string collides bool }{ - {"immediate child", "{domain: shop.example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true}, - {"same wildcard", "{wildcard_suffix: example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true}, - {"case-insensitive exact child", "{domain: Shop.Example.COM., port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true}, - {"apex does not overlap", "{domain: example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", false}, - {"nested host does not overlap", "{domain: a.b.example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", false}, - {"different path", "{domain: shop.example.com, path: /api, port: 80, tls: none}", "{wildcard_suffix: example.com, path: /, port: 81, tls: none}", false}, + {"immediate child", "{hostname: shop.example.com, port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", true}, + {"same wildcard", "{hostname: '*.example.com', port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", true}, + {"apex does not overlap", "{hostname: example.com, port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", false}, + {"nested host does not overlap", "{hostname: a.b.example.com, port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", false}, + {"different path", "{hostname: shop.example.com, path: /api, port: 80, tls: none}", "{hostname: '*.example.com', path: /, port: 81, tls: none}", false}, } { t.Run(tc.name, func(t *testing.T) { _, err := LoadBytes([]byte(project(tc.left, tc.right)), "ob.yml") @@ -146,8 +223,8 @@ func TestWildcardRouteOverlap(t *testing.T) { } func TestPlaintextTCPCatchAllOverlapsEveryHost(t *testing.T) { - catchAll := Route{Domain: "*", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"} - exact := Route{Domain: "db.example.com", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"} + catchAll := Route{Hostname: "*", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"} + exact := Route{Hostname: "db.example.com", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"} if !routesOverlap(catchAll, exact) || !routesOverlap(exact, catchAll) { t.Fatal("plaintext TCP catch-all must collide with every exact host on the same route address") } @@ -168,13 +245,13 @@ 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", "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: h}}\nimage: nginx\n", true}, - {"app starting ob-", "api_version: onebox.run/v1\napp: ob-app\nenvironments: {p: {server: h}}\nimage: nginx\n", false}, - {"host proxy name", "api_version: onebox.run/v1\napp: onebox-proxy\nenvironments: {p: {server: h}}\nimage: nginx\n", false}, - {"underscore identifier", "api_version: onebox.run/v1\napp: my_app\nenvironments: {p: {server: h}}\nimage: nginx\n", false}, + {"one-char identifier", "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: h}}\nimage: nginx\n", true}, + {"app starting ob-", "api_version: onebox.run/v2\napp: ob-app\nenvironments: {p: {server: h}}\nimage: nginx\n", false}, + {"host proxy name", "api_version: onebox.run/v2\napp: onebox-proxy\nenvironments: {p: {server: h}}\nimage: nginx\n", false}, + {"underscore identifier", "api_version: onebox.run/v2\napp: my_app\nenvironments: {p: {server: h}}\nimage: nginx\n", false}, {"unknown top-level field", min + "bogus: 1\n", false}, {"x- extension accepted", min + "x-note: anything\n", true}, - {"port out of range", base + "image: nginx\ndomain: d\nport: 70000\n", false}, + {"port out of range", base + "image: nginx\nhostname: d\nport: 70000\n", false}, {"zero replicas", wl("w: {image: nginx, replicas: 0}"), false}, {"job requires data_effect", wl("j: {image: nginx, role: job}"), false}, {"job with data_effect", wl("j: {image: nginx, role: job, data_effect: none}"), true}, @@ -186,27 +263,28 @@ func conformanceCases() []conformanceCase { {"scheduled job run policy", wl("j: {image: nginx, role: job, data_effect: none, schedule: {cron: \"0 4 * * *\", timeout: 45m, catch_up: false}}"), true}, {"scheduled job invalid timeout", wl("j: {image: nginx, role: job, data_effect: none, schedule: {cron: \"0 4 * * *\", timeout: forever}}"), false}, {"daemon role", wl("db: {image: postgres:16, role: daemon}"), true}, - {"routes list", wl("w: {image: nginx, routes: [{domain: x, port: 4317, protocol: tcp, scheme: h2c}]}"), true}, - {"provider-qualified route middlewares", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file, rate-limit@file]}]}") + "proxy: {config: traefik}\n", true}, - {"unqualified route middleware", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth]}]}"), false}, - {"repeated route middleware remains ordered", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file, auth@file]}]}") + "proxy: {config: traefik}\n", true}, - {"managed route middleware without proxy config", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file]}]}"), false}, - {"operator proxy owns route middleware", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file]}]}") + "proxy: {managed: false}\n", true}, - {"bad protocol", wl("w: {image: nginx, routes: [{domain: x, port: 1, protocol: udp}]}"), false}, + {"routes list", wl("w: {image: nginx, routes: [{hostname: x, port: 4317, protocol: tcp, scheme: h2c}]}"), true}, + {"provider-qualified route middlewares", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file, rate-limit@file]}]}") + "proxy: {config: traefik}\n", true}, + {"unqualified route middleware", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth]}]}"), false}, + {"repeated route middleware remains ordered", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file, auth@file]}]}") + "proxy: {config: traefik}\n", true}, + {"managed route middleware without proxy config", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file]}]}"), false}, + {"operator proxy owns route middleware", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file]}]}") + "proxy: {managed: false}\n", true}, + {"bad protocol", wl("w: {image: nginx, routes: [{hostname: x, port: 1, protocol: udp}]}"), false}, + {"http route with TLS passthrough", wl("w: {image: nginx, routes: [{hostname: x, port: 443, tls: passthrough}]}"), false}, {"absolute compose ref", wl("w: {compose: \"/etc/compose.yml#web\"}"), false}, {"relative compose ref", wl("w: {compose: \"compose.yaml#web\"}"), true}, {"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", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {migrations: {backup_max_age: 14d}}}}\n", true}, - {"non-calver minimum version", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_onebox_version: 0.0.1-m0}}}\n", false}, - {"incomplete plan schema", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_plan_schema: \"onebox.run/executable-deploy-plan/v1alpha\"}}}\n", false}, + {"duration in days", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {migrations: {backup_max_age: 14d}}}}\n", true}, + {"non-calver minimum version", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_onebox_version: 0.0.1-m0}}}\n", false}, + {"incomplete plan schema", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_plan_schema: \"onebox.run/executable-deploy-plan/v1alpha\"}}}\n", false}, {"hook with local", min + "hooks: {pre_release: {run: scripts/build.sh, local: true}}\n", true}, // A hook key is a lifecycle seam OR a declared job name. Both halves need // 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", "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: h}}\nhooks: {migrate: {run: ./bin/migrate}}\nworkloads:\n w: {role: application, image: nginx}\n migrate: {role: job, image: nginx, data_effect: migration}\n", true}, + {"hook naming a declared job", "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: h}}\nhooks: {migrate: {run: ./bin/migrate}}\nworkloads:\n w: {role: application, image: nginx}\n migrate: {role: job, image: nginx, data_effect: migration}\n", true}, {"hook naming neither", min + "hooks: {typo_hook: {run: scripts/x.sh}}\n", false}, // A settings key is interpolated into a generated shell command without // quoting, so the grammar is the only thing between a project file and @@ -240,7 +318,7 @@ func conformanceCases() []conformanceCase { {"unknown enum: strategy", wl("w: {image: nginx, health: /h, strategy: sideways}"), false}, {"unknown enum: role", wl("w: {image: nginx, role: sidecar}"), false}, {"unknown enum: data_effect", wl("j: {image: nginx, role: job, data_effect: maybe}"), false}, - {"unknown enum: route protocol", wl("w: {image: nginx, routes: [{domain: x, port: 1, protocol: quic}]}"), false}, + {"unknown enum: route protocol", wl("w: {image: nginx, routes: [{hostname: x, port: 1, protocol: quic}]}"), false}, {"unknown enum: persistence mode", wl("w: {image: nginx, persistence: {mode: sometimes}}"), false}, // Several problems at once. What matters is that the refusal is // deterministic: an author fixing one thing at a time must not see the @@ -250,7 +328,7 @@ func conformanceCases() []conformanceCase { {"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", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, env_files: [.env.p]}}\n", true}, + {"environment-scoped env files", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, env_files: [.env.p]}}\n", true}, {"http check without a path", min + "checks: {http: [{workload: ledger}]}\n", false}, {"url check carrying an exec field", min + "checks: {url: [{url: \"https://x/\", run: \"echo\"}]}\n", false}, {"url check with contains and advisory", min + "checks: {url: [{url: \"https://x/\", contains: \" 0 { - return w.Routes - } - if w.Domain == "" { - return nil - } - return []Route{{ - Domain: w.Domain, Port: w.Port, Path: "/", - Entrypoint: "websecure", Protocol: "http", Scheme: "http", TLS: "terminate", - }} -} - -// NormalisedRoutes returns the workload's routes with the scalar shorthand -// expanded, so callers never handle two shapes. -func (w Workload) NormalisedRoutes() []Route { return routesOf(w) } - -// HostPattern returns the host matcher value represented by the route. A -// wildcard suffix is deliberately expanded here rather than accepted as an -// authored matcher expression, so no regular expression reaches Traefik. -func (r Route) HostPattern() string { - if r.WildcardSuffix != "" { - return "*." + r.WildcardSuffix - } - return r.Domain -} +// NormalisedRoutes returns the workload's routes. It remains the single +// accessor used by rendering and validation so route normalization can evolve +// without spreading representation knowledge through the codebase. +func (w Workload) NormalisedRoutes() []Route { return w.Routes } + +// HostPattern returns the validated hostname matcher represented by the route. +// Authored regular expressions never reach Traefik. +func (r Route) HostPattern() string { return r.Hostname } + +// IsWildcard reports whether the hostname begins with the complete wildcard +// label accepted by the project contract. +func (r Route) IsWildcard() bool { return strings.HasPrefix(r.Hostname, "*.") } + +// HostSuffix returns the exact suffix below a wildcard label. +func (r Route) HostSuffix() string { return strings.TrimPrefix(r.Hostname, "*.") } // HasTerminatingTLS reports whether the resolved project needs the managed // proxy's certificate resolver. Passthrough routes carry TLS without asking @@ -447,7 +434,7 @@ func (p *Spec) HasExactTerminatingTLS() bool { } for _, w := range p.Workloads { for _, route := range w.NormalisedRoutes() { - if route.WildcardSuffix == "" && route.TLS == "terminate" { + if !route.IsWildcard() && route.TLS == "terminate" { return true } } @@ -463,7 +450,7 @@ func (p *Spec) HasWildcardTerminatingTLS() bool { } for _, workload := range p.Workloads { for _, route := range workload.NormalisedRoutes() { - if route.WildcardSuffix != "" && route.TLS == "terminate" { + if route.IsWildcard() && route.TLS == "terminate" { return true } } diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 7a21fae4..9ce2ed33 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -const namesFixture = `api_version: onebox.run/v1 +const namesFixture = `api_version: onebox.run/v2 app: ledger environments: production: {server: root@1.2.3.4} @@ -17,8 +17,8 @@ workloads: image: nginx replicas: 3 routes: - - {domain: ledger.example.com, port: 8080} - - {domain: api.ledger.example.com, port: 8080} + - {hostname: ledger.example.com, port: 8080} + - {hostname: api.ledger.example.com, port: 8080} volumes: [{name: uploads, path: /var/lib/ledger/uploads}, {source: ./seed, path: /seed, mode: ro}] worker: role: worker @@ -225,9 +225,9 @@ func TestRuntimeContainerDerivationIsInjective(t *testing.T) { } } -// TestScalarRoutingNormalises: the domain/port shorthand becomes one route with -// documented defaults, so generation never sees two shapes. -func TestScalarRoutingNormalises(t *testing.T) { +// TestNormalisedRoutesReturnsDeclaredRoutes keeps generation behind one route +// accessor even though the public contract now has only the explicit list form. +func TestNormalisedRoutesReturnsDeclaredRoutes(t *testing.T) { p, err := LoadBytes([]byte(min), "ob.yml") if err != nil { t.Fatal(err) @@ -237,7 +237,7 @@ func TestScalarRoutingNormalises(t *testing.T) { t.Fatalf("got %d routes, want 1", len(routes)) } r := routes[0] - if r.Domain != "ledger.example.com" || r.Port != 8080 || r.Path != "/" || + if r.Hostname != "ledger.example.com" || r.Port != 8080 || r.Path != "/" || r.Protocol != "http" || r.Scheme != "http" || r.TLS != "terminate" { t.Fatalf("normalised route = %+v", r) } diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go index b91a5a5d..5933c865 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -60,7 +60,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 := `api_version: onebox.run/v1 + body := `api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} @@ -70,10 +70,10 @@ workloads: image: nginx health: /healthz routes: - - {domain: shop.example.com, path: /, port: 3000, middlewares: [compress@file, secure-headers@file]} - - {domain: shop.example.com, path: /api, port: 3001} - - {domain: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c} - - {domain: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg, middlewares: [office-only@file]} + - {hostname: shop.example.com, path: /, port: 3000, middlewares: [compress@file, secure-headers@file]} + - {hostname: shop.example.com, path: /api, port: 3001} + - {hostname: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c} + - {hostname: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg, middlewares: [office-only@file]} proxy: {config: traefik} ` r, err := loadText(t, body).Resolve("production") @@ -124,14 +124,14 @@ proxy: {config: traefik} } func TestRouteMiddlewareOrderPreservesRepetition(t *testing.T) { - body := `api_version: onebox.run/v1 + body := `api_version: onebox.run/v2 app: shop environments: {production: {server: root@203.0.113.10}} workloads: web: image: nginx routes: - - {domain: shop.example.com, port: 3000, middlewares: [prefix@file, auth@file, prefix@file]} + - {hostname: shop.example.com, port: 3000, middlewares: [prefix@file, auth@file, prefix@file]} proxy: {managed: false} ` r, err := loadText(t, body).Resolve("production") diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index 25bbac11..820f8106 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -59,7 +59,7 @@ func TestPreflightRefusesForeignHostOwner(t *testing.T) { } } -const preflightProject = `api_version: onebox.run/v1 +const preflightProject = `api_version: onebox.run/v2 app: ledger environments: production: {server: root@1.2.3.4} @@ -67,8 +67,8 @@ workloads: web: role: application image: nginx - domain: ledger.example.com - port: 8080 + routes: + - {hostname: ledger.example.com, port: 8080} volumes: [{name: uploads, path: /var/lib/ledger/uploads}] ` @@ -356,15 +356,15 @@ func TestInterpolationEnvUsesComposeSemantics(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "ob.yml") - if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1 + if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} runtime: env_files: [.env] image: nginx -domain: shop.example.com -port: 3000 +routes: + - {hostname: shop.example.com, port: 3000} `), 0o600); err != nil { t.Fatal(err) } @@ -413,7 +413,7 @@ func TestPreflightResolvesAcrossDeclaredFilesInOrder(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "ob.yml") - if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1 + if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2 app: shop environments: production: {server: root@203.0.113.10} @@ -423,8 +423,8 @@ runtime: - file: .env.production require: [API_TOKEN] image: nginx -domain: shop.example.com -port: 3000 +routes: + - {hostname: shop.example.com, port: 3000} `), 0o600); err != nil { t.Fatal(err) } @@ -574,7 +574,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 := "api_version: onebox.run/v1\napp: sample\nenvironments:\n \"" + name + + src := "api_version: onebox.run/v2\napp: sample\nenvironments:\n \"" + name + "\": {server: root@h}\nworkloads:\n web: {role: application, image: x:1}\n" if _, err := LoadBytes([]byte(src), "ob.yml"); err == nil { t.Fatalf("environment name %q was accepted by the loader but cannot round-trip the owner record", name) @@ -582,7 +582,7 @@ func TestEnvironmentNamesMustSurviveTheOwnerRecord(t *testing.T) { } // And the ones that are legal stay legal. for _, name := range []string{"production", "staging", "prod-east"} { - src := "api_version: onebox.run/v1\napp: sample\nenvironments:\n " + name + + src := "api_version: onebox.run/v2\napp: sample\nenvironments:\n " + name + ": {server: root@h}\nworkloads:\n web: {role: application, image: x:1}\n" if _, err := LoadBytes([]byte(src), "ob.yml"); err != nil { t.Fatalf("environment name %q should be accepted: %v", name, err) diff --git a/internal/app/purity_test.go b/internal/app/purity_test.go index 30ecc7f7..114926e7 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 = `api_version: onebox.run/v1 +const purityProject = `api_version: onebox.run/v2 app: shop environments: production: @@ -35,10 +35,10 @@ workloads: health: /healthz replicas: 2 routes: - - {domain: shop.example.com, path: /, port: 3000} - - {domain: shop.example.com, path: /api, port: 3001} - - {domain: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c} - - {domain: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg} + - {hostname: shop.example.com, path: /, port: 3000} + - {hostname: shop.example.com, path: /api, port: 3001} + - {hostname: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c} + - {hostname: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg} worker: role: worker image: nginx:1.27 @@ -198,12 +198,12 @@ func TestGenerationCannotReachATarget(t *testing.T) { // connect to production. func TestEveryGenerationFailureIsReachableOffline(t *testing.T) { for name, body := range map[string]string{ - "unknown field": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nreplicaz: 3\n", - "no source": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application}}\n", - "two sources": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application, image: nginx, build: .}}\n", - "job without effect": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {j: {role: job, image: nginx}}\n", - "unknown driver": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nservices: {weird: {driver: nosuchthing, version: \"1\"}}\n", - "route collision": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads:\n a: {role: application, image: nginx, domain: x.example.com, port: 1}\n b: {role: application, image: nginx, domain: x.example.com, port: 2}\n", + "unknown field": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nreplicaz: 3\n", + "no source": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application}}\n", + "two sources": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application, image: nginx, build: .}}\n", + "job without effect": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {j: {role: job, image: nginx}}\n", + "unknown driver": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nservices: {weird: {driver: nosuchthing, version: \"1\"}}\n", + "route collision": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads:\n a: {role: application, image: nginx, routes: [{hostname: x.example.com, port: 1}]}\n b: {role: application, image: nginx, routes: [{hostname: x.example.com, port: 2}]}\n", } { t.Run(name, func(t *testing.T) { dir := t.TempDir() diff --git a/internal/app/resolve.go b/internal/app/resolve.go index 4ffb916e..96a89f44 100644 --- a/internal/app/resolve.go +++ b/internal/app/resolve.go @@ -304,6 +304,7 @@ func (p *Spec) deepCopy() (*Spec, error) { } out.Dir = p.Dir out.file = p.file + out.legacyV1Snapshot = p.legacyV1Snapshot // Without these a resolved project has no memory of what was authored, and // would report every value as a default. out.rawExpanded = p.rawExpanded diff --git a/internal/app/resolve_test.go b/internal/app/resolve_test.go index 4d40d3da..a1a45bb8 100644 --- a/internal/app/resolve_test.go +++ b/internal/app/resolve_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -const overrideFixture = `api_version: onebox.run/v1 +const overrideFixture = `api_version: onebox.run/v2 app: ledger environments: production: @@ -116,7 +116,7 @@ func TestResolveDoesNotLeakBetweenEnvironments(t *testing.T) { } func TestRouteMiddlewareOverrideRequiresManagedProxyConfig(t *testing.T) { - body := `api_version: onebox.run/v1 + body := `api_version: onebox.run/v2 app: shop environments: production: {server: root@prod} @@ -125,11 +125,11 @@ environments: overrides: workloads: web: - routes: [{domain: shop.example.com, path: /, port: 3000, entrypoint: websecure, protocol: http, scheme: http, tls: terminate, middlewares: [auth@file]}] + routes: [{hostname: shop.example.com, path: /, port: 3000, entrypoint: websecure, protocol: http, scheme: http, tls: terminate, middlewares: [auth@file]}] workloads: web: image: nginx - routes: [{domain: shop.example.com, port: 3000}] + routes: [{hostname: shop.example.com, port: 3000}] ` p, err := LoadBytes([]byte(body), "ob.yml") if err != nil { @@ -245,7 +245,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 := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: production: {server: root@h} @@ -281,7 +281,7 @@ services: {postgres: 17} // 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 := `api_version: onebox.run/v1 + yaml := `api_version: onebox.run/v2 app: a environments: production: diff --git a/internal/app/route_test.go b/internal/app/route_test.go index 93a63300..b63b6c19 100644 --- a/internal/app/route_test.go +++ b/internal/app/route_test.go @@ -68,9 +68,9 @@ 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 := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + resolved, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: root@10.20.0.10:2222}}\n"+ - "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml") + "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml") if err != nil { t.Fatal(err) } @@ -94,9 +94,9 @@ func TestScalarServerPortReachesTheRoute(t *testing.T) { func TestBracketedIPv6ScalarNormalisesLikeTheObjectForm(t *testing.T) { load := func(server string) Environment { t.Helper() - resolved, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + resolved, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: "+server+"}}\n"+ - "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml") + "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml") if err != nil { t.Fatal(err) } @@ -121,9 +121,9 @@ func TestBracketedIPv6ScalarNormalisesLikeTheObjectForm(t *testing.T) { // 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 := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + resolved, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: {host: \"[2001:db8::1]\", user: root}}}\n"+ - "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml") + "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml") if err != nil { t.Fatal(err) } @@ -146,9 +146,9 @@ func TestInvalidServerAddressIsRejectedAtLoad(t *testing.T) { } for name, server := range invalid { t.Run(name, func(t *testing.T) { - _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+ + _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+ "environments: {production: {server: "+server+"}}\n"+ - "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml") + "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml") if err == nil { t.Fatalf("server %q was accepted", server) } diff --git a/internal/app/schedule_test.go b/internal/app/schedule_test.go index 3dc250fa..c613bd6e 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 := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -91,7 +91,7 @@ workloads: } func TestPinnedScheduleEligibilityFailsClosed(t *testing.T) { - valid := `api_version: onebox.run/v1 + valid := `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -120,7 +120,7 @@ workloads: } func TestScheduledJobRunPolicyIsExplicitAndValidated(t *testing.T) { - spec, err := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -141,7 +141,7 @@ workloads: t.Fatalf("authored run policy was not preserved: %#v", jobs) } - bad := `api_version: onebox.run/v1 + bad := `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -153,7 +153,7 @@ workloads: } func TestScheduledJobRetryAndNotifyResolveWithDefaults(t *testing.T) { - spec, err := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -206,7 +206,7 @@ func TestScheduledJobRetryIsBoundedByTheTimeout(t *testing.T) { "unknown notify": {`{cron: "0 * * * *", notify: [warning]}`, "project_invalid"}, } { t.Run(name, func(t *testing.T) { - _, err := LoadBytes([]byte(`api_version: onebox.run/v1 + _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -227,7 +227,7 @@ workloads: } func TestJobInputsValidateNamesConstraintsAndDefaults(t *testing.T) { - base := `api_version: onebox.run/v1 + base := `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -268,7 +268,7 @@ workloads: } }) } - if _, err := LoadBytes([]byte(`api_version: onebox.run/v1 + if _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -310,7 +310,7 @@ func TestValidateJobInputValuesChecksOverrides(t *testing.T) { } func TestScheduledJobInputDefaultsRenderIntoTheComposeEnvironment(t *testing.T) { - spec, err := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: diff --git a/internal/app/secrets_graph_test.go b/internal/app/secrets_graph_test.go index d31b1f18..967ceb32 100644 --- a/internal/app/secrets_graph_test.go +++ b/internal/app/secrets_graph_test.go @@ -20,7 +20,7 @@ func secretGraphProject(t *testing.T, body string) *Resolved { func TestSecretDeclarationGraphCapturesOrderScopeAndAffectedWorkloads(t *testing.T) { resolved := secretGraphProject(t, ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} runtime: @@ -43,7 +43,7 @@ workloads: func TestSecretDeclarationIDsAreStableAndValueFree(t *testing.T) { resolved := secretGraphProject(t, ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} runtime: {env_files: [{file: secrets.enc.env, provider: sops}]} @@ -58,7 +58,7 @@ workloads: {web: {role: application, image: nginx}} func TestSecretDeclarationGraphChangesForEveryRuntimeRelevantDrift(t *testing.T) { base := ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} runtime: @@ -81,14 +81,14 @@ workloads: body string }{ {name: "reordered", body: ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} runtime: {env_files: [{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: ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} runtime: {env_files: [{file: first.enc.env, provider: sops}, {file: second.enc.env, provider: sops}]} @@ -97,14 +97,14 @@ workloads: worker: {role: worker, image: nginx} `}, {name: "provider removed", body: ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} runtime: {env_files: [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: ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} runtime: {env_files: [{file: first.enc.env, provider: sops}, {file: second.enc.env, provider: sops}]} @@ -122,7 +122,7 @@ workloads: {web: {role: application, image: nginx}} func TestSecretDeclarationGraphIncludesSortedExternalProjection(t *testing.T) { resolved := secretGraphProject(t, ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example}} workloads: diff --git a/internal/app/service_extensions_test.go b/internal/app/service_extensions_test.go index d26a2c2f..a456b6a9 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, `api_version: onebox.run/v1 + rendered := renderServices(t, `api_version: onebox.run/v2 app: goal environments: {production: {server: root@host}} workloads: @@ -66,7 +66,7 @@ func TestProtectedPostgresMustAdoptTheOneboxImageBeforeExtensions(t *testing.T) } func TestPostgresExtensionsDerivePreloadAndCronSettings(t *testing.T) { - rendered := renderServices(t, `api_version: onebox.run/v1 + rendered := renderServices(t, `api_version: onebox.run/v2 app: goal environments: {production: {server: root@host}} workloads: @@ -99,7 +99,7 @@ services: } func TestServiceExtensionsArePostgresOnly(t *testing.T) { - _, err := LoadBytes([]byte(`api_version: onebox.run/v1 + _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: root@host}} workloads: @@ -116,7 +116,7 @@ services: } func TestPostgresExtensionsRequireThePublishedImageVersion(t *testing.T) { - _, err := LoadBytes([]byte(`api_version: onebox.run/v1 + _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: root@host}} workloads: @@ -132,7 +132,7 @@ services: } func TestServiceExtensionNamesAreSafeSQLIdentifiers(t *testing.T) { - _, err := LoadBytes([]byte(`api_version: onebox.run/v1 + _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: root@host}} workloads: @@ -154,7 +154,7 @@ func TestPgCronSettingsCannotDisableTheManagedContract(t *testing.T) { "cron.database_name: elsewhere", "cron.use_background_workers: off", } { - _, err := LoadBytes([]byte(`api_version: onebox.run/v1 + _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: root@host}} workloads: diff --git a/internal/app/services_test.go b/internal/app/services_test.go index 57186500..1c4031fe 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 := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: @@ -88,7 +88,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 := LoadBytes([]byte(`api_version: onebox.run/v1 + _, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: {web: {role: application, image: x:1}} @@ -184,7 +184,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 := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: n8n environments: {production: {server: root@h}} workloads: @@ -343,7 +343,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, `api_version: onebox.run/v1 + rendered := renderServices(t, `api_version: onebox.run/v2 app: sample environments: {production: {server: root@h}} workloads: @@ -406,7 +406,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, "api_version: onebox.run/v1\napp: sample\n"+ + rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+ "environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\n"+ "services:\n "+svc+":\n version: \""+version+"\"\n persistence: {mode: ephemeral}\n") if strings.Contains(string(rendered[svc]), "_"+svc+"_data") { @@ -422,7 +422,7 @@ func TestDurableRedisKeepsItsVolumeAndAppendOnlyLog(t *testing.T) { " redis: {version: 8-alpine}\n", " redis: {version: 8-alpine, persistence: {mode: durable}}\n", } { - rendered := renderServices(t, "api_version: onebox.run/v1\napp: sample\n"+ + rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+ "environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\nservices:\n"+decl) doc := string(rendered["redis"]) if !strings.Contains(doc, "_redis_data") { @@ -438,7 +438,7 @@ func TestDurableRedisKeepsItsVolumeAndAppendOnlyLog(t *testing.T) { // intends to read back. func TestEphemeralRedisFamilyDisablesBothPersistenceMechanisms(t *testing.T) { for _, svc := range []string{"redis", "valkey"} { - rendered := renderServices(t, "api_version: onebox.run/v1\napp: sample\n"+ + rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+ "environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\n"+ "services:\n "+svc+": {version: 8-alpine, persistence: {mode: ephemeral}}\n") doc := string(rendered[svc]) @@ -455,7 +455,7 @@ func TestEphemeralRedisFamilyDisablesBothPersistenceMechanisms(t *testing.T) { // 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, `api_version: onebox.run/v1 + rendered := renderServices(t, `api_version: onebox.run/v2 app: sample environments: {production: {server: root@h}} workloads: @@ -485,7 +485,7 @@ services: // downgrade on the one mode that says the data matters. func TestOnlyEphemeralDisablesServerPersistence(t *testing.T) { for _, mode := range []string{"durable", "external"} { - rendered := renderServices(t, "api_version: onebox.run/v1\napp: sample\n"+ + rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+ "environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\n"+ "services:\n redis: {version: 8-alpine, persistence: {mode: "+mode+"}}\n") doc := string(rendered["redis"]) @@ -508,7 +508,7 @@ func TestOnlyEphemeralDisablesServerPersistence(t *testing.T) { // 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 := `api_version: onebox.run/v1 + src := `api_version: onebox.run/v2 app: sample environments: {production: {server: root@h}} workloads: diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index 3f28f5ae..c90af7ed 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -114,16 +114,6 @@ "loads": false, "code": "stateful_replicas" }, - { - "case": "conformance/domain and routes together", - "loads": false, - "code": "routing_exclusive" - }, - { - "case": "conformance/domain without port", - "loads": false, - "code": "routing_incomplete" - }, { "case": "conformance/duration in days", "loads": true, @@ -194,6 +184,11 @@ "loads": false, "code": "project_invalid" }, + { + "case": "conformance/http route with TLS passthrough", + "loads": false, + "code": "project_invalid" + }, { "case": "conformance/image reference with registry port", "loads": true, @@ -312,7 +307,7 @@ { "case": "conformance/port out of range", "loads": false, - "code": "project_invalid" + "code": "unknown_field" }, { "case": "conformance/provider-qualified route middlewares", diff --git a/internal/app/testdata/corpus/README.md b/internal/app/testdata/corpus/README.md index c4539ff7..2fdcda85 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/v1`, used to freeze the contract: +Real projects, authored against `onebox.run/v2`, 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 eba704a7..a1072e23 100644 --- a/internal/app/testdata/corpus/ext-authentik-managed.yml +++ b/internal/app/testdata/corpus/ext-authentik-managed.yml @@ -12,7 +12,7 @@ # The secret key is encrypted and the rest of the configuration is not, so the # frozen digest also covers a list mixing a plaintext entry with an encrypted # one — including the name the runtime references for the decrypted file. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: authentik environments: production: {server: root@example.com} @@ -26,8 +26,8 @@ workloads: strategy: recreate image: ghcr.io/goauthentik/server:2026.5.6 command: [server] - domain: auth.example.com - port: 9000 + routes: + - {hostname: auth.example.com, port: 9000} needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}] published_ports: [{host: 9000, container: 9000}, {host: 9443, container: 9443}] volumes: diff --git a/internal/app/testdata/corpus/ext-authentik.yml b/internal/app/testdata/corpus/ext-authentik.yml index c00a9491..016b18bc 100644 --- a/internal/app/testdata/corpus/ext-authentik.yml +++ b/internal/app/testdata/corpus/ext-authentik.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: authentik environments: production: {server: root@example.com} @@ -7,8 +7,8 @@ workloads: role: application image: ghcr.io/goauthentik/server:2026.5.6 command: [server] - domain: auth.example.com - port: 9000 + routes: + - {hostname: auth.example.com, port: 9000} needs: [{name: postgresql, condition: healthy}] env_files: [.env] published_ports: [{host: 9000, container: 9000}, {host: 9443, container: 9443}] diff --git a/internal/app/testdata/corpus/ext-frigate.yml b/internal/app/testdata/corpus/ext-frigate.yml index c9834fbc..3777b6ce 100644 --- a/internal/app/testdata/corpus/ext-frigate.yml +++ b/internal/app/testdata/corpus/ext-frigate.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: frigate environments: production: {server: root@example.com} @@ -8,8 +8,8 @@ workloads: # devices, privileged, shm_size and the tmpfs mount are not expressible in # the declaration and are carried by the Compose reference instead. compose: docker-compose.yml#frigate - domain: frigate.example.com - port: 8971 + routes: + - {hostname: frigate.example.com, port: 8971} drain: {grace: 30s} published_ports: - {host: 8971, container: 8971} diff --git a/internal/app/testdata/corpus/ext-gitea.yml b/internal/app/testdata/corpus/ext-gitea.yml index 8a37f67b..d2c2b63c 100644 --- a/internal/app/testdata/corpus/ext-gitea.yml +++ b/internal/app/testdata/corpus/ext-gitea.yml @@ -1,5 +1,5 @@ # Deployed and verified against managed services on a throwaway host. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: gitea environments: production: {server: root@example.com} @@ -8,8 +8,8 @@ workloads: role: application strategy: recreate image: docker.gitea.com/gitea:1.27.1 - domain: git.example.com - port: 3000 + routes: + - {hostname: git.example.com, port: 3000} health: {http: /api/healthz, port: 3000, interval: 5s, start_period: 20s, within: 240s} published_ports: [{host: 222, container: 22, bind: "0.0.0.0"}] volumes: [{name: data, path: /data}] diff --git a/internal/app/testdata/corpus/ext-immich-sourced.yml b/internal/app/testdata/corpus/ext-immich-sourced.yml index 4edaa82f..0d301901 100644 --- a/internal/app/testdata/corpus/ext-immich-sourced.yml +++ b/internal/app/testdata/corpus/ext-immich-sourced.yml @@ -12,7 +12,7 @@ # that decide what the container reads — the referenced service's own # `env_file`, the project's declared entries, and the referenced service's own # `environment` — plus a managed service supplying a connection file. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: immich environments: production: {server: root@example.com} @@ -24,8 +24,8 @@ workloads: immich-server: role: application compose: ext-immich-sourced.compose.yaml#immich-server - domain: photos.example.com - port: 2283 + routes: + - {hostname: photos.example.com, port: 2283} needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}] immich-machine-learning: role: worker diff --git a/internal/app/testdata/corpus/ext-immich.yml b/internal/app/testdata/corpus/ext-immich.yml index 72050ea6..a0bebc50 100644 --- a/internal/app/testdata/corpus/ext-immich.yml +++ b/internal/app/testdata/corpus/ext-immich.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: immich environments: production: {server: root@example.com} @@ -6,8 +6,8 @@ workloads: server: role: application image: ghcr.io/immich-app/immich-server:v1.119.0 - domain: photos.example.com - port: 2283 + routes: + - {hostname: photos.example.com, port: 2283} needs: [{name: redis, condition: healthy}, {name: database, condition: healthy}] env_files: [.env] volumes: [{name: upload, path: /data}] diff --git a/internal/app/testdata/corpus/ext-n8n.yml b/internal/app/testdata/corpus/ext-n8n.yml index f2b66be3..32d4f823 100644 --- a/internal/app/testdata/corpus/ext-n8n.yml +++ b/internal/app/testdata/corpus/ext-n8n.yml @@ -1,6 +1,6 @@ # Deployed and verified: n8n ran its migrations against the managed Postgres # and served /healthz, reading the connection under its own variable names. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: n8n environments: production: @@ -8,8 +8,8 @@ environments: workloads: n8n: role: application - domain: n8n.example.com - port: 5678 + routes: + - {hostname: n8n.example.com, port: 5678} image: docker.n8n.io/n8nio/n8n:1.70.0 health: {http: /healthz, port: 5678, interval: 3s, start_period: 10s, within: 180s} volumes: [{name: storage, path: /home/node/.n8n}] diff --git a/internal/app/testdata/corpus/ext-paperless.yml b/internal/app/testdata/corpus/ext-paperless.yml index 62874a66..6661b73f 100644 --- a/internal/app/testdata/corpus/ext-paperless.yml +++ b/internal/app/testdata/corpus/ext-paperless.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: paperless environments: production: {server: root@example.com} @@ -6,8 +6,8 @@ workloads: webserver: role: application image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7 - domain: paperless.example.com - port: 8000 + routes: + - {hostname: paperless.example.com, port: 8000} # Bare names: the loader waits for health where the dependency declares a # check and for start where it does not. gotenberg and tika declare none. needs: [db, broker, gotenberg, tika] diff --git a/internal/app/testdata/corpus/ext-plausible.yml b/internal/app/testdata/corpus/ext-plausible.yml index 53574fe8..39c3f071 100644 --- a/internal/app/testdata/corpus/ext-plausible.yml +++ b/internal/app/testdata/corpus/ext-plausible.yml @@ -1,5 +1,5 @@ # Deployed and verified against managed services on a throwaway host. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: plausible environments: production: {server: root@example.com} @@ -7,8 +7,8 @@ workloads: plausible: role: application image: ghcr.io/plausible/community-edition:v3.0.1 - domain: stats.example.com - port: 8000 + routes: + - {hostname: stats.example.com, port: 8000} health: {exec: ["/bin/sh", "-c", "wget -qO- http://127.0.0.1:8000/api/health || exit 1"], interval: 5s, start_period: 40s, within: 300s} volumes: [{name: data, path: /var/lib/plausible}] env: diff --git a/internal/app/testdata/corpus/ext-umami.yml b/internal/app/testdata/corpus/ext-umami.yml index 2e8cd008..af7f7ccd 100644 --- a/internal/app/testdata/corpus/ext-umami.yml +++ b/internal/app/testdata/corpus/ext-umami.yml @@ -1,5 +1,5 @@ # Deployed and verified against managed services on a throwaway host. -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: umami environments: production: {server: root@example.com} @@ -7,8 +7,8 @@ workloads: umami: role: application image: ghcr.io/umami-software/umami:postgresql-v2.19.0 - domain: analytics.example.com - port: 3000 + routes: + - {hostname: analytics.example.com, port: 3000} health: {exec: ["/usr/local/bin/node", "-e", "fetch('http://127.0.0.1:3000/api/heartbeat').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"], interval: 5s, start_period: 20s, within: 240s} env: {APP_SECRET: throwaway-secret-for-this-test} needs: diff --git a/internal/app/testdata/corpus/goal.yml b/internal/app/testdata/corpus/goal.yml index 51880cfa..77d0804c 100644 --- a/internal/app/testdata/corpus/goal.yml +++ b/internal/app/testdata/corpus/goal.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: goal environments: production: @@ -10,8 +10,8 @@ workloads: image: ghcr.io/labstack/goal-server:2026.8.1 replicas: 3 strategy: rolling - domain: goal.fit - port: 7510 + routes: + - {hostname: goal.fit, port: 7510} health: {http: /healthz, port: 7510, interval: 5s, start_period: 15s, within: 2m, retries: 5} drain: {signal: TERM, grace: 30s} env: {ENVIRONMENT: production, OTEL_SERVICE_NAME: goal-server} diff --git a/internal/app/testdata/corpus/monk.yml b/internal/app/testdata/corpus/monk.yml index 10754e23..45c22695 100644 --- a/internal/app/testdata/corpus/monk.yml +++ b/internal/app/testdata/corpus/monk.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: monk environments: production: @@ -10,8 +10,8 @@ workloads: image: ghcr.io/labstack/monk-server:2026.8.1 replicas: 3 strategy: rolling - domain: monk.trade - port: 7500 + routes: + - {hostname: monk.trade, port: 7500} health: exec: "curl --fail --silent --show-error http://127.0.0.1:7500/healthz" interval: 10s diff --git a/internal/app/testdata/corpus/pursue.yml b/internal/app/testdata/corpus/pursue.yml index 56fbc7da..ae48d015 100644 --- a/internal/app/testdata/corpus/pursue.yml +++ b/internal/app/testdata/corpus/pursue.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: pursue environments: production: @@ -10,8 +10,8 @@ workloads: image: ghcr.io/labstack/pursue-server:2026.8.1 replicas: 1 strategy: rolling - domain: pursue.run - port: 8080 + routes: + - {hostname: pursue.run, port: 8080} health: {http: /healthz, port: 8080, interval: 2s, within: 2m, retries: 3} drain: {signal: TERM, wait: 2s, grace: 30s} migrate: diff --git a/internal/app/testdata/corpus/recast.yml b/internal/app/testdata/corpus/recast.yml index e5490c98..43ab2b89 100644 --- a/internal/app/testdata/corpus/recast.yml +++ b/internal/app/testdata/corpus/recast.yml @@ -1,4 +1,4 @@ -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: recast environments: production: @@ -15,8 +15,8 @@ workloads: image: ghcr.io/labstack/recast-server:2026.8.1 replicas: 1 strategy: rolling - domain: recast.report - port: 8080 + routes: + - {hostname: recast.report, port: 8080} health: {http: /healthz, port: 8080, interval: 2s, within: 2m, retries: 3} drain: {signal: TERM, wait: 2s, grace: 30s} migrate: diff --git a/internal/app/types.go b/internal/app/types.go index 2edacaf5..94647655 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -1,4 +1,4 @@ -// Package app loads the onebox.run/v1 declarative authoring contract: one +// Package app loads the onebox.run/v2 declarative authoring contract: one // application, its workloads, the services it needs, and how a release rolls // out. // @@ -28,8 +28,12 @@ type Spec struct { // file is the exact project path supplied to Load/LoadBytes. Mutating // operations such as eject must never reconstruct it as Dir/ob.yml. file string + // legacyV1Snapshot is set only while replaying an immutable release created + // by a v1 binary. It preserves the validation rules that release originally + // passed without reopening v1 as an authoring contract. + legacyV1Snapshot bool - APIVersion string `json:"api_version" description:"Project contract version. Must be onebox.run/v1." example:"onebox.run/v1"` + APIVersion string `json:"api_version" description:"Project contract version. Must be onebox.run/v2." example:"onebox.run/v2"` // Name is the application's name. Spelled Name rather than App because // inside a package called app, `spec.App` is a stutter and every caller // then writes `.App.App`. The authored key is still `app:`. @@ -142,8 +146,7 @@ type Workload struct { Replicas int `json:"replicas" description:"Desired number of long-running workload containers." default:"1" example:"2"` Strategy string `json:"strategy,omitempty" description:"Replacement strategy for a changed or uncertain workload. An unchanged healthy workload is retained automatically. Defaults to rolling only for an application workload with health; all other workloads default to recreate."` - Domain string `json:"domain,omitempty" description:"Domain shorthand for one HTTPS route; requires port and cannot be combined with routes." example:"shop.example.com"` - Port int `json:"port,omitempty" description:"Container port used with domain shorthand and as the default HTTP health port." example:"3000"` + Port int `json:"port,omitempty" description:"Default container port used by HTTP health checks." example:"3000"` Routes []Route `json:"routes,omitempty" description:"Ingress routes exposed by this workload."` Health *Health `json:"health,omitempty" description:"Readiness check used to gate rolling replacement."` @@ -213,15 +216,14 @@ type Image struct { } type Route struct { - Domain string `json:"domain,omitempty" description:"Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix." example:"shop.example.com"` - WildcardSuffix string `json:"wildcard_suffix,omitempty" description:"DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain." example:"preview.example.com"` - Path string `json:"path" description:"URL path prefix matched by an HTTP route." default:"/"` - Port int `json:"port" description:"Container port receiving routed traffic." example:"3000"` - Entrypoint string `json:"entrypoint" description:"Named proxy listener used for the route." default:"websecure"` - Protocol string `json:"protocol" description:"Routing protocol: http, tcp, or udp." default:"http"` - Scheme string `json:"scheme" description:"Backend connection scheme: http, https, h2c, tcp, or udp." default:"http"` - TLS string `json:"tls" description:"TLS handling: terminate, passthrough, or none." default:"terminate"` - Middlewares []MiddlewareRef `json:"middlewares,omitempty" description:"Ordered provider-qualified middleware references applied to this route."` + Hostname string `json:"hostname" description:"Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes." example:"shop.example.com"` + Path string `json:"path" description:"URL path prefix matched by an HTTP route." default:"/"` + Port int `json:"port" description:"Container port receiving routed traffic." example:"3000"` + Entrypoint string `json:"entrypoint" description:"Named proxy listener used for the route." default:"websecure"` + Protocol string `json:"protocol" description:"Routing protocol: http or tcp." default:"http"` + Scheme string `json:"scheme" description:"Backend connection scheme for HTTP routes: http, https, or h2c." default:"http"` + TLS string `json:"tls" description:"TLS handling: terminate, passthrough, or none." default:"terminate"` + Middlewares []MiddlewareRef `json:"middlewares,omitempty" description:"Ordered provider-qualified middleware references applied to this route."` } // MiddlewareRef names dynamic proxy configuration without opening the diff --git a/internal/app/validate.go b/internal/app/validate.go index 839fef6e..5e59e405 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -59,7 +59,7 @@ func validateSpec(p *Spec) error { if err := gIdent.check("workloads."+name, name); err != nil { return err } - if err := validateWorkload(p.Workloads[name], "workloads."+name); err != nil { + if err := validateWorkload(p.Workloads[name], "workloads."+name, p.legacyV1Snapshot); err != nil { return err } } @@ -248,7 +248,7 @@ func validateEnvironment(e Environment, path string) error { return gCalVer.checkOptional(path+".policy.min_onebox_version", e.Policy.MinOneboxVersion) } -func validateWorkload(w Workload, path string) error { +func validateWorkload(w Workload, path string, legacyV1Snapshot bool) error { if err := validateJobExecution(w, path); err != nil { return err } @@ -290,34 +290,26 @@ func validateWorkload(w Workload, path string) error { return err } } - if w.Domain != "" && w.Port != 0 { - if err := gRouteHost.check(path+".domain", w.Domain); err != nil { - return err - } + if w.Port != 0 { if err := checkPort(path+".port", w.Port); err != nil { return err } } for i, r := range w.Routes { rp := indexed(path+".routes", i) - if (r.Domain == "") == (r.WildcardSuffix == "") { - return errf("project_invalid", rp, "", "a route must declare exactly one of domain or wildcard_suffix") - } - if r.Domain != "" { - if r.Domain == "*" && r.Protocol == "tcp" && (r.TLS == "none" || r.TLS == "passthrough") { - // HostSNI(`*`) is Traefik's TCP catch-all for plaintext and - // TLS passthrough. It predates wildcard HTTP routes and remains - // the one intentional exception to exact-host syntax. - } else if err := gRouteHost.check(rp+".domain", r.Domain); err != nil { - return err - } + if r.Hostname == "" { + return errf("project_invalid", rp+".hostname", "", "a route must declare hostname") } - if r.WildcardSuffix != "" { - if err := validateWildcardSuffix(rp+".wildcard_suffix", r.WildcardSuffix); err != nil { - return err - } + if r.Hostname == "*" && r.Protocol == "tcp" && (r.TLS == "none" || r.TLS == "passthrough") { + // HostSNI(`*`) is Traefik's TCP catch-all for plaintext and + // TLS passthrough. It predates wildcard HTTP routes and remains + // the one intentional exception to exact-host syntax. + } else if err := validateRouteHostname(rp+".hostname", r.Hostname, legacyV1Snapshot); err != nil { + return err + } + if r.IsWildcard() { if r.Protocol != "http" { - return errf("project_invalid", rp+".wildcard_suffix", "", "wildcard_suffix is supported only for HTTP routes") + return errf("project_invalid", rp+".hostname", "", "wildcard hostnames are supported only for HTTP routes") } } if err := gURLPath.check(rp+".path", r.Path); err != nil { @@ -514,11 +506,22 @@ func validateWorkload(w Workload, path string) error { return nil } -func validateWildcardSuffix(path, value string) error { +func validateRouteHostname(path, value string, legacyV1Snapshot bool) error { + if legacyV1Snapshot { + if !strings.HasPrefix(value, "*.") { + return gLegacyRouteHost.check(path, value) + } + // v1 measured wildcard_suffix without the authored "*." marker. Keep + // that exact bound when replaying a release that already passed v1. + if len(strings.TrimPrefix(value, "*.")) > 253 { + return errf("project_invalid", path, "", "%q is not a DNS hostname: its suffix exceeds 253 characters", value) + } + return gRouteHostname.check(path, value) + } if len(value) > 253 { return errf("project_invalid", path, "", "%q is not a DNS hostname: it exceeds 253 characters", value) } - return gWildcardSuffix.check(path, value) + return gRouteHostname.check(path, value) } func validateHealth(h *Health, path string) error { diff --git a/internal/app/workload_contract_test.go b/internal/app/workload_contract_test.go index f880b664..0442f3ae 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 := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -94,7 +94,7 @@ func TestSecretInputRevisionsUseTheProvidedSnapshot(t *testing.T) { if err := os.WriteFile(path, []byte("cipher-before"), 0o600); err != nil { t.Fatal(err) } - spec, err := LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: diff --git a/internal/engine/backup_identity_test.go b/internal/engine/backup_identity_test.go index 3ade3012..58a333d3 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 = `api_version: onebox.run/v1 +const protectedPostgresProject = `api_version: onebox.run/v2 app: shop environments: production: {server: deploy@example.net} diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go index d2e24cfd..1573f887 100644 --- a/internal/engine/deploy_test.go +++ b/internal/engine/deploy_test.go @@ -23,7 +23,7 @@ import ( const guardedHealthcheck = `["CMD-SHELL","[ -f /tmp/ob-drain ] \u0026\u0026 exit 1; curl -fsS 'http://127.0.0.1:80/'"]` const enginePreviousFrontendProject = ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: production: diff --git a/internal/engine/fixtures_test.go b/internal/engine/fixtures_test.go index 667d90d6..4575610e 100644 --- a/internal/engine/fixtures_test.go +++ b/internal/engine/fixtures_test.go @@ -21,7 +21,7 @@ const ( // that assembles the struct directly can assert on a shape the loader would // never produce. const engineProject = ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: production: diff --git a/internal/engine/host_environment_test.go b/internal/engine/host_environment_test.go index 1a86cbe7..79d1c1e0 100644 --- a/internal/engine/host_environment_test.go +++ b/internal/engine/host_environment_test.go @@ -114,7 +114,7 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) { // host owner record. func TestEnvironmentSelectsTheBasePath(t *testing.T) { spec, err := app.LoadBytes([]byte(` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample base_path: /var/lib/ob environments: diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go index 926e28e2..19d84fd4 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -55,8 +55,7 @@ func TestEnsureProxyRefusesMissingResolverBeforeHostMutation(t *testing.T) { } cfg := testConfig() web := cfg.Workloads["web"] - web.Domain = "app.example.com" - web.Port = 7500 + web.Routes = []app.Route{{Hostname: "app.example.com", Port: 7500, Path: "/", Entrypoint: "websecure", Protocol: "http", Scheme: "http", TLS: "terminate"}} cfg.Workloads["web"] = web cfg.Proxy = app.Proxy{Kind: "traefik-docker", Managed: true, Config: "traefik"} f := &transport.Fake{} diff --git a/internal/engine/recovery.go b/internal/engine/recovery.go index 534eb944..2d5a8aa2 100644 --- a/internal/engine/recovery.go +++ b/internal/engine/recovery.go @@ -44,7 +44,7 @@ func (e *Engine) engineFromReleaseSnapshotFor(ctx context.Context, releaseID, op return nil, fmt.Errorf("%s refused: release %s snapshot is empty", operation, releaseID) } - snapshot, err := app.LoadBytes([]byte(res.Stdout), path) + snapshot, err := app.LoadReleaseSnapshotBytes([]byte(res.Stdout), path) if err != nil { return nil, fmt.Errorf("%s refused: release %s snapshot unusable: %w", operation, releaseID, err) } diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go index d5c5a84f..f89fb944 100644 --- a/internal/engine/recovery_test.go +++ b/internal/engine/recovery_test.go @@ -49,6 +49,37 @@ func recoveryWriter(engine *Engine) *journal.Writer { return &journal.Writer{T: engine.T, Names: engine.Names(), DeployID: engineTestDeployReleaseID, Epoch: 2} } +func TestLifecycleReplayLoadsV1ReleaseSnapshot(t *testing.T) { + target := happyFake() + base := target.Dynamic + target.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "/releases/legacy/ob.snapshot.yml") { + return transport.Result{Stdout: `api_version: onebox.run/v1 +app: sample +environments: + production: + server: deploy@h + overrides: + workloads: + sample: + routes: [{domain: override.example.com, path: /, port: 8081, entrypoint: websecure, protocol: http, scheme: http, tls: none}] +image: nginx +routes: [{domain: Example.COM., port: 8080}] +`}, true + } + return base(command) + } + engine := New(testConfig(), testProject(t), target, Options{Environment: "production"}) + replay, err := engine.engineFromReleaseSnapshotFor(context.Background(), "legacy", "retired-workload cleanup") + if err != nil { + t.Fatalf("load v1 lifecycle snapshot: %v", err) + } + routes := replay.Spec.Workloads["sample"].Routes + if len(routes) != 1 || routes[0].Hostname != "override.example.com" || routes[0].Port != 8081 { + t.Fatalf("replayed routes = %+v", routes) + } +} + func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing.T) { target := happyFake() verifyCalls := 0 diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go index 065e52f1..3167c1ca 100644 --- a/internal/engine/resume_test.go +++ b/internal/engine/resume_test.go @@ -390,7 +390,7 @@ func TestAbortUsesInterruptedExpandOnlyPolicyAfterConfigEdit(t *testing.T) { } const interruptedWebSnapshot = ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: { production: { server: deploy@h } } workloads: diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go index 8a2a7ef9..d8c4cd37 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 = `api_version: onebox.run/v1 +const rollingGenerationProject = `api_version: onebox.run/v2 app: shop base_path: /srv/onebox environments: @@ -23,7 +23,7 @@ workloads: web: image: nginx port: 3000 - domain: shop.example.com + hostname: shop.example.com health: {exec: ["/health"]} env_files: [{file: web.enc.env, provider: sops}] worker: diff --git a/internal/engine/secret_generation_test.go b/internal/engine/secret_generation_test.go index 3eeff3da..239546a0 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 = `api_version: onebox.run/v1 +const generationProject = `api_version: onebox.run/v2 app: shop base_path: /srv/onebox environments: @@ -22,7 +22,7 @@ workloads: web: image: nginx port: 3000 - domain: shop.example.com + hostname: shop.example.com env_files: [{file: web.enc.env, provider: sops}] worker: role: worker diff --git a/internal/engine/secretspush_test.go b/internal/engine/secretspush_test.go index 8df773e7..6be31561 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 = `api_version: onebox.run/v1 +const secretGraphProject = `api_version: onebox.run/v2 app: shop environments: production: {server: deploy@example.invalid} @@ -22,7 +22,7 @@ workloads: web: image: nginx port: 3000 - domain: shop.example.com + hostname: shop.example.com env_files: - {file: first.enc.env, provider: sops} - {file: second.enc.env, provider: sops} diff --git a/internal/engine/verify_injection_test.go b/internal/engine/verify_injection_test.go index 20f22515..4709e6b6 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(` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: production: diff --git a/internal/onebox/bootstrap_test.go b/internal/onebox/bootstrap_test.go index 41a8e788..7b7a2368 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 = `api_version: onebox.run/v1 +const bootstrapBuildProject = `api_version: onebox.run/v2 app: demo environments: {production: {server: deploy@example.invalid}} runtime: diff --git a/internal/onebox/exec_test.go b/internal/onebox/exec_test.go index 1fecad41..870b69a8 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 = `api_version: onebox.run/v1 +const execProjectYAML = `api_version: onebox.run/v2 app: shop environments: production: @@ -23,7 +23,7 @@ workloads: api: image: nginx port: 3000 - domain: shop.example.com + hostname: shop.example.com ` func execService(t *testing.T, connect Connector) *Service { diff --git a/internal/onebox/jump_route_test.go b/internal/onebox/jump_route_test.go index 171f738d..6d77317e 100644 --- a/internal/onebox/jump_route_test.go +++ b/internal/onebox/jump_route_test.go @@ -16,15 +16,15 @@ func writeJumpProject(t *testing.T) string { dir := t.TempDir() path := filepath.Join(dir, "ob.yml") body := ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: demo environments: production: server: deploy@example.invalid jump: bastion@jump.invalid:2222 image: ghcr.io/example/app:v1 -domain: demo.example.com -port: 8080 +routes: + - {hostname: demo.example.com, port: 8080} ` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) @@ -83,15 +83,15 @@ func TestChangingOnlyTheJumpChangesTheBinding(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ob.yml") body := ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: demo environments: production: server: deploy@example.invalid jump: ` + jump + ` image: ghcr.io/example/app:v1 -domain: demo.example.com -port: 8080 +routes: + - {hostname: demo.example.com, port: 8080} ` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) diff --git a/internal/onebox/load_service_runtime_test.go b/internal/onebox/load_service_runtime_test.go index 765ba60a..1f485c75 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 := `api_version: onebox.run/v1 + body := `api_version: onebox.run/v2 app: example environments: {production: {server: root@host}} workloads: {web: {image: nginx:1}} diff --git a/internal/onebox/operation_graph_test.go b/internal/onebox/operation_graph_test.go index 443f0b2f..9d832edd 100644 --- a/internal/onebox/operation_graph_test.go +++ b/internal/onebox/operation_graph_test.go @@ -82,7 +82,7 @@ func TestDeploymentGraphNeverContainsHookBodies(t *testing.T) { func TestDeploymentGraphOmitsAbsentHooksAndJobs(t *testing.T) { t.Parallel() spec, err := app.LoadBytes([]byte(` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: root@h}} workloads: @@ -118,7 +118,7 @@ func TestDeploymentClassificationDoesNotOverstateFirstDeployRollback(t *testing. func operationGraphConfig() *app.Resolved { spec, err := app.LoadBytes([]byte(` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: sample environments: {production: {server: root@h}} workloads: diff --git a/internal/onebox/secrets_push_test.go b/internal/onebox/secrets_push_test.go index 868e4fac..ec34ca13 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 = `api_version: onebox.run/v1 +const pushProjectYAML = `api_version: onebox.run/v2 app: shop environments: production: @@ -21,7 +21,7 @@ workloads: web: image: nginx port: 3000 - domain: shop.example.com + hostname: shop.example.com env_files: [{file: api.enc.env, provider: sops}] jobs: role: worker @@ -172,11 +172,11 @@ 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(`api_version: onebox.run/v1 + if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`api_version: onebox.run/v2 app: shop environments: {production: {server: deploy@example.invalid}} workloads: - web: {image: nginx, port: 3000, domain: shop.example.com} + web: {image: nginx, routes: [{hostname: shop.example.com, port: 3000}]} `), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/onebox/service_test.go b/internal/onebox/service_test.go index 827873e7..bc2eae19 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": ` -api_version: onebox.run/v1 +api_version: onebox.run/v2 app: demo environments: production: @@ -164,7 +164,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": `api_version: onebox.run/v1 + "ob.yml": `api_version: onebox.run/v2 app: demo environments: {production: {server: deploy@example.invalid}} workloads: @@ -232,7 +232,7 @@ func TestPlanDeployUsesDeployedSecretGraphDuringTransition(t *testing.T) { if workerSecret { workerEnv = "\n env_files: [{file: worker.enc.env, provider: sops}]" } - return `api_version: onebox.run/v1 + return `api_version: onebox.run/v2 app: demo environments: production: {server: deploy@example.invalid} diff --git a/internal/onebox/staging_secrets_test.go b/internal/onebox/staging_secrets_test.go index 1a295127..9370516b 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", `api_version: onebox.run/v1 + write("ob.yml", `api_version: onebox.run/v2 app: shop environments: production: @@ -50,7 +50,7 @@ workloads: web: image: nginx port: 3000 - domain: shop.example.com + hostname: shop.example.com volumes: - {source: ., path: /app, mode: ro} env_files: @@ -252,7 +252,7 @@ func TestExternalServiceConnectionIsProjectedLeastPrivilegeIntoRelease(t *testin if err := os.WriteFile(filepath.Join(dir, "secrets", "database.env"), []byte(secret), 0o600); err != nil { t.Fatal(err) } - project := `api_version: onebox.run/v1 + project := `api_version: onebox.run/v2 app: shop environments: {production: {server: root@h}} workloads: diff --git a/internal/onebox/workload_contract_test.go b/internal/onebox/workload_contract_test.go index f6df03d6..914121b0 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(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -83,7 +83,7 @@ func TestWorkloadContractsTrackRelativeBindMountContent(t *testing.T) { t.Fatal(err) } } - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -121,7 +121,7 @@ deployment: {order: [api, worker]} } func TestBindMountContractIsIndependentOfWhereTheReleaseIsStaged(t *testing.T) { - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -157,7 +157,7 @@ deployment: {order: [api]} func TestWorkloadContractsIgnoreVolumesOnAnAdoptedComposeService(t *testing.T) { staging := t.TempDir() - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -177,7 +177,7 @@ deployment: {order: [api]} } func TestBindMountContractNoticesAnAddedEmptyDirectory(t *testing.T) { - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -216,7 +216,7 @@ deployment: {order: [api]} func bindMountRevision(t *testing.T, mode os.FileMode) string { t.Helper() - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: diff --git a/internal/onebox/workload_plan_test.go b/internal/onebox/workload_plan_test.go index 89fa5df3..ea305623 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(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: @@ -163,7 +163,7 @@ func TestPlanDeployRetainsUnchangedWorkerWhenAnotherWorkloadChanges(t *testing.T digestB := strings.Repeat("2", 64) workerDigest := strings.Repeat("3", 64) project := func(apiDigest string) string { - return `api_version: onebox.run/v1 + return `api_version: onebox.run/v2 app: sample environments: {production: {server: deploy@example.test}} workloads: diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 04a5b9de..877e9b4c 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -160,7 +160,7 @@ func TestManagedWildcardTLSRejectsIncompleteDNSConfiguration(t *testing.T) { } func TestManagedTLSRouterReferencesDefaultStaticResolver(t *testing.T) { - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2 app: sample environments: production: {server: root@example.com} @@ -168,8 +168,8 @@ workloads: web: role: application image: nginx:1.27 - domain: app.example.com - port: 80 + routes: + - {hostname: app.example.com, port: 80} `), "ob.yml") if err != nil { t.Fatal(err) diff --git a/site/public/onebox.run-v2.schema.json b/site/public/onebox.run-v2.schema.json new file mode 100644 index 00000000..5786c127 --- /dev/null +++ b/site/public/onebox.run-v2.schema.json @@ -0,0 +1,3119 @@ +{ + "$id": "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "workloads": { + "minProperties": 1 + } + }, + "required": [ + "workloads" + ] + }, + { + "anyOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + } + ] + } + ], + "description": "One application, its workloads, the services it needs, and how a release rolls out.", + "not": { + "allOf": [ + { + "required": [ + "workloads" + ] + }, + { + "anyOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + }, + { + "required": [ + "port" + ] + }, + { + "required": [ + "health" + ] + }, + { + "required": [ + "routes" + ] + } + ] + } + ] + }, + "patternProperties": { + "^x-": {} + }, + "properties": { + "api_version": { + "const": "onebox.run/v2", + "description": "Project contract version. Must be onebox.run/v2.", + "examples": [ + "onebox.run/v2" + ], + "type": "string" + }, + "app": { + "description": "Stable application name used in generated container, volume, network, and host paths. The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"ob-\" or be a name the host layout reserves.", + "examples": [ + "shop" + ], + "not": { + "anyOf": [ + { + "pattern": "^ob-" + }, + { + "const": "ob" + }, + { + "const": "onebox-proxy" + }, + { + "const": "_host" + } + ] + }, + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "backup_targets": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "bucket": { + "description": "Existing destination bucket used by this target. Expects a lower-case S3-compatible bucket name between 3 and 63 characters.", + "examples": [ + "onebox-backups" + ], + "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", + "type": "string" + }, + "credentials": { + "additionalProperties": false, + "description": "Trusted encrypted-file entries containing destination credentials; values never appear in the project.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "access_key_entry": { + "description": "Variable name containing the destination access key. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "BACKUP_ACCESS_KEY_ID" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "file": { + "description": "Repository-relative encrypted credential file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "secrets/backup.env" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "default": "sops", + "description": "Trusted secret provider. Only sops is currently executable.", + "enum": [ + "sops" + ], + "type": "string" + }, + "secret_key_entry": { + "description": "Variable name containing the destination secret key. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "BACKUP_SECRET_ACCESS_KEY" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "session_token_entry": { + "description": "Optional variable name containing a temporary destination session token. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "BACKUP_SESSION_TOKEN" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + } + }, + "type": "object" + }, + "encryption": { + "additionalProperties": false, + "description": "Required encryption mode for each recovery kind this target may store.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cold": { + "description": "Encryption mode required for cold recovery: client-side or server-side.", + "enum": [ + "client-side", + "server-side" + ], + "type": "string" + }, + "pitr": { + "description": "Encryption mode required for point-in-time recovery: client-side or server-side.", + "enum": [ + "client-side", + "server-side" + ], + "type": "string" + }, + "snapshot": { + "description": "Encryption mode required for snapshot recovery: client-side or server-side.", + "enum": [ + "client-side", + "server-side" + ], + "type": "string" + } + }, + "type": "object" + }, + "endpoint": { + "description": "Destination API endpoint. HTTPS is required unless tls is explicitly insecure. Expects an http or https URL.", + "examples": [ + "https://objects.example.com" + ], + "pattern": "^https?://", + "type": "string" + }, + "failure_domain": { + "additionalProperties": false, + "description": "Operator-declared identity used to prove the destination does not share the protected host.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "host": { + "description": "Destination host identity used to refuse a target on the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.", + "examples": [ + "backup-01.example.net" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$", + "type": "string" + }, + "identity": { + "description": "Stable operator-owned failure-domain identity, distinct from the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.", + "examples": [ + "provider-a/us-east-1/account-42" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$", + "type": "string" + } + }, + "type": "object" + }, + "kind": { + "description": "Destination kind. Only s3-compatible is supported.", + "enum": [ + "s3-compatible" + ], + "examples": [ + "s3-compatible" + ], + "type": "string" + }, + "prefix": { + "description": "Non-secret object prefix reserved for Onebox backup data. Expects a relative object prefix with no empty leading component or shell metacharacter.", + "examples": [ + "production/shop" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$", + "type": "string" + }, + "region": { + "description": "S3-compatible region when the endpoint requires one. Expects a lower-case S3-compatible region of letters, digits and hyphens.", + "examples": [ + "us-east-1" + ], + "pattern": "^[a-z0-9][a-z0-9-]{0,62}$", + "type": "string" + }, + "tls": { + "default": "verify", + "description": "Transport policy: verify, or skip-verify to accept a plaintext http endpoint.", + "enum": [ + "verify", + "skip-verify" + ], + "type": "string" + } + }, + "type": "object" + }, + "description": "User-owned off-host repositories available to service backup policies.", + "type": "object" + }, + "base_path": { + "default": "/var/lib/ob", + "description": "Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/srv/ob" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "build": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Build metadata for development. Production requires a resolved image supplied with --image.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "args": { + "additionalProperties": {}, + "description": "Build arguments supplied by the external build system.", + "type": "object" + }, + "context": { + "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "." + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "dockerfile": { + "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "Dockerfile" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "target": { + "description": "Named Dockerfile stage to build.", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path." + }, + "checks": { + "additionalProperties": false, + "description": "Assertions that must pass before a release becomes current unless marked advisory.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "exec": { + "description": "Commands run inside a named workload.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "run": { + "description": "Shell command verified inside the workload.", + "examples": [ + "test -f /srv/ready" + ], + "type": "string" + }, + "workload": { + "description": "Workload the command runs inside.", + "examples": [ + "web" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "http": { + "description": "HTTP paths probed inside a named workload.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "path": { + "description": "HTTP path verified inside the workload. Expects a path beginning with /.", + "examples": [ + "/healthz" + ], + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "port": { + "description": "Container port to probe.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "workload": { + "description": "Workload the path is probed inside.", + "examples": [ + "web" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "migrations": { + "description": "Migration revisions checked against captured job evidence.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "applied_revisions": { + "description": "Revisions the job must report as applied.", + "items": { + "type": "string" + }, + "type": "array" + }, + "job": { + "description": "Job workload whose captured evidence is checked.", + "examples": [ + "migrate" + ], + "type": "string" + }, + "provider": { + "description": "Migration tool that produced the revisions.", + "examples": [ + "alembic" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "url": { + "description": "External URLs probed from the operator side.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "contains": { + "description": "Text the response body must contain.", + "type": "string" + }, + "json_assertions": { + "description": "Scalar JSON response values that must match exactly.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "equals": { + "description": "Exact scalar value required at path." + }, + "path": { + "description": "Dot-separated path to a scalar value in the JSON response.", + "examples": [ + "service.ready" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "required_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Exact response headers required for success.", + "type": "object" + }, + "status_codes": { + "description": "Allowed response status codes. A successful 2xx response is expected when omitted.", + "items": { + "maximum": 599, + "minimum": 100, + "type": "integer" + }, + "type": "array" + }, + "url": { + "description": "External HTTP or HTTPS URL verified from the operator side. Expects an http or https URL.", + "examples": [ + "https://shop.example.com/healthz" + ], + "pattern": "^https?://", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "compose": { + "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.", + "examples": [ + "docker-compose.yml#web" + ], + "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$", + "type": "string" + }, + "deployment": { + "additionalProperties": false, + "description": "Release ordering, retention, and migration behavior.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "migration_policy": { + "default": "manual", + "description": "Policy for migration jobs during release and recovery.", + "enum": [ + "manual", + "auto", + "expand-only" + ], + "type": "string" + }, + "order": { + "description": "Explicit workload release order. Dependency order is derived when omitted.", + "items": { + "type": "string" + }, + "type": "array" + }, + "retain_releases": { + "default": 5, + "description": "Number of completed release directories retained for inspection and rollback.", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "environments": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "base_path": { + "description": "Environment-specific replacement for the project base_path. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/srv/ob" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "env_files": { + "description": "Default ordered environment-file list for application, worker, and job workloads in this environment.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "description": "Decryptor used before staging the file. The supported encrypted provider is sops.", + "enum": [ + "sops" + ], + "examples": [ + "sops" + ], + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ], + "description": "Also accepts a path to an environment file." + }, + "type": "array" + }, + "jump": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent.", + "examples": [ + "deploy@bastion.example.com" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "host": { + "description": "Jump host name or IP address.", + "examples": [ + "bastion.example.com" + ], + "type": "string" + }, + "port": { + "description": "SSH port on the jump host. The SSH default is used when omitted.", + "examples": [ + 2222 + ], + "type": "integer" + }, + "user": { + "description": "SSH user on the jump host. $USER is used when omitted; ob does not read ~/.ssh/config.", + "examples": [ + "deploy" + ], + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent. Also accepts user@host or user@host:port." + }, + "overrides": { + "additionalProperties": false, + "description": "Environment-specific operational tuning. Overrides cannot change workload identity or data semantics.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "services": { + "additionalProperties": { + "additionalProperties": {}, + "type": "object" + }, + "description": "Allowed service tuning keyed by service name: resources and settings.", + "type": "object" + }, + "workloads": { + "additionalProperties": { + "additionalProperties": {}, + "type": "object" + }, + "description": "Allowed workload tuning keyed by workload name: replicas, resources, env, env_files, strategy, and routes.", + "type": "object" + } + }, + "type": "object" + }, + "policy": { + "additionalProperties": false, + "description": "Approval, runner compatibility, and migration-backup requirements for this environment.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "allow_agent_proposals": { + "default": true, + "description": "Declared permission for agent-authored proposals. The current CLI does not distinguish agent identity; execution remains approval-gated.", + "type": "boolean" + }, + "migrations": { + "additionalProperties": false, + "description": "What this environment requires of a release carrying migration risk.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "backup_key_material": { + "description": "Key-material identities the backup report must name.", + "examples": [ + [ + "BACKUP_ACCESS_KEY_ID" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "backup_max_age": { + "default": "24h", + "description": "Maximum age of a backup report accepted for a migration. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "24h" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "require_backup": { + "default": false, + "description": "Require a plan-bound backup report before a release with migration risk.", + "type": "boolean" + }, + "require_restore_test": { + "default": false, + "description": "Require the backup report to state that a restore test succeeded.", + "type": "boolean" + } + }, + "type": "object" + }, + "min_onebox_version": { + "description": "Oldest released Onebox runner allowed to operate this environment. Expects a CalVer release such as v2026.8.0.", + "examples": [ + "v2026.8.0" + ], + "pattern": "^v([1-9][0-9]{3})\\.([1-9]|1[0-2])\\.(0|[1-9][0-9]{0,18})$", + "type": "string" + }, + "min_plan_schema": { + "description": "Oldest executable plan schema accepted by this environment. Expects a plan schema identity such as onebox.run/executable-deploy-plan/v1alpha2.", + "examples": [ + "onebox.run/executable-deploy-plan/v1alpha2" + ], + "pattern": "^onebox\\.run/executable-deploy-plan/v[1-9][0-9]*((alpha|beta)[1-9][0-9]*)?$", + "type": "string" + }, + "require_approval": { + "default": true, + "description": "Require a plan-bound local confirmation before mutating this environment.", + "type": "boolean" + } + }, + "type": "object" + }, + "server": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "SSH server, written as user@host or as an object with host, user, and port.", + "examples": [ + "root@203.0.113.10" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "host": { + "description": "SSH hostname or IP address.", + "examples": [ + "203.0.113.10" + ], + "type": "string" + }, + "port": { + "description": "SSH port. The SSH default is used when omitted.", + "examples": [ + 2222 + ], + "type": "integer" + }, + "user": { + "description": "SSH user. $USER is used when omitted; ob does not read ~/.ssh/config.", + "examples": [ + "root" + ], + "type": "string" + } + }, + "type": "object" + } + ], + "description": "SSH server, written as user@host or as an object with host, user, and port. Also accepts user@host." + } + }, + "type": "object" + }, + "description": "Named environments, each naming the server it deploys to and the policy applied to it.", + "minProperties": 1, + "type": "object" + }, + "external_services": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "backup_owner": { + "description": "Operator or provider responsible for backup, restore, upgrades, credentials, and durability. Expects a stable operator or provider identity of letters, digits, dots, @, colons, slashes, underscores and hyphens.", + "examples": [ + "platform-team/rds" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@:/-]{0,127}$", + "type": "string" + }, + "connection": { + "additionalProperties": false, + "description": "Trusted connection source and driver-shaped entry mapping.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "entries": { + "additionalProperties": { + "description": "Expects a variable name of letters, digits and underscores, not starting with a digit.", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "description": "Maps driver connection parts such as host, port, user, password, database, or url to variable names in the trusted source.", + "type": "object" + }, + "source": { + "additionalProperties": false, + "description": "Trusted encrypted file containing the connection values.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative encrypted environment file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "secrets/production-db.env" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "default": "sops", + "description": "Trusted secret provider. Only sops is currently executable.", + "enum": [ + "sops" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "driver": { + "description": "Built-in connection shape used to validate and project this dependency.", + "enum": [ + "clickhouse", + "mariadb", + "meilisearch", + "minio", + "mongodb", + "mysql", + "nats", + "postgres", + "rabbitmq", + "redis", + "valkey" + ], + "examples": [ + "postgres" + ], + "type": "string" + }, + "probe": { + "additionalProperties": false, + "description": "Optional bounded read-only health observation; it never creates or repairs provider resources.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "kind": { + "default": "driver-health", + "description": "Read-only observation kind: driver-health.", + "enum": [ + "driver-health" + ], + "type": "string" + }, + "max_age": { + "default": "5m", + "description": "Maximum age of a probe observation bound into a plan. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "timeout": { + "default": "5s", + "description": "Maximum duration of one read-only probe. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "description": "Typed dependencies operated outside Onebox. Their connection projection is trusted, but their lifecycle and backup remain external.", + "type": "object" + }, + "health": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Readiness check used to gate rolling replacement.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "exec": { + "description": "Health command as a shell string or direct argument list." + }, + "http": { + "description": "HTTP path probed inside the container. Expects a path beginning with /.", + "examples": [ + "/healthz" + ], + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "interval": { + "default": "5s", + "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "2s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "port": { + "description": "Container port probed by HTTP or TCP health checks.", + "examples": [ + 8080 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "retries": { + "default": 3, + "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.", + "examples": [ + 3 + ], + "type": "integer" + }, + "start_period": { + "default": "30s", + "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "tcp": { + "default": false, + "description": "Probe the configured port by opening a TCP connection.", + "type": "boolean" + }, + "within": { + "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "120s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path." + }, + "hooks": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "local": { + "default": false, + "description": "Run on the operator machine instead of the server.", + "type": "boolean" + }, + "run": { + "description": "Command executed at the lifecycle seam.", + "examples": [ + "./scripts/notify.sh" + ], + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Also accepts the command to run." + }, + "description": "Lifecycle commands keyed by seam: bootstrap, pre_release, post_release, or post_deploy.", + "type": "object" + }, + "image": { + "anyOf": [ + { + "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + }, + { + "additionalProperties": false, + "description": "Container image source, written as a reference string or an object.", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "pull": { + "default": "missing", + "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.", + "enum": [ + "always", + "missing", + "never" + ], + "type": "string" + }, + "reference": { + "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Container image source, written as a reference string or an object. Also accepts an image reference." + }, + "notifications": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "format": { + "default": "text", + "description": "Notification payload format.", + "enum": [ + "text", + "json" + ], + "type": "string" + }, + "on": { + "default": [ + "success", + "failure" + ], + "description": "Operation outcomes that trigger this notification.", + "items": { + "enum": [ + "success", + "failure" + ], + "type": "string" + }, + "type": "array" + }, + "webhook": { + "description": "HTTP endpoint that receives outcome notifications.", + "examples": [ + "https://hooks.example.com/onebox" + ], + "type": "string" + } + }, + "type": "object" + }, + "description": "Named webhooks that receive selected operation and scheduled-job outcomes.", + "type": "object" + }, + "port": { + "description": "Default container port used by HTTP health checks.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "proxy": { + "additionalProperties": false, + "description": "Ownership and configuration of the host ingress proxy.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "config": { + "description": "Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. A managed DNS challenge may use a directory containing only .env for provider credentials. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter.", + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "dns_challenge": { + "additionalProperties": false, + "description": "Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "provider": { + "description": "Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env. Expects a lower-case Traefik DNS provider name such as cloudflare or route53.", + "examples": [ + "cloudflare" + ], + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "resolvers": { + "description": "DNS resolvers used to verify challenge propagation, written as host:port.", + "examples": [ + [ + "1.1.1.1:53" + ] + ], + "items": { + "description": "Expects a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port.", + "pattern": "^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\\[[0-9A-Fa-f:.]+\\]):[0-9]{1,5}$", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, + "entrypoints": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "port": { + "description": "Host and proxy-container TCP port used by this listener.", + "examples": [ + 4317 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints.", + "propertyNames": { + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" + }, + "type": "object" + }, + "image": { + "description": "Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + }, + "kind": { + "default": "traefik-docker", + "description": "Proxy implementation, or none to disable routing.", + "enum": [ + "traefik-docker", + "none" + ], + "type": "string" + }, + "managed": { + "description": "Let Onebox converge the host-scoped proxy when routes are declared.", + "type": "boolean" + }, + "network": { + "default": "ob-ingress", + "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.", + "type": "string" + } + }, + "type": "object" + }, + "registries": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "password_env": { + "description": "Local environment-variable name containing the registry password or token. Expects a variable name of letters, digits and underscores, not starting with a digit.", + "examples": [ + "GHCR_TOKEN" + ], + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "server": { + "description": "Registry hostname, optionally with a port. Expects a host with an optional port and path, such as ghcr.io or registry.example.com:5000.", + "examples": [ + "ghcr.io" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9.-]*(:[0-9]{1,5})?(/[A-Za-z0-9._/-]*)?$", + "type": "string" + }, + "username": { + "description": "Registry login username. Expects a username of letters, digits and . _ @ + -.", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", + "type": "string" + } + }, + "type": "object" + }, + "description": "Named container registries and the environment variables holding their credentials.", + "type": "object" + }, + "routes": { + "description": "Ingress routes exposed by this workload.", + "items": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "hostname": { + "const": "*" + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + }, + { + "if": { + "properties": { + "hostname": { + "pattern": "^\\*\\." + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "http" + } + } + } + }, + { + "if": { + "properties": { + "tls": { + "const": "passthrough" + } + }, + "required": [ + "tls" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + } + }, + "required": [ + "protocol" + ] + } + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "entrypoint": { + "default": "websecure", + "description": "Named proxy listener used for the route.", + "type": "string" + }, + "hostname": { + "anyOf": [ + { + "maxLength": 253, + "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$" + }, + { + "const": "*" + } + ], + "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.", + "examples": [ + "shop.example.com" + ], + "type": "string" + }, + "middlewares": { + "description": "Ordered provider-qualified middleware references applied to this route.", + "items": { + "description": "Expects a provider-qualified name such as secure-headers@file.", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$", + "type": "string" + }, + "type": "array" + }, + "path": { + "default": "/", + "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.", + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "port": { + "description": "Container port receiving routed traffic.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "default": "http", + "description": "Routing protocol: http or tcp.", + "enum": [ + "http", + "tcp" + ], + "type": "string" + }, + "scheme": { + "default": "http", + "description": "Backend connection scheme for HTTP routes: http, https, or h2c.", + "enum": [ + "http", + "https", + "h2c" + ], + "type": "string" + }, + "tls": { + "default": "terminate", + "description": "TLS handling: terminate, passthrough, or none.", + "enum": [ + "terminate", + "passthrough", + "none" + ], + "type": "string" + } + }, + "required": [ + "hostname" + ], + "type": "object" + }, + "type": "array" + }, + "runtime": { + "additionalProperties": false, + "description": "Project-wide environment files and local environment-file requirements.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "env_checks": { + "description": "Local environment-file assertions checked before planning or deploying.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative dotenv file whose declared keys are checked. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "present": { + "description": "Environment keys that must be declared but may be empty.", + "items": { + "type": "string" + }, + "type": "array" + }, + "require": { + "description": "Environment keys that must be declared with non-empty values.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "env_files": { + "description": "Project-wide ordered environment-file list for application, worker, and job workloads.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "description": "Decryptor used before staging the file. The supported encrypted provider is sops.", + "enum": [ + "sops" + ], + "examples": [ + "sops" + ], + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ], + "description": "Also accepts a path to an environment file." + }, + "type": "array" + } + }, + "type": "object" + }, + "services": { + "additionalProperties": { + "anyOf": [ + { + "type": [ + "string", + "number", + "integer" + ] + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "backup": { + "additionalProperties": false, + "description": "Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "allow_downtime": { + "default": false, + "description": "Whether recurring backup operations may use the driver-declared stopped-service window.", + "type": "boolean" + }, + "drill": { + "additionalProperties": false, + "description": "Exact isolated restore-test schedule, proof age, and optional staging filesystem.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "max_age": { + "default": "7d", + "description": "Maximum age of the latest passing restore proof. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "7d" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "schedule": { + "additionalProperties": false, + "description": "Exact recurring isolated restore-test schedule.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cron": { + "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.", + "examples": [ + "0 2 * * *" + ], + "pattern": "^[-0-9*/,A-Za-z ]+$", + "type": "string" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.", + "examples": [ + "Europe/Berlin" + ], + "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "max_data_loss": { + "description": "Maximum tolerable interval between the latest recoverable point and failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "15m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "recovery_kind": { + "description": "Required recovery envelope: snapshot, pitr, or cold.", + "enum": [ + "snapshot", + "pitr", + "cold" + ], + "examples": [ + "pitr" + ], + "type": "string" + }, + "retention": { + "additionalProperties": false, + "description": "Portable minimum recovery history that the selected native driver must be able to preserve.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "keep": { + "default": 7, + "description": "Minimum number of independently recoverable base generations to retain.", + "examples": [ + 7 + ], + "minimum": 1, + "type": "integer" + }, + "window": { + "default": "7d", + "description": "Minimum continuous recovery history the native retention mapping must preserve. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "7d" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + }, + "schedule": { + "additionalProperties": false, + "description": "Exact recurring base-backup schedule.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cron": { + "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.", + "examples": [ + "0 2 * * *" + ], + "pattern": "^[-0-9*/,A-Za-z ]+$", + "type": "string" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.", + "examples": [ + "Europe/Berlin" + ], + "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$", + "type": "string" + } + }, + "type": "object" + }, + "target": { + "description": "Name of a project-level backup target. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "examples": [ + "offsite" + ], + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + } + }, + "type": "object" + }, + "driver": { + "description": "Built-in service driver. Defaults to the service map key. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "examples": [ + "postgres" + ], + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "features": { + "additionalProperties": false, + "description": "Capabilities Onebox must establish before application workloads run.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": {}, + "type": "object" + }, + "description": "PostgreSQL extensions Onebox installs in the managed application database before application migrations run.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "type": "object" + } + }, + "type": "object" + }, + "persistence": { + "additionalProperties": false, + "description": "Data-lifetime declaration for this supporting service.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "mode": { + "default": "durable", + "description": "Data lifetime: durable, ephemeral, or external.", + "enum": [ + "durable", + "ephemeral", + "external" + ], + "type": "string" + } + }, + "type": "object" + }, + "resources": { + "additionalProperties": false, + "description": "Memory and CPU limits for this supporting service.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cpus": { + "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.", + "examples": [ + "0.5" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?$", + "type": "string" + }, + "memory": { + "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.", + "examples": [ + "512MB" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$", + "type": "string" + } + }, + "type": "object" + }, + "settings": { + "additionalProperties": {}, + "description": "Driver-specific settings validated by the selected service driver.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "type": "object" + }, + "version": { + "description": "Driver version or image tag to run.", + "examples": [ + "17" + ] + }, + "volumes": { + "description": "Additional driver-defined persistent volume names.", + "items": { + "description": "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" + }, + "type": "array" + } + }, + "type": "object" + } + ], + "description": "Also accepts the version to run." + }, + "description": "Supporting services managed outside application releases, such as databases and caches.", + "type": "object" + }, + "workloads": { + "additionalProperties": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "required": [ + "execution" + ] + }, + "then": { + "not": { + "required": [ + "compose" + ] + }, + "properties": { + "data_effect": { + "const": "none" + }, + "deployment_phase": { + "const": "none" + }, + "operator_run": { + "const": "allowed" + } + }, + "required": [ + "schedule", + "data_effect" + ] + } + }, + { + "oneOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + } + ] + }, + { + "not": { + "allOf": [ + { + "required": [ + "published_ports" + ] + }, + { + "anyOf": [ + { + "properties": { + "strategy": { + "const": "rolling" + } + }, + "required": [ + "strategy" + ] + }, + { + "allOf": [ + { + "not": { + "required": [ + "strategy" + ] + } + }, + { + "required": [ + "health" + ] + }, + { + "anyOf": [ + { + "properties": { + "role": { + "const": "application" + } + }, + "required": [ + "role" + ] + }, + { + "not": { + "required": [ + "role" + ] + } + } + ] + } + ] + } + ] + } + ] + } + }, + { + "if": { + "properties": { + "persistence": { + "anyOf": [ + { + "properties": { + "mode": { + "const": "durable" + } + }, + "required": [ + "mode" + ] + }, + { + "not": { + "required": [ + "mode" + ] + } + } + ] + } + }, + "required": [ + "persistence" + ] + }, + "then": { + "properties": { + "replicas": { + "maximum": 1 + } + } + } + }, + { + "else": { + "not": { + "anyOf": [ + { + "required": [ + "deployment_phase" + ] + }, + { + "required": [ + "operator_run" + ] + }, + { + "required": [ + "data_effect" + ] + }, + { + "required": [ + "schedule" + ] + }, + { + "required": [ + "inputs" + ] + }, + { + "required": [ + "execution" + ] + } + ] + } + }, + "if": { + "properties": { + "role": { + "const": "job" + } + }, + "required": [ + "role" + ] + }, + "then": { + "required": [ + "data_effect" + ] + } + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "build": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Build metadata for development. Production requires a resolved image supplied with --image.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "args": { + "additionalProperties": {}, + "description": "Build arguments supplied by the external build system.", + "type": "object" + }, + "context": { + "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "." + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "dockerfile": { + "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + "Dockerfile" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "target": { + "description": "Named Dockerfile stage to build.", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path." + }, + "command": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + { + "description": "Container command as a shell string or argument list.", + "examples": [ + "./bin/server" + ] + } + ], + "description": "Container command as a shell string or argument list. Also accepts a command line or argument list." + }, + "compose": { + "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.", + "examples": [ + "docker-compose.yml#web" + ], + "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$", + "type": "string" + }, + "data_effect": { + "description": "Job data impact used by rollback and abort gates.", + "enum": [ + "none", + "migration", + "destructive", + "unknown" + ], + "examples": [ + "migration" + ], + "type": "string" + }, + "deployment_phase": { + "default": "none", + "description": "Deployment phase for this job: none, pre_release, or post_release.", + "enum": [ + "none", + "pre_release", + "post_release" + ], + "type": "string" + }, + "drain": { + "additionalProperties": false, + "description": "Signal and timing used to remove a container from traffic before stopping it.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "grace": { + "description": "Maximum graceful-shutdown time before forced termination, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "30s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "signal": { + "default": "TERM", + "description": "Signal sent to begin graceful shutdown. Expects a signal name such as TERM or QUIT.", + "pattern": "^[A-Z][A-Z0-9]*$", + "type": "string" + }, + "wait": { + "description": "Maximum drain window before shutdown continues, at most 7d. Recreate workloads continue sooner when every old container exits. Rolling workloads wait the full interval before stopping each container when their health check supports drain guarding. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "10s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + }, + "entrypoint": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + { + "description": "Container entrypoint as a string or argument list." + } + ], + "description": "Container entrypoint as a string or argument list. Also accepts an entrypoint or argument list." + }, + "env": { + "additionalProperties": {}, + "description": "Literal container environment values. Managed-service credential variables cannot be overridden.", + "type": "object" + }, + "env_files": { + "description": "Workload-specific ordered environment-file list. Replaces broader defaults when present.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "file": { + "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.", + "examples": [ + ".env.production" + ], + "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "provider": { + "description": "Decryptor used before staging the file. The supported encrypted provider is sops.", + "enum": [ + "sops" + ], + "examples": [ + "sops" + ], + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ], + "description": "Also accepts a path to an environment file." + }, + "type": "array" + }, + "execution": { + "additionalProperties": false, + "description": "Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with data_effect none. Stores non-secret checkpoints on the host and permits explicit same-release resume.", + "patternProperties": { + "^x-": {} + }, + "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, + "patternProperties": { + "^x-": {} + }, + "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.", + "patternProperties": { + "^x-": {} + }, + "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" + }, + "max_backoff": { + "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" + }, + "extra_hosts": { + "description": "Additional host-to-address entries added to the container.", + "items": { + "type": "string" + }, + "type": "array" + }, + "health": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Readiness check used to gate rolling replacement.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "exec": { + "description": "Health command as a shell string or direct argument list." + }, + "http": { + "description": "HTTP path probed inside the container. Expects a path beginning with /.", + "examples": [ + "/healthz" + ], + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "interval": { + "default": "5s", + "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "2s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "port": { + "description": "Container port probed by HTTP or TCP health checks.", + "examples": [ + 8080 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "retries": { + "default": 3, + "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.", + "examples": [ + 3 + ], + "type": "integer" + }, + "start_period": { + "default": "30s", + "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "5s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "tcp": { + "default": false, + "description": "Probe the configured port by opening a TCP connection.", + "type": "boolean" + }, + "within": { + "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "120s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path." + }, + "hostname": { + "description": "Hostname assigned inside the workload container.", + "type": "string" + }, + "image": { + "anyOf": [ + { + "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + }, + { + "additionalProperties": false, + "description": "Container image source, written as a reference string or an object.", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "pull": { + "default": "missing", + "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.", + "enum": [ + "always", + "missing", + "never" + ], + "type": "string" + }, + "reference": { + "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….", + "examples": [ + "ghcr.io/acme/shop:1.4.0" + ], + "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Container image source, written as a reference string or an object. Also accepts an image reference." + }, + "init": { + "description": "Run a minimal init process as PID 1 inside the container.", + "type": "boolean" + }, + "inputs": { + "additionalProperties": { + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "enum" + ] + }, + { + "required": [ + "pattern" + ] + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "default": { + "description": "Value used by a timer firing and by an operator run that does not override it. Must satisfy the input's own constraint.", + "type": "string" + }, + "description": { + "description": "What the input controls.", + "type": "string" + }, + "enum": { + "description": "Accepted values.", + "examples": [ + [ + "catalog" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "pattern": { + "description": "Regular expression the whole value must match.", + "examples": [ + "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + ], + "type": "string" + } + }, + "required": [ + "default" + ], + "type": "object" + }, + "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.", + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "type": "object" + }, + "labels": { + "additionalProperties": {}, + "description": "Additional container labels outside namespaces reserved by Onebox and the proxy.", + "type": "object" + }, + "logging": { + "additionalProperties": false, + "description": "Container logging driver and driver-specific options.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "driver": { + "description": "Container runtime logging driver. Expects a log driver name such as local, json-file or an org/plugin:tag.", + "examples": [ + "local" + ], + "pattern": "^[a-z0-9][a-z0-9_.-]*(/[a-z0-9][a-z0-9_.-]*)?(:[A-Za-z0-9_.-]+)?$", + "type": "string" + }, + "options": { + "additionalProperties": {}, + "description": "Driver-specific logging options passed to the container runtime.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "type": "object" + } + }, + "type": "object" + }, + "needs": { + "description": "Workload or supporting-service prerequisites and optional connection-variable mappings.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "condition": { + "description": "Prerequisite condition: started, healthy, or completed.", + "enum": [ + "started", + "healthy", + "completed" + ], + "type": "string" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Maps application environment-variable names to service connection parts such as host, port, user, password, database, or url.", + "type": "object" + }, + "name": { + "description": "Name of a workload or supporting service that must start first. 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" + } + }, + "type": "object" + } + ], + "description": "Also accepts the name of a prerequisite." + }, + "type": "array" + }, + "operator_run": { + "description": "Whether an operator may invoke this job outside deployment: allowed or disabled. Defaults to allowed for phase none and disabled otherwise.", + "enum": [ + "allowed", + "disabled" + ], + "type": "string" + }, + "persistence": { + "additionalProperties": false, + "description": "Declares whether this workload holds data that must outlive releases.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "mode": { + "default": "durable", + "description": "Data lifetime: durable, ephemeral, or external.", + "enum": [ + "durable", + "ephemeral", + "external" + ], + "type": "string" + } + }, + "type": "object" + }, + "port": { + "description": "Default container port used by HTTP health checks.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "published_ports": { + "description": "Host ports published outside the proxy. They bind to loopback by default. A rolling workload cannot publish one, because two replicas cannot hold the same host port during a roll: set strategy: recreate, or route through the proxy instead.", + "items": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "bind": { + "default": "127.0.0.1", + "description": "Host address on which the published port listens.", + "type": "string" + }, + "container": { + "description": "Port receiving traffic inside the container.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "host": { + "description": "Port exposed on the host.", + "examples": [ + 8080 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "default": "tcp", + "description": "Published transport protocol: tcp or udp.", + "enum": [ + "tcp", + "udp" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "replicas": { + "default": 1, + "description": "Desired number of long-running workload containers.", + "examples": [ + 2 + ], + "minimum": 1, + "type": "integer" + }, + "resources": { + "additionalProperties": false, + "description": "Container memory and CPU limits.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "cpus": { + "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.", + "examples": [ + "0.5" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?$", + "type": "string" + }, + "memory": { + "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.", + "examples": [ + "512MB" + ], + "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$", + "type": "string" + } + }, + "type": "object" + }, + "role": { + "description": "Lifecycle role: application, worker, daemon, or job.", + "enum": [ + "application", + "worker", + "daemon", + "job" + ], + "examples": [ + "application" + ], + "type": "string" + }, + "routes": { + "description": "Ingress routes exposed by this workload.", + "items": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "hostname": { + "const": "*" + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + }, + { + "if": { + "properties": { + "hostname": { + "pattern": "^\\*\\." + } + }, + "required": [ + "hostname" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "http" + } + } + } + }, + { + "if": { + "properties": { + "tls": { + "const": "passthrough" + } + }, + "required": [ + "tls" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + } + }, + "required": [ + "protocol" + ] + } + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "entrypoint": { + "default": "websecure", + "description": "Named proxy listener used for the route.", + "type": "string" + }, + "hostname": { + "anyOf": [ + { + "maxLength": 253, + "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$" + }, + { + "const": "*" + } + ], + "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.", + "examples": [ + "shop.example.com" + ], + "type": "string" + }, + "middlewares": { + "description": "Ordered provider-qualified middleware references applied to this route.", + "items": { + "description": "Expects a provider-qualified name such as secure-headers@file.", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$", + "type": "string" + }, + "type": "array" + }, + "path": { + "default": "/", + "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.", + "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$", + "type": "string" + }, + "port": { + "description": "Container port receiving routed traffic.", + "examples": [ + 3000 + ], + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "default": "http", + "description": "Routing protocol: http or tcp.", + "enum": [ + "http", + "tcp" + ], + "type": "string" + }, + "scheme": { + "default": "http", + "description": "Backend connection scheme for HTTP routes: http, https, or h2c.", + "enum": [ + "http", + "https", + "h2c" + ], + "type": "string" + }, + "tls": { + "default": "terminate", + "description": "TLS handling: terminate, passthrough, or none.", + "enum": [ + "terminate", + "passthrough", + "none" + ], + "type": "string" + } + }, + "required": [ + "hostname" + ], + "type": "object" + }, + "type": "array" + }, + "schedule": { + "additionalProperties": false, + "description": "Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "catch_up": { + "default": true, + "description": "Run once after the host returns if an elapsed schedule was missed while it was offline.", + "type": "boolean" + }, + "cron": { + "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.", + "examples": [ + "0 2 * * *" + ], + "pattern": "^[-0-9*/,A-Za-z ]+$", + "type": "string" + }, + "deploy_lock": { + "default": "exclusive", + "description": "Deployment coordination policy: exclusive blocks application operations for the full run; pinned leases the immutable starting release and permits only deployments without data-changing jobs or untyped hooks.", + "enum": [ + "exclusive", + "pinned" + ], + "examples": [ + "pinned" + ], + "type": "string" + }, + "notify": { + "default": [ + "failure", + "timeout" + ], + "description": "Run outcomes that send the configured notifications: success, failure, timeout, skipped.", + "items": { + "enum": [ + "success", + "failure", + "timeout", + "skipped" + ], + "type": "string" + }, + "type": "array" + }, + "retry": { + "additionalProperties": false, + "description": "Bounded retry inside one timer firing. Attempts run under the same locks and the same timeout; a timeout ends the run.", + "patternProperties": { + "^x-": {} + }, + "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" + }, + "max_backoff": { + "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" + }, + "shutdown_grace": { + "default": "30s", + "description": "Time allowed for graceful container shutdown after the run deadline before Onebox forces removal. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "45s" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "timeout": { + "default": "1h", + "description": "Maximum wall time for one scheduled run before systemd terminates it and records failure. 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" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.", + "examples": [ + "Europe/Berlin" + ], + "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$", + "type": "string" + } + }, + "type": "object" + }, + "stdin_open": { + "description": "Keep standard input open for the container.", + "type": "boolean" + }, + "strategy": { + "description": "Replacement strategy for a changed or uncertain workload. An unchanged healthy workload is retained automatically. Defaults to rolling only for an application workload with health; all other workloads default to recreate.", + "enum": [ + "rolling", + "recreate" + ], + "type": "string" + }, + "tty": { + "description": "Allocate a pseudo-TTY for the container.", + "type": "boolean" + }, + "user": { + "description": "User or UID used to run the container process.", + "type": "string" + }, + "volumes": { + "description": "Managed named volumes or bind mounts. Relative bind sources are read-only release content; absolute sources are external host state.", + "items": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "source": { + "pattern": "^[^/]" + } + }, + "required": [ + "source" + ] + }, + "then": { + "properties": { + "mode": { + "const": "ro" + } + }, + "required": [ + "mode" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "name", + "path" + ] + }, + { + "required": [ + "source", + "path" + ] + } + ], + "patternProperties": { + "^x-": {} + }, + "properties": { + "mode": { + "default": "rw", + "description": "Mount access mode: rw or ro. A relative bind source requires ro.", + "enum": [ + "rw", + "ro" + ], + "type": "string" + }, + "name": { + "description": "Stable logical name of a Onebox-managed volume. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "examples": [ + "data" + ], + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "path": { + "description": "Absolute container path where the volume or bind mount is attached. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/var/lib/app" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "source": { + "description": "Bind mount source. An absolute path is external host state that outlives releases. A dot-prefixed repository path is read-only release content, kept for as long as a container still mounts it. Expects an absolute host path or a dot-prefixed path inside the repository, with no colon, control character or shell metacharacter.", + "examples": [ + "./config" + ], + "not": { + "pattern": "(^|/)\\.\\.(/|$)" + }, + "pattern": "^(/[^\\x00-\\x1f'\"$`\\\\:]*|\\.(?:/[^\\x00-\\x1f'\"$`\\\\:]*)?)$", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "working_dir": { + "description": "Absolute working directory for the container process. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/app" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + } + }, + "type": "object" + }, + "description": "Application containers, workers, daemons, and jobs managed as releases.", + "type": "object" + } + }, + "required": [ + "api_version", + "environments" + ], + "title": "Onebox project (onebox.run/v2)", + "type": "object" +} diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index 6a1dad3a..47eac2df 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -18,7 +18,7 @@