From b306e35e70cc9dfab22da683c8acdf361ab2390e Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 20 Sep 2026 18:02:01 -0700 Subject: [PATCH 1/3] feat(spec)!: reset Application contract to v1alpha1 Adopt the Kubernetes-shaped onebox.run/v1alpha1 resource, publish and embed its schema, migrate all fixtures and docs, and remove the v1 compatibility surface. BREAKING CHANGE: onebox.run/v1, snake_case declarative fields, lowercase enum values, root workload shorthand, aliases, and fallbacks are no longer accepted. --- README.md | 23 +- .../v1alpha1/application.schema.json | 2554 ++++++++++++++ api/application/v1alpha1/schema.go | 9 + .../application/invalid/extension.yml | 9 + .../application/invalid/legacy-v1.yml | 5 + .../application/invalid/root-shorthand.yml | 8 + .../application/invalid/snake-case.yml | 11 + api/testdata/application/valid/minimal.yml | 15 + cmd/ob-docgen/main.go | 47 +- cmd/ob-docgen/main_test.go | 24 +- cmd/ob/backup.go | 2 +- cmd/ob/commands_test.go | 42 +- cmd/ob/doctor_test.go | 4 +- cmd/ob/init.go | 34 +- cmd/ob/init_test.go | 20 +- cmd/ob/job.go | 2 +- cmd/ob/main_test.go | 35 +- cmd/ob/ops_contract_test.go | 38 +- cmd/ob/output_test.go | 82 +- cmd/ob/preview.go | 2 +- cmd/ob/preview_test.go | 55 +- cmd/ob/schema.go | 4 +- docs/README.md | 1 + .../2026-09-20-application-v1alpha1.md | 216 ++ docs/onebox.run-v1.schema.json | 3119 ----------------- e2e/apps/README.md | 2 +- e2e/apps/authentik.yml | 73 +- e2e/apps/ghost.yml | 54 +- e2e/apps/gitea.yml | 57 +- e2e/apps/immich.yml | 82 +- e2e/apps/n8n.yml | 93 +- e2e/apps/paperless.yml | 83 +- e2e/apps/penpot.yml | 74 +- e2e/apps/rocketchat.yml | 55 +- e2e/apps/umami.yml | 47 +- e2e/apps/uptime-kuma.yml | 21 +- e2e/apps/vaultwarden.yml | 27 +- e2e/destroy_test.go | 18 +- e2e/e2e_test.go | 4 +- e2e/network_ownership_test.go | 14 +- e2e/ops_test.go | 6 +- e2e/server_execution_test.go | 74 +- e2e/testdata/app/ob.yml | 49 +- e2e/testdata/postgres/ob.yml.tmpl | 221 +- e2e/testdata/worker/ob-broken.yml | 45 +- e2e/testdata/worker/ob.yml | 45 +- internal/app/application_fixture_test.go | 244 ++ internal/app/backup_schema_test.go | 170 +- internal/app/canonical.go | 33 +- internal/app/canonical_test.go | 188 +- internal/app/compose_test.go | 52 +- internal/app/contract.go | 315 ++ internal/app/contract_shapes_test.go | 55 +- internal/app/eject.go | 21 +- internal/app/eject_test.go | 88 +- internal/app/ejection_contract_test.go | 49 +- internal/app/environment_model_test.go | 322 +- internal/app/equivalence_test.go | 4 +- internal/app/errors.go | 9 +- internal/app/errors_test.go | 4 +- internal/app/external_schema_test.go | 103 +- internal/app/generate_test.go | 258 +- internal/app/health_timing_test.go | 32 +- internal/app/jsonschema.go | 188 +- internal/app/jsonschema_test.go | 104 +- internal/app/jump_config_test.go | 22 +- internal/app/load.go | 135 +- internal/app/load_test.go | 456 ++- internal/app/names_test.go | 59 +- internal/app/naming_scope_test.go | 56 +- internal/app/preflight_test.go | 94 +- internal/app/purity_test.go | 137 +- internal/app/resolve_test.go | 142 +- internal/app/route_test.go | 32 +- internal/app/schedule_test.go | 217 +- internal/app/secrets_graph_test.go | 164 +- internal/app/service_extensions_test.go | 155 +- internal/app/services_test.go | 161 +- internal/app/shape.go | 27 +- internal/app/testdata/contract-verdicts.json | 10 +- internal/app/testdata/corpus/README.md | 2 +- .../testdata/corpus/ext-authentik-managed.yml | 85 +- .../app/testdata/corpus/ext-authentik.yml | 69 +- internal/app/testdata/corpus/ext-frigate.yml | 39 +- internal/app/testdata/corpus/ext-gitea.yml | 54 +- .../testdata/corpus/ext-immich-sourced.yml | 59 +- internal/app/testdata/corpus/ext-immich.yml | 55 +- internal/app/testdata/corpus/ext-n8n.yml | 69 +- .../app/testdata/corpus/ext-paperless.yml | 71 +- .../app/testdata/corpus/ext-plausible.yml | 98 +- internal/app/testdata/corpus/ext-umami.yml | 38 +- internal/app/testdata/corpus/goal.yml | 83 +- internal/app/testdata/corpus/monk.yml | 119 +- internal/app/testdata/corpus/pursue.yml | 91 +- internal/app/testdata/corpus/recast.yml | 107 +- internal/app/types.go | 11 +- internal/app/workload_contract_test.go | 32 +- internal/engine/backup_identity_test.go | 47 +- internal/engine/deploy_test.go | 28 +- internal/engine/finalize_test.go | 20 +- internal/engine/fixtures_test.go | 66 +- internal/engine/host_environment_test.go | 20 +- internal/engine/ops_test.go | 2 +- internal/engine/recovery_test.go | 4 +- internal/engine/resume_test.go | 26 +- internal/engine/rollback_test.go | 18 +- .../engine/secret_generation_rolling_test.go | 37 +- internal/engine/secret_generation_test.go | 34 +- internal/engine/secretspush_test.go | 57 +- internal/engine/verify_injection_test.go | 28 +- internal/onebox/backup_evidence_test.go | 8 +- internal/onebox/bootstrap_test.go | 25 +- internal/onebox/exec_test.go | 27 +- internal/onebox/execution_boundary_test.go | 2 +- internal/onebox/job_plan_test.go | 12 +- internal/onebox/jump_route_test.go | 48 +- internal/onebox/load_service_runtime_test.go | 13 +- internal/onebox/operation_graph_test.go | 52 +- internal/onebox/secrets_push_test.go | 46 +- internal/onebox/service_test.go | 117 +- internal/onebox/staging_secrets_test.go | 86 +- internal/onebox/workload_contract_test.go | 94 +- internal/onebox/workload_plan_test.go | 75 +- internal/proxy/proxy_test.go | 23 +- site/public/onebox.run-v1.schema.json | 3119 ----------------- .../v1alpha1/application.schema.json | 2554 ++++++++++++++ site/src/components/landing/Derivation.astro | 2 +- .../explanation/evidence-not-declaration.mdx | 2 +- .../docs/explanation/generated-compose.mdx | 2 +- .../docs/explanation/what-onebox-refuses.mdx | 4 +- .../content/docs/guides/add-a-database.mdx | 4 +- .../src/content/docs/guides/adopt-compose.mdx | 4 +- .../docs/guides/back-up-a-database.mdx | 4 +- .../docs/guides/environment-variables.mdx | 2 +- .../content/docs/guides/handle-secrets.mdx | 20 +- site/src/content/docs/guides/roll-back.mdx | 4 +- .../content/docs/guides/run-migrations.mdx | 18 +- .../content/docs/guides/schedule-a-job.mdx | 58 +- site/src/content/docs/reference/cli.mdx | 14 +- site/src/content/docs/reference/errors.mdx | 9 +- .../docs/reference/fields/backup-targets.mdx | 45 + .../docs/reference/fields/backup_targets.mdx | 45 - .../content/docs/reference/fields/checks.mdx | 14 +- .../docs/reference/fields/deployment.mdx | 6 +- .../docs/reference/fields/environments.mdx | 28 +- ...nal_services.mdx => external-services.mdx} | 12 +- .../docs/reference/fields/notifications.mdx | 4 +- .../content/docs/reference/fields/proxy.mdx | 10 +- .../docs/reference/fields/registries.mdx | 4 +- .../content/docs/reference/fields/runtime.mdx | 16 +- .../docs/reference/fields/services.mdx | 12 +- .../docs/reference/fields/top-level.mdx | 43 +- .../docs/reference/fields/workloads.mdx | 64 +- site/src/content/docs/reference/naming.mdx | 6 +- site/src/content/docs/reference/policies.mdx | 2 +- .../content/docs/reference/project-file.mdx | 84 +- .../content/docs/start/reading-it-back.mdx | 9 +- site/src/content/docs/status/capabilities.mdx | 6 +- 158 files changed, 10246 insertions(+), 9692 deletions(-) create mode 100644 api/application/v1alpha1/application.schema.json create mode 100644 api/application/v1alpha1/schema.go create mode 100644 api/testdata/application/invalid/extension.yml create mode 100644 api/testdata/application/invalid/legacy-v1.yml create mode 100644 api/testdata/application/invalid/root-shorthand.yml create mode 100644 api/testdata/application/invalid/snake-case.yml create mode 100644 api/testdata/application/valid/minimal.yml create mode 100644 docs/decisions/2026-09-20-application-v1alpha1.md delete mode 100644 docs/onebox.run-v1.schema.json create mode 100644 internal/app/application_fixture_test.go create mode 100644 internal/app/contract.go delete mode 100644 site/public/onebox.run-v1.schema.json create mode 100644 site/public/schemas/application/v1alpha1/application.schema.json create mode 100644 site/src/content/docs/reference/fields/backup-targets.mdx delete mode 100644 site/src/content/docs/reference/fields/backup_targets.mdx rename site/src/content/docs/reference/fields/{external_services.mdx => external-services.mdx} (74%) diff --git a/README.md b/README.md index 3a8d3390..d06eef6c 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,20 @@ 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 -app: shop -environments: - production: - server: root@203.0.113.10 -image: ghcr.io/acme/shop:1.4.0 -routes: - - {hostname: shop.example.com, port: 3000} +# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha1/application.schema.json +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: root@203.0.113.10 + workloads: + web: + image: ghcr.io/acme/shop:1.4.0 + routes: + - {hostname: shop.example.com, port: 3000} ``` It derives the application container, Traefik routing and TLS, release layout diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha1/application.schema.json new file mode 100644 index 00000000..14270a02 --- /dev/null +++ b/api/application/v1alpha1/application.schema.json @@ -0,0 +1,2554 @@ +{ + "$id": "https://onebox.run/schemas/application/v1alpha1/application.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "One application, its workloads, the services it needs, and how a release rolls out.", + "properties": { + "apiVersion": { + "const": "onebox.run/v1alpha1", + "description": "Authored Application API identity.", + "type": "string" + }, + "kind": { + "const": "Application", + "description": "Authored resource kind.", + "type": "string" + }, + "metadata": { + "additionalProperties": false, + "description": "Application identity and opaque user metadata.", + "properties": { + "annotations": { + "additionalProperties": { + "type": "string" + }, + "description": "Opaque user metadata that never affects plans or runtime behavior.", + "type": "object" + }, + "name": { + "description": "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. Stable application name used in generated runtime identities.", + "examples": [ + "shop" + ], + "not": { + "anyOf": [ + { + "pattern": "^ob-" + }, + { + "const": "ob" + }, + { + "const": "onebox-proxy" + }, + { + "const": "_host" + } + ] + }, + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "spec": { + "additionalProperties": false, + "description": "Desired Onebox application configuration.", + "properties": { + "backupTargets": { + "additionalProperties": { + "additionalProperties": false, + "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.", + "properties": { + "accessKeyEntry": { + "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" + }, + "secretKeyEntry": { + "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" + }, + "sessionTokenEntry": { + "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.", + "properties": { + "cold": { + "description": "Encryption mode required for cold recovery: client-side or server-side.", + "enum": [ + "ClientSide", + "ServerSide" + ], + "type": "string" + }, + "pitr": { + "description": "Encryption mode required for point-in-time recovery: client-side or server-side.", + "enum": [ + "ClientSide", + "ServerSide" + ], + "type": "string" + }, + "snapshot": { + "description": "Encryption mode required for snapshot recovery: client-side or server-side.", + "enum": [ + "ClientSide", + "ServerSide" + ], + "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" + }, + "failureDomain": { + "additionalProperties": false, + "description": "Operator-declared identity used to prove the destination does not share the protected host.", + "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": [ + "S3Compatible" + ], + "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", + "SkipVerify" + ], + "type": "string" + } + }, + "type": "object" + }, + "description": "User-owned off-host repositories available to service backup policies.", + "type": "object" + }, + "basePath": { + "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" + }, + "checks": { + "additionalProperties": false, + "description": "Assertions that must pass before a release becomes current unless marked advisory.", + "properties": { + "exec": { + "description": "Commands run inside a named workload.", + "items": { + "additionalProperties": false, + "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, + "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, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "appliedRevisions": { + "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, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "contains": { + "description": "Text the response body must contain.", + "type": "string" + }, + "jsonAssertions": { + "description": "Scalar JSON response values that must match exactly.", + "items": { + "additionalProperties": false, + "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" + }, + "requiredHeaders": { + "additionalProperties": { + "type": "string" + }, + "description": "Exact response headers required for success.", + "type": "object" + }, + "statusCodes": { + "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" + }, + "deployment": { + "additionalProperties": false, + "description": "Release ordering, retention, and migration behavior.", + "properties": { + "migrationPolicy": { + "default": "Manual", + "description": "Policy for migration jobs during release and recovery.", + "enum": [ + "Manual", + "Auto", + "ExpandOnly" + ], + "type": "string" + }, + "order": { + "description": "Explicit workload release order. Dependency order is derived when omitted.", + "items": { + "type": "string" + }, + "type": "array" + }, + "retainReleases": { + "default": 5, + "description": "Number of completed release directories retained for inspection and rollback.", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "environments": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "basePath": { + "description": "Environment-specific replacement for the Application basePath. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/srv/ob" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "envFiles": { + "description": "Default ordered environment-file list for application, worker, and job workloads in this environment.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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" + ], + "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.", + "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, envFiles, strategy, and routes.", + "type": "object" + } + }, + "type": "object" + }, + "policy": { + "additionalProperties": false, + "description": "Approval, runner compatibility, and migration-backup requirements for this environment.", + "properties": { + "allowAgentProposals": { + "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.", + "properties": { + "backupKeyMaterial": { + "description": "Key-material identities the backup report must name.", + "examples": [ + [ + "BACKUP_ACCESS_KEY_ID" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "backupMaxAge": { + "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" + }, + "requireBackup": { + "default": false, + "description": "Require a plan-bound backup report before a release with migration risk.", + "type": "boolean" + }, + "requireRestoreTest": { + "default": false, + "description": "Require the backup report to state that a restore test succeeded.", + "type": "boolean" + } + }, + "type": "object" + }, + "minOneboxVersion": { + "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" + }, + "minPlanSchema": { + "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" + }, + "requireApproval": { + "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" + ], + "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" + }, + "externalServices": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "backupOwner": { + "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.", + "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.", + "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.", + "properties": { + "kind": { + "default": "DriverHealth", + "description": "Read-only observation kind: driver-health.", + "enum": [ + "DriverHealth" + ], + "type": "string" + }, + "maxAge": { + "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" + }, + "hooks": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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" + }, + "notifications": { + "additionalProperties": { + "additionalProperties": false, + "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" + }, + "proxy": { + "additionalProperties": false, + "description": "Ownership and configuration of the host ingress proxy.", + "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" + }, + "dnsChallenge": { + "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.", + "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, + "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": "TraefikDocker", + "description": "Proxy implementation, or none to disable routing.", + "enum": [ + "TraefikDocker", + "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, + "properties": { + "passwordEnv": { + "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" + }, + "runtime": { + "additionalProperties": false, + "description": "Project-wide environment files and local environment-file requirements.", + "properties": { + "envChecks": { + "description": "Local environment-file assertions checked before planning or deploying.", + "items": { + "additionalProperties": false, + "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" + }, + "envFiles": { + "description": "Project-wide ordered environment-file list for application, worker, and job workloads.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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, + "properties": { + "backup": { + "additionalProperties": false, + "description": "Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup.", + "properties": { + "allowDowntime": { + "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.", + "properties": { + "maxAge": { + "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.", + "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" + }, + "maxDataLoss": { + "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" + }, + "recoveryKind": { + "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.", + "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.", + "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.", + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": false, + "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.", + "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.", + "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": { + "dataEffect": { + "const": "None" + }, + "deploymentPhase": { + "const": "None" + }, + "operatorRun": { + "const": "Allowed" + } + }, + "required": [ + "schedule", + "dataEffect" + ] + } + }, + { + "oneOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + } + ] + }, + { + "not": { + "allOf": [ + { + "required": [ + "publishedPorts" + ] + }, + { + "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": [ + "deploymentPhase" + ] + }, + { + "required": [ + "operatorRun" + ] + }, + { + "required": [ + "dataEffect" + ] + }, + { + "required": [ + "schedule" + ] + }, + { + "required": [ + "inputs" + ] + }, + { + "required": [ + "execution" + ] + } + ] + } + }, + "if": { + "properties": { + "role": { + "const": "Job" + } + }, + "required": [ + "role" + ] + }, + "then": { + "required": [ + "dataEffect" + ] + } + } + ], + "properties": { + "build": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Build metadata for development. Production requires a resolved image supplied with --image.", + "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" + }, + "dataEffect": { + "description": "Job data impact used by rollback and abort gates.", + "enum": [ + "None", + "Migration", + "Destructive", + "Unknown" + ], + "examples": [ + "migration" + ], + "type": "string" + }, + "deploymentPhase": { + "default": "None", + "description": "Deployment phase for this job: none, pre_release, or post_release.", + "enum": [ + "None", + "PreRelease", + "PostRelease" + ], + "type": "string" + }, + "drain": { + "additionalProperties": false, + "description": "Signal and timing used to remove a container from traffic before stopping it.", + "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" + }, + "envFiles": { + "description": "Workload-specific ordered environment-file list. Replaces broader defaults when present.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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 dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume.", + "properties": { + "retention": { + "default": "168h", + "description": "Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "steps": { + "description": "Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps.", + "items": { + "additionalProperties": false, + "properties": { + "command": { + "description": "Argument vector passed to the job image's entrypoint. No shell evaluation is performed.", + "items": { + "type": "string" + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + }, + "id": { + "description": "Unique stable step identifier, used by output references. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "inputs": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables populated from a preceding step's declared output, written as step.OUTPUT.", + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "type": "object" + }, + "outputs": { + "description": "Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total.", + "items": { + "description": "Expects upper-case letters, digits and underscores, starting with a letter.", + "pattern": "^[A-Z][A-Z0-9_]*$", + "type": "string" + }, + "maxItems": 32, + "type": "array", + "uniqueItems": true + }, + "retry": { + "additionalProperties": false, + "description": "Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout.", + "properties": { + "attempts": { + "default": 1, + "description": "Total attempts including the first, 1 to 10.", + "examples": [ + 3 + ], + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "backoff": { + "default": "30s", + "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "1m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "maxBackoff": { + "default": "10m", + "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "30m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "id", + "command" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + } + }, + "type": "object" + }, + "extraHosts": { + "description": "Additional host-to-address entries added to the container.", + "items": { + "type": "string" + }, + "type": "array" + }, + "health": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Readiness check used to gate rolling replacement.", + "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" + }, + "startPeriod": { + "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" + ], + "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" + ] + } + ], + "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.", + "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, + "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" + }, + "operatorRun": { + "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.", + "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" + }, + "publishedPorts": { + "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, + "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.", + "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" + ] + } + } + ], + "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.", + "properties": { + "catchUp": { + "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" + }, + "deployLock": { + "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.", + "properties": { + "attempts": { + "default": 1, + "description": "Total attempts including the first, 1 to 10.", + "examples": [ + 3 + ], + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "backoff": { + "default": "30s", + "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "1m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "maxBackoff": { + "default": "10m", + "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "30m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + }, + "shutdownGrace": { + "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" + }, + "stdinOpen": { + "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" + ] + } + ], + "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" + }, + "workingDir": { + "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.", + "minProperties": 1, + "type": "object" + } + }, + "required": [ + "environments", + "workloads" + ], + "type": "object" + } + }, + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "title": "Onebox Application (onebox.run/v1alpha1)", + "type": "object" +} diff --git a/api/application/v1alpha1/schema.go b/api/application/v1alpha1/schema.go new file mode 100644 index 00000000..896dcd99 --- /dev/null +++ b/api/application/v1alpha1/schema.go @@ -0,0 +1,9 @@ +// Package applicationv1alpha1 embeds the public Onebox Application contract. +package applicationv1alpha1 + +import _ "embed" + +// Schema is the exact JSON Schema published for onebox.run/v1alpha1. +// +//go:embed application.schema.json +var Schema []byte diff --git a/api/testdata/application/invalid/extension.yml b/api/testdata/application/invalid/extension.yml new file mode 100644 index 00000000..6920ee7d --- /dev/null +++ b/api/testdata/application/invalid/extension.yml @@ -0,0 +1,9 @@ +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: shop} +spec: + environments: + production: {server: deploy@example.com} + workloads: + web: {image: nginx:1.27} + x-rollout-mode: unsafe diff --git a/api/testdata/application/invalid/legacy-v1.yml b/api/testdata/application/invalid/legacy-v1.yml new file mode 100644 index 00000000..91e0a9b2 --- /dev/null +++ b/api/testdata/application/invalid/legacy-v1.yml @@ -0,0 +1,5 @@ +api_version: onebox.run/v1 +app: shop +environments: + production: {server: deploy@example.com} +image: nginx:1.27 diff --git a/api/testdata/application/invalid/root-shorthand.yml b/api/testdata/application/invalid/root-shorthand.yml new file mode 100644 index 00000000..e3831037 --- /dev/null +++ b/api/testdata/application/invalid/root-shorthand.yml @@ -0,0 +1,8 @@ +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: shop} +spec: + environments: + production: {server: deploy@example.com} + workloads: {} +image: nginx:1.27 diff --git a/api/testdata/application/invalid/snake-case.yml b/api/testdata/application/invalid/snake-case.yml new file mode 100644 index 00000000..3f87e49b --- /dev/null +++ b/api/testdata/application/invalid/snake-case.yml @@ -0,0 +1,11 @@ +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: shop} +spec: + environments: + production: {server: deploy@example.com} + workloads: + web: + role: application + image: nginx:1.27 + data_effect: none diff --git a/api/testdata/application/valid/minimal.yml b/api/testdata/application/valid/minimal.yml new file mode 100644 index 00000000..234707ee --- /dev/null +++ b/api/testdata/application/valid/minimal.yml @@ -0,0 +1,15 @@ +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop + annotations: + example.com/owner: platform +spec: + environments: + production: + server: deploy@example.com + workloads: + web: + role: Application + image: nginx:1.27 + strategy: Recreate diff --git a/cmd/ob-docgen/main.go b/cmd/ob-docgen/main.go index 9ad4bf5e..0e6a1339 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 below `site/public/schemas/application/v1alpha1/`. 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{"schemas/application/v1alpha1/application.schema.json": string(schema)} if check { if err := verify(out, files); err != nil { @@ -220,6 +220,29 @@ func loadSchema() (map[string]any, error) { return schema, nil } +// schemaProperties presents the fields inside spec as the project-file blocks +// while retaining the three resource-envelope fields on the top-level page. +func schemaProperties(schema map[string]any) (map[string]any, map[string]bool, error) { + envelope, _ := schema["properties"].(map[string]any) + spec, _ := envelope["spec"].(map[string]any) + specProps, _ := spec["properties"].(map[string]any) + if envelope == nil || specProps == nil { + return nil, nil, fmt.Errorf("the JSON Schema has no Application spec properties") + } + props := map[string]any{} + for _, name := range []string{"apiVersion", "kind", "metadata"} { + props[name] = envelope[name] + } + for name, node := range specProps { + props[name] = node + } + required := stringSet(spec["required"]) + for _, name := range []string{"apiVersion", "kind", "metadata"} { + required[name] = true + } + return props, required, nil +} + // field is one row of a reference table: a dotted path, its type, its default // and its description, flattened out of the schema's nesting. type field struct { @@ -234,6 +257,7 @@ type field struct { // block is one generated page: a top-level key and every field beneath it. type block struct { Key string + Slug string Title string Summary string Status status @@ -311,20 +335,19 @@ var blocks = []block{ {Key: "notifications", Title: "notifications", Order: 100, Status: statusShipped, Summary: "Named webhooks that receive selected operation and scheduled-job outcomes.", ReadWhen: []string{"Sending deploy outcomes to Slack, Discord or an incident tool"}}, - {Key: "backup_targets", Title: "backup_targets", Order: 200, Status: statusShipped, + {Key: "backupTargets", Slug: "backup-targets", Title: "backupTargets", Order: 200, Status: statusShipped, Summary: "User-owned off-host S3-compatible repositories a protected service writes its backups to. Executable for the postgres driver; every other driver refuses a policy rather than accepting one it cannot honour.", ReadWhen: []string{"Declaring where a database's backups go", "Understanding why Onebox refuses a backup target that shares the protected host"}}, - {Key: "external_services", Title: "external_services", Order: 210, Status: statusSchemaOnly, + {Key: "externalServices", Slug: "external-services", Title: "externalServices", Order: 210, Status: statusSchemaOnly, Summary: "Typed dependencies operated outside Onebox, whose lifecycle and backups stay external. Accepted by the loader; not yet executable.", ReadWhen: []string{"Modelling an RDS, Neon, Supabase or Upstash dependency"}}, } func renderFieldPages(schema map[string]any) (map[string]string, error) { - props, _ := schema["properties"].(map[string]any) - if props == nil { - return nil, fmt.Errorf("the JSON Schema has no top-level properties") + props, required, err := schemaProperties(schema) + if err != nil { + return nil, err } - required := stringSet(schema["required"]) // `blocks` is a template, not an accumulator. Aliasing it and appending // through the pointers made every field permanent process state, so a second @@ -343,7 +366,7 @@ func renderFieldPages(schema map[string]any) (map[string]string, error) { // named. top := &block{ Key: "top-level", Title: "Top level", Order: 1, Status: statusShipped, - Summary: "Required keys, project-wide scalars, and the single-workload shorthand.", + Summary: "The Application envelope and project-wide fields inside spec.", ReadWhen: []string{ "Starting a new project file", "Working out which keys are required", @@ -403,7 +426,11 @@ func renderFieldPages(schema map[string]any) (map[string]string, error) { for _, b := range all { sort.SliceStable(b.Fields, func(i, j int) bool { return b.Fields[i].Path < b.Fields[j].Path }) - name := "fields/" + b.Key + ".mdx" + slug := b.Slug + if slug == "" { + slug = b.Key + } + name := "fields/" + slug + ".mdx" pages[name] = renderBlockPage(b) } return pages, nil diff --git a/cmd/ob-docgen/main_test.go b/cmd/ob-docgen/main_test.go index b0ee367b..dc9ef5cc 100644 --- a/cmd/ob-docgen/main_test.go +++ b/cmd/ob-docgen/main_test.go @@ -74,7 +74,10 @@ func TestEveryTopLevelSchemaKeyAppearsOnSomePage(t *testing.T) { t.Fatalf("cannot render field pages: %v", err) } - props, _ := schema["properties"].(map[string]any) + props, _, err := schemaProperties(schema) + if err != nil { + t.Fatal(err) + } if len(props) == 0 { t.Fatal("the schema has no top-level properties") } @@ -84,7 +87,13 @@ func TestEveryTopLevelSchemaKeyAppearsOnSomePage(t *testing.T) { // when it owns a page or has a table row — not when its name occurs in a // sentence. for key := range props { - if _, hasPage := pages["fields/"+key+".mdx"]; hasPage { + slug := key + for _, block := range blocks { + if block.Key == key && block.Slug != "" { + slug = block.Slug + } + } + if _, hasPage := pages["fields/"+slug+".mdx"]; hasPage { continue } documented := false @@ -278,7 +287,7 @@ func TestUnclaimedTopLevelKeysDocumentTheirSubtree(t *testing.T) { t.Fatalf("cannot render: %v", err) } page := pages["fields/top-level.mdx"] - for _, path := range []string{"routes[].hostname", "health.http", "image.reference", "build.context"} { + for _, path := range []string{"apiVersion", "kind", "metadata.name", "metadata.annotations"} { if !strings.Contains(page, "`"+path+"`") { t.Errorf("top-level.mdx does not document %q", path) } @@ -292,7 +301,10 @@ func TestEveryRegisteredBlockMatchesASchemaKey(t *testing.T) { if err != nil { t.Fatalf("cannot load the schema: %v", err) } - props, _ := schema["properties"].(map[string]any) + props, _, err := schemaProperties(schema) + if err != nil { + t.Fatal(err) + } for _, b := range blocks { if _, ok := props[b.Key]; !ok { t.Errorf("block %q has no matching top-level schema key", b.Key) @@ -318,12 +330,12 @@ func TestPublishedSchemaMatchesTheCheckedInCopy(t *testing.T) { } // Skipping on a read failure would turn "someone moved the file" into a // passing test, which is the drift this exists to catch. - onDisk, err := os.ReadFile(filepath.Join("..", "..", "docs", "onebox.run-v1.schema.json")) + onDisk, err := os.ReadFile(filepath.Join("..", "..", "api", "application", "v1alpha1", "application.schema.json")) if err != nil { t.Fatalf("the checked-in schema must be readable: %v", err) } if strings.TrimSpace(string(generated)) != strings.TrimSpace(string(onDisk)) { - t.Error("the published schema differs from docs/onebox.run-v1.schema.json") + t.Error("the published schema differs from api/application/v1alpha1/application.schema.json") } } diff --git a/cmd/ob/backup.go b/cmd/ob/backup.go index 7779b5bd..38129ba9 100644 --- a/cmd/ob/backup.go +++ b/cmd/ob/backup.go @@ -28,7 +28,7 @@ func addBackupCommands(root *cobra.Command, g *globalFlags) { Short: "protect a data service and inspect what can be recovered", Long: "Backup and recovery for the data services this project declares.\n\n" + "Backup is physical: a base backup plus continuous WAL archiving to the\n" + - "off-host repository the project's backup_targets name, which is what makes\n" + + "off-host repository the project's backupTargets name, which is what makes\n" + "recovery to a point in time possible rather than recovery to last night.\n\n" + "Declaring a policy does not establish it. `ob backup enable` restarts the\n" + "service with archiving on, stages the verified backup tooling, and takes\n" + diff --git a/cmd/ob/commands_test.go b/cmd/ob/commands_test.go index 358ef3c6..d7b5f153 100644 --- a/cmd/ob/commands_test.go +++ b/cmd/ob/commands_test.go @@ -62,14 +62,16 @@ func TestConfirmInteractiveDeployRequiresConfirmationWithoutPolicyApproval(t *te func writeProject(t *testing.T) string { t.Helper() dir := t.TempDir() - obYAML := ` -api_version: onebox.run/v1 -app: demo -environments: { production: { server: deploy@example.invalid } } -workloads: - web: { role: application, image: ghcr.io/x/app:v1, health: { http: /healthz, port: 8080 } } - postgres: { role: daemon, image: postgres:17, persistence: { mode: durable } } -deployment: { order: [web] } + obYAML := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: {production: {server: deploy@example.invalid}} + workloads: + web: {role: Application, image: 'ghcr.io/x/app:v1', health: {http: /healthz, port: 8080}} + postgres: {role: Daemon, image: 'postgres:17', persistence: {mode: Durable}} + deployment: {order: [web]} ` if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(obYAML), 0o644); err != nil { t.Fatal(err) @@ -101,17 +103,19 @@ func TestValidateOK(t *testing.T) { func TestPreflightBlocksDeploy(t *testing.T) { dir := writeProject(t) - obYAML := ` -api_version: onebox.run/v1 -app: demo -environments: { production: { server: deploy@example.invalid } } -workloads: - web: { role: application, image: ghcr.io/x/app:v1, health: { http: /healthz, port: 8080 } } - postgres: { role: daemon, image: postgres:17, persistence: { mode: durable } } -deployment: { order: [web] } -runtime: - env_checks: - - { file: secrets.env, require: [MISSING_KEY] } + obYAML := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: {production: {server: deploy@example.invalid}} + workloads: + web: {role: Application, image: 'ghcr.io/x/app:v1', health: {http: /healthz, port: 8080}} + postgres: {role: Daemon, image: 'postgres:17', persistence: {mode: Durable}} + deployment: {order: [web]} + runtime: + envChecks: + - {file: secrets.env, require: [MISSING_KEY]} ` if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(obYAML), 0o644); err != nil { t.Fatal(err) diff --git a/cmd/ob/doctor_test.go b/cmd/ob/doctor_test.go index 124eb52a..f8225bcf 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: app.APIVersion, 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: app.APIVersion, 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..f18ac334 100644 --- a/cmd/ob/init.go +++ b/cmd/ob/init.go @@ -98,10 +98,10 @@ 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") - fmt.Fprintf(&b, "app: %s\n", application) - b.WriteString("environments:\n production:\n server: deploy@CHANGE-ME\n") - b.WriteString("workloads:\n") + b.WriteString("apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n") + fmt.Fprintf(&b, " name: %s\n", application) + b.WriteString("spec:\n environments:\n production:\n server: deploy@CHANGE-ME\n") + b.WriteString(" workloads:\n") for _, name := range componentNames { typ := types[name] role := map[string]string{ @@ -113,25 +113,25 @@ func runInit(ctx context.Context, cmd *cobra.Command, g *globalFlags) error { // declaration says what it is; nothing is guessed from the image. role = "daemon" } - fmt.Fprintf(&b, " %s:\n role: %s\n", name, role) + fmt.Fprintf(&b, " %s:\n role: %s\n", name, upperCamelValue(role)) // The workload keeps referencing the Compose service it came from, so // adoption changes nothing about how it runs on the first deploy. Move // fields into the declaration when you want Onebox to own them. - fmt.Fprintf(&b, " compose: %q\n", composePath+"#"+name) + fmt.Fprintf(&b, " compose: %q\n", composePath+"#"+name) switch role { case "application", "worker": strategy := "recreate" if rolling[name] { strategy = "rolling" } - fmt.Fprintf(&b, " strategy: %s\n", strategy) + fmt.Fprintf(&b, " strategy: %s\n", upperCamelValue(strategy)) if strategy == "rolling" { path, port, ok := inferHTTPReadiness(p.Services[name]) switch { case ok: - fmt.Fprintf(&b, " health: { http: %s, port: %d }\n", path, port) + fmt.Fprintf(&b, " health: { http: %s, port: %d }\n", path, port) case p.Services[name].HealthCheck == nil: - b.WriteString(" health: { http: /healthz, port: CHANGE-ME }\n") + b.WriteString(" health: { http: /healthz, port: CHANGE-ME }\n") } } case "job": @@ -139,7 +139,7 @@ func runInit(ctx context.Context, cmd *cobra.Command, g *globalFlags) error { if isMigration(name, p.Services[name]) { effect = "migration" } - fmt.Fprintf(&b, " data_effect: %s\n", effect) + fmt.Fprintf(&b, " dataEffect: %s\n", upperCamelValue(effect)) case "daemon": // Durability is scaffolded from what the image is, not from // whether a volume happens to be declared. A Postgres written @@ -152,12 +152,12 @@ func runInit(ctx context.Context, cmd *cobra.Command, g *globalFlags) error { case len(p.Services[name].Volumes) > 0: mode = "durable" } - fmt.Fprintf(&b, " persistence: { mode: %s }\n", mode) + fmt.Fprintf(&b, " persistence: { mode: %s }\n", upperCamelValue(mode)) } } if len(workloads) > 0 { - b.WriteString("deployment:\n") - fmt.Fprintf(&b, " order: [%s]\n", strings.Join(workloads, ", ")) + b.WriteString(" deployment:\n") + fmt.Fprintf(&b, " order: [%s]\n", strings.Join(workloads, ", ")) } if err := os.WriteFile(g.ConfigPath, []byte(b.String()), 0o644); err != nil { return writeStructuredCommandFailure(cmd, g, "config_write_failed", "the project file could not be written", err) @@ -208,6 +208,14 @@ func runInit(ctx context.Context, cmd *cobra.Command, g *globalFlags) error { return nil } +func upperCamelValue(value string) string { + parts := strings.FieldsFunc(value, func(r rune) bool { return r == '-' || r == '_' }) + for i, part := range parts { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, "") +} + func classify(name string, svc ctypes.ServiceConfig) string { if isMigration(name, svc) { return "job" diff --git a/cmd/ob/init_test.go b/cmd/ob/init_test.go index fc181bce..3b9e10f5 100644 --- a/cmd/ob/init_test.go +++ b/cmd/ob/init_test.go @@ -48,18 +48,18 @@ func TestInitClassifiesAndDoctors(t *testing.T) { } y := string(b) for _, want := range []string{ - "api_version: onebox.run/v1", + "apiVersion: onebox.run/v1alpha1", "server: deploy@CHANGE-ME", "workloads:", - "role: application", - "role: worker", - "role: daemon", - "role: job", - "data_effect: migration", - "persistence: { mode: durable }", - "persistence: { mode: ephemeral }", - "strategy: rolling", - "strategy: recreate", + "role: Application", + "role: Worker", + "role: Daemon", + "role: Job", + "dataEffect: Migration", + "persistence: { mode: Durable }", + "persistence: { mode: Ephemeral }", + "strategy: Rolling", + "strategy: Recreate", "health: { http: /healthz, port: 7500 }", "order: [server, worker]", } { diff --git a/cmd/ob/job.go b/cmd/ob/job.go index 49a1d9a2..d068f4df 100644 --- a/cmd/ob/job.go +++ b/cmd/ob/job.go @@ -18,7 +18,7 @@ func addJobCommand(root *cobra.Command, g *globalFlags) { group := &cobra.Command{ Use: "job", Short: "plan and run a sealed one-shot operator job", - Long: "Plan and run one declared `operator_run: allowed` job against the current serving release.\n\nDeployment participation is independent: `deployment_phase` may be none, pre_release,\nor post_release. Saved plans bind the release, runtime digest, immutable image,\ndata effect and inputs so agents can obtain separate approval before execution.", + Long: "Plan and run one declared `operatorRun: Allowed` job against the current serving release.\n\nDeployment participation is independent: `deploymentPhase` may be None, PreRelease,\nor PostRelease. Saved plans bind the release, runtime digest, immutable image,\ndata effect and inputs so agents can obtain separate approval before execution.", Args: cobra.NoArgs, RunE: showCommandHelp, } diff --git a/cmd/ob/main_test.go b/cmd/ob/main_test.go index e05d458a..14cd4cbe 100644 --- a/cmd/ob/main_test.go +++ b/cmd/ob/main_test.go @@ -8,21 +8,28 @@ import ( "testing" ) -const mainTestProject = `api_version: onebox.run/v1 -app: demo -environments: {production: {server: deploy@example.invalid}} -image: nginx:1.27 -proxy: {kind: none} +const mainTestProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: {production: {server: deploy@example.invalid}} + proxy: {kind: None} + workloads: + demo: + image: nginx:1.27 ` - -const mainTestBuildProject = `api_version: onebox.run/v1 -app: demo -environments: {production: {server: deploy@example.invalid}} -workloads: - api: - role: application - build: {context: ., dockerfile: Dockerfile} -proxy: {kind: none} +const mainTestBuildProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: {production: {server: deploy@example.invalid}} + workloads: + api: + role: Application + build: {context: ., dockerfile: Dockerfile} + proxy: {kind: None} ` func TestRootHelpListsVerbs(t *testing.T) { diff --git a/cmd/ob/ops_contract_test.go b/cmd/ob/ops_contract_test.go index 105019ab..ffe1e564 100644 --- a/cmd/ob/ops_contract_test.go +++ b/cmd/ob/ops_contract_test.go @@ -18,18 +18,23 @@ func writeOpsContractProject(t *testing.T, dir string, encrypted bool) string { t.Helper() runtime := "" if encrypted { - runtime = "runtime:\n env_files: [{file: secrets.env, provider: sops}]\n" + runtime = " runtime:\n envFiles: [{file: secrets.env, provider: Sops}]\n" if err := os.WriteFile(filepath.Join(dir, "secrets.env"), []byte("encrypted-placeholder\n"), 0o600); err != nil { t.Fatal(err) } } path := filepath.Join(dir, "project.yml") - if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1 -app: shop -environments: - production: {server: deploy@example.invalid} -`+runtime+`image: nginx -`), 0o600); err != nil { + if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: deploy@example.invalid} + workloads: + web: {image: nginx} +`+ + runtime), 0o600); err != nil { t.Fatal(err) } return path @@ -94,7 +99,7 @@ func TestSecretsEditRequiresIDWhenSeveralEntriesExist(t *testing.T) { if err != nil { t.Fatal(err) } - data = bytes.Replace(data, []byte("runtime:\n env_files: [{file: secrets.env, provider: sops}]"), []byte("runtime:\n env_files: [{file: secrets.env, provider: sops}, {file: other.env, provider: sops}]"), 1) + data = bytes.Replace(data, []byte("runtime:\n envFiles: [{file: secrets.env, provider: Sops}]"), []byte("runtime:\n envFiles: [{file: secrets.env, provider: Sops}, {file: other.env, provider: Sops}]"), 1) if err := os.WriteFile(config, data, 0o600); err != nil { t.Fatal(err) } @@ -169,12 +174,17 @@ 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 -app: shop -environments: - production: {server: deploy@example.invalid} -image: nginx -services: {postgres: 17} + if err := os.WriteFile(config, []byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: deploy@example.invalid} + services: {postgres: 17} + workloads: + shop: + image: nginx `), 0o600); err != nil { t.Fatal(err) } diff --git a/cmd/ob/output_test.go b/cmd/ob/output_test.go index eb23a2af..940e2bb4 100644 --- a/cmd/ob/output_test.go +++ b/cmd/ob/output_test.go @@ -314,15 +314,20 @@ 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 -app: shop -environments: - production: {server: root@203.0.113.10} -runtime: - env_files: [.env.production] -image: nginx -routes: - - {hostname: shop.example.com, port: 3000} + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: root@203.0.113.10} + runtime: + envFiles: [.env.production] + workloads: + shop: + image: nginx + routes: + - {hostname: shop.example.com, port: 3000} `) writeFile(t, dir, ".env.production", "API_TOKEN=super-secret-value\nPUBLIC_MODE=on\n") @@ -347,18 +352,21 @@ routes: // 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 -app: shop -environments: - production: {server: root@203.0.113.10} -workloads: - web: - role: application - image: nginx - routes: - - {hostname: shop.example.com, port: 3000} - env: - API_TOKEN: super-secret-value + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: root@203.0.113.10} + workloads: + web: + role: Application + image: nginx + routes: + - {hostname: shop.example.com, port: 3000} + env: + API_TOKEN: super-secret-value `) for _, verb := range []string{"canonical", "preview"} { out, err := run(t, dir, verb, "--output", "json") @@ -424,11 +432,16 @@ 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 -app: shop -environments: - production: {server: root@203.0.113.10} -image: nginx + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: root@203.0.113.10} + workloads: + shop: + image: nginx `) out, err := run(t, dir, "eject", "--output", mode) if err != nil { @@ -455,12 +468,15 @@ image: nginx func TestStructuredReadFailuresEmitTypedSafeRecords(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1 -app: shop -environments: - production: {server: root@203.0.113.10} -workloads: - web: {role: application, image: nginx, replicaz: 3} + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: root@203.0.113.10} + workloads: + web: {role: Application, image: nginx, replicaz: 3} `) for _, verb := range []string{"validate", "canonical", "preview", "eject"} { out, err := run(t, dir, verb, "--output", "json") @@ -479,7 +495,7 @@ workloads: if record.SchemaVersion != cliSchemaVersion || record.Command != "ob "+verb || record.Outcome != cliOutcomeError || record.Error == nil { t.Fatalf("%s: incomplete failure record: %+v", verb, record) } - if record.Error.Code != "unknown_field" || record.Error.Path != "workloads.web.replicaz" { + if record.Error.Code != "unknown_field" || record.Error.Path != "spec.workloads.web.replicaz" { t.Errorf("%s: failure = %+v", verb, record.Error) } if strings.Contains(out, "did you mean") { diff --git a/cmd/ob/preview.go b/cmd/ob/preview.go index 7c2783ea..a7c64f3a 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 a onebox.run/v1alpha1 Application, resolve the environment's overrides, and print\n" + "the Compose runtime Onebox would generate, with its content digest.\n\n" + "Nothing is contacted and nothing is written. Environment values are redacted:\n" + "a preview must never put a secret on a terminal.", diff --git a/cmd/ob/preview_test.go b/cmd/ob/preview_test.go index 9e1d6eaa..832758e6 100644 --- a/cmd/ob/preview_test.go +++ b/cmd/ob/preview_test.go @@ -7,19 +7,22 @@ import ( "testing" ) -const previewProject = `api_version: onebox.run/v1 -app: demo -environments: - production: {server: root@1.2.3.4} - staging: {server: root@5.6.7.8, overrides: {workloads: {web: {replicas: 1}}}} -workloads: - web: - role: application - image: nginx:1.27 - replicas: 3 - routes: - - {hostname: demo.example.com, port: 8080} - env: {API_TOKEN: super-secret-value, LOG_LEVEL: info} +const previewProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: + production: {server: root@1.2.3.4} + staging: {server: root@5.6.7.8, overrides: {workloads: {web: {replicas: 1}}}} + workloads: + web: + role: Application + image: nginx:1.27 + replicas: 3 + routes: + - {hostname: demo.example.com, port: 8080} + env: {API_TOKEN: super-secret-value, LOG_LEVEL: info} ` func TestPreviewRendersAndRedacts(t *testing.T) { @@ -79,10 +82,13 @@ 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 -app: demo -environments: {production: {server: h}} -workloads: {web: {role: application, build: ., routes: [{hostname: d.example.com, port: 80}]}} + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: {production: {server: h}} + workloads: {web: {role: Application, build: ., routes: [{hostname: d.example.com, port: 80}]}} `) out, err := run(t, dir, "preview") if err == nil { @@ -133,12 +139,15 @@ 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 -app: ledger -environments: {production: {server: root@1.2.3.4}} -workloads: - web: {role: application, image: nginx, routes: [{hostname: d.example.com, port: 80}]} - db: {role: daemon, compose: "compose.yaml#db"} + writeFile(t, dir, "ob.yml", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: {production: {server: root@1.2.3.4}} + workloads: + 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") if err != nil { diff --git a/cmd/ob/schema.go b/cmd/ob/schema.go index ab1aa36d..3f0c192e 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/v1alpha1` Application resource.\n\n" + "Reference it from the first line of a project so an editor can offer\n" + "completion, hover documentation and inline errors:\n\n" + " # yaml-language-server: $schema=" + app.SchemaID + "\n\n" + @@ -31,7 +31,7 @@ func addSchemaCommand(root *cobra.Command, g *globalFlags) { "needs when the machine is offline.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - body, err := app.JSONSchema() + body, err := app.GenerateJSONSchema() if err != nil { return writeStructuredReadFailure(cmd, g, err) } diff --git a/docs/README.md b/docs/README.md index ee1be826..befa3489 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ the repository rather than to a reader. |---|---| | [`onebox.run-v1.schema.json`](onebox.run-v1.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. | | [`product.md`](product.md) | Product direction. Not an implementation contract, and not a capability list. | +| [`decisions/2026-09-20-application-v1alpha1.md`](decisions/2026-09-20-application-v1alpha1.md) | Accepted decision and complete cutover inventory for the breaking `onebox.run/v1alpha1` Application baseline. It governs the staged implementation but does not claim the current binary has cut over. | ## Where the user documentation went diff --git a/docs/decisions/2026-09-20-application-v1alpha1.md b/docs/decisions/2026-09-20-application-v1alpha1.md new file mode 100644 index 00000000..deacfe31 --- /dev/null +++ b/docs/decisions/2026-09-20-application-v1alpha1.md @@ -0,0 +1,216 @@ +# Application contract reset to `onebox.run/v1alpha1` + +- Status: accepted +- Date: 2026-09-20 +- Tracking issue: [#202](https://github.com/labstack/onebox/issues/202) +- Scope: M0 — contract decision, naming specification, and cutover inventory + +## Context + +The current authored project file uses `onebox.run/v1`, a flat root object, +snake_case fields, and an optional single-workload shorthand. That identity was +assigned before Onebox had a governed public baseline. Keeping it would turn a +development shape into a compatibility promise and would preserve two authored +representations for the same workload. + +Onebox also writes machine artifacts such as plans, confirmations, results, and +host state. Those artifacts have independent identities and recovery rules. +Changing the human-authored resource must not silently rename their fields or +advance their schemas. + +## Decision + +The only authored Application identity is `onebox.run/v1alpha1`: + +```yaml +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {} + workloads: {} +``` + +The root contains exactly `apiVersion`, `kind`, `metadata`, and `spec`. +`apiVersion` and `kind` are the resource's type identity. `metadata.name` +replaces `app`. All Onebox configuration is below `spec`; observed state is not +part of this authored resource. A future observed resource may add a top-level +`status`, but this Application does not accept one. + +Every application uses `spec.workloads`. The root-level `build`, `image`, +`compose`, `port`, `health`, and `routes` shorthand is removed. Defaults may +keep small resources concise, but the parser exposes only one authored shape +for a workload. + +The public schema uses JSON Schema Draft 2020-12, lives at +`api/application/v1alpha1/application.schema.json`, and has the stable ID +`https://onebox.run/schemas/application/v1alpha1/application.schema.json`. +The binary embeds that schema. The checked-in schema remains reviewable, and CI +proves byte-for-byte agreement between it, the Go model, and the schema emitted +by the binary. Cross-field and operational rules remain in the semantic +validator. + +## Naming specification + +Onebox has two deliberately separate wire dialects. + +### Authored declarative resources + +- Fields use lowerCamelCase: `apiVersion`, `basePath`, `backupTargets`, + `externalServices`, `envFiles`, `dataEffect`, and `deploymentPhase`. +- Resource kinds and finite declarative values use singular UpperCamelCase: + `Application`, `Job`, `PreRelease`, and `ClientSide`. +- Lowercase values remain lowercase only when they are data rather than Onebox + constants, including identifiers, protocols, image references, hostnames, + paths, and provider-native values. +- Go identifiers use the standard initialisms `ID`, `URL`, `HTTP`, `JSON`, + `SSH`, `UID`, and `GID`. +- References are typed objects named `Ref` and contain at least + `name`. Timestamps are named `Time` and serialize as RFC 3339. +- Durations and quantities use standard string syntax unless the unit is part + of the field name. +- Map keys identify declared objects; they do not extend behavior. +- Unknown and duplicate fields are rejected at every typed level. +- Behavior-bearing `x-*` fields are rejected. Opaque user metadata may be + stored in `metadata.annotations`, and annotations must not affect generated + plans or runtime behavior. + +### Machine artifacts + +- Artifact identity remains in `schema_version`. +- Machine fields remain snake_case and machine codes remain lower_snake_case. +- Existing artifact identities advance only through their own schema change, + compatibility decision, and recovery assessment. +- The Application reset does not rename plan, confirmation, result, release, + host-state, or structured-output fields. + +This boundary is mechanical: declarative resource checks must not be applied to +machine artifacts, and machine-field checks must not permit snake_case in an +authored Application. + +## Version and compatibility policy + +This is an intentional breaking reset before the first public baseline is +locked. The active product contract does not retain or translate +`onebox.run/v1` and does not accept aliases for its keys or enum spellings. +There is no legacy loader, dual read or write, migration command, automatic +rewrite, fallback, or compatibility fixture that promises v1 remains valid. + +Old files fail with a concise diagnostic that names +`onebox.run/v1alpha1` and points to the current field reference. Users rewrite +their Application explicitly. + +The raw authored project bytes participate in `config_digest`. Rewriting a +project therefore invalidates every outstanding plan and local confirmation +bound to the old bytes. They must be regenerated after the rewrite. Durable +release manifests and host state keep their independent schema identities; +they are not made current by accepting stale plan input or by relabeling their +wire format. + +Alpha does not mean implicit compatibility. Any incompatible change after this +baseline advances the authored API identity and states its migration policy. +The first stable compatibility guarantee begins only when a stable API is +declared. + +## Product boundary + +Onebox and Multibox share the 2026 vocabulary and casing conventions, but they +are independent products. Onebox owns its Application schema, loader, runtime, +release policy, environments, host routing, backups, and deployment behavior. +There is no cross-repository schema import or runtime dependency. A convention +change may be coordinated, but each product versions, reviews, verifies, and +releases its own contract. + +## Cutover inventory + +This inventory freezes the M0 audit boundary. Generated artifacts are not +edited by hand; their generators change in the milestone named below. Authored +documentation continues to describe the shipped v1 binary until the code +cutover is complete, then changes atomically in M5. + +### Generated contract artifacts and reference pages + +| Current path | Owner | Cutover | +| --- | --- | --- | +| `docs/onebox.run-v1.schema.json` | `internal/app/jsonschema.go`, `ob schema` | M1 introduces `api/application/v1alpha1/application.schema.json`; M5 removes the retired publication path. | +| `site/public/onebox.run-v1.schema.json` | `cmd/ob-docgen` | M5 publishes the v1alpha1 schema at the website path matching the stable schema ID. | +| `site/src/content/docs/reference/fields/*.mdx` | `cmd/ob-docgen` | M5 regenerates field names, nesting, requiredness, values, and examples. | +| `site/src/content/docs/reference/cli.mdx` | Cobra help through `cmd/ob-docgen` | M3 changes command help; M5 regenerates the page. | +| `site/src/content/docs/reference/errors.mdx` | loader and lifecycle registries through `cmd/ob-docgen` | M3 adds the cutover diagnostics; M5 regenerates the page. | +| `site/src/content/docs/reference/drivers.mdx` | service-driver catalogue through `cmd/ob-docgen` | M5 regeneration verifies the generator remains complete even when its prose is unchanged. | + +The generator entry points that carry old names or destinations are +`cmd/ob-docgen/main.go`, `internal/app/jsonschema.go`, `cmd/ob/schema.go`, and +`cmd/ob/init.go`. CI references in `.github/workflows/ci.yml`, the `justfile` +documentation targets, and `cmd/ob-docgen/main_test.go` must follow the new +schema path and continue enforcing exact agreement. + +### Authored user documentation + +The following authored surfaces contain the old identity, old shape, old field +names, old enum spellings, or examples whose indentation changes under `spec`: + +- `README.md` and `docs/README.md`; +- `site/src/components/landing/Derivation.astro` and + `site/src/components/landing/Hero.astro`; +- `site/src/content/docs/reference/project-file.mdx`, `naming.mdx`, + `policies.mdx`, and `status/capabilities.mdx`; +- `site/src/content/docs/start/reading-it-back.mdx`; +- `site/src/content/docs/explanation/what-onebox-refuses.mdx`; +- the guides `adopt-compose.mdx`, `back-up-a-database.mdx`, + `environment-variables.mdx`, `handle-secrets.mdx`, `roll-back.mdx`, + `run-migrations.mdx`, and `schedule-a-job.mdx`; +- authored text surrounding generated content in + `site/src/content/docs/reference/cli.mdx` and `errors.mdx`; +- the source diagrams `docs/media/deploy-light.svg` and + `docs/media/deploy-dark.svg`. + +M5 updates these only after the loader, runtime consumers, CLI, examples, and +end-to-end corpus all use v1alpha1. Historical decision records remain +historical and are not rewritten to pretend they described the new API. + +### Repository-owned Applications and conformance data + +M4 rewrites every shipped Application example or fixture: + +- all `e2e/apps/*.yml` files and `e2e/apps/README.md`; +- `e2e/testdata/app/ob.yml`, `e2e/testdata/postgres/ob.yml.tmpl`, and + `e2e/testdata/worker/{ob.yml,ob-broken.yml}`; +- all authored YAML under `internal/app/testdata/corpus/` and its `README.md`; +- `internal/app/testdata/contract-verdicts.json` and every inline project + fixture in `internal/app`, `internal/engine`, `internal/onebox`, + `internal/proxy`, and `cmd/ob` tests. + +The new schema-owned fixture corpus is rooted at +`api/testdata/application/{valid,invalid}/`. It must cover the envelope, +identity, metadata, strict field closure, lowerCamelCase names, UpperCamelCase +values, every supported workload/source form, and explicit rejection of v1, +`api_version`, root `app`, snake_case declarative fields, shorthand, aliases, +and behavior-bearing extensions. + +## Delivery and verification + +The cutover implements M0 through M6 from issue #202 atomically in one pull +request. This is an explicit delivery override: splitting a breaking wire +reset across mergeable intermediate states would leave the loader, generated +schema, examples, and documentation describing different contracts. The PR +keeps milestone-specific commits and verification evidence where useful, while +the merge boundary remains the complete cutover. + +Final acceptance additionally requires `just ci` and the applicable Docker E2E +suites. M3 proves stale plan and confirmation rejection after project bytes +change. M6 mechanically prevents authored and machine dialects from drifting +together. + +## Consequences + +- Existing v1 project files stop loading and require an explicit rewrite. +- Outstanding plans and confirmations become stale when that rewrite changes + the bound project bytes. +- The public authored contract gains a conventional resource envelope and one + canonical workload shape. +- Runtime internals may retain implementation-specific names, but no old wire + spelling is accepted or emitted at the authored boundary. +- Machine artifacts remain independently versioned and recoverable according + to their own contracts. diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json deleted file mode 100644 index 9a1182b1..00000000 --- a/docs/onebox.run-v1.schema.json +++ /dev/null @@ -1,3119 +0,0 @@ -{ - "$id": "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.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/v1", - "description": "Project contract version. Must be onebox.run/v1.", - "examples": [ - "onebox.run/v1" - ], - "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/v1)", - "type": "object" -} diff --git a/e2e/apps/README.md b/e2e/apps/README.md index 9d3ccf4a..13053406 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/v1alpha1` Applications for real open-source applications, chosen for the shape people normally build rather than for being exotic. Each declares everything it needs, so it renders and runs without a Compose reference. diff --git a/e2e/apps/authentik.yml b/e2e/apps/authentik.yml index 8423c594..5af6deb1 100644 --- a/e2e/apps/authentik.yml +++ b/e2e/apps/authentik.yml @@ -1,37 +1,36 @@ -# authentik. Bind mounts, a published port, and a worker that shares the -# 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 -app: authentik -environments: - production: {server: root@TARGET} -workloads: - server: - role: application - strategy: recreate - image: ghcr.io/goauthentik/server:2025.2.4 - command: [server] - routes: - - {hostname: auth.example.com, port: 9000} - needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}] - published_ports: [{host: 9000, container: 9000}] - volumes: - - {source: /srv/authentik/media, path: /media} - - {source: /srv/authentik/templates, path: /templates, mode: ro} - env: &authentik_env - AUTHENTIK_SECRET_KEY: replace-me-with-a-real-secret - AUTHENTIK_ERROR_REPORTING__ENABLED: "false" - health: {exec: "ak healthcheck", interval: 10s, start_period: 60s, retries: 10} - worker: - role: worker - image: ghcr.io/goauthentik/server:2025.2.4 - command: [worker] - needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}] - volumes: - - {source: /srv/authentik/media, path: /media} - - {source: /srv/authentik/certs, path: /certs} - env: *authentik_env -services: - postgres: "16" - redis: "7.4" +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: authentik +spec: + environments: + production: {server: root@TARGET} + workloads: + server: + role: Application + strategy: Recreate + image: ghcr.io/goauthentik/server:2025.2.4 + command: [server] + routes: + - {hostname: auth.example.com, port: 9000} + needs: [{name: postgres, condition: Healthy}, {name: redis, condition: Healthy}] + publishedPorts: [{host: 9000, container: 9000}] + volumes: + - {source: /srv/authentik/media, path: /media} + - {source: /srv/authentik/templates, path: /templates, mode: Ro} + env: &authentik_env + AUTHENTIK_SECRET_KEY: replace-me-with-a-real-secret + AUTHENTIK_ERROR_REPORTING__ENABLED: "false" + health: {exec: "ak healthcheck", interval: 10s, startPeriod: 60s, retries: 10} + worker: + role: Worker + image: ghcr.io/goauthentik/server:2025.2.4 + command: [worker] + needs: [{name: postgres, condition: Healthy}, {name: redis, condition: Healthy}] + volumes: + - {source: /srv/authentik/media, path: /media} + - {source: /srv/authentik/certs, path: /certs} + env: *authentik_env + services: + postgres: "16" + redis: "7.4" diff --git a/e2e/apps/ghost.yml b/e2e/apps/ghost.yml index f96469d4..ba7cb7e7 100644 --- a/e2e/apps/ghost.yml +++ b/e2e/apps/ghost.yml @@ -1,27 +1,27 @@ -# 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 -app: ghost -environments: - production: {server: root@TARGET} -workloads: - ghost: - role: application - image: ghost:5-alpine - routes: - - {hostname: blog.example.com, port: 2368} - needs: - - name: mysql - env: - database__connection__host: host - database__connection__user: user - database__connection__password: password - database__connection__database: database - env: - database__client: mysql - url: http://blog.example.com - NODE_ENV: production - health: {exec: "wget -qO- http://127.0.0.1:2368/ >/dev/null || exit 1", interval: 10s, start_period: 40s, retries: 6} -services: - mysql: "8.4" +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ghost +spec: + environments: + production: {server: root@TARGET} + workloads: + ghost: + role: Application + image: ghost:5-alpine + routes: + - {hostname: blog.example.com, port: 2368} + needs: + - name: mysql + env: + database__connection__host: host + database__connection__user: user + database__connection__password: password + database__connection__database: database + env: + database__client: mysql + url: http://blog.example.com + NODE_ENV: production + health: {exec: "wget -qO- http://127.0.0.1:2368/ >/dev/null || exit 1", interval: 10s, startPeriod: 40s, retries: 6} + services: + mysql: "8.4" diff --git a/e2e/apps/gitea.yml b/e2e/apps/gitea.yml index 27e89b6a..97530f42 100644 --- a/e2e/apps/gitea.yml +++ b/e2e/apps/gitea.yml @@ -1,27 +1,30 @@ -api_version: onebox.run/v1 -app: gitea -environments: - production: {server: root@TARGET} -workloads: - server: - role: application - image: docker.gitea.com/gitea:1.22.6 - routes: - - {hostname: git.example.com, port: 3000} - needs: [db] - published_ports: [{host: 2222, container: 22}] - env: - USER_UID: "1000" - USER_GID: "1000" - GITEA__database__DB_TYPE: postgres - GITEA__database__HOST: "db:5432" - GITEA__database__NAME: gitea - GITEA__database__USER: gitea - GITEA__database__PASSWD: gitea - volumes: [{name: data, path: /data}] - db: - role: daemon - image: postgres:14-alpine - env: {POSTGRES_USER: gitea, POSTGRES_PASSWORD: gitea, POSTGRES_DB: gitea} - volumes: [{name: data, path: /var/lib/postgresql/data}] - health: {exec: "pg_isready -U gitea", interval: 5s, retries: 10} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: gitea +spec: + environments: + production: {server: root@TARGET} + workloads: + server: + role: Application + image: docker.gitea.com/gitea:1.22.6 + routes: + - {hostname: git.example.com, port: 3000} + needs: [db] + publishedPorts: [{host: 2222, container: 22}] + env: + USER_UID: "1000" + USER_GID: "1000" + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: "db:5432" + GITEA__database__NAME: gitea + GITEA__database__USER: gitea + GITEA__database__PASSWD: gitea + volumes: [{name: data, path: /data}] + db: + role: Daemon + image: postgres:14-alpine + env: {POSTGRES_USER: gitea, POSTGRES_PASSWORD: gitea, POSTGRES_DB: gitea} + volumes: [{name: data, path: /var/lib/postgresql/data}] + health: {exec: "pg_isready -U gitea", interval: 5s, retries: 10} diff --git a/e2e/apps/immich.yml b/e2e/apps/immich.yml index f6d13d14..5fe7c508 100644 --- a/e2e/apps/immich.yml +++ b/e2e/apps/immich.yml @@ -1,41 +1,41 @@ -# 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 -app: immich -environments: - production: {server: root@TARGET} -workloads: - server: - role: application - image: ghcr.io/immich-app/immich-server:v1.125.7 - 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: - DB_HOSTNAME: database - DB_USERNAME: immich - DB_PASSWORD: immich - DB_DATABASE_NAME: immich - REDIS_HOSTNAME: redis - IMMICH_MACHINE_LEARNING_URL: http://machine-learning:3003 - health: {exec: "curl -fsS http://127.0.0.1:2283/api/server/ping >/dev/null || exit 1", interval: 10s, start_period: 90s, retries: 12} - machine-learning: - role: worker - image: ghcr.io/immich-app/immich-machine-learning:v1.125.7 - volumes: [{name: model-cache, path: /cache}] - database: - role: daemon - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0 - env: - POSTGRES_USER: immich - POSTGRES_PASSWORD: immich - POSTGRES_DB: immich - POSTGRES_INITDB_ARGS: "--data-checksums" - volumes: [{name: pgdata, path: /var/lib/postgresql/data}] - health: {exec: "pg_isready -U immich", interval: 10s, start_period: 30s, retries: 10} - redis: - role: daemon - image: docker.io/valkey/valkey:8-alpine - health: {exec: "valkey-cli ping", interval: 5s, retries: 10} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: immich +spec: + environments: + production: {server: root@TARGET} + workloads: + server: + role: Application + image: ghcr.io/immich-app/immich-server:v1.125.7 + 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: + DB_HOSTNAME: database + DB_USERNAME: immich + DB_PASSWORD: immich + DB_DATABASE_NAME: immich + REDIS_HOSTNAME: redis + IMMICH_MACHINE_LEARNING_URL: http://machine-learning:3003 + health: {exec: "curl -fsS http://127.0.0.1:2283/api/server/ping >/dev/null || exit 1", interval: 10s, startPeriod: 90s, retries: 12} + machine-learning: + role: Worker + image: ghcr.io/immich-app/immich-machine-learning:v1.125.7 + volumes: [{name: model-cache, path: /cache}] + database: + role: Daemon + image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0 + env: + POSTGRES_USER: immich + POSTGRES_PASSWORD: immich + POSTGRES_DB: immich + POSTGRES_INITDB_ARGS: "--data-checksums" + volumes: [{name: pgdata, path: /var/lib/postgresql/data}] + health: {exec: "pg_isready -U immich", interval: 10s, startPeriod: 30s, retries: 10} + redis: + role: Daemon + image: docker.io/valkey/valkey:8-alpine + health: {exec: "valkey-cli ping", interval: 5s, retries: 10} diff --git a/e2e/apps/n8n.yml b/e2e/apps/n8n.yml index a6901d8e..d9844148 100644 --- a/e2e/apps/n8n.yml +++ b/e2e/apps/n8n.yml @@ -1,45 +1,48 @@ -api_version: onebox.run/v1 -app: n8n -environments: - production: {server: root@TARGET} -workloads: - n8n: - role: application - image: docker.n8n.io/n8nio/n8n:1.73.1 - routes: - - {hostname: n8n.example.com, port: 5678} - needs: [postgres, redis] - env: - DB_TYPE: postgresdb - DB_POSTGRESDB_HOST: postgres - DB_POSTGRESDB_USER: n8n - DB_POSTGRESDB_PASSWORD: n8n - EXECUTIONS_MODE: queue - QUEUE_BULL_REDIS_HOST: redis - N8N_ENCRYPTION_KEY: replace-me - volumes: [{name: storage, path: /home/node/.n8n}] - worker: - role: worker - image: docker.n8n.io/n8nio/n8n:1.73.1 - command: [worker] - needs: [postgres, redis] - env: - DB_TYPE: postgresdb - DB_POSTGRESDB_HOST: postgres - DB_POSTGRESDB_USER: n8n - DB_POSTGRESDB_PASSWORD: n8n - EXECUTIONS_MODE: queue - QUEUE_BULL_REDIS_HOST: redis - N8N_ENCRYPTION_KEY: replace-me - volumes: [{name: storage, path: /home/node/.n8n}] - postgres: - role: daemon - image: postgres:16-alpine - env: {POSTGRES_USER: n8n, POSTGRES_PASSWORD: n8n, POSTGRES_DB: n8n} - volumes: [{name: data, path: /var/lib/postgresql/data}] - health: {exec: "pg_isready -U n8n", interval: 5s, retries: 10} - redis: - role: daemon - image: redis:7-alpine - volumes: [{name: data, path: /data}] - health: {exec: "redis-cli ping", interval: 5s, retries: 10} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: n8n +spec: + environments: + production: {server: root@TARGET} + workloads: + n8n: + role: Application + image: docker.n8n.io/n8nio/n8n:1.73.1 + routes: + - {hostname: n8n.example.com, port: 5678} + needs: [postgres, redis] + env: + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: postgres + DB_POSTGRESDB_USER: n8n + DB_POSTGRESDB_PASSWORD: n8n + EXECUTIONS_MODE: queue + QUEUE_BULL_REDIS_HOST: redis + N8N_ENCRYPTION_KEY: replace-me + volumes: [{name: storage, path: /home/node/.n8n}] + worker: + role: Worker + image: docker.n8n.io/n8nio/n8n:1.73.1 + command: [worker] + needs: [postgres, redis] + env: + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: postgres + DB_POSTGRESDB_USER: n8n + DB_POSTGRESDB_PASSWORD: n8n + EXECUTIONS_MODE: queue + QUEUE_BULL_REDIS_HOST: redis + N8N_ENCRYPTION_KEY: replace-me + volumes: [{name: storage, path: /home/node/.n8n}] + postgres: + role: Daemon + image: postgres:16-alpine + env: {POSTGRES_USER: n8n, POSTGRES_PASSWORD: n8n, POSTGRES_DB: n8n} + volumes: [{name: data, path: /var/lib/postgresql/data}] + health: {exec: "pg_isready -U n8n", interval: 5s, retries: 10} + redis: + role: Daemon + image: redis:7-alpine + volumes: [{name: data, path: /data}] + health: {exec: "redis-cli ping", interval: 5s, retries: 10} diff --git a/e2e/apps/paperless.yml b/e2e/apps/paperless.yml index 003afd99..51195021 100644 --- a/e2e/apps/paperless.yml +++ b/e2e/apps/paperless.yml @@ -1,40 +1,43 @@ -api_version: onebox.run/v1 -app: paperless -environments: - production: {server: root@TARGET} -workloads: - webserver: - role: application - image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7 - routes: - - {hostname: paperless.example.com, port: 8000} - needs: [db, broker, gotenberg, tika] - env: - PAPERLESS_REDIS: redis://broker:6379 - PAPERLESS_DBHOST: db - PAPERLESS_TIKA_ENABLED: "1" - PAPERLESS_TIKA_ENDPOINT: http://tika:9998 - PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 - PAPERLESS_SECRET_KEY: replace-me - PAPERLESS_URL: https://paperless.example.com - volumes: - - {name: data, path: /usr/src/paperless/data} - - {name: media, path: /usr/src/paperless/media} - broker: - role: daemon - image: docker.io/valkey/valkey:8-alpine - volumes: [{name: data, path: /data}] - health: {exec: "valkey-cli ping", interval: 5s, retries: 10} - db: - role: daemon - image: docker.io/library/postgres:16-alpine - env: {POSTGRES_DB: paperless, POSTGRES_USER: paperless, POSTGRES_PASSWORD: paperless} - volumes: [{name: data, path: /var/lib/postgresql/data}] - health: {exec: "pg_isready -U paperless", interval: 5s, retries: 10} - gotenberg: - role: daemon - image: docker.io/gotenberg/gotenberg:8.7 - command: ["gotenberg", "--chromium-disable-javascript=true", "--chromium-allow-list=file:///tmp/.*"] - tika: - role: daemon - image: docker.io/apache/tika:latest +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: paperless +spec: + environments: + production: {server: root@TARGET} + workloads: + webserver: + role: Application + image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7 + routes: + - {hostname: paperless.example.com, port: 8000} + needs: [db, broker, gotenberg, tika] + env: + PAPERLESS_REDIS: redis://broker:6379 + PAPERLESS_DBHOST: db + PAPERLESS_TIKA_ENABLED: "1" + PAPERLESS_TIKA_ENDPOINT: http://tika:9998 + PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 + PAPERLESS_SECRET_KEY: replace-me + PAPERLESS_URL: https://paperless.example.com + volumes: + - {name: data, path: /usr/src/paperless/data} + - {name: media, path: /usr/src/paperless/media} + broker: + role: Daemon + image: docker.io/valkey/valkey:8-alpine + volumes: [{name: data, path: /data}] + health: {exec: "valkey-cli ping", interval: 5s, retries: 10} + db: + role: Daemon + image: docker.io/library/postgres:16-alpine + env: {POSTGRES_DB: paperless, POSTGRES_USER: paperless, POSTGRES_PASSWORD: paperless} + volumes: [{name: data, path: /var/lib/postgresql/data}] + health: {exec: "pg_isready -U paperless", interval: 5s, retries: 10} + gotenberg: + role: Daemon + image: docker.io/gotenberg/gotenberg:8.7 + command: ["gotenberg", "--chromium-disable-javascript=true", "--chromium-allow-list=file:///tmp/.*"] + tika: + role: Daemon + image: docker.io/apache/tika:latest diff --git a/e2e/apps/penpot.yml b/e2e/apps/penpot.yml index 1feb26d2..46aeef51 100644 --- a/e2e/apps/penpot.yml +++ b/e2e/apps/penpot.yml @@ -1,37 +1,37 @@ -# 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 -app: penpot -environments: - production: {server: root@TARGET} -workloads: - frontend: - role: application - image: penpotapp/frontend:2.4.3 - routes: - - {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 - health: {exec: "curl -fsS http://127.0.0.1:80/ >/dev/null || exit 1", interval: 10s, start_period: 30s, retries: 8} - backend: - role: worker - image: penpotapp/backend:2.4.3 - needs: - - name: postgres - condition: healthy - env: - PENPOT_DATABASE_USERNAME: user - PENPOT_DATABASE_PASSWORD: password - - {name: redis, condition: healthy} - volumes: [{name: assets, path: /opt/data}] - env: - PENPOT_FLAGS: disable-registration disable-email-verification disable-secure-session-cookies - PENPOT_SECRET_KEY: replace-me-with-a-real-secret - PENPOT_DATABASE_URI: postgresql://postgres:5432/penpot - PENPOT_REDIS_URI: redis://redis/0 -services: - postgres: "15" - redis: "7.4" +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: penpot +spec: + environments: + production: {server: root@TARGET} + workloads: + frontend: + role: Application + image: penpotapp/frontend:2.4.3 + routes: + - {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 + health: {exec: "curl -fsS http://127.0.0.1:80/ >/dev/null || exit 1", interval: 10s, startPeriod: 30s, retries: 8} + backend: + role: Worker + image: penpotapp/backend:2.4.3 + needs: + - name: postgres + condition: Healthy + env: + PENPOT_DATABASE_USERNAME: user + PENPOT_DATABASE_PASSWORD: password + - {name: redis, condition: Healthy} + volumes: [{name: assets, path: /opt/data}] + env: + PENPOT_FLAGS: disable-registration disable-email-verification disable-secure-session-cookies + PENPOT_SECRET_KEY: replace-me-with-a-real-secret + PENPOT_DATABASE_URI: postgresql://postgres:5432/penpot + PENPOT_REDIS_URI: redis://redis/0 + services: + postgres: "15" + redis: "7.4" diff --git a/e2e/apps/rocketchat.yml b/e2e/apps/rocketchat.yml index 260e5085..8ab1b1a3 100644 --- a/e2e/apps/rocketchat.yml +++ b/e2e/apps/rocketchat.yml @@ -1,32 +1,23 @@ -# Rocket.Chat on managed MongoDB. -# -# It does not run, and that is what this fixture records. The connection is -# correct: the generated URL authenticates against a live server, which is the -# authSource fix proven. What fails is the application. Rocket.Chat needs change -# streams, change streams need a replica set, and the managed driver runs a -# standalone server, so it starts, connects, and then fails in its own logs with -# "The $changeStream stage is only supported on replica sets". -# -# 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 -app: rocketchat -environments: - production: {server: root@TARGET} -workloads: - rocketchat: - role: application - image: registry.rocket.chat/rocketchat/rocket.chat:7.3.0 - routes: - - {hostname: chat.example.com, port: 3000} - needs: - - name: mongodb - env: {MONGO_URL: url} - env: - ROOT_URL: http://chat.example.com - PORT: "3000" - DEPLOY_METHOD: docker - health: {exec: "curl -fsS http://127.0.0.1:3000/api/info >/dev/null || exit 1", interval: 10s, start_period: 60s, retries: 10} -services: - mongodb: "7.0" +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: rocketchat +spec: + environments: + production: {server: root@TARGET} + workloads: + rocketchat: + role: Application + image: registry.rocket.chat/rocketchat/rocket.chat:7.3.0 + routes: + - {hostname: chat.example.com, port: 3000} + needs: + - name: mongodb + env: {MONGO_URL: url} + env: + ROOT_URL: http://chat.example.com + PORT: "3000" + DEPLOY_METHOD: docker + health: {exec: "curl -fsS http://127.0.0.1:3000/api/info >/dev/null || exit 1", interval: 10s, startPeriod: 60s, retries: 10} + services: + mongodb: "7.0" diff --git a/e2e/apps/umami.yml b/e2e/apps/umami.yml index 1c3c44e1..50bbfa4b 100644 --- a/e2e/apps/umami.yml +++ b/e2e/apps/umami.yml @@ -1,22 +1,25 @@ -api_version: onebox.run/v1 -app: umami -environments: - production: {server: root@TARGET} -workloads: - umami: - role: application - image: ghcr.io/umami-software/umami:postgresql-v2.13.2 - routes: - - {hostname: analytics.example.com, port: 3000} - needs: [db] - env: - DATABASE_URL: postgresql://umami:umami@db:5432/umami - DATABASE_TYPE: postgresql - APP_SECRET: replace-me - health: {exec: "curl -f http://localhost:3000/api/heartbeat || exit 1", interval: 10s, start_period: 40s, retries: 5} - db: - role: daemon - image: postgres:15-alpine - env: {POSTGRES_DB: umami, POSTGRES_USER: umami, POSTGRES_PASSWORD: umami} - volumes: [{name: data, path: /var/lib/postgresql/data}] - health: {exec: "pg_isready -U umami -d umami", interval: 5s, retries: 10} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: umami +spec: + environments: + production: {server: root@TARGET} + workloads: + umami: + role: Application + image: ghcr.io/umami-software/umami:postgresql-v2.13.2 + routes: + - {hostname: analytics.example.com, port: 3000} + needs: [db] + env: + DATABASE_URL: postgresql://umami:umami@db:5432/umami + DATABASE_TYPE: postgresql + APP_SECRET: replace-me + health: {exec: "curl -f http://localhost:3000/api/heartbeat || exit 1", interval: 10s, startPeriod: 40s, retries: 5} + db: + role: Daemon + image: postgres:15-alpine + env: {POSTGRES_DB: umami, POSTGRES_USER: umami, POSTGRES_PASSWORD: umami} + volumes: [{name: data, path: /var/lib/postgresql/data}] + health: {exec: "pg_isready -U umami -d umami", interval: 5s, retries: 10} diff --git a/e2e/apps/uptime-kuma.yml b/e2e/apps/uptime-kuma.yml index 885d68db..9a6bd6fe 100644 --- a/e2e/apps/uptime-kuma.yml +++ b/e2e/apps/uptime-kuma.yml @@ -1,8 +1,13 @@ -api_version: onebox.run/v1 -app: uptime-kuma -environments: - production: {server: root@TARGET} -image: louislam/uptime-kuma:1.23.16 -routes: - - {hostname: kuma.example.com, port: 3001} -health: {http: /, port: 3001, interval: 10s, start_period: 30s, retries: 5} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: uptime-kuma +spec: + environments: + production: {server: root@TARGET} + workloads: + uptime-kuma: + image: louislam/uptime-kuma:1.23.16 + health: {http: /, port: 3001, interval: 10s, startPeriod: 30s, retries: 5} + routes: + - {hostname: kuma.example.com, port: 3001} diff --git a/e2e/apps/vaultwarden.yml b/e2e/apps/vaultwarden.yml index 3edf7fe1..51f61159 100644 --- a/e2e/apps/vaultwarden.yml +++ b/e2e/apps/vaultwarden.yml @@ -1,12 +1,15 @@ -api_version: onebox.run/v1 -app: vaultwarden -environments: - production: {server: root@TARGET} -workloads: - server: - role: application - image: vaultwarden/server:1.32.7 - routes: - - {hostname: vault.example.com, port: 80} - env: {WEBSOCKET_ENABLED: "true", SIGNUPS_ALLOWED: "false"} - volumes: [{name: data, path: /data}] +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: vaultwarden +spec: + environments: + production: {server: root@TARGET} + workloads: + server: + role: Application + image: vaultwarden/server:1.32.7 + 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..b1842936 100644 --- a/e2e/destroy_test.go +++ b/e2e/destroy_test.go @@ -27,13 +27,15 @@ func TestDestroyUsesReleaseRecordedInterpolationEnvironment(t *testing.T) { releaseID := "20260821-120000-legacy" volume := application + "_legacy_data" - currentBody := fmt.Sprintf(`api_version: onebox.run/v1 -app: %s -base_path: %q -environments: - production: {server: root@localhost} -workloads: - web: {image: alpine:3} + currentBody := fmt.Sprintf(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: %s} +spec: + basePath: %q + environments: + production: {server: root@localhost} + workloads: + web: {image: alpine:3} `, application, base) current, err := app.LoadBytes([]byte(currentBody), filepath.Join(base, "current.yml")) if err != nil { @@ -62,7 +64,7 @@ volumes: %s: name: %s `, volume, volume, volume) - snapshotBody := currentBody + "runtime:\n env_files: [legacy.env]\n" + snapshotBody := currentBody + " runtime:\n envFiles: [legacy.env]\n" for path, body := range map[string]string{ composePath: composeBody, envPath: "LEGACY_SECRET=recorded-value\n", diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 3fcf1e0a..07a9a675 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -25,7 +25,7 @@ import ( // Keep the checked-in examples on the stable authoring contract even when the // Docker-gated deployment tests are skipped. -func TestV1ConfigFixturesLoad(t *testing.T) { +func TestApplicationFixturesLoad(t *testing.T) { for _, path := range []string{ "testdata/app/ob.yml", "testdata/worker/ob.yml", @@ -75,7 +75,7 @@ func TestZeroDowntimeDeploy(t *testing.T) { if err != nil { return err } - // base_path is the field, not an environment variable: the engine now + // basePath is the field, not an environment variable: the engine now // has one path authority and it is the project's own. spec.BasePath = base resolved, err := spec.Resolve("production") diff --git a/e2e/network_ownership_test.go b/e2e/network_ownership_test.go index b791a8f7..109b479c 100644 --- a/e2e/network_ownership_test.go +++ b/e2e/network_ownership_test.go @@ -24,12 +24,14 @@ func TestApplicationNetworkOwnershipAndExternalLifecycle(t *testing.T) { application := fmt.Sprintf("obnet%d", os.Getpid()) network := application + "_default" - projectBody := fmt.Sprintf(`api_version: onebox.run/v1 -app: %s -environments: - production: {server: root@localhost} -workloads: - web: {image: alpine:3} + projectBody := fmt.Sprintf(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: %s} +spec: + environments: + production: {server: root@localhost} + workloads: + web: {image: alpine:3} `, application) project, err := app.LoadBytes([]byte(projectBody), filepath.Join(t.TempDir(), "ob.yml")) if err != nil { diff --git a/e2e/ops_test.go b/e2e/ops_test.go index 4b50652b..fc4c62f4 100644 --- a/e2e/ops_test.go +++ b/e2e/ops_test.go @@ -50,7 +50,11 @@ func releaseSnapshot(t *testing.T, dir, cfgFile, base string) []byte { if err != nil { t.Fatal(err) } - return append(body, []byte("\nbase_path: "+base+"\n")...) + updated := strings.Replace(string(body), "spec:\n", fmt.Sprintf("spec:\n basePath: %q\n", base), 1) + if updated == string(body) { + t.Fatal("Application fixture has no spec block") + } + return []byte(updated) } func buildDeploy(t *testing.T, dir, cfgFile, version, base string) (*engine.Engine, string, string) { diff --git a/e2e/server_execution_test.go b/e2e/server_execution_test.go index 1d0e3c4e..587c8e29 100644 --- a/e2e/server_execution_test.go +++ b/e2e/server_execution_test.go @@ -27,42 +27,44 @@ 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 -app: %s -base_path: %s -environments: {production: {server: %s}} -proxy: {managed: false} -workloads: - refresh: - role: job - image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 - command: ["true"] - data_effect: none - inputs: - SOURCE: {enum: [catalog, custom], default: catalog} - volumes: - - {source: %s/data, path: /data} - schedule: {cron: "0 0 1 1 *", timeout: 30s, catch_up: false} - execution: - retention: 168h - steps: - - id: sync - command: - - sh - - -c - - | - echo "$SOURCE" >> /data/sync.log - printf '{"RELEASE":"release-123"}' > "$ONEBOX_OUTPUT_FILE" - outputs: [RELEASE] - - id: index - inputs: {RELEASE_ID: sync.RELEASE} - command: - - sh - - -c - - | - echo "$ONEBOX_STEP_ID $ONEBOX_ATTEMPT_ID $RELEASE_ID" >> /data/index.log - while test -f /data/hold; do touch /data/started; sleep 1; done - test -f /data/allow + project := fmt.Sprintf(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: %s} +spec: + basePath: %s + environments: {production: {server: %s}} + proxy: {managed: false} + workloads: + refresh: + role: Job + image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 + command: ["true"] + dataEffect: None + inputs: + SOURCE: {enum: [catalog, custom], default: catalog} + volumes: + - {source: %s/data, path: /data} + schedule: {cron: "0 0 1 1 *", timeout: 30s, catchUp: false} + execution: + retention: 168h + steps: + - id: sync + command: + - sh + - -c + - | + echo "$SOURCE" >> /data/sync.log + printf '{"RELEASE":"release-123"}' > "$ONEBOX_OUTPUT_FILE" + outputs: [RELEASE] + - id: index + inputs: {RELEASE_ID: sync.RELEASE} + command: + - sh + - -c + - | + echo "$ONEBOX_STEP_ID $ONEBOX_ATTEMPT_ID $RELEASE_ID" >> /data/index.log + while test -f /data/hold; do touch /data/started; sleep 1; done + test -f /data/allow `, name, base, s.target, base) if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(project), 0o600); err != nil { t.Fatal(err) diff --git a/e2e/testdata/app/ob.yml b/e2e/testdata/app/ob.yml index a1d17b80..94d4f830 100644 --- a/e2e/testdata/app/ob.yml +++ b/e2e/testdata/app/ob.yml @@ -1,23 +1,26 @@ -api_version: onebox.run/v1 -app: obe2e -environments: - production: { server: local } # e2e uses the local transport; the value is unused -workloads: - web: - role: application - # The workload references the Compose file so the e2e fixture keeps its - # Traefik labels and its busybox command exactly as written. - compose: "docker-compose.yaml#web" - strategy: rolling - health: { http: /, port: 8080, interval: 1s, start_period: 1s, within: 60s } -deployment: - order: [web] -proxy: - # Traefik is started by the test itself, from the same Compose file, so - # Onebox must not try to manage one. - managed: false -checks: - # exec (not http): on macOS the container bridge network is unreachable - # from the host, so probe from inside the container. - exec: - - {workload: web, run: "wget -qO- http://localhost:8080/"} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: obe2e +spec: + environments: + production: {server: local} # e2e uses the local transport; the value is unused + workloads: + web: + role: Application + # The workload references the Compose file so the e2e fixture keeps its + # Traefik labels and its busybox command exactly as written. + compose: "docker-compose.yaml#web" + strategy: Rolling + health: {http: /, port: 8080, interval: 1s, startPeriod: 1s, within: 60s} + deployment: + order: [web] + proxy: + # Traefik is started by the test itself, from the same Compose file, so + # Onebox must not try to manage one. + managed: false + checks: + # exec (not http): on macOS the container bridge network is unreachable + # from the host, so probe from inside the container. + exec: + - {workload: web, run: "wget -qO- http://localhost:8080/"} diff --git a/e2e/testdata/postgres/ob.yml.tmpl b/e2e/testdata/postgres/ob.yml.tmpl index 5cd76811..735aa6ac 100644 --- a/e2e/testdata/postgres/ob.yml.tmpl +++ b/e2e/testdata/postgres/ob.yml.tmpl @@ -1,112 +1,109 @@ -# The project the server suite deploys: a managed PostgreSQL with a backup -# policy pointing at an object store the guest runs itself. -# -# 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 -app: observer -environments: - production: - server: {{ .Server }} -# A workload is required, and this one exists only to satisfy that: the suite -# is about the database beside it. Kept to a sleeping busybox so the deploy -# costs a pull of a few megabytes rather than a real application image. -# One workload that actually serves something, because a sleeping container -# cannot answer whether a release replaced what was running. It writes its -# version into a file and serves it, so `exec` can read the version back and -# `rollback` has something observable to undo. -workloads: - app: - role: application - # Digest-pinned and taken from a mirror, so a deploy never asks a registry - # to resolve a tag. That resolution is `docker buildx imagetools inspect` - # (internal/engine/plan.go), which walks every child manifest in the index - # — sixteen requests for this image — and exhausts the anonymous quota of - # any public registry within a few runs. Tag resolution has its own test. - image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 - command: - - sh - - -c - - mkdir -p /srv && echo {{ .Version }} > /srv/index.html && httpd -f -p 8080 -h /srv - strategy: rolling - health: { exec: "wget -qO- http://localhost:8080/", interval: 1s, start_period: 1s, within: 30s } - # A one-shot workload so `job plan` and `job run` have something sealed to - # run. Jobs are workloads with the job role, which is why the loader reports - # them separately from applications. - chore: - role: job - image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 - command: ["sh", "-c", "echo chore-ran"] - data_effect: none - schedule: { cron: "0 0 1 1 *", timeout: 20s, catch_up: false } - # A deliberately wedged timer run proves the host-enforced timeout becomes a - # recorded timeout that `ob status` exposes. Its annual timer never fires - # during the suite; the test starts the service directly. The SLEEP input - # lets a later operator run succeed, which is the only thing that clears a - # recorded failure. The timeout leaves enough room for container startup and - # cleanup while remaining well below the deliberately wedged 30-second run. - timeout-chore: - role: job - image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 - command: ["sh", "-c", "sleep $SLEEP"] - data_effect: none - inputs: - SLEEP: {enum: ["0", "30"], default: "30"} - schedule: { cron: "0 0 1 1 *", timeout: 5s, catch_up: false } - # Fails once, then succeeds: proves the in-firing retry and that the run - # record counts attempts. The marker lives on the host so the second - # attempt, a fresh container, can see the first one ran. - retry-chore: - role: job - image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 - command: ["sh", "-c", "if [ -f /marker/ran ]; then echo second; else touch /marker/ran; exit 1; fi"] - data_effect: none - volumes: [{source: /tmp/onebox-e2e-retry, path: /marker}] - schedule: { cron: "0 0 1 1 *", timeout: 60s, catch_up: false, retry: {attempts: 2, backoff: 1s} } - # A declared input reaches the container as an environment variable: its - # default on a timer-shaped start, an override on a operator run. - input-chore: - role: job - image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 - command: ["sh", "-c", "echo greeting=$GREETING"] - data_effect: none - inputs: - GREETING: {enum: [hi, hello], default: hi} - schedule: { cron: "0 0 1 1 *", timeout: 20s, catch_up: false } -deployment: - order: [app] -services: - postgres: - driver: postgres - version: "18" - persistence: { mode: durable } - backup: - target: offsite - recovery_kind: pitr - max_data_loss: 15m - schedule: { cron: "0 2 * * *", timezone: UTC } -backup_targets: - offsite: - kind: s3-compatible - # HTTPS, with a certificate signed by an authority that exists only on this - # guest. That is the whole point: nothing about this endpoint is publicly - # trusted, so it can only verify if ob carries the host's trust store into - # the container the way it carries the binary. - endpoint: {{ .Endpoint }} - bucket: observer-backups - failure_domain: { identity: e2e/guest } - credentials: - file: secrets/backup.env - access_key_entry: BACKUP_ACCESS_KEY_ID - secret_key_entry: BACKUP_SECRET_ACCESS_KEY - encryption: { pitr: client-side } -notifications: - # The server test starts a one-shot receiver before inducing a scheduled-job - # timeout. Because the timer fires on the host, localhost is the guest itself. - schedule-failures: - webhook: http://127.0.0.1:18080/notify - on: [failure] - format: json -proxy: - managed: false +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: observer +spec: + environments: + production: + server: {? {.Server: ''} : ''} + # A workload is required, and this one exists only to satisfy that: the suite + # is about the database beside it. Kept to a sleeping busybox so the deploy + # costs a pull of a few megabytes rather than a real application image. + # One workload that actually serves something, because a sleeping container + # cannot answer whether a release replaced what was running. It writes its + # version into a file and serves it, so `exec` can read the version back and + # `rollback` has something observable to undo. + workloads: + app: + role: Application + # Digest-pinned and taken from a mirror, so a deploy never asks a registry + # to resolve a tag. That resolution is `docker buildx imagetools inspect` + # (internal/engine/plan.go), which walks every child manifest in the index + # — sixteen requests for this image — and exhausts the anonymous quota of + # any public registry within a few runs. Tag resolution has its own test. + image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 + command: + - sh + - -c + - mkdir -p /srv && echo {{ .Version }} > /srv/index.html && httpd -f -p 8080 -h /srv + strategy: Rolling + health: {exec: "wget -qO- http://localhost:8080/", interval: 1s, startPeriod: 1s, within: 30s} + # A one-shot workload so `job plan` and `job run` have something sealed to + # run. Jobs are workloads with the job role, which is why the loader reports + # them separately from applications. + chore: + role: Job + image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 + command: ["sh", "-c", "echo chore-ran"] + dataEffect: None + schedule: {cron: "0 0 1 1 *", timeout: 20s, catchUp: false} + # A deliberately wedged timer run proves the host-enforced timeout becomes a + # recorded timeout that `ob status` exposes. Its annual timer never fires + # during the suite; the test starts the service directly. The SLEEP input + # lets a later operator run succeed, which is the only thing that clears a + # recorded failure. The timeout leaves enough room for container startup and + # cleanup while remaining well below the deliberately wedged 30-second run. + timeout-chore: + role: Job + image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 + command: ["sh", "-c", "sleep $SLEEP"] + dataEffect: None + inputs: + SLEEP: {enum: ["0", "30"], default: "30"} + schedule: {cron: "0 0 1 1 *", timeout: 5s, catchUp: false} + # Fails once, then succeeds: proves the in-firing retry and that the run + # record counts attempts. The marker lives on the host so the second + # attempt, a fresh container, can see the first one ran. + retry-chore: + role: Job + image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 + command: ["sh", "-c", "if [ -f /marker/ran ]; then echo second; else touch /marker/ran; exit 1; fi"] + dataEffect: None + volumes: [{source: /tmp/onebox-e2e-retry, path: /marker}] + schedule: {cron: "0 0 1 1 *", timeout: 60s, catchUp: false, retry: {attempts: 2, backoff: 1s}} + # A declared input reaches the container as an environment variable: its + # default on a timer-shaped start, an override on a operator run. + input-chore: + role: Job + image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 + command: ["sh", "-c", "echo greeting=$GREETING"] + dataEffect: None + inputs: + GREETING: {enum: [hi, hello], default: hi} + schedule: {cron: "0 0 1 1 *", timeout: 20s, catchUp: false} + deployment: + order: [app] + services: + postgres: + driver: postgres + version: "18" + persistence: {mode: Durable} + backup: + target: offsite + recoveryKind: pitr + maxDataLoss: 15m + schedule: {cron: "0 2 * * *", timezone: UTC} + backupTargets: + offsite: + kind: S3Compatible + # HTTPS, with a certificate signed by an authority that exists only on this + # guest. That is the whole point: nothing about this endpoint is publicly + # trusted, so it can only verify if ob carries the host's trust store into + # the container the way it carries the binary. + endpoint: {? {.Endpoint: ''} : ''} + bucket: observer-backups + failureDomain: {identity: e2e/guest} + credentials: + file: secrets/backup.env + accessKeyEntry: BACKUP_ACCESS_KEY_ID + secretKeyEntry: BACKUP_SECRET_ACCESS_KEY + encryption: {pitr: client-side} + notifications: + # The server test starts a one-shot receiver before inducing a scheduled-job + # timeout. Because the timer fires on the host, localhost is the guest itself. + schedule-failures: + webhook: http://127.0.0.1:18080/notify + on: [Failure] + format: JSON + proxy: + managed: false diff --git a/e2e/testdata/worker/ob-broken.yml b/e2e/testdata/worker/ob-broken.yml index 370aa171..8d34da5f 100644 --- a/e2e/testdata/worker/ob-broken.yml +++ b/e2e/testdata/worker/ob-broken.yml @@ -1,21 +1,24 @@ -api_version: onebox.run/v1 -app: obworker -environments: { production: { server: local } } -workloads: - worker: - role: worker - compose: "docker-compose.yaml#worker" - strategy: recreate - health: { exec: "test -f /nonexistent", interval: 1s, start_period: 1s, within: 6s } - web: - role: application - compose: "docker-compose.yaml#web" - strategy: recreate - health: { exec: "wget -qO- http://localhost:8080/", interval: 1s, start_period: 1s, within: 30s } -deployment: - order: [worker, web] -proxy: - managed: false -checks: - exec: - - {workload: web, run: "wget -qO- http://localhost:8080/"} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: obworker +spec: + environments: {production: {server: local}} + workloads: + worker: + role: Worker + compose: "docker-compose.yaml#worker" + strategy: Recreate + health: {exec: "test -f /nonexistent", interval: 1s, startPeriod: 1s, within: 6s} + web: + role: Application + compose: "docker-compose.yaml#web" + strategy: Recreate + health: {exec: "wget -qO- http://localhost:8080/", interval: 1s, startPeriod: 1s, within: 30s} + deployment: + order: [worker, web] + proxy: + managed: false + checks: + exec: + - {workload: web, run: "wget -qO- http://localhost:8080/"} diff --git a/e2e/testdata/worker/ob.yml b/e2e/testdata/worker/ob.yml index 3b8df8c4..ec060e23 100644 --- a/e2e/testdata/worker/ob.yml +++ b/e2e/testdata/worker/ob.yml @@ -1,21 +1,24 @@ -api_version: onebox.run/v1 -app: obworker -environments: { production: { server: local } } -workloads: - worker: - role: worker - compose: "docker-compose.yaml#worker" - strategy: recreate - health: { exec: "test -f /marker", interval: 1s, start_period: 1s, within: 30s } - web: - role: application - compose: "docker-compose.yaml#web" - strategy: recreate - health: { exec: "wget -qO- http://localhost:8080/", interval: 1s, start_period: 1s, within: 30s } -deployment: - order: [worker, web] -proxy: - managed: false -checks: - exec: - - {workload: web, run: "wget -qO- http://localhost:8080/"} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: obworker +spec: + environments: {production: {server: local}} + workloads: + worker: + role: Worker + compose: "docker-compose.yaml#worker" + strategy: Recreate + health: {exec: "test -f /marker", interval: 1s, startPeriod: 1s, within: 30s} + web: + role: Application + compose: "docker-compose.yaml#web" + strategy: Recreate + health: {exec: "wget -qO- http://localhost:8080/", interval: 1s, startPeriod: 1s, within: 30s} + deployment: + order: [worker, web] + proxy: + managed: false + checks: + exec: + - {workload: web, run: "wget -qO- http://localhost:8080/"} diff --git a/internal/app/application_fixture_test.go b/internal/app/application_fixture_test.go new file mode 100644 index 00000000..6351e601 --- /dev/null +++ b/internal/app/application_fixture_test.go @@ -0,0 +1,244 @@ +package app + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// loadFixtureBytes repairs only v1alpha1 test fragments assembled through Go +// string concatenation. Repository-owned YAML files and the shipped loader do +// not pass through it. Direct contract tests below call LoadBytes itself. +func loadFixtureBytes(body []byte, filename string) (*Spec, error) { + return LoadBytes(normalizeApplicationFixture(body), filename) +} + +func TestAuthoredVocabularyIsMechanicallyNormalized(t *testing.T) { + seen := map[reflect.Type]bool{} + var walk func(reflect.Type) + walk = func(typ reflect.Type) { + typ = deref(typ) + if seen[typ] { + return + } + seen[typ] = true + switch typ.Kind() { + case reflect.Struct: + for name, field := range authoredFieldsOf(typ) { + if strings.Contains(name, "_") || name != lowerCamel(name) { + t.Errorf("%s.%s publishes non-lowerCamel field %q", typ.Name(), field.Name, name) + } + walk(field.Type) + } + case reflect.Map, reflect.Slice, reflect.Array: + walk(typ.Elem()) + } + } + walk(reflect.TypeOf(Application{})) + + for field, values := range declarativeEnums { + for _, internal := range values { + public := publicEnum(field, internal) + if public != upperCamel(internal) { + t.Errorf("%s value %q publishes as %q", field, internal, public) + } + if roundTrip, ok := internalEnum(field, public); !ok || roundTrip != internal { + t.Errorf("%s value %q does not round-trip through %q", field, internal, public) + } + } + } +} + +func TestPublishedApplicationFixtures(t *testing.T) { + root := filepath.Join("..", "..", "api", "testdata", "application") + for _, tc := range []struct { + dir string + ok bool + }{{"valid", true}, {"invalid", false}} { + entries, err := os.ReadDir(filepath.Join(root, tc.dir)) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + t.Run(tc.dir+"/"+entry.Name(), func(t *testing.T) { + path := filepath.Join(root, tc.dir, entry.Name()) + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + _, err = LoadBytes(body, path) + if (err == nil) != tc.ok { + t.Fatalf("load error = %v, want valid=%v", err, tc.ok) + } + }) + } + } +} + +func normalizeApplicationFixture(body []byte) []byte { + var root map[string]any + if yaml.Unmarshal(body, &root) != nil || root["apiVersion"] != APIVersion { + return body + } + spec, _ := root["spec"].(map[string]any) + if spec == nil { + spec = map[string]any{} + root["spec"] = spec + } + for key, value := range root { + switch key { + case "apiVersion", "kind", "metadata", "spec": + default: + if existing, ok := spec[key].(map[string]any); ok { + if incoming, ok := value.(map[string]any); ok { + for name, child := range incoming { + existing[name] = child + } + } else { + spec[key] = value + } + } else { + spec[key] = value + } + delete(root, key) + } + } + metadata, _ := root["metadata"].(map[string]any) + name, _ := metadata["name"].(string) + shorthand := map[string]any{} + for _, key := range fixtureShorthandKeys { + if value, ok := spec[key]; ok { + shorthand[key] = value + delete(spec, key) + } + } + if len(shorthand) > 0 { + workloads, _ := spec["workloads"].(map[string]any) + if workloads == nil { + workloads = map[string]any{} + spec["workloads"] = workloads + } + if len(workloads) == 0 { + workloads[name] = shorthand + } else { + for key, value := range shorthand { + spec[key] = value + } + } + } + normalizeFixtureMap(spec, "spec") + normalized, err := yaml.Marshal(root) + if err != nil { + return body + } + return normalized +} + +var fixtureShorthandKeys = []string{"build", "image", "compose", "port", "health", "routes"} + +var fixtureIdentifierMaps = map[string]bool{ + "environments": true, "workloads": true, "services": true, + "external_services": true, "externalServices": true, + "backup_targets": true, "backupTargets": true, + "notifications": true, "registries": true, "entrypoints": true, + "env": true, "labels": true, "settings": true, "args": true, + "inputs": true, "annotations": true, "extensions": true, "hooks": true, +} + +func normalizeFixtureMap(value map[string]any, field string) { + currentIdentifiers := fixtureIdentifierMaps[field] + for key, child := range value { + publicKey := key + if currentIdentifiers { + if field == "hooks" { + for _, seam := range eHookSeam { + if key == seam { + publicKey = upperCamel(key) + } + } + } + } else { + publicKey = lowerCamel(key) + } + normalized := normalizeFixtureValue(child, key) + if publicKey != key { + delete(value, key) + } + value[publicKey] = normalized + } +} + +func normalizeFixtureValue(value any, field string) any { + switch typed := value.(type) { + case map[string]any: + normalizeFixtureMap(typed, field) + return typed + case []any: + for i, item := range typed { + typed[i] = normalizeFixtureValue(item, field) + } + return typed + case string: + if field == "provider" && typed != "sops" && typed != "Sops" { + return typed + } + if _, ok := declarativeEnums[field]; ok { + for _, candidate := range declarativeEnums[field] { + if typed == candidate || strings.EqualFold(typed, upperCamel(candidate)) { + return upperCamel(candidate) + } + } + } + return typed + default: + return value + } +} + +func TestApplicationContractRejectsLegacyForms(t *testing.T) { + legacy := []string{ + "api_version: onebox.run/v1\napp: shop\nenvironments: {}\nimage: nginx\n", + "apiVersion: onebox.run/v1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}}\n", + "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}, base_path: /srv/ob}\n", + "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {web: {image: nginx, role: application}}}\n", + "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}}\nimage: nginx\n", + "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: shop}\nspec: {environments: {}, workloads: {}, x-behavior: true}\n", + } + for _, source := range legacy { + if _, err := LoadBytes([]byte(source), "legacy.yml"); err == nil { + t.Errorf("legacy form was accepted:\n%s", source) + } + } +} + +func TestProviderNativeValuesRemainData(t *testing.T) { + source := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: shop} +spec: + environments: {production: {server: deploy@example.com}} + workloads: + migrate: {role: Job, image: migrate:1, dataEffect: Migration} + checks: + migrations: + - {job: migrate, provider: atlas, appliedRevisions: ["202609200001"]} + proxy: + managed: true + config: traefik + dnsChallenge: {provider: cloudflare} +` + loaded, err := LoadBytes([]byte(source), "ob.yml") + if err != nil { + t.Fatal(err) + } + if got := loaded.Checks.Migrations[0].Provider; got != "atlas" { + t.Fatalf("migration provider = %q", got) + } + if got := loaded.Proxy.DNSChallenge.Provider; got != "cloudflare" { + t.Fatalf("DNS provider = %q", got) + } +} diff --git a/internal/app/backup_schema_test.go b/internal/app/backup_schema_test.go index 806eaf82..541d6621 100644 --- a/internal/app/backup_schema_test.go +++ b/internal/app/backup_schema_test.go @@ -6,40 +6,43 @@ import ( "testing" ) -const validBackupProject = `api_version: onebox.run/v1 -app: shop -environments: - production: - server: deploy@app.example.net -workloads: - web: {image: nginx:1} -backup_targets: - offsite: - kind: s3-compatible - endpoint: https://objects.example.net - bucket: onebox-backups - prefix: production/shop - failure_domain: - identity: provider-b/region-2/account-7 - host: objects.example.net - credentials: - file: secrets/backup.env - provider: sops - access_key_entry: BACKUP_ACCESS_KEY_ID - secret_key_entry: BACKUP_SECRET_ACCESS_KEY - encryption: - pitr: client-side -services: - postgres: - version: 17 - backup: - target: offsite - recovery_kind: pitr - max_data_loss: 15m +const validBackupProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: deploy@app.example.net + workloads: + web: {image: 'nginx:1'} + backupTargets: + offsite: + kind: S3Compatible + endpoint: https://objects.example.net + bucket: onebox-backups + prefix: production/shop + failureDomain: + identity: provider-b/region-2/account-7 + host: objects.example.net + credentials: + file: secrets/backup.env + provider: Sops + accessKeyEntry: BACKUP_ACCESS_KEY_ID + secretKeyEntry: BACKUP_SECRET_ACCESS_KEY + encryption: + pitr: ClientSide + services: + postgres: + version: 17 + backup: + target: offsite + recoveryKind: Pitr + maxDataLoss: 15m ` func TestBackupIntentLoadsAndDefaultsToExactSchedules(t *testing.T) { - p, err := LoadBytes([]byte(validBackupProject), "ob.yml") + p, err := loadFixtureBytes([]byte(validBackupProject), "ob.yml") if err != nil { t.Fatal(err) } @@ -66,24 +69,27 @@ 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 -app: shop -environments: {production: {server: deploy@app.example.net}} -workloads: {web: {image: nginx:1}} -backup_targets: - offsite: - kind: s3-compatible - endpoint: https://objects.example.net - bucket: onebox-backups - failure_domain: {identity: provider-b/region-2, host: objects.example.net} - credentials: {file: secrets/backup.env, provider: sops, access_key_entry: BACKUP_ACCESS, secret_key_entry: BACKUP_SECRET} - encryption: {cold: client-side} -services: - minio: - version: RELEASE.2026-07-31T00-00-00Z - backup: {target: offsite, recovery_kind: cold, max_data_loss: 24h, allow_downtime: true} + project := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: deploy@app.example.net}} + workloads: {web: {image: 'nginx:1'}} + backupTargets: + offsite: + kind: S3Compatible + endpoint: https://objects.example.net + bucket: onebox-backups + failureDomain: {identity: provider-b/region-2, host: objects.example.net} + credentials: {file: secrets/backup.env, provider: Sops, accessKeyEntry: BACKUP_ACCESS, secretKeyEntry: BACKUP_SECRET} + encryption: {cold: ClientSide} + services: + minio: + version: RELEASE.2026-07-31T00-00-00Z + backup: {target: offsite, recoveryKind: Cold, maxDataLoss: 24h, allowDowntime: true} ` - _, err := LoadBytes([]byte(project), "ob.yml") + _, err := loadFixtureBytes([]byte(project), "ob.yml") if err == nil { t.Fatal("a minio backup policy was accepted, but no driver except postgres can establish one") } @@ -93,25 +99,29 @@ services: } func TestReplicationIntentIsRejected(t *testing.T) { - project := strings.ReplaceAll(validBackupProject, "kind: s3-compatible", "kind: minio-replication") - if _, err := LoadBytes([]byte(project), "ob.yml"); err == nil { + project := strings.ReplaceAll(validBackupProject, "kind: S3Compatible", "kind: MinioReplication") + if _, err := loadFixtureBytes([]byte(project), "ob.yml"); err == nil { t.Fatal("removed replication target was accepted") } } func TestRunnableUnqualifiedDriverRejectsBackupWithoutFallback(t *testing.T) { - if _, err := LoadBytes([]byte(`api_version: onebox.run/v1 -app: shop -environments: {production: {server: deploy@app.example.net}} -workloads: {web: {image: nginx:1}} -services: {redis: 7} + if _, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: deploy@app.example.net}} + workloads: {web: {image: 'nginx:1'}} + services: {redis: 7} `), "ob.yml"); err != nil { t.Fatalf("unqualified driver must remain runnable: %v", err) } protected := strings.ReplaceAll(validBackupProject, "postgres", "redis") protected = strings.ReplaceAll(protected, "pitr", "snapshot") - _, err := LoadBytes([]byte(protected), "ob.yml") + protected = strings.ReplaceAll(protected, "Pitr", "Snapshot") + _, err := loadFixtureBytes([]byte(protected), "ob.yml") assertAppErrorCode(t, err, "backup_driver_unsupported") var appErr *Error if !errors.As(err, &appErr) || appErr.Next != "ob validate" { @@ -142,7 +152,7 @@ func TestBackupIntentRefusals(t *testing.T) { }{ { name: "inline storage secret", - yaml: strings.Replace(validBackupProject, " secret_key_entry: BACKUP_SECRET_ACCESS_KEY\n", " secret_key_entry: BACKUP_SECRET_ACCESS_KEY\n secret_key: plaintext-must-not-enter-the-model\n", 1), + yaml: strings.Replace(validBackupProject, " secretKeyEntry: BACKUP_SECRET_ACCESS_KEY\n", " secretKeyEntry: BACKUP_SECRET_ACCESS_KEY\n secretKey: plaintext-must-not-enter-the-model\n", 1), code: "unknown_field", }, { @@ -152,43 +162,43 @@ func TestBackupIntentRefusals(t *testing.T) { }, { name: "author selects backup tool", - yaml: strings.Replace(validBackupProject, " target: offsite\n", " target: offsite\n tool: some-backup-tool\n", 1), + yaml: strings.Replace(validBackupProject, " target: offsite\n", " target: offsite\n tool: some-backup-tool\n", 1), code: "unknown_field", }, { name: "recurring policy tries to authorize enablement restart", - yaml: strings.Replace(validBackupProject, " max_data_loss: 15m\n", " max_data_loss: 15m\n allow_enablement_restart: true\n", 1), + yaml: strings.Replace(validBackupProject, " maxDataLoss: 15m\n", " maxDataLoss: 15m\n allowEnablementRestart: true\n", 1), code: "unknown_field", }, { name: "restore drill too sparse", - yaml: strings.Replace(validBackupProject, " max_data_loss: 15m\n", " max_data_loss: 15m\n drill:\n schedule: {cron: '0 3 1 * *', timezone: UTC}\n max_age: 7d\n", 1), + yaml: strings.Replace(validBackupProject, " maxDataLoss: 15m\n", " maxDataLoss: 15m\n drill:\n schedule: {cron: '0 3 1 * *', timezone: UTC}\n maxAge: 7d\n", 1), code: "drill_schedule_too_sparse", }, { name: "stepped weekday drill too sparse", - yaml: strings.Replace(validBackupProject, " max_data_loss: 15m\n", " max_data_loss: 15m\n drill:\n schedule: {cron: '0 3 * * */2', timezone: UTC}\n max_age: 36h\n", 1), + yaml: strings.Replace(validBackupProject, " maxDataLoss: 15m\n", " maxDataLoss: 15m\n drill:\n schedule: {cron: '0 3 * * */2', timezone: UTC}\n maxAge: 36h\n", 1), code: "drill_schedule_too_sparse", }, { name: "sub-minute replay objective", - yaml: strings.Replace(validBackupProject, "max_data_loss: 15m", "max_data_loss: 30s", 1), + yaml: strings.Replace(validBackupProject, "maxDataLoss: 15m", "maxDataLoss: 30s", 1), code: "recovery_objective_unsupported", }, { name: "unsupported retention", - yaml: strings.Replace(validBackupProject, " max_data_loss: 15m\n", " max_data_loss: 15m\n retention: {keep: 0, window: 7d}\n", 1), + yaml: strings.Replace(validBackupProject, " maxDataLoss: 15m\n", " maxDataLoss: 15m\n retention: {keep: 0, window: 7d}\n", 1), code: "backup_retention_unsupported", }, { name: "unsupported objective", - yaml: strings.Replace(validBackupProject, "recovery_kind: pitr", "recovery_kind: snapshot", 1), + yaml: strings.Replace(validBackupProject, "recoveryKind: Pitr", "recoveryKind: Snapshot", 1), code: "recovery_objective_unsupported", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := LoadBytes([]byte(tc.yaml), "ob.yml") + _, err := loadFixtureBytes([]byte(tc.yaml), "ob.yml") assertAppErrorCode(t, err, tc.code) if strings.Contains(errorString(err), "plaintext-must-not-enter-the-model") { t.Fatal("failure reflected an inline credential value") @@ -198,16 +208,16 @@ func TestBackupIntentRefusals(t *testing.T) { } func TestBackupEnvironmentOverridesTuneOnlySchedulesAndRetention(t *testing.T) { - valid := strings.Replace(validBackupProject, " server: deploy@app.example.net\n", ` server: deploy@app.example.net - overrides: - services: - postgres: - backup: - schedule: {cron: '0 4 * * *'} - retention: {keep: 10} - drill: {schedule: {cron: '0 5 * * 1,4'}} + valid := strings.Replace(validBackupProject, " server: deploy@app.example.net\n", ` server: deploy@app.example.net + overrides: + services: + postgres: + backup: + schedule: {cron: '0 4 * * *'} + retention: {keep: 10} + drill: {schedule: {cron: '0 5 * * 1,4'}} `, 1) - p, err := LoadBytes([]byte(valid), "ob.yml") + p, err := loadFixtureBytes([]byte(valid), "ob.yml") if err != nil { t.Fatal(err) } @@ -220,13 +230,13 @@ func TestBackupEnvironmentOverridesTuneOnlySchedulesAndRetention(t *testing.T) { t.Fatalf("resolved safe override = %#v", policy) } - unsafe := strings.Replace(validBackupProject, " server: deploy@app.example.net\n", ` server: deploy@app.example.net - overrides: - services: - postgres: - backup: {target: another-repository} + unsafe := strings.Replace(validBackupProject, " server: deploy@app.example.net\n", ` server: deploy@app.example.net + overrides: + services: + postgres: + backup: {target: another-repository} `, 1) - p, err = LoadBytes([]byte(unsafe), "ob.yml") + p, err = loadFixtureBytes([]byte(unsafe), "ob.yml") if err != nil { t.Fatal(err) } diff --git a/internal/app/canonical.go b/internal/app/canonical.go index 5272d446..c4876ad6 100644 --- a/internal/app/canonical.go +++ b/internal/app/canonical.go @@ -3,6 +3,7 @@ package app import ( "encoding/json" "fmt" + "reflect" "sort" "strings" @@ -120,11 +121,11 @@ func (r *Resolved) Canonical() ([]byte, error) { restoreDeclaredEmpty(generic, r.Spec) restoreDeclaredEmptyScopes(generic, r.Spec) if r.canonicalFacts != nil { - facts, err := canonicalFactsGeneric(*r.canonicalFacts) - if err != nil { + // Facts remain validated for callers that collect them, but observed + // state is not serialized into the authored Application resource. + if _, err := canonicalFactsGeneric(*r.canonicalFacts); err != nil { return nil, err } - generic["effective"] = facts } origins := r.Spec.originOf() // An override is more specific than anything derived from the file, but it @@ -134,6 +135,27 @@ func (r *Resolved) Canonical() ([]byte, error) { for path, o := range r.Origins { origins[path] = o } + authoredSpec := internalToAuthored(reflect.TypeOf(Spec{}), generic, "spec") + generic = map[string]any{ + "apiVersion": APIVersion, + "kind": ApplicationKind, + "metadata": map[string]any{ + "name": r.Spec.Name, + }, + "spec": authoredSpec, + } + if len(r.Spec.Annotations) > 0 { + generic["metadata"].(map[string]any)["annotations"] = r.Spec.Annotations + } + authoredOrigins := map[string]Origin{ + "apiVersion": OriginAuthored, + "kind": OriginAuthored, + "metadata.name": OriginAuthored, + } + for path, origin := range origins { + authoredOrigins[authoredOriginPath(path)] = origin + } + origins = authoredOrigins node, err := annotated("", generic, origins) if err != nil { @@ -206,6 +228,11 @@ func (r *Resolved) OriginTable() [][2]string { for path, o := range r.Origins { origins[path] = o } + public := make(map[string]Origin, len(origins)) + for path, origin := range origins { + public[authoredOriginPath(path)] = origin + } + origins = public paths := make([]string, 0, len(origins)) for p := range origins { paths = append(paths, p) diff --git a/internal/app/canonical_test.go b/internal/app/canonical_test.go index e220d11b..177b23b6 100644 --- a/internal/app/canonical_test.go +++ b/internal/app/canonical_test.go @@ -5,21 +5,26 @@ import ( "testing" ) -const canonicalProject = `api_version: onebox.run/v1 -app: ledger -environments: - production: {server: root@1.2.3.4} - staging: - server: root@5.6.7.8 - overrides: {workloads: {ledger: {replicas: 3}}} -build: . -routes: - - {hostname: ledger.example.com, port: 8080} +const canonicalProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: + production: {server: root@1.2.3.4} + staging: + server: root@5.6.7.8 + overrides: {workloads: {ledger: {replicas: 3}}} + workloads: + ledger: + build: . + routes: + - {hostname: ledger.example.com, port: 8080} ` func originsFor(t *testing.T, env string) map[string]Origin { t.Helper() - p, err := LoadBytes([]byte(canonicalProject), "ob.yml") + p, err := loadFixtureBytes([]byte(canonicalProject), "ob.yml") if err != nil { t.Fatal(err) } @@ -40,14 +45,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.routes[0].hostname": OriginAuthored, - "workloads.ledger.routes[0].port": OriginAuthored, - "workloads.ledger.replicas": OriginDefault, - "workloads.ledger.strategy": OriginDefault, - "base_path": OriginDefault, - "proxy.network": OriginDefault, + "metadata.name": OriginAuthored, + "spec.workloads.ledger.build.context": OriginAuthored, + "spec.workloads.ledger.routes[0].hostname": OriginAuthored, + "spec.workloads.ledger.routes[0].port": OriginAuthored, + "spec.workloads.ledger.replicas": OriginDefault, + "spec.workloads.ledger.strategy": OriginDefault, + "spec.basePath": OriginDefault, + "spec.proxy.network": OriginDefault, } { if o[path] != want { t.Errorf("%s = %q, want %q", path, o[path], want) @@ -59,17 +64,17 @@ func TestOriginsDistinguishWhatWasWritten(t *testing.T) { // so the schema can discriminate. Reporting it as explicit would tell someone // they made a decision they never made. func TestInjectedRoleIsNotClaimedAsTheAuthorsChoice(t *testing.T) { - if got := originsFor(t, "production")["workloads.ledger.role"]; got != OriginDefault { + if got := originsFor(t, "production")["spec.workloads.ledger.role"]; got != OriginDefault { t.Errorf("injected role reported as %q, want default", got) } } // TestOverrideOriginSurvivesResolution. func TestOverrideOriginSurvivesResolution(t *testing.T) { - if got := originsFor(t, "staging")["workloads.ledger.replicas"]; got != OriginEnvironmentOverride { + if got := originsFor(t, "staging")["spec.workloads.ledger.replicas"]; got != OriginEnvironmentOverride { t.Errorf("staging replicas = %q, want override", got) } - if got := originsFor(t, "production")["workloads.ledger.replicas"]; got != OriginDefault { + if got := originsFor(t, "production")["spec.workloads.ledger.replicas"]; got != OriginDefault { t.Errorf("production replicas = %q, want default", got) } } @@ -77,7 +82,7 @@ func TestOverrideOriginSurvivesResolution(t *testing.T) { // TestCanonicalAnnotatesOnlyWhatWasNotWritten: annotating an explicit value // would be noise on every line the author actually typed. func TestCanonicalAnnotatesOnlyWhatWasNotWritten(t *testing.T) { - p, _ := LoadBytes([]byte(canonicalProject), "ob.yml") + p, _ := loadFixtureBytes([]byte(canonicalProject), "ob.yml") r, _ := p.Resolve("staging") body, err := r.Canonical() if err != nil { @@ -98,15 +103,15 @@ func TestCanonicalAnnotatesOnlyWhatWasNotWritten(t *testing.T) { } } -func TestCanonicalBackupFactsCoverEveryPublicOrigin(t *testing.T) { - project := strings.Replace(validBackupProject, " server: deploy@app.example.net\n", ` server: deploy@app.example.net - overrides: - services: - postgres: - backup: - retention: {keep: 10} +func TestCanonicalKeepsObservedFactsOutsideTheAuthoredResource(t *testing.T) { + project := strings.Replace(validBackupProject, " server: deploy@app.example.net\n", ` server: deploy@app.example.net + overrides: + services: + postgres: + backup: + retention: {keep: 10} `, 1) - spec, err := LoadBytes([]byte(project), "ob.yml") + spec, err := loadFixtureBytes([]byte(project), "ob.yml") if err != nil { t.Fatal(err) } @@ -119,9 +124,9 @@ func TestCanonicalBackupFactsCoverEveryPublicOrigin(t *testing.T) { origins[row[0]] = Origin(row[1]) } for path, want := range map[string]Origin{ - "services.postgres.backup.recovery_kind": OriginAuthored, - "services.postgres.backup.schedule.cron": OriginDefault, - "services.postgres.backup.retention.keep": OriginEnvironmentOverride, + "spec.services.postgres.backup.recoveryKind": OriginAuthored, + "spec.services.postgres.backup.schedule.cron": OriginDefault, + "spec.services.postgres.backup.retention.keep": OriginEnvironmentOverride, } { if got := origins[path]; got != want { t.Errorf("%s origin = %q, want %q", path, got, want) @@ -155,30 +160,20 @@ func TestCanonicalBackupFactsCoverEveryPublicOrigin(t *testing.T) { t.Fatal(err) } out := string(body) - for _, golden := range []string{ - "recovery_kind: pitr", - "keep: 10 # environment-override", - "logging_max_size:", - "value: 20MB", - "origin: default", - "value: Managed", - "origin: derived", - "service_image_digest:", - "origin: observed", - "observed_rpo:", - "observed_recovery_window:", - "expected_interruption:", - "drill_capacity_state:", - "code: archive-mode", - } { + for _, golden := range []string{"recoveryKind: Pitr", "keep: 10 # environment-override"} { if !strings.Contains(out, golden) { t.Errorf("canonical output lacks %q\n%s", golden, out) } } + for _, observed := range []string{"effective:", "serviceImageDigest", "observedRPO"} { + if strings.Contains(out, observed) { + t.Errorf("authored resource contains observed field %q\n%s", observed, out) + } + } } func TestCanonicalFactsRejectUnsafeObservedValuesWithoutReflectingThem(t *testing.T) { - spec, err := LoadBytes([]byte(validBackupProject), "ob.yml") + spec, err := loadFixtureBytes([]byte(validBackupProject), "ob.yml") if err != nil { t.Fatal(err) } @@ -216,27 +211,30 @@ 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 -app: shop -environments: {production: {server: root@h}} -workloads: - web: - role: application - image: nginx - routes: [{hostname: shop.example.com, port: 80}] - volumes: [{name: data, path: /data}] - published_ports: [{host: 9000, container: 9000}] - persistence: {} - drain: {} - job: - role: job - image: nginx - data_effect: none - schedule: {cron: "0 2 * * *"} -services: - postgres: 17 -notifications: {ops: {webhook: "https://example.invalid/hook"}} -runtime: {env_files: [{file: secrets.env, provider: sops}]} + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + workloads: + web: + role: Application + image: nginx + routes: [{hostname: shop.example.com, port: 80}] + volumes: [{name: data, path: /data}] + publishedPorts: [{host: 9000, container: 9000}] + persistence: {} + drain: {} + job: + role: Job + image: nginx + dataEffect: None + schedule: {cron: "0 2 * * *"} + services: + postgres: 17 + notifications: {ops: {webhook: "https://example.invalid/hook"}} + runtime: {envFiles: [{file: secrets.env, provider: Sops}]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -253,27 +251,27 @@ runtime: {env_files: [{file: secrets.env, provider: sops}]} // Each of these is a value nobody wrote and every one of them decides // something about the runtime. for path, want := range map[string]string{ - "base_path": DefaultBasePath, - "deployment.retain_releases": "5", - "deployment.migration_policy": "manual", - "proxy.kind": "traefik-docker", - "proxy.network": IngressNetwork, - "workloads.web.replicas": "1", - "workloads.web.strategy": "rolling", - "workloads.web.image.pull": "missing", - "workloads.web.drain.signal": "TERM", - "workloads.web.routes[0].path": "/", - "workloads.web.routes[0].entrypoint": "websecure", - "workloads.web.routes[0].tls": "terminate", - "workloads.web.volumes[0].mode": "rw", - "workloads.web.published_ports[0].bind": "127.0.0.1", - "workloads.web.published_ports[0].protocol": "tcp", - "workloads.web.persistence.mode": "durable", - "workloads.job.deployment_phase": "none", - "workloads.job.operator_run": "allowed", - "workloads.job.schedule.timezone": "UTC", - "workloads.job.schedule.shutdown_grace": "30s", - "notifications.ops.format": "text", + "spec.basePath": DefaultBasePath, + "spec.deployment.retainReleases": "5", + "spec.deployment.migrationPolicy": "manual", + "spec.proxy.kind": "traefik-docker", + "spec.proxy.network": IngressNetwork, + "spec.workloads.web.replicas": "1", + "spec.workloads.web.strategy": "rolling", + "spec.workloads.web.image.pull": "missing", + "spec.workloads.web.drain.signal": "TERM", + "spec.workloads.web.routes[0].path": "/", + "spec.workloads.web.routes[0].entrypoint": "websecure", + "spec.workloads.web.routes[0].tls": "terminate", + "spec.workloads.web.volumes[0].mode": "rw", + "spec.workloads.web.publishedPorts[0].bind": "127.0.0.1", + "spec.workloads.web.publishedPorts[0].protocol": "tcp", + "spec.workloads.web.persistence.mode": "durable", + "spec.workloads.job.deploymentPhase": "none", + "spec.workloads.job.operatorRun": "allowed", + "spec.workloads.job.schedule.timezone": "UTC", + "spec.workloads.job.schedule.shutdownGrace": "30s", + "spec.notifications.ops.format": "text", } { if origins[path] != string(OriginDefault) { t.Errorf("%s is %q, want %q — a default nobody can see is a default nobody can check (value should be %q)", @@ -282,8 +280,8 @@ runtime: {env_files: [{file: secrets.env, provider: sops}]} } // And a value the author did write is never reported as derived. - for _, path := range []string{"app", "workloads.web.role", "workloads.job.data_effect"} { - if origins[path] == string(OriginDefault) && path != "workloads.web.role" { + for _, path := range []string{"metadata.name", "spec.workloads.web.role", "spec.workloads.job.dataEffect"} { + if origins[path] == string(OriginDefault) && path != "spec.workloads.web.role" { t.Errorf("%s was written by the author and must not be reported as derived", path) } } diff --git a/internal/app/compose_test.go b/internal/app/compose_test.go index fcfa97f8..151d7cf8 100644 --- a/internal/app/compose_test.go +++ b/internal/app/compose_test.go @@ -132,21 +132,24 @@ func TestPathEscapeRefused(t *testing.T) { // TestComposeRefRendersEndToEnd puts the merge through generation. func TestComposeRefRendersEndToEnd(t *testing.T) { - y := `api_version: onebox.run/v1 -app: ledger -environments: - production: {server: root@1.2.3.4} -workloads: - web: - role: application - image: nginx - routes: - - {hostname: ledger.example.com, port: 8080} - db: - role: daemon - compose: compose.yaml#postgres + y := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: + production: {server: root@1.2.3.4} + workloads: + web: + role: Application + image: nginx + routes: + - {hostname: ledger.example.com, port: 8080} + db: + role: Daemon + compose: compose.yaml#postgres ` - p, err := LoadBytes([]byte(y), "testdata/ob.yml") + p, err := loadFixtureBytes([]byte(y), "testdata/ob.yml") if err != nil { t.Fatal(err) } @@ -227,15 +230,18 @@ 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 -app: shop -environments: - production: {server: root@203.0.113.10} -workloads: - web: - role: application - compose: "docker-compose.yaml#web" - health: {http: /healthz, port: 8080} + if err := os.WriteFile(path, []byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: root@203.0.113.10} + workloads: + web: + role: Application + compose: "docker-compose.yaml#web" + health: {http: /healthz, port: 8080} `), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/app/contract.go b/internal/app/contract.go new file mode 100644 index 00000000..7ad73cf3 --- /dev/null +++ b/internal/app/contract.go @@ -0,0 +1,315 @@ +package app + +import ( + "fmt" + "reflect" + "strings" + "unicode" +) + +// Application is the authored Kubernetes-shaped resource envelope. Spec is +// kept as the runtime model so the contract reset does not leak into machine +// artifacts or the engine's internal representation. +type Application struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata ApplicationMeta `json:"metadata"` + Spec Spec `json:"spec"` +} + +type ApplicationMeta struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +const ApplicationKind = "Application" + +func lowerCamel(name string) string { + parts := strings.Split(name, "_") + for i := 1; i < len(parts); i++ { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, "") +} + +func upperCamel(value string) string { + parts := strings.FieldsFunc(value, func(r rune) bool { return r == '_' || r == '-' }) + for i, part := range parts { + switch strings.ToLower(part) { + case "id": + parts[i] = "ID" + case "url": + parts[i] = "URL" + case "http": + parts[i] = "HTTP" + case "json": + parts[i] = "JSON" + case "ssh": + parts[i] = "SSH" + case "uid": + parts[i] = "UID" + case "gid": + parts[i] = "GID" + default: + runes := []rune(part) + if len(runes) > 0 { + runes[0] = unicode.ToUpper(runes[0]) + } + parts[i] = string(runes) + } + } + return strings.Join(parts, "") +} + +// These are Onebox constants, rather than external protocol/data tokens. +// Internally they deliberately retain their historical spellings so machine +// artifacts do not change as a side effect of the authored API reset. +var declarativeEnums = map[string][]string{ + "pull": eImagePull, + "role": eRole, + "strategy": eStrategy, + "deployment_phase": eJobDeploymentPhase, + "operator_run": eJobOperatorRun, + "data_effect": eDataEffect, + "provider": eSecretProvider, + "condition": eNeedCondition, + "mode": append(append(append([]string{}, eMountMode...), ePersistence...), + eEncryptionMode...), + "tls": append(append([]string{}, eRouteTLS...), eBackupTLS...), + "notify": eScheduleNotify, + "deploy_lock": eScheduleDeployLock, + "policy": eMigrationPolicy, + "migration_policy": eMigrationPolicy, + "format": eNotifyFormat, + "on": eNotifyEvent, + "kind": append(append(append(append([]string{}, eProxyKind...), eBackupTargetKind...), + eRecoveryKind...), eExternalProbeKind...), + "part": eConnectionPart, + "recovery_kind": eRecoveryKind, + "pitr": eEncryptionMode, + "cold": eEncryptionMode, + "snapshot": eEncryptionMode, +} + +func internalEnum(field, public string) (string, bool) { + for _, value := range declarativeEnums[field] { + if public == upperCamel(value) { + return value, true + } + } + return public, len(declarativeEnums[field]) == 0 +} + +func publicEnum(field, internal string) string { + for _, value := range declarativeEnums[field] { + if internal == value { + return upperCamel(value) + } + } + return internal +} + +func authoredFieldsOf(t reflect.Type) map[string]reflect.StructField { + out := map[string]reflect.StructField{} + for i := range t.NumField() { + f := t.Field(i) + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "" || name == "-" || (t == reflect.TypeOf(Spec{}) && (name == "api_version" || name == "app")) { + continue + } + out[lowerCamel(name)] = f + } + return out +} + +func authoredToInternal(t reflect.Type, value any, fieldName, path string) (any, error) { + t = deref(t) + switch t.Kind() { + case reflect.Struct: + body, ok := value.(map[string]any) + if !ok { + return value, nil + } + out := map[string]any{} + fields := authoredFieldsOf(t) + for key, child := range body { + field, exists := fields[key] + if !exists { + return nil, errf("unknown_field", join2(path, key), "", "%q is not a field of this contract", key) + } + legacy, _, _ := strings.Cut(field.Tag.Get("json"), ",") + var converted any + var err error + if t.Name() == "Overrides" && (legacy == "workloads" || legacy == "services") { + target := reflect.TypeOf(Workload{}) + if legacy == "services" { + target = reflect.TypeOf(Service{}) + } + converted, err = authoredPatchMap(target, child, join2(path, key)) + } else { + converted, err = authoredToInternal(field.Type, child, legacy, join2(path, key)) + } + if err != nil { + return nil, err + } + out[legacy] = converted + } + return out, nil + case reflect.Map: + body, ok := value.(map[string]any) + if !ok { + return value, nil + } + out := map[string]any{} + for key, child := range body { + mapKey := key + if fieldName == "hooks" { + for _, seam := range eHookSeam { + if key == upperCamel(seam) { + mapKey = seam + break + } + } + } + converted, err := authoredToInternal(t.Elem(), child, fieldName, join2(path, key)) + if err != nil { + return nil, err + } + out[mapKey] = converted + } + return out, nil + case reflect.Slice: + items, ok := value.([]any) + if !ok { + return value, nil + } + out := make([]any, len(items)) + for i, child := range items { + converted, err := authoredToInternal(t.Elem(), child, fieldName, fmt.Sprintf("%s[%d]", path, i)) + if err != nil { + return nil, err + } + out[i] = converted + } + return out, nil + case reflect.String: + text, ok := value.(string) + if !ok { + return value, nil + } + if fieldName == "provider" && !strings.Contains(path, "envFiles") && + !strings.Contains(path, "credentials.provider") && + !strings.Contains(path, "connection.source.provider") { + return text, nil + } + converted, valid := internalEnum(fieldName, text) + if !valid { + allowed := declarativeEnums[fieldName] + public := make([]string, len(allowed)) + for i, candidate := range allowed { + public[i] = upperCamel(candidate) + } + return nil, errf("project_invalid", path, "", "%q is not accepted; expected one of %s", text, strings.Join(public, ", ")) + } + return converted, nil + default: + return value, nil + } +} + +func authoredPatchMap(target reflect.Type, value any, path string) (any, error) { + body, ok := value.(map[string]any) + if !ok { + return value, nil + } + out := map[string]any{} + for name, patch := range body { + converted, err := authoredToInternal(target, patch, "", join2(path, name)) + if err != nil { + return nil, err + } + out[name] = converted + } + return out, nil +} + +func internalToAuthored(t reflect.Type, value any, fieldName string) any { + t = deref(t) + switch t.Kind() { + case reflect.Struct: + body, _ := value.(map[string]any) + out := map[string]any{} + for legacy, field := range fieldsOf(t) { + if t == reflect.TypeOf(Spec{}) && (legacy == "api_version" || legacy == "app") { + continue + } + if child, ok := body[legacy]; ok { + if t.Name() == "Overrides" && (legacy == "workloads" || legacy == "services") { + target := reflect.TypeOf(Workload{}) + if legacy == "services" { + target = reflect.TypeOf(Service{}) + } + patches := map[string]any{} + for name, patch := range child.(map[string]any) { + patches[name] = internalToAuthored(target, patch, "") + } + out[lowerCamel(legacy)] = patches + } else { + out[lowerCamel(legacy)] = internalToAuthored(field.Type, child, legacy) + } + } + } + return out + case reflect.Map: + body, _ := value.(map[string]any) + out := map[string]any{} + for key, child := range body { + if fieldName == "hooks" { + key = upperCamel(key) + } + out[key] = internalToAuthored(t.Elem(), child, fieldName) + } + return out + case reflect.Slice: + items, _ := value.([]any) + out := make([]any, len(items)) + for i, child := range items { + out[i] = internalToAuthored(t.Elem(), child, fieldName) + } + return out + case reflect.String: + if text, ok := value.(string); ok { + return publicEnum(fieldName, text) + } + } + return value +} + +func publicPath(path string) string { + if path == "" { + return path + } + parts := strings.Split(path, ".") + for i, part := range parts { + if bracket := strings.IndexByte(part, '['); bracket >= 0 { + parts[i] = lowerCamel(part[:bracket]) + part[bracket:] + } else { + parts[i] = lowerCamel(part) + } + } + return "spec." + strings.Join(parts, ".") +} + +func authoredOriginPath(path string) string { + switch path { + case "app": + return "metadata.name" + case "api_version": + return "apiVersion" + default: + return publicPath(path) + } +} diff --git a/internal/app/contract_shapes_test.go b/internal/app/contract_shapes_test.go index 5c5ae342..fa26c1f6 100644 --- a/internal/app/contract_shapes_test.go +++ b/internal/app/contract_shapes_test.go @@ -39,7 +39,16 @@ func canonicalOf(t *testing.T, body string) string { return string(out) } -const shapeHead = "api_version: onebox.run/v1\napp: shop\n" +const shapeHead = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: +` + +func underSpec(body string) string { + return " " + strings.ReplaceAll(strings.TrimSuffix(body, "\n"), "\n", "\n ") + "\n" +} // 3.4 — a scalar shorthand and its object form are the same project. // @@ -50,40 +59,40 @@ const shapeHead = "api_version: onebox.run/v1\napp: shop\n" func TestEveryShorthandEqualsItsObjectForm(t *testing.T) { for name, pair := range map[string][2]string{ "image": { - "environments: {production: {server: root@h}}\nimage: nginx\n", - "environments: {production: {server: root@h}}\nimage: {reference: nginx}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx}}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: {reference: nginx}}}\n", }, "health": { - "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", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx, port: 8080, health: /healthz}}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx, port: 8080, health: {http: /healthz}}}\n", }, "server": { - "environments: {production: {server: root@203.0.113.10}}\nimage: nginx\n", - "environments: {production: {server: {user: root, host: 203.0.113.10}}}\nimage: nginx\n", + "environments: {production: {server: root@203.0.113.10}}\nworkloads: {shop: {image: nginx}}\n", + "environments: {production: {server: {user: root, host: 203.0.113.10}}}\nworkloads: {shop: {image: nginx}}\n", }, // `needs` is not a top-level shorthand — the scalar form is the list // element, so this pair exercises it where it actually appears. "needs element": { - "environments: {production: {server: root@h}}\nworkloads: {web: {role: application, image: nginx, needs: [postgres]}}\nservices: {postgres: 16}\n", - "environments: {production: {server: root@h}}\nworkloads: {web: {role: application, image: nginx, needs: [{name: postgres}]}}\nservices: {postgres: 16}\n", + "environments: {production: {server: root@h}}\nworkloads: {web: {role: Application, image: nginx, needs: [postgres]}}\nservices: {postgres: 16}\n", + "environments: {production: {server: root@h}}\nworkloads: {web: {role: Application, image: nginx, needs: [{name: postgres}]}}\nservices: {postgres: 16}\n", }, "service version": { - "environments: {production: {server: root@h}}\nimage: nginx\nservices: {postgres: 16}\n", - "environments: {production: {server: root@h}}\nimage: nginx\nservices: {postgres: {version: 16}}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx}}\nservices: {postgres: 16}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx}}\nservices: {postgres: {version: 16}}\n", }, "hook": { - "environments: {production: {server: root@h}}\nimage: nginx\nhooks: {post_deploy: \"echo hi\"}\n", - "environments: {production: {server: root@h}}\nimage: nginx\nhooks: {post_deploy: {run: \"echo hi\"}}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx}}\nhooks: {PostDeploy: \"echo hi\"}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx}}\nhooks: {PostDeploy: {run: \"echo hi\"}}\n", }, // An env_files entry is a path or an object naming the same path. "env file entry": { - "environments: {production: {server: root@h}}\nimage: nginx\nruntime: {env_files: [.env]}\n", - "environments: {production: {server: root@h}}\nimage: nginx\nruntime: {env_files: [{file: .env}]}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx}}\nruntime: {envFiles: [.env]}\n", + "environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx}}\nruntime: {envFiles: [{file: .env}]}\n", }, } { t.Run(name, func(t *testing.T) { - scalar := canonicalOf(t, shapeHead+pair[0]) - object := canonicalOf(t, shapeHead+pair[1]) + scalar := canonicalOf(t, shapeHead+underSpec(pair[0])) + object := canonicalOf(t, shapeHead+underSpec(pair[1])) if scalar != object { t.Errorf("the two forms normalise differently:\n--- scalar\n%s\n--- object\n%s", scalar, object) } @@ -98,20 +107,20 @@ func TestEveryShorthandEqualsItsObjectForm(t *testing.T) { // thing that is supposed to show an operator what will be deployed is worse // than no canonical form at all. func TestCanonicalOutputIsStableAcrossRuns(t *testing.T) { - body := shapeHead + `environments: + body := shapeHead + underSpec(`environments: production: {server: root@h} staging: {server: root@h2} workloads: - zebra: {role: worker, image: nginx} - alpha: {role: application, image: nginx, health: /healthz, routes: [{hostname: a.example.com, port: 1}]} - middle: {role: worker, image: nginx} + zebra: {role: Worker, image: nginx} + alpha: {role: Application, image: nginx, health: /healthz, routes: [{hostname: a.example.com, port: 1}]} + middle: {role: Worker, image: nginx} services: redis: "7.4" postgres: 16 notifications: slack: {webhook: "https://hooks.example.com/x"} email: {webhook: "https://mail.example.com/y"} -` +`) first := canonicalOf(t, body) for i := range 30 { if again := canonicalOf(t, body); again != first { @@ -128,7 +137,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\nroutes: [{hostname: shop.example.com, port: 3000}]\n" + body := shapeHead + underSpec("environments: {production: {server: root@h}}\nworkloads: {shop: {image: nginx, routes: [{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.go b/internal/app/eject.go index f2219e79..957218dd 100644 --- a/internal/app/eject.go +++ b/internal/app/eject.go @@ -124,7 +124,6 @@ func stripOverlay(runtime []byte, generated map[string]bool, projected map[strin if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { root = root.Content[0] } - var names []string services := mapValue(root, "services") if services == nil { @@ -217,6 +216,11 @@ func repointProject(path, dest string, names []string) error { if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { root = root.Content[0] } + documentRoot := root + root = mapValue(root, "spec") + if root == nil { + return errf("eject_failed", path, "", "project declares no spec") + } // A Compose-referenced workload is shaped by the file, not the declaration. // Leaving these behind would be worse than removing them: someone editing a @@ -224,9 +228,9 @@ func repointProject(path, dest string, names []string) error { // error. What stays is what still has meaning — the role, the routing the // overlay derives, and the intent fields. inert := []string{ - "image", "build", "command", "env", "env_files", "volumes", "published_ports", + "image", "build", "command", "env", "envFiles", "volumes", "publishedPorts", "health", "drain", "resources", "entrypoint", "user", "hostname", - "working_dir", "init", "tty", "stdin_open", "extra_hosts", "labels", + "workingDir", "init", "tty", "stdinOpen", "extraHosts", "labels", "logging", "persistence", } @@ -249,21 +253,12 @@ func repointProject(path, dest string, names []string) error { setMapKey(svc, "compose", dest+"#"+name, firstNonEmpty(bh, ih), firstNonEmpty(bl, il)) } - } else { - // Top-level shorthand: replace the source in place. - bh, bl := dropMapKey(root, "build") - ih, il := dropMapKey(root, "image") - for _, k := range inert { - dropMapKey(root, k) - } - setMapKey(root, "compose", dest+"#"+names[0], - firstNonEmpty(bh, ih), firstNonEmpty(bl, il)) } var sb strings.Builder enc := yaml.NewEncoder(&sb) enc.SetIndent(2) - if err := enc.Encode(root); err != nil { + if err := enc.Encode(documentRoot); err != nil { return errf("eject_failed", path, "", "%v", err) } if err := enc.Close(); err != nil { diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go index b0b0edba..37186297 100644 --- a/internal/app/eject_test.go +++ b/internal/app/eject_test.go @@ -7,20 +7,21 @@ import ( "testing" ) -const ejectProject = `# Ledger's production contract. -api_version: onebox.run/v1 -app: ledger - -environments: - # The only host that matters. - production: {server: root@1.2.3.4} - -workloads: - web: - role: application - image: nginx:1.27 # pinned deliberately - routes: - - {hostname: ledger.example.com, port: 8080} +const ejectProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + # Ledger's production contract. + name: ledger +spec: + environments: + # The only host that matters. + production: {server: root@1.2.3.4} + workloads: + web: + role: Application + image: nginx:1.27 # pinned deliberately + routes: + - {hostname: ledger.example.com, port: 8080} ` func ejectInto(t *testing.T, body string) (dir string, res *EjectResult) { @@ -190,18 +191,21 @@ 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 -app: ledger -environments: {production: {server: root@1.2.3.4}} -workloads: - web: - role: application - image: nginx - routes: - - {hostname: ledger.example.com, port: 8080} - health: {http: /healthz, port: 8080} - volumes: [{name: uploads, path: /var/lib/ledger/uploads}] - env: {LOG_LEVEL: info} + dir, _ := ejectInto(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: {production: {server: root@1.2.3.4}} + workloads: + web: + role: Application + image: nginx + routes: + - {hostname: ledger.example.com, port: 8080} + health: {http: /healthz, port: 8080} + volumes: [{name: uploads, path: /var/lib/ledger/uploads}] + env: {LOG_LEVEL: info} `) body, _ := os.ReadFile(filepath.Join(dir, "ob.yml")) out := string(body) @@ -211,7 +215,7 @@ workloads: } } // What the overlay still derives must stay. - for _, kept := range []string{"role: application", "hostname:", "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,12 +228,15 @@ 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 -app: ledger -environments: {production: {server: root@1.2.3.4}} -workloads: - web: {role: application, image: nginx, routes: [{hostname: d.example.com, port: 80}]} - db: {role: daemon, compose: "compose.yaml#db"} + os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: {production: {server: root@1.2.3.4}} + workloads: + web: {role: Application, image: nginx, routes: [{hostname: d.example.com, port: 80}]} + db: {role: Daemon, compose: "compose.yaml#db"} `), 0o600) p, err := Load(filepath.Join(dir, "ob.yml")) @@ -260,12 +267,15 @@ workloads: func TestEjectAfterAnInterruptionCompletes(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := `api_version: onebox.run/v1 -app: shop -environments: - production: {server: root@203.0.113.10} -workloads: - web: {role: application, image: nginx, routes: [{hostname: shop.example.com, port: 3000}]} + body := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: root@203.0.113.10} + workloads: + 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 a55c1b70..26a202d5 100644 --- a/internal/app/ejection_contract_test.go +++ b/internal/app/ejection_contract_test.go @@ -7,29 +7,32 @@ import ( "testing" ) -const ejectContractProject = `api_version: onebox.run/v1 -app: shop -environments: - production: - server: root@203.0.113.10 -runtime: - env_files: [.env.production] -workloads: - web: - role: application - image: nginx:1.27 - health: /healthz - env: - API_TOKEN: super-secret-value - routes: - - {hostname: shop.example.com, path: /, port: 3000} - - {hostname: shop.example.com, path: /api, port: 3001} - worker: - role: worker - image: nginx:1.27 - needs: [postgres] -services: - postgres: 16 +const ejectContractProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: root@203.0.113.10 + runtime: + envFiles: [.env.production] + workloads: + web: + role: Application + image: nginx:1.27 + health: /healthz + env: + API_TOKEN: super-secret-value + routes: + - {hostname: shop.example.com, path: /, port: 3000} + - {hostname: shop.example.com, path: /api, port: 3001} + worker: + role: Worker + image: nginx:1.27 + needs: [postgres] + services: + postgres: 16 ` func ejectContractFixture(t *testing.T) (string, *Resolved) { diff --git a/internal/app/environment_model_test.go b/internal/app/environment_model_test.go index 3238ff09..9d24457c 100644 --- a/internal/app/environment_model_test.go +++ b/internal/app/environment_model_test.go @@ -55,29 +55,32 @@ func listFor(t *testing.T, r *Resolved, workload string) []string { return out } -const envModelBody = `api_version: onebox.run/v1 -app: shop -environments: - production: {server: root@203.0.113.10} - staging: - server: root@203.0.113.20 - env_files: [.env.staging] - overrides: - workloads: - db: {env_files: [.env.staging]} - mixed: {env_files: [.env.staging]} - silent: {env_files: []} - quiet: {replicas: 2} -runtime: - env_files: [.env] -workloads: - 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]} - mixed: {role: worker, image: nginx, env_files: [.env.own]} - silent: {role: worker, image: nginx, env_files: [.env.own]} - db: {role: daemon, image: postgres:16} +const envModelBody = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: root@203.0.113.10} + staging: + server: root@203.0.113.20 + envFiles: [.env.staging] + overrides: + workloads: + db: {envFiles: [.env.staging]} + mixed: {envFiles: [.env.staging]} + silent: {envFiles: []} + quiet: {replicas: 2} + runtime: + envFiles: [.env] + workloads: + web: {role: Application, image: nginx, routes: [{hostname: s.example.com, port: 3000}]} + cron: {role: Job, image: nginx, command: ["true"], dataEffect: None} + quiet: {role: Worker, image: nginx, envFiles: []} + own: {role: Worker, image: nginx, envFiles: [.env.own]} + mixed: {role: Worker, image: nginx, envFiles: [.env.own]} + silent: {role: Worker, image: nginx, envFiles: [.env.own]} + db: {role: Daemon, image: 'postgres:16'} ` var envModelFiles = map[string]string{".env": "A=1\n", ".env.staging": "B=2\n", ".env.own": "C=3\n"} @@ -136,46 +139,46 @@ 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 -app: shop -environments: {production: {server: root@h}} -image: nginx -secrets: {production: s.yaml} +// The withdrawn block has no alias in the reset contract. +func TestTheWithdrawnSecretsBlockIsUnknown(t *testing.T) { + _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + secrets: {production: s.yaml} + workloads: + shop: + image: nginx `, nil)) if err == nil { t.Fatal("the withdrawn block must be refused") } var e *Error - if !asError(err, &e) || e.Code != "secrets_withdrawn" { - t.Fatalf("want secrets_withdrawn, got %v", err) - } - // The replacement shape belongs in the message. Next is published as a safe - // command an agent may run, so a YAML fragment there would be executed. - if !strings.Contains(e.Message, "provider: sops") { - t.Errorf("the refusal must name the replacement form: %q", e.Message) - } - if !strings.HasPrefix(e.Next, "ob ") { - t.Errorf("Next must be a runnable command, got %q", e.Next) + if !asError(err, &e) || e.Code != "unknown_field" { + t.Fatalf("want unknown_field, got %v", err) } } // 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 -app: shop -environments: {production: {server: root@h}} -workloads: - web: - role: application - image: nginx - routes: - - {hostname: s.example.com, port: 3000} - needs: [postgres] - env: {POSTGRES_PASSWORD: mine} -services: - postgres: 17 + _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + workloads: + web: + role: Application + image: nginx + routes: + - {hostname: s.example.com, port: 3000} + needs: [postgres] + env: {POSTGRES_PASSWORD: mine} + services: + postgres: 17 `, nil)) if err == nil { t.Fatal("an inline env claiming a connection variable must be refused") @@ -189,20 +192,23 @@ 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 -app: shop -environments: {production: {server: root@203.0.113.10}} -runtime: - env_files: [.env] -workloads: - legacy: - role: application - compose: legacy.yml#legacy - routes: - - {hostname: s.example.com, port: 80} - web: - role: worker - image: nginx + path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + runtime: + envFiles: [.env] + workloads: + legacy: + role: Application + compose: legacy.yml#legacy + routes: + - {hostname: s.example.com, port: 80} + web: + role: Worker + image: nginx `, map[string]string{ ".env": "A=1\n", "legacy.yml": "services:\n legacy:\n image: nginx\n env_file: [own.env]\n", @@ -243,13 +249,18 @@ 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 -app: shop -environments: {production: {server: root@203.0.113.10}} -image: nginx -routes: - - {hostname: s.example.com, port: 3000} -health: /healthz + r := resolvedFor(t, envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + workloads: + shop: + image: nginx + health: /healthz + routes: + - {hostname: s.example.com, port: 3000} `, nil), "production") if got := r.Spec.Workloads["shop"].Health.Port; got != 3000 { t.Fatalf("probe port = %d, want the routed port 3000", got) @@ -270,14 +281,19 @@ 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 -app: shop -environments: {production: {server: root@203.0.113.10}} -runtime: - env_files: [.env] -image: nginx -routes: - - {hostname: s.example.com, port: 3000} + path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + runtime: + envFiles: [.env] + workloads: + shop: + image: nginx + routes: + - {hostname: s.example.com, port: 3000} `, map[string]string{".env": "API_TOKEN=super-secret-value\n"}) r := resolvedFor(t, path, "production") @@ -310,14 +326,17 @@ routes: // 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 -app: shop -environments: {production: {server: root@h}} -workloads: - worker: - role: worker - image: nginx - health: /healthz + _, err := Load(envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + workloads: + worker: + role: Worker + image: nginx + health: /healthz `, nil)) if err == nil { t.Fatal("a probe with no port to probe must be refused") @@ -349,17 +368,20 @@ 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 -app: shop -environments: {production: {server: root@203.0.113.10}} -runtime: - env_files: [.env.one, .env.two] -workloads: - legacy: - role: application - compose: legacy.yml#legacy - routes: - - {hostname: s.example.com, port: 80} + path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + runtime: + envFiles: [.env.one, .env.two] + workloads: + legacy: + role: Application + compose: legacy.yml#legacy + routes: + - {hostname: s.example.com, port: 80} `, map[string]string{ ".env.one": "A=1\n", ".env.two": "B=2\n", @@ -387,20 +409,23 @@ 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 -app: shop -environments: {production: {server: root@203.0.113.10}} -runtime: - env_files: [.env] -workloads: - web: - role: application - image: nginx - routes: - - {hostname: s.example.com, port: 3000} - needs: [postgres] -services: - postgres: 17 + path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + runtime: + envFiles: [.env] + workloads: + web: + role: Application + image: nginx + routes: + - {hostname: s.example.com, port: 3000} + needs: [postgres] + services: + postgres: 17 `, map[string]string{".env": "A=1\n"}) r := resolvedFor(t, path, "production") rendered, err := r.Render("production", "R1", nil) @@ -420,18 +445,21 @@ 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 -app: shop -environments: {production: {server: root@203.0.113.10}} -workloads: - legacy: - role: application - compose: legacy.yml#legacy - routes: - - {hostname: s.example.com, port: 80} - needs: [postgres] -services: - postgres: 17 + _, err := resolvedForErr(t, envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + workloads: + legacy: + role: Application + compose: legacy.yml#legacy + routes: + - {hostname: s.example.com, port: 80} + needs: [postgres] + services: + postgres: 17 `, map[string]string{ "legacy.yml": "services:\n legacy:\n image: nginx\n environment:\n POSTGRES_PASSWORD: mine\n", })) @@ -469,15 +497,20 @@ 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 -app: shop -environments: {production: {server: root@203.0.113.10}} -runtime: - env_files: [{file: s.enc, provider: sops}] -image: nginx -routes: - - {hostname: s.example.com, port: 3000} -health: {http: /healthz, port: 3000} + path := envModelProject(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + runtime: + envFiles: [{file: s.enc, provider: Sops}] + workloads: + shop: + image: nginx + health: {http: /healthz, port: 3000} + routes: + - {hostname: s.example.com, port: 3000} `, map[string]string{"s.enc": "A=1\n"}) // Through the function the callers use. `Load` does not reach it, so a test // that only loads would pass while the behaviour this names is broken. @@ -547,13 +580,16 @@ 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 -app: shop -environments: {production: {server: root@h}} -runtime: - env_files: [.env.absent] -workloads: - web: {image: nginx, routes: [{hostname: s.example.com, port: 3000}]} + body := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + runtime: + envFiles: [.env.absent] + workloads: + web: {image: nginx, routes: [{hostname: s.example.com, port: 3000}]} ` _, err := Load(envModelProject(t, body, nil)) if err == nil { diff --git a/internal/app/equivalence_test.go b/internal/app/equivalence_test.go index 79e66999..5cf77d4f 100644 --- a/internal/app/equivalence_test.go +++ b/internal/app/equivalence_test.go @@ -38,7 +38,7 @@ func recordVerdicts(t *testing.T) []verdict { for _, c := range conformanceCases() { v := verdict{Case: "conformance/" + c.name} - spec, err := LoadBytes([]byte(c.yaml), "ob.yml") + spec, err := loadFixtureBytes([]byte(c.yaml), "ob.yml") if err == nil { v.Loads = true v.Digest = renderDigest(t, spec) @@ -56,7 +56,7 @@ func recordVerdicts(t *testing.T) []verdict { if err != nil { t.Fatal(err) } - spec, err := LoadBytes([]byte(strings.ReplaceAll(string(body), "root@TARGET", "root@1.2.3.4")), path) + spec, err := loadFixtureBytes([]byte(strings.ReplaceAll(string(body), "root@TARGET", "root@1.2.3.4")), path) if err == nil { v.Loads = true v.Digest = renderDigest(t, spec) diff --git a/internal/app/errors.go b/internal/app/errors.go index e0e95cab..5e2d17c4 100644 --- a/internal/app/errors.go +++ b/internal/app/errors.go @@ -15,22 +15,22 @@ var errorCodes = map[string]string{ // The document could not be read at all. "project_unreadable": "the project file could not be read", "project_unparsable": "the project file is not valid YAML, or is not a mapping", - "schema_identity_missing": "the project declares no api_version", - "schema_identity_unsupported": "the project declares an api_version this binary does not speak", + "schema_identity_missing": "the Application declares no apiVersion", + "schema_identity_unsupported": "the Application declares an apiVersion this binary does not speak", + "schema_kind_unsupported": "the authored resource is not an Application", // The document is not what the contract defines. "unknown_field": "a field the contract does not define", "project_invalid": "a value that does not satisfy the contract", // Rules across more than one field. - "app_required": "the shorthand form needs an application name to attach the workload to", + "app_required": "metadata.name is required", "no_environment": "a project must declare at least one environment", "no_workload": "a project must declare at least one workload", "workload_malformed": "a workload is not a mapping", "workload_source": "a workload declares other than exactly one of build, image or compose", "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", "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", @@ -86,7 +86,6 @@ var errorCodes = map[string]string{ "env_file_missing": "an environment file the project declares is not on disk", "health_port_unknown": "an http health check has no port to probe and none can be derived", "connection_variable_claimed": "an authored value claims a name a managed-service connection supplies", - "secrets_withdrawn": "the withdrawn secrets block; environment files carry encrypted entries now", "image_unresolved": "a build-sourced workload has no resolved image for this release", "render_failed": "the runtime could not be rendered", "server_unreachable": "the server could not be reached", diff --git a/internal/app/errors_test.go b/internal/app/errors_test.go index 3b25c0e1..cbcdbd0f 100644 --- a/internal/app/errors_test.go +++ b/internal/app/errors_test.go @@ -73,11 +73,11 @@ func TestNoCorpusFailureEscapesTheEnumeration(t *testing.T) { } } for _, c := range conformanceCases() { - _, err := LoadBytes([]byte(c.yaml), "ob.yml") + _, err := loadFixtureBytes([]byte(c.yaml), "ob.yml") check("conformance/"+c.name, err) } for _, path := range corpusProjects(t) { - _, err := LoadBytes([]byte(readFixture(t, path)), path) + _, err := loadFixtureBytes([]byte(readFixture(t, path)), path) check("corpus/"+filepath.Base(path), err) } } diff --git a/internal/app/external_schema_test.go b/internal/app/external_schema_test.go index f5b51203..268b8e0d 100644 --- a/internal/app/external_schema_test.go +++ b/internal/app/external_schema_test.go @@ -5,24 +5,27 @@ import ( "testing" ) -const validExternalServiceProject = `api_version: onebox.run/v1 -app: shop -environments: {production: {server: deploy@app.example.net}} -workloads: - web: - image: nginx:1 - needs: - - name: database - condition: healthy - env: {DATABASE_URL: url} -external_services: - database: - driver: postgres - connection: - source: {file: secrets/database.env, provider: sops} - entries: {url: DATABASE_URL} - backup_owner: platform-team/rds - probe: {} +const validExternalServiceProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: deploy@app.example.net}} + workloads: + web: + image: nginx:1 + needs: + - name: database + condition: Healthy + env: {DATABASE_URL: url} + externalServices: + database: + driver: postgres + connection: + source: {file: secrets/database.env, provider: Sops} + entries: {url: DATABASE_URL} + backupOwner: platform-team/rds + probe: {} ` func TestExternalServiceFixtures(t *testing.T) { @@ -37,43 +40,47 @@ func TestExternalServiceFixtures(t *testing.T) { }, { name: "external_service_ambiguous_owner", - yaml: `api_version: onebox.run/v1 -app: shop -environments: {production: {server: deploy@app.example.net}} -workloads: {web: {image: nginx:1}} -services: {database: {driver: postgres, version: 17}} -external_services: - database: - driver: postgres - connection: - source: {file: secrets/database.env, provider: sops} - entries: {url: DATABASE_URL} - backup_owner: platform-team/rds -`, - code: "identifier_collision", + yaml: `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: deploy@app.example.net}} + workloads: {web: {image: 'nginx:1'}} + services: {database: {driver: postgres, version: 17}} + externalServices: + database: + driver: postgres + connection: + source: {file: secrets/database.env, provider: Sops} + entries: {url: DATABASE_URL} + backupOwner: platform-team/rds +`, code: "identifier_collision", }, { name: "external_service_lifecycle_field_refused", - yaml: `api_version: onebox.run/v1 -app: shop -environments: {production: {server: deploy@app.example.net}} -workloads: {web: {image: nginx:1}} -external_services: - database: - driver: postgres - version: 17 - connection: - source: {file: secrets/database.env, provider: sops} - entries: {url: DATABASE_URL} - backup_owner: platform-team/rds -`, - code: "unknown_field", + yaml: `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: deploy@app.example.net}} + workloads: {web: {image: 'nginx:1'}} + externalServices: + database: + driver: postgres + version: 17 + connection: + source: {file: secrets/database.env, provider: Sops} + entries: {url: DATABASE_URL} + backupOwner: platform-team/rds +`, code: "unknown_field", }, } for _, fixture := range fixtures { t.Run(fixture.name, func(t *testing.T) { - project, err := LoadBytes([]byte(fixture.yaml), fixture.name+".yml") + project, err := loadFixtureBytes([]byte(fixture.yaml), fixture.name+".yml") if fixture.code != "" { assertAppErrorCode(t, err, fixture.code) return @@ -97,6 +104,6 @@ external_services: func TestExternalNeedMustMapADeclaredTrustedEntry(t *testing.T) { project := strings.Replace(validExternalServiceProject, "env: {DATABASE_URL: url}", "env: {DATABASE_HOST: host}", 1) - _, err := LoadBytes([]byte(project), "ob.yml") + _, err := loadFixtureBytes([]byte(project), "ob.yml") assertAppErrorCode(t, err, "project_invalid") } diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index b5d0a726..0face0c1 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -11,46 +11,49 @@ 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 -app: ledger -environments: - production: {server: root@1.2.3.4} -workloads: - web: - role: application - image: ghcr.io/acme/ledger:1.4.0 - replicas: 2 - routes: - - {hostname: ledger.example.com, port: 8080} - health: {http: /healthz, port: 8080, interval: 10s, retries: 3} - drain: {grace: 30s} - needs: [db] - volumes: [{name: uploads, path: /var/lib/ledger/uploads}] - resources: {memory: 1GB} - worker: - role: worker - image: ghcr.io/acme/ledger:1.4.0 - command: [./ledger, worker] - needs: [db] - migrate: - role: job - image: ghcr.io/acme/ledger:1.4.0 - command: [./ledger, migrate] - deployment_phase: pre_release - data_effect: migration - needs: [{name: db, condition: healthy}] - db: - role: daemon - image: postgres:16-alpine - health: {exec: "pg_isready -U ledger", interval: 5s} - volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}] -runtime: - env_files: [.env.production] +const appFixture = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: + production: {server: root@1.2.3.4} + workloads: + web: + role: Application + image: ghcr.io/acme/ledger:1.4.0 + replicas: 2 + routes: + - {hostname: ledger.example.com, port: 8080} + health: {http: /healthz, port: 8080, interval: 10s, retries: 3} + drain: {grace: 30s} + needs: [db] + volumes: [{name: uploads, path: /var/lib/ledger/uploads}] + resources: {memory: 1GB} + worker: + role: Worker + image: ghcr.io/acme/ledger:1.4.0 + command: [./ledger, worker] + needs: [db] + migrate: + role: Job + image: ghcr.io/acme/ledger:1.4.0 + command: [./ledger, migrate] + deploymentPhase: PreRelease + dataEffect: Migration + needs: [{name: db, condition: Healthy}] + db: + role: Daemon + image: postgres:16-alpine + health: {exec: "pg_isready -U ledger", interval: 5s} + volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}] + runtime: + envFiles: [.env.production] ` func digestOf(t *testing.T, yaml string) string { t.Helper() - p, err := LoadBytes([]byte(yaml), "ob.yml") + p, err := loadFixtureBytes([]byte(yaml), "ob.yml") if err != nil { t.Fatal(err) } @@ -63,7 +66,7 @@ func digestOf(t *testing.T, yaml string) string { func render(t *testing.T, yaml string) []byte { t.Helper() - p, err := LoadBytes([]byte(yaml), "ob.yml") + p, err := loadFixtureBytes([]byte(yaml), "ob.yml") if err != nil { t.Fatal(err) } @@ -86,17 +89,20 @@ func TestRenderIsDeterministic(t *testing.T) { } func TestWorkloadRevisionIsReleaseIndependentAndRuntimeSensitive(t *testing.T) { - project := `api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: ghcr.io/example/api:v1, strategy: rolling, health: {http: /healthz, port: 8080}} - worker: {role: worker, image: ghcr.io/example/worker:v1, strategy: recreate, command: [run, worker]} -deployment: {order: [api, worker]} + project := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: 'ghcr.io/example/api:v1', strategy: Rolling, health: {http: /healthz, port: 8080}} + worker: {role: Worker, image: 'ghcr.io/example/worker:v1', strategy: Recreate, command: [run, worker]} + deployment: {order: [api, worker]} ` revisions := func(source, release string) map[string]string { t.Helper() - spec, err := LoadBytes([]byte(source), "ob.yml") + spec, err := loadFixtureBytes([]byte(source), "ob.yml") if err != nil { t.Fatal(err) } @@ -141,9 +147,9 @@ func TestDigestChangesWithRuntimeAffectingInput(t *testing.T) { func TestDigestIgnoresNonRuntimeInput(t *testing.T) { before := digestOf(t, appFixture) - after := digestOf(t, appFixture+"x-note: irrelevant\n") + after := digestOf(t, strings.Replace(appFixture, " name: ledger\n", " name: ledger\n annotations:\n note: irrelevant\n", 1)) if before != after { - t.Fatalf("an extension key must not change the runtime") + t.Fatalf("an annotation must not change the runtime") } } @@ -197,8 +203,8 @@ func TestRenderedRuntime(t *testing.T) { func TestBindMountLifetimesRenderWithoutChangingTheirScope(t *testing.T) { y := strings.Replace(appFixture, - " volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}]\n", - " volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}, {source: ./postgres.conf, path: /etc/postgres.conf, mode: ro}]\n", 1) + " volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}]\n", + " volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}, {source: ./postgres.conf, path: /etc/postgres.conf, mode: Ro}]\n", 1) out := string(render(t, y)) for _, want := range []string{ "/data/postgres:/var/lib/postgresql/data", @@ -213,7 +219,7 @@ func TestBindMountLifetimesRenderWithoutChangingTheirScope(t *testing.T) { // TestEnvFilesAreNotProjectedIntoDaemons is the rule seven real projects forced: // a database must not receive the application's secrets. func TestEnvFilesAreNotProjectedIntoDaemons(t *testing.T) { - p, err := LoadBytes([]byte(appFixture), "ob.yml") + p, err := loadFixtureBytes([]byte(appFixture), "ob.yml") if err != nil { t.Fatal(err) } @@ -232,8 +238,8 @@ func TestEnvFilesAreNotProjectedIntoDaemons(t *testing.T) { // only its own, so one service's secrets stay out of another's container. func TestWorkloadEnvFilesOverrideProjectList(t *testing.T) { y := strings.Replace(appFixture, - " role: worker\n", " role: worker\n env_files: [worker/.env]\n", 1) - p, err := LoadBytes([]byte(y), "ob.yml") + " role: Worker\n", " role: Worker\n envFiles: [worker/.env]\n", 1) + p, err := loadFixtureBytes([]byte(y), "ob.yml") if err != nil { t.Fatal(err) } @@ -246,9 +252,9 @@ func TestWorkloadEnvFilesOverrideProjectList(t *testing.T) { // TestBuildWithoutResolvedImageFailsClosed: the release pipeline resolves this // later; until then generation must refuse rather than emit a broken runtime. func TestBuildWithoutResolvedImageFailsClosed(t *testing.T) { - y := strings.Replace(appFixture, " image: ghcr.io/acme/ledger:1.4.0\n replicas: 2\n", - " build: .\n replicas: 2\n", 1) - p, err := LoadBytes([]byte(y), "ob.yml") + y := strings.Replace(appFixture, " image: ghcr.io/acme/ledger:1.4.0\n replicas: 2\n", + " build: .\n replicas: 2\n", 1) + p, err := loadFixtureBytes([]byte(y), "ob.yml") if err != nil { t.Fatal(err) } @@ -277,7 +283,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, " routes:\n - {hostname: ledger.example.com, 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") @@ -289,8 +295,8 @@ func TestNoProxyAddsNothing(t *testing.T) { // TestUDPPortRendered covers the protocol a real project needed. func TestUDPPortRendered(t *testing.T) { - y := strings.Replace(appFixture, " volumes: [{name: uploads, path: /var/lib/ledger/uploads}]\n", - " volumes: [{name: uploads, path: /var/lib/ledger/uploads}]\n strategy: recreate\n published_ports: [{host: 8555, container: 8555, protocol: udp}]\n", 1) + y := strings.Replace(appFixture, " volumes: [{name: uploads, path: /var/lib/ledger/uploads}]\n", + " volumes: [{name: uploads, path: /var/lib/ledger/uploads}]\n strategy: Recreate\n publishedPorts: [{host: 8555, container: 8555, protocol: udp}]\n", 1) out := string(render(t, y)) if !strings.Contains(out, "127.0.0.1:8555:8555/udp") { t.Errorf("expected a loopback-bound UDP publish\n%s", out) @@ -300,7 +306,7 @@ func TestUDPPortRendered(t *testing.T) { // TestJobsDoNotRestartOrAutoStart: a job runs to completion at a release phase. // Restarting it forever would be wrong, and `compose up` must not start it. func TestJobsDoNotRestartOrAutoStart(t *testing.T) { - p, err := LoadBytes([]byte(appFixture), "ob.yml") + p, err := loadFixtureBytes([]byte(appFixture), "ob.yml") if err != nil { t.Fatal(err) } @@ -342,8 +348,8 @@ func TestOperatorOwnedProxyDoesNotUseManagedResolver(t *testing.T) { } func TestCertificateResolverIsNotAProjectField(t *testing.T) { - _, err := LoadBytes([]byte(appFixture+"proxy: {cert_resolver: le}\n"), "ob.yml") - if err == nil || !strings.Contains(err.Error(), "cert_resolver") { + _, err := loadFixtureBytes([]byte(appFixture+"proxy: {certResolver: le}\n"), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "certResolver") { t.Fatalf("implementation-specific resolver name must be refused: %v", err) } } @@ -362,16 +368,19 @@ func TestHasTerminatingTLSDistinguishesPassthrough(t *testing.T) { } func TestWildcardRouteRendersSafeHostRegexpAndDNSResolver(t *testing.T) { - project := `api_version: onebox.run/v1 -app: preview -environments: {production: {server: root@example.com}} -workloads: - web: - image: nginx - routes: [{hostname: "*.preview.example.com", port: 8080}] -proxy: - config: traefik - dns_challenge: {provider: cloudflare} + project := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: preview +spec: + environments: {production: {server: root@example.com}} + workloads: + web: + image: nginx + routes: [{hostname: "*.preview.example.com", port: 8080}] + proxy: + config: traefik + dnsChallenge: {provider: cloudflare} ` out := string(render(t, project)) if !strings.Contains(out, `HostRegexp(`+"`"+`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.preview\.example\.com$$`+"`"+`)`) { @@ -402,7 +411,7 @@ func TestEveryDraftRenders(t *testing.T) { if err != nil { t.Fatal(err) } - p, err := LoadBytes(b, f) + p, err := loadFixtureBytes(b, f) if err != nil { t.Fatal(err) } @@ -440,23 +449,26 @@ 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 -app: ledger -environments: - production: {server: root@1.2.3.4} -workloads: - web: - role: application - image: nginx - entrypoint: [/bin/sh, -c, "exec app"] - user: "1000:1000" - hostname: web-1 - working_dir: /srv - init: true - tty: false - stdin_open: true - extra_hosts: ["db:10.0.0.5"] - labels: {com.example.team: platform, ofelia.enabled: "true"} + y := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: + production: {server: root@1.2.3.4} + workloads: + web: + role: Application + image: nginx + entrypoint: [/bin/sh, -c, "exec app"] + user: "1000:1000" + hostname: web-1 + workingDir: /srv + init: true + tty: false + stdinOpen: true + extraHosts: ["db:10.0.0.5"] + labels: {com.example.team: platform, ofelia.enabled: "true"} ` out := string(render(t, y)) for _, want := range []string{ @@ -482,12 +494,14 @@ 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 -app: ledger -environments: {production: {server: h}} -workloads: {web: {role: application, image: nginx, labels: {"` + bad + `": x}}} + y := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: ledger} +spec: + environments: {production: {server: h}} + workloads: {web: {role: Application, image: nginx, labels: {"` + bad + `": x}}} ` - if _, err := LoadBytes([]byte(y), "ob.yml"); err == nil { + if _, err := loadFixtureBytes([]byte(y), "ob.yml"); err == nil { t.Errorf("label %q should be refused", bad) } } @@ -519,14 +533,17 @@ 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 -app: shop -environments: {production: {server: root@h}} -workloads: - web: - role: application - image: scratch-built:1 - health: {exec: ["/app", "health"], interval: 2s} + out := render(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + workloads: + web: + role: Application + image: scratch-built:1 + health: {exec: ["/app", "health"], interval: 2s} `) body := string(out) if !strings.Contains(body, "- CMD\n") { @@ -540,11 +557,14 @@ 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 -app: shop -environments: {production: {server: root@h}} -workloads: - web: {role: application, image: x:1, health: {exec: "test -f /ready && echo ok"}} + out := render(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + workloads: + web: {role: Application, image: 'x:1', health: {exec: "test -f /ready && echo ok"}} `) if !strings.Contains(string(out), "CMD-SHELL") { t.Fatalf("a shell-form check must keep its shell:\n%s", out) @@ -561,11 +581,13 @@ func TestShellHealthChecksCarryTheDrainGuard(t *testing.T) { `health: {tcp: true, port: 5432}`, `health: {exec: "test -f /ready"}`, } { - out := string(render(t, `api_version: onebox.run/v1 -app: shop -environments: {production: {server: root@h}} -workloads: - web: {role: application, image: x:1, `+form+`} + out := string(render(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: shop} +spec: + environments: {production: {server: root@h}} + workloads: + web: {role: Application, image: x:1, `+form+`} `)) if !strings.Contains(out, DrainFile) { t.Errorf("%s is not drain-guarded:\n%s", form, out) @@ -580,11 +602,13 @@ 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 -app: shop -environments: {production: {server: root@h}} -workloads: - web: {role: application, image: x:1, health: {http: `+injected+`, port: 8080}} + out := string(render(t, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: shop} +spec: + environments: {production: {server: root@h}} + workloads: + web: {role: Application, image: x:1, health: {http: `+injected+`, port: 8080}} `)) // Twice, because the check tries curl and falls back to wget. Counting is // what catches a half-fix that quotes one arm and leaves the other open. diff --git a/internal/app/health_timing_test.go b/internal/app/health_timing_test.go index 6ce62239..9029f4e3 100644 --- a/internal/app/health_timing_test.go +++ b/internal/app/health_timing_test.go @@ -111,7 +111,13 @@ 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 := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + workloads: {} +`+ "environments: {production: {server: root@10.0.0.1}}\n"+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+ "health: {http: /healthz, retries: 100000000000}\n"), "ob.yml") @@ -134,7 +140,13 @@ 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 := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + workloads: {} +`+ "environments: {production: {server: root@10.0.0.1}}\n"+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+ "health: "+health+"\n"), "ob.yml") @@ -206,7 +218,13 @@ 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 := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + workloads: {} +`+ "environments: {production: {server: root@10.0.0.1}}\n"+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+ "health: {http: /healthz, interval: 1000000d}\n"), "ob.yml") @@ -224,7 +242,13 @@ 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 := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + workloads: {} +`+ "environments: {production: {server: root@10.0.0.1}}\n"+ "workloads: {web: {image: nginx, routes: [{hostname: d.example.com, port: 8080}], drain: "+drain+"}}\n"), "ob.yml") if err == nil { diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index 6158baf3..5da8c081 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -1,10 +1,13 @@ package app import ( + "bytes" "encoding/json" "reflect" "strconv" "strings" + + applicationv1alpha1 "github.com/labstack/onebox/api/application/v1alpha1" ) // The published schema describes the document an author writes, not the one the @@ -22,51 +25,144 @@ 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://onebox.run/schemas/application/v1alpha1/application.schema.json" // JSONSchema is the published contract, ready to write. func JSONSchema() ([]byte, error) { - defs := map[string]any{} - root := schemaFor(reflect.TypeOf(Spec{}), defs) + embedded := bytes.TrimSpace(applicationv1alpha1.Schema) + return append([]byte(nil), embedded...), nil +} - doc := map[string]any{} - for k, v := range root { - doc[k] = v - } - doc["$schema"] = "https://json-schema.org/draft/2020-12/schema" - doc["$id"] = SchemaID - doc["title"] = "Onebox project (onebox.run/v1)" - doc["description"] = "One application, its workloads, the services it needs, and how a release rolls out." +// GenerateJSONSchema derives the public schema from the loader's declarations. +// It is used only to regenerate and verify the embedded published artifact. +func GenerateJSONSchema() ([]byte, error) { + defs := map[string]any{} + spec := schemaFor(reflect.TypeOf(Spec{}), defs) // The constraints the loader enforces, so the schema refuses what the // loader refuses. Without these it would describe only the shape, and an // editor would stay silent on a value that fails at deploy time. for _, c := range schemaConstraints { - if at := indexPath(doc, c.path); at != nil { + if at := indexPath(spec, c.path); at != nil { mergeSchema(at, c.apply) } } - applyRoleRules(doc) + applyRoleRules(spec) // Every form the loader accepts, so an editor does not underline a correct // project. A schema that flags valid work teaches the author to ignore it. for _, form := range authoredForms { - replaceAt(doc, form.path, form.alternative, form.note) + replaceAt(spec, form.path, form.alternative, form.note) + } + if props, ok := spec["properties"].(map[string]any); ok { + delete(props, "api_version") + delete(props, "app") + } + spec["required"] = []any{"environments", "workloads"} + spec = authoredSchema(spec, "") + nameSchema := appNameConstraint() + mergeSchema(nameSchema, map[string]any{ + "description": "Stable application name used in generated runtime identities.", + "examples": []any{"shop"}, + }) + + doc := map[string]any{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": SchemaID, + "title": "Onebox Application (onebox.run/v1alpha1)", + "description": "One application, its workloads, the services it needs, and how a release rolls out.", + "type": "object", + "properties": map[string]any{ + "apiVersion": map[string]any{"type": "string", "const": APIVersion, "description": "Authored Application API identity."}, + "kind": map[string]any{"type": "string", "const": ApplicationKind, "description": "Authored resource kind."}, + "metadata": map[string]any{ + "type": "object", + "description": "Application identity and opaque user metadata.", + "properties": map[string]any{ + "name": nameSchema, + "annotations": map[string]any{"type": "object", "description": "Opaque user metadata that never affects plans or runtime behavior.", "additionalProperties": map[string]any{"type": "string"}}, + }, + "required": []any{"name"}, + "additionalProperties": false, + }, + "spec": mergeDescription(spec, "Desired Onebox application configuration."), + }, + "required": []any{"apiVersion", "kind", "metadata", "spec"}, + "additionalProperties": false, } + return json.MarshalIndent(doc, "", " ") +} + +func mergeDescription(schema map[string]any, description string) map[string]any { + schema["description"] = description + return schema +} - // A single-workload project may write the workload's own fields at the top - // level. They are optional there and refused alongside a workloads block, - // which the loader enforces and a schema cannot express. - if props, ok := doc["properties"].(map[string]any); ok { - if workloads, ok := indexPathOK(doc, []string{"workloads", "*"}); ok { - if wp, ok := workloads["properties"].(map[string]any); ok { - for k, v := range topLevelShorthand(wp) { - props[k] = v +func authoredSchema(node map[string]any, field string) map[string]any { + out := map[string]any{} + for key, value := range node { + switch key { + case "patternProperties": + continue + case "properties": + props, _ := value.(map[string]any) + converted := map[string]any{} + for name, child := range props { + if schema, ok := child.(map[string]any); ok { + converted[lowerCamel(name)] = authoredSchema(schema, name) + } else { + converted[lowerCamel(name)] = child } } + out[key] = converted + case "required": + items, _ := value.([]any) + converted := make([]any, len(items)) + for i, item := range items { + if name, ok := item.(string); ok { + converted[i] = lowerCamel(name) + } else { + converted[i] = item + } + } + out[key] = converted + case "enum": + items, _ := value.([]any) + converted := make([]any, len(items)) + for i, item := range items { + if text, ok := item.(string); ok { + converted[i] = publicEnum(field, text) + } else { + converted[i] = item + } + } + out[key] = converted + case "const", "default": + if text, ok := value.(string); ok { + out[key] = publicEnum(field, text) + } else { + out[key] = value + } + default: + switch child := value.(type) { + case map[string]any: + out[key] = authoredSchema(child, field) + case []any: + items := make([]any, len(child)) + for i, item := range child { + if schema, ok := item.(map[string]any); ok { + items[i] = authoredSchema(schema, field) + } else { + items[i] = item + } + } + out[key] = items + default: + out[key] = value + } } } - return json.MarshalIndent(doc, "", " ") + return out } // schemaFor renders one type, registering named object types in $defs so the @@ -102,11 +198,8 @@ func schemaFor(t reflect.Type, defs map[string]any) map[string]any { props[name] = property } out := map[string]any{ - "type": "object", - "properties": props, - // Extension keys are accepted wherever a mapping is; everything - // else is refused, which is the whole point of a closed contract. - "patternProperties": map[string]any{"^x-": map[string]any{}}, + "type": "object", + "properties": props, "additionalProperties": false, } return out @@ -229,23 +322,6 @@ func commandForms() map[string]any { }} } -// shorthandKeysAtTopLevel are the workload fields a single-workload project may -// write at the top level instead of a workloads block. -func topLevelShorthand(workloadProps map[string]any) map[string]any { - out := map[string]any{} - for _, k := range shorthandKeys { - if v, ok := workloadProps[k]; ok { - out[k] = v - } - } - return out -} - -func indexPathOK(doc map[string]any, path []string) (map[string]any, bool) { - v := indexPath(doc, path) - return v, v != nil -} - func indexPath(doc map[string]any, path []string) map[string]any { cur := doc for _, step := range path { @@ -553,7 +629,7 @@ var schemaConstraints = []struct { // declare at all. Both are within a JSON Schema's reach and are exactly the // mistakes an editor should catch while the file is still open. func applyRoleRules(doc map[string]any) { - doc["required"] = []any{"api_version", "environments"} + doc["required"] = []any{"environments", "workloads"} workload := indexPath(doc, []string{"workloads", "*"}) if workload == nil { @@ -563,25 +639,11 @@ func applyRoleRules(doc map[string]any) { if envs := indexPath(doc, []string{"environments"}); envs != nil { envs["minProperties"] = 1 } - - // Shorthand replaces the workloads block; it does not extend it. Writing - // both leaves it ambiguous which workload the top-level fields describe. - 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...), "port", "health", "routes"))}, - }} - - // A project must describe something to run: a non-empty workloads block, - // or the shorthand that becomes one. - doc["anyOf"] = []any{ - map[string]any{ - "required": []any{"workloads"}, - "properties": map[string]any{"workloads": map[string]any{"minProperties": 1}}, - }, - map[string]any{"anyOf": anyRequired(sources)}, + if workloads := indexPath(doc, []string{"workloads"}); workloads != nil { + workloads["minProperties"] = 1 } + sources := []any{"build", "image", "compose"} jobOnly := []any{"deployment_phase", "operator_run", "data_effect", "schedule", "inputs", "execution"} workload["allOf"] = []any{ map[string]any{ diff --git a/internal/app/jsonschema_test.go b/internal/app/jsonschema_test.go index 7d04b1ce..7a8d1318 100644 --- a/internal/app/jsonschema_test.go +++ b/internal/app/jsonschema_test.go @@ -24,7 +24,7 @@ import ( // decision rather than an accident. func compiledSchema(t *testing.T) *jsonschema.Schema { t.Helper() - body, err := JSONSchema() + body, err := GenerateJSONSchema() if err != nil { t.Fatal(err) } @@ -47,7 +47,7 @@ func compiledSchema(t *testing.T) *jsonschema.Schema { func asJSON(t *testing.T, in string) any { t.Helper() var v any - if err := yaml.Unmarshal([]byte(in), &v); err != nil { + if err := yaml.Unmarshal(normalizeApplicationFixture([]byte(in)), &v); err != nil { t.Fatalf("fixture is not YAML: %v", err) } b, err := json.Marshal(v) @@ -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 := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata: {name: a}\nspec:\n environments: {p: {server: root@h}}\n workloads:\n sync:\n role: Job\n image: busybox\n deploymentPhase: None\n dataEffect: None\n schedule: {cron: '0 * * * *'}\n execution:\n steps: [" + step + "]\n" err := schema.Validate(asJSON(t, y)) valid := strings.Contains(step, "id:") && strings.Contains(step, "command:") if (err == nil) != valid { @@ -133,15 +133,50 @@ func TestPublishedSchemaRequiresExecutionStepIDAndCommand(t *testing.T) { } } -func TestPublishedSchemaAcceptsAuthoredShorthand(t *testing.T) { +func TestPublishedSchemaAcceptsAuthoredScalarForms(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", - } { + `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: root@h}} + workloads: + a: + image: nginx +`, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: root@h}} + workloads: {w: {image: nginx}} + services: {postgres: 17} +`, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: root@h}} + workloads: {w: {image: nginx, volumes: [{name: data, path: /data}], needs: [db], command: run}} +`, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: root@h}} + workloads: {w: {image: nginx}} + hooks: {PostDeploy: "echo done"} +`, `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a + annotations: {note: anything} +spec: + environments: {p: {server: root@h}} + workloads: {w: {image: nginx}} +`} { if err := schema.Validate(asJSON(t, y)); err != nil { t.Errorf("authored shorthand rejected:\n%s\n%v", y, err) } @@ -152,7 +187,14 @@ 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 := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: root@h}} + workloads: {w: {image: nginx, replicaz: 3}} +` 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,17 +204,25 @@ 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 := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: root@h}} + workloads: {w: {image: nginx}} + proxy: + entrypoints: +` for _, tc := range []struct { name string entrypoint string valid bool }{ - {name: "valid", entrypoint: " otlp-grpc: {port: 4317}\n", valid: true}, - {name: "invalid name", entrypoint: " OTLP: {port: 4317}\n"}, - {name: "port below range", entrypoint: " otlp: {port: 0}\n"}, - {name: "port above range", entrypoint: " otlp: {port: 70000}\n"}, + {name: "valid", entrypoint: " otlp-grpc: {port: 4317}\n", valid: true}, + {name: "invalid name", entrypoint: " OTLP: {port: 4317}\n"}, + {name: "port below range", entrypoint: " otlp: {port: 0}\n"}, + {name: "port above range", entrypoint: " otlp: {port: 70000}\n"}, } { t.Run(tc.name, func(t *testing.T) { err := schema.Validate(asJSON(t, base+tc.entrypoint)) @@ -243,11 +293,11 @@ func TestPublishedSchemaDocumentsImportantDefaultsAndExamples(t *testing.T) { key string expected any }{ - {[]string{"base_path"}, "default", "/var/lib/ob"}, - {[]string{"deployment", "retain_releases"}, "default", float64(5)}, - {[]string{"environments", "*", "policy", "require_approval"}, "default", true}, - {[]string{"workloads", "*", "replicas"}, "default", float64(1)}, - {[]string{"app"}, "examples", []any{"shop"}}, + {[]string{"spec", "basePath"}, "default", "/var/lib/ob"}, + {[]string{"spec", "deployment", "retainReleases"}, "default", float64(5)}, + {[]string{"spec", "environments", "*", "policy", "requireApproval"}, "default", true}, + {[]string{"spec", "workloads", "*", "replicas"}, "default", float64(1)}, + {[]string{"metadata", "name"}, "examples", []any{"shop"}}, } for _, check := range checks { at := indexPath(doc, check.path) @@ -262,26 +312,26 @@ func TestPublishedSchemaDocumentsImportantDefaultsAndExamples(t *testing.T) { } func TestCheckedInSchemaMatchesGenerator(t *testing.T) { - body, err := JSONSchema() + body, err := GenerateJSONSchema() if err != nil { t.Fatal(err) } want := append(append([]byte(nil), body...), '\n') - path := filepath.Join("..", "..", "docs", "onebox.run-v1.schema.json") + path := filepath.Join("..", "..", "api", "application", "v1alpha1", "application.schema.json") got, err := os.ReadFile(path) if err != nil { t.Fatalf("read published schema: %v", err) } if !bytes.Equal(got, want) { - t.Fatalf("%s is stale; regenerate it with `go run ./cmd/ob schema --out docs/onebox.run-v1.schema.json`", path) + t.Fatalf("%s is stale; regenerate it with `go run ./cmd/ob schema --out api/application/v1alpha1/application.schema.json`", path) } } // The schema is only useful to an author if the guide tells them the URL to // point their editor at. A published identity nobody is told about helps no one. func TestPublishedSchemaURLIsUsedByTheHumanGuide(t *testing.T) { - if !strings.HasPrefix(SchemaID, "https://raw.githubusercontent.com/labstack/onebox/main/") { - t.Fatalf("schema identity must be a retrievable main-branch URL, got %q", SchemaID) + if SchemaID != "https://onebox.run/schemas/application/v1alpha1/application.schema.json" { + t.Fatalf("schema identity = %q", SchemaID) } guides := []string{ "site/src/content/docs/start/reading-it-back.mdx", diff --git a/internal/app/jump_config_test.go b/internal/app/jump_config_test.go index 4fe173a6..c62f43e2 100644 --- a/internal/app/jump_config_test.go +++ b/internal/app/jump_config_test.go @@ -6,13 +6,19 @@ import ( ) func projectWithJump(jump string) string { - return "api_version: onebox.run/v1\napp: ledger\n" + + return `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + workloads: {} +` + "environments: {production: {server: root@10.20.0.10, jump: " + jump + "}}\n" + "image: nginx\nroutes: [{hostname: ledger.example.com, port: 8080}]\n" } func TestScalarJumpExpandsToUserHostAndPort(t *testing.T) { - resolved, err := LoadBytes([]byte(projectWithJump("deploy@bastion.example.com:2222")), "ob.yml") + resolved, err := loadFixtureBytes([]byte(projectWithJump("deploy@bastion.example.com:2222")), "ob.yml") if err != nil { t.Fatal(err) } @@ -26,7 +32,7 @@ func TestScalarJumpExpandsToUserHostAndPort(t *testing.T) { } func TestScalarJumpWithoutUserOrPortKeepsThoseImplicit(t *testing.T) { - resolved, err := LoadBytes([]byte(projectWithJump("bastion.example.com")), "ob.yml") + resolved, err := loadFixtureBytes([]byte(projectWithJump("bastion.example.com")), "ob.yml") if err != nil { t.Fatal(err) } @@ -37,7 +43,7 @@ func TestScalarJumpWithoutUserOrPortKeepsThoseImplicit(t *testing.T) { } func TestObjectJumpDecodes(t *testing.T) { - resolved, err := LoadBytes([]byte(projectWithJump("{host: bastion.example.com, user: deploy, port: 2222}")), "ob.yml") + resolved, err := loadFixtureBytes([]byte(projectWithJump("{host: bastion.example.com, user: deploy, port: 2222}")), "ob.yml") if err != nil { t.Fatal(err) } @@ -48,7 +54,7 @@ func TestObjectJumpDecodes(t *testing.T) { } func TestAbsentJumpLeavesTheEnvironmentDirect(t *testing.T) { - resolved, err := LoadBytes([]byte(min), "ob.yml") + resolved, err := loadFixtureBytes([]byte(min), "ob.yml") if err != nil { t.Fatal(err) } @@ -76,7 +82,7 @@ func TestInvalidJumpIsRejectedAtLoad(t *testing.T) { } for name, jump := range invalid { t.Run(name, func(t *testing.T) { - _, err := LoadBytes([]byte(projectWithJump(jump)), "ob.yml") + _, err := loadFixtureBytes([]byte(projectWithJump(jump)), "ob.yml") if err == nil { t.Fatalf("jump %q was accepted", jump) } @@ -102,7 +108,7 @@ func TestIPv6JumpIsAcceptedInBothForms(t *testing.T) { } for name, form := range forms { t.Run(name, func(t *testing.T) { - resolved, err := LoadBytes([]byte(projectWithJump(form)), "ob.yml") + resolved, err := loadFixtureBytes([]byte(projectWithJump(form)), "ob.yml") if err != nil { t.Fatal(err) } @@ -116,7 +122,7 @@ func TestIPv6JumpIsAcceptedInBothForms(t *testing.T) { func TestIPv6JumpRoutesWithBracketsOnlyWhenAPortIsWritten(t *testing.T) { route := func(form string) string { t.Helper() - resolved, err := LoadBytes([]byte(projectWithJump(form)), "ob.yml") + resolved, err := loadFixtureBytes([]byte(projectWithJump(form)), "ob.yml") if err != nil { t.Fatal(err) } diff --git a/internal/app/load.go b/internal/app/load.go index cfa47951..bd295af7 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" "strconv" "strings" @@ -16,7 +17,7 @@ import ( ) // APIVersion is the only authoring contract this package accepts. -const APIVersion = "onebox.run/v1" +const APIVersion = "onebox.run/v1alpha1" // 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,11 +68,11 @@ 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) { - var raw map[string]any - if err := yaml.Unmarshal(b, &raw); err != nil { + var authored map[string]any + if err := yaml.Unmarshal(b, &authored); err != nil { return nil, errf("project_unparsable", filename, "", "invalid YAML: %v", firstLine(err.Error())) } - if raw == nil { + if authored == nil { return nil, errf("project_unparsable", filename, "", "project is not a mapping") } @@ -83,37 +84,56 @@ func LoadBytes(b []byte, filename string) (*Spec, error) { lines = lineIndex(&doc) } - if err := checkAPIVersion(raw); err != nil { + if err := checkAPIVersion(authored); err != nil { return nil, err } - app, _ := raw["app"].(string) - derived, err := expand(raw, app) - if err != nil { + if err := checkAuthoredShape(authored, lines); err != nil { return nil, err } - // Before checkShape, deliberately: the contract requires this to fail with - // direction rather than as an unknown field, and closedness would otherwise - // answer first with a generic refusal. - // - // The block is withdrawn rather than repurposed. Its keys are arbitrary - // names today, so reading them as environment names would silently change - // what an existing project means — the failure this contract exists to - // remove — and leaving it accepted would keep two mechanisms for one idea. - if _, ok := raw["secrets"]; ok { - return nil, errf("secrets_withdrawn", "secrets", - "ob validate", - "the `secrets` block is withdrawn: declare the file as an env_files "+ - "entry carrying a provider — runtime.env_files: [{file: , "+ - "provider: sops}] — at the project, environment or workload scope "+ - "that should receive it") - } - if err := checkShape(raw, lines); err != nil { + if kind, _ := authored["kind"].(string); kind != ApplicationKind { + return nil, errf("schema_kind_unsupported", "kind", "", "kind must be %q", ApplicationKind) + } + metadata, ok := authored["metadata"].(map[string]any) + if !ok { + return nil, errf("project_invalid", "metadata", "", "metadata must be a mapping") + } + name, ok := metadata["name"].(string) + if !ok || name == "" { + return nil, errf("app_required", "metadata.name", "", "metadata.name is required") + } + specBody, ok := authored["spec"].(map[string]any) + if !ok { + return nil, errf("project_invalid", "spec", "", "spec must be a mapping") + } + for _, required := range []string{"environments", "workloads"} { + if _, ok := specBody[required]; !ok { + return nil, errf("project_invalid", "spec."+required, "", "%s is required", required) + } + } + converted, err := authoredToInternal(reflect.TypeOf(Spec{}), specBody, "spec", "spec") + if err != nil { return nil, err } - + raw := converted.(map[string]any) + raw["api_version"] = APIVersion + raw["app"] = name + derived, err := expand(raw) + if err != nil { + return nil, authoredError(err) + } p, err := decodeSpec(raw) if err != nil { - return nil, err + return nil, authoredError(err) + } + if annotations, ok := metadata["annotations"].(map[string]any); ok { + p.Annotations = map[string]string{} + for key, value := range annotations { + text, ok := value.(string) + if !ok { + return nil, errf("project_invalid", "metadata.annotations."+key, "", "annotation values must be strings") + } + p.Annotations[key] = text + } } applyDefaults(p, raw, derived) // Before validation, not after: validation stats the files a project @@ -123,66 +143,45 @@ func LoadBytes(b []byte, filename string) (*Spec, error) { p.Dir = filepath.Dir(filename) p.file = filename if err := validateSpec(p); err != nil { - return nil, err + return nil, authoredError(err) } defaultProxyManagement(p, raw, derived) p.captureRaw(raw, derived) if err := crossFieldRules(p); err != nil { - return nil, err + return nil, authoredError(err) } return p, nil } func checkAPIVersion(raw map[string]any) error { - got, ok := raw["api_version"].(string) + got, ok := raw["apiVersion"].(string) switch { case !ok || got == "": - return errf("schema_identity_missing", "api_version", "ob init", - "api_version is required; this binary accepts %q", APIVersion) + return errf("schema_identity_missing", "apiVersion", "ob init", + "apiVersion is required; this binary accepts %q", APIVersion) case got != APIVersion: - return errf("schema_identity_unsupported", "api_version", "", - "unsupported api_version %q; this binary accepts %q", got, APIVersion) + return errf("schema_identity_unsupported", "apiVersion", "", + "unsupported apiVersion %q; this binary accepts %q; see %s", got, APIVersion, SchemaID) } return nil } -// shorthandKeys are the top-level fields that describe a single workload. -var shorthandKeys = []string{"build", "image", "compose", "port", "health", "routes"} - -// expand rewrites shorthand into the normalised form the schema validates. It -// runs before validation because the schema requires discriminators — a role -// left absent would keep every branch of the workload disjunction alive. -func expand(raw map[string]any, app string) (map[string]Origin, error) { - var present []string - for _, k := range shorthandKeys { - if _, ok := raw[k]; ok { - present = append(present, k) - } +func authoredError(err error) error { + var projectErr *Error + if !errors.As(err, &projectErr) || projectErr.Path == "" { + return err } - wl, hasBlock := raw["workloads"] + copy := *projectErr + copy.Path = publicPath(projectErr.Path) + return © +} +// expand normalizes supported scalar forms inside the single authored +// workloads block and injects discriminators required by validation. +func expand(raw map[string]any) (map[string]Origin, error) { // Paths whose value the author did not write where it now appears. derived := map[string]Origin{} - - if len(present) > 0 && hasBlock { - return nil, errf("shorthand_and_workloads", "workloads", "", - "top-level %s cannot be combined with a workloads block; move them into it", - strings.Join(present, ", ")) - } - if len(present) > 0 { - if app == "" { - return nil, errf("app_required", "app", "", "app is required") - } - single := map[string]any{} - for _, k := range present { - single[k] = raw[k] - delete(raw, k) - derived["workloads."+app+"."+k] = OriginShorthand - } - raw["workloads"] = map[string]any{app: single} - wl = raw["workloads"] - } - + wl := raw["workloads"] workloads, _ := wl.(map[string]any) for name, w := range workloads { m, ok := w.(map[string]any) @@ -388,7 +387,7 @@ func crossFieldRules(p *Spec) error { } if len(p.Workloads) == 0 { return errf("no_workload", "workloads", "", - "at least one workload is required; declare one or use the top-level shorthand") + "at least one workload is required under spec.workloads") } for _, name := range sortedKeys(p.Workloads) { diff --git a/internal/app/load_test.go b/internal/app/load_test.go index 0b549c6d..3c18c02f 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -8,13 +8,20 @@ import ( "testing" ) -const base = "api_version: onebox.run/v1\napp: ledger\nenvironments: {production: {server: root@1.2.3.4}}\n" +const base = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ledger +spec: + environments: {production: {server: root@1.2.3.4}} + workloads: {} +` const min = base + "build: .\nroutes: [{hostname: ledger.example.com, port: 8080}]\n" func wl(body string) string { return base + "workloads: {" + body + "}\n" } func TestAPIVersionV1IsRequired(t *testing.T) { - _, err := LoadBytes([]byte(strings.Replace(min, APIVersion, "onebox.run/v2", 1)), "ob.yml") + _, err := loadFixtureBytes([]byte(strings.Replace(min, APIVersion, "onebox.run/v2", 1)), "ob.yml") if err == nil || !strings.Contains(err.Error(), "schema_identity_unsupported") { t.Fatalf("v2 project must be rejected with a version error: %v", err) } @@ -23,7 +30,7 @@ func TestAPIVersionV1IsRequired(t *testing.T) { func TestRoutedProjectRefusesDefaultAsProxyNetwork(t *testing.T) { for _, network := range []string{"default", "ledger_default", "ob_ledger"} { t.Run(network, func(t *testing.T) { - _, err := LoadBytes([]byte(min+"proxy: {network: "+network+"}\n"), "ob.yml") + _, err := loadFixtureBytes([]byte(min+"proxy: {network: "+network+"}\n"), "ob.yml") if err == nil || !strings.Contains(err.Error(), "proxy.network") || !strings.Contains(err.Error(), "reserved") { t.Fatalf("reserved proxy network error = %v", err) } @@ -45,7 +52,7 @@ func TestProxyEntrypointsValidateNamesAndPorts(t *testing.T) { {"invalid port", "proxy: {entrypoints: {otlp: {port: 70000}}}\n", "proxy.entrypoints.otlp.port"}, } { t.Run(tc.name, func(t *testing.T) { - _, err := LoadBytes([]byte(min+tc.yaml), "ob.yml") + _, err := loadFixtureBytes([]byte(min+tc.yaml), "ob.yml") if tc.want == "" && err != nil { t.Fatalf("valid proxy entrypoints: %v", err) } @@ -62,10 +69,10 @@ func TestManagedGeneratedProxyRequiresDeclaredRouteEntrypoint(t *testing.T) { image: app:1 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") { + if _, err := loadFixtureBytes([]byte(project), "ob.yml"); err == nil || !strings.Contains(err.Error(), "proxy.entrypoints") { t.Fatalf("an unknown generated entrypoint must be refused: %v", err) } - if _, err := LoadBytes([]byte(project+"proxy: {config: traefik}\n"), "ob.yml"); err != nil { + if _, err := loadFixtureBytes([]byte(project+"proxy: {config: traefik}\n"), "ob.yml"); err != nil { t.Fatalf("custom static proxy config owns its entrypoints: %v", err) } } @@ -79,7 +86,7 @@ proxy: config: traefik dns_challenge: {provider: cloudflare, resolvers: ["1.1.1.1:53"]} ` - if _, err := LoadBytes([]byte(valid), "ob.yml"); err != nil { + if _, err := loadFixtureBytes([]byte(valid), "ob.yml"); err != nil { t.Fatalf("valid wildcard route: %v", err) } @@ -103,7 +110,7 @@ proxy: {"unmanaged dns challenge", min + "proxy: {managed: false, config: traefik, dns_challenge: {provider: cloudflare}}\n", "managed proxy"}, } { t.Run(tc.name, func(t *testing.T) { - _, err := LoadBytes([]byte(tc.body), "ob.yml") + _, err := loadFixtureBytes([]byte(tc.body), "ob.yml") if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("error = %v, want text %q", err, tc.want) } @@ -111,14 +118,14 @@ proxy: } 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 { + if _, err := loadFixtureBytes([]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: [{hostname: '*', protocol: tcp, tls: none, port: 9000}] }")), "ob.yml"); err != nil { + if _, err := loadFixtureBytes([]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: [{hostname: '*', protocol: tcp, tls: passthrough, port: 9000}] }")), "ob.yml"); err != nil { + if _, err := loadFixtureBytes([]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) } } @@ -140,7 +147,7 @@ func TestWildcardRouteOverlap(t *testing.T) { {"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") + _, err := loadFixtureBytes([]byte(project(tc.left, tc.right)), "ob.yml") if tc.collides && (err == nil || !strings.Contains(err.Error(), "route_collision")) { t.Fatalf("expected collision, got %v", err) } @@ -174,12 +181,48 @@ 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", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: h}} + workloads: + a: + image: nginx +`, true}, + {"app starting ob-", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: ob-app +spec: + environments: {p: {server: h}} + workloads: + ob-app: + image: nginx +`, false}, + {"host proxy name", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: onebox-proxy +spec: + environments: {p: {server: h}} + workloads: + onebox-proxy: + image: nginx +`, false}, + {"underscore identifier", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: my_app +spec: + environments: {p: {server: h}} + workloads: + myApp: + image: nginx +`, false}, {"unknown top-level field", min + "bogus: 1\n", false}, - {"x- extension accepted", min + "x-note: anything\n", true}, + {"x- extension rejected", min + "x-note: anything\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}, @@ -205,15 +248,52 @@ func conformanceCases() []conformanceCase { {"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", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: h, policy: {migrations: {backupMaxAge: 14d}}}} + workloads: + a: + image: nginx +`, true}, + {"non-calver minimum version", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: h, policy: {minOneboxVersion: 0.0.1-m0}}} + workloads: + a: + image: nginx +`, false}, + {"incomplete plan schema", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: h, policy: {minPlanSchema: "onebox.run/executable-deploy-plan/v1alpha"}}} + workloads: + a: + image: nginx +`, 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", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: h}} + hooks: {migrate: {run: ./bin/migrate}} + workloads: + w: {role: Application, image: nginx} + migrate: {role: Job, image: nginx, dataEffect: Migration} +`, 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 @@ -257,7 +337,16 @@ 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", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: a +spec: + environments: {p: {server: h, envFiles: [.env.p]}} + workloads: + a: + image: nginx +`, 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: \" "$ONEBOX_RESULT_FILE" - data_effect: migration - deployment_phase: pre_release - env: - BASE_URL: "http://stats.example.com" - SECRET_KEY_BASE: "0123456789012345678901234567890123456789012345678901234567890123" - TOTP_VAULT_KEY: "CHANGE-ME-openssl-rand-base64-32" - needs: - - name: postgres - env: {DATABASE_URL: url} - - name: events - env: {CLICKHOUSE_DATABASE_URL: url} -deployment: {migration_policy: expand-only} -services: - postgres: 16 - events: {driver: clickhouse, version: "24.12"} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: plausible +spec: + environments: + production: {server: root@example.com} + workloads: + plausible: + role: Application + image: ghcr.io/plausible/community-edition:v3.0.1 + 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, startPeriod: 40s, within: 300s} + volumes: [{name: data, path: /var/lib/plausible}] + env: + BASE_URL: "http://stats.example.com" + SECRET_KEY_BASE: "0123456789012345678901234567890123456789012345678901234567890123" + TOTP_VAULT_KEY: "CHANGE-ME-openssl-rand-base64-32" + needs: + - name: postgres + env: {DATABASE_URL: url} + - name: events + env: {CLICKHOUSE_DATABASE_URL: url} + migrate: + role: Job + image: ghcr.io/plausible/community-edition:v3.0.1 + command: + - /bin/sh + - -c + - | + set -e + /entrypoint.sh db createdb + /entrypoint.sh db migrate + printf '{"schema_version":"onebox.run/job-result/v1alpha1","changed":true}' > "$ONEBOX_RESULT_FILE" + dataEffect: Migration + deploymentPhase: PreRelease + env: + BASE_URL: "http://stats.example.com" + SECRET_KEY_BASE: "0123456789012345678901234567890123456789012345678901234567890123" + TOTP_VAULT_KEY: "CHANGE-ME-openssl-rand-base64-32" + needs: + - name: postgres + env: {DATABASE_URL: url} + - name: events + env: {CLICKHOUSE_DATABASE_URL: url} + deployment: {migrationPolicy: ExpandOnly} + services: + postgres: 16 + events: {driver: clickhouse, version: "24.12"} diff --git a/internal/app/testdata/corpus/ext-umami.yml b/internal/app/testdata/corpus/ext-umami.yml index ab437268..c149b61e 100644 --- a/internal/app/testdata/corpus/ext-umami.yml +++ b/internal/app/testdata/corpus/ext-umami.yml @@ -1,18 +1,20 @@ -# Deployed and verified against managed services on a throwaway host. -api_version: onebox.run/v1 -app: umami -environments: - production: {server: root@example.com} -workloads: - umami: - role: application - image: ghcr.io/umami-software/umami:postgresql-v2.19.0 - 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: - - name: postgres - env: {DATABASE_URL: url} -services: - postgres: 15 +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: umami +spec: + environments: + production: {server: root@example.com} + workloads: + umami: + role: Application + image: ghcr.io/umami-software/umami:postgresql-v2.19.0 + 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, startPeriod: 20s, within: 240s} + env: {APP_SECRET: throwaway-secret-for-this-test} + needs: + - name: postgres + env: {DATABASE_URL: url} + services: + postgres: 15 diff --git a/internal/app/testdata/corpus/goal.yml b/internal/app/testdata/corpus/goal.yml index f36bf45b..877323d4 100644 --- a/internal/app/testdata/corpus/goal.yml +++ b/internal/app/testdata/corpus/goal.yml @@ -1,40 +1,43 @@ -api_version: onebox.run/v1 -app: goal -environments: - production: - server: root@goal.example.com - policy: {require_approval: true, allow_agent_proposals: true} -workloads: - server: - role: application - image: ghcr.io/labstack/goal-server:2026.8.1 - replicas: 3 - strategy: rolling - 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} - database: - role: daemon - compose: docker-compose.yaml#postgres - persistence: {mode: durable} - cache: - role: daemon - compose: docker-compose.yaml#redis - persistence: {mode: durable} -deployment: {order: [server], retain_releases: 5} -runtime: - env_files: [server/.env.production, server/.env.production.secrets] - env_checks: - - file: server/.env.production.secrets - require: [JWT_SECRET, POSTGRES_PASSWORD] - present: [RESEND_API_KEY, TWILIO_ACCOUNT_SID, S3_BUCKET, AWS_ACCESS_KEY_ID] -proxy: {managed: true, config: traefik} -hooks: - bootstrap: {run: 'scripts/bootstrap.sh "$ONEBOX_SERVER"', local: true} -checks: - http: - - {workload: server, path: /readyz, port: 7510} - url: - - {url: "https://goal.fit/healthz"} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: goal +spec: + environments: + production: + server: root@goal.example.com + policy: {requireApproval: true, allowAgentProposals: true} + workloads: + server: + role: Application + image: ghcr.io/labstack/goal-server:2026.8.1 + replicas: 3 + strategy: Rolling + routes: + - {hostname: goal.fit, port: 7510} + health: {http: /healthz, port: 7510, interval: 5s, startPeriod: 15s, within: 2m, retries: 5} + drain: {signal: TERM, grace: 30s} + env: {ENVIRONMENT: production, OTEL_SERVICE_NAME: goal-server} + database: + role: Daemon + compose: docker-compose.yaml#postgres + persistence: {mode: Durable} + cache: + role: Daemon + compose: docker-compose.yaml#redis + persistence: {mode: Durable} + deployment: {order: [server], retainReleases: 5} + runtime: + envFiles: [server/.env.production, server/.env.production.secrets] + envChecks: + - file: server/.env.production.secrets + require: [JWT_SECRET, POSTGRES_PASSWORD] + present: [RESEND_API_KEY, TWILIO_ACCOUNT_SID, S3_BUCKET, AWS_ACCESS_KEY_ID] + proxy: {managed: true, config: traefik} + hooks: + Bootstrap: {run: 'scripts/bootstrap.sh "$ONEBOX_SERVER"', local: true} + checks: + http: + - {workload: server, path: /readyz, port: 7510} + url: + - {url: "https://goal.fit/healthz"} diff --git a/internal/app/testdata/corpus/monk.yml b/internal/app/testdata/corpus/monk.yml index aa755a6e..cadbf3ac 100644 --- a/internal/app/testdata/corpus/monk.yml +++ b/internal/app/testdata/corpus/monk.yml @@ -1,58 +1,61 @@ -api_version: onebox.run/v1 -app: monk -environments: - production: - server: root@monk.example.com - policy: {require_approval: true, allow_agent_proposals: true} -workloads: - server: - role: application - image: ghcr.io/labstack/monk-server:2026.8.1 - replicas: 3 - strategy: rolling - routes: - - {hostname: monk.trade, port: 7500} - health: - exec: "curl --fail --silent --show-error http://127.0.0.1:7500/healthz" - interval: 10s - start_period: 25s - within: 3m - retries: 5 - drain: {signal: TERM, grace: 30s} - feed: - role: worker - image: ghcr.io/labstack/monk-feed:2026.8.1 - strategy: recreate - drain: {signal: TERM, wait: 30s, grace: 30s} - database: - role: daemon - compose: docker-compose.yaml#postgres - persistence: {mode: durable} - cache: - role: daemon - compose: docker-compose.yaml#redis - persistence: {mode: durable} - maintenance: - role: daemon - compose: docker-compose.yaml#ofelia -deployment: {order: [feed, server], retain_releases: 5} -runtime: - env_files: [server/.env, server/.env.production] - env_checks: - - {file: traefik/.env, require: [CF_DNS_API_TOKEN]} - - file: web/.env.production - require: [VITE_STRIPE_PUBLISHABLE_KEY, VITE_GA_MEASUREMENT_ID] - present: [VITE_VAPID_PUBLIC_KEY] -proxy: {managed: true, config: traefik} -notifications: - ntfy: {webhook: "https://ntfy.sh/monk-deploy-f413efb2e8f7", on: [failure], format: text} -hooks: - bootstrap: {run: 'scripts/bootstrap.sh "$ONEBOX_SERVER"', local: true} - pre_release: {run: "cd web && bun install --frozen-lockfile && bun run build && rsync -az dist/ $ONEBOX_SERVER:/data/monk/web/", local: true} - post_deploy: {run: "rsync -az web/dist/index.html $ONEBOX_SERVER:/data/monk/web/index.html", local: true} -checks: - http: - - {workload: server, path: /healthz, port: 7500} - url: - - {url: "https://monk.trade/healthz", advisory: true} - - {url: "https://monk.trade/", contains: 'id="root"', advisory: true} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: monk +spec: + environments: + production: + server: root@monk.example.com + policy: {requireApproval: true, allowAgentProposals: true} + workloads: + server: + role: Application + image: ghcr.io/labstack/monk-server:2026.8.1 + replicas: 3 + strategy: Rolling + routes: + - {hostname: monk.trade, port: 7500} + health: + exec: "curl --fail --silent --show-error http://127.0.0.1:7500/healthz" + interval: 10s + startPeriod: 25s + within: 3m + retries: 5 + drain: {signal: TERM, grace: 30s} + feed: + role: Worker + image: ghcr.io/labstack/monk-feed:2026.8.1 + strategy: Recreate + drain: {signal: TERM, wait: 30s, grace: 30s} + database: + role: Daemon + compose: docker-compose.yaml#postgres + persistence: {mode: Durable} + cache: + role: Daemon + compose: docker-compose.yaml#redis + persistence: {mode: Durable} + maintenance: + role: Daemon + compose: docker-compose.yaml#ofelia + deployment: {order: [feed, server], retainReleases: 5} + runtime: + envFiles: [server/.env, server/.env.production] + envChecks: + - {file: traefik/.env, require: [CF_DNS_API_TOKEN]} + - file: web/.env.production + require: [VITE_STRIPE_PUBLISHABLE_KEY, VITE_GA_MEASUREMENT_ID] + present: [VITE_VAPID_PUBLIC_KEY] + proxy: {managed: true, config: traefik} + notifications: + ntfy: {webhook: "https://ntfy.sh/monk-deploy-f413efb2e8f7", on: [Failure], format: Text} + hooks: + Bootstrap: {run: 'scripts/bootstrap.sh "$ONEBOX_SERVER"', local: true} + PreRelease: {run: "cd web && bun install --frozen-lockfile && bun run build && rsync -az dist/ $ONEBOX_SERVER:/data/monk/web/", local: true} + PostDeploy: {run: "rsync -az web/dist/index.html $ONEBOX_SERVER:/data/monk/web/index.html", local: true} + checks: + http: + - {workload: server, path: /healthz, port: 7500} + url: + - {url: "https://monk.trade/healthz", advisory: true} + - {url: "https://monk.trade/", contains: 'id="root"', advisory: true} diff --git a/internal/app/testdata/corpus/pursue.yml b/internal/app/testdata/corpus/pursue.yml index eb394c36..3057628d 100644 --- a/internal/app/testdata/corpus/pursue.yml +++ b/internal/app/testdata/corpus/pursue.yml @@ -1,44 +1,47 @@ -api_version: onebox.run/v1 -app: pursue -environments: - production: - server: root@203.0.113.10 - policy: {require_approval: true, allow_agent_proposals: true} -workloads: - server: - role: application - image: ghcr.io/labstack/pursue-server:2026.8.1 - replicas: 1 - strategy: rolling - 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: - role: job - compose: compose.yaml#migrate - deployment_phase: pre_release - data_effect: migration - postgres: - role: daemon - compose: compose.yaml#postgres - persistence: {mode: durable} -deployment: {order: [server], retain_releases: 5, migration_policy: manual} -runtime: - env_files: [.env.production] - env_checks: - - file: .env.production - require: [DATABASE_URL, POSTGRES_PASSWORD, PUBLIC_BASE_URL, HTTP_ADDR, PURSUE_SECRET_KEY] -checks: - http: - - {workload: server, path: /healthz, port: 8080} - url: - - {url: "https://pursue.run/healthz"} -proxy: - managed: true - kind: traefik-docker - image: "traefik:v3.7@sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8" - config: traefik - network: ob-ingress -registries: - ghcr: {server: ghcr.io, username: vishr, password_env: GHCR_TOKEN} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: pursue +spec: + environments: + production: + server: root@203.0.113.10 + policy: {requireApproval: true, allowAgentProposals: true} + workloads: + server: + role: Application + image: ghcr.io/labstack/pursue-server:2026.8.1 + replicas: 1 + strategy: Rolling + 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: + role: Job + compose: compose.yaml#migrate + deploymentPhase: PreRelease + dataEffect: Migration + postgres: + role: Daemon + compose: compose.yaml#postgres + persistence: {mode: Durable} + deployment: {order: [server], retainReleases: 5, migrationPolicy: Manual} + runtime: + envFiles: [.env.production] + envChecks: + - file: .env.production + require: [DATABASE_URL, POSTGRES_PASSWORD, PUBLIC_BASE_URL, HTTP_ADDR, PURSUE_SECRET_KEY] + checks: + http: + - {workload: server, path: /healthz, port: 8080} + url: + - {url: "https://pursue.run/healthz"} + proxy: + managed: true + kind: TraefikDocker + image: "traefik:v3.7@sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8" + config: traefik + network: ob-ingress + registries: + ghcr: {server: ghcr.io, username: vishr, passwordEnv: GHCR_TOKEN} diff --git a/internal/app/testdata/corpus/recast.yml b/internal/app/testdata/corpus/recast.yml index b44c6be7..bb5fae1e 100644 --- a/internal/app/testdata/corpus/recast.yml +++ b/internal/app/testdata/corpus/recast.yml @@ -1,52 +1,55 @@ -api_version: onebox.run/v1 -app: recast -environments: - production: - server: root@recast.example.com - policy: - require_approval: true - allow_agent_proposals: true - min_onebox_version: v2026.8.0 - min_plan_schema: onebox.run/executable-deploy-plan/v1alpha2 - migrations: {require_backup: false} -workloads: - server: - role: application - image: ghcr.io/labstack/recast-server:2026.8.1 - replicas: 1 - strategy: rolling - 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: - role: job - compose: compose.yaml#migrate - deployment_phase: pre_release - data_effect: migration - postgres: - role: daemon - compose: compose.yaml#postgres - persistence: {mode: durable} - clamav: - role: daemon - compose: compose.yaml#clamav -deployment: {order: [server], retain_releases: 5, migration_policy: manual} -runtime: - env_files: [.env.production] - env_checks: - - file: .env.production - require: [RECAST_ENV, RECAST_BASE_URL, DATABASE_URL, RECAST_AUTH_PEPPER, POSTGRES_PASSWORD] -checks: - http: - - {workload: server, path: /healthz, port: 8080} - url: - - {url: "https://recast.report/healthz"} -proxy: - managed: true - kind: traefik-docker - image: "traefik:v3.7@sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8" - config: traefik - network: ob-ingress -registries: - ghcr: {server: ghcr.io, username: vishr, password_env: GHCR_TOKEN} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: recast +spec: + environments: + production: + server: root@recast.example.com + policy: + requireApproval: true + allowAgentProposals: true + minOneboxVersion: v2026.8.0 + minPlanSchema: onebox.run/executable-deploy-plan/v1alpha2 + migrations: {requireBackup: false} + workloads: + server: + role: Application + image: ghcr.io/labstack/recast-server:2026.8.1 + replicas: 1 + strategy: Rolling + 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: + role: Job + compose: compose.yaml#migrate + deploymentPhase: PreRelease + dataEffect: Migration + postgres: + role: Daemon + compose: compose.yaml#postgres + persistence: {mode: Durable} + clamav: + role: Daemon + compose: compose.yaml#clamav + deployment: {order: [server], retainReleases: 5, migrationPolicy: Manual} + runtime: + envFiles: [.env.production] + envChecks: + - file: .env.production + require: [RECAST_ENV, RECAST_BASE_URL, DATABASE_URL, RECAST_AUTH_PEPPER, POSTGRES_PASSWORD] + checks: + http: + - {workload: server, path: /healthz, port: 8080} + url: + - {url: "https://recast.report/healthz"} + proxy: + managed: true + kind: TraefikDocker + image: "traefik:v3.7@sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8" + config: traefik + network: ob-ingress + registries: + ghcr: {server: ghcr.io, username: vishr, passwordEnv: GHCR_TOKEN} diff --git a/internal/app/types.go b/internal/app/types.go index 2dad31e7..3b1e4ade 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/v1alpha1 declarative authoring contract: one // application, its workloads, the services it needs, and how a release rolls // out. // @@ -28,11 +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 - APIVersion string `json:"api_version" description:"Project contract version. Must be onebox.run/v1." example:"onebox.run/v1"` + APIVersion string `json:"api_version"` // 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:`. Name string `json:"app" description:"Stable application name used in generated container, volume, network, and host paths." example:"shop"` + Annotations map[string]string `json:"-"` BasePath string `json:"base_path" description:"Absolute host directory beneath which Onebox stores application state and releases." default:"/var/lib/ob" example:"/srv/ob"` Environments map[string]Environment `json:"environments" description:"Named environments, each naming the server it deploys to and the policy applied to it."` Workloads map[string]Workload `json:"workloads,omitempty" description:"Application containers, workers, daemons, and jobs managed as releases."` @@ -71,7 +72,7 @@ type Environment struct { // type, not another Server: a Jump has no jump of its own, which is how // "exactly one hop" is enforced by the model instead of by validation. Jump *Jump `json:"jump,omitempty" 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." example:"deploy@bastion.example.com"` - BasePath string `json:"base_path,omitempty" description:"Environment-specific replacement for the project base_path." example:"/srv/ob"` + BasePath string `json:"base_path,omitempty" description:"Environment-specific replacement for the Application basePath." example:"/srv/ob"` // EnvFiles is this environment's default list. It sits on the environment // rather than in an environment-scoped `runtime` block for the same reason // base_path does: an environment restating a project-level default is an @@ -117,7 +118,7 @@ type MigrationPolicy struct { } type Overrides struct { - Workloads map[string]map[string]any `json:"workloads,omitempty" description:"Allowed workload tuning keyed by workload name: replicas, resources, env, env_files, strategy, and routes."` + Workloads map[string]map[string]any `json:"workloads,omitempty" description:"Allowed workload tuning keyed by workload name: replicas, resources, env, envFiles, strategy, and routes."` Services map[string]map[string]any `json:"services,omitempty" description:"Allowed service tuning keyed by service name: resources and settings."` } @@ -171,7 +172,7 @@ type Workload struct { DataEffect DataEffect `json:"data_effect,omitempty" description:"Job data impact used by rollback and abort gates." example:"migration"` Schedule *JobSchedule `json:"schedule,omitempty" description:"Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy."` Inputs map[string]JobInput `json:"inputs,omitempty" description:"Declared parameters of a scheduled job, exposed as environment variables. Names are upper-case identifiers; each declares exactly one of enum or pattern and a default. A timer firing uses the defaults; ob job run may override them."` - Execution *JobExecution `json:"execution,omitempty" description:"Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with data_effect none. Stores non-secret checkpoints on the host and permits explicit same-release resume."` + Execution *JobExecution `json:"execution,omitempty" description:"Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume."` } type JobExecution struct { diff --git a/internal/app/workload_contract_test.go b/internal/app/workload_contract_test.go index f880b664..9bac2457 100644 --- a/internal/app/workload_contract_test.go +++ b/internal/app/workload_contract_test.go @@ -54,13 +54,16 @@ func TestSecretInputRevisionsAreScopedByWorkload(t *testing.T) { t.Fatal(err) } } - spec, err := LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: example/api, env_files: [{file: api.enc.env, provider: sops}]} - worker: {role: worker, image: example/worker, env_files: [{file: worker.enc.env, provider: sops}]} -deployment: {order: [api, worker]} + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: example/api, envFiles: [{file: api.enc.env, provider: Sops}]} + worker: {role: Worker, image: example/worker, envFiles: [{file: worker.enc.env, provider: Sops}]} + deployment: {order: [api, worker]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -94,12 +97,15 @@ 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 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: example/api, env_files: [{file: api.enc.env, provider: sops}]} -deployment: {order: [api]} + spec, err := loadFixtureBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: example/api, envFiles: [{file: api.enc.env, provider: Sops}]} + deployment: {order: [api]} `), "ob.yml") if err != nil { t.Fatal(err) diff --git a/internal/engine/backup_identity_test.go b/internal/engine/backup_identity_test.go index 3ade3012..d3e2128d 100644 --- a/internal/engine/backup_identity_test.go +++ b/internal/engine/backup_identity_test.go @@ -10,28 +10,31 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const protectedPostgresProject = `api_version: onebox.run/v1 -app: shop -environments: - production: {server: deploy@example.net} -workloads: - web: {image: nginx:1} -backup_targets: - offsite: - kind: s3-compatible - endpoint: https://objects.example.net - bucket: backups - failure_domain: {identity: remote, host: objects.example.net} - credentials: - file: backup.env - provider: sops - access_key_entry: ACCESS_KEY - secret_key_entry: SECRET_KEY - encryption: {pitr: client-side} -services: - postgres: - version: 17 - backup: {target: offsite, recovery_kind: pitr, max_data_loss: 15m} +const protectedPostgresProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: deploy@example.net} + workloads: + web: {image: 'nginx:1'} + backupTargets: + offsite: + kind: S3Compatible + endpoint: https://objects.example.net + bucket: backups + failureDomain: {identity: remote, host: objects.example.net} + credentials: + file: backup.env + provider: Sops + accessKeyEntry: ACCESS_KEY + secretKeyEntry: SECRET_KEY + encryption: {pitr: ClientSide} + services: + postgres: + version: 17 + backup: {target: offsite, recoveryKind: Pitr, maxDataLoss: 15m} ` func protectedPostgresResolved(t *testing.T, identifier string) *app.Resolved { diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go index 7b2533e3..d8e5e437 100644 --- a/internal/engine/deploy_test.go +++ b/internal/engine/deploy_test.go @@ -22,19 +22,21 @@ import ( // exercise the unguardable path in every test. 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 -app: sample -environments: - production: - server: deploy@h -workloads: - frontend: - role: application - image: ghcr.io/x/app:v1 - health: {http: /healthz, port: 8080, interval: 5s, start_period: 5s, within: 120s} -deployment: - order: [frontend] +const enginePreviousFrontendProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: + production: + server: deploy@h + workloads: + frontend: + role: Application + image: ghcr.io/x/app:v1 + health: {http: /healthz, port: 8080, interval: 5s, startPeriod: 5s, within: 120s} + deployment: + order: [frontend] ` func seedStagedApplicationManifest(f *transport.Fake, releaseID string) { diff --git a/internal/engine/finalize_test.go b/internal/engine/finalize_test.go index f4e6fb5f..c27f865c 100644 --- a/internal/engine/finalize_test.go +++ b/internal/engine/finalize_test.go @@ -45,8 +45,8 @@ func seedServingApplicationManifest(f *transport.Fake, releaseID, predecessor st // snapshot staged with the release, never the working tree, so the hook under // test has to live here. const engineProjectWithPostDeployHook = engineProject + ` -hooks: - post_deploy: {run: notify-release} + hooks: + PostDeploy: {run: notify-release} ` // activatedFake is a deploy that got past activation and then failed in the @@ -148,14 +148,14 @@ func storedManifest(t *testing.T, f *transport.Fake, releaseID string) release.M // A project whose job carries a schedule, so the middle post-activation step // has work to do and can be made to fail. -var engineProjectWithScheduledJob = strings.Replace(engineProject, "services:", ` report: - role: job - image: ghcr.io/x/app:v2 - command: report - deployment_phase: none - data_effect: none - schedule: {cron: "0 2 * * *", timezone: UTC} -services:`, 1) +var engineProjectWithScheduledJob = strings.Replace(engineProject, " services:", ` report: + role: Job + image: ghcr.io/x/app:v2 + command: report + deploymentPhase: None + dataEffect: None + schedule: {cron: "0 2 * * *", timezone: UTC} + services:`, 1) func scheduledJobConfig() *app.Resolved { spec, err := app.LoadBytes([]byte(engineProjectWithScheduledJob), "ob.yml") diff --git a/internal/engine/fixtures_test.go b/internal/engine/fixtures_test.go index 667d90d6..f1f27755 100644 --- a/internal/engine/fixtures_test.go +++ b/internal/engine/fixtures_test.go @@ -20,38 +20,40 @@ const ( // normalised config. Loading it through the real loader is the point: a test // that assembles the struct directly can assert on a shape the loader would // never produce. -const engineProject = ` -api_version: onebox.run/v1 -app: sample -environments: - production: - server: deploy@h -workloads: - web: - role: application - image: ghcr.io/x/app:v2 - health: {http: /healthz, port: 7500, interval: 5s, start_period: 5s, within: 120s} - worker: - role: worker - image: ghcr.io/x/app:v2 - command: work - strategy: recreate - drain: {signal: TERM, wait: 1s} - migrate: - role: job - image: ghcr.io/x/app:v2 - command: migrate - deployment_phase: pre_release - data_effect: unknown -services: - postgres: - driver: postgres - version: 17 -deployment: - order: [web, worker] -checks: - http: - - {workload: web, path: /healthz} +const engineProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: + production: + server: deploy@h + workloads: + web: + role: Application + image: ghcr.io/x/app:v2 + health: {http: /healthz, port: 7500, interval: 5s, startPeriod: 5s, within: 120s} + worker: + role: Worker + image: ghcr.io/x/app:v2 + command: work + strategy: Recreate + drain: {signal: TERM, wait: 1s} + migrate: + role: Job + image: ghcr.io/x/app:v2 + command: migrate + deploymentPhase: PreRelease + dataEffect: Unknown + services: + postgres: + driver: postgres + version: 17 + deployment: + order: [web, worker] + checks: + http: + - {workload: web, path: /healthz} ` func testConfig() *app.Resolved { diff --git a/internal/engine/host_environment_test.go b/internal/engine/host_environment_test.go index 1a86cbe7..31cfe932 100644 --- a/internal/engine/host_environment_test.go +++ b/internal/engine/host_environment_test.go @@ -113,15 +113,17 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) { // base_path override — and would have compared an empty environment against the // host owner record. func TestEnvironmentSelectsTheBasePath(t *testing.T) { - spec, err := app.LoadBytes([]byte(` -api_version: onebox.run/v1 -app: sample -base_path: /var/lib/ob -environments: - production: {server: root@h} - staging: {server: root@h2, base_path: /srv/staging} -workloads: - web: {role: application, image: x:1} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + basePath: /var/lib/ob + environments: + production: {server: root@h} + staging: {server: root@h2, basePath: /srv/staging} + workloads: + web: {role: Application, image: 'x:1'} `), "ob.yml") if err != nil { t.Fatal(err) diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index 46f21fa1..e045b8d0 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -154,7 +154,7 @@ func TestDestroyUsesTheCurrentReleaseEnvironment(t *testing.T) { base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { if strings.Contains(command, "/releases/R7/ob.snapshot.yml") { - return transport.Result{Stdout: engineProject + "\nruntime:\n env_files: [legacy.env]\n"}, true + return transport.Result{Stdout: engineProject + "\n runtime:\n envFiles: [legacy.env]\n"}, true } return base(command) } diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go index d5c5a84f..f49f2d7a 100644 --- a/internal/engine/recovery_test.go +++ b/internal/engine/recovery_test.go @@ -255,7 +255,7 @@ func TestRecoverySnapshotRejectsAnotherApplication(t *testing.T) { target := happyFake() target.Dynamic = func(command string) (transport.Result, bool) { if strings.Contains(command, "/ob.snapshot.yml") { - return transport.Result{Stdout: strings.Replace(engineProject, "app: sample", "app: other", 1)}, true + return transport.Result{Stdout: strings.Replace(engineProject, "name: sample", "name: other", 1)}, true } return transport.Result{}, false } @@ -267,7 +267,7 @@ func TestRecoverySnapshotRejectsAnotherApplication(t *testing.T) { } func TestRecoveryEngineUsesSnapshotChoreography(t *testing.T) { - snapshot := strings.Replace(engineProject, " worker:\n", " legacy:\n role: worker\n image: ghcr.io/x/legacy:v1\n command: legacy-work\n strategy: recreate\n worker:\n", 1) + snapshot := strings.Replace(engineProject, " worker:\n", " legacy:\n role: Worker\n image: ghcr.io/x/legacy:v1\n command: legacy-work\n strategy: Recreate\n worker:\n", 1) snapshot = strings.Replace(snapshot, "order: [web, worker]", "order: [legacy, worker, web]", 1) target := happyFake() base := target.Dynamic diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go index 065e52f1..3e657336 100644 --- a/internal/engine/resume_test.go +++ b/internal/engine/resume_test.go @@ -206,7 +206,7 @@ func interruptedBeforeMigrationFake(allowUnknown bool) *transport.Fake { case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true case strings.Contains(cmd, "ob.snapshot.yml"): - return transport.Result{Stdout: strings.Replace(engineProject, "data_effect: unknown", "data_effect: migration", 1)}, true + return transport.Result{Stdout: strings.Replace(engineProject, "dataEffect: Unknown", "dataEffect: Migration", 1)}, true case strings.Contains(cmd, "test -d"): return transport.Result{ExitCode: 0}, true case strings.Contains(cmd, "readlink"): @@ -389,17 +389,19 @@ func TestAbortUsesInterruptedExpandOnlyPolicyAfterConfigEdit(t *testing.T) { testAbortReplaysPreviousRelease(t, "changed=unknown", true) } -const interruptedWebSnapshot = ` -api_version: onebox.run/v1 -app: sample -environments: { production: { server: deploy@h } } -workloads: - web: - role: application - image: ghcr.io/x/app:v2 - health: {http: /healthz, port: 7500} -deployment: - order: [web] +const interruptedWebSnapshot = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@h}} + workloads: + web: + role: Application + image: ghcr.io/x/app:v2 + health: {http: /healthz, port: 7500} + deployment: + order: [web] ` func TestAbortUsesBothReleaseSnapshotsAfterConfigEdit(t *testing.T) { diff --git a/internal/engine/rollback_test.go b/internal/engine/rollback_test.go index 4d9bbfcb..5f9759e6 100644 --- a/internal/engine/rollback_test.go +++ b/internal/engine/rollback_test.go @@ -49,14 +49,16 @@ func seedRollbackState(t *testing.T, target *transport.Fake) { // The previous release's snapshot has a DIFFERENT choreography (worker only, // recreate) — rollback must replay THAT, not the current ob.yml. -const oldSnapshot = ` -api_version: onebox.run/v1 -app: sample -environments: { production: { server: deploy@h } } -workloads: - worker: { role: worker, image: ghcr.io/x/app:v1, command: work, strategy: recreate } -deployment: - order: [worker] +const oldSnapshot = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@h}} + workloads: + worker: {role: Worker, image: 'ghcr.io/x/app:v1', command: work, strategy: Recreate} + deployment: + order: [worker] ` func TestRollbackReplaysSnapshotChoreography(t *testing.T) { diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go index 1557efb6..c294f971 100644 --- a/internal/engine/secret_generation_rolling_test.go +++ b/internal/engine/secret_generation_rolling_test.go @@ -14,28 +14,31 @@ 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 -app: shop -base_path: /srv/onebox -environments: - production: {server: deploy@example.invalid} -workloads: - web: - image: nginx - port: 3000 - hostname: shop.example.com - health: {exec: ["/health"]} - env_files: [{file: web.enc.env, provider: sops}] - worker: - role: worker - image: nginx - env_files: [{file: worker.enc.env, provider: sops}] +const rollingGenerationProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + basePath: /srv/onebox + environments: + production: {server: deploy@example.invalid} + workloads: + web: + image: nginx + port: 3000 + hostname: shop.example.com + health: {exec: ["/health"]} + envFiles: [{file: web.enc.env, provider: Sops}] + worker: + role: Worker + image: nginx + envFiles: [{file: worker.enc.env, provider: Sops}] ` // A two-replica rolling web, so a rotation can be caught part-way through: // one replica already on the new generation, one still on the old. var rollingGenerationProjectPair = strings.Replace(rollingGenerationProject, - " health: {exec: [\"/health\"]}", " replicas: 2\n health: {exec: [\"/health\"]}", 1) + " health: {exec: [\"/health\"]}", " replicas: 2\n health: {exec: [\"/health\"]}", 1) type rollingGenerationState struct { project string diff --git a/internal/engine/secret_generation_test.go b/internal/engine/secret_generation_test.go index 2a864a13..3ccfc153 100644 --- a/internal/engine/secret_generation_test.go +++ b/internal/engine/secret_generation_test.go @@ -13,23 +13,25 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const generationProject = `api_version: onebox.run/v1 -app: shop -base_path: /srv/onebox -environments: - production: {server: deploy@example.invalid} -workloads: - web: - image: nginx - port: 3000 - hostname: shop.example.com - env_files: [{file: web.enc.env, provider: sops}] - worker: - role: worker - image: nginx - env_files: [{file: worker.enc.env, provider: sops}] +const generationProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + basePath: /srv/onebox + environments: + production: {server: deploy@example.invalid} + workloads: + web: + image: nginx + port: 3000 + hostname: shop.example.com + envFiles: [{file: web.enc.env, provider: Sops}] + worker: + role: Worker + image: nginx + envFiles: [{file: worker.enc.env, provider: Sops}] ` - const ( oldSecretGeneration = "sg-111111111111111111111111" newSecretGeneration = "sg-222222222222222222222222" diff --git a/internal/engine/secretspush_test.go b/internal/engine/secretspush_test.go index d35c0851..6658f037 100644 --- a/internal/engine/secretspush_test.go +++ b/internal/engine/secretspush_test.go @@ -11,24 +11,27 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const secretGraphProject = `api_version: onebox.run/v1 -app: shop -environments: - production: {server: deploy@example.invalid} -runtime: - env_files: - - {file: shared.enc.env, provider: sops} -workloads: - web: - image: nginx - port: 3000 - hostname: shop.example.com - env_files: - - {file: first.enc.env, provider: sops} - - {file: second.enc.env, provider: sops} - worker: - role: worker - image: nginx +const secretGraphProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: {server: deploy@example.invalid} + runtime: + envFiles: + - {file: shared.enc.env, provider: Sops} + workloads: + web: + image: nginx + port: 3000 + hostname: shop.example.com + envFiles: + - {file: first.enc.env, provider: Sops} + - {file: second.enc.env, provider: Sops} + worker: + role: Worker + image: nginx ` func resolvedSecretGraph(t *testing.T, project string) *app.Resolved { @@ -47,18 +50,18 @@ func resolvedSecretGraph(t *testing.T, project string) *app.Resolved { func TestSecretsPushRefusesExactDeclarationGraphDriftBeforeMutation(t *testing.T) { variants := map[string]string{ "added": strings.Replace(secretGraphProject, - " - {file: second.enc.env, provider: sops}\n", - " - {file: second.enc.env, provider: sops}\n - {file: third.enc.env, provider: sops}\n", 1), + " - {file: second.enc.env, provider: Sops}\n", + " - {file: second.enc.env, provider: Sops}\n - {file: third.enc.env, provider: Sops}\n", 1), "removed": strings.Replace(secretGraphProject, - " - {file: second.enc.env, provider: sops}\n", "", 1), + " - {file: second.enc.env, provider: Sops}\n", "", 1), "reordered": strings.Replace(secretGraphProject, - " - {file: first.enc.env, provider: sops}\n - {file: second.enc.env, provider: sops}\n", - " - {file: second.enc.env, provider: sops}\n - {file: first.enc.env, provider: sops}\n", 1), + " - {file: first.enc.env, provider: Sops}\n - {file: second.enc.env, provider: Sops}\n", + " - {file: second.enc.env, provider: Sops}\n - {file: first.enc.env, provider: Sops}\n", 1), "provider removed": strings.Replace(secretGraphProject, - "{file: first.enc.env, provider: sops}", "{file: first.enc.env}", 1), + "{file: first.enc.env, provider: Sops}", "{file: first.enc.env}", 1), "scope changed": strings.Replace(secretGraphProject, - " env_files:\n - {file: first.enc.env, provider: sops}\n - {file: second.enc.env, provider: sops}\n worker:\n role: worker\n image: nginx\n", - " worker:\n role: worker\n image: nginx\n env_files:\n - {file: first.enc.env, provider: sops}\n - {file: second.enc.env, provider: sops}\n", 1), + " envFiles:\n - {file: first.enc.env, provider: Sops}\n - {file: second.enc.env, provider: Sops}\n worker:\n role: Worker\n image: nginx\n", + " worker:\n role: Worker\n image: nginx\n envFiles:\n - {file: first.enc.env, provider: Sops}\n - {file: second.enc.env, provider: Sops}\n", 1), } for name, deployedProject := range variants { @@ -125,7 +128,7 @@ func TestValidateSecretPayloadsRefusesIncompleteOrUnsafeGraphs(t *testing.T) { } func TestCurrentSecretEngineKeepsDeployedOperationalSettings(t *testing.T) { - deployed := strings.Replace(secretGraphProject, " web:\n image: nginx\n", " web:\n image: nginx\n replicas: 3\n", 1) + deployed := strings.Replace(secretGraphProject, " web:\n image: nginx\n", " web:\n image: nginx\n replicas: 3\n", 1) target := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { if strings.Contains(command, "/ob.snapshot.yml") { return transport.Result{Stdout: deployed}, true diff --git a/internal/engine/verify_injection_test.go b/internal/engine/verify_injection_test.go index 20f22515..103b204e 100644 --- a/internal/engine/verify_injection_test.go +++ b/internal/engine/verify_injection_test.go @@ -64,19 +64,21 @@ 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 -app: sample -environments: - production: - server: deploy@h -workloads: - web: - role: application - image: ghcr.io/x/app:v2 - health: {http: /healthz, port: 7500, interval: 5s, start_period: 5s, within: 120s} -checks: - http: - - {workload: web, path: `+path+`} +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: sample} +spec: + environments: + production: + server: deploy@h + workloads: + web: + role: Application + image: ghcr.io/x/app:v2 + health: {http: /healthz, port: 7500, interval: 5s, startPeriod: 5s, within: 120s} + checks: + http: + - {workload: web, path: `+path+`} `), "ob.yml") if err != nil { t.Fatalf("the project grammar rejected %q, so this path cannot reach the engine: %v", path, err) diff --git a/internal/onebox/backup_evidence_test.go b/internal/onebox/backup_evidence_test.go index 4b27d187..14ac8513 100644 --- a/internal/onebox/backup_evidence_test.go +++ b/internal/onebox/backup_evidence_test.go @@ -362,11 +362,11 @@ func TestPlanDerivesMigrationBackupRequirementAndExecuteRejectsMissingReportBefo t.Fatal(err) } configText := strings.Replace(string(configBytes), - " allow_agent_proposals: true\n", - " allow_agent_proposals: true\n migrations: {require_backup: true, backup_max_age: 24h, require_restore_test: true, backup_key_material: [application_encryption_key]}\n", 1) + " allowAgentProposals: true\n", + " allowAgentProposals: true\n migrations: {requireBackup: true, backupMaxAge: 24h, requireRestoreTest: true, backupKeyMaterial: [application_encryption_key]}\n", 1) configText = strings.Replace(configText, - " database:\n", - " migrate:\n role: job\n image: ghcr.io/example/app:migrate\n deployment_phase: pre_release\n data_effect: migration\n database:\n", 1) + " database:\n", + " migrate:\n role: Job\n image: ghcr.io/example/app:migrate\n deploymentPhase: PreRelease\n dataEffect: Migration\n database:\n", 1) if err := os.WriteFile(configPath, []byte(configText), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/onebox/bootstrap_test.go b/internal/onebox/bootstrap_test.go index 41a8e788..efb7a7dc 100644 --- a/internal/onebox/bootstrap_test.go +++ b/internal/onebox/bootstrap_test.go @@ -10,17 +10,20 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const bootstrapBuildProject = `api_version: onebox.run/v1 -app: demo -environments: {production: {server: deploy@example.invalid}} -runtime: - env_files: [app.env] -workloads: - api: - role: application - build: {context: ., dockerfile: Dockerfile} - env: {INLINE_SECRET: inline-application-secret} -proxy: {kind: none} +const bootstrapBuildProject = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: {production: {server: deploy@example.invalid}} + runtime: + envFiles: [app.env] + workloads: + api: + role: Application + build: {context: ., dockerfile: Dockerfile} + env: {INLINE_SECRET: inline-application-secret} + proxy: {kind: None} ` func writeBootstrapBuildProject(t *testing.T) string { diff --git a/internal/onebox/exec_test.go b/internal/onebox/exec_test.go index e30ac920..0e0ac167 100644 --- a/internal/onebox/exec_test.go +++ b/internal/onebox/exec_test.go @@ -14,16 +14,19 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const execProjectYAML = `api_version: onebox.run/v1 -app: shop -environments: - production: - server: deploy@example.invalid -workloads: - api: - image: nginx - port: 3000 - hostname: shop.example.com +const execProjectYAML = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: deploy@example.invalid + workloads: + api: + image: nginx + port: 3000 + hostname: shop.example.com ` func execService(t *testing.T, connect Connector) *Service { @@ -83,8 +86,8 @@ func TestExecEnforcesEnvironmentAndRunnerPolicyBeforeConnecting(t *testing.T) { {name: "unknown environment", prepare: func(service *Service) { service.environment = "staging" }, want: "unknown_environment"}, {name: "runner policy", prepare: func(service *Service) { service.configPath = writeExecProject(t, strings.Replace(execProjectYAML, - " server: deploy@example.invalid\n", - " server: deploy@example.invalid\n policy: {min_onebox_version: v2026.8.3}\n", 1)) + " server: deploy@example.invalid\n", + " server: deploy@example.invalid\n policy: {minOneboxVersion: v2026.8.3}\n", 1)) }, want: "not a released Onebox CalVer"}, } { t.Run(test.name, func(t *testing.T) { diff --git a/internal/onebox/execution_boundary_test.go b/internal/onebox/execution_boundary_test.go index 19ef2205..17262d2f 100644 --- a/internal/onebox/execution_boundary_test.go +++ b/internal/onebox/execution_boundary_test.go @@ -851,7 +851,7 @@ func TestExecuteRejectsRelocatedBasePathBeforeMutation(t *testing.T) { if err != nil { t.Fatal(err) } - moved := strings.Replace(string(source), "app: demo\n", "app: demo\nbase_path: /srv/onebox\n", 1) + moved := strings.Replace(string(source), "spec:\n", "spec:\n basePath: /srv/onebox\n", 1) if err := os.WriteFile(svc.configPath, []byte(moved), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/onebox/job_plan_test.go b/internal/onebox/job_plan_test.go index 7d0b5f8e..32dfdd28 100644 --- a/internal/onebox/job_plan_test.go +++ b/internal/onebox/job_plan_test.go @@ -19,12 +19,12 @@ func writeManualJobProject(t *testing.T, effect string, requireBackup bool) stri t.Fatal(err) } project := strings.Replace(string(encoded), - " database:\n", - " maintenance:\n role: job\n image: ghcr.io/example/maintenance:v1\n deployment_phase: none\n data_effect: "+effect+"\n database:\n", 1) + " database:\n", + " maintenance:\n role: Job\n image: ghcr.io/example/maintenance:v1\n deploymentPhase: None\n dataEffect: "+strings.ToUpper(effect[:1])+effect[1:]+"\n database:\n", 1) if requireBackup { project = strings.Replace(project, - " allow_agent_proposals: true\n", - " allow_agent_proposals: true\n migrations: {require_backup: true, backup_max_age: 24h}\n", 1) + " allowAgentProposals: true\n", + " allowAgentProposals: true\n migrations: {requireBackup: true, backupMaxAge: 24h}\n", 1) } if err := os.WriteFile(path, []byte(project), 0o600); err != nil { t.Fatal(err) @@ -320,8 +320,8 @@ func TestExecuteScheduledDestructiveJobDetachesToHostUnit(t *testing.T) { t.Fatal(err) } project := strings.Replace(string(encoded), - " data_effect: destructive\n", - " data_effect: destructive\n schedule: {cron: '0 4 * * 1', timezone: UTC, timeout: 8h}\n", 1) + " dataEffect: Destructive\n", + " dataEffect: Destructive\n schedule: {cron: '0 4 * * 1', timezone: UTC, timeout: 8h}\n", 1) if err := os.WriteFile(path, []byte(project), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/onebox/jump_route_test.go b/internal/onebox/jump_route_test.go index 630b3107..337c9ef4 100644 --- a/internal/onebox/jump_route_test.go +++ b/internal/onebox/jump_route_test.go @@ -15,16 +15,20 @@ func writeJumpProject(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := ` -api_version: onebox.run/v1 -app: demo -environments: - production: - server: deploy@example.invalid - jump: bastion@jump.invalid:2222 -image: ghcr.io/example/app:v1 -routes: - - {hostname: demo.example.com, port: 8080} + body := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: + production: + server: deploy@example.invalid + jump: bastion@jump.invalid:2222 + workloads: + demo: + image: ghcr.io/example/app:v1 + routes: + - {hostname: demo.example.com, port: 8080} ` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) @@ -82,16 +86,20 @@ func TestChangingOnlyTheJumpChangesTheBinding(t *testing.T) { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := ` -api_version: onebox.run/v1 -app: demo -environments: - production: - server: deploy@example.invalid - jump: ` + jump + ` -image: ghcr.io/example/app:v1 -routes: - - {hostname: demo.example.com, port: 8080} + body := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: + production: + server: deploy@example.invalid + jump: ` + jump + ` + workloads: + demo: + image: ghcr.io/example/app:v1 + 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..3673d595 100644 --- a/internal/onebox/load_service_runtime_test.go +++ b/internal/onebox/load_service_runtime_test.go @@ -16,11 +16,14 @@ func protectedRuntimeProject(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ob.yml") - body := `api_version: onebox.run/v1 -app: example -environments: {production: {server: root@host}} -workloads: {web: {image: nginx:1}} -services: {database: {driver: postgres, version: 17}} + body := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: example +spec: + environments: {production: {server: root@host}} + workloads: {web: {image: 'nginx:1'}} + services: {database: {driver: postgres, version: 17}} ` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) diff --git a/internal/onebox/operation_graph_test.go b/internal/onebox/operation_graph_test.go index 443f0b2f..ce95afdd 100644 --- a/internal/onebox/operation_graph_test.go +++ b/internal/onebox/operation_graph_test.go @@ -81,13 +81,15 @@ func TestDeploymentGraphNeverContainsHookBodies(t *testing.T) { func TestDeploymentGraphOmitsAbsentHooksAndJobs(t *testing.T) { t.Parallel() - spec, err := app.LoadBytes([]byte(` -api_version: onebox.run/v1 -app: sample -environments: {production: {server: root@h}} -workloads: - web: {role: application, image: x:1, strategy: rolling, health: {http: /healthz, port: 8080}} -deployment: {order: [web]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: root@h}} + workloads: + web: {role: Application, image: 'x:1', strategy: Rolling, health: {http: /healthz, port: 8080}} + deployment: {order: [web]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -117,23 +119,25 @@ func TestDeploymentClassificationDoesNotOverstateFirstDeployRollback(t *testing. } func operationGraphConfig() *app.Resolved { - spec, err := app.LoadBytes([]byte(` -api_version: onebox.run/v1 -app: sample -environments: {production: {server: root@h}} -workloads: - web: {role: application, image: x:1, strategy: rolling, health: {http: /healthz, port: 8080}} - worker: {role: worker, image: x:1, strategy: recreate} - migrate: {role: job, image: x:1, command: "echo JOB_SECRET", deployment_phase: pre_release, data_effect: migration} - assets: {role: job, image: x:1, deployment_phase: pre_release, data_effect: none} - cleanup: {role: job, image: x:1, deployment_phase: post_release, data_effect: none} - nightly: {role: job, image: x:1, deployment_phase: none, data_effect: none, schedule: {cron: "0 2 * * *"}} -deployment: - order: [worker, web] -hooks: - pre_release: {run: "echo PRE_RELEASE_SECRET"} - post_release: {run: "echo POST_RELEASE_SECRET"} - post_deploy: {run: "echo POST_DEPLOY_SECRET"} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: root@h}} + workloads: + web: {role: Application, image: 'x:1', strategy: Rolling, health: {http: /healthz, port: 8080}} + worker: {role: Worker, image: 'x:1', strategy: Recreate} + migrate: {role: Job, image: 'x:1', command: "echo JOB_SECRET", deploymentPhase: PreRelease, dataEffect: Migration} + assets: {role: Job, image: 'x:1', deploymentPhase: PreRelease, dataEffect: None} + cleanup: {role: Job, image: 'x:1', deploymentPhase: PostRelease, dataEffect: None} + nightly: {role: Job, image: 'x:1', deploymentPhase: None, dataEffect: None, schedule: {cron: "0 2 * * *"}} + deployment: + order: [worker, web] + hooks: + PreRelease: {run: "echo PRE_RELEASE_SECRET"} + PostRelease: {run: "echo POST_RELEASE_SECRET"} + PostDeploy: {run: "echo POST_DEPLOY_SECRET"} `), "ob.yml") if err != nil { panic("operation graph fixture does not load: " + err.Error()) diff --git a/internal/onebox/secrets_push_test.go b/internal/onebox/secrets_push_test.go index 512444cf..644307a0 100644 --- a/internal/onebox/secrets_push_test.go +++ b/internal/onebox/secrets_push_test.go @@ -12,21 +12,24 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const pushProjectYAML = `api_version: onebox.run/v1 -app: shop -environments: - production: - server: deploy@example.invalid -workloads: - web: - image: nginx - port: 3000 - hostname: shop.example.com - env_files: [{file: api.enc.env, provider: sops}] - jobs: - role: worker - image: nginx - env_files: [{file: worker.enc.env, provider: sops}] +const pushProjectYAML = `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: deploy@example.invalid + workloads: + web: + image: nginx + port: 3000 + hostname: shop.example.com + envFiles: [{file: api.enc.env, provider: Sops}] + jobs: + role: Worker + image: nginx + envFiles: [{file: worker.enc.env, provider: Sops}] ` // pushFake answers the reads `secrets push` makes: a current release, and a @@ -172,11 +175,14 @@ 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 -app: shop -environments: {production: {server: deploy@example.invalid}} -workloads: - web: {image: nginx, routes: [{hostname: shop.example.com, port: 3000}]} + if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: deploy@example.invalid}} + workloads: + 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..be4052fe 100644 --- a/internal/onebox/service_test.go +++ b/internal/onebox/service_test.go @@ -27,39 +27,41 @@ services: image: ghcr.io/example/postgres:` + testSecret + ` `, "ob.yml": ` -api_version: onebox.run/v1 -app: demo -environments: - production: - server: deploy@example.invalid - policy: - require_approval: true - allow_agent_proposals: true -workloads: - web: - role: application - image: ghcr.io/example/app:v1 - strategy: rolling - health: { http: /healthz, port: 8080 } - env: { SECRET_TOKEN: "` + testSecret + `" } - database: - role: daemon - compose: "docker-compose.yaml#database" - persistence: { mode: durable } - volumes: [{ name: data, path: /var/lib/postgresql/data }] -deployment: - order: [web] - retain_releases: 5 - migration_policy: manual -runtime: - env_files: [app.env] -hooks: - post_deploy: "echo ` + testSecret + `" -checks: - url: - - { url: "https://example.invalid/private/` + testSecret + `?token=` + testSecret + `", advisory: true } - http: - - { workload: web, path: "/private/` + testSecret + `" } +apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: demo} +spec: + environments: + production: + server: deploy@example.invalid + policy: + requireApproval: true + allowAgentProposals: true + workloads: + web: + role: Application + image: ghcr.io/example/app:v1 + strategy: Rolling + health: { http: /healthz, port: 8080 } + env: { SECRET_TOKEN: "` + testSecret + `" } + database: + role: Daemon + compose: "docker-compose.yaml#database" + persistence: { mode: Durable } + volumes: [{ name: data, path: /var/lib/postgresql/data }] + deployment: + order: [web] + retainReleases: 5 + migrationPolicy: Manual + runtime: + envFiles: [app.env] + hooks: + PostDeploy: "echo ` + testSecret + `" + checks: + url: + - { url: "https://example.invalid/private/` + testSecret + `?token=` + testSecret + `", advisory: true } + http: + - { workload: web, path: "/private/` + testSecret + `" } `, } for name, body := range files { @@ -164,13 +166,15 @@ 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 -app: demo -environments: {production: {server: deploy@example.invalid}} -workloads: - web: {role: application, image: ` + pinnedWeb + `, strategy: recreate} - database: {role: daemon, compose: compose.yaml#database} -deployment: {order: [web, database]} + "ob.yml": `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: demo} +spec: + environments: {production: {server: deploy@example.invalid}} + workloads: + web: {role: Application, image: ` + pinnedWeb + `, strategy: Recreate} + database: {role: Daemon, compose: compose.yaml#database} + deployment: {order: [web, database]} `, } for name, body := range files { @@ -230,22 +234,25 @@ func TestPlanDeployUsesDeployedSecretGraphDuringTransition(t *testing.T) { project := func(workerSecret bool) string { workerEnv := "" if workerSecret { - workerEnv = "\n env_files: [{file: worker.enc.env, provider: sops}]" + workerEnv = "\n envFiles: [{file: worker.enc.env, provider: Sops}]" } - return `api_version: onebox.run/v1 -app: demo -environments: - production: {server: deploy@example.invalid} -workloads: - web: - role: application - image: ` + imageWeb + ` - strategy: recreate - env_files: [{file: web.enc.env, provider: sops}] - worker: - role: daemon - image: ` + imageWorker + workerEnv + ` -deployment: {order: [web, worker]} + return `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: demo +spec: + environments: + production: {server: deploy@example.invalid} + workloads: + web: + role: Application + image: ` + imageWeb + ` + strategy: Recreate + envFiles: [{file: web.enc.env, provider: Sops}] + worker: + role: Daemon + image: ` + imageWorker + workerEnv + ` + deployment: {order: [web, worker]} ` } write("web.enc.env", "WEB_TOKEN=web\n") diff --git a/internal/onebox/staging_secrets_test.go b/internal/onebox/staging_secrets_test.go index 334bf02c..3289e610 100644 --- a/internal/onebox/staging_secrets_test.go +++ b/internal/onebox/staging_secrets_test.go @@ -38,29 +38,32 @@ 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 -app: shop -environments: - production: - server: root@h -runtime: - env_files: - - shared.env -workloads: - web: - image: nginx - port: 3000 - hostname: shop.example.com - volumes: - - {source: ., path: /app, mode: ro} - env_files: + write("ob.yml", `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: + production: + server: root@h + runtime: + envFiles: - shared.env - - {file: api.enc.env, provider: sops} - jobs: - role: worker - image: nginx - env_files: - - {file: worker.enc.env, provider: sops} + workloads: + web: + image: nginx + port: 3000 + hostname: shop.example.com + volumes: + - {source: ., path: /app, mode: Ro} + envFiles: + - shared.env + - {file: api.enc.env, provider: Sops} + jobs: + role: Worker + image: nginx + envFiles: + - {file: worker.enc.env, provider: Sops} `) return filepath.Join(dir, "ob.yml") } @@ -252,24 +255,27 @@ 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 -app: shop -environments: {production: {server: root@h}} -workloads: - web: - image: nginx - needs: - - name: database - condition: healthy - env: {APP_DATABASE_URL: url} -external_services: - database: - driver: postgres - connection: - source: {file: secrets/database.env, provider: sops} - entries: {url: DATABASE_URL} - backup_owner: platform-team/rds - probe: {} + project := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@h}} + workloads: + web: + image: nginx + needs: + - name: database + condition: Healthy + env: {APP_DATABASE_URL: url} + externalServices: + database: + driver: postgres + connection: + source: {file: secrets/database.env, provider: Sops} + entries: {url: DATABASE_URL} + backupOwner: platform-team/rds + probe: {} ` configPath := filepath.Join(dir, "ob.yml") if err := os.WriteFile(configPath, []byte(project), 0o600); err != nil { diff --git a/internal/onebox/workload_contract_test.go b/internal/onebox/workload_contract_test.go index f6df03d6..cf2eb9a6 100644 --- a/internal/onebox/workload_contract_test.go +++ b/internal/onebox/workload_contract_test.go @@ -15,13 +15,16 @@ func TestWorkloadContractsScopePlainEnvironmentChanges(t *testing.T) { t.Fatal(err) } } - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: example/api, env_files: [api.env]} - worker: {role: worker, image: example/worker, env_files: [worker.env]} -deployment: {order: [api, worker]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: example/api, envFiles: [api.env]} + worker: {role: Worker, image: example/worker, envFiles: [worker.env]} + deployment: {order: [api, worker]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -83,13 +86,16 @@ func TestWorkloadContractsTrackRelativeBindMountContent(t *testing.T) { t.Fatal(err) } } - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: example/api, volumes: [{source: ./api-conf, path: /conf, mode: ro}]} - worker: {role: worker, image: example/worker, volumes: [{source: ./worker-conf, path: /conf, mode: ro}]} -deployment: {order: [api, worker]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: example/api, volumes: [{source: ./api-conf, path: /conf, mode: Ro}]} + worker: {role: Worker, image: example/worker, volumes: [{source: ./worker-conf, path: /conf, mode: Ro}]} + deployment: {order: [api, worker]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -121,12 +127,15 @@ deployment: {order: [api, worker]} } func TestBindMountContractIsIndependentOfWhereTheReleaseIsStaged(t *testing.T) { - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: example/api, volumes: [{source: ./conf, path: /conf, mode: ro}]} -deployment: {order: [api]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: example/api, volumes: [{source: ./conf, path: /conf, mode: Ro}]} + deployment: {order: [api]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -157,12 +166,15 @@ deployment: {order: [api]} func TestWorkloadContractsIgnoreVolumesOnAnAdoptedComposeService(t *testing.T) { staging := t.TempDir() - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, compose: docker-compose.yml#api, volumes: [{source: ./api-conf, path: /conf, mode: ro}]} -deployment: {order: [api]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, compose: docker-compose.yml#api, volumes: [{source: ./api-conf, path: /conf, mode: Ro}]} + deployment: {order: [api]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -177,12 +189,15 @@ deployment: {order: [api]} } func TestBindMountContractNoticesAnAddedEmptyDirectory(t *testing.T) { - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: example/api, volumes: [{source: ./conf, path: /conf, mode: ro}]} -deployment: {order: [api]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: example/api, volumes: [{source: ./conf, path: /conf, mode: Ro}]} + deployment: {order: [api]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -216,12 +231,15 @@ deployment: {order: [api]} func bindMountRevision(t *testing.T, mode os.FileMode) string { t.Helper() - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: {role: application, image: example/api, volumes: [{source: ./conf, path: /conf, mode: ro}]} -deployment: {order: [api]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: {role: Application, image: example/api, volumes: [{source: ./conf, path: /conf, mode: Ro}]} + deployment: {order: [api]} `), "ob.yml") if err != nil { t.Fatal(err) diff --git a/internal/onebox/workload_plan_test.go b/internal/onebox/workload_plan_test.go index 89fa5df3..e43f17ba 100644 --- a/internal/onebox/workload_plan_test.go +++ b/internal/onebox/workload_plan_test.go @@ -15,30 +15,33 @@ import ( func workloadPlanFixture(t *testing.T) (*app.Resolved, string, []OperationStep, engine.HostState) { t.Helper() - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: - role: application - image: ghcr.io/example/api:v1 - replicas: 2 - strategy: rolling - health: {http: /healthz, port: 8080} - worker: {role: worker, image: ghcr.io/example/worker:v1, strategy: recreate} - env-worker: {role: worker, image: ghcr.io/example/env:v1, strategy: recreate, env_files: [worker.env]} - secret-worker: {role: worker, image: ghcr.io/example/secret:v1, strategy: recreate, env_files: [{file: worker.enc.env, provider: sops}]} - bind-worker: - role: worker - image: ghcr.io/example/bind:v1 - strategy: recreate - volumes: [{source: ./payload, path: /payload, mode: ro}] - host-bind-worker: - role: worker - image: ghcr.io/example/host-bind:v1 - strategy: recreate - volumes: [{source: /data/sample, path: /data, mode: rw}] -deployment: {order: [api, worker, env-worker, secret-worker, bind-worker, host-bind-worker]} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: + role: Application + image: ghcr.io/example/api:v1 + replicas: 2 + strategy: Rolling + health: {http: /healthz, port: 8080} + worker: {role: Worker, image: 'ghcr.io/example/worker:v1', strategy: Recreate} + env-worker: {role: Worker, image: 'ghcr.io/example/env:v1', strategy: Recreate, envFiles: [worker.env]} + secret-worker: {role: Worker, image: 'ghcr.io/example/secret:v1', strategy: Recreate, envFiles: [{file: worker.enc.env, provider: Sops}]} + bind-worker: + role: Worker + image: ghcr.io/example/bind:v1 + strategy: Recreate + volumes: [{source: ./payload, path: /payload, mode: Ro}] + host-bind-worker: + role: Worker + image: ghcr.io/example/host-bind:v1 + strategy: Recreate + volumes: [{source: /data/sample, path: /data, mode: Rw}] + deployment: {order: [api, worker, env-worker, secret-worker, bind-worker, host-bind-worker]} `), "ob.yml") if err != nil { t.Fatal(err) @@ -163,17 +166,19 @@ 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 -app: sample -environments: {production: {server: deploy@example.test}} -workloads: - api: - role: application - image: ghcr.io/example/api@sha256:` + apiDigest + ` - strategy: rolling - health: {http: /healthz, port: 8080} - worker: {role: worker, image: ghcr.io/example/worker@sha256:` + workerDigest + `, strategy: recreate} -deployment: {order: [api, worker]} + return `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: {name: sample} +spec: + environments: {production: {server: deploy@example.test}} + workloads: + api: + role: Application + image: ghcr.io/example/api@sha256:` + apiDigest + ` + strategy: Rolling + health: {http: /healthz, port: 8080} + worker: {role: Worker, image: ghcr.io/example/worker@sha256:` + workerDigest + `, strategy: Recreate} + deployment: {order: [api, worker]} ` } dir := t.TempDir() diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index b728df02..476f837c 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -160,16 +160,19 @@ func TestManagedWildcardTLSRejectsIncompleteDNSConfiguration(t *testing.T) { } func TestManagedTLSRouterReferencesDefaultStaticResolver(t *testing.T) { - spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 -app: sample -environments: - production: {server: root@example.com} -workloads: - web: - role: application - image: nginx:1.27 - routes: - - {hostname: app.example.com, port: 80} + spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: sample +spec: + environments: + production: {server: root@example.com} + workloads: + web: + role: Application + image: nginx:1.27 + routes: + - {hostname: app.example.com, port: 80} `), "ob.yml") if err != nil { t.Fatal(err) diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json deleted file mode 100644 index 9a1182b1..00000000 --- a/site/public/onebox.run-v1.schema.json +++ /dev/null @@ -1,3119 +0,0 @@ -{ - "$id": "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.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/v1", - "description": "Project contract version. Must be onebox.run/v1.", - "examples": [ - "onebox.run/v1" - ], - "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/v1)", - "type": "object" -} diff --git a/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json new file mode 100644 index 00000000..14270a02 --- /dev/null +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -0,0 +1,2554 @@ +{ + "$id": "https://onebox.run/schemas/application/v1alpha1/application.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "One application, its workloads, the services it needs, and how a release rolls out.", + "properties": { + "apiVersion": { + "const": "onebox.run/v1alpha1", + "description": "Authored Application API identity.", + "type": "string" + }, + "kind": { + "const": "Application", + "description": "Authored resource kind.", + "type": "string" + }, + "metadata": { + "additionalProperties": false, + "description": "Application identity and opaque user metadata.", + "properties": { + "annotations": { + "additionalProperties": { + "type": "string" + }, + "description": "Opaque user metadata that never affects plans or runtime behavior.", + "type": "object" + }, + "name": { + "description": "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. Stable application name used in generated runtime identities.", + "examples": [ + "shop" + ], + "not": { + "anyOf": [ + { + "pattern": "^ob-" + }, + { + "const": "ob" + }, + { + "const": "onebox-proxy" + }, + { + "const": "_host" + } + ] + }, + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "spec": { + "additionalProperties": false, + "description": "Desired Onebox application configuration.", + "properties": { + "backupTargets": { + "additionalProperties": { + "additionalProperties": false, + "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.", + "properties": { + "accessKeyEntry": { + "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" + }, + "secretKeyEntry": { + "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" + }, + "sessionTokenEntry": { + "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.", + "properties": { + "cold": { + "description": "Encryption mode required for cold recovery: client-side or server-side.", + "enum": [ + "ClientSide", + "ServerSide" + ], + "type": "string" + }, + "pitr": { + "description": "Encryption mode required for point-in-time recovery: client-side or server-side.", + "enum": [ + "ClientSide", + "ServerSide" + ], + "type": "string" + }, + "snapshot": { + "description": "Encryption mode required for snapshot recovery: client-side or server-side.", + "enum": [ + "ClientSide", + "ServerSide" + ], + "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" + }, + "failureDomain": { + "additionalProperties": false, + "description": "Operator-declared identity used to prove the destination does not share the protected host.", + "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": [ + "S3Compatible" + ], + "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", + "SkipVerify" + ], + "type": "string" + } + }, + "type": "object" + }, + "description": "User-owned off-host repositories available to service backup policies.", + "type": "object" + }, + "basePath": { + "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" + }, + "checks": { + "additionalProperties": false, + "description": "Assertions that must pass before a release becomes current unless marked advisory.", + "properties": { + "exec": { + "description": "Commands run inside a named workload.", + "items": { + "additionalProperties": false, + "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, + "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, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "appliedRevisions": { + "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, + "properties": { + "advisory": { + "default": false, + "description": "Report a failure without blocking release activation.", + "type": "boolean" + }, + "contains": { + "description": "Text the response body must contain.", + "type": "string" + }, + "jsonAssertions": { + "description": "Scalar JSON response values that must match exactly.", + "items": { + "additionalProperties": false, + "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" + }, + "requiredHeaders": { + "additionalProperties": { + "type": "string" + }, + "description": "Exact response headers required for success.", + "type": "object" + }, + "statusCodes": { + "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" + }, + "deployment": { + "additionalProperties": false, + "description": "Release ordering, retention, and migration behavior.", + "properties": { + "migrationPolicy": { + "default": "Manual", + "description": "Policy for migration jobs during release and recovery.", + "enum": [ + "Manual", + "Auto", + "ExpandOnly" + ], + "type": "string" + }, + "order": { + "description": "Explicit workload release order. Dependency order is derived when omitted.", + "items": { + "type": "string" + }, + "type": "array" + }, + "retainReleases": { + "default": 5, + "description": "Number of completed release directories retained for inspection and rollback.", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "environments": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "basePath": { + "description": "Environment-specific replacement for the Application basePath. Expects an absolute path with no control character or shell metacharacter.", + "examples": [ + "/srv/ob" + ], + "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$", + "type": "string" + }, + "envFiles": { + "description": "Default ordered environment-file list for application, worker, and job workloads in this environment.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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" + ], + "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.", + "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, envFiles, strategy, and routes.", + "type": "object" + } + }, + "type": "object" + }, + "policy": { + "additionalProperties": false, + "description": "Approval, runner compatibility, and migration-backup requirements for this environment.", + "properties": { + "allowAgentProposals": { + "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.", + "properties": { + "backupKeyMaterial": { + "description": "Key-material identities the backup report must name.", + "examples": [ + [ + "BACKUP_ACCESS_KEY_ID" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "backupMaxAge": { + "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" + }, + "requireBackup": { + "default": false, + "description": "Require a plan-bound backup report before a release with migration risk.", + "type": "boolean" + }, + "requireRestoreTest": { + "default": false, + "description": "Require the backup report to state that a restore test succeeded.", + "type": "boolean" + } + }, + "type": "object" + }, + "minOneboxVersion": { + "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" + }, + "minPlanSchema": { + "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" + }, + "requireApproval": { + "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" + ], + "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" + }, + "externalServices": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "backupOwner": { + "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.", + "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.", + "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.", + "properties": { + "kind": { + "default": "DriverHealth", + "description": "Read-only observation kind: driver-health.", + "enum": [ + "DriverHealth" + ], + "type": "string" + }, + "maxAge": { + "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" + }, + "hooks": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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" + }, + "notifications": { + "additionalProperties": { + "additionalProperties": false, + "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" + }, + "proxy": { + "additionalProperties": false, + "description": "Ownership and configuration of the host ingress proxy.", + "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" + }, + "dnsChallenge": { + "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.", + "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, + "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": "TraefikDocker", + "description": "Proxy implementation, or none to disable routing.", + "enum": [ + "TraefikDocker", + "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, + "properties": { + "passwordEnv": { + "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" + }, + "runtime": { + "additionalProperties": false, + "description": "Project-wide environment files and local environment-file requirements.", + "properties": { + "envChecks": { + "description": "Local environment-file assertions checked before planning or deploying.", + "items": { + "additionalProperties": false, + "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" + }, + "envFiles": { + "description": "Project-wide ordered environment-file list for application, worker, and job workloads.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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, + "properties": { + "backup": { + "additionalProperties": false, + "description": "Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup.", + "properties": { + "allowDowntime": { + "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.", + "properties": { + "maxAge": { + "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.", + "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" + }, + "maxDataLoss": { + "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" + }, + "recoveryKind": { + "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.", + "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.", + "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.", + "properties": { + "extensions": { + "additionalProperties": { + "additionalProperties": false, + "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.", + "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.", + "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": { + "dataEffect": { + "const": "None" + }, + "deploymentPhase": { + "const": "None" + }, + "operatorRun": { + "const": "Allowed" + } + }, + "required": [ + "schedule", + "dataEffect" + ] + } + }, + { + "oneOf": [ + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + }, + { + "required": [ + "compose" + ] + } + ] + }, + { + "not": { + "allOf": [ + { + "required": [ + "publishedPorts" + ] + }, + { + "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": [ + "deploymentPhase" + ] + }, + { + "required": [ + "operatorRun" + ] + }, + { + "required": [ + "dataEffect" + ] + }, + { + "required": [ + "schedule" + ] + }, + { + "required": [ + "inputs" + ] + }, + { + "required": [ + "execution" + ] + } + ] + } + }, + "if": { + "properties": { + "role": { + "const": "Job" + } + }, + "required": [ + "role" + ] + }, + "then": { + "required": [ + "dataEffect" + ] + } + } + ], + "properties": { + "build": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Build metadata for development. Production requires a resolved image supplied with --image.", + "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" + }, + "dataEffect": { + "description": "Job data impact used by rollback and abort gates.", + "enum": [ + "None", + "Migration", + "Destructive", + "Unknown" + ], + "examples": [ + "migration" + ], + "type": "string" + }, + "deploymentPhase": { + "default": "None", + "description": "Deployment phase for this job: none, pre_release, or post_release.", + "enum": [ + "None", + "PreRelease", + "PostRelease" + ], + "type": "string" + }, + "drain": { + "additionalProperties": false, + "description": "Signal and timing used to remove a container from traffic before stopping it.", + "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" + }, + "envFiles": { + "description": "Workload-specific ordered environment-file list. Replaces broader defaults when present.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "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 dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume.", + "properties": { + "retention": { + "default": "168h", + "description": "Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "steps": { + "description": "Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps.", + "items": { + "additionalProperties": false, + "properties": { + "command": { + "description": "Argument vector passed to the job image's entrypoint. No shell evaluation is performed.", + "items": { + "type": "string" + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + }, + "id": { + "description": "Unique stable step identifier, used by output references. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.", + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$", + "type": "string" + }, + "inputs": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables populated from a preceding step's declared output, written as step.OUTPUT.", + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "type": "object" + }, + "outputs": { + "description": "Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total.", + "items": { + "description": "Expects upper-case letters, digits and underscores, starting with a letter.", + "pattern": "^[A-Z][A-Z0-9_]*$", + "type": "string" + }, + "maxItems": 32, + "type": "array", + "uniqueItems": true + }, + "retry": { + "additionalProperties": false, + "description": "Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout.", + "properties": { + "attempts": { + "default": 1, + "description": "Total attempts including the first, 1 to 10.", + "examples": [ + 3 + ], + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "backoff": { + "default": "30s", + "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "1m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "maxBackoff": { + "default": "10m", + "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "30m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "id", + "command" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + } + }, + "type": "object" + }, + "extraHosts": { + "description": "Additional host-to-address entries added to the container.", + "items": { + "type": "string" + }, + "type": "array" + }, + "health": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "description": "Readiness check used to gate rolling replacement.", + "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" + }, + "startPeriod": { + "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" + ], + "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" + ] + } + ], + "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.", + "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, + "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" + }, + "operatorRun": { + "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.", + "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" + }, + "publishedPorts": { + "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, + "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.", + "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" + ] + } + } + ], + "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.", + "properties": { + "catchUp": { + "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" + }, + "deployLock": { + "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.", + "properties": { + "attempts": { + "default": 1, + "description": "Total attempts including the first, 1 to 10.", + "examples": [ + 3 + ], + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "backoff": { + "default": "30s", + "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "1m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + }, + "maxBackoff": { + "default": "10m", + "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.", + "examples": [ + "30m" + ], + "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$", + "type": "string" + } + }, + "type": "object" + }, + "shutdownGrace": { + "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" + }, + "stdinOpen": { + "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" + ] + } + ], + "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" + }, + "workingDir": { + "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.", + "minProperties": 1, + "type": "object" + } + }, + "required": [ + "environments", + "workloads" + ], + "type": "object" + } + }, + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "title": "Onebox Application (onebox.run/v1alpha1)", + "type": "object" +} diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index 573c53b1..37b5ea8d 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -219,7 +219,7 @@ const render = () => { const lines: string[] = [ - `api_version: onebox.run/v1`, + `apiVersion: onebox.run/v1alpha1`, `app: shop`, `environments:`, ` production:`, diff --git a/site/src/content/docs/explanation/evidence-not-declaration.mdx b/site/src/content/docs/explanation/evidence-not-declaration.mdx index a6394bab..f738b424 100644 --- a/site/src/content/docs/explanation/evidence-not-declaration.mdx +++ b/site/src/content/docs/explanation/evidence-not-declaration.mdx @@ -76,7 +76,7 @@ check process has no converge methods in its operation graph at all. `# shorthand`, `# override`. The same idea appears in structured output, which labels authored, default, environment-override, observed and derived values. -Knowing that `retain_releases: 5` was Onebox's choice rather than yours is the +Knowing that `retainReleases: 5` was Onebox's choice rather than yours is the difference between reviewing a configuration and reading one. ## Drift fails closed diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx index 69ed9305..df93dd86 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -85,7 +85,7 @@ A container Onebox cannot describe is adopted verbatim: ```yaml workloads: legacy: - role: daemon + role: Daemon compose: docker-compose.yml#legacy ``` diff --git a/site/src/content/docs/explanation/what-onebox-refuses.mdx b/site/src/content/docs/explanation/what-onebox-refuses.mdx index 1224927c..14191544 100644 --- a/site/src/content/docs/explanation/what-onebox-refuses.mdx +++ b/site/src/content/docs/explanation/what-onebox-refuses.mdx @@ -48,7 +48,7 @@ one replica. **`override_not_permitted`** — an environment override touching anything outside the permitted set. An environment may override exactly `replicas`, `resources`, -`env`, `env_files`, `strategy` and `routes`; everything else, `image` and +`env`, `envFiles`, `strategy` and `routes`; everything else, `image` and `command` and `volumes` among them, is refused. It is an allow-list rather than a deny-list because the question is not "which fields are dangerous" but "which fields can differ while this is still the same application" — a staging @@ -61,7 +61,7 @@ mechanism to read it. Silently ignoring it would leave you believing it applied. **A rolling workload publishing a host port** — two replicas cannot both hold `8080` on the host, so the roll would either fail halfway or quietly become a recreate. Either give the workload `strategy: recreate`, or drop -`published_ports` and route to it through the proxy. This one arrives as a plain +`publishedPorts` and route to it through the proxy. This one arrives as a plain `project_invalid` today rather than a dedicated code. **`host_owner_mismatch`** — a second application trying to mutate a host that diff --git a/site/src/content/docs/guides/add-a-database.mdx b/site/src/content/docs/guides/add-a-database.mdx index 787f27c2..fc86ff3d 100644 --- a/site/src/content/docs/guides/add-a-database.mdx +++ b/site/src/content/docs/guides/add-a-database.mdx @@ -128,7 +128,7 @@ A workload that declares `needs` receives `_URL` and its parts: ```yaml workloads: web: - role: application + role: Application image: ghcr.io/acme/shop:1.4.0 needs: - {name: postgres, condition: healthy} @@ -142,7 +142,7 @@ services: ```yaml workloads: n8n: - role: application + role: Application image: docker.n8n.io/n8nio/n8n:1.70.0 needs: - name: postgres diff --git a/site/src/content/docs/guides/adopt-compose.mdx b/site/src/content/docs/guides/adopt-compose.mdx index 94efb68a..5641e7fb 100644 --- a/site/src/content/docs/guides/adopt-compose.mdx +++ b/site/src/content/docs/guides/adopt-compose.mdx @@ -28,7 +28,7 @@ Some containers cannot be described by the project schema. Adopt them verbatim: ```yaml workloads: legacy: - role: daemon + role: Daemon compose: docker-compose.yml#legacy ``` @@ -63,7 +63,7 @@ release behaviour. The one difference is precedence *inside* the container: the referenced Compose service's own `env_file` sits at the bottom of the stack, below the resolved -`env_files` entries. See [Handle secrets](/guides/handle-secrets). +`envFiles` entries. See [Handle secrets](/guides/handle-secrets). ## Migrating away from adoption diff --git a/site/src/content/docs/guides/back-up-a-database.mdx b/site/src/content/docs/guides/back-up-a-database.mdx index ba7dc3cb..75746a80 100644 --- a/site/src/content/docs/guides/back-up-a-database.mdx +++ b/site/src/content/docs/guides/back-up-a-database.mdx @@ -24,7 +24,7 @@ services: drill: schedule: {cron: "0 4 * * *", timezone: UTC} -backup_targets: +backupTargets: offsite: kind: s3-compatible endpoint: https://objects.example.net @@ -278,4 +278,4 @@ one it cannot honour. A workload's own volume is never copied anywhere — `ob doctor` names every workload holding durable data. Full field list: [`services`](/reference/fields/services) and -[`backup_targets`](/reference/fields/backup_targets). +[`backupTargets`](/reference/fields/backup-targets). diff --git a/site/src/content/docs/guides/environment-variables.mdx b/site/src/content/docs/guides/environment-variables.mdx index b0ed70ab..3131b6a2 100644 --- a/site/src/content/docs/guides/environment-variables.mdx +++ b/site/src/content/docs/guides/environment-variables.mdx @@ -112,7 +112,7 @@ no aliases, no fallback reads, and no deprecation window: Update your hook scripts, CI and release settings, and any job writing to the result file. The executable is still `ob`, the project file is still `ob.yml`, -and the API version is still `onebox.run/v1` — only the environment namespace +and the API version is `onebox.run/v1alpha1` — only the environment namespace changed. A stray old name does not fall back: it is simply never read, so a hook reading diff --git a/site/src/content/docs/guides/handle-secrets.mdx b/site/src/content/docs/guides/handle-secrets.mdx index d3100958..56c45721 100644 --- a/site/src/content/docs/guides/handle-secrets.mdx +++ b/site/src/content/docs/guides/handle-secrets.mdx @@ -14,10 +14,10 @@ One field carries environment values, at four scopes. ```yaml runtime: - env_files: + envFiles: - .env # plaintext - {file: .env.production} # the same thing, object form - - {file: secrets/prod.env, provider: sops} # encrypted at rest + - {file: secrets/prod.env, provider: Sops} # encrypted at rest ``` Whether an entry is encrypted is a property of the **entry**, not of the workload @@ -30,10 +30,10 @@ than extend. | | Scope | Declared at | | --- | --- | --- | -| 1st | most specific | `environments..overrides.workloads..env_files` | -| 2nd | workload | `workloads..env_files` | -| 3rd | environment | `environments..env_files` | -| 4th | project | `runtime.env_files` | +| 1st | most specific | `environments..overrides.workloads..envFiles` | +| 2nd | workload | `workloads..envFiles` | +| 3rd | environment | `environments..envFiles` | +| 4th | project | `runtime.envFiles` | A workload wanting the project's files *plus* its own restates both. Within the resolved list, entries apply in the order written, a later one overriding an @@ -46,13 +46,13 @@ declaring different lists: environments: production: server: root@203.0.113.10 - env_files: [.env, {file: secrets/production.env, provider: sops}] + envFiles: [.env, {file: secrets/production.env, provider: Sops}] staging: server: root@203.0.113.20 - env_files: [.env, {file: secrets/staging.env, provider: sops}] + envFiles: [.env, {file: secrets/staging.env, provider: Sops}] ``` -`env_files: []` means the workload receives nothing, which is different from +`envFiles: []` means the workload receives nothing, which is different from declaring no list at all. `ob canonical` shows which you wrote, at every scope. ## Rule 2 — what wins inside the container? @@ -60,7 +60,7 @@ declaring no list at all. `ob canonical` shows which you wrote, at every scope. Lowest precedence first: 1. the referenced Compose service's own `env_file`, for a `compose:` workload -2. the resolved `env_files` entries, in order +2. the resolved `envFiles` entries, in order 3. managed-service connection files 4. the service's `environment` — your inline `env`, or the referenced service's diff --git a/site/src/content/docs/guides/roll-back.mdx b/site/src/content/docs/guides/roll-back.mdx index 33671f81..373df66d 100644 --- a/site/src/content/docs/guides/roll-back.mdx +++ b/site/src/content/docs/guides/roll-back.mdx @@ -37,7 +37,7 @@ ob rollback Every release has a strict manifest state and an explicit predecessor. Rollback activates the current serving release's eligible predecessor, verifies it, and records the transition; it does not select a directory by timestamp or name. -`deployment.retain_releases` (default `5`) bounds ordinary history while always +`deployment.retainReleases` (default `5`) bounds ordinary history while always protecting current, predecessor-chain, and checkpoint-referenced releases. Rollback reconciles each workload against the predecessor's immutable runtime @@ -64,7 +64,7 @@ Concretely: project and outlives every release, so no deploy and no rollback stops it or removes its volume. That protects your data; it also means a schema migration is still applied after you roll the application back. -- **A job with `data_effect: migration` or `destructive` constrains the gate.** +- **A job with `dataEffect: Migration` or `destructive` constrains the gate.** That is what the field is for. - **A job whose result evidence is missing becomes `changed=unknown`** and halts before workload replacement rather than proceeding on an assumption. diff --git a/site/src/content/docs/guides/run-migrations.mdx b/site/src/content/docs/guides/run-migrations.mdx index a9baa904..d15c2b2d 100644 --- a/site/src/content/docs/guides/run-migrations.mdx +++ b/site/src/content/docs/guides/run-migrations.mdx @@ -1,7 +1,7 @@ --- title: Run migrations safely description: Migration jobs, data effects, result evidence, and the backup-report gate. -summary: How to declare a migration job, what data_effect controls, how job result evidence is captured and verified, and how the migration-backup policy gate works. +summary: How to declare a migration job, what dataEffect controls, how job result evidence is captured and verified, and how the migration-backup policy gate works. sidebar: order: 3 read_when: @@ -15,15 +15,15 @@ read_when: ```yaml workloads: migrate: - role: job + role: Job image: ghcr.io/acme/shop:1.4.0 command: ["./bin/migrate"] - data_effect: migration - deployment_phase: pre_release + dataEffect: Migration + deploymentPhase: PreRelease needs: [{name: postgres, condition: healthy}] ``` -`data_effect` is **required** on a job, and it is what the rollback and abort +`dataEffect` is **required** on a job, and it is what the rollback and abort gates read: | Value | Meaning | @@ -33,8 +33,8 @@ gates read: | `destructive` | Removes data. | | `unknown` | You cannot state it. Treated as the most cautious case. | -`deployment_phase` decides whether it runs during deployment: `none` (default), -`pre_release`, or `post_release`. `operator_run` independently controls explicit +`deploymentPhase` decides whether it runs during deployment: `none` (default), +`pre_release`, or `post_release`. `operatorRun` independently controls explicit invocation (`allowed` or `disabled`); release-phase jobs default to `disabled`. ## Report what actually happened @@ -158,14 +158,14 @@ not approval policy is enabled. ```yaml deployment: - migration_policy: manual # manual · auto · expand-only + migrationPolicy: Manual # manual · auto · expand-only ``` `expand-only` is your promise, not a check Onebox performs. It declares that every migration in this project is additive — that a release running the old code against the new schema still works. Onebox never inspects your SQL; it takes the declaration and, on the strength of it, keeps the rollback gate -**open** after a `data_effect: migration` job runs, so a failed verify can roll +**open** after a `dataEffect: Migration` job runs, so a failed verify can roll back automatically instead of halting. That makes it the right setting for a genuinely expand-only workflow and the diff --git a/site/src/content/docs/guides/schedule-a-job.mdx b/site/src/content/docs/guides/schedule-a-job.mdx index 72677ff0..aedfdba0 100644 --- a/site/src/content/docs/guides/schedule-a-job.mdx +++ b/site/src/content/docs/guides/schedule-a-job.mdx @@ -17,17 +17,17 @@ read_when: ```yaml workloads: nightly-dump: - role: job + role: Job image: postgres:17 command: ["sh", "-c", "pg_dump \"$POSTGRES_URL\" | gzip > /backups/$(date -u +%F).sql.gz"] - data_effect: none + dataEffect: None needs: [postgres] volumes: [{name: backups, path: /backups}] schedule: cron: "0 2 * * *" timezone: Europe/Berlin timeout: 45m - catch_up: true + catchUp: true ``` ## It becomes a host timer @@ -44,7 +44,7 @@ Each run is a systemd oneshot with a wall-time limit. `timeout` defaults to `1h`; when it expires, systemd terminates the run and records `timeout` as its result. Set a longer duration for jobs that legitimately need it. -`catch_up` defaults to `true`: if the host was off at the scheduled time, the +`catchUp` defaults to `true`: if the host was off at the scheduled time, the timer runs once after it returns. Set it to `false` for time-sensitive work that should be skipped rather than run late. @@ -70,8 +70,8 @@ attempt runs inside the same timer firing, under the same locks and the same Validation refuses a retry whose worst-case backoff is not smaller than the timeout, because the last attempt could never start and the record would say -it did. With `deploy_lock: exclusive` the deploy lock is held through the -sleeps, so a deploy waits for the run to finish; with `deploy_lock: pinned` the +it did. With `deployLock: Exclusive` the deploy lock is held through the +sleeps, so a deploy waits for the run to finish; with `deployLock: Pinned` the release lease is held instead and every attempt runs the same release. ## Deployment coordination is exclusive by default @@ -97,11 +97,11 @@ For a long-running job that does not change shared data, opt into a pinned release instead: ```yaml -data_effect: none +dataEffect: None schedule: cron: "0 2 * * *" timeout: 6h - deploy_lock: pinned + deployLock: Pinned ``` The runner briefly meets deployment under the same scheduling mutex, resolves @@ -115,8 +115,8 @@ cleanup preserves the leased Compose document, environment files, and release-bound mounts until the job exits. `ob status` reports the policy, the pinned release, start time, and timeout while it runs. -This is deliberately fail-closed. `deploy_lock: pinned` is accepted only for a -Onebox-rendered job declaring `data_effect: none`. Migration, destructive, +This is deliberately fail-closed. `deployLock: Pinned` is accepted only for a +Onebox-rendered job declaring `dataEffect: None`. Migration, destructive, unknown-effect, and adopted-Compose jobs remain exclusive because pinning their files cannot prove that a concurrent deployment is safe for the data or external files they use. Destroy and every other non-deploy application @@ -235,7 +235,7 @@ cron minutes into its default one-minute wake-up window. ## Jobs still declare a data effect -`data_effect` is required on every job, scheduled or not. A nightly report is +`dataEffect` is required on every job, scheduled or not. A nightly report is `none`; a nightly prune is `destructive`. The rollback and abort gates read it, and a job that lies about it defeats them. @@ -294,10 +294,10 @@ the job now and override them. ```yaml workloads: source-sync: - role: job + role: Job image: ghcr.io/acme/ingest:1.8.2 command: ["./ingest", "sync"] - data_effect: none + dataEffect: None inputs: SOURCE: enum: [catalog, prices, reviews] @@ -309,7 +309,7 @@ workloads: description: Only records changed since this date. Empty means the stored cursor. schedule: cron: "0 * * * *" - deploy_lock: pinned + deployLock: Pinned ``` ```sh @@ -332,7 +332,7 @@ operation id, so `ob audit` and `ob job history` join on it. The command follows the host-supervised unit by default; `--detach` returns once the unit accepts the run. -Only a job with `data_effect: none` accepts inputs. Every operator invocation +Only a job with `dataEffect: None` accepts inputs. Every operator invocation uses a sealed plan; migration jobs additionally retain their approval and backup-report gates and remain attached so Onebox can capture result evidence. @@ -356,8 +356,8 @@ observe what started it and will not guess. Opt into durable execution when a failed job needs to continue with its original inputs. Onebox saves orchestration state on the managed host; the application keeps responsibility for domain checkpoints and idempotent effects. This requires -a native Onebox job with `schedule`, `deployment_phase: none` (the default), and -`data_effect: none`. Adopted Compose jobs and release-phase jobs are refused. +a native Onebox job with `schedule`, `deploymentPhase: None` (the default), and +`dataEffect: None`. Adopted Compose jobs and release-phase jobs are refused. For one command, add `execution: {retention: 168h}` to the job. Onebox treats the job's existing command as one step with ID `main`. An ingestion job that already @@ -368,9 +368,9 @@ For several ordered commands, declare steps: ```yaml workloads: refresh: - role: job + role: Job image: ghcr.io/acme/catalog:1.8.2 - data_effect: none + dataEffect: None schedule: cron: "0 * * * *" timeout: 45m @@ -533,13 +533,13 @@ with `ob job history ` and `ob job logs `. Jobs without `schedule` keep the direct foreground path. Migration jobs also stay attached because their result evidence is part of the approved operation. -`ob job plan` accepts a job when `operator_run` is `allowed`. This property is -independent of deployment: `deployment_phase` decides whether the job runs in -the deploy graph, while `operator_run` decides whether an operator may invoke +`ob job plan` accepts a job when `operatorRun` is `allowed`. This property is +independent of deployment: `deploymentPhase` decides whether the job runs in +the deploy graph, while `operatorRun` decides whether an operator may invoke it. By default, phase `none` allows operator runs; release-phase jobs disable them unless explicitly enabled. -A declared `deployment_phase: none` job remains in the digest-pinned release runtime but +A declared `deploymentPhase: None` job remains in the digest-pinned release runtime but never joins the deploy graph. Its job plan binds the current serving release, runtime digest, immutable image, data effect, target, and expiry. Automation supplies the saved plan and its separately recorded local confirmation; @@ -561,15 +561,15 @@ The reason is durable metadata, so do not put a secret in it. For a job that participates automatically in a deploy, choose a release phase: ```yaml -deployment_phase: pre_release -operator_run: allowed # optional: also permit ob job plan/run +deploymentPhase: PreRelease +operatorRun: allowed # optional: also permit ob job plan/run ``` -The three properties are independent. `deployment_phase` controls deploy hooks, -`schedule` controls timer activation, and `operator_run` controls explicit -invocation. A job declaring `schedule:` and `deployment_phase: pre_release` +The three properties are independent. `deploymentPhase` controls deploy hooks, +`schedule` controls timer activation, and `operatorRun` controls explicit +invocation. A job declaring `schedule:` and `deploymentPhase: PreRelease` runs both at its cron time and on every deploy. For a timer-only job, leave -`deployment_phase` at `none`; set `operator_run: disabled` too if operators +`deploymentPhase` at `none`; set `operatorRun: Disabled` too if operators must not start it explicitly. See [`workloads`](/reference/fields/workloads) for every field. diff --git a/site/src/content/docs/reference/cli.mdx b/site/src/content/docs/reference/cli.mdx index dbedff09..c06111f4 100644 --- a/site/src/content/docs/reference/cli.mdx +++ b/site/src/content/docs/reference/cli.mdx @@ -168,7 +168,7 @@ Global Flags: Backup and recovery for the data services this project declares. Backup is physical: a base backup plus continuous WAL archiving to the -off-host repository the project's backup_targets name, which is what makes +off-host repository the project's backupTargets name, which is what makes recovery to a point in time possible rather than recovery to last night. Declaring a policy does not establish it. `ob backup enable` restarts the @@ -749,10 +749,10 @@ Global Flags: ## ob job ``` -Plan and run one declared `operator_run: allowed` job against the current serving release. +Plan and run one declared `operatorRun: Allowed` job against the current serving release. -Deployment participation is independent: `deployment_phase` may be none, pre_release, -or post_release. Saved plans bind the release, runtime digest, immutable image, +Deployment participation is independent: `deploymentPhase` may be None, PreRelease, +or PostRelease. Saved plans bind the release, runtime digest, immutable image, data effect and inputs so agents can obtain separate approval before execution. Usage: @@ -953,7 +953,7 @@ Global Flags: ## ob preview ``` -Load an onebox.run/v1 project, resolve the environment's overrides, and print +Load a onebox.run/v1alpha1 Application, resolve the environment's overrides, and print the Compose runtime Onebox would generate, with its content digest. Nothing is contacted and nothing is written. Environment values are redacted: @@ -1190,12 +1190,12 @@ Global Flags: ## ob schema ``` -Write the JSON Schema for the `onebox.run/v1` project file. +Write the JSON Schema for the `onebox.run/v1alpha1` Application resource. Reference it from the first line of a project so an editor can offer completion, hover documentation and inline errors: - # yaml-language-server: $schema=https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.schema.json + # yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha1/application.schema.json Or keep a copy in the repository with --out, which is what an editor needs when the machine is offline. diff --git a/site/src/content/docs/reference/errors.mdx b/site/src/content/docs/reference/errors.mdx index e390c89b..ddce0185 100644 --- a/site/src/content/docs/reference/errors.mdx +++ b/site/src/content/docs/reference/errors.mdx @@ -28,7 +28,7 @@ command. | Code | Means | | --- | --- | -| `app_required` | the shorthand form needs an application name to attach the workload to | +| `app_required` | metadata.name is required | | `backup_credentials_invalid` | decrypted backup credentials are missing or malformed | | `backup_driver_unsupported` | a runnable service driver has no qualified executable backup contract | | `backup_encryption_unverified` | the selected target cannot prove the encryption mode required by the recovery kind | @@ -83,15 +83,14 @@ command. | `route_collision` | two workloads claim the same address | | `route_without_proxy` | a route is declared with nothing to route it | | `schedule_untranslatable` | a cron expression whose meaning the host's scheduler cannot preserve | -| `schema_identity_missing` | the project declares no api_version | -| `schema_identity_unsupported` | the project declares an api_version this binary does not speak | -| `secrets_withdrawn` | the withdrawn secrets block; environment files carry encrypted entries now | +| `schema_identity_missing` | the Application declares no apiVersion | +| `schema_identity_unsupported` | the Application declares an apiVersion this binary does not speak | +| `schema_kind_unsupported` | the authored resource is not an Application | | `server_unreachable` | the server could not be reached | | `service_image_digest_unavailable` | the immutable service image is unavailable from registry and exact cache | | `service_image_patch_disable_pending` | protected image refresh is refused while disablement is pending | | `service_patch_unsupported` | no exact qualified protected current-to-candidate image transition exists | | `service_settings_unsupported` | a setting was declared for a driver with no way to apply it | -| `shorthand_and_workloads` | top-level shorthand cannot be combined with a workloads block | | `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 | | `unknown_environment` | an environment the project does not declare | diff --git a/site/src/content/docs/reference/fields/backup-targets.mdx b/site/src/content/docs/reference/fields/backup-targets.mdx new file mode 100644 index 00000000..f85a4a6a --- /dev/null +++ b/site/src/content/docs/reference/fields/backup-targets.mdx @@ -0,0 +1,45 @@ +--- +title: "backupTargets" +description: "User-owned off-host S3-compatible repositories a protected service writes its backups to. Executable for the postgres driver; every other driver refuses a policy rather than accepting one it cannot honour." +summary: "User-owned off-host S3-compatible repositories a protected service writes its backups to. Executable for the postgres driver; every other driver refuses a policy rather than accepting one it cannot honour." +status: shipped +generated: true +sidebar: + order: 200 +read_when: + - "Declaring where a database's backups go" + - "Understanding why Onebox refuses a backup target that shares the protected host" +--- + +{/* Generated by cmd/ob-docgen. Do not edit by hand. */} + +This page is generated from the same Go declarations the loader enforces, so it +cannot drift from what `ob validate` accepts. + +## Fields on this page + +`accessKeyEntry` · `bucket` · `cold` · `credentials` · `encryption` · `endpoint` · `failureDomain` · `file` · `host` · `identity` · `kind` · `pitr` · `prefix` · `provider` · `region` · `secretKeyEntry` · `sessionTokenEntry` · `snapshot` · `tls` + +## Reference + +| Field | Type | Default | What it does | +| --- | --- | --- | --- | +| `.bucket` | string | — | Existing destination bucket used by this target. Expects a lower-case S3-compatible bucket name between 3 and 63 characters. | +| `.credentials` | object | — | Trusted encrypted-file entries containing destination credentials; values never appear in the project. | +| `.credentials.accessKeyEntry` | string | — | Variable name containing the destination access key. Expects a variable name of letters, digits and underscores, not starting with a digit. | +| `.credentials.file` | string | — | Repository-relative encrypted credential file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter. | +| `.credentials.provider` | `Sops` | `Sops` | Trusted secret provider. Only sops is currently executable. | +| `.credentials.secretKeyEntry` | string | — | Variable name containing the destination secret key. Expects a variable name of letters, digits and underscores, not starting with a digit. | +| `.credentials.sessionTokenEntry` | string | — | Optional variable name containing a temporary destination session token. Expects a variable name of letters, digits and underscores, not starting with a digit. | +| `.encryption` | object | — | Required encryption mode for each recovery kind this target may store. | +| `.encryption.cold` | `ClientSide` · `ServerSide` | — | Encryption mode required for cold recovery: client-side or server-side. | +| `.encryption.pitr` | `ClientSide` · `ServerSide` | — | Encryption mode required for point-in-time recovery: client-side or server-side. | +| `.encryption.snapshot` | `ClientSide` · `ServerSide` | — | Encryption mode required for snapshot recovery: client-side or server-side. | +| `.endpoint` | string | — | Destination API endpoint. HTTPS is required unless tls is explicitly insecure. Expects an http or https URL. | +| `.failureDomain` | object | — | Operator-declared identity used to prove the destination does not share the protected host. | +| `.failureDomain.host` | string | — | 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. | +| `.failureDomain.identity` | string | — | Stable operator-owned failure-domain identity, distinct from the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens. | +| `.kind` | `S3Compatible` | — | Destination kind. Only s3-compatible is supported. | +| `.prefix` | string | — | Non-secret object prefix reserved for Onebox backup data. Expects a relative object prefix with no empty leading component or shell metacharacter. | +| `.region` | string | — | S3-compatible region when the endpoint requires one. Expects a lower-case S3-compatible region of letters, digits and hyphens. | +| `.tls` | `Verify` · `SkipVerify` | `Verify` | Transport policy: verify, or skip-verify to accept a plaintext http endpoint. | diff --git a/site/src/content/docs/reference/fields/backup_targets.mdx b/site/src/content/docs/reference/fields/backup_targets.mdx deleted file mode 100644 index 3fc76e71..00000000 --- a/site/src/content/docs/reference/fields/backup_targets.mdx +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "backup_targets" -description: "User-owned off-host S3-compatible repositories a protected service writes its backups to. Executable for the postgres driver; every other driver refuses a policy rather than accepting one it cannot honour." -summary: "User-owned off-host S3-compatible repositories a protected service writes its backups to. Executable for the postgres driver; every other driver refuses a policy rather than accepting one it cannot honour." -status: shipped -generated: true -sidebar: - order: 200 -read_when: - - "Declaring where a database's backups go" - - "Understanding why Onebox refuses a backup target that shares the protected host" ---- - -{/* Generated by cmd/ob-docgen. Do not edit by hand. */} - -This page is generated from the same Go declarations the loader enforces, so it -cannot drift from what `ob validate` accepts. - -## Fields on this page - -`access_key_entry` · `bucket` · `cold` · `credentials` · `encryption` · `endpoint` · `failure_domain` · `file` · `host` · `identity` · `kind` · `pitr` · `prefix` · `provider` · `region` · `secret_key_entry` · `session_token_entry` · `snapshot` · `tls` - -## Reference - -| Field | Type | Default | What it does | -| --- | --- | --- | --- | -| `.bucket` | string | — | Existing destination bucket used by this target. Expects a lower-case S3-compatible bucket name between 3 and 63 characters. | -| `.credentials` | object | — | Trusted encrypted-file entries containing destination credentials; values never appear in the project. | -| `.credentials.access_key_entry` | string | — | Variable name containing the destination access key. Expects a variable name of letters, digits and underscores, not starting with a digit. | -| `.credentials.file` | string | — | Repository-relative encrypted credential file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter. | -| `.credentials.provider` | `sops` | `sops` | Trusted secret provider. Only sops is currently executable. | -| `.credentials.secret_key_entry` | string | — | Variable name containing the destination secret key. Expects a variable name of letters, digits and underscores, not starting with a digit. | -| `.credentials.session_token_entry` | string | — | Optional variable name containing a temporary destination session token. Expects a variable name of letters, digits and underscores, not starting with a digit. | -| `.encryption` | object | — | Required encryption mode for each recovery kind this target may store. | -| `.encryption.cold` | `client-side` · `server-side` | — | Encryption mode required for cold recovery: client-side or server-side. | -| `.encryption.pitr` | `client-side` · `server-side` | — | Encryption mode required for point-in-time recovery: client-side or server-side. | -| `.encryption.snapshot` | `client-side` · `server-side` | — | Encryption mode required for snapshot recovery: client-side or server-side. | -| `.endpoint` | string | — | Destination API endpoint. HTTPS is required unless tls is explicitly insecure. Expects an http or https URL. | -| `.failure_domain` | object | — | Operator-declared identity used to prove the destination does not share the protected host. | -| `.failure_domain.host` | string | — | 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. | -| `.failure_domain.identity` | string | — | Stable operator-owned failure-domain identity, distinct from the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens. | -| `.kind` | `s3-compatible` | — | Destination kind. Only s3-compatible is supported. | -| `.prefix` | string | — | Non-secret object prefix reserved for Onebox backup data. Expects a relative object prefix with no empty leading component or shell metacharacter. | -| `.region` | string | — | S3-compatible region when the endpoint requires one. Expects a lower-case S3-compatible region of letters, digits and hyphens. | -| `.tls` | `verify` · `skip-verify` | `verify` | Transport policy: verify, or skip-verify to accept a plaintext http endpoint. | diff --git a/site/src/content/docs/reference/fields/checks.mdx b/site/src/content/docs/reference/fields/checks.mdx index 1a7f6bf2..a2025bc1 100644 --- a/site/src/content/docs/reference/fields/checks.mdx +++ b/site/src/content/docs/reference/fields/checks.mdx @@ -17,7 +17,7 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`advisory` · `applied_revisions` · `contains` · `equals` · `exec` · `http` · `job` · `json_assertions` · `migrations` · `path` · `port` · `provider` · `required_headers` · `run` · `status_codes` · `url` · `workload` +`advisory` · `appliedRevisions` · `contains` · `equals` · `exec` · `http` · `job` · `jsonAssertions` · `migrations` · `path` · `port` · `provider` · `requiredHeaders` · `run` · `statusCodes` · `url` · `workload` ## Reference @@ -34,15 +34,15 @@ cannot drift from what `ob validate` accepts. | `http[].workload` | string | — | Workload the path is probed inside. | | `migrations` | list | — | Migration revisions checked against captured job evidence. | | `migrations[].advisory` | boolean | `false` | Report a failure without blocking release activation. | -| `migrations[].applied_revisions` | list | — | Revisions the job must report as applied. | +| `migrations[].appliedRevisions` | list | — | Revisions the job must report as applied. | | `migrations[].job` | string | — | Job workload whose captured evidence is checked. | | `migrations[].provider` | string | — | Migration tool that produced the revisions. | | `url` | list | — | External URLs probed from the operator side. | | `url[].advisory` | boolean | `false` | Report a failure without blocking release activation. | | `url[].contains` | string | — | Text the response body must contain. | -| `url[].json_assertions` | list | — | Scalar JSON response values that must match exactly. | -| `url[].json_assertions[].equals` | — | — | Exact scalar value required at path. | -| `url[].json_assertions[].path` | string | — | Dot-separated path to a scalar value in the JSON response. | -| `url[].required_headers` | map | — | Exact response headers required for success. | -| `url[].status_codes` | list | — | Allowed response status codes. A successful 2xx response is expected when omitted. | +| `url[].jsonAssertions` | list | — | Scalar JSON response values that must match exactly. | +| `url[].jsonAssertions[].equals` | — | — | Exact scalar value required at path. | +| `url[].jsonAssertions[].path` | string | — | Dot-separated path to a scalar value in the JSON response. | +| `url[].requiredHeaders` | map | — | Exact response headers required for success. | +| `url[].statusCodes` | list | — | Allowed response status codes. A successful 2xx response is expected when omitted. | | `url[].url` | string | — | External HTTP or HTTPS URL verified from the operator side. Expects an http or https URL. | diff --git a/site/src/content/docs/reference/fields/deployment.mdx b/site/src/content/docs/reference/fields/deployment.mdx index 00a7cf1f..7c87ead3 100644 --- a/site/src/content/docs/reference/fields/deployment.mdx +++ b/site/src/content/docs/reference/fields/deployment.mdx @@ -17,12 +17,12 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`migration_policy` · `order` · `retain_releases` +`migrationPolicy` · `order` · `retainReleases` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `migration_policy` | `manual` · `auto` · `expand-only` | `manual` | Policy for migration jobs during release and recovery. | +| `migrationPolicy` | `Manual` · `Auto` · `ExpandOnly` | `Manual` | Policy for migration jobs during release and recovery. | | `order` | list | — | Explicit workload release order. Dependency order is derived when omitted. | -| `retain_releases` | integer | `5` | Number of completed release directories retained for inspection and rollback. | +| `retainReleases` | integer | `5` | Number of completed release directories retained for inspection and rollback. | diff --git a/site/src/content/docs/reference/fields/environments.mdx b/site/src/content/docs/reference/fields/environments.mdx index 06207b38..e6b8fe18 100644 --- a/site/src/content/docs/reference/fields/environments.mdx +++ b/site/src/content/docs/reference/fields/environments.mdx @@ -23,33 +23,33 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`allow_agent_proposals` · `backup_key_material` · `backup_max_age` · `base_path` · `env_files` · `file` · `host` · `jump` · `migrations` · `min_onebox_version` · `min_plan_schema` · `overrides` · `policy` · `port` · `provider` · `require_approval` · `require_backup` · `require_restore_test` · `server` · `services` · `user` · `workloads` +`allowAgentProposals` · `backupKeyMaterial` · `backupMaxAge` · `basePath` · `envFiles` · `file` · `host` · `jump` · `migrations` · `minOneboxVersion` · `minPlanSchema` · `overrides` · `policy` · `port` · `provider` · `requireApproval` · `requireBackup` · `requireRestoreTest` · `server` · `services` · `user` · `workloads` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `.base_path` | string | — | Environment-specific replacement for the project base_path. Expects an absolute path with no control character or shell metacharacter. | -| `.env_files` | list | — | Default ordered environment-file list for application, worker, and job workloads in this environment. | -| `.env_files[].file` `*` | string | — | Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter. | -| `.env_files[].provider` | `sops` | — | Decryptor used before staging the file. The supported encrypted provider is sops. | +| `.basePath` | string | — | Environment-specific replacement for the Application basePath. Expects an absolute path with no control character or shell metacharacter. | +| `.envFiles` | list | — | Default ordered environment-file list for application, worker, and job workloads in this environment. | +| `.envFiles[].file` `*` | string | — | Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter. | +| `.envFiles[].provider` | `Sops` | — | Decryptor used before staging the file. The supported encrypted provider is sops. | | `.jump` | object | — | 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. | | `.jump.host` | string | — | Jump host name or IP address. | | `.jump.port` | integer | — | SSH port on the jump host. The SSH default is used when omitted. | | `.jump.user` | string | — | SSH user on the jump host. $USER is used when omitted; ob does not read ~/.ssh/config. | | `.overrides` | object | — | Environment-specific operational tuning. Overrides cannot change workload identity or data semantics. | | `.overrides.services` | map | — | Allowed service tuning keyed by service name: resources and settings. | -| `.overrides.workloads` | map | — | Allowed workload tuning keyed by workload name: replicas, resources, env, env_files, strategy, and routes. | +| `.overrides.workloads` | map | — | Allowed workload tuning keyed by workload name: replicas, resources, env, envFiles, strategy, and routes. | | `.policy` | object | — | Approval, runner compatibility, and migration-backup requirements for this environment. | -| `.policy.allow_agent_proposals` | boolean | `true` | Declared permission for agent-authored proposals. The current CLI does not distinguish agent identity; execution remains approval-gated. | +| `.policy.allowAgentProposals` | boolean | `true` | Declared permission for agent-authored proposals. The current CLI does not distinguish agent identity; execution remains approval-gated. | | `.policy.migrations` | object | — | What this environment requires of a release carrying migration risk. | -| `.policy.migrations.backup_key_material` | list | — | Key-material identities the backup report must name. | -| `.policy.migrations.backup_max_age` | string | `24h` | Maximum age of a backup report accepted for a migration. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.policy.migrations.require_backup` | boolean | `false` | Require a plan-bound backup report before a release with migration risk. | -| `.policy.migrations.require_restore_test` | boolean | `false` | Require the backup report to state that a restore test succeeded. | -| `.policy.min_onebox_version` | string | — | Oldest released Onebox runner allowed to operate this environment. Expects a CalVer release such as v2026.8.0. | -| `.policy.min_plan_schema` | string | — | Oldest executable plan schema accepted by this environment. Expects a plan schema identity such as onebox.run/executable-deploy-plan/v1alpha2. | -| `.policy.require_approval` | boolean | `true` | Require a plan-bound local confirmation before mutating this environment. | +| `.policy.migrations.backupKeyMaterial` | list | — | Key-material identities the backup report must name. | +| `.policy.migrations.backupMaxAge` | string | `24h` | Maximum age of a backup report accepted for a migration. Expects a duration such as 30s, 5m, 1h30m or 14d. | +| `.policy.migrations.requireBackup` | boolean | `false` | Require a plan-bound backup report before a release with migration risk. | +| `.policy.migrations.requireRestoreTest` | boolean | `false` | Require the backup report to state that a restore test succeeded. | +| `.policy.minOneboxVersion` | string | — | Oldest released Onebox runner allowed to operate this environment. Expects a CalVer release such as v2026.8.0. | +| `.policy.minPlanSchema` | string | — | Oldest executable plan schema accepted by this environment. Expects a plan schema identity such as onebox.run/executable-deploy-plan/v1alpha2. | +| `.policy.requireApproval` | boolean | `true` | Require a plan-bound local confirmation before mutating this environment. | | `.server` | object | — | SSH server, written as user@host or as an object with host, user, and port. Also accepts user@host. | | `.server.host` | string | — | SSH hostname or IP address. | | `.server.port` | integer | — | SSH port. The SSH default is used when omitted. | diff --git a/site/src/content/docs/reference/fields/external_services.mdx b/site/src/content/docs/reference/fields/external-services.mdx similarity index 74% rename from site/src/content/docs/reference/fields/external_services.mdx rename to site/src/content/docs/reference/fields/external-services.mdx index 25b2a7dd..a7f6b4ec 100644 --- a/site/src/content/docs/reference/fields/external_services.mdx +++ b/site/src/content/docs/reference/fields/external-services.mdx @@ -1,5 +1,5 @@ --- -title: "external_services" +title: "externalServices" description: "Typed dependencies operated outside Onebox, whose lifecycle and backups stay external. Accepted by the loader; not yet executable." summary: "Typed dependencies operated outside Onebox, whose lifecycle and backups stay external. Accepted by the loader; not yet executable." status: schema-only @@ -23,20 +23,20 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`backup_owner` · `connection` · `driver` · `entries` · `file` · `kind` · `max_age` · `probe` · `provider` · `source` · `timeout` +`backupOwner` · `connection` · `driver` · `entries` · `file` · `kind` · `maxAge` · `probe` · `provider` · `source` · `timeout` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `.backup_owner` | string | — | 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. | +| `.backupOwner` | string | — | 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. | | `.connection` | object | — | Trusted connection source and driver-shaped entry mapping. | | `.connection.entries` | map | — | Maps driver connection parts such as host, port, user, password, database, or url to variable names in the trusted source. | | `.connection.source` | object | — | Trusted encrypted file containing the connection values. | | `.connection.source.file` | string | — | Repository-relative encrypted environment file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter. | -| `.connection.source.provider` | `sops` | `sops` | Trusted secret provider. Only sops is currently executable. | +| `.connection.source.provider` | `Sops` | `Sops` | Trusted secret provider. Only sops is currently executable. | | `.driver` | `clickhouse` · `mariadb` · `meilisearch` · `minio` · `mongodb` · `mysql` · `nats` · `postgres` · `rabbitmq` · `redis` · `valkey` | — | Built-in connection shape used to validate and project this dependency. | | `.probe` | object | — | Optional bounded read-only health observation; it never creates or repairs provider resources. | -| `.probe.kind` | `driver-health` | `driver-health` | Read-only observation kind: driver-health. | -| `.probe.max_age` | string | `5m` | Maximum age of a probe observation bound into a plan. Expects a duration such as 30s, 5m, 1h30m or 14d. | +| `.probe.kind` | `DriverHealth` | `DriverHealth` | Read-only observation kind: driver-health. | +| `.probe.maxAge` | string | `5m` | Maximum age of a probe observation bound into a plan. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.probe.timeout` | string | `5s` | Maximum duration of one read-only probe. Expects a duration such as 30s, 5m, 1h30m or 14d. | diff --git a/site/src/content/docs/reference/fields/notifications.mdx b/site/src/content/docs/reference/fields/notifications.mdx index c4ead909..b6d85971 100644 --- a/site/src/content/docs/reference/fields/notifications.mdx +++ b/site/src/content/docs/reference/fields/notifications.mdx @@ -23,6 +23,6 @@ cannot drift from what `ob validate` accepts. | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `.format` | `text` · `json` | `text` | Notification payload format. | -| `.on` | list of `success` · `failure` | `success, failure` | Operation outcomes that trigger this notification. | +| `.format` | `Text` · `JSON` | `Text` | Notification payload format. | +| `.on` | list of `Success` · `Failure` | `success, failure` | Operation outcomes that trigger this notification. | | `.webhook` | string | — | HTTP endpoint that receives outcome notifications. | diff --git a/site/src/content/docs/reference/fields/proxy.mdx b/site/src/content/docs/reference/fields/proxy.mdx index 0d1dbef2..3baa2377 100644 --- a/site/src/content/docs/reference/fields/proxy.mdx +++ b/site/src/content/docs/reference/fields/proxy.mdx @@ -17,20 +17,20 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`config` · `dns_challenge` · `entrypoints` · `image` · `kind` · `managed` · `network` · `port` · `provider` · `resolvers` +`config` · `dnsChallenge` · `entrypoints` · `image` · `kind` · `managed` · `network` · `port` · `provider` · `resolvers` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | | `config` | string | — | 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. | -| `dns_challenge` | object | — | 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. | -| `dns_challenge.provider` `*` | string | — | 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. | -| `dns_challenge.resolvers` | list | — | DNS resolvers used to verify challenge propagation, written as host:port. | +| `dnsChallenge` | object | — | 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. | +| `dnsChallenge.provider` `*` | string | — | 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. | +| `dnsChallenge.resolvers` | list | — | DNS resolvers used to verify challenge propagation, written as host:port. | | `entrypoints` | map | — | 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. | | `entrypoints..port` | integer | — | Host and proxy-container TCP port used by this listener. | | `image` | string | — | Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. | -| `kind` | `traefik-docker` · `none` | `traefik-docker` | Proxy implementation, or none to disable routing. | +| `kind` | `TraefikDocker` · `None` | `TraefikDocker` | Proxy implementation, or none to disable routing. | | `managed` | boolean | — | Let Onebox converge the host-scoped proxy when routes are declared. | | `network` | string | `ob-ingress` | External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved. | diff --git a/site/src/content/docs/reference/fields/registries.mdx b/site/src/content/docs/reference/fields/registries.mdx index 51fe44ae..9348755b 100644 --- a/site/src/content/docs/reference/fields/registries.mdx +++ b/site/src/content/docs/reference/fields/registries.mdx @@ -17,12 +17,12 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`password_env` · `server` · `username` +`passwordEnv` · `server` · `username` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `.password_env` | string | — | Local environment-variable name containing the registry password or token. Expects a variable name of letters, digits and underscores, not starting with a digit. | +| `.passwordEnv` | string | — | Local environment-variable name containing the registry password or token. Expects a variable name of letters, digits and underscores, not starting with a digit. | | `.server` | string | — | Registry hostname, optionally with a port. Expects a host with an optional port and path, such as ghcr.io or registry.example.com:5000. | | `.username` | string | — | Registry login username. Expects a username of letters, digits and . _ @ + -. | diff --git a/site/src/content/docs/reference/fields/runtime.mdx b/site/src/content/docs/reference/fields/runtime.mdx index 89d6fb36..8a1f6aef 100644 --- a/site/src/content/docs/reference/fields/runtime.mdx +++ b/site/src/content/docs/reference/fields/runtime.mdx @@ -18,18 +18,18 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`env_checks` · `env_files` · `file` · `present` · `provider` · `require` +`envChecks` · `envFiles` · `file` · `present` · `provider` · `require` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `env_checks` | list | — | Local environment-file assertions checked before planning or deploying. | -| `env_checks[].file` | string | — | Repository-relative dotenv file whose declared keys are checked. Expects a path inside the repository, with no control character or shell metacharacter. | -| `env_checks[].present` | list | — | Environment keys that must be declared but may be empty. | -| `env_checks[].require` | list | — | Environment keys that must be declared with non-empty values. | -| `env_files` | list | — | Project-wide ordered environment-file list for application, worker, and job workloads. | -| `env_files[].file` `*` | string | — | Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter. | -| `env_files[].provider` | `sops` | — | Decryptor used before staging the file. The supported encrypted provider is sops. | +| `envChecks` | list | — | Local environment-file assertions checked before planning or deploying. | +| `envChecks[].file` | string | — | Repository-relative dotenv file whose declared keys are checked. Expects a path inside the repository, with no control character or shell metacharacter. | +| `envChecks[].present` | list | — | Environment keys that must be declared but may be empty. | +| `envChecks[].require` | list | — | Environment keys that must be declared with non-empty values. | +| `envFiles` | list | — | Project-wide ordered environment-file list for application, worker, and job workloads. | +| `envFiles[].file` `*` | string | — | Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter. | +| `envFiles[].provider` | `Sops` | — | Decryptor used before staging the file. The supported encrypted provider is sops. | `*` marks a field that is required within its own object. diff --git a/site/src/content/docs/reference/fields/services.mdx b/site/src/content/docs/reference/fields/services.mdx index 7f262662..4e840ed2 100644 --- a/site/src/content/docs/reference/fields/services.mdx +++ b/site/src/content/docs/reference/fields/services.mdx @@ -18,21 +18,21 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`allow_downtime` · `backup` · `cpus` · `cron` · `drill` · `driver` · `extensions` · `features` · `keep` · `max_age` · `max_data_loss` · `memory` · `mode` · `persistence` · `recovery_kind` · `resources` · `retention` · `schedule` · `settings` · `target` · `timezone` · `version` · `volumes` · `window` +`allowDowntime` · `backup` · `cpus` · `cron` · `drill` · `driver` · `extensions` · `features` · `keep` · `maxAge` · `maxDataLoss` · `memory` · `mode` · `persistence` · `recoveryKind` · `resources` · `retention` · `schedule` · `settings` · `target` · `timezone` · `version` · `volumes` · `window` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | | `.backup` | object | — | Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup. | -| `.backup.allow_downtime` | boolean | `false` | Whether recurring backup operations may use the driver-declared stopped-service window. | +| `.backup.allowDowntime` | boolean | `false` | Whether recurring backup operations may use the driver-declared stopped-service window. | | `.backup.drill` | object | — | Exact isolated restore-test schedule, proof age, and optional staging filesystem. | -| `.backup.drill.max_age` | string | `7d` | Maximum age of the latest passing restore proof. Expects a duration such as 30s, 5m, 1h30m or 14d. | +| `.backup.drill.maxAge` | string | `7d` | Maximum age of the latest passing restore proof. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.backup.drill.schedule` | object | — | Exact recurring isolated restore-test schedule. | | `.backup.drill.schedule.cron` | string | — | Five-field cron schedule translated to a host timer. Expects five cron fields. | | `.backup.drill.schedule.timezone` | string | `UTC` | IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin. | -| `.backup.max_data_loss` | string | — | Maximum tolerable interval between the latest recoverable point and failure. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.backup.recovery_kind` | `snapshot` · `pitr` · `cold` | — | Required recovery envelope: snapshot, pitr, or cold. | +| `.backup.maxDataLoss` | string | — | Maximum tolerable interval between the latest recoverable point and failure. Expects a duration such as 30s, 5m, 1h30m or 14d. | +| `.backup.recoveryKind` | `Snapshot` · `Pitr` · `Cold` | — | Required recovery envelope: snapshot, pitr, or cold. | | `.backup.retention` | object | — | Portable minimum recovery history that the selected native driver must be able to preserve. | | `.backup.retention.keep` | integer | `7` | Minimum number of independently recoverable base generations to retain. | | `.backup.retention.window` | string | `7d` | Minimum continuous recovery history the native retention mapping must preserve. Expects a duration such as 30s, 5m, 1h30m or 14d. | @@ -44,7 +44,7 @@ cannot drift from what `ob validate` accepts. | `.features` | object | — | Capabilities Onebox must establish before application workloads run. | | `.features.extensions` | map | — | PostgreSQL extensions Onebox installs in the managed application database before application migrations run. | | `.persistence` | object | — | Data-lifetime declaration for this supporting service. | -| `.persistence.mode` | `durable` · `ephemeral` · `external` | `durable` | Data lifetime: durable, ephemeral, or external. | +| `.persistence.mode` | `Durable` · `Ephemeral` · `External` | `Durable` | Data lifetime: durable, ephemeral, or external. | | `.resources` | object | — | Memory and CPU limits for this supporting service. | | `.resources.cpus` | string | — | Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2. | | `.resources.memory` | string | — | Container memory limit. Expects a size such as 512MB or 1.5GB. | diff --git a/site/src/content/docs/reference/fields/top-level.mdx b/site/src/content/docs/reference/fields/top-level.mdx index a18c0887..f4abe111 100644 --- a/site/src/content/docs/reference/fields/top-level.mdx +++ b/site/src/content/docs/reference/fields/top-level.mdx @@ -1,7 +1,7 @@ --- title: "Top level" -description: "Required keys, project-wide scalars, and the single-workload shorthand." -summary: "Required keys, project-wide scalars, and the single-workload shorthand." +description: "The Application envelope and project-wide fields inside spec." +summary: "The Application envelope and project-wide fields inside spec." status: shipped generated: true sidebar: @@ -18,42 +18,17 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`api_version` · `app` · `args` · `base_path` · `build` · `compose` · `context` · `dockerfile` · `entrypoint` · `exec` · `health` · `hostname` · `http` · `image` · `interval` · `middlewares` · `path` · `port` · `protocol` · `pull` · `reference` · `retries` · `routes` · `scheme` · `start_period` · `target` · `tcp` · `tls` · `within` +`annotations` · `apiVersion` · `basePath` · `kind` · `metadata` · `name` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `api_version` `*` | string | — | Project contract version. Must be onebox.run/v1. | -| `app` | string | — | 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. | -| `base_path` | string | `/var/lib/ob` | Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter. | -| `build` | object | — | Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path. | -| `build.args` | map | — | Build arguments supplied by the external build system. | -| `build.context` | string | — | Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter. | -| `build.dockerfile` | string | — | Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter. | -| `build.target` | string | — | Named Dockerfile stage to build. | -| `compose` | string | — | Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service. | -| `health` | object | — | Readiness check used to gate rolling replacement. Also accepts an HTTP health path. | -| `health.exec` | — | — | Health command as a shell string or direct argument list. | -| `health.http` | string | — | HTTP path probed inside the container. Expects a path beginning with /. | -| `health.interval` | string | `5s` | 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. | -| `health.port` | integer | — | Container port probed by HTTP or TCP health checks. | -| `health.retries` | integer | `3` | 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. | -| `health.start_period` | string | `30s` | 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. | -| `health.tcp` | boolean | `false` | Probe the configured port by opening a TCP connection. | -| `health.within` | string | — | Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `image` | object | — | Container image source, written as a reference string or an object. Also accepts an image reference. | -| `image.pull` | `always` · `missing` · `never` | `missing` | 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. | -| `image.reference` | string | — | Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. | -| `port` | integer | — | Default container port used by HTTP health checks. | -| `routes` | list | — | Ingress routes exposed by this workload. | -| `routes[].entrypoint` | string | `websecure` | Named proxy listener used for the route. | -| `routes[].hostname` `*` | string | — | 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. | -| `routes[].middlewares` | list | — | Ordered provider-qualified middleware references applied to this route. | -| `routes[].path` | string | `/` | URL path prefix matched by an HTTP route. Expects a path beginning with /. | -| `routes[].port` | integer | — | Container port receiving routed traffic. | -| `routes[].protocol` | `http` · `tcp` | `http` | Routing protocol: http or tcp. | -| `routes[].scheme` | `http` · `https` · `h2c` | `http` | Backend connection scheme for HTTP routes: http, https, or h2c. | -| `routes[].tls` | `terminate` · `passthrough` · `none` | `terminate` | TLS handling: terminate, passthrough, or none. | +| `apiVersion` `*` | string | — | Authored Application API identity. | +| `basePath` | string | `/var/lib/ob` | Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter. | +| `kind` `*` | string | — | Authored resource kind. | +| `metadata` `*` | object | — | Application identity and opaque user metadata. | +| `metadata.annotations` | map | — | Opaque user metadata that never affects plans or runtime behavior. | +| `metadata.name` `*` | — | — | 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. Stable application name used in generated runtime identities. | `*` marks a field that is required within its own object. diff --git a/site/src/content/docs/reference/fields/workloads.mdx b/site/src/content/docs/reference/fields/workloads.mdx index 7b7278d7..ccb47203 100644 --- a/site/src/content/docs/reference/fields/workloads.mdx +++ b/site/src/content/docs/reference/fields/workloads.mdx @@ -14,12 +14,16 @@ read_when: {/* Generated by cmd/ob-docgen. Do not edit by hand. */} +:::caution[`workloads` is required] +A project file that does not declare `workloads` fails to load. +::: + This page is generated from the same Go declarations the loader enforces, so it cannot drift from what `ob validate` accepts. ## Fields on this page -`args` · `attempts` · `backoff` · `bind` · `build` · `catch_up` · `command` · `compose` · `condition` · `container` · `context` · `cpus` · `cron` · `data_effect` · `default` · `deploy_lock` · `deployment_phase` · `description` · `dockerfile` · `drain` · `driver` · `entrypoint` · `enum` · `env` · `env_files` · `exec` · `execution` · `extra_hosts` · `file` · `grace` · `health` · `host` · `hostname` · `http` · `id` · `image` · `init` · `inputs` · `interval` · `labels` · `logging` · `max_backoff` · `memory` · `middlewares` · `mode` · `name` · `needs` · `notify` · `operator_run` · `options` · `outputs` · `path` · `pattern` · `persistence` · `port` · `protocol` · `provider` · `published_ports` · `pull` · `reference` · `replicas` · `resources` · `retention` · `retries` · `retry` · `role` · `routes` · `schedule` · `scheme` · `shutdown_grace` · `signal` · `source` · `start_period` · `stdin_open` · `steps` · `strategy` · `target` · `tcp` · `timeout` · `timezone` · `tls` · `tty` · `user` · `volumes` · `wait` · `within` · `working_dir` +`args` · `attempts` · `backoff` · `bind` · `build` · `catchUp` · `command` · `compose` · `condition` · `container` · `context` · `cpus` · `cron` · `dataEffect` · `default` · `deployLock` · `deploymentPhase` · `description` · `dockerfile` · `drain` · `driver` · `entrypoint` · `enum` · `env` · `envFiles` · `exec` · `execution` · `extraHosts` · `file` · `grace` · `health` · `host` · `hostname` · `http` · `id` · `image` · `init` · `inputs` · `interval` · `labels` · `logging` · `maxBackoff` · `memory` · `middlewares` · `mode` · `name` · `needs` · `notify` · `operatorRun` · `options` · `outputs` · `path` · `pattern` · `persistence` · `port` · `protocol` · `provider` · `publishedPorts` · `pull` · `reference` · `replicas` · `resources` · `retention` · `retries` · `retry` · `role` · `routes` · `schedule` · `scheme` · `shutdownGrace` · `signal` · `source` · `startPeriod` · `stdinOpen` · `steps` · `strategy` · `target` · `tcp` · `timeout` · `timezone` · `tls` · `tty` · `user` · `volumes` · `wait` · `within` · `workingDir` ## Reference @@ -32,18 +36,18 @@ cannot drift from what `ob validate` accepts. | `.build.target` | string | — | Named Dockerfile stage to build. | | `.command` | list | — | Container command as a shell string or argument list. Also accepts a command line or argument list. | | `.compose` | string | — | Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service. | -| `.data_effect` | `none` · `migration` · `destructive` · `unknown` | — | Job data impact used by rollback and abort gates. | -| `.deployment_phase` | `none` · `pre_release` · `post_release` | `none` | Deployment phase for this job: none, pre_release, or post_release. | +| `.dataEffect` | `None` · `Migration` · `Destructive` · `Unknown` | — | Job data impact used by rollback and abort gates. | +| `.deploymentPhase` | `None` · `PreRelease` · `PostRelease` | `None` | Deployment phase for this job: none, pre_release, or post_release. | | `.drain` | object | — | Signal and timing used to remove a container from traffic before stopping it. | | `.drain.grace` | string | — | Maximum graceful-shutdown time before forced termination, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.drain.signal` | string | `TERM` | Signal sent to begin graceful shutdown. Expects a signal name such as TERM or QUIT. | | `.drain.wait` | string | — | 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. | | `.entrypoint` | list | — | Container entrypoint as a string or argument list. Also accepts an entrypoint or argument list. | | `.env` | map | — | Literal container environment values. Managed-service credential variables cannot be overridden. | -| `.env_files` | list | — | Workload-specific ordered environment-file list. Replaces broader defaults when present. | -| `.env_files[].file` `*` | string | — | Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter. | -| `.env_files[].provider` | `sops` | — | Decryptor used before staging the file. The supported encrypted provider is sops. | -| `.execution` | object | — | Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with data_effect none. Stores non-secret checkpoints on the host and permits explicit same-release resume. | +| `.envFiles` | list | — | Workload-specific ordered environment-file list. Replaces broader defaults when present. | +| `.envFiles[].file` `*` | string | — | Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter. | +| `.envFiles[].provider` | `Sops` | — | Decryptor used before staging the file. The supported encrypted provider is sops. | +| `.execution` | object | — | Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with dataEffect None. Stores non-secret checkpoints on the host and permits explicit same-release resume. | | `.execution.retention` | string | `168h` | Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.execution.steps` | list | — | Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps. | | `.execution.steps[].command` `*` | list | — | Argument vector passed to the job image's entrypoint. No shell evaluation is performed. | @@ -53,20 +57,20 @@ cannot drift from what `ob validate` accepts. | `.execution.steps[].retry` | object | — | Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout. | | `.execution.steps[].retry.attempts` | integer | `1` | Total attempts including the first, 1 to 10. | | `.execution.steps[].retry.backoff` | string | `30s` | Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.execution.steps[].retry.max_backoff` | string | `10m` | Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.extra_hosts` | list | — | Additional host-to-address entries added to the container. | +| `.execution.steps[].retry.maxBackoff` | string | `10m` | Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d. | +| `.extraHosts` | list | — | Additional host-to-address entries added to the container. | | `.health` | object | — | Readiness check used to gate rolling replacement. Also accepts an HTTP health path. | | `.health.exec` | — | — | Health command as a shell string or direct argument list. | | `.health.http` | string | — | HTTP path probed inside the container. Expects a path beginning with /. | | `.health.interval` | string | `5s` | 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. | | `.health.port` | integer | — | Container port probed by HTTP or TCP health checks. | | `.health.retries` | integer | `3` | 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. | -| `.health.start_period` | string | `30s` | 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. | +| `.health.startPeriod` | string | `30s` | 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. | | `.health.tcp` | boolean | `false` | Probe the configured port by opening a TCP connection. | | `.health.within` | string | — | Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.hostname` | string | — | Hostname assigned inside the workload container. | | `.image` | object | — | Container image source, written as a reference string or an object. Also accepts an image reference. | -| `.image.pull` | `always` · `missing` · `never` | `missing` | 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. | +| `.image.pull` | `Always` · `Missing` · `Never` | `Missing` | 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. | | `.image.reference` | string | — | Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. | | `.init` | boolean | — | Run a minimal init process as PID 1 inside the container. | | `.inputs` | map | — | 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. | @@ -79,23 +83,23 @@ cannot drift from what `ob validate` accepts. | `.logging.driver` | string | — | Container runtime logging driver. Expects a log driver name such as local, json-file or an org/plugin:tag. | | `.logging.options` | map | — | Driver-specific logging options passed to the container runtime. | | `.needs` | list | — | Workload or supporting-service prerequisites and optional connection-variable mappings. | -| `.needs[].condition` | `started` · `healthy` · `completed` | — | Prerequisite condition: started, healthy, or completed. | +| `.needs[].condition` | `Started` · `Healthy` · `Completed` | — | Prerequisite condition: started, healthy, or completed. | | `.needs[].env` | map | — | Maps application environment-variable names to service connection parts such as host, port, user, password, database, or url. | | `.needs[].name` | string | — | 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. | -| `.operator_run` | `allowed` · `disabled` | — | Whether an operator may invoke this job outside deployment: allowed or disabled. Defaults to allowed for phase none and disabled otherwise. | +| `.operatorRun` | `Allowed` · `Disabled` | — | Whether an operator may invoke this job outside deployment: allowed or disabled. Defaults to allowed for phase none and disabled otherwise. | | `.persistence` | object | — | Declares whether this workload holds data that must outlive releases. | -| `.persistence.mode` | `durable` · `ephemeral` · `external` | `durable` | Data lifetime: durable, ephemeral, or external. | +| `.persistence.mode` | `Durable` · `Ephemeral` · `External` | `Durable` | Data lifetime: durable, ephemeral, or external. | | `.port` | integer | — | Default container port used by HTTP health checks. | -| `.published_ports` | list | — | 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. | -| `.published_ports[].bind` | string | `127.0.0.1` | Host address on which the published port listens. | -| `.published_ports[].container` | integer | — | Port receiving traffic inside the container. | -| `.published_ports[].host` | integer | — | Port exposed on the host. | -| `.published_ports[].protocol` | `tcp` · `udp` | `tcp` | Published transport protocol: tcp or udp. | +| `.publishedPorts` | list | — | 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. | +| `.publishedPorts[].bind` | string | `127.0.0.1` | Host address on which the published port listens. | +| `.publishedPorts[].container` | integer | — | Port receiving traffic inside the container. | +| `.publishedPorts[].host` | integer | — | Port exposed on the host. | +| `.publishedPorts[].protocol` | `tcp` · `udp` | `tcp` | Published transport protocol: tcp or udp. | | `.replicas` | integer | `1` | Desired number of long-running workload containers. | | `.resources` | object | — | Container memory and CPU limits. | | `.resources.cpus` | string | — | Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2. | | `.resources.memory` | string | — | Container memory limit. Expects a size such as 512MB or 1.5GB. | -| `.role` | `application` · `worker` · `daemon` · `job` | — | Lifecycle role: application, worker, daemon, or job. | +| `.role` | `Application` · `Worker` · `Daemon` · `Job` | — | Lifecycle role: application, worker, daemon, or job. | | `.routes` | list | — | Ingress routes exposed by this workload. | | `.routes[].entrypoint` | string | `websecure` | Named proxy listener used for the route. | | `.routes[].hostname` `*` | string | — | 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. | @@ -104,28 +108,28 @@ cannot drift from what `ob validate` accepts. | `.routes[].port` | integer | — | Container port receiving routed traffic. | | `.routes[].protocol` | `http` · `tcp` | `http` | Routing protocol: http or tcp. | | `.routes[].scheme` | `http` · `https` · `h2c` | `http` | Backend connection scheme for HTTP routes: http, https, or h2c. | -| `.routes[].tls` | `terminate` · `passthrough` · `none` | `terminate` | TLS handling: terminate, passthrough, or none. | +| `.routes[].tls` | `Terminate` · `Passthrough` · `None` | `Terminate` | TLS handling: terminate, passthrough, or none. | | `.schedule` | object | — | Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy. | -| `.schedule.catch_up` | boolean | `true` | Run once after the host returns if an elapsed schedule was missed while it was offline. | +| `.schedule.catchUp` | boolean | `true` | Run once after the host returns if an elapsed schedule was missed while it was offline. | | `.schedule.cron` | string | — | Five-field cron schedule translated to a host timer. Expects five cron fields. | -| `.schedule.deploy_lock` | `exclusive` · `pinned` | `exclusive` | 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. | -| `.schedule.notify` | list of `success` · `failure` · `timeout` · `skipped` | `failure, timeout` | Run outcomes that send the configured notifications: success, failure, timeout, skipped. | +| `.schedule.deployLock` | `Exclusive` · `Pinned` | `Exclusive` | 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. | +| `.schedule.notify` | list of `Success` · `Failure` · `Timeout` · `Skipped` | `failure, timeout` | Run outcomes that send the configured notifications: success, failure, timeout, skipped. | | `.schedule.retry` | object | — | Bounded retry inside one timer firing. Attempts run under the same locks and the same timeout; a timeout ends the run. | | `.schedule.retry.attempts` | integer | `1` | Total attempts including the first, 1 to 10. | | `.schedule.retry.backoff` | string | `30s` | Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.schedule.retry.max_backoff` | string | `10m` | Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d. | -| `.schedule.shutdown_grace` | string | `30s` | Time allowed for graceful container shutdown after the run deadline before Onebox forces removal. Expects a duration such as 30s, 5m, 1h30m or 14d. | +| `.schedule.retry.maxBackoff` | string | `10m` | Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d. | +| `.schedule.shutdownGrace` | string | `30s` | Time allowed for graceful container shutdown after the run deadline before Onebox forces removal. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.schedule.timeout` | string | `1h` | Maximum wall time for one scheduled run before systemd terminates it and records failure. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.schedule.timezone` | string | `UTC` | IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin. | -| `.stdin_open` | boolean | — | Keep standard input open for the container. | -| `.strategy` | `rolling` · `recreate` | — | 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. | +| `.stdinOpen` | boolean | — | Keep standard input open for the container. | +| `.strategy` | `Rolling` · `Recreate` | — | 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. | | `.tty` | boolean | — | Allocate a pseudo-TTY for the container. | | `.user` | string | — | User or UID used to run the container process. | | `.volumes` | list | — | Managed named volumes or bind mounts. Relative bind sources are read-only release content; absolute sources are external host state. | -| `.volumes[].mode` | `rw` · `ro` | `rw` | Mount access mode: rw or ro. A relative bind source requires ro. | +| `.volumes[].mode` | `Rw` · `Ro` | `Rw` | Mount access mode: rw or ro. A relative bind source requires ro. | | `.volumes[].name` | string | — | Stable logical name of a Onebox-managed volume. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters. | | `.volumes[].path` | string | — | Absolute container path where the volume or bind mount is attached. Expects an absolute path with no control character or shell metacharacter. | | `.volumes[].source` | string | — | 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. | -| `.working_dir` | string | — | Absolute working directory for the container process. Expects an absolute path with no control character or shell metacharacter. | +| `.workingDir` | string | — | Absolute working directory for the container process. Expects an absolute path with no control character or shell metacharacter. | `*` marks a field that is required within its own object. diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 3cef1757..56e3ff0c 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -29,9 +29,9 @@ map named for the thing it configures. | Plural | Singular | | --- | --- | -| `workloads` `services` `registries` `environments` `hooks` `notifications` `backup_targets` `external_services` | `runtime` `deployment` `proxy` | +| `workloads` `services` `registries` `environments` `hooks` `notifications` `backupTargets` `externalServices` | `runtime` `deployment` `proxy` | -Arrays are plural too: `routes`, `env_files`, `volumes`, `published_ports`, +Arrays are plural too: `routes`, `envFiles`, `volumes`, `publishedPorts`, `needs`, `checks`, `env_checks`. Four are not, because the singular reads as the thing being stated rather than a list: `order`, `on`, `require`, `present`. @@ -154,7 +154,7 @@ are pinned: | Word | Means | Never means | | --- | --- | --- | | **server** | the machine you deploy to, in the project file and in every artifact | a backup repository | -| **target** | a backup destination — `backup_targets`, `backup.target` | the machine you deploy to | +| **target** | a backup destination — `backupTargets`, `backup.target` | the machine you deploy to | | **workload** | a container Onebox runs from your declaration | a supporting service, a Compose service | `ob exec` and `ob logs` take a `` because those are two diff --git a/site/src/content/docs/reference/policies.mdx b/site/src/content/docs/reference/policies.mdx index cad94337..1b33f320 100644 --- a/site/src/content/docs/reference/policies.mdx +++ b/site/src/content/docs/reference/policies.mdx @@ -77,7 +77,7 @@ it. Independent authorization is not shipped. | Field | Default | Effect | | --- | --- | --- | -| `require_approval` | `true` | A plan-bound local confirmation is required before mutating | +| `requireApproval` | `true` | A plan-bound local confirmation is required before mutating | | `allow_agent_proposals` | `true` | Recorded policy; the current CLI does not distinguish agent identity, and execution stays approval-gated regardless | | `require_migration_backup` | `false` | Plan-bound backup report before migration risk | | `migration_backup_maximum_age` | — | Maximum age of the accepted report | diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 28278d81..455deed3 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -1,7 +1,7 @@ --- title: Project file description: The shape of ob.yml — required keys, shorthand forms, and where each block lives. -summary: Overview of the onebox.run/v1 project file — the block map, every scalar shorthand, and the two environment-value precedence rules. +summary: Overview of the onebox.run/v1alpha1 Application resource, its envelope, spec blocks, and environment-value precedence rules. sidebar: order: 1 read_when: @@ -10,50 +10,41 @@ read_when: - "Working out which block a field belongs to" --- -`onebox.run/v1` is the contract for one application on one host. It goes in +`onebox.run/v1alpha1` is the contract for one application on one host. It goes in `ob.yml` at the root of your repository. `ob.yaml` is accepted automatically when `ob.yml` is absent, and `-c` accepts either spelling or any explicit path. -Only `api_version` and `environments` are required — plus at least one workload, -from a `workloads` block or the top-level shorthand. +The resource requires `apiVersion`, `kind`, `metadata.name`, and `spec`. +Inside `spec`, both `environments` and `workloads` are required. There are no +top-level workload shorthands or legacy aliases. Start with the schema reference so your editor can help while you type: ```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://onebox.run/schemas/application/v1alpha1/application.schema.json +apiVersion: onebox.run/v1alpha1 ``` +Use the published schema at +`https://onebox.run/schemas/application/v1alpha1/application.schema.json` for +editor completion and validation. + `ob schema --out onebox.schema.json` writes a local copy, and `ob init` puts the published reference on the first line of a scaffolded project. -## Migrating route declarations +## Route declarations Every ingress rule is explicit and uses `hostname` for both exact hosts and -wildcard hosts. The contract identity remains `onebox.run/v1`; update retired -route fields as follows: - -```yaml -# retired scalar route shorthand -domain: shop.example.com -port: 3000 - -# current route -routes: - - hostname: shop.example.com - port: 3000 -``` +wildcard hosts. Routes live under a workload in `spec.workloads`: ```yaml -# retired wildcard route -routes: - - wildcard_suffix: preview.example.com - port: 3000 - -# current: a complete leftmost wildcard label, matching one label only -routes: - - hostname: "*.preview.example.com" - port: 3000 +spec: + workloads: + web: + image: nginx + routes: + - {hostname: shop.example.com, port: 3000} + - {hostname: "*.preview.example.com", port: 3000} ``` The loader rejects retired route fields instead of silently changing their @@ -73,11 +64,11 @@ container port for HTTP health checks; it no longer creates an ingress route. | `runtime` | Environment files and local environment-file checks. | [runtime](/reference/fields/runtime) | | `hooks` | Commands at lifecycle seams. | [hooks](/reference/fields/hooks) | | `checks` | What must be true for a release to activate, grouped by kind. | [checks](/reference/fields/checks) | -| `external_services` | Dependencies operated outside Onebox, and how a workload reaches them. | [external_services](/reference/fields/external_services) | -| `backup_targets` | Off-host repositories a protected service writes its backups to. | [backup_targets](/reference/fields/backup_targets) | +| `externalServices` | Dependencies operated outside Onebox, and how a workload reaches them. | [externalServices](/reference/fields/external-services) | +| `backupTargets` | Off-host repositories a protected service writes its backups to. | [backupTargets](/reference/fields/backup-targets) | | `registries` `notifications` | Named maps. | [registries](/reference/fields/registries) · [notifications](/reference/fields/notifications) | -`external_services` and `backup_targets` are published in the JSON Schema and +`externalServices` and `backupTargets` are published in the JSON Schema and accepted by `ob validate`, but the lifecycle behind them is not shipped: declaring a backup target creates no repository and no schedule, and an external service's health probe never runs. See @@ -99,21 +90,10 @@ These are contract, not convenience that changes within a version. | `jump: deploy@bastion.example.com:2222` | `jump: {user: deploy, host: bastion.example.com, port: 2222}` | | `needs: [postgres]` | `needs: [{name: postgres}]` | | `services: {postgres: 17}` | `services: {postgres: {version: 17}}` | -| `env_files: [.env]` | `env_files: [{file: .env}]` | -| `hooks: {post_deploy: "echo hi"}` | `hooks: {post_deploy: {run: "echo hi"}}` | +| `envFiles: [.env]` | `envFiles: [{file: .env}]` | +| `hooks: {PostDeploy: "echo hi"}` | `hooks: {PostDeploy: {run: "echo hi"}}` | | `build: .` | `build: {context: .}` | -### Top-level workload shorthand - -A single-workload project may write the workload's own fields at the top level -instead of a `workloads` block: `build`, `image`, `compose`, `port`, `health`, -`routes`. Ingress is always declared through `routes`; the scalar `domain`/`port` -route shorthand is not accepted. - -Mixing the two is `shorthand_and_workloads` — it would be ambiguous which -workload the top-level fields describe. Shorthand also needs `app` to attach the -workload to (`app_required`). - ## Environment values: two rules **Which list a workload resolves.** Exactly one, from the most specific @@ -121,7 +101,7 @@ declaration present — override, then workload, then environment, then project. Lists replace rather than extend. **What wins inside the container.** Lowest first: a `compose:` workload's own -`env_file`; the resolved `env_files` entries in order; managed-service connection +`env_file`; the resolved `envFiles` entries in order; managed-service connection files; the service's `environment`. Full explanation, including why level four outranks the rest: @@ -138,7 +118,7 @@ once a persistent resource exists its name cannot change without migration. A foreign resource already holding a derived name is refused, not adopted. **Layout** — `/var/lib/ob//releases/`, plus `current`, `journal`, and -`services`. Configurable per environment with `base_path`. +`services`. Configurable per environment with `basePath`. ### Bind-mount lifetimes @@ -157,7 +137,7 @@ workloads: A relative source begins with `./` (or is `.`) and resolves inside the release directory. It must be read-only because that directory belongs to the -release: the next deploy ships a new one, and `deployment.retain_releases` +release: the next deploy ships a new one, and `deployment.retainReleases` cleanup removes an expired release once no container still mounts it, so anything written there is lost. Use it for versioned configuration, never changing data. @@ -340,10 +320,14 @@ generates the runtime from it. Individual services can still be adopted with ## Evolution -`api_version: onebox.run/v1` is stable. Within it: +`apiVersion: onebox.run/v1alpha1` is the pre-baseline contract. Before the +baseline is locked, normalization may be breaking and will invalidate config +digests and any plans or staged artifacts bound to the old bytes. Regenerate +those artifacts after changing the declaration. Within the locked baseline: - A field is **added**, never repurposed. -- A scalar form once accepted is accepted **permanently**. +- Only the documented resource shape is accepted; aliases and fallbacks are not + part of the contract. - A default may be added; an existing default's value **does not change**. - A constraint is **not tightened** against a project that already loads unless accepting it can silently lose data or produce an ambiguous runtime. A safety diff --git a/site/src/content/docs/start/reading-it-back.mdx b/site/src/content/docs/start/reading-it-back.mdx index 94e81ea5..d58ced5e 100644 --- a/site/src/content/docs/start/reading-it-back.mdx +++ b/site/src/content/docs/start/reading-it-back.mdx @@ -41,16 +41,19 @@ Or reference the published copy from the first line of the project: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.schema.json -api_version: onebox.run/v1 +apiVersion: onebox.run/v1alpha1 ``` +The published schema is available at +`https://onebox.run/schemas/application/v1alpha1/application.schema.json`. + The schema is generated from the same declarations the loader enforces and is checked against the conformance corpus, so what your editor tells you while you type is what `ob validate` tells you afterwards. :::caution[One place your editor is ahead of the engine] -The published schema includes `backup_targets`, `services..backup` and -`external_services`. Your editor will complete them and `ob validate` accepts +The published schema includes `backupTargets`, `services..backup` and +`externalServices`. Your editor will complete them and `ob validate` accepts them, but the behaviour behind them is not yet executable — see [Shipped vs proposed](/status/capabilities). ::: diff --git a/site/src/content/docs/status/capabilities.mdx b/site/src/content/docs/status/capabilities.mdx index 53905170..d30bfd24 100644 --- a/site/src/content/docs/status/capabilities.mdx +++ b/site/src/content/docs/status/capabilities.mdx @@ -23,7 +23,7 @@ This page is the reconciliation. Three states: ## Shipped -- A stable, explicit `onebox.run/v1` project schema. Future contract evolution is +- An explicit `onebox.run/v1alpha1` Application schema. Future contract evolution is additive. - Compose generation and validation, SSH transport with known-host checking, state-bound plans, image pinning, rendered diffs. @@ -96,9 +96,9 @@ Nothing behind them executes. | Block | Reality today | | --- | --- | -| [`backup_targets`](/reference/fields/backup_targets) | Declaring a target creates no repository and no schedule. | +| [`backupTargets`](/reference/fields/backup-targets) | Declaring a target creates no repository and no schedule. | -| [`external_services`](/reference/fields/external_services) | Connections are projected into the generated runtime, and two refusals are wired: an unknown `needs.env` part, and `condition: healthy` against a service with no probe. Health probes do not execute, and the remaining lifecycle refusals are not wired. | +| [`externalServices`](/reference/fields/external-services) | Connections are projected into the generated runtime, and two refusals are wired: an unknown `needs.env` part, and `condition: healthy` against a service with no probe. Health probes do not execute, and the remaining lifecycle refusals are not wired. | Every [lifecycle failure code](/reference/errors#lifecycle-failure-codes) is raised by a path in the shipped binary, checked against the source in both From 5a29be289c02b98cd22a761cd320e748dab3ae44 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 20 Sep 2026 18:48:16 -0700 Subject: [PATCH 2/3] fix(spec): enforce v1alpha1 contract edges --- internal/app/application_fixture_test.go | 30 +++++++++++++++++++ internal/app/canonical_test.go | 14 +++++++++ internal/app/contract.go | 20 +++++++++++-- internal/app/load.go | 6 +++- .../content/docs/start/reading-it-back.mdx | 2 +- 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/internal/app/application_fixture_test.go b/internal/app/application_fixture_test.go index 6351e601..09e370aa 100644 --- a/internal/app/application_fixture_test.go +++ b/internal/app/application_fixture_test.go @@ -1,6 +1,7 @@ package app import ( + "errors" "os" "path/filepath" "reflect" @@ -215,6 +216,35 @@ func TestApplicationContractRejectsLegacyForms(t *testing.T) { } } +func TestApplicationContractRejectsMalformedAnnotations(t *testing.T) { + for name, annotations := range map[string]string{ + "scalar": "nope", + "list": "[nope]", + "null": "null", + "non-string": "{note: 1}", + } { + t.Run(name, func(t *testing.T) { + source := `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop + annotations: ` + annotations + ` +spec: + environments: {} + workloads: {} +` + _, err := LoadBytes([]byte(source), "annotations.yml") + if err == nil { + t.Fatal("malformed annotations were accepted") + } + var contractErr *Error + if !errors.As(err, &contractErr) || contractErr.Code != "project_invalid" || !strings.HasPrefix(contractErr.Path, "metadata.annotations") { + t.Fatalf("error = %#v, want project_invalid at metadata.annotations", err) + } + }) + } +} + func TestProviderNativeValuesRemainData(t *testing.T) { source := `apiVersion: onebox.run/v1alpha1 kind: Application diff --git a/internal/app/canonical_test.go b/internal/app/canonical_test.go index 177b23b6..bc8fd025 100644 --- a/internal/app/canonical_test.go +++ b/internal/app/canonical_test.go @@ -60,6 +60,20 @@ func TestOriginsDistinguishWhatWasWritten(t *testing.T) { } } +func TestPublicPathsPreserveHookSeamNames(t *testing.T) { + for internal, want := range map[string]string{ + "hooks.bootstrap.local": "spec.hooks.Bootstrap.local", + "hooks.pre_release.local": "spec.hooks.PreRelease.local", + "hooks.post_release.local": "spec.hooks.PostRelease.local", + "hooks.post_deploy.local": "spec.hooks.PostDeploy.local", + "hooks.data_migrate.local": "spec.hooks.data_migrate.local", + } { + if got := authoredOriginPath(internal); got != want { + t.Errorf("authoredOriginPath(%q) = %q, want %q", internal, got, want) + } + } +} + // TestInjectedRoleIsNotClaimedAsTheAuthorsChoice. Normalisation inserts `role` // so the schema can discriminate. Reporting it as explicit would tell someone // they made a decision they never made. diff --git a/internal/app/contract.go b/internal/app/contract.go index 7ad73cf3..40dce334 100644 --- a/internal/app/contract.go +++ b/internal/app/contract.go @@ -294,11 +294,25 @@ func publicPath(path string) string { } parts := strings.Split(path, ".") for i, part := range parts { + base := part + suffix := "" if bracket := strings.IndexByte(part, '['); bracket >= 0 { - parts[i] = lowerCamel(part[:bracket]) + part[bracket:] - } else { - parts[i] = lowerCamel(part) + base, suffix = part[:bracket], part[bracket:] } + public := lowerCamel(base) + if i > 0 && lowerCamel(strings.SplitN(parts[i-1], "[", 2)[0]) == "hooks" { + // Hook map keys are either fixed lifecycle seams or authored job names. + // Preserve job names byte-for-byte and publish only the fixed seams in + // their UpperCamelCase contract spelling. + public = base + for _, seam := range eHookSeam { + if base == seam { + public = upperCamel(seam) + break + } + } + } + parts[i] = public + suffix } return "spec." + strings.Join(parts, ".") } diff --git a/internal/app/load.go b/internal/app/load.go index bd295af7..8065e4b3 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -125,7 +125,11 @@ func LoadBytes(b []byte, filename string) (*Spec, error) { if err != nil { return nil, authoredError(err) } - if annotations, ok := metadata["annotations"].(map[string]any); ok { + if value, present := metadata["annotations"]; present { + annotations, ok := value.(map[string]any) + if !ok { + return nil, errf("project_invalid", "metadata.annotations", "", "metadata.annotations must be a mapping of strings") + } p.Annotations = map[string]string{} for key, value := range annotations { text, ok := value.(string) diff --git a/site/src/content/docs/start/reading-it-back.mdx b/site/src/content/docs/start/reading-it-back.mdx index d58ced5e..bb5aede3 100644 --- a/site/src/content/docs/start/reading-it-back.mdx +++ b/site/src/content/docs/start/reading-it-back.mdx @@ -40,7 +40,7 @@ ob schema --out onebox.schema.json Or reference the published copy from the first line of the project: ```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.schema.json +# yaml-language-server: $schema=https://onebox.run/schemas/application/v1alpha1/application.schema.json apiVersion: onebox.run/v1alpha1 ``` From b9db9f0ee2f6a7a1b1e8e9f781e89e01ada4f275 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 20 Sep 2026 18:48:58 -0700 Subject: [PATCH 3/3] docs: update remaining v1alpha1 schema links --- docs/README.md | 2 +- site/src/pages/llms.txt.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index befa3489..59744e3c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ the repository rather than to a reader. | Path | What it is | |---|---| -| [`onebox.run-v1.schema.json`](onebox.run-v1.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. | +| [`api/application/v1alpha1/application.schema.json`](../api/application/v1alpha1/application.schema.json) | The current JSON Schema for the authored Application resource. Generated from the Go model by `ob schema`, tested byte-for-byte against it, and published at the stable URL written by `ob init`. | | [`product.md`](product.md) | Product direction. Not an implementation contract, and not a capability list. | | [`decisions/2026-09-20-application-v1alpha1.md`](decisions/2026-09-20-application-v1alpha1.md) | Accepted decision and complete cutover inventory for the breaking `onebox.run/v1alpha1` Application baseline. It governs the staged implementation but does not claim the current binary has cut over. | diff --git a/site/src/pages/llms.txt.ts b/site/src/pages/llms.txt.ts index 844b1718..07d23428 100644 --- a/site/src/pages/llms.txt.ts +++ b/site/src/pages/llms.txt.ts @@ -58,7 +58,7 @@ export const GET: APIRoute = async ({ site }) => { "", `- [Markdown page export](${origin}/start/first-deploy.md): Append \`.md\` to any docs page URL for clean Markdown.`, `- [Full documentation text](${origin}/llms-full.txt): Every page concatenated, for one-shot ingestion.`, - `- [Project file JSON Schema](${origin}/onebox.run-v1.schema.json): The machine contract the loader enforces.`, + `- [Project file JSON Schema](${origin}/schemas/application/v1alpha1/application.schema.json): The machine contract the loader enforces.`, `- [Sitemap](${origin}/sitemap-index.xml): Crawler URL index.`, "", "## Operating Onebox from an agent",