diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 271f1a2d..05058997 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -149,7 +149,7 @@ jobs:
run: |
./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} version
./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} --help
- ./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} schema --out "${{ runner.temp }}/onebox.run-v1.schema.json"
+ ./ob-smoke${{ runner.os == 'Windows' && '.exe' || '' }} schema --out "${{ runner.temp }}/onebox.run-v2.schema.json"
e2e:
name: End-to-end (Docker)
diff --git a/README.md b/README.md
index 910d78f2..d5db7c19 100644
--- a/README.md
+++ b/README.md
@@ -75,15 +75,15 @@ Starting from an existing Compose project, `ob init` writes the first draft.
This is a complete single-workload project:
```yaml
-# yaml-language-server: $schema=https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.schema.json
-api_version: onebox.run/v1
+# yaml-language-server: $schema=https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json
+api_version: onebox.run/v2
app: shop
environments:
production:
server: root@203.0.113.10
image: ghcr.io/acme/shop:1.4.0
-domain: shop.example.com
-port: 3000
+routes:
+ - {hostname: shop.example.com, port: 3000}
```
It derives the application container, Traefik routing and TLS, release layout
diff --git a/cmd/ob-docgen/main.go b/cmd/ob-docgen/main.go
index 9ad4bf5e..4a9efad3 100644
--- a/cmd/ob-docgen/main.go
+++ b/cmd/ob-docgen/main.go
@@ -9,7 +9,7 @@
//
// So this program is the only writer of `site/src/content/docs/reference/`
// — the field pages, `drivers.mdx`, `errors.mdx` and `cli.mdx` — and of the
-// schema published at `site/public/onebox.run-v1.schema.json`. Those pages carry
+// schema published at `site/public/onebox.run-v2.schema.json`. Those pages carry
// a generated marker,
// which `--check` reads in both directions: it fails when a page differs from
// what this binary would produce, and when a marked page survives that no
@@ -67,7 +67,7 @@ func main() {
os.Exit(1)
}
schema = append(schema, '\n')
- publicFiles := map[string]string{"onebox.run-v1.schema.json": string(schema)}
+ publicFiles := map[string]string{"onebox.run-v2.schema.json": string(schema)}
if check {
if err := verify(out, files); err != nil {
diff --git a/cmd/ob-docgen/main_test.go b/cmd/ob-docgen/main_test.go
index 640642c9..a591e295 100644
--- a/cmd/ob-docgen/main_test.go
+++ b/cmd/ob-docgen/main_test.go
@@ -278,7 +278,7 @@ func TestUnclaimedTopLevelKeysDocumentTheirSubtree(t *testing.T) {
t.Fatalf("cannot render: %v", err)
}
page := pages["fields/top-level.mdx"]
- for _, path := range []string{"routes[].domain", "health.http", "image.reference", "build.context"} {
+ for _, path := range []string{"routes[].hostname", "health.http", "image.reference", "build.context"} {
if !strings.Contains(page, "`"+path+"`") {
t.Errorf("top-level.mdx does not document %q", path)
}
@@ -318,12 +318,12 @@ func TestPublishedSchemaMatchesTheCheckedInCopy(t *testing.T) {
}
// Skipping on a read failure would turn "someone moved the file" into a
// passing test, which is the drift this exists to catch.
- onDisk, err := os.ReadFile(filepath.Join("..", "..", "docs", "onebox.run-v1.schema.json"))
+ onDisk, err := os.ReadFile(filepath.Join("..", "..", "docs", "onebox.run-v2.schema.json"))
if err != nil {
t.Fatalf("the checked-in schema must be readable: %v", err)
}
if strings.TrimSpace(string(generated)) != strings.TrimSpace(string(onDisk)) {
- t.Error("the published schema differs from docs/onebox.run-v1.schema.json")
+ t.Error("the published schema differs from docs/onebox.run-v2.schema.json")
}
}
diff --git a/cmd/ob/commands_test.go b/cmd/ob/commands_test.go
index 358ef3c6..388a8535 100644
--- a/cmd/ob/commands_test.go
+++ b/cmd/ob/commands_test.go
@@ -63,7 +63,7 @@ func writeProject(t *testing.T) string {
t.Helper()
dir := t.TempDir()
obYAML := `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: demo
environments: { production: { server: deploy@example.invalid } }
workloads:
@@ -102,7 +102,7 @@ func TestValidateOK(t *testing.T) {
func TestPreflightBlocksDeploy(t *testing.T) {
dir := writeProject(t)
obYAML := `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: demo
environments: { production: { server: deploy@example.invalid } }
workloads:
diff --git a/cmd/ob/doctor_test.go b/cmd/ob/doctor_test.go
index 124eb52a..d91565a2 100644
--- a/cmd/ob/doctor_test.go
+++ b/cmd/ob/doctor_test.go
@@ -31,7 +31,7 @@ func doctorTestDependencies(t *testing.T) doctorDependencies {
oldBinary := filepath.Join(oldDir, "ob")
currentBinary := filepath.Join(currentDir, "ob")
cfg := &app.Spec{
- APIVersion: "onebox.run/v1",
+ APIVersion: "onebox.run/v2",
Name: "demo",
Environments: map[string]app.Environment{
"production": {
@@ -195,7 +195,7 @@ func TestDoctorReportsIncompatibleProjectPolicy(t *testing.T) {
deps := doctorTestDependencies(t)
deps.loadConfig = func(string) (*app.Spec, error) {
return &app.Spec{
- APIVersion: "onebox.run/v1",
+ APIVersion: "onebox.run/v2",
Environments: map[string]app.Environment{
"production": {Policy: app.Policy{MinOneboxVersion: "v2027.1.0"}},
},
diff --git a/cmd/ob/init.go b/cmd/ob/init.go
index 01eb63e6..fdc38ac2 100644
--- a/cmd/ob/init.go
+++ b/cmd/ob/init.go
@@ -98,7 +98,7 @@ func runInit(ctx context.Context, cmd *cobra.Command, g *globalFlags) error {
// errors from the moment the file exists rather than after someone finds
// out it could.
fmt.Fprintf(&b, "# yaml-language-server: $schema=%s\n", app.SchemaID)
- b.WriteString("api_version: onebox.run/v1\n")
+ b.WriteString("api_version: onebox.run/v2\n")
fmt.Fprintf(&b, "app: %s\n", application)
b.WriteString("environments:\n production:\n server: deploy@CHANGE-ME\n")
b.WriteString("workloads:\n")
diff --git a/cmd/ob/init_test.go b/cmd/ob/init_test.go
index fc181bce..18f9513f 100644
--- a/cmd/ob/init_test.go
+++ b/cmd/ob/init_test.go
@@ -48,7 +48,7 @@ func TestInitClassifiesAndDoctors(t *testing.T) {
}
y := string(b)
for _, want := range []string{
- "api_version: onebox.run/v1",
+ "api_version: onebox.run/v2",
"server: deploy@CHANGE-ME",
"workloads:",
"role: application",
diff --git a/cmd/ob/main_test.go b/cmd/ob/main_test.go
index e05d458a..bc6e96f1 100644
--- a/cmd/ob/main_test.go
+++ b/cmd/ob/main_test.go
@@ -8,14 +8,14 @@ import (
"testing"
)
-const mainTestProject = `api_version: onebox.run/v1
+const mainTestProject = `api_version: onebox.run/v2
app: demo
environments: {production: {server: deploy@example.invalid}}
image: nginx:1.27
proxy: {kind: none}
`
-const mainTestBuildProject = `api_version: onebox.run/v1
+const mainTestBuildProject = `api_version: onebox.run/v2
app: demo
environments: {production: {server: deploy@example.invalid}}
workloads:
diff --git a/cmd/ob/ops_contract_test.go b/cmd/ob/ops_contract_test.go
index 105019ab..be35bba2 100644
--- a/cmd/ob/ops_contract_test.go
+++ b/cmd/ob/ops_contract_test.go
@@ -24,7 +24,7 @@ func writeOpsContractProject(t *testing.T, dir string, encrypted bool) string {
}
}
path := filepath.Join(dir, "project.yml")
- if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1
+ if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2
app: shop
environments:
production: {server: deploy@example.invalid}
@@ -169,7 +169,7 @@ func TestDestroyConfirmationMismatchIsCancelledBeforeTargetContact(t *testing.T)
func TestServiceLogsAndExecNDJSONTagChannelsAndTargetKind(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, "project.yml")
- if err := os.WriteFile(config, []byte(`api_version: onebox.run/v1
+ if err := os.WriteFile(config, []byte(`api_version: onebox.run/v2
app: shop
environments:
production: {server: deploy@example.invalid}
diff --git a/cmd/ob/output_test.go b/cmd/ob/output_test.go
index fe4b718e..d9ec13e5 100644
--- a/cmd/ob/output_test.go
+++ b/cmd/ob/output_test.go
@@ -314,15 +314,15 @@ func TestStructuredDeployRequiresApprovalArtifactWithoutPrompting(t *testing.T)
// failure appears at the consumer rather than here.
func TestStructuredOutputCarriesNoDiagnostics(t *testing.T) {
dir := t.TempDir()
- writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1
+ writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
runtime:
env_files: [.env.production]
image: nginx
-domain: shop.example.com
-port: 3000
+routes:
+ - {hostname: shop.example.com, port: 3000}
`)
writeFile(t, dir, ".env.production", "API_TOKEN=super-secret-value\nPUBLIC_MODE=on\n")
@@ -347,7 +347,7 @@ port: 3000
// publish outlives the terminal it would have scrolled off.
func TestStructuredOutputCarriesNoPlaintextSecret(t *testing.T) {
dir := t.TempDir()
- writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1
+ writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
@@ -355,8 +355,8 @@ workloads:
web:
role: application
image: nginx
- domain: shop.example.com
- port: 3000
+ routes:
+ - {hostname: shop.example.com, port: 3000}
env:
API_TOKEN: super-secret-value
`)
@@ -424,7 +424,7 @@ func TestCommandGroupsValidateOutputBeforeRenderingHelp(t *testing.T) {
func TestEjectStructuredOutputIsVersioned(t *testing.T) {
for _, mode := range []string{"json"} {
dir := t.TempDir()
- writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1
+ writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
@@ -455,7 +455,7 @@ image: nginx
func TestStructuredReadFailuresEmitTypedSafeRecords(t *testing.T) {
dir := t.TempDir()
- writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1
+ writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
diff --git a/cmd/ob/preview.go b/cmd/ob/preview.go
index 7c2783ea..f9db016e 100644
--- a/cmd/ob/preview.go
+++ b/cmd/ob/preview.go
@@ -31,7 +31,7 @@ func addPreviewCommand(root *cobra.Command, g *globalFlags) {
cmd := &cobra.Command{
Use: "preview",
Short: "render the runtime the declarative contract generates (no target, no changes)",
- Long: "Load an onebox.run/v1 project, resolve the environment's overrides, and print\n" +
+ Long: "Load an onebox.run/v2 project, resolve the environment's overrides, and print\n" +
"the Compose runtime Onebox would generate, with its content digest.\n\n" +
"Nothing is contacted and nothing is written. Environment values are redacted:\n" +
"a preview must never put a secret on a terminal.",
diff --git a/cmd/ob/preview_test.go b/cmd/ob/preview_test.go
index d624c359..edae6afb 100644
--- a/cmd/ob/preview_test.go
+++ b/cmd/ob/preview_test.go
@@ -7,7 +7,7 @@ import (
"testing"
)
-const previewProject = `api_version: onebox.run/v1
+const previewProject = `api_version: onebox.run/v2
app: demo
environments:
production: {server: root@1.2.3.4}
@@ -17,8 +17,8 @@ workloads:
role: application
image: nginx:1.27
replicas: 3
- domain: demo.example.com
- port: 8080
+ routes:
+ - {hostname: demo.example.com, port: 8080}
env: {API_TOKEN: super-secret-value, LOG_LEVEL: info}
`
@@ -79,10 +79,10 @@ func TestPreviewAppliesEnvironmentOverrides(t *testing.T) {
// wrong, where, and what to run.
func TestPreviewFailureIsActionable(t *testing.T) {
dir := t.TempDir()
- writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1
+ writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2
app: demo
environments: {production: {server: h}}
-workloads: {web: {role: application, build: ., domain: d.example.com, port: 80}}
+workloads: {web: {role: application, build: ., routes: [{hostname: d.example.com, port: 80}]}}
`)
out, err := run(t, dir, "preview")
if err == nil {
@@ -133,11 +133,11 @@ func dirEntries(t *testing.T, dir string) int {
func TestEjectPicksAFreeName(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "compose.yaml", "services:\n db: {image: postgres}\n")
- writeFile(t, dir, "ob.yml", `api_version: onebox.run/v1
+ writeFile(t, dir, "ob.yml", `api_version: onebox.run/v2
app: ledger
environments: {production: {server: root@1.2.3.4}}
workloads:
- web: {role: application, image: nginx, domain: d.example.com, port: 80}
+ web: {role: application, image: nginx, routes: [{hostname: d.example.com, port: 80}]}
db: {role: daemon, compose: "compose.yaml#db"}
`)
out, err := run(t, dir, "eject")
diff --git a/cmd/ob/schema.go b/cmd/ob/schema.go
index ab1aa36d..eae87425 100644
--- a/cmd/ob/schema.go
+++ b/cmd/ob/schema.go
@@ -23,7 +23,7 @@ func addSchemaCommand(root *cobra.Command, g *globalFlags) {
cmd := &cobra.Command{
Use: "schema",
Short: "print the JSON Schema for the project file, for editors",
- Long: "Write the JSON Schema for the `onebox.run/v1` project file.\n\n" +
+ Long: "Write the JSON Schema for the `onebox.run/v2` project file.\n\n" +
"Reference it from the first line of a project so an editor can offer\n" +
"completion, hover documentation and inline errors:\n\n" +
" # yaml-language-server: $schema=" + app.SchemaID + "\n\n" +
diff --git a/docs/README.md b/docs/README.md
index e726fcd7..b93088bb 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,7 +6,8 @@ the repository rather than to a reader.
| Path | What it is |
|---|---|
-| [`onebox.run-v1.schema.json`](onebox.run-v1.schema.json) | The published JSON Schema for the project file. Generated from the Go model by `ob schema` and tested byte-for-byte against it. `app.SchemaID` points at this path on `main`, and `ob init` writes that URL onto the first line of every scaffolded project. |
+| [`onebox.run-v2.schema.json`](onebox.run-v2.schema.json) | The current JSON Schema for the project file. Generated from the Go model by `ob schema` and tested byte-for-byte against it. `app.SchemaID` points at this path on `main`, and `ob init` writes that URL onto the first line of every scaffolded project. |
+| [`onebox.run-v1.schema.json`](onebox.run-v1.schema.json) | The frozen v1 schema, retained so the stable schema URL in existing v1 projects continues to resolve. |
| [`product.md`](product.md) | Product direction. Not an implementation contract, and not a capability list. |
## Where the user documentation went
diff --git a/docs/onebox.run-v2.schema.json b/docs/onebox.run-v2.schema.json
new file mode 100644
index 00000000..5786c127
--- /dev/null
+++ b/docs/onebox.run-v2.schema.json
@@ -0,0 +1,3119 @@
+{
+ "$id": "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "anyOf": [
+ {
+ "properties": {
+ "workloads": {
+ "minProperties": 1
+ }
+ },
+ "required": [
+ "workloads"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "required": [
+ "build"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ },
+ {
+ "required": [
+ "compose"
+ ]
+ }
+ ]
+ }
+ ],
+ "description": "One application, its workloads, the services it needs, and how a release rolls out.",
+ "not": {
+ "allOf": [
+ {
+ "required": [
+ "workloads"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "required": [
+ "build"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ },
+ {
+ "required": [
+ "compose"
+ ]
+ },
+ {
+ "required": [
+ "port"
+ ]
+ },
+ {
+ "required": [
+ "health"
+ ]
+ },
+ {
+ "required": [
+ "routes"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "api_version": {
+ "const": "onebox.run/v2",
+ "description": "Project contract version. Must be onebox.run/v2.",
+ "examples": [
+ "onebox.run/v2"
+ ],
+ "type": "string"
+ },
+ "app": {
+ "description": "Stable application name used in generated container, volume, network, and host paths. The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"ob-\" or be a name the host layout reserves.",
+ "examples": [
+ "shop"
+ ],
+ "not": {
+ "anyOf": [
+ {
+ "pattern": "^ob-"
+ },
+ {
+ "const": "ob"
+ },
+ {
+ "const": "onebox-proxy"
+ },
+ {
+ "const": "_host"
+ }
+ ]
+ },
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "backup_targets": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "bucket": {
+ "description": "Existing destination bucket used by this target. Expects a lower-case S3-compatible bucket name between 3 and 63 characters.",
+ "examples": [
+ "onebox-backups"
+ ],
+ "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$",
+ "type": "string"
+ },
+ "credentials": {
+ "additionalProperties": false,
+ "description": "Trusted encrypted-file entries containing destination credentials; values never appear in the project.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "access_key_entry": {
+ "description": "Variable name containing the destination access key. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "BACKUP_ACCESS_KEY_ID"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "file": {
+ "description": "Repository-relative encrypted credential file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "secrets/backup.env"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "default": "sops",
+ "description": "Trusted secret provider. Only sops is currently executable.",
+ "enum": [
+ "sops"
+ ],
+ "type": "string"
+ },
+ "secret_key_entry": {
+ "description": "Variable name containing the destination secret key. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "BACKUP_SECRET_ACCESS_KEY"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "session_token_entry": {
+ "description": "Optional variable name containing a temporary destination session token. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "BACKUP_SESSION_TOKEN"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "encryption": {
+ "additionalProperties": false,
+ "description": "Required encryption mode for each recovery kind this target may store.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cold": {
+ "description": "Encryption mode required for cold recovery: client-side or server-side.",
+ "enum": [
+ "client-side",
+ "server-side"
+ ],
+ "type": "string"
+ },
+ "pitr": {
+ "description": "Encryption mode required for point-in-time recovery: client-side or server-side.",
+ "enum": [
+ "client-side",
+ "server-side"
+ ],
+ "type": "string"
+ },
+ "snapshot": {
+ "description": "Encryption mode required for snapshot recovery: client-side or server-side.",
+ "enum": [
+ "client-side",
+ "server-side"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "endpoint": {
+ "description": "Destination API endpoint. HTTPS is required unless tls is explicitly insecure. Expects an http or https URL.",
+ "examples": [
+ "https://objects.example.com"
+ ],
+ "pattern": "^https?://",
+ "type": "string"
+ },
+ "failure_domain": {
+ "additionalProperties": false,
+ "description": "Operator-declared identity used to prove the destination does not share the protected host.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "host": {
+ "description": "Destination host identity used to refuse a target on the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.",
+ "examples": [
+ "backup-01.example.net"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$",
+ "type": "string"
+ },
+ "identity": {
+ "description": "Stable operator-owned failure-domain identity, distinct from the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.",
+ "examples": [
+ "provider-a/us-east-1/account-42"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "kind": {
+ "description": "Destination kind. Only s3-compatible is supported.",
+ "enum": [
+ "s3-compatible"
+ ],
+ "examples": [
+ "s3-compatible"
+ ],
+ "type": "string"
+ },
+ "prefix": {
+ "description": "Non-secret object prefix reserved for Onebox backup data. Expects a relative object prefix with no empty leading component or shell metacharacter.",
+ "examples": [
+ "production/shop"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$",
+ "type": "string"
+ },
+ "region": {
+ "description": "S3-compatible region when the endpoint requires one. Expects a lower-case S3-compatible region of letters, digits and hyphens.",
+ "examples": [
+ "us-east-1"
+ ],
+ "pattern": "^[a-z0-9][a-z0-9-]{0,62}$",
+ "type": "string"
+ },
+ "tls": {
+ "default": "verify",
+ "description": "Transport policy: verify, or skip-verify to accept a plaintext http endpoint.",
+ "enum": [
+ "verify",
+ "skip-verify"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "User-owned off-host repositories available to service backup policies.",
+ "type": "object"
+ },
+ "base_path": {
+ "default": "/var/lib/ob",
+ "description": "Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/srv/ob"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "build": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "args": {
+ "additionalProperties": {},
+ "description": "Build arguments supplied by the external build system.",
+ "type": "object"
+ },
+ "context": {
+ "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "."
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "dockerfile": {
+ "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "Dockerfile"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "target": {
+ "description": "Named Dockerfile stage to build.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path."
+ },
+ "checks": {
+ "additionalProperties": false,
+ "description": "Assertions that must pass before a release becomes current unless marked advisory.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "exec": {
+ "description": "Commands run inside a named workload.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "run": {
+ "description": "Shell command verified inside the workload.",
+ "examples": [
+ "test -f /srv/ready"
+ ],
+ "type": "string"
+ },
+ "workload": {
+ "description": "Workload the command runs inside.",
+ "examples": [
+ "web"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "http": {
+ "description": "HTTP paths probed inside a named workload.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "path": {
+ "description": "HTTP path verified inside the workload. Expects a path beginning with /.",
+ "examples": [
+ "/healthz"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port to probe.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "workload": {
+ "description": "Workload the path is probed inside.",
+ "examples": [
+ "web"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "migrations": {
+ "description": "Migration revisions checked against captured job evidence.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "applied_revisions": {
+ "description": "Revisions the job must report as applied.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "job": {
+ "description": "Job workload whose captured evidence is checked.",
+ "examples": [
+ "migrate"
+ ],
+ "type": "string"
+ },
+ "provider": {
+ "description": "Migration tool that produced the revisions.",
+ "examples": [
+ "alembic"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "url": {
+ "description": "External URLs probed from the operator side.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "contains": {
+ "description": "Text the response body must contain.",
+ "type": "string"
+ },
+ "json_assertions": {
+ "description": "Scalar JSON response values that must match exactly.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "equals": {
+ "description": "Exact scalar value required at path."
+ },
+ "path": {
+ "description": "Dot-separated path to a scalar value in the JSON response.",
+ "examples": [
+ "service.ready"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "required_headers": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Exact response headers required for success.",
+ "type": "object"
+ },
+ "status_codes": {
+ "description": "Allowed response status codes. A successful 2xx response is expected when omitted.",
+ "items": {
+ "maximum": 599,
+ "minimum": 100,
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "url": {
+ "description": "External HTTP or HTTPS URL verified from the operator side. Expects an http or https URL.",
+ "examples": [
+ "https://shop.example.com/healthz"
+ ],
+ "pattern": "^https?://",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "compose": {
+ "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.",
+ "examples": [
+ "docker-compose.yml#web"
+ ],
+ "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$",
+ "type": "string"
+ },
+ "deployment": {
+ "additionalProperties": false,
+ "description": "Release ordering, retention, and migration behavior.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "migration_policy": {
+ "default": "manual",
+ "description": "Policy for migration jobs during release and recovery.",
+ "enum": [
+ "manual",
+ "auto",
+ "expand-only"
+ ],
+ "type": "string"
+ },
+ "order": {
+ "description": "Explicit workload release order. Dependency order is derived when omitted.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "retain_releases": {
+ "default": 5,
+ "description": "Number of completed release directories retained for inspection and rollback.",
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "environments": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "base_path": {
+ "description": "Environment-specific replacement for the project base_path. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/srv/ob"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "env_files": {
+ "description": "Default ordered environment-file list for application, worker, and job workloads in this environment.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Decryptor used before staging the file. The supported encrypted provider is sops.",
+ "enum": [
+ "sops"
+ ],
+ "examples": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts a path to an environment file."
+ },
+ "type": "array"
+ },
+ "jump": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent.",
+ "examples": [
+ "deploy@bastion.example.com"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "host": {
+ "description": "Jump host name or IP address.",
+ "examples": [
+ "bastion.example.com"
+ ],
+ "type": "string"
+ },
+ "port": {
+ "description": "SSH port on the jump host. The SSH default is used when omitted.",
+ "examples": [
+ 2222
+ ],
+ "type": "integer"
+ },
+ "user": {
+ "description": "SSH user on the jump host. $USER is used when omitted; ob does not read ~/.ssh/config.",
+ "examples": [
+ "deploy"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent. Also accepts user@host or user@host:port."
+ },
+ "overrides": {
+ "additionalProperties": false,
+ "description": "Environment-specific operational tuning. Overrides cannot change workload identity or data semantics.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "services": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "type": "object"
+ },
+ "description": "Allowed service tuning keyed by service name: resources and settings.",
+ "type": "object"
+ },
+ "workloads": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "type": "object"
+ },
+ "description": "Allowed workload tuning keyed by workload name: replicas, resources, env, env_files, strategy, and routes.",
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "policy": {
+ "additionalProperties": false,
+ "description": "Approval, runner compatibility, and migration-backup requirements for this environment.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "allow_agent_proposals": {
+ "default": true,
+ "description": "Declared permission for agent-authored proposals. The current CLI does not distinguish agent identity; execution remains approval-gated.",
+ "type": "boolean"
+ },
+ "migrations": {
+ "additionalProperties": false,
+ "description": "What this environment requires of a release carrying migration risk.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "backup_key_material": {
+ "description": "Key-material identities the backup report must name.",
+ "examples": [
+ [
+ "BACKUP_ACCESS_KEY_ID"
+ ]
+ ],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "backup_max_age": {
+ "default": "24h",
+ "description": "Maximum age of a backup report accepted for a migration. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "24h"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "require_backup": {
+ "default": false,
+ "description": "Require a plan-bound backup report before a release with migration risk.",
+ "type": "boolean"
+ },
+ "require_restore_test": {
+ "default": false,
+ "description": "Require the backup report to state that a restore test succeeded.",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "min_onebox_version": {
+ "description": "Oldest released Onebox runner allowed to operate this environment. Expects a CalVer release such as v2026.8.0.",
+ "examples": [
+ "v2026.8.0"
+ ],
+ "pattern": "^v([1-9][0-9]{3})\\.([1-9]|1[0-2])\\.(0|[1-9][0-9]{0,18})$",
+ "type": "string"
+ },
+ "min_plan_schema": {
+ "description": "Oldest executable plan schema accepted by this environment. Expects a plan schema identity such as onebox.run/executable-deploy-plan/v1alpha2.",
+ "examples": [
+ "onebox.run/executable-deploy-plan/v1alpha2"
+ ],
+ "pattern": "^onebox\\.run/executable-deploy-plan/v[1-9][0-9]*((alpha|beta)[1-9][0-9]*)?$",
+ "type": "string"
+ },
+ "require_approval": {
+ "default": true,
+ "description": "Require a plan-bound local confirmation before mutating this environment.",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "server": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "SSH server, written as user@host or as an object with host, user, and port.",
+ "examples": [
+ "root@203.0.113.10"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "host": {
+ "description": "SSH hostname or IP address.",
+ "examples": [
+ "203.0.113.10"
+ ],
+ "type": "string"
+ },
+ "port": {
+ "description": "SSH port. The SSH default is used when omitted.",
+ "examples": [
+ 2222
+ ],
+ "type": "integer"
+ },
+ "user": {
+ "description": "SSH user. $USER is used when omitted; ob does not read ~/.ssh/config.",
+ "examples": [
+ "root"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "SSH server, written as user@host or as an object with host, user, and port. Also accepts user@host."
+ }
+ },
+ "type": "object"
+ },
+ "description": "Named environments, each naming the server it deploys to and the policy applied to it.",
+ "minProperties": 1,
+ "type": "object"
+ },
+ "external_services": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "backup_owner": {
+ "description": "Operator or provider responsible for backup, restore, upgrades, credentials, and durability. Expects a stable operator or provider identity of letters, digits, dots, @, colons, slashes, underscores and hyphens.",
+ "examples": [
+ "platform-team/rds"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@:/-]{0,127}$",
+ "type": "string"
+ },
+ "connection": {
+ "additionalProperties": false,
+ "description": "Trusted connection source and driver-shaped entry mapping.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "entries": {
+ "additionalProperties": {
+ "description": "Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "description": "Maps driver connection parts such as host, port, user, password, database, or url to variable names in the trusted source.",
+ "type": "object"
+ },
+ "source": {
+ "additionalProperties": false,
+ "description": "Trusted encrypted file containing the connection values.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative encrypted environment file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "secrets/production-db.env"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "default": "sops",
+ "description": "Trusted secret provider. Only sops is currently executable.",
+ "enum": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "driver": {
+ "description": "Built-in connection shape used to validate and project this dependency.",
+ "enum": [
+ "clickhouse",
+ "mariadb",
+ "meilisearch",
+ "minio",
+ "mongodb",
+ "mysql",
+ "nats",
+ "postgres",
+ "rabbitmq",
+ "redis",
+ "valkey"
+ ],
+ "examples": [
+ "postgres"
+ ],
+ "type": "string"
+ },
+ "probe": {
+ "additionalProperties": false,
+ "description": "Optional bounded read-only health observation; it never creates or repairs provider resources.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "kind": {
+ "default": "driver-health",
+ "description": "Read-only observation kind: driver-health.",
+ "enum": [
+ "driver-health"
+ ],
+ "type": "string"
+ },
+ "max_age": {
+ "default": "5m",
+ "description": "Maximum age of a probe observation bound into a plan. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "timeout": {
+ "default": "5s",
+ "description": "Maximum duration of one read-only probe. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Typed dependencies operated outside Onebox. Their connection projection is trusted, but their lifecycle and backup remain external.",
+ "type": "object"
+ },
+ "health": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Readiness check used to gate rolling replacement.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "exec": {
+ "description": "Health command as a shell string or direct argument list."
+ },
+ "http": {
+ "description": "HTTP path probed inside the container. Expects a path beginning with /.",
+ "examples": [
+ "/healthz"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "interval": {
+ "default": "5s",
+ "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "2s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port probed by HTTP or TCP health checks.",
+ "examples": [
+ 8080
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "retries": {
+ "default": 3,
+ "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.",
+ "examples": [
+ 3
+ ],
+ "type": "integer"
+ },
+ "start_period": {
+ "default": "30s",
+ "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "tcp": {
+ "default": false,
+ "description": "Probe the configured port by opening a TCP connection.",
+ "type": "boolean"
+ },
+ "within": {
+ "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "120s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path."
+ },
+ "hooks": {
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "local": {
+ "default": false,
+ "description": "Run on the operator machine instead of the server.",
+ "type": "boolean"
+ },
+ "run": {
+ "description": "Command executed at the lifecycle seam.",
+ "examples": [
+ "./scripts/notify.sh"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts the command to run."
+ },
+ "description": "Lifecycle commands keyed by seam: bootstrap, pre_release, post_release, or post_deploy.",
+ "type": "object"
+ },
+ "image": {
+ "anyOf": [
+ {
+ "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Container image source, written as a reference string or an object.",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "pull": {
+ "default": "missing",
+ "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.",
+ "enum": [
+ "always",
+ "missing",
+ "never"
+ ],
+ "type": "string"
+ },
+ "reference": {
+ "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Container image source, written as a reference string or an object. Also accepts an image reference."
+ },
+ "notifications": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "format": {
+ "default": "text",
+ "description": "Notification payload format.",
+ "enum": [
+ "text",
+ "json"
+ ],
+ "type": "string"
+ },
+ "on": {
+ "default": [
+ "success",
+ "failure"
+ ],
+ "description": "Operation outcomes that trigger this notification.",
+ "items": {
+ "enum": [
+ "success",
+ "failure"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "webhook": {
+ "description": "HTTP endpoint that receives outcome notifications.",
+ "examples": [
+ "https://hooks.example.com/onebox"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Named webhooks that receive selected operation and scheduled-job outcomes.",
+ "type": "object"
+ },
+ "port": {
+ "description": "Default container port used by HTTP health checks.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "proxy": {
+ "additionalProperties": false,
+ "description": "Ownership and configuration of the host ingress proxy.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "config": {
+ "description": "Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. A managed DNS challenge may use a directory containing only .env for provider credentials. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "dns_challenge": {
+ "additionalProperties": false,
+ "description": "Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "provider": {
+ "description": "Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env. Expects a lower-case Traefik DNS provider name such as cloudflare or route53.",
+ "examples": [
+ "cloudflare"
+ ],
+ "pattern": "^[a-z][a-z0-9_-]*$",
+ "type": "string"
+ },
+ "resolvers": {
+ "description": "DNS resolvers used to verify challenge propagation, written as host:port.",
+ "examples": [
+ [
+ "1.1.1.1:53"
+ ]
+ ],
+ "items": {
+ "description": "Expects a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port.",
+ "pattern": "^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\\[[0-9A-Fa-f:.]+\\]):[0-9]{1,5}$",
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "provider"
+ ],
+ "type": "object"
+ },
+ "entrypoints": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "port": {
+ "description": "Host and proxy-container TCP port used by this listener.",
+ "examples": [
+ 4317
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints.",
+ "propertyNames": {
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$"
+ },
+ "type": "object"
+ },
+ "image": {
+ "description": "Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ },
+ "kind": {
+ "default": "traefik-docker",
+ "description": "Proxy implementation, or none to disable routing.",
+ "enum": [
+ "traefik-docker",
+ "none"
+ ],
+ "type": "string"
+ },
+ "managed": {
+ "description": "Let Onebox converge the host-scoped proxy when routes are declared.",
+ "type": "boolean"
+ },
+ "network": {
+ "default": "ob-ingress",
+ "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "registries": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "password_env": {
+ "description": "Local environment-variable name containing the registry password or token. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "GHCR_TOKEN"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "server": {
+ "description": "Registry hostname, optionally with a port. Expects a host with an optional port and path, such as ghcr.io or registry.example.com:5000.",
+ "examples": [
+ "ghcr.io"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9.-]*(:[0-9]{1,5})?(/[A-Za-z0-9._/-]*)?$",
+ "type": "string"
+ },
+ "username": {
+ "description": "Registry login username. Expects a username of letters, digits and . _ @ + -.",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Named container registries and the environment variables holding their credentials.",
+ "type": "object"
+ },
+ "routes": {
+ "description": "Ingress routes exposed by this workload.",
+ "items": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "const": "*"
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ },
+ "tls": {
+ "enum": [
+ "none",
+ "passthrough"
+ ]
+ }
+ },
+ "required": [
+ "protocol",
+ "tls"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "pattern": "^\\*\\."
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "http"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "tls": {
+ "const": "passthrough"
+ }
+ },
+ "required": [
+ "tls"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ }
+ },
+ "required": [
+ "protocol"
+ ]
+ }
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "entrypoint": {
+ "default": "websecure",
+ "description": "Named proxy listener used for the route.",
+ "type": "string"
+ },
+ "hostname": {
+ "anyOf": [
+ {
+ "maxLength": 253,
+ "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
+ },
+ {
+ "const": "*"
+ }
+ ],
+ "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.",
+ "examples": [
+ "shop.example.com"
+ ],
+ "type": "string"
+ },
+ "middlewares": {
+ "description": "Ordered provider-qualified middleware references applied to this route.",
+ "items": {
+ "description": "Expects a provider-qualified name such as secure-headers@file.",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$",
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "path": {
+ "default": "/",
+ "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.",
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port receiving routed traffic.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "protocol": {
+ "default": "http",
+ "description": "Routing protocol: http or tcp.",
+ "enum": [
+ "http",
+ "tcp"
+ ],
+ "type": "string"
+ },
+ "scheme": {
+ "default": "http",
+ "description": "Backend connection scheme for HTTP routes: http, https, or h2c.",
+ "enum": [
+ "http",
+ "https",
+ "h2c"
+ ],
+ "type": "string"
+ },
+ "tls": {
+ "default": "terminate",
+ "description": "TLS handling: terminate, passthrough, or none.",
+ "enum": [
+ "terminate",
+ "passthrough",
+ "none"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "hostname"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "runtime": {
+ "additionalProperties": false,
+ "description": "Project-wide environment files and local environment-file requirements.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "env_checks": {
+ "description": "Local environment-file assertions checked before planning or deploying.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative dotenv file whose declared keys are checked. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "present": {
+ "description": "Environment keys that must be declared but may be empty.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "require": {
+ "description": "Environment keys that must be declared with non-empty values.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "env_files": {
+ "description": "Project-wide ordered environment-file list for application, worker, and job workloads.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Decryptor used before staging the file. The supported encrypted provider is sops.",
+ "enum": [
+ "sops"
+ ],
+ "examples": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts a path to an environment file."
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "services": {
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "type": [
+ "string",
+ "number",
+ "integer"
+ ]
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "backup": {
+ "additionalProperties": false,
+ "description": "Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "allow_downtime": {
+ "default": false,
+ "description": "Whether recurring backup operations may use the driver-declared stopped-service window.",
+ "type": "boolean"
+ },
+ "drill": {
+ "additionalProperties": false,
+ "description": "Exact isolated restore-test schedule, proof age, and optional staging filesystem.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "max_age": {
+ "default": "7d",
+ "description": "Maximum age of the latest passing restore proof. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "7d"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "schedule": {
+ "additionalProperties": false,
+ "description": "Exact recurring isolated restore-test schedule.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cron": {
+ "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.",
+ "examples": [
+ "0 2 * * *"
+ ],
+ "pattern": "^[-0-9*/,A-Za-z ]+$",
+ "type": "string"
+ },
+ "timezone": {
+ "default": "UTC",
+ "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.",
+ "examples": [
+ "Europe/Berlin"
+ ],
+ "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "max_data_loss": {
+ "description": "Maximum tolerable interval between the latest recoverable point and failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "15m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "recovery_kind": {
+ "description": "Required recovery envelope: snapshot, pitr, or cold.",
+ "enum": [
+ "snapshot",
+ "pitr",
+ "cold"
+ ],
+ "examples": [
+ "pitr"
+ ],
+ "type": "string"
+ },
+ "retention": {
+ "additionalProperties": false,
+ "description": "Portable minimum recovery history that the selected native driver must be able to preserve.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "keep": {
+ "default": 7,
+ "description": "Minimum number of independently recoverable base generations to retain.",
+ "examples": [
+ 7
+ ],
+ "minimum": 1,
+ "type": "integer"
+ },
+ "window": {
+ "default": "7d",
+ "description": "Minimum continuous recovery history the native retention mapping must preserve. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "7d"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "schedule": {
+ "additionalProperties": false,
+ "description": "Exact recurring base-backup schedule.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cron": {
+ "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.",
+ "examples": [
+ "0 2 * * *"
+ ],
+ "pattern": "^[-0-9*/,A-Za-z ]+$",
+ "type": "string"
+ },
+ "timezone": {
+ "default": "UTC",
+ "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.",
+ "examples": [
+ "Europe/Berlin"
+ ],
+ "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "target": {
+ "description": "Name of a project-level backup target. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "examples": [
+ "offsite"
+ ],
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "driver": {
+ "description": "Built-in service driver. Defaults to the service map key. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "examples": [
+ "postgres"
+ ],
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "features": {
+ "additionalProperties": false,
+ "description": "Capabilities Onebox must establish before application workloads run.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "extensions": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {},
+ "type": "object"
+ },
+ "description": "PostgreSQL extensions Onebox installs in the managed application database before application migrations run.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_-]*$"
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "persistence": {
+ "additionalProperties": false,
+ "description": "Data-lifetime declaration for this supporting service.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "mode": {
+ "default": "durable",
+ "description": "Data lifetime: durable, ephemeral, or external.",
+ "enum": [
+ "durable",
+ "ephemeral",
+ "external"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "resources": {
+ "additionalProperties": false,
+ "description": "Memory and CPU limits for this supporting service.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cpus": {
+ "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.",
+ "examples": [
+ "0.5"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?$",
+ "type": "string"
+ },
+ "memory": {
+ "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.",
+ "examples": [
+ "512MB"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "settings": {
+ "additionalProperties": {},
+ "description": "Driver-specific settings validated by the selected service driver.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_-]*$"
+ },
+ "type": "object"
+ },
+ "version": {
+ "description": "Driver version or image tag to run.",
+ "examples": [
+ "17"
+ ]
+ },
+ "volumes": {
+ "description": "Additional driver-defined persistent volume names.",
+ "items": {
+ "description": "Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts the version to run."
+ },
+ "description": "Supporting services managed outside application releases, such as databases and caches.",
+ "type": "object"
+ },
+ "workloads": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "required": [
+ "execution"
+ ]
+ },
+ "then": {
+ "not": {
+ "required": [
+ "compose"
+ ]
+ },
+ "properties": {
+ "data_effect": {
+ "const": "none"
+ },
+ "deployment_phase": {
+ "const": "none"
+ },
+ "operator_run": {
+ "const": "allowed"
+ }
+ },
+ "required": [
+ "schedule",
+ "data_effect"
+ ]
+ }
+ },
+ {
+ "oneOf": [
+ {
+ "required": [
+ "build"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ },
+ {
+ "required": [
+ "compose"
+ ]
+ }
+ ]
+ },
+ {
+ "not": {
+ "allOf": [
+ {
+ "required": [
+ "published_ports"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "properties": {
+ "strategy": {
+ "const": "rolling"
+ }
+ },
+ "required": [
+ "strategy"
+ ]
+ },
+ {
+ "allOf": [
+ {
+ "not": {
+ "required": [
+ "strategy"
+ ]
+ }
+ },
+ {
+ "required": [
+ "health"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "properties": {
+ "role": {
+ "const": "application"
+ }
+ },
+ "required": [
+ "role"
+ ]
+ },
+ {
+ "not": {
+ "required": [
+ "role"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "persistence": {
+ "anyOf": [
+ {
+ "properties": {
+ "mode": {
+ "const": "durable"
+ }
+ },
+ "required": [
+ "mode"
+ ]
+ },
+ {
+ "not": {
+ "required": [
+ "mode"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ "required": [
+ "persistence"
+ ]
+ },
+ "then": {
+ "properties": {
+ "replicas": {
+ "maximum": 1
+ }
+ }
+ }
+ },
+ {
+ "else": {
+ "not": {
+ "anyOf": [
+ {
+ "required": [
+ "deployment_phase"
+ ]
+ },
+ {
+ "required": [
+ "operator_run"
+ ]
+ },
+ {
+ "required": [
+ "data_effect"
+ ]
+ },
+ {
+ "required": [
+ "schedule"
+ ]
+ },
+ {
+ "required": [
+ "inputs"
+ ]
+ },
+ {
+ "required": [
+ "execution"
+ ]
+ }
+ ]
+ }
+ },
+ "if": {
+ "properties": {
+ "role": {
+ "const": "job"
+ }
+ },
+ "required": [
+ "role"
+ ]
+ },
+ "then": {
+ "required": [
+ "data_effect"
+ ]
+ }
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "build": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "args": {
+ "additionalProperties": {},
+ "description": "Build arguments supplied by the external build system.",
+ "type": "object"
+ },
+ "context": {
+ "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "."
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "dockerfile": {
+ "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "Dockerfile"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "target": {
+ "description": "Named Dockerfile stage to build.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path."
+ },
+ "command": {
+ "anyOf": [
+ {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ {
+ "description": "Container command as a shell string or argument list.",
+ "examples": [
+ "./bin/server"
+ ]
+ }
+ ],
+ "description": "Container command as a shell string or argument list. Also accepts a command line or argument list."
+ },
+ "compose": {
+ "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.",
+ "examples": [
+ "docker-compose.yml#web"
+ ],
+ "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$",
+ "type": "string"
+ },
+ "data_effect": {
+ "description": "Job data impact used by rollback and abort gates.",
+ "enum": [
+ "none",
+ "migration",
+ "destructive",
+ "unknown"
+ ],
+ "examples": [
+ "migration"
+ ],
+ "type": "string"
+ },
+ "deployment_phase": {
+ "default": "none",
+ "description": "Deployment phase for this job: none, pre_release, or post_release.",
+ "enum": [
+ "none",
+ "pre_release",
+ "post_release"
+ ],
+ "type": "string"
+ },
+ "drain": {
+ "additionalProperties": false,
+ "description": "Signal and timing used to remove a container from traffic before stopping it.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "grace": {
+ "description": "Maximum graceful-shutdown time before forced termination, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "signal": {
+ "default": "TERM",
+ "description": "Signal sent to begin graceful shutdown. Expects a signal name such as TERM or QUIT.",
+ "pattern": "^[A-Z][A-Z0-9]*$",
+ "type": "string"
+ },
+ "wait": {
+ "description": "Maximum drain window before shutdown continues, at most 7d. Recreate workloads continue sooner when every old container exits. Rolling workloads wait the full interval before stopping each container when their health check supports drain guarding. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "10s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "entrypoint": {
+ "anyOf": [
+ {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ {
+ "description": "Container entrypoint as a string or argument list."
+ }
+ ],
+ "description": "Container entrypoint as a string or argument list. Also accepts an entrypoint or argument list."
+ },
+ "env": {
+ "additionalProperties": {},
+ "description": "Literal container environment values. Managed-service credential variables cannot be overridden.",
+ "type": "object"
+ },
+ "env_files": {
+ "description": "Workload-specific ordered environment-file list. Replaces broader defaults when present.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Decryptor used before staging the file. The supported encrypted provider is sops.",
+ "enum": [
+ "sops"
+ ],
+ "examples": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts a path to an environment file."
+ },
+ "type": "array"
+ },
+ "execution": {
+ "additionalProperties": false,
+ "description": "Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with data_effect none. Stores non-secret checkpoints on the host and permits explicit same-release resume.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "retention": {
+ "default": "168h",
+ "description": "Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "steps": {
+ "description": "Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "command": {
+ "description": "Argument vector passed to the job image's entrypoint. No shell evaluation is performed.",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 128,
+ "minItems": 1,
+ "type": "array"
+ },
+ "id": {
+ "description": "Unique stable step identifier, used by output references. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "inputs": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Environment variables populated from a preceding step's declared output, written as step.OUTPUT.",
+ "propertyNames": {
+ "pattern": "^[A-Z][A-Z0-9_]*$"
+ },
+ "type": "object"
+ },
+ "outputs": {
+ "description": "Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total.",
+ "items": {
+ "description": "Expects upper-case letters, digits and underscores, starting with a letter.",
+ "pattern": "^[A-Z][A-Z0-9_]*$",
+ "type": "string"
+ },
+ "maxItems": 32,
+ "type": "array",
+ "uniqueItems": true
+ },
+ "retry": {
+ "additionalProperties": false,
+ "description": "Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "attempts": {
+ "default": 1,
+ "description": "Total attempts including the first, 1 to 10.",
+ "examples": [
+ 3
+ ],
+ "maximum": 10,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "backoff": {
+ "default": "30s",
+ "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "1m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "max_backoff": {
+ "default": "10m",
+ "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "required": [
+ "id",
+ "command"
+ ],
+ "type": "object"
+ },
+ "maxItems": 32,
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "extra_hosts": {
+ "description": "Additional host-to-address entries added to the container.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "health": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Readiness check used to gate rolling replacement.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "exec": {
+ "description": "Health command as a shell string or direct argument list."
+ },
+ "http": {
+ "description": "HTTP path probed inside the container. Expects a path beginning with /.",
+ "examples": [
+ "/healthz"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "interval": {
+ "default": "5s",
+ "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "2s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port probed by HTTP or TCP health checks.",
+ "examples": [
+ 8080
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "retries": {
+ "default": 3,
+ "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.",
+ "examples": [
+ 3
+ ],
+ "type": "integer"
+ },
+ "start_period": {
+ "default": "30s",
+ "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "tcp": {
+ "default": false,
+ "description": "Probe the configured port by opening a TCP connection.",
+ "type": "boolean"
+ },
+ "within": {
+ "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "120s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path."
+ },
+ "hostname": {
+ "description": "Hostname assigned inside the workload container.",
+ "type": "string"
+ },
+ "image": {
+ "anyOf": [
+ {
+ "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Container image source, written as a reference string or an object.",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "pull": {
+ "default": "missing",
+ "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.",
+ "enum": [
+ "always",
+ "missing",
+ "never"
+ ],
+ "type": "string"
+ },
+ "reference": {
+ "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Container image source, written as a reference string or an object. Also accepts an image reference."
+ },
+ "init": {
+ "description": "Run a minimal init process as PID 1 inside the container.",
+ "type": "boolean"
+ },
+ "inputs": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "oneOf": [
+ {
+ "required": [
+ "enum"
+ ]
+ },
+ {
+ "required": [
+ "pattern"
+ ]
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "default": {
+ "description": "Value used by a timer firing and by an operator run that does not override it. Must satisfy the input's own constraint.",
+ "type": "string"
+ },
+ "description": {
+ "description": "What the input controls.",
+ "type": "string"
+ },
+ "enum": {
+ "description": "Accepted values.",
+ "examples": [
+ [
+ "catalog"
+ ]
+ ],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "pattern": {
+ "description": "Regular expression the whole value must match.",
+ "examples": [
+ "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "default"
+ ],
+ "type": "object"
+ },
+ "description": "Declared parameters of a scheduled job, exposed as environment variables. Names are upper-case identifiers; each declares exactly one of enum or pattern and a default. A timer firing uses the defaults; ob job run may override them.",
+ "propertyNames": {
+ "pattern": "^[A-Z][A-Z0-9_]*$"
+ },
+ "type": "object"
+ },
+ "labels": {
+ "additionalProperties": {},
+ "description": "Additional container labels outside namespaces reserved by Onebox and the proxy.",
+ "type": "object"
+ },
+ "logging": {
+ "additionalProperties": false,
+ "description": "Container logging driver and driver-specific options.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "driver": {
+ "description": "Container runtime logging driver. Expects a log driver name such as local, json-file or an org/plugin:tag.",
+ "examples": [
+ "local"
+ ],
+ "pattern": "^[a-z0-9][a-z0-9_.-]*(/[a-z0-9][a-z0-9_.-]*)?(:[A-Za-z0-9_.-]+)?$",
+ "type": "string"
+ },
+ "options": {
+ "additionalProperties": {},
+ "description": "Driver-specific logging options passed to the container runtime.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_.-]*$"
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "needs": {
+ "description": "Workload or supporting-service prerequisites and optional connection-variable mappings.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "condition": {
+ "description": "Prerequisite condition: started, healthy, or completed.",
+ "enum": [
+ "started",
+ "healthy",
+ "completed"
+ ],
+ "type": "string"
+ },
+ "env": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Maps application environment-variable names to service connection parts such as host, port, user, password, database, or url.",
+ "type": "object"
+ },
+ "name": {
+ "description": "Name of a workload or supporting service that must start first. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts the name of a prerequisite."
+ },
+ "type": "array"
+ },
+ "operator_run": {
+ "description": "Whether an operator may invoke this job outside deployment: allowed or disabled. Defaults to allowed for phase none and disabled otherwise.",
+ "enum": [
+ "allowed",
+ "disabled"
+ ],
+ "type": "string"
+ },
+ "persistence": {
+ "additionalProperties": false,
+ "description": "Declares whether this workload holds data that must outlive releases.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "mode": {
+ "default": "durable",
+ "description": "Data lifetime: durable, ephemeral, or external.",
+ "enum": [
+ "durable",
+ "ephemeral",
+ "external"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "port": {
+ "description": "Default container port used by HTTP health checks.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "published_ports": {
+ "description": "Host ports published outside the proxy. They bind to loopback by default. A rolling workload cannot publish one, because two replicas cannot hold the same host port during a roll: set strategy: recreate, or route through the proxy instead.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "bind": {
+ "default": "127.0.0.1",
+ "description": "Host address on which the published port listens.",
+ "type": "string"
+ },
+ "container": {
+ "description": "Port receiving traffic inside the container.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "host": {
+ "description": "Port exposed on the host.",
+ "examples": [
+ 8080
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "protocol": {
+ "default": "tcp",
+ "description": "Published transport protocol: tcp or udp.",
+ "enum": [
+ "tcp",
+ "udp"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "replicas": {
+ "default": 1,
+ "description": "Desired number of long-running workload containers.",
+ "examples": [
+ 2
+ ],
+ "minimum": 1,
+ "type": "integer"
+ },
+ "resources": {
+ "additionalProperties": false,
+ "description": "Container memory and CPU limits.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cpus": {
+ "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.",
+ "examples": [
+ "0.5"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?$",
+ "type": "string"
+ },
+ "memory": {
+ "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.",
+ "examples": [
+ "512MB"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "role": {
+ "description": "Lifecycle role: application, worker, daemon, or job.",
+ "enum": [
+ "application",
+ "worker",
+ "daemon",
+ "job"
+ ],
+ "examples": [
+ "application"
+ ],
+ "type": "string"
+ },
+ "routes": {
+ "description": "Ingress routes exposed by this workload.",
+ "items": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "const": "*"
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ },
+ "tls": {
+ "enum": [
+ "none",
+ "passthrough"
+ ]
+ }
+ },
+ "required": [
+ "protocol",
+ "tls"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "pattern": "^\\*\\."
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "http"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "tls": {
+ "const": "passthrough"
+ }
+ },
+ "required": [
+ "tls"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ }
+ },
+ "required": [
+ "protocol"
+ ]
+ }
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "entrypoint": {
+ "default": "websecure",
+ "description": "Named proxy listener used for the route.",
+ "type": "string"
+ },
+ "hostname": {
+ "anyOf": [
+ {
+ "maxLength": 253,
+ "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
+ },
+ {
+ "const": "*"
+ }
+ ],
+ "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.",
+ "examples": [
+ "shop.example.com"
+ ],
+ "type": "string"
+ },
+ "middlewares": {
+ "description": "Ordered provider-qualified middleware references applied to this route.",
+ "items": {
+ "description": "Expects a provider-qualified name such as secure-headers@file.",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$",
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "path": {
+ "default": "/",
+ "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.",
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port receiving routed traffic.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "protocol": {
+ "default": "http",
+ "description": "Routing protocol: http or tcp.",
+ "enum": [
+ "http",
+ "tcp"
+ ],
+ "type": "string"
+ },
+ "scheme": {
+ "default": "http",
+ "description": "Backend connection scheme for HTTP routes: http, https, or h2c.",
+ "enum": [
+ "http",
+ "https",
+ "h2c"
+ ],
+ "type": "string"
+ },
+ "tls": {
+ "default": "terminate",
+ "description": "TLS handling: terminate, passthrough, or none.",
+ "enum": [
+ "terminate",
+ "passthrough",
+ "none"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "hostname"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "schedule": {
+ "additionalProperties": false,
+ "description": "Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "catch_up": {
+ "default": true,
+ "description": "Run once after the host returns if an elapsed schedule was missed while it was offline.",
+ "type": "boolean"
+ },
+ "cron": {
+ "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.",
+ "examples": [
+ "0 2 * * *"
+ ],
+ "pattern": "^[-0-9*/,A-Za-z ]+$",
+ "type": "string"
+ },
+ "deploy_lock": {
+ "default": "exclusive",
+ "description": "Deployment coordination policy: exclusive blocks application operations for the full run; pinned leases the immutable starting release and permits only deployments without data-changing jobs or untyped hooks.",
+ "enum": [
+ "exclusive",
+ "pinned"
+ ],
+ "examples": [
+ "pinned"
+ ],
+ "type": "string"
+ },
+ "notify": {
+ "default": [
+ "failure",
+ "timeout"
+ ],
+ "description": "Run outcomes that send the configured notifications: success, failure, timeout, skipped.",
+ "items": {
+ "enum": [
+ "success",
+ "failure",
+ "timeout",
+ "skipped"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "retry": {
+ "additionalProperties": false,
+ "description": "Bounded retry inside one timer firing. Attempts run under the same locks and the same timeout; a timeout ends the run.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "attempts": {
+ "default": 1,
+ "description": "Total attempts including the first, 1 to 10.",
+ "examples": [
+ 3
+ ],
+ "maximum": 10,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "backoff": {
+ "default": "30s",
+ "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "1m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "max_backoff": {
+ "default": "10m",
+ "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "shutdown_grace": {
+ "default": "30s",
+ "description": "Time allowed for graceful container shutdown after the run deadline before Onebox forces removal. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "45s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "timeout": {
+ "default": "1h",
+ "description": "Maximum wall time for one scheduled run before systemd terminates it and records failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "timezone": {
+ "default": "UTC",
+ "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.",
+ "examples": [
+ "Europe/Berlin"
+ ],
+ "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "stdin_open": {
+ "description": "Keep standard input open for the container.",
+ "type": "boolean"
+ },
+ "strategy": {
+ "description": "Replacement strategy for a changed or uncertain workload. An unchanged healthy workload is retained automatically. Defaults to rolling only for an application workload with health; all other workloads default to recreate.",
+ "enum": [
+ "rolling",
+ "recreate"
+ ],
+ "type": "string"
+ },
+ "tty": {
+ "description": "Allocate a pseudo-TTY for the container.",
+ "type": "boolean"
+ },
+ "user": {
+ "description": "User or UID used to run the container process.",
+ "type": "string"
+ },
+ "volumes": {
+ "description": "Managed named volumes or bind mounts. Relative bind sources are read-only release content; absolute sources are external host state.",
+ "items": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "source": {
+ "pattern": "^[^/]"
+ }
+ },
+ "required": [
+ "source"
+ ]
+ },
+ "then": {
+ "properties": {
+ "mode": {
+ "const": "ro"
+ }
+ },
+ "required": [
+ "mode"
+ ]
+ }
+ }
+ ],
+ "anyOf": [
+ {
+ "required": [
+ "name",
+ "path"
+ ]
+ },
+ {
+ "required": [
+ "source",
+ "path"
+ ]
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "mode": {
+ "default": "rw",
+ "description": "Mount access mode: rw or ro. A relative bind source requires ro.",
+ "enum": [
+ "rw",
+ "ro"
+ ],
+ "type": "string"
+ },
+ "name": {
+ "description": "Stable logical name of a Onebox-managed volume. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "examples": [
+ "data"
+ ],
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "path": {
+ "description": "Absolute container path where the volume or bind mount is attached. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/var/lib/app"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "source": {
+ "description": "Bind mount source. An absolute path is external host state that outlives releases. A dot-prefixed repository path is read-only release content, kept for as long as a container still mounts it. Expects an absolute host path or a dot-prefixed path inside the repository, with no colon, control character or shell metacharacter.",
+ "examples": [
+ "./config"
+ ],
+ "not": {
+ "pattern": "(^|/)\\.\\.(/|$)"
+ },
+ "pattern": "^(/[^\\x00-\\x1f'\"$`\\\\:]*|\\.(?:/[^\\x00-\\x1f'\"$`\\\\:]*)?)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "working_dir": {
+ "description": "Absolute working directory for the container process. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/app"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Application containers, workers, daemons, and jobs managed as releases.",
+ "type": "object"
+ }
+ },
+ "required": [
+ "api_version",
+ "environments"
+ ],
+ "title": "Onebox project (onebox.run/v2)",
+ "type": "object"
+}
diff --git a/e2e/apps/README.md b/e2e/apps/README.md
index 9d3ccf4a..105b2638 100644
--- a/e2e/apps/README.md
+++ b/e2e/apps/README.md
@@ -1,6 +1,6 @@
# Deployable application fixtures
-Self-contained `onebox.run/v1` projects for real open-source applications,
+Self-contained `onebox.run/v2` projects for real open-source applications,
chosen for the shape people normally build rather than for being exotic. Each
declares everything it needs, so it renders and runs without a Compose
reference.
diff --git a/e2e/apps/authentik.yml b/e2e/apps/authentik.yml
index fba59cab..49bb9bdb 100644
--- a/e2e/apps/authentik.yml
+++ b/e2e/apps/authentik.yml
@@ -2,7 +2,7 @@
# application's image — none of which any deployed fixture had, and the bind
# mount in particular is the one a converted Compose file almost always brings
# with it.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: authentik
environments:
production: {server: root@TARGET}
@@ -12,8 +12,8 @@ workloads:
strategy: recreate
image: ghcr.io/goauthentik/server:2025.2.4
command: [server]
- domain: auth.example.com
- port: 9000
+ routes:
+ - {hostname: auth.example.com, port: 9000}
needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}]
published_ports: [{host: 9000, container: 9000}]
volumes:
diff --git a/e2e/apps/ghost.yml b/e2e/apps/ghost.yml
index e0793ddf..4c3bfd0a 100644
--- a/e2e/apps/ghost.yml
+++ b/e2e/apps/ghost.yml
@@ -1,7 +1,7 @@
# Ghost on managed MySQL. The point of this fixture is the driver: mysql is in
# the catalogue and had never run on a host, so the credential generation, the
# connection file and the health check were all unproven for it.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: ghost
environments:
production: {server: root@TARGET}
@@ -9,8 +9,8 @@ workloads:
ghost:
role: application
image: ghost:5-alpine
- domain: blog.example.com
- port: 2368
+ routes:
+ - {hostname: blog.example.com, port: 2368}
needs:
- name: mysql
env:
diff --git a/e2e/apps/gitea.yml b/e2e/apps/gitea.yml
index aa388daa..ca6427af 100644
--- a/e2e/apps/gitea.yml
+++ b/e2e/apps/gitea.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: gitea
environments:
production: {server: root@TARGET}
@@ -6,8 +6,8 @@ workloads:
server:
role: application
image: docker.gitea.com/gitea:1.22.6
- domain: git.example.com
- port: 3000
+ routes:
+ - {hostname: git.example.com, port: 3000}
needs: [db]
published_ports: [{host: 2222, container: 22}]
env:
diff --git a/e2e/apps/immich.yml b/e2e/apps/immich.yml
index 4186fc23..29e7105a 100644
--- a/e2e/apps/immich.yml
+++ b/e2e/apps/immich.yml
@@ -1,7 +1,7 @@
# Immich. Its database is Postgres with the pgvector extension, which is not
# the image the managed driver runs — so it is a daemon the user owns, which is
# exactly the boundary the contract draws. Also the heaviest images in the set.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: immich
environments:
production: {server: root@TARGET}
@@ -9,8 +9,8 @@ workloads:
server:
role: application
image: ghcr.io/immich-app/immich-server:v1.125.7
- domain: photos.example.com
- port: 2283
+ routes:
+ - {hostname: photos.example.com, port: 2283}
needs: [{name: database, condition: healthy}, {name: redis, condition: healthy}]
volumes: [{name: upload, path: /usr/src/app/upload}]
env:
diff --git a/e2e/apps/n8n.yml b/e2e/apps/n8n.yml
index c0c1f405..f45f5ecf 100644
--- a/e2e/apps/n8n.yml
+++ b/e2e/apps/n8n.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: n8n
environments:
production: {server: root@TARGET}
@@ -6,8 +6,8 @@ workloads:
n8n:
role: application
image: docker.n8n.io/n8nio/n8n:1.73.1
- domain: n8n.example.com
- port: 5678
+ routes:
+ - {hostname: n8n.example.com, port: 5678}
needs: [postgres, redis]
env:
DB_TYPE: postgresdb
diff --git a/e2e/apps/one-app-one-host.sh b/e2e/apps/one-app-one-host.sh
index 465eb07a..60cab264 100755
--- a/e2e/apps/one-app-one-host.sh
+++ b/e2e/apps/one-app-one-host.sh
@@ -85,7 +85,7 @@ name, routed = None, None
for line in doc.splitlines():
if line.startswith(" ") and line.endswith(":") and not line.startswith(" "):
name = line.strip().rstrip(":")
- if name and ("domain:" in line or "routes:" in line) and routed is None:
+ if name and "routes:" in line and routed is None:
routed = name
print(routed or "")
')
diff --git a/e2e/apps/paperless.yml b/e2e/apps/paperless.yml
index 6ea69209..fe00581e 100644
--- a/e2e/apps/paperless.yml
+++ b/e2e/apps/paperless.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: paperless
environments:
production: {server: root@TARGET}
@@ -6,8 +6,8 @@ workloads:
webserver:
role: application
image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7
- domain: paperless.example.com
- port: 8000
+ routes:
+ - {hostname: paperless.example.com, port: 8000}
needs: [db, broker, gotenberg, tika]
env:
PAPERLESS_REDIS: redis://broker:6379
diff --git a/e2e/apps/penpot.yml b/e2e/apps/penpot.yml
index 49996858..6c1ee73e 100644
--- a/e2e/apps/penpot.yml
+++ b/e2e/apps/penpot.yml
@@ -1,7 +1,7 @@
# Penpot. Two routes on one workload's host — the frontend serves the app and
# proxies /api to the backend — which is the multi-route shape that only ever
# existed in a synthetic fixture until now.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: penpot
environments:
production: {server: root@TARGET}
@@ -10,8 +10,8 @@ workloads:
role: application
image: penpotapp/frontend:2.4.3
routes:
- - {domain: penpot.example.com, path: /, port: 80}
- - {domain: penpot.example.com, path: /api, port: 80}
+ - {hostname: penpot.example.com, path: /, port: 80}
+ - {hostname: penpot.example.com, path: /api, port: 80}
needs: [backend]
env:
PENPOT_FLAGS: disable-registration disable-email-verification
diff --git a/e2e/apps/rocketchat.yml b/e2e/apps/rocketchat.yml
index 615c0250..83e7bd12 100644
--- a/e2e/apps/rocketchat.yml
+++ b/e2e/apps/rocketchat.yml
@@ -10,7 +10,7 @@
# Kept as a rendering and validation case, and as the thing to re-run if the
# driver ever configures a replica set. An application that needs one today
# wants a daemon workload it owns.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: rocketchat
environments:
production: {server: root@TARGET}
@@ -18,8 +18,8 @@ workloads:
rocketchat:
role: application
image: registry.rocket.chat/rocketchat/rocket.chat:7.3.0
- domain: chat.example.com
- port: 3000
+ routes:
+ - {hostname: chat.example.com, port: 3000}
needs:
- name: mongodb
env: {MONGO_URL: url}
diff --git a/e2e/apps/umami.yml b/e2e/apps/umami.yml
index f8af6f42..8960402a 100644
--- a/e2e/apps/umami.yml
+++ b/e2e/apps/umami.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: umami
environments:
production: {server: root@TARGET}
@@ -6,8 +6,8 @@ workloads:
umami:
role: application
image: ghcr.io/umami-software/umami:postgresql-v2.13.2
- domain: analytics.example.com
- port: 3000
+ routes:
+ - {hostname: analytics.example.com, port: 3000}
needs: [db]
env:
DATABASE_URL: postgresql://umami:umami@db:5432/umami
diff --git a/e2e/apps/uptime-kuma.yml b/e2e/apps/uptime-kuma.yml
index 576cf998..53ea0498 100644
--- a/e2e/apps/uptime-kuma.yml
+++ b/e2e/apps/uptime-kuma.yml
@@ -1,8 +1,8 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: uptime-kuma
environments:
production: {server: root@TARGET}
image: louislam/uptime-kuma:1.23.16
-domain: kuma.example.com
-port: 3001
+routes:
+ - {hostname: kuma.example.com, port: 3001}
health: {http: /, port: 3001, interval: 10s, start_period: 30s, retries: 5}
diff --git a/e2e/apps/vaultwarden.yml b/e2e/apps/vaultwarden.yml
index 4b2da653..85dce82b 100644
--- a/e2e/apps/vaultwarden.yml
+++ b/e2e/apps/vaultwarden.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: vaultwarden
environments:
production: {server: root@TARGET}
@@ -6,7 +6,7 @@ workloads:
server:
role: application
image: vaultwarden/server:1.32.7
- domain: vault.example.com
- port: 80
+ routes:
+ - {hostname: vault.example.com, port: 80}
env: {WEBSOCKET_ENABLED: "true", SIGNUPS_ALLOWED: "false"}
volumes: [{name: data, path: /data}]
diff --git a/e2e/destroy_test.go b/e2e/destroy_test.go
index c9dff16f..de36bc62 100644
--- a/e2e/destroy_test.go
+++ b/e2e/destroy_test.go
@@ -27,7 +27,7 @@ func TestDestroyUsesReleaseRecordedInterpolationEnvironment(t *testing.T) {
releaseID := "20260821-120000-legacy"
volume := application + "_legacy_data"
- currentBody := fmt.Sprintf(`api_version: onebox.run/v1
+ currentBody := fmt.Sprintf(`api_version: onebox.run/v2
app: %s
base_path: %q
environments:
diff --git a/e2e/network_ownership_test.go b/e2e/network_ownership_test.go
index b791a8f7..264949ed 100644
--- a/e2e/network_ownership_test.go
+++ b/e2e/network_ownership_test.go
@@ -24,7 +24,7 @@ func TestApplicationNetworkOwnershipAndExternalLifecycle(t *testing.T) {
application := fmt.Sprintf("obnet%d", os.Getpid())
network := application + "_default"
- projectBody := fmt.Sprintf(`api_version: onebox.run/v1
+ projectBody := fmt.Sprintf(`api_version: onebox.run/v2
app: %s
environments:
production: {server: root@localhost}
diff --git a/e2e/server_execution_test.go b/e2e/server_execution_test.go
index 1d0e3c4e..ed8094e6 100644
--- a/e2e/server_execution_test.go
+++ b/e2e/server_execution_test.go
@@ -27,7 +27,7 @@ func TestServerDurableExecutions(t *testing.T) {
defer cancel()
_, _ = s.output(ctx, "systemctl disable --now "+unit+".timer >/dev/null 2>&1; systemctl stop "+unit+".service >/dev/null 2>&1; docker rm -f "+name+"-refresh-1 >/dev/null 2>&1; rm -f /etc/systemd/system/"+unit+".*; systemctl daemon-reload; rm -rf "+base)
})
- project := fmt.Sprintf(`api_version: onebox.run/v1
+ project := fmt.Sprintf(`api_version: onebox.run/v2
app: %s
base_path: %s
environments: {production: {server: %s}}
diff --git a/e2e/testdata/app/ob.yml b/e2e/testdata/app/ob.yml
index a1d17b80..f14928c5 100644
--- a/e2e/testdata/app/ob.yml
+++ b/e2e/testdata/app/ob.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: obe2e
environments:
production: { server: local } # e2e uses the local transport; the value is unused
diff --git a/e2e/testdata/postgres/ob.yml.tmpl b/e2e/testdata/postgres/ob.yml.tmpl
index 6dc07df8..b66a48f0 100644
--- a/e2e/testdata/postgres/ob.yml.tmpl
+++ b/e2e/testdata/postgres/ob.yml.tmpl
@@ -4,7 +4,7 @@
# Rendered rather than checked in whole, because two values are only known once
# the guest is up: the address ob connects to, and the endpoint the object
# store is reachable at from inside the container.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: observer
environments:
production:
diff --git a/e2e/testdata/worker/ob-broken.yml b/e2e/testdata/worker/ob-broken.yml
index 370aa171..9205205c 100644
--- a/e2e/testdata/worker/ob-broken.yml
+++ b/e2e/testdata/worker/ob-broken.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: obworker
environments: { production: { server: local } }
workloads:
diff --git a/e2e/testdata/worker/ob.yml b/e2e/testdata/worker/ob.yml
index 3b8df8c4..abb32764 100644
--- a/e2e/testdata/worker/ob.yml
+++ b/e2e/testdata/worker/ob.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: obworker
environments: { production: { server: local } }
workloads:
diff --git a/internal/app/backup_schema_test.go b/internal/app/backup_schema_test.go
index 806eaf82..57b876fc 100644
--- a/internal/app/backup_schema_test.go
+++ b/internal/app/backup_schema_test.go
@@ -6,7 +6,7 @@ import (
"testing"
)
-const validBackupProject = `api_version: onebox.run/v1
+const validBackupProject = `api_version: onebox.run/v2
app: shop
environments:
production:
@@ -66,7 +66,7 @@ func TestBackupIntentLoadsAndDefaultsToExactSchedules(t *testing.T) {
// The refusal belongs at the point the policy is written, so this is now the
// same rejection every other unqualified driver gets.
func TestMinIOBackupIntentIsRefusedUntilItsContractRuns(t *testing.T) {
- project := `api_version: onebox.run/v1
+ project := `api_version: onebox.run/v2
app: shop
environments: {production: {server: deploy@app.example.net}}
workloads: {web: {image: nginx:1}}
@@ -100,7 +100,7 @@ func TestReplicationIntentIsRejected(t *testing.T) {
}
func TestRunnableUnqualifiedDriverRejectsBackupWithoutFallback(t *testing.T) {
- if _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ if _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: deploy@app.example.net}}
workloads: {web: {image: nginx:1}}
diff --git a/internal/app/canonical_test.go b/internal/app/canonical_test.go
index 945c59b8..96251e44 100644
--- a/internal/app/canonical_test.go
+++ b/internal/app/canonical_test.go
@@ -5,7 +5,7 @@ import (
"testing"
)
-const canonicalProject = `api_version: onebox.run/v1
+const canonicalProject = `api_version: onebox.run/v2
app: ledger
environments:
production: {server: root@1.2.3.4}
@@ -13,8 +13,8 @@ environments:
server: root@5.6.7.8
overrides: {workloads: {ledger: {replicas: 3}}}
build: .
-domain: ledger.example.com
-port: 8080
+routes:
+ - {hostname: ledger.example.com, port: 8080}
`
func originsFor(t *testing.T, env string) map[string]Origin {
@@ -40,14 +40,14 @@ func originsFor(t *testing.T, env string) map[string]Origin {
func TestOriginsDistinguishWhatWasWritten(t *testing.T) {
o := originsFor(t, "production")
for path, want := range map[string]Origin{
- "app": OriginAuthored,
- "workloads.ledger.build.context": OriginAuthored,
- "workloads.ledger.domain": OriginShorthand,
- "workloads.ledger.port": OriginShorthand,
- "workloads.ledger.replicas": OriginDefault,
- "workloads.ledger.strategy": OriginDefault,
- "base_path": OriginDefault,
- "proxy.network": OriginDefault,
+ "app": OriginAuthored,
+ "workloads.ledger.build.context": OriginAuthored,
+ "workloads.ledger.routes[0].hostname": OriginAuthored,
+ "workloads.ledger.routes[0].port": OriginAuthored,
+ "workloads.ledger.replicas": OriginDefault,
+ "workloads.ledger.strategy": OriginDefault,
+ "base_path": OriginDefault,
+ "proxy.network": OriginDefault,
} {
if o[path] != want {
t.Errorf("%s = %q, want %q", path, o[path], want)
@@ -88,8 +88,8 @@ func TestCanonicalAnnotatesOnlyWhatWasNotWritten(t *testing.T) {
if !strings.Contains(out, "replicas: 3 # environment-override") {
t.Errorf("the override should be marked\n%s", out)
}
- if !strings.Contains(out, "# default") || !strings.Contains(out, "# shorthand") {
- t.Errorf("defaults and shorthand should be marked\n%s", out)
+ if !strings.Contains(out, "# default") {
+ t.Errorf("defaults should be marked\n%s", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "app: ledger") && strings.Contains(line, "#") {
@@ -216,14 +216,14 @@ func TestCanonicalFactsRejectUnsafeObservedValuesWithoutReflectingThem(t *testin
// silently absent, and the canonical form — the thing people read to find out
// what Onebox understood — did not show it either.
func TestEveryDefaultAppearsAsDerived(t *testing.T) {
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
web:
role: application
image: nginx
- routes: [{domain: shop.example.com, port: 80}]
+ routes: [{hostname: shop.example.com, port: 80}]
volumes: [{name: data, path: /data}]
published_ports: [{host: 9000, container: 9000}]
persistence: {}
diff --git a/internal/app/compose_test.go b/internal/app/compose_test.go
index 75f7537c..1cee2347 100644
--- a/internal/app/compose_test.go
+++ b/internal/app/compose_test.go
@@ -132,7 +132,7 @@ func TestPathEscapeRefused(t *testing.T) {
// TestComposeRefRendersEndToEnd puts the merge through generation.
func TestComposeRefRendersEndToEnd(t *testing.T) {
- y := `api_version: onebox.run/v1
+ y := `api_version: onebox.run/v2
app: ledger
environments:
production: {server: root@1.2.3.4}
@@ -140,8 +140,8 @@ workloads:
web:
role: application
image: nginx
- domain: ledger.example.com
- port: 8080
+ routes:
+ - {hostname: ledger.example.com, port: 8080}
db:
role: daemon
compose: compose.yaml#postgres
@@ -227,7 +227,7 @@ func TestADeclaredHealthCheckReachesAReferencedService(t *testing.T) {
t.Fatal(err)
}
path := filepath.Join(dir, "ob.yml")
- if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1
+ if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
diff --git a/internal/app/constraints.go b/internal/app/constraints.go
index 7b281033..fb3e63ee 100644
--- a/internal/app/constraints.go
+++ b/internal/app/constraints.go
@@ -114,14 +114,13 @@ var (
gDNSResolver = grammar{"DNS resolver", regexp.MustCompile(`^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9A-Fa-f:.]+\]):[0-9]{1,5}$`),
"a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port"}
- // Exact route hosts predate strict hostname validation. Keep accepting their
- // established spellings (including upper-case and a trailing dot), while
- // excluding the characters that can escape Traefik's backtick literal.
- gRouteHost = grammar{"route host", regexp.MustCompile("^[^\\x00-\\x1f\\x7f`*]+$"),
- "an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing"}
-
- gWildcardSuffix = grammar{"wildcard DNS suffix", regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`),
- "a lower-case ASCII or Punycode DNS hostname whose labels contain 1 to 63 characters"}
+ gRouteHostname = grammar{"route hostname", regexp.MustCompile(`^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`),
+ "a lower-case ASCII or IDNA A-label hostname, optionally prefixed by the complete wildcard label *."}
+ // v1 exact route hosts were deliberately permissive. Immutable v1 release
+ // snapshots must retain that grammar so a v2 runner can still roll them back
+ // or finish their lifecycle; new authored projects never use it.
+ gLegacyRouteHost = grammar{"route host", regexp.MustCompile("^[^\\x00-\\x1f\\x7f`*]+$"),
+ "an exact host with no wildcard, control character or backtick"}
gCalVer = grammar{"version", buildinfo.ReleaseVersionPattern,
"a CalVer release such as v2026.8.0"}
diff --git a/internal/app/contract_shapes_test.go b/internal/app/contract_shapes_test.go
index 32b4cc27..2eaea824 100644
--- a/internal/app/contract_shapes_test.go
+++ b/internal/app/contract_shapes_test.go
@@ -39,7 +39,7 @@ func canonicalOf(t *testing.T, body string) string {
return string(out)
}
-const shapeHead = "api_version: onebox.run/v1\napp: shop\n"
+const shapeHead = "api_version: onebox.run/v2\napp: shop\n"
// 3.4 — a scalar shorthand and its object form are the same project.
//
@@ -54,8 +54,8 @@ func TestEveryShorthandEqualsItsObjectForm(t *testing.T) {
"environments: {production: {server: root@h}}\nimage: {reference: nginx}\n",
},
"health": {
- "environments: {production: {server: root@h}}\nimage: nginx\ndomain: x\nport: 8080\nhealth: /healthz\n",
- "environments: {production: {server: root@h}}\nimage: nginx\ndomain: x\nport: 8080\nhealth: {http: /healthz}\n",
+ "environments: {production: {server: root@h}}\nimage: nginx\nport: 8080\nhealth: /healthz\n",
+ "environments: {production: {server: root@h}}\nimage: nginx\nport: 8080\nhealth: {http: /healthz}\n",
},
"server": {
"environments: {production: {server: root@203.0.113.10}}\nimage: nginx\n",
@@ -103,7 +103,7 @@ func TestCanonicalOutputIsStableAcrossRuns(t *testing.T) {
staging: {server: root@h2}
workloads:
zebra: {role: worker, image: nginx}
- alpha: {role: application, image: nginx, health: /healthz, domain: a.example.com, port: 1}
+ alpha: {role: application, image: nginx, health: /healthz, routes: [{hostname: a.example.com, port: 1}]}
middle: {role: worker, image: nginx}
services:
redis: "7.4"
@@ -128,7 +128,7 @@ notifications:
func TestInspectionChangesNothingOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ob.yml")
- body := shapeHead + "environments: {production: {server: root@h}}\nimage: nginx\ndomain: shop.example.com\nport: 3000\n"
+ body := shapeHead + "environments: {production: {server: root@h}}\nimage: nginx\nroutes: [{hostname: shop.example.com, port: 3000}]\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go
index 37c9f8b8..9809db35 100644
--- a/internal/app/eject_test.go
+++ b/internal/app/eject_test.go
@@ -8,7 +8,7 @@ import (
)
const ejectProject = `# Ledger's production contract.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: ledger
environments:
@@ -19,8 +19,8 @@ workloads:
web:
role: application
image: nginx:1.27 # pinned deliberately
- domain: ledger.example.com
- port: 8080
+ routes:
+ - {hostname: ledger.example.com, port: 8080}
`
func ejectInto(t *testing.T, body string) (dir string, res *EjectResult) {
@@ -190,15 +190,15 @@ func TestEjectCarriesTheAuthorsNote(t *testing.T) {
// shaped by the file. Leaving a health check or a volume in the project would
// let someone edit it, see no effect, and get no error.
func TestEjectRemovesWhatTheComposeFileNowOwns(t *testing.T) {
- dir, _ := ejectInto(t, `api_version: onebox.run/v1
+ dir, _ := ejectInto(t, `api_version: onebox.run/v2
app: ledger
environments: {production: {server: root@1.2.3.4}}
workloads:
web:
role: application
image: nginx
- domain: ledger.example.com
- port: 8080
+ routes:
+ - {hostname: ledger.example.com, port: 8080}
health: {http: /healthz, port: 8080}
volumes: [{name: uploads, path: /var/lib/ledger/uploads}]
env: {LOG_LEVEL: info}
@@ -211,7 +211,7 @@ workloads:
}
}
// What the overlay still derives must stay.
- for _, kept := range []string{"role: application", "domain:", "port:", "compose:"} {
+ for _, kept := range []string{"role: application", "hostname:", "port:", "compose:"} {
if !strings.Contains(out, kept) {
t.Errorf("%q should have been kept\n%s", kept, out)
}
@@ -224,11 +224,11 @@ workloads:
func TestEjectDefaultAvoidsAReferencedFile(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services:\n db: {image: postgres}\n"), 0o600)
- os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`api_version: onebox.run/v1
+ os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`api_version: onebox.run/v2
app: ledger
environments: {production: {server: root@1.2.3.4}}
workloads:
- web: {role: application, image: nginx, domain: d.example.com, port: 80}
+ web: {role: application, image: nginx, routes: [{hostname: d.example.com, port: 80}]}
db: {role: daemon, compose: "compose.yaml#db"}
`), 0o600)
@@ -260,12 +260,12 @@ workloads:
func TestEjectAfterAnInterruptionCompletes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ob.yml")
- body := `api_version: onebox.run/v1
+ body := `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
workloads:
- web: {role: application, image: nginx, domain: shop.example.com, port: 3000}
+ web: {role: application, image: nginx, routes: [{hostname: shop.example.com, port: 3000}]}
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
diff --git a/internal/app/ejection_contract_test.go b/internal/app/ejection_contract_test.go
index 22bc2ef6..bdabfb6d 100644
--- a/internal/app/ejection_contract_test.go
+++ b/internal/app/ejection_contract_test.go
@@ -7,7 +7,7 @@ import (
"testing"
)
-const ejectContractProject = `api_version: onebox.run/v1
+const ejectContractProject = `api_version: onebox.run/v2
app: shop
environments:
production:
@@ -22,8 +22,8 @@ workloads:
env:
API_TOKEN: super-secret-value
routes:
- - {domain: shop.example.com, path: /, port: 3000}
- - {domain: shop.example.com, path: /api, port: 3001}
+ - {hostname: shop.example.com, path: /, port: 3000}
+ - {hostname: shop.example.com, path: /api, port: 3001}
worker:
role: worker
image: nginx:1.27
diff --git a/internal/app/environment_model_test.go b/internal/app/environment_model_test.go
index 3907824a..116859da 100644
--- a/internal/app/environment_model_test.go
+++ b/internal/app/environment_model_test.go
@@ -55,7 +55,7 @@ func listFor(t *testing.T, r *Resolved, workload string) []string {
return out
}
-const envModelBody = `api_version: onebox.run/v1
+const envModelBody = `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
@@ -71,7 +71,7 @@ environments:
runtime:
env_files: [.env]
workloads:
- web: {role: application, image: nginx, domain: s.example.com, port: 3000}
+ web: {role: application, image: nginx, routes: [{hostname: s.example.com, port: 3000}]}
cron: {role: job, image: nginx, command: ["true"], data_effect: none}
quiet: {role: worker, image: nginx, env_files: []}
own: {role: worker, image: nginx, env_files: [.env.own]}
@@ -138,7 +138,7 @@ func TestTwoEntriesNeverShareAStagedFile(t *testing.T) {
// The withdrawn block is refused with direction, not as an unknown field.
func TestTheWithdrawnSecretsBlockIsRefusedWithDirection(t *testing.T) {
- _, err := Load(envModelProject(t, `api_version: onebox.run/v1
+ _, err := Load(envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
image: nginx
@@ -163,15 +163,15 @@ secrets: {production: s.yaml}
// An authored value may not claim a name a connection supplies.
func TestAuthoredValuesCannotClaimAConnectionVariable(t *testing.T) {
- _, err := Load(envModelProject(t, `api_version: onebox.run/v1
+ _, err := Load(envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
web:
role: application
image: nginx
- domain: s.example.com
- port: 3000
+ routes:
+ - {hostname: s.example.com, port: 3000}
needs: [postgres]
env: {POSTGRES_PASSWORD: mine}
services:
@@ -189,7 +189,7 @@ services:
// A compose-sourced application receives what an image-sourced one receives,
// and ejecting then generating does not duplicate the projection.
func TestComposeSourcedWorkloadsAreNotASpecialCase(t *testing.T) {
- path := envModelProject(t, `api_version: onebox.run/v1
+ path := envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
runtime:
@@ -198,8 +198,8 @@ workloads:
legacy:
role: application
compose: legacy.yml#legacy
- domain: s.example.com
- port: 80
+ routes:
+ - {hostname: s.example.com, port: 80}
web:
role: worker
image: nginx
@@ -243,12 +243,12 @@ workloads:
// rolling release waited out its entire budget and then reported the container
// unhealthy, naming the container and saying nothing about the port.
func TestAnHTTPProbeInheritsTheRoutedPort(t *testing.T) {
- r := resolvedFor(t, envModelProject(t, `api_version: onebox.run/v1
+ r := resolvedFor(t, envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
image: nginx
-domain: s.example.com
-port: 3000
+routes:
+ - {hostname: s.example.com, port: 3000}
health: /healthz
`, nil), "production")
if got := r.Spec.Workloads["shop"].Health.Port; got != 3000 {
@@ -270,14 +270,14 @@ health: /healthz
// a contract treating "how it is stored" as "who may see it" would let the
// commoner form leak.
func TestNoEntryValueReachesAnArtifact(t *testing.T) {
- path := envModelProject(t, `api_version: onebox.run/v1
+ path := envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
runtime:
env_files: [.env]
image: nginx
-domain: s.example.com
-port: 3000
+routes:
+ - {hostname: s.example.com, port: 3000}
`, map[string]string{".env": "API_TOKEN=super-secret-value\n"})
r := resolvedFor(t, path, "production")
@@ -310,7 +310,7 @@ port: 3000
// rolling release waits out in full before reporting the container unhealthy
// without naming a port.
func TestAProbeWithNoPortIsRefused(t *testing.T) {
- _, err := Load(envModelProject(t, `api_version: onebox.run/v1
+ _, err := Load(envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -349,7 +349,7 @@ func composeServiceEnvFiles(t *testing.T, runtime []byte, service string) []stri
// adds. Both halves were unguarded — deleting the projection outright left the
// suite green.
func TestTheProjectionAppendsAndPreservesOrder(t *testing.T) {
- path := envModelProject(t, `api_version: onebox.run/v1
+ path := envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
runtime:
@@ -358,8 +358,8 @@ workloads:
legacy:
role: application
compose: legacy.yml#legacy
- domain: s.example.com
- port: 80
+ routes:
+ - {hostname: s.example.com, port: 80}
`, map[string]string{
".env.one": "A=1\n",
".env.two": "B=2\n",
@@ -387,7 +387,7 @@ workloads:
// cannot be shadowed by one. Emitting them in the other order passed every
// test.
func TestConnectionFilesComeAfterDeclaredEntries(t *testing.T) {
- path := envModelProject(t, `api_version: onebox.run/v1
+ path := envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
runtime:
@@ -396,8 +396,8 @@ workloads:
web:
role: application
image: nginx
- domain: s.example.com
- port: 3000
+ routes:
+ - {hostname: s.example.com, port: 3000}
needs: [postgres]
services:
postgres: 17
@@ -420,15 +420,15 @@ services:
// half was tested; this half is a scenario stated twice in the contract and had
// no test — making the check unconditionally return nil passed everything.
func TestAReferencedServiceCannotClaimAConnectionVariable(t *testing.T) {
- _, err := resolvedForErr(t, envModelProject(t, `api_version: onebox.run/v1
+ _, err := resolvedForErr(t, envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
workloads:
legacy:
role: application
compose: legacy.yml#legacy
- domain: s.example.com
- port: 80
+ routes:
+ - {hostname: s.example.com, port: 80}
needs: [postgres]
services:
postgres: 17
@@ -469,14 +469,14 @@ func resolvedForErr(t *testing.T, path string) ([]byte, error) {
// asked for interpolation. Stopping a correct project from loading is a worse
// failure than the one it would prevent.
func TestAnEncryptedEntryDoesNotBlockAProjectThatNeedsNoInterpolation(t *testing.T) {
- path := envModelProject(t, `api_version: onebox.run/v1
+ path := envModelProject(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
runtime:
env_files: [{file: s.enc, provider: sops}]
image: nginx
-domain: s.example.com
-port: 3000
+routes:
+ - {hostname: s.example.com, port: 3000}
health: {http: /healthz, port: 3000}
`, map[string]string{"s.enc": "A=1\n"})
// Through the function the callers use. `Load` does not reach it, so a test
@@ -547,13 +547,13 @@ func TestAnOverrideDeclaringNoneIsPreserved(t *testing.T) {
// after the release is staged and the old one is coming down. The name is in
// the document; there is no reason to find out there.
func TestAnEntryNamingAMissingFileIsRefused(t *testing.T) {
- body := `api_version: onebox.run/v1
+ body := `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
runtime:
env_files: [.env.absent]
workloads:
- web: {image: nginx, domain: s.example.com, port: 3000}
+ web: {image: nginx, routes: [{hostname: s.example.com, port: 3000}]}
`
_, err := Load(envModelProject(t, body, nil))
if err == nil {
diff --git a/internal/app/errors.go b/internal/app/errors.go
index 072edc6d..e0e95cab 100644
--- a/internal/app/errors.go
+++ b/internal/app/errors.go
@@ -31,8 +31,6 @@ var errorCodes = map[string]string{
"stateful_replicas": "a workload keeping durable state asks for more than one replica",
"strategy_ungated": "a rolling release is asked for by a workload with no health check to gate it",
"shorthand_and_workloads": "top-level shorthand cannot be combined with a workloads block",
- "routing_exclusive": "the domain shorthand and the routes list say the same thing twice",
- "routing_incomplete": "domain and port are declared together or not at all",
"route_collision": "two workloads claim the same address",
"route_without_proxy": "a route is declared with nothing to route it",
"identifier_collision": "a name is used by both a workload and a service",
diff --git a/internal/app/external_schema_test.go b/internal/app/external_schema_test.go
index f5b51203..b08bd29d 100644
--- a/internal/app/external_schema_test.go
+++ b/internal/app/external_schema_test.go
@@ -5,7 +5,7 @@ import (
"testing"
)
-const validExternalServiceProject = `api_version: onebox.run/v1
+const validExternalServiceProject = `api_version: onebox.run/v2
app: shop
environments: {production: {server: deploy@app.example.net}}
workloads:
@@ -37,7 +37,7 @@ func TestExternalServiceFixtures(t *testing.T) {
},
{
name: "external_service_ambiguous_owner",
- yaml: `api_version: onebox.run/v1
+ yaml: `api_version: onebox.run/v2
app: shop
environments: {production: {server: deploy@app.example.net}}
workloads: {web: {image: nginx:1}}
@@ -54,7 +54,7 @@ external_services:
},
{
name: "external_service_lifecycle_field_refused",
- yaml: `api_version: onebox.run/v1
+ yaml: `api_version: onebox.run/v2
app: shop
environments: {production: {server: deploy@app.example.net}}
workloads: {web: {image: nginx:1}}
diff --git a/internal/app/generate.go b/internal/app/generate.go
index 9d2e2574..663bdcea 100644
--- a/internal/app/generate.go
+++ b/internal/app/generate.go
@@ -545,8 +545,8 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any {
router := n.Router(name, i)
kind := "http"
rule := fmt.Sprintf("Host(`%s`)", r.HostPattern())
- if r.WildcardSuffix != "" {
- suffix := strings.ReplaceAll(r.WildcardSuffix, ".", `\.`)
+ if r.IsWildcard() {
+ suffix := strings.ReplaceAll(r.HostSuffix(), ".", `\.`)
rule = fmt.Sprintf("HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.%s$`)", suffix)
}
if r.Protocol == "tcp" {
@@ -579,7 +579,7 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any {
// this same private identity.
if p.Proxy.Managed && r.TLS == "terminate" {
resolver := ManagedCertificateResolver
- if r.WildcardSuffix != "" {
+ if r.IsWildcard() {
resolver = ManagedWildcardCertificateResolver
out[pre+"tls.domains[0].main"] = r.HostPattern()
}
diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go
index 4c96a576..83dfa717 100644
--- a/internal/app/generate_test.go
+++ b/internal/app/generate_test.go
@@ -11,7 +11,7 @@ import (
// A decent-size project of the shape people actually build: a web application,
// a background worker, a migration job, and a database they still author.
-const appFixture = `api_version: onebox.run/v1
+const appFixture = `api_version: onebox.run/v2
app: ledger
environments:
production: {server: root@1.2.3.4}
@@ -20,8 +20,8 @@ workloads:
role: application
image: ghcr.io/acme/ledger:1.4.0
replicas: 2
- domain: ledger.example.com
- port: 8080
+ routes:
+ - {hostname: ledger.example.com, port: 8080}
health: {http: /healthz, port: 8080, interval: 10s, retries: 3}
drain: {grace: 30s}
needs: [db]
@@ -86,7 +86,7 @@ func TestRenderIsDeterministic(t *testing.T) {
}
func TestWorkloadRevisionIsReleaseIndependentAndRuntimeSensitive(t *testing.T) {
- project := `api_version: onebox.run/v1
+ project := `api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -277,8 +277,7 @@ func TestBuildWithoutResolvedImageFailsClosed(t *testing.T) {
// drops its route first, because declaring one under `kind: none` is refused
// at load — a route nobody would serve is not a runtime question.
func TestNoProxyAddsNothing(t *testing.T) {
- y := strings.Replace(appFixture, " domain: ledger.example.com\n", "", 1)
- y = strings.Replace(y, " port: 8080\n", "", 1)
+ y := strings.Replace(appFixture, " routes:\n - {hostname: ledger.example.com, port: 8080}\n", "", 1)
out := string(render(t, y+"proxy: {kind: none}\n"))
if strings.Contains(out, "traefik") {
t.Error("no proxy must not add routing labels")
@@ -363,13 +362,13 @@ func TestHasTerminatingTLSDistinguishesPassthrough(t *testing.T) {
}
func TestWildcardRouteRendersSafeHostRegexpAndDNSResolver(t *testing.T) {
- project := `api_version: onebox.run/v1
+ project := `api_version: onebox.run/v2
app: preview
environments: {production: {server: root@example.com}}
workloads:
web:
image: nginx
- routes: [{wildcard_suffix: preview.example.com, port: 8080}]
+ routes: [{hostname: "*.preview.example.com", port: 8080}]
proxy:
config: traefik
dns_challenge: {provider: cloudflare}
@@ -441,7 +440,7 @@ func TestEveryDraftRenders(t *testing.T) {
// showed standing between two thirds of services and the declaration. Each
// carries no Onebox semantics: it is declared, and it appears.
func TestPassthroughFields(t *testing.T) {
- y := `api_version: onebox.run/v1
+ y := `api_version: onebox.run/v2
app: ledger
environments:
production: {server: root@1.2.3.4}
@@ -483,7 +482,7 @@ workloads:
// generates into are reserved, so a user label can never silently win.
func TestUserLabelsCannotClaimOneboxNamespaces(t *testing.T) {
for _, bad := range []string{"ob.app", "traefik.enable"} {
- y := `api_version: onebox.run/v1
+ y := `api_version: onebox.run/v2
app: ledger
environments: {production: {server: h}}
workloads: {web: {role: application, image: nginx, labels: {"` + bad + `": x}}}
@@ -520,7 +519,7 @@ func TestVolumeNamesArePinned(t *testing.T) {
// workload that can never be released, so the exec form must reach the runtime
// as CMD rather than CMD-SHELL.
func TestExecListHealthRunsWithoutAShell(t *testing.T) {
- out := render(t, `api_version: onebox.run/v1
+ out := render(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -541,7 +540,7 @@ workloads:
// The string form still runs through a shell, which is what makes `pg_isready
// -U x && test -f /ready` work.
func TestExecStringHealthKeepsItsShell(t *testing.T) {
- out := render(t, `api_version: onebox.run/v1
+ out := render(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -562,7 +561,7 @@ func TestShellHealthChecksCarryTheDrainGuard(t *testing.T) {
`health: {tcp: true, port: 5432}`,
`health: {exec: "test -f /ready"}`,
} {
- out := string(render(t, `api_version: onebox.run/v1
+ out := string(render(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -581,7 +580,7 @@ workloads:
// command and stays unquoted; a path is not, and must be one argument.
func TestHTTPHealthPathIsQuotedInsideItsShellCheck(t *testing.T) {
const injected = "/healthz;id>/tmp/ob-owned"
- out := string(render(t, `api_version: onebox.run/v1
+ out := string(render(t, `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
diff --git a/internal/app/health_timing_test.go b/internal/app/health_timing_test.go
index c08bd0e3..0acd172e 100644
--- a/internal/app/health_timing_test.go
+++ b/internal/app/health_timing_test.go
@@ -111,9 +111,9 @@ func TestReadyBudgetCoversAtLeastOneFlipCycle(t *testing.T) {
// it negative, which expires instantly — the failure the budget exists to
// prevent, reached by a route validation could have closed.
func TestAbsurdRetriesIsRejected(t *testing.T) {
- _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: root@10.0.0.1}}\n"+
- "image: nginx\ndomain: d.example.com\nport: 8080\n"+
+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+
"health: {http: /healthz, retries: 100000000000}\n"), "ob.yml")
if err == nil {
t.Fatal("a retries count that overflows the drain budget was accepted")
@@ -134,9 +134,9 @@ func TestAbsurdHealthDurationsAreRejected(t *testing.T) {
"within": "{http: /healthz, within: 100000d}",
} {
t.Run(name, func(t *testing.T) {
- _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: root@10.0.0.1}}\n"+
- "image: nginx\ndomain: d.example.com\nport: 8080\n"+
+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+
"health: "+health+"\n"), "ob.yml")
if err == nil {
t.Fatalf("health %s was accepted", health)
@@ -206,9 +206,9 @@ func TestParseDurationRejectsOverflowingDayCounts(t *testing.T) {
// A day count that wraps int64 must be rejected by validation too, not merely
// by the parser: the two together are what make the bound mean something.
func TestOverflowingDayCountIsRejectedAtLoad(t *testing.T) {
- _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: root@10.0.0.1}}\n"+
- "image: nginx\ndomain: d.example.com\nport: 8080\n"+
+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"+
"health: {http: /healthz, interval: 1000000d}\n"), "ob.yml")
if err == nil {
t.Fatal("an interval that overflows int64 nanoseconds was accepted")
@@ -224,9 +224,9 @@ func TestAbsurdDrainDurationsAreRejected(t *testing.T) {
"grace": "{grace: 100000d}",
} {
t.Run(name, func(t *testing.T) {
- _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: root@10.0.0.1}}\n"+
- "workloads: {web: {image: nginx, domain: d.example.com, port: 8080, drain: "+drain+"}}\n"), "ob.yml")
+ "workloads: {web: {image: nginx, routes: [{hostname: d.example.com, port: 8080}], drain: "+drain+"}}\n"), "ob.yml")
if err == nil {
t.Fatalf("drain %s was accepted", drain)
}
diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go
index eb03f542..4474c5dc 100644
--- a/internal/app/jsonschema.go
+++ b/internal/app/jsonschema.go
@@ -22,7 +22,7 @@ import (
// SchemaID is both the schema identity and its stable, publicly retrievable
// location. The main-branch path stays fixed across Onebox releases.
-const SchemaID = "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v1.schema.json"
+const SchemaID = "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json"
// JSONSchema is the published contract, ready to write.
func JSONSchema() ([]byte, error) {
@@ -35,7 +35,7 @@ func JSONSchema() ([]byte, error) {
}
doc["$schema"] = "https://json-schema.org/draft/2020-12/schema"
doc["$id"] = SchemaID
- doc["title"] = "Onebox project (onebox.run/v1)"
+ doc["title"] = "Onebox project (onebox.run/v2)"
doc["description"] = "One application, its workloads, the services it needs, and how a release rolls out."
// The constraints the loader enforces, so the schema refuses what the
@@ -344,7 +344,6 @@ var schemaConstraints = []struct {
{[]string{"workloads", "*", "operator_run"}, enum(eJobOperatorRun)},
{[]string{"workloads", "*", "data_effect"}, enum(eDataEffect)},
{[]string{"workloads", "*", "compose"}, pattern(gComposeRef)},
- {[]string{"workloads", "*", "domain"}, pattern(gRouteHost)},
{[]string{"workloads", "*", "port"}, portBounds()},
{[]string{"workloads", "*", "working_dir"}, pattern(gAbsPath)},
{[]string{"workloads", "*", "env_files", "items", "file"}, pattern(gRepoPath)},
@@ -388,32 +387,49 @@ var schemaConstraints = []struct {
{[]string{"workloads", "*", "resources", "cpus"}, pattern(gCpus)},
{[]string{"workloads", "*", "persistence", "mode"}, enum(ePersistence)},
{[]string{"workloads", "*", "routes", "items"}, map[string]any{
- "oneOf": []any{
- map[string]any{"required": []any{"domain"}, "not": map[string]any{"required": []any{"wildcard_suffix"}}},
- map[string]any{"required": []any{"wildcard_suffix"}, "not": map[string]any{"required": []any{"domain"}}},
- },
- "allOf": []any{map[string]any{
- "if": map[string]any{
- "required": []any{"domain"},
- "properties": map[string]any{"domain": map[string]any{"const": "*"}},
+ "required": []any{"hostname"},
+ "allOf": []any{
+ map[string]any{
+ "if": map[string]any{
+ "required": []any{"hostname"},
+ "properties": map[string]any{"hostname": map[string]any{"const": "*"}},
+ },
+ "then": map[string]any{
+ "required": []any{"protocol", "tls"},
+ "properties": map[string]any{
+ "protocol": map[string]any{"const": "tcp"},
+ "tls": map[string]any{"enum": []any{"none", "passthrough"}},
+ },
+ },
},
- "then": map[string]any{
- "required": []any{"protocol", "tls"},
- "properties": map[string]any{
- "protocol": map[string]any{"const": "tcp"},
- "tls": map[string]any{"enum": []any{"none", "passthrough"}},
+ map[string]any{
+ "if": map[string]any{
+ "required": []any{"hostname"},
+ "properties": map[string]any{"hostname": map[string]any{"pattern": `^\*\.`}},
+ },
+ "then": map[string]any{
+ "properties": map[string]any{"protocol": map[string]any{"const": "http"}},
},
},
- }},
+ map[string]any{
+ "if": map[string]any{
+ "required": []any{"tls"},
+ "properties": map[string]any{"tls": map[string]any{"const": "passthrough"}},
+ },
+ "then": map[string]any{
+ "required": []any{"protocol"},
+ "properties": map[string]any{"protocol": map[string]any{"const": "tcp"}},
+ },
+ },
+ },
}},
- {[]string{"workloads", "*", "routes", "items", "domain"}, map[string]any{"anyOf": []any{
- pattern(gRouteHost),
+ {[]string{"workloads", "*", "routes", "items", "hostname"}, map[string]any{"anyOf": []any{
+ map[string]any{
+ "pattern": gRouteHostname.pattern.String(),
+ "maxLength": 253,
+ },
map[string]any{"const": "*"},
}}},
- {[]string{"workloads", "*", "routes", "items", "wildcard_suffix"}, map[string]any{
- "pattern": gWildcardSuffix.pattern.String(),
- "maxLength": 253,
- }},
{[]string{"workloads", "*", "routes", "items", "path"}, pattern(gURLPath)},
{[]string{"workloads", "*", "routes", "items", "port"}, portBounds()},
{[]string{"workloads", "*", "routes", "items", "protocol"}, enum(eRouteProtocol)},
@@ -553,7 +569,7 @@ func applyRoleRules(doc map[string]any) {
sources := []any{"build", "image", "compose"}
doc["not"] = map[string]any{"allOf": []any{
map[string]any{"required": []any{"workloads"}},
- map[string]any{"anyOf": anyRequired(append(append([]any{}, sources...), "domain", "port", "health", "routes"))},
+ map[string]any{"anyOf": anyRequired(append(append([]any{}, sources...), "port", "health", "routes"))},
}}
// A project must describe something to run: a non-empty workloads block,
@@ -583,20 +599,6 @@ func applyRoleRules(doc map[string]any) {
// Exactly one source. A workload with none cannot run and a workload
// with two does not say which image it is.
map[string]any{"oneOf": anyRequired(sources)},
- // The domain shorthand and the routes list say the same thing twice,
- // and domain without a port does not say where to send the traffic.
- map[string]any{"not": map[string]any{"allOf": []any{
- map[string]any{"anyOf": anyRequired([]any{"domain", "port"})},
- map[string]any{"required": []any{"routes"}},
- }}},
- map[string]any{
- "if": map[string]any{"required": []any{"domain"}},
- "then": map[string]any{"required": []any{"port"}},
- },
- map[string]any{
- "if": map[string]any{"required": []any{"port"}},
- "then": map[string]any{"required": []any{"domain"}},
- },
// A published host socket is singular, so it cannot be held by both
// sides of a rolling handover. Include the authored default case:
// application + health + no strategy is rolling after normalization.
diff --git a/internal/app/jsonschema_test.go b/internal/app/jsonschema_test.go
index 7d04b1ce..dc1501da 100644
--- a/internal/app/jsonschema_test.go
+++ b/internal/app/jsonschema_test.go
@@ -124,7 +124,7 @@ func TestPublishedSchemaAcceptsEveryRealProject(t *testing.T) {
func TestPublishedSchemaRequiresExecutionStepIDAndCommand(t *testing.T) {
schema := compiledSchema(t)
for _, step := range []string{`{id: sync, command: [echo, ok]}`, `{command: [echo, ok]}`, `{id: sync}`, `{}`} {
- y := "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads:\n sync:\n role: job\n image: busybox\n deployment_phase: none\n data_effect: none\n schedule: {cron: '0 * * * *'}\n execution:\n steps: [" + step + "]\n"
+ y := "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads:\n sync:\n role: job\n image: busybox\n deployment_phase: none\n data_effect: none\n schedule: {cron: '0 * * * *'}\n execution:\n steps: [" + step + "]\n"
err := schema.Validate(asJSON(t, y))
valid := strings.Contains(step, "id:") && strings.Contains(step, "command:")
if (err == nil) != valid {
@@ -136,11 +136,11 @@ func TestPublishedSchemaRequiresExecutionStepIDAndCommand(t *testing.T) {
func TestPublishedSchemaAcceptsAuthoredShorthand(t *testing.T) {
schema := compiledSchema(t)
for _, y := range []string{
- "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nimage: nginx\n",
- "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nservices: {postgres: 17}\n",
- "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, volumes: [{name: data, path: /data}], needs: [db], command: run}}\n",
- "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nhooks: {post_deploy: \"echo done\"}\n",
- "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nx-note: anything\n",
+ "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nimage: nginx\n",
+ "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nservices: {postgres: 17}\n",
+ "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, volumes: [{name: data, path: /data}], needs: [db], command: run}}\n",
+ "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nhooks: {post_deploy: \"echo done\"}\n",
+ "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nx-note: anything\n",
} {
if err := schema.Validate(asJSON(t, y)); err != nil {
t.Errorf("authored shorthand rejected:\n%s\n%v", y, err)
@@ -152,7 +152,7 @@ func TestPublishedSchemaAcceptsAuthoredShorthand(t *testing.T) {
// completion and error support the schema exists to provide.
func TestPublishedSchemaRefusesAnUndefinedField(t *testing.T) {
schema := compiledSchema(t)
- y := "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, replicaz: 3}}\n"
+ y := "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx, replicaz: 3}}\n"
if err := schema.Validate(asJSON(t, y)); err == nil {
t.Error("the published schema accepted a field the contract does not define")
} else if !strings.Contains(err.Error(), "replicaz") {
@@ -162,7 +162,7 @@ func TestPublishedSchemaRefusesAnUndefinedField(t *testing.T) {
func TestPublishedSchemaConstrainsProxyEntrypoints(t *testing.T) {
schema := compiledSchema(t)
- base := "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nproxy:\n entrypoints:\n"
+ base := "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: root@h}}\nworkloads: {w: {image: nginx}}\nproxy:\n entrypoints:\n"
for _, tc := range []struct {
name string
@@ -267,13 +267,13 @@ func TestCheckedInSchemaMatchesGenerator(t *testing.T) {
t.Fatal(err)
}
want := append(append([]byte(nil), body...), '\n')
- path := filepath.Join("..", "..", "docs", "onebox.run-v1.schema.json")
+ path := filepath.Join("..", "..", "docs", "onebox.run-v2.schema.json")
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read published schema: %v", err)
}
if !bytes.Equal(got, want) {
- t.Fatalf("%s is stale; regenerate it with `go run ./cmd/ob schema --out docs/onebox.run-v1.schema.json`", path)
+ t.Fatalf("%s is stale; regenerate it with `go run ./cmd/ob schema --out docs/onebox.run-v2.schema.json`", path)
}
}
diff --git a/internal/app/jump_config_test.go b/internal/app/jump_config_test.go
index 04a29818..70ea75f2 100644
--- a/internal/app/jump_config_test.go
+++ b/internal/app/jump_config_test.go
@@ -6,9 +6,9 @@ import (
)
func projectWithJump(jump string) string {
- return "api_version: onebox.run/v1\napp: ledger\n" +
+ return "api_version: onebox.run/v2\napp: ledger\n" +
"environments: {production: {server: root@10.20.0.10, jump: " + jump + "}}\n" +
- "image: nginx\ndomain: ledger.example.com\nport: 8080\n"
+ "image: nginx\nroutes: [{hostname: ledger.example.com, port: 8080}]\n"
}
func TestScalarJumpExpandsToUserHostAndPort(t *testing.T) {
diff --git a/internal/app/load.go b/internal/app/load.go
index 108c42e0..e3cae4aa 100644
--- a/internal/app/load.go
+++ b/internal/app/load.go
@@ -16,7 +16,7 @@ import (
)
// APIVersion is the only authoring contract this package accepts.
-const APIVersion = "onebox.run/v1"
+const APIVersion = "onebox.run/v2"
// maxDerivedName is an Onebox limit chosen for headroom, not a container-runtime
// maximum. An over-long name is refused rather than truncated: truncation with a
@@ -67,6 +67,18 @@ func Load(path string) (*Spec, error) {
// LoadBytes runs the fixed pipeline: parse, expand, validate, then apply the
// cross-field rules the schema cannot express.
func LoadBytes(b []byte, filename string) (*Spec, error) {
+ return loadBytes(b, filename, false)
+}
+
+// LoadReleaseSnapshotBytes loads an immutable project snapshot written by an
+// earlier Onebox release. It accepts v1 only on this internal replay boundary,
+// migrating its route representation in memory; normal project loading remains
+// strictly v2 so an authored file can never opt into retired semantics.
+func LoadReleaseSnapshotBytes(b []byte, filename string) (*Spec, error) {
+ return loadBytes(b, filename, true)
+}
+
+func loadBytes(b []byte, filename string, allowV1Snapshot bool) (*Spec, error) {
var raw map[string]any
if err := yaml.Unmarshal(b, &raw); err != nil {
return nil, errf("project_unparsable", filename, "", "invalid YAML: %v", firstLine(err.Error()))
@@ -83,6 +95,13 @@ func LoadBytes(b []byte, filename string) (*Spec, error) {
lines = lineIndex(&doc)
}
+ legacyV1Snapshot := false
+ if allowV1Snapshot && raw["api_version"] == "onebox.run/v1" {
+ if err := migrateV1ReleaseSnapshot(raw); err != nil {
+ return nil, err
+ }
+ legacyV1Snapshot = true
+ }
if err := checkAPIVersion(raw); err != nil {
return nil, err
}
@@ -122,6 +141,7 @@ func LoadBytes(b []byte, filename string) (*Spec, error) {
// because the enumeration then promises a failure that never fires.
p.Dir = filepath.Dir(filename)
p.file = filename
+ p.legacyV1Snapshot = legacyV1Snapshot
if err := validateSpec(p); err != nil {
return nil, err
}
@@ -147,7 +167,101 @@ func checkAPIVersion(raw map[string]any) error {
}
// shorthandKeys are the top-level fields that describe a single workload.
-var shorthandKeys = []string{"build", "image", "compose", "port", "health", "domain", "routes"}
+var shorthandKeys = []string{"build", "image", "compose", "port", "health", "routes"}
+
+func migrateV1ReleaseSnapshot(raw map[string]any) error {
+ raw["api_version"] = APIVersion
+ if err := migrateV1Workload(raw, "workload shorthand"); err != nil {
+ return err
+ }
+ workloads, ok := raw["workloads"].(map[string]any)
+ if ok {
+ for name, value := range workloads {
+ workload, ok := value.(map[string]any)
+ if !ok {
+ continue
+ }
+ if err := migrateV1Workload(workload, "workloads."+name); err != nil {
+ return err
+ }
+ }
+ }
+ environments, ok := raw["environments"].(map[string]any)
+ if !ok {
+ return nil
+ }
+ for environmentName, value := range environments {
+ environment, ok := value.(map[string]any)
+ if !ok {
+ continue
+ }
+ overrides, ok := environment["overrides"].(map[string]any)
+ if !ok {
+ continue
+ }
+ workloadOverrides, ok := overrides["workloads"].(map[string]any)
+ if !ok {
+ continue
+ }
+ for workloadName, patch := range workloadOverrides {
+ workload, ok := patch.(map[string]any)
+ if !ok {
+ continue
+ }
+ path := "environments." + environmentName + ".overrides.workloads." + workloadName
+ if err := migrateV1Workload(workload, path); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func migrateV1Workload(workload map[string]any, path string) error {
+ domain, hasDomain := workload["domain"]
+ port, hasPort := workload["port"]
+ _, hasRoutes := workload["routes"]
+ if hasRoutes && (hasDomain || hasPort) {
+ return errf("project_invalid", path, "", "v1 snapshot declares both domain/port and routes")
+ }
+ if hasDomain != hasPort {
+ return errf("project_invalid", path, "", "v1 snapshot must declare domain and port together")
+ }
+ if hasDomain {
+ workload["routes"] = []any{map[string]any{"hostname": domain, "port": port}}
+ delete(workload, "domain")
+ return nil
+ }
+ routes, ok := workload["routes"].([]any)
+ if !ok {
+ return nil
+ }
+ for i, value := range routes {
+ route, ok := value.(map[string]any)
+ if !ok {
+ continue
+ }
+ domain, hasDomain := route["domain"]
+ suffix, hasSuffix := route["wildcard_suffix"]
+ _, hasHostname := route["hostname"]
+ if hasHostname || hasDomain == hasSuffix {
+ return errf("project_invalid", indexed(path+".routes", i), "",
+ "v1 snapshot route must declare exactly one of domain or wildcard_suffix")
+ }
+ if hasDomain {
+ route["hostname"] = domain
+ delete(route, "domain")
+ } else {
+ text, ok := suffix.(string)
+ if !ok {
+ return errf("project_invalid", indexed(path+".routes", i)+".wildcard_suffix", "", "v1 wildcard suffix must be a string")
+ }
+ route["hostname"] = "*." + text
+ delete(route, "wildcard_suffix")
+ }
+ }
+ return nil
+}
// expand rewrites shorthand into the normalised form the schema validates. It
// runs before validation because the schema requires discriminators — a role
@@ -450,16 +564,6 @@ func crossFieldRules(p *Spec) error {
name, w.Replicas)
}
- hasScalar := w.Domain != "" || w.Port != 0
- if hasScalar && len(w.Routes) > 0 {
- return errf("routing_exclusive", path, "",
- "workload %q declares both the domain/port shorthand and routes; use one", name)
- }
- if (w.Domain == "") != (w.Port == 0) {
- return errf("routing_incomplete", path, "",
- "workload %q must declare domain and port together", name)
- }
-
for i, n := range w.Needs {
dep, isWorkload := p.Workloads[n.Name]
_, isExternal := p.ExternalServices[n.Name]
@@ -650,19 +754,19 @@ func routesOverlap(a, b Route) bool {
if a.Entrypoint != b.Entrypoint || a.Protocol != b.Protocol || a.Path != b.Path {
return false
}
- if a.WildcardSuffix != "" && b.WildcardSuffix != "" {
- return a.WildcardSuffix == b.WildcardSuffix
+ if a.IsWildcard() && b.IsWildcard() {
+ return a.HostSuffix() == b.HostSuffix()
}
- if a.WildcardSuffix == "" && b.WildcardSuffix == "" {
- if a.Domain == "*" || b.Domain == "*" {
+ if !a.IsWildcard() && !b.IsWildcard() {
+ if a.Hostname == "*" || b.Hostname == "*" {
return true
}
- return canonicalRouteHost(a.Domain) == canonicalRouteHost(b.Domain)
+ return canonicalRouteHost(a.Hostname) == canonicalRouteHost(b.Hostname)
}
- if a.WildcardSuffix == "" {
+ if !a.IsWildcard() {
a, b = b, a
}
- prefix, ok := strings.CutSuffix(canonicalRouteHost(b.Domain), "."+a.WildcardSuffix)
+ prefix, ok := strings.CutSuffix(canonicalRouteHost(b.Hostname), "."+a.HostSuffix())
return ok && prefix != "" && !strings.Contains(prefix, ".")
}
diff --git a/internal/app/load_test.go b/internal/app/load_test.go
index f2bb043d..8ac008b3 100644
--- a/internal/app/load_test.go
+++ b/internal/app/load_test.go
@@ -8,11 +8,89 @@ import (
"testing"
)
-const base = "api_version: onebox.run/v1\napp: ledger\nenvironments: {production: {server: root@1.2.3.4}}\n"
-const min = base + "build: .\ndomain: ledger.example.com\nport: 8080\n"
+const base = "api_version: onebox.run/v2\napp: ledger\nenvironments: {production: {server: root@1.2.3.4}}\n"
+const min = base + "build: .\nroutes: [{hostname: ledger.example.com, port: 8080}]\n"
func wl(body string) string { return base + "workloads: {" + body + "}\n" }
+func TestAPIVersionV2IsRequired(t *testing.T) {
+ _, err := LoadBytes([]byte(strings.Replace(min, APIVersion, "onebox.run/v1", 1)), "ob.yml")
+ if err == nil || !strings.Contains(err.Error(), "schema_identity_unsupported") {
+ t.Fatalf("v1 project must be rejected with a version error: %v", err)
+ }
+}
+
+func TestReleaseSnapshotLoaderMigratesV1RoutesOnlyAtReplayBoundary(t *testing.T) {
+ legacy := `api_version: onebox.run/v1
+app: ledger
+environments: {production: {server: root@1.2.3.4}}
+image: nginx
+domain: Example.COM.
+port: 8080
+`
+ p, err := LoadReleaseSnapshotBytes([]byte(legacy), "/var/lib/ob/ledger/releases/old/ob.snapshot.yml")
+ if err != nil {
+ t.Fatalf("load v1 release snapshot: %v", err)
+ }
+ routes := p.Workloads["ledger"].NormalisedRoutes()
+ if p.APIVersion != APIVersion || len(routes) != 1 || routes[0].Hostname != "Example.COM." || routes[0].Port != 8080 {
+ t.Fatalf("migrated snapshot = version %q, routes %+v", p.APIVersion, routes)
+ }
+ resolved, err := p.Resolve("production")
+ if err != nil {
+ t.Fatalf("resolve migrated v1 snapshot: %v", err)
+ }
+ if got := resolved.Workloads["ledger"].Routes[0].Hostname; got != "Example.COM." {
+ t.Fatalf("resolved legacy hostname = %q", got)
+ }
+
+ wildcard := `api_version: onebox.run/v1
+app: ledger
+environments:
+ production:
+ server: root@1.2.3.4
+ overrides:
+ workloads:
+ web:
+ routes: [{wildcard_suffix: branch.example.com, path: /, port: 8081, entrypoint: websecure, protocol: http, scheme: http, tls: none}]
+workloads:
+ web:
+ image: nginx
+ routes: [{wildcard_suffix: preview.example.com, port: 8080, tls: none}]
+`
+ p, err = LoadReleaseSnapshotBytes([]byte(wildcard), "ob.snapshot.yml")
+ if err != nil {
+ t.Fatalf("load v1 wildcard snapshot: %v", err)
+ }
+ if got := p.Workloads["web"].Routes[0].Hostname; got != "*.preview.example.com" {
+ t.Fatalf("migrated wildcard hostname = %q", got)
+ }
+ resolved, err = p.Resolve("production")
+ if err != nil {
+ t.Fatalf("resolve v1 wildcard override: %v", err)
+ }
+ if got := resolved.Workloads["web"].Routes[0].Hostname; got != "*.branch.example.com" {
+ t.Fatalf("migrated wildcard override hostname = %q", got)
+ }
+ maxV1Suffix := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." +
+ strings.Repeat("c", 63) + "." + strings.Repeat("d", 61)
+ maxWildcard := strings.Replace(wildcard, "preview.example.com", maxV1Suffix, 1)
+ if _, err := LoadReleaseSnapshotBytes([]byte(maxWildcard), "ob.snapshot.yml"); err != nil {
+ t.Fatalf("v1 snapshot wildcard at the v1 suffix limit: %v", err)
+ }
+
+ for name, body := range map[string]string{
+ "unknown version": strings.Replace(legacy, "onebox.run/v1", "onebox.run/v0", 1),
+ "incomplete route": strings.Replace(legacy, "port: 8080\n", "", 1),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := LoadReleaseSnapshotBytes([]byte(body), "ob.snapshot.yml"); err == nil {
+ t.Fatal("invalid legacy snapshot was accepted")
+ }
+ })
+ }
+}
+
func TestRoutedProjectRefusesDefaultAsProxyNetwork(t *testing.T) {
for _, network := range []string{"default", "ledger_default", "ob_ledger"} {
t.Run(network, func(t *testing.T) {
@@ -53,7 +131,7 @@ func TestManagedGeneratedProxyRequiresDeclaredRouteEntrypoint(t *testing.T) {
project := base + `workloads:
grpc:
image: app:1
- routes: [{domain: grpc.example.com, port: 4317, entrypoint: otlp-grpc, scheme: h2c}]
+ routes: [{hostname: grpc.example.com, port: 4317, entrypoint: otlp-grpc, scheme: h2c}]
`
if _, err := LoadBytes([]byte(project), "ob.yml"); err == nil || !strings.Contains(err.Error(), "proxy.entrypoints") {
t.Fatalf("an unknown generated entrypoint must be refused: %v", err)
@@ -67,7 +145,7 @@ func TestWildcardRouteContract(t *testing.T) {
valid := base + `workloads:
web:
image: nginx
- routes: [{wildcard_suffix: preview.example.com, port: 8080}]
+ routes: [{hostname: "*.preview.example.com", port: 8080}]
proxy:
config: traefik
dns_challenge: {provider: cloudflare, resolvers: ["1.1.1.1:53"]}
@@ -81,17 +159,16 @@ proxy:
body string
want string
}{
- {"missing dns challenge", wl("web: {image: nginx, routes: [{wildcard_suffix: example.com, port: 80}] }"), "dns_challenge"},
- {"both host forms", wl("web: {image: nginx, routes: [{domain: api.example.com, wildcard_suffix: example.com, port: 80}] }"), "exactly one"},
- {"neither host form", wl("web: {image: nginx, routes: [{port: 80}] }"), "exactly one"},
- {"bare wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: '*', port: 80, tls: none}] }"), "DNS hostname"},
- {"embedded wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: '*.example.com', port: 80, tls: none}] }"), "DNS hostname"},
- {"uppercase suffix", wl("web: {image: nginx, routes: [{wildcard_suffix: Example.com, port: 80, tls: none}] }"), "lower-case"},
- {"tcp wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: example.com, port: 80, protocol: tcp, tls: passthrough}] }"), "only for HTTP"},
- {"exact matcher injection", wl("web: {image: nginx, routes: [{domain: 'x`) || Host(`*', port: 80}] }"), "route host"},
- {"wildcard in exact route", wl("web: {image: nginx, routes: [{domain: '*.example.com', port: 80}] }"), "wildcard_suffix"},
- {"wildcard in exact shorthand", wl("web: {image: nginx, domain: '*.example.com', port: 80}"), "wildcard_suffix"},
- {"catch-all exact route", wl("web: {image: nginx, routes: [{domain: '*', port: 80}] }"), "wildcard_suffix"},
+ {"missing dns challenge", wl("web: {image: nginx, routes: [{hostname: '*.example.com', port: 80}] }"), "dns_challenge"},
+ {"missing hostname", wl("web: {image: nginx, routes: [{port: 80}] }"), "hostname"},
+ {"bare wildcard", wl("web: {image: nginx, routes: [{hostname: '*', port: 80, tls: none}] }"), "route hostname"},
+ {"embedded wildcard", wl("web: {image: nginx, routes: [{hostname: 'api.*.example.com', port: 80, tls: none}] }"), "route hostname"},
+ {"partial wildcard", wl("web: {image: nginx, routes: [{hostname: 'api*.example.com', port: 80, tls: none}] }"), "route hostname"},
+ {"uppercase hostname", wl("web: {image: nginx, routes: [{hostname: Example.com, port: 80, tls: none}] }"), "lower-case"},
+ {"trailing dot", wl("web: {image: nginx, routes: [{hostname: example.com., port: 80, tls: none}] }"), "route hostname"},
+ {"tcp wildcard", wl("web: {image: nginx, routes: [{hostname: '*.example.com', port: 80, protocol: tcp, tls: passthrough}] }"), "only for HTTP"},
+ {"matcher injection", wl("web: {image: nginx, routes: [{hostname: 'x`) || Host(`*', port: 80}] }"), "route hostname"},
+ {"catch-all HTTP route", wl("web: {image: nginx, routes: [{hostname: '*', port: 80}] }"), "route hostname"},
{"dns challenge needs config", min + "proxy: {dns_challenge: {provider: cloudflare}}\n", "proxy.config"},
{"invalid resolver", min + "proxy: {config: traefik, dns_challenge: {provider: cloudflare, resolvers: [1.1.1.1]}}\n", "host:port"},
{"unmanaged dns challenge", min + "proxy: {managed: false, config: traefik, dns_challenge: {provider: cloudflare}}\n", "managed proxy"},
@@ -103,15 +180,16 @@ proxy:
}
})
}
- for _, domain := range []string{"API.Example.COM", "api.example.com."} {
- if _, err := LoadBytes([]byte(wl("web: {image: nginx, routes: [{domain: '"+domain+"', port: 80, tls: none}] }")), "ob.yml"); err != nil {
- t.Errorf("existing exact route spelling %q must remain valid: %v", domain, err)
+ for _, hostname := range []string{"api.example.com", "*.example.com"} {
+ project := wl("web: {image: nginx, routes: [{hostname: '" + hostname + "', port: 80, tls: none}] }")
+ if _, err := LoadBytes([]byte(project), "ob.yml"); err != nil {
+ t.Errorf("standard hostname spelling %q must be valid: %v", hostname, err)
}
}
- if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{domain: '*', protocol: tcp, tls: none, port: 9000}] }")), "ob.yml"); err != nil {
+ if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{hostname: '*', protocol: tcp, tls: none, port: 9000}] }")), "ob.yml"); err != nil {
t.Errorf("existing plaintext TCP catch-all must remain valid: %v", err)
}
- if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{domain: '*', protocol: tcp, tls: passthrough, port: 9000}] }")), "ob.yml"); err != nil {
+ if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{hostname: '*', protocol: tcp, tls: passthrough, port: 9000}] }")), "ob.yml"); err != nil {
t.Errorf("existing TLS-passthrough TCP catch-all must remain valid: %v", err)
}
}
@@ -126,12 +204,11 @@ func TestWildcardRouteOverlap(t *testing.T) {
right string
collides bool
}{
- {"immediate child", "{domain: shop.example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true},
- {"same wildcard", "{wildcard_suffix: example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true},
- {"case-insensitive exact child", "{domain: Shop.Example.COM., port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true},
- {"apex does not overlap", "{domain: example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", false},
- {"nested host does not overlap", "{domain: a.b.example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", false},
- {"different path", "{domain: shop.example.com, path: /api, port: 80, tls: none}", "{wildcard_suffix: example.com, path: /, port: 81, tls: none}", false},
+ {"immediate child", "{hostname: shop.example.com, port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", true},
+ {"same wildcard", "{hostname: '*.example.com', port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", true},
+ {"apex does not overlap", "{hostname: example.com, port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", false},
+ {"nested host does not overlap", "{hostname: a.b.example.com, port: 80, tls: none}", "{hostname: '*.example.com', port: 81, tls: none}", false},
+ {"different path", "{hostname: shop.example.com, path: /api, port: 80, tls: none}", "{hostname: '*.example.com', path: /, port: 81, tls: none}", false},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := LoadBytes([]byte(project(tc.left, tc.right)), "ob.yml")
@@ -146,8 +223,8 @@ func TestWildcardRouteOverlap(t *testing.T) {
}
func TestPlaintextTCPCatchAllOverlapsEveryHost(t *testing.T) {
- catchAll := Route{Domain: "*", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"}
- exact := Route{Domain: "db.example.com", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"}
+ catchAll := Route{Hostname: "*", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"}
+ exact := Route{Hostname: "db.example.com", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"}
if !routesOverlap(catchAll, exact) || !routesOverlap(exact, catchAll) {
t.Fatal("plaintext TCP catch-all must collide with every exact host on the same route address")
}
@@ -168,13 +245,13 @@ func conformanceCases() []conformanceCase {
{"explicit workloads block", wl("web: {image: nginx}"), true},
{"image reference with registry port", wl("web: {image: \"registry.example.com:5000/acme/app:1.2\"}"), true},
{"image reference with uppercase repository", wl("web: {image: \"ghcr.io/Acme/app:1.2\"}"), false},
- {"one-char identifier", "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: h}}\nimage: nginx\n", true},
- {"app starting ob-", "api_version: onebox.run/v1\napp: ob-app\nenvironments: {p: {server: h}}\nimage: nginx\n", false},
- {"host proxy name", "api_version: onebox.run/v1\napp: onebox-proxy\nenvironments: {p: {server: h}}\nimage: nginx\n", false},
- {"underscore identifier", "api_version: onebox.run/v1\napp: my_app\nenvironments: {p: {server: h}}\nimage: nginx\n", false},
+ {"one-char identifier", "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: h}}\nimage: nginx\n", true},
+ {"app starting ob-", "api_version: onebox.run/v2\napp: ob-app\nenvironments: {p: {server: h}}\nimage: nginx\n", false},
+ {"host proxy name", "api_version: onebox.run/v2\napp: onebox-proxy\nenvironments: {p: {server: h}}\nimage: nginx\n", false},
+ {"underscore identifier", "api_version: onebox.run/v2\napp: my_app\nenvironments: {p: {server: h}}\nimage: nginx\n", false},
{"unknown top-level field", min + "bogus: 1\n", false},
{"x- extension accepted", min + "x-note: anything\n", true},
- {"port out of range", base + "image: nginx\ndomain: d\nport: 70000\n", false},
+ {"port out of range", base + "image: nginx\nhostname: d\nport: 70000\n", false},
{"zero replicas", wl("w: {image: nginx, replicas: 0}"), false},
{"job requires data_effect", wl("j: {image: nginx, role: job}"), false},
{"job with data_effect", wl("j: {image: nginx, role: job, data_effect: none}"), true},
@@ -186,27 +263,28 @@ func conformanceCases() []conformanceCase {
{"scheduled job run policy", wl("j: {image: nginx, role: job, data_effect: none, schedule: {cron: \"0 4 * * *\", timeout: 45m, catch_up: false}}"), true},
{"scheduled job invalid timeout", wl("j: {image: nginx, role: job, data_effect: none, schedule: {cron: \"0 4 * * *\", timeout: forever}}"), false},
{"daemon role", wl("db: {image: postgres:16, role: daemon}"), true},
- {"routes list", wl("w: {image: nginx, routes: [{domain: x, port: 4317, protocol: tcp, scheme: h2c}]}"), true},
- {"provider-qualified route middlewares", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file, rate-limit@file]}]}") + "proxy: {config: traefik}\n", true},
- {"unqualified route middleware", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth]}]}"), false},
- {"repeated route middleware remains ordered", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file, auth@file]}]}") + "proxy: {config: traefik}\n", true},
- {"managed route middleware without proxy config", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file]}]}"), false},
- {"operator proxy owns route middleware", wl("w: {image: nginx, routes: [{domain: x, port: 8080, middlewares: [auth@file]}]}") + "proxy: {managed: false}\n", true},
- {"bad protocol", wl("w: {image: nginx, routes: [{domain: x, port: 1, protocol: udp}]}"), false},
+ {"routes list", wl("w: {image: nginx, routes: [{hostname: x, port: 4317, protocol: tcp, scheme: h2c}]}"), true},
+ {"provider-qualified route middlewares", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file, rate-limit@file]}]}") + "proxy: {config: traefik}\n", true},
+ {"unqualified route middleware", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth]}]}"), false},
+ {"repeated route middleware remains ordered", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file, auth@file]}]}") + "proxy: {config: traefik}\n", true},
+ {"managed route middleware without proxy config", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file]}]}"), false},
+ {"operator proxy owns route middleware", wl("w: {image: nginx, routes: [{hostname: x, port: 8080, middlewares: [auth@file]}]}") + "proxy: {managed: false}\n", true},
+ {"bad protocol", wl("w: {image: nginx, routes: [{hostname: x, port: 1, protocol: udp}]}"), false},
+ {"http route with TLS passthrough", wl("w: {image: nginx, routes: [{hostname: x, port: 443, tls: passthrough}]}"), false},
{"absolute compose ref", wl("w: {compose: \"/etc/compose.yml#web\"}"), false},
{"relative compose ref", wl("w: {compose: \"compose.yaml#web\"}"), true},
{"absolute env_file", min + "runtime: {env_files: [/etc/x.env]}\n", false},
{"relative env_file", min + "runtime: {env_files: [.env.production]}\n", true},
{"base_path absolute", min + "base_path: /mnt/data/ob\n", true},
- {"duration in days", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {migrations: {backup_max_age: 14d}}}}\n", true},
- {"non-calver minimum version", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_onebox_version: 0.0.1-m0}}}\n", false},
- {"incomplete plan schema", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_plan_schema: \"onebox.run/executable-deploy-plan/v1alpha\"}}}\n", false},
+ {"duration in days", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {migrations: {backup_max_age: 14d}}}}\n", true},
+ {"non-calver minimum version", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_onebox_version: 0.0.1-m0}}}\n", false},
+ {"incomplete plan schema", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, policy: {min_plan_schema: \"onebox.run/executable-deploy-plan/v1alpha\"}}}\n", false},
{"hook with local", min + "hooks: {pre_release: {run: scripts/build.sh, local: true}}\n", true},
// A hook key is a lifecycle seam OR a declared job name. Both halves need
// a case: an unlisted seam loads and never fires, and refusing a job name
// would break the per-job command override the engine reads.
{"hook naming an unlisted seam", min + "hooks: {pre_deploy: {run: scripts/backup.sh}}\n", false},
- {"hook naming a declared job", "api_version: onebox.run/v1\napp: a\nenvironments: {p: {server: h}}\nhooks: {migrate: {run: ./bin/migrate}}\nworkloads:\n w: {role: application, image: nginx}\n migrate: {role: job, image: nginx, data_effect: migration}\n", true},
+ {"hook naming a declared job", "api_version: onebox.run/v2\napp: a\nenvironments: {p: {server: h}}\nhooks: {migrate: {run: ./bin/migrate}}\nworkloads:\n w: {role: application, image: nginx}\n migrate: {role: job, image: nginx, data_effect: migration}\n", true},
{"hook naming neither", min + "hooks: {typo_hook: {run: scripts/x.sh}}\n", false},
// A settings key is interpolated into a generated shell command without
// quoting, so the grammar is the only thing between a project file and
@@ -240,7 +318,7 @@ func conformanceCases() []conformanceCase {
{"unknown enum: strategy", wl("w: {image: nginx, health: /h, strategy: sideways}"), false},
{"unknown enum: role", wl("w: {image: nginx, role: sidecar}"), false},
{"unknown enum: data_effect", wl("j: {image: nginx, role: job, data_effect: maybe}"), false},
- {"unknown enum: route protocol", wl("w: {image: nginx, routes: [{domain: x, port: 1, protocol: quic}]}"), false},
+ {"unknown enum: route protocol", wl("w: {image: nginx, routes: [{hostname: x, port: 1, protocol: quic}]}"), false},
{"unknown enum: persistence mode", wl("w: {image: nginx, persistence: {mode: sometimes}}"), false},
// Several problems at once. What matters is that the refusal is
// deterministic: an author fixing one thing at a time must not see the
@@ -250,7 +328,7 @@ func conformanceCases() []conformanceCase {
{"encrypted env file entry", min + "runtime: {env_files: [{file: secrets.env, provider: sops}]}\n", true},
{"unknown env file provider", min + "runtime: {env_files: [{file: s.env, provider: vault}]}\n", false},
{"env file entry without a file", min + "runtime: {env_files: [{provider: sops}]}\n", false},
- {"environment-scoped env files", "api_version: onebox.run/v1\napp: a\nimage: nginx\nenvironments: {p: {server: h, env_files: [.env.p]}}\n", true},
+ {"environment-scoped env files", "api_version: onebox.run/v2\napp: a\nimage: nginx\nenvironments: {p: {server: h, env_files: [.env.p]}}\n", true},
{"http check without a path", min + "checks: {http: [{workload: ledger}]}\n", false},
{"url check carrying an exec field", min + "checks: {url: [{url: \"https://x/\", run: \"echo\"}]}\n", false},
{"url check with contains and advisory", min + "checks: {url: [{url: \"https://x/\", contains: \"
0 {
- return w.Routes
- }
- if w.Domain == "" {
- return nil
- }
- return []Route{{
- Domain: w.Domain, Port: w.Port, Path: "/",
- Entrypoint: "websecure", Protocol: "http", Scheme: "http", TLS: "terminate",
- }}
-}
-
-// NormalisedRoutes returns the workload's routes with the scalar shorthand
-// expanded, so callers never handle two shapes.
-func (w Workload) NormalisedRoutes() []Route { return routesOf(w) }
-
-// HostPattern returns the host matcher value represented by the route. A
-// wildcard suffix is deliberately expanded here rather than accepted as an
-// authored matcher expression, so no regular expression reaches Traefik.
-func (r Route) HostPattern() string {
- if r.WildcardSuffix != "" {
- return "*." + r.WildcardSuffix
- }
- return r.Domain
-}
+// NormalisedRoutes returns the workload's routes. It remains the single
+// accessor used by rendering and validation so route normalization can evolve
+// without spreading representation knowledge through the codebase.
+func (w Workload) NormalisedRoutes() []Route { return w.Routes }
+
+// HostPattern returns the validated hostname matcher represented by the route.
+// Authored regular expressions never reach Traefik.
+func (r Route) HostPattern() string { return r.Hostname }
+
+// IsWildcard reports whether the hostname begins with the complete wildcard
+// label accepted by the project contract.
+func (r Route) IsWildcard() bool { return strings.HasPrefix(r.Hostname, "*.") }
+
+// HostSuffix returns the exact suffix below a wildcard label.
+func (r Route) HostSuffix() string { return strings.TrimPrefix(r.Hostname, "*.") }
// HasTerminatingTLS reports whether the resolved project needs the managed
// proxy's certificate resolver. Passthrough routes carry TLS without asking
@@ -447,7 +434,7 @@ func (p *Spec) HasExactTerminatingTLS() bool {
}
for _, w := range p.Workloads {
for _, route := range w.NormalisedRoutes() {
- if route.WildcardSuffix == "" && route.TLS == "terminate" {
+ if !route.IsWildcard() && route.TLS == "terminate" {
return true
}
}
@@ -463,7 +450,7 @@ func (p *Spec) HasWildcardTerminatingTLS() bool {
}
for _, workload := range p.Workloads {
for _, route := range workload.NormalisedRoutes() {
- if route.WildcardSuffix != "" && route.TLS == "terminate" {
+ if route.IsWildcard() && route.TLS == "terminate" {
return true
}
}
diff --git a/internal/app/names_test.go b/internal/app/names_test.go
index 7a21fae4..9ce2ed33 100644
--- a/internal/app/names_test.go
+++ b/internal/app/names_test.go
@@ -6,7 +6,7 @@ import (
"testing"
)
-const namesFixture = `api_version: onebox.run/v1
+const namesFixture = `api_version: onebox.run/v2
app: ledger
environments:
production: {server: root@1.2.3.4}
@@ -17,8 +17,8 @@ workloads:
image: nginx
replicas: 3
routes:
- - {domain: ledger.example.com, port: 8080}
- - {domain: api.ledger.example.com, port: 8080}
+ - {hostname: ledger.example.com, port: 8080}
+ - {hostname: api.ledger.example.com, port: 8080}
volumes: [{name: uploads, path: /var/lib/ledger/uploads}, {source: ./seed, path: /seed, mode: ro}]
worker:
role: worker
@@ -225,9 +225,9 @@ func TestRuntimeContainerDerivationIsInjective(t *testing.T) {
}
}
-// TestScalarRoutingNormalises: the domain/port shorthand becomes one route with
-// documented defaults, so generation never sees two shapes.
-func TestScalarRoutingNormalises(t *testing.T) {
+// TestNormalisedRoutesReturnsDeclaredRoutes keeps generation behind one route
+// accessor even though the public contract now has only the explicit list form.
+func TestNormalisedRoutesReturnsDeclaredRoutes(t *testing.T) {
p, err := LoadBytes([]byte(min), "ob.yml")
if err != nil {
t.Fatal(err)
@@ -237,7 +237,7 @@ func TestScalarRoutingNormalises(t *testing.T) {
t.Fatalf("got %d routes, want 1", len(routes))
}
r := routes[0]
- if r.Domain != "ledger.example.com" || r.Port != 8080 || r.Path != "/" ||
+ if r.Hostname != "ledger.example.com" || r.Port != 8080 || r.Path != "/" ||
r.Protocol != "http" || r.Scheme != "http" || r.TLS != "terminate" {
t.Fatalf("normalised route = %+v", r)
}
diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go
index b91a5a5d..5933c865 100644
--- a/internal/app/naming_scope_test.go
+++ b/internal/app/naming_scope_test.go
@@ -60,7 +60,7 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) {
// 6.3 — a multi-route workload and a non-HTTP route survive the whole path:
// the canonical form describes them, and the generated labels route them.
func TestMultiRouteAndNonHTTPRouteEndToEnd(t *testing.T) {
- body := `api_version: onebox.run/v1
+ body := `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
@@ -70,10 +70,10 @@ workloads:
image: nginx
health: /healthz
routes:
- - {domain: shop.example.com, path: /, port: 3000, middlewares: [compress@file, secure-headers@file]}
- - {domain: shop.example.com, path: /api, port: 3001}
- - {domain: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c}
- - {domain: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg, middlewares: [office-only@file]}
+ - {hostname: shop.example.com, path: /, port: 3000, middlewares: [compress@file, secure-headers@file]}
+ - {hostname: shop.example.com, path: /api, port: 3001}
+ - {hostname: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c}
+ - {hostname: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg, middlewares: [office-only@file]}
proxy: {config: traefik}
`
r, err := loadText(t, body).Resolve("production")
@@ -124,14 +124,14 @@ proxy: {config: traefik}
}
func TestRouteMiddlewareOrderPreservesRepetition(t *testing.T) {
- body := `api_version: onebox.run/v1
+ body := `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@203.0.113.10}}
workloads:
web:
image: nginx
routes:
- - {domain: shop.example.com, port: 3000, middlewares: [prefix@file, auth@file, prefix@file]}
+ - {hostname: shop.example.com, port: 3000, middlewares: [prefix@file, auth@file, prefix@file]}
proxy: {managed: false}
`
r, err := loadText(t, body).Resolve("production")
diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go
index 25bbac11..820f8106 100644
--- a/internal/app/preflight_test.go
+++ b/internal/app/preflight_test.go
@@ -59,7 +59,7 @@ func TestPreflightRefusesForeignHostOwner(t *testing.T) {
}
}
-const preflightProject = `api_version: onebox.run/v1
+const preflightProject = `api_version: onebox.run/v2
app: ledger
environments:
production: {server: root@1.2.3.4}
@@ -67,8 +67,8 @@ workloads:
web:
role: application
image: nginx
- domain: ledger.example.com
- port: 8080
+ routes:
+ - {hostname: ledger.example.com, port: 8080}
volumes: [{name: uploads, path: /var/lib/ledger/uploads}]
`
@@ -356,15 +356,15 @@ func TestInterpolationEnvUsesComposeSemantics(t *testing.T) {
t.Fatal(err)
}
path := filepath.Join(dir, "ob.yml")
- if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1
+ if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
runtime:
env_files: [.env]
image: nginx
-domain: shop.example.com
-port: 3000
+routes:
+ - {hostname: shop.example.com, port: 3000}
`), 0o600); err != nil {
t.Fatal(err)
}
@@ -413,7 +413,7 @@ func TestPreflightResolvesAcrossDeclaredFilesInOrder(t *testing.T) {
t.Fatal(err)
}
path := filepath.Join(dir, "ob.yml")
- if err := os.WriteFile(path, []byte(`api_version: onebox.run/v1
+ if err := os.WriteFile(path, []byte(`api_version: onebox.run/v2
app: shop
environments:
production: {server: root@203.0.113.10}
@@ -423,8 +423,8 @@ runtime:
- file: .env.production
require: [API_TOKEN]
image: nginx
-domain: shop.example.com
-port: 3000
+routes:
+ - {hostname: shop.example.com, port: 3000}
`), 0o600); err != nil {
t.Fatal(err)
}
@@ -574,7 +574,7 @@ func TestHostOwnerRecordParsesTheSameForPreflightAndEngine(t *testing.T) {
// every mutation after it refuses a record it cannot parse.
func TestEnvironmentNamesMustSurviveTheOwnerRecord(t *testing.T) {
for _, name := range []string{"Staging", "prod_east", "staging replica", "-lead", "trail-"} {
- src := "api_version: onebox.run/v1\napp: sample\nenvironments:\n \"" + name +
+ src := "api_version: onebox.run/v2\napp: sample\nenvironments:\n \"" + name +
"\": {server: root@h}\nworkloads:\n web: {role: application, image: x:1}\n"
if _, err := LoadBytes([]byte(src), "ob.yml"); err == nil {
t.Fatalf("environment name %q was accepted by the loader but cannot round-trip the owner record", name)
@@ -582,7 +582,7 @@ func TestEnvironmentNamesMustSurviveTheOwnerRecord(t *testing.T) {
}
// And the ones that are legal stay legal.
for _, name := range []string{"production", "staging", "prod-east"} {
- src := "api_version: onebox.run/v1\napp: sample\nenvironments:\n " + name +
+ src := "api_version: onebox.run/v2\napp: sample\nenvironments:\n " + name +
": {server: root@h}\nworkloads:\n web: {role: application, image: x:1}\n"
if _, err := LoadBytes([]byte(src), "ob.yml"); err != nil {
t.Fatalf("environment name %q should be accepted: %v", name, err)
diff --git a/internal/app/purity_test.go b/internal/app/purity_test.go
index 30ecc7f7..114926e7 100644
--- a/internal/app/purity_test.go
+++ b/internal/app/purity_test.go
@@ -16,7 +16,7 @@ import (
// one commit disagree, entropy makes a digest meaningless, and an environment
// variable makes the result depend on whose shell ran it.
-const purityProject = `api_version: onebox.run/v1
+const purityProject = `api_version: onebox.run/v2
app: shop
environments:
production:
@@ -35,10 +35,10 @@ workloads:
health: /healthz
replicas: 2
routes:
- - {domain: shop.example.com, path: /, port: 3000}
- - {domain: shop.example.com, path: /api, port: 3001}
- - {domain: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c}
- - {domain: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg}
+ - {hostname: shop.example.com, path: /, port: 3000}
+ - {hostname: shop.example.com, path: /api, port: 3001}
+ - {hostname: grpc.example.com, port: 9000, entrypoint: grpc, scheme: h2c}
+ - {hostname: db.example.com, port: 5432, protocol: tcp, tls: passthrough, entrypoint: pg}
worker:
role: worker
image: nginx:1.27
@@ -198,12 +198,12 @@ func TestGenerationCannotReachATarget(t *testing.T) {
// connect to production.
func TestEveryGenerationFailureIsReachableOffline(t *testing.T) {
for name, body := range map[string]string{
- "unknown field": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nreplicaz: 3\n",
- "no source": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application}}\n",
- "two sources": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application, image: nginx, build: .}}\n",
- "job without effect": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {j: {role: job, image: nginx}}\n",
- "unknown driver": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nservices: {weird: {driver: nosuchthing, version: \"1\"}}\n",
- "route collision": "api_version: onebox.run/v1\napp: shop\nenvironments: {p: {server: h}}\nworkloads:\n a: {role: application, image: nginx, domain: x.example.com, port: 1}\n b: {role: application, image: nginx, domain: x.example.com, port: 2}\n",
+ "unknown field": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nreplicaz: 3\n",
+ "no source": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application}}\n",
+ "two sources": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {web: {role: application, image: nginx, build: .}}\n",
+ "job without effect": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads: {j: {role: job, image: nginx}}\n",
+ "unknown driver": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nimage: nginx\nservices: {weird: {driver: nosuchthing, version: \"1\"}}\n",
+ "route collision": "api_version: onebox.run/v2\napp: shop\nenvironments: {p: {server: h}}\nworkloads:\n a: {role: application, image: nginx, routes: [{hostname: x.example.com, port: 1}]}\n b: {role: application, image: nginx, routes: [{hostname: x.example.com, port: 2}]}\n",
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
diff --git a/internal/app/resolve.go b/internal/app/resolve.go
index 4ffb916e..96a89f44 100644
--- a/internal/app/resolve.go
+++ b/internal/app/resolve.go
@@ -304,6 +304,7 @@ func (p *Spec) deepCopy() (*Spec, error) {
}
out.Dir = p.Dir
out.file = p.file
+ out.legacyV1Snapshot = p.legacyV1Snapshot
// Without these a resolved project has no memory of what was authored, and
// would report every value as a default.
out.rawExpanded = p.rawExpanded
diff --git a/internal/app/resolve_test.go b/internal/app/resolve_test.go
index 4d40d3da..a1a45bb8 100644
--- a/internal/app/resolve_test.go
+++ b/internal/app/resolve_test.go
@@ -5,7 +5,7 @@ import (
"testing"
)
-const overrideFixture = `api_version: onebox.run/v1
+const overrideFixture = `api_version: onebox.run/v2
app: ledger
environments:
production:
@@ -116,7 +116,7 @@ func TestResolveDoesNotLeakBetweenEnvironments(t *testing.T) {
}
func TestRouteMiddlewareOverrideRequiresManagedProxyConfig(t *testing.T) {
- body := `api_version: onebox.run/v1
+ body := `api_version: onebox.run/v2
app: shop
environments:
production: {server: root@prod}
@@ -125,11 +125,11 @@ environments:
overrides:
workloads:
web:
- routes: [{domain: shop.example.com, path: /, port: 3000, entrypoint: websecure, protocol: http, scheme: http, tls: terminate, middlewares: [auth@file]}]
+ routes: [{hostname: shop.example.com, path: /, port: 3000, entrypoint: websecure, protocol: http, scheme: http, tls: terminate, middlewares: [auth@file]}]
workloads:
web:
image: nginx
- routes: [{domain: shop.example.com, port: 3000}]
+ routes: [{hostname: shop.example.com, port: 3000}]
`
p, err := LoadBytes([]byte(body), "ob.yml")
if err != nil {
@@ -245,7 +245,7 @@ func TestRenderResolvesAutomatically(t *testing.T) {
// permitted set would accept an override naming something no service has, and
// accepting it silently is how an operator comes to believe a setting applied.
func TestOverridingAWithdrawnFieldIsRefused(t *testing.T) {
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments:
production: {server: root@h}
@@ -281,7 +281,7 @@ services: {postgres: 17}
// time — blamed on a `replicas` override nobody wrote. The inference is a
// derived read now, and the document is never edited.
func TestAProjectThatLoadsAlsoResolves(t *testing.T) {
- yaml := `api_version: onebox.run/v1
+ yaml := `api_version: onebox.run/v2
app: a
environments:
production:
diff --git a/internal/app/route_test.go b/internal/app/route_test.go
index 93a63300..b63b6c19 100644
--- a/internal/app/route_test.go
+++ b/internal/app/route_test.go
@@ -68,9 +68,9 @@ func TestRouteJumpDefaultsToPort22(t *testing.T) {
// port has to survive into the route, or the connection is attempted against
// a hostname with a colon in it.
func TestScalarServerPortReachesTheRoute(t *testing.T) {
- resolved, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ resolved, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: root@10.20.0.10:2222}}\n"+
- "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml")
+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml")
if err != nil {
t.Fatal(err)
}
@@ -94,9 +94,9 @@ func TestScalarServerPortReachesTheRoute(t *testing.T) {
func TestBracketedIPv6ScalarNormalisesLikeTheObjectForm(t *testing.T) {
load := func(server string) Environment {
t.Helper()
- resolved, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ resolved, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: "+server+"}}\n"+
- "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml")
+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml")
if err != nil {
t.Fatal(err)
}
@@ -121,9 +121,9 @@ func TestBracketedIPv6ScalarNormalisesLikeTheObjectForm(t *testing.T) {
// now, so the brackets have to come off while the project is read or
// JoinHostPort builds [[2001:db8::1]]:22.
func TestBracketedIPv6ServerHostNormalises(t *testing.T) {
- resolved, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ resolved, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: {host: \"[2001:db8::1]\", user: root}}}\n"+
- "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml")
+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml")
if err != nil {
t.Fatal(err)
}
@@ -146,9 +146,9 @@ func TestInvalidServerAddressIsRejectedAtLoad(t *testing.T) {
}
for name, server := range invalid {
t.Run(name, func(t *testing.T) {
- _, err := LoadBytes([]byte("api_version: onebox.run/v1\napp: ledger\n"+
+ _, err := LoadBytes([]byte("api_version: onebox.run/v2\napp: ledger\n"+
"environments: {production: {server: "+server+"}}\n"+
- "image: nginx\ndomain: d.example.com\nport: 8080\n"), "ob.yml")
+ "image: nginx\nroutes: [{hostname: d.example.com, port: 8080}]\n"), "ob.yml")
if err == nil {
t.Fatalf("server %q was accepted", server)
}
diff --git a/internal/app/schedule_test.go b/internal/app/schedule_test.go
index 3dc250fa..c613bd6e 100644
--- a/internal/app/schedule_test.go
+++ b/internal/app/schedule_test.go
@@ -66,7 +66,7 @@ func TestMalformedCronIsRefused(t *testing.T) {
// A job's schedule reaches the host with its timezone; a backup at 2am means
// 2am where the operator lives, not wherever the box was imaged.
func TestScheduledJobsCarryTimezone(t *testing.T) {
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -91,7 +91,7 @@ workloads:
}
func TestPinnedScheduleEligibilityFailsClosed(t *testing.T) {
- valid := `api_version: onebox.run/v1
+ valid := `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -120,7 +120,7 @@ workloads:
}
func TestScheduledJobRunPolicyIsExplicitAndValidated(t *testing.T) {
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -141,7 +141,7 @@ workloads:
t.Fatalf("authored run policy was not preserved: %#v", jobs)
}
- bad := `api_version: onebox.run/v1
+ bad := `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -153,7 +153,7 @@ workloads:
}
func TestScheduledJobRetryAndNotifyResolveWithDefaults(t *testing.T) {
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -206,7 +206,7 @@ func TestScheduledJobRetryIsBoundedByTheTimeout(t *testing.T) {
"unknown notify": {`{cron: "0 * * * *", notify: [warning]}`, "project_invalid"},
} {
t.Run(name, func(t *testing.T) {
- _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -227,7 +227,7 @@ workloads:
}
func TestJobInputsValidateNamesConstraintsAndDefaults(t *testing.T) {
- base := `api_version: onebox.run/v1
+ base := `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -268,7 +268,7 @@ workloads:
}
})
}
- if _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ if _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -310,7 +310,7 @@ func TestValidateJobInputValuesChecksOverrides(t *testing.T) {
}
func TestScheduledJobInputDefaultsRenderIntoTheComposeEnvironment(t *testing.T) {
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
diff --git a/internal/app/secrets_graph_test.go b/internal/app/secrets_graph_test.go
index d31b1f18..967ceb32 100644
--- a/internal/app/secrets_graph_test.go
+++ b/internal/app/secrets_graph_test.go
@@ -20,7 +20,7 @@ func secretGraphProject(t *testing.T, body string) *Resolved {
func TestSecretDeclarationGraphCapturesOrderScopeAndAffectedWorkloads(t *testing.T) {
resolved := secretGraphProject(t, `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
runtime:
@@ -43,7 +43,7 @@ workloads:
func TestSecretDeclarationIDsAreStableAndValueFree(t *testing.T) {
resolved := secretGraphProject(t, `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
runtime: {env_files: [{file: secrets.enc.env, provider: sops}]}
@@ -58,7 +58,7 @@ workloads: {web: {role: application, image: nginx}}
func TestSecretDeclarationGraphChangesForEveryRuntimeRelevantDrift(t *testing.T) {
base := `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
runtime:
@@ -81,14 +81,14 @@ workloads:
body string
}{
{name: "reordered", body: `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
runtime: {env_files: [{file: second.enc.env, provider: sops}, {file: first.enc.env, provider: sops}]}
workloads: {web: {role: application, image: nginx}, worker: {role: worker, image: nginx}}
`},
{name: "scope changed", body: `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
runtime: {env_files: [{file: first.enc.env, provider: sops}, {file: second.enc.env, provider: sops}]}
@@ -97,14 +97,14 @@ workloads:
worker: {role: worker, image: nginx}
`},
{name: "provider removed", body: `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
runtime: {env_files: [first.enc.env, {file: second.enc.env, provider: sops}]}
workloads: {web: {role: application, image: nginx}, worker: {role: worker, image: nginx}}
`},
{name: "affected workload removed", body: `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
runtime: {env_files: [{file: first.enc.env, provider: sops}, {file: second.enc.env, provider: sops}]}
@@ -122,7 +122,7 @@ workloads: {web: {role: application, image: nginx}}
func TestSecretDeclarationGraphIncludesSortedExternalProjection(t *testing.T) {
resolved := secretGraphProject(t, `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example}}
workloads:
diff --git a/internal/app/service_extensions_test.go b/internal/app/service_extensions_test.go
index d26a2c2f..a456b6a9 100644
--- a/internal/app/service_extensions_test.go
+++ b/internal/app/service_extensions_test.go
@@ -24,7 +24,7 @@ func TestVectorscaleIncludesItsVectorDependency(t *testing.T) {
}
func TestPostgresExtensionsSelectTheOneboxImage(t *testing.T) {
- rendered := renderServices(t, `api_version: onebox.run/v1
+ rendered := renderServices(t, `api_version: onebox.run/v2
app: goal
environments: {production: {server: root@host}}
workloads:
@@ -66,7 +66,7 @@ func TestProtectedPostgresMustAdoptTheOneboxImageBeforeExtensions(t *testing.T)
}
func TestPostgresExtensionsDerivePreloadAndCronSettings(t *testing.T) {
- rendered := renderServices(t, `api_version: onebox.run/v1
+ rendered := renderServices(t, `api_version: onebox.run/v2
app: goal
environments: {production: {server: root@host}}
workloads:
@@ -99,7 +99,7 @@ services:
}
func TestServiceExtensionsArePostgresOnly(t *testing.T) {
- _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: root@host}}
workloads:
@@ -116,7 +116,7 @@ services:
}
func TestPostgresExtensionsRequireThePublishedImageVersion(t *testing.T) {
- _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: root@host}}
workloads:
@@ -132,7 +132,7 @@ services:
}
func TestServiceExtensionNamesAreSafeSQLIdentifiers(t *testing.T) {
- _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: root@host}}
workloads:
@@ -154,7 +154,7 @@ func TestPgCronSettingsCannotDisableTheManagedContract(t *testing.T) {
"cron.database_name: elsewhere",
"cron.use_background_workers: off",
} {
- _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: root@host}}
workloads:
diff --git a/internal/app/services_test.go b/internal/app/services_test.go
index 57186500..1c4031fe 100644
--- a/internal/app/services_test.go
+++ b/internal/app/services_test.go
@@ -10,7 +10,7 @@ import (
func serviceSpec(t *testing.T, body string) *Spec {
t.Helper()
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
@@ -88,7 +88,7 @@ func TestNeedingAServiceJoinsItAndReadsItsURL(t *testing.T) {
// Guessing an image from an identifier would produce a container that starts
// and stores nothing durable.
func TestUnknownDriverIsRefusedWithAlternatives(t *testing.T) {
- _, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ _, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads: {web: {role: application, image: x:1}}
@@ -184,7 +184,7 @@ func TestGeneratedDollarsSurviveComposeInterpolation(t *testing.T) {
// service is only usable by one that happens to read the names Onebox chose,
// which almost none do.
func TestAWorkloadCanNameTheConnectionItself(t *testing.T) {
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: n8n
environments: {production: {server: root@h}}
workloads:
@@ -343,7 +343,7 @@ func TestCredentialWritesAreAtomic(t *testing.T) {
// behaviour callers depend on is unavailable, and a health-gated rollout
// converges onto a dependency that cannot store anything.
func TestRedisFamilyHealthChecksProveAWrite(t *testing.T) {
- rendered := renderServices(t, `api_version: onebox.run/v1
+ rendered := renderServices(t, `api_version: onebox.run/v2
app: sample
environments: {production: {server: root@h}}
workloads:
@@ -406,7 +406,7 @@ func TestEphemeralServicesOwnNoDurableVolume(t *testing.T) {
"clickhouse": "25.3", "redis": "8-alpine", "valkey": "8-alpine",
"rabbitmq": "4", "meilisearch": "1.10", "nats": "2.10",
}[svc]
- rendered := renderServices(t, "api_version: onebox.run/v1\napp: sample\n"+
+ rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+
"environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\n"+
"services:\n "+svc+":\n version: \""+version+"\"\n persistence: {mode: ephemeral}\n")
if strings.Contains(string(rendered[svc]), "_"+svc+"_data") {
@@ -422,7 +422,7 @@ func TestDurableRedisKeepsItsVolumeAndAppendOnlyLog(t *testing.T) {
" redis: {version: 8-alpine}\n",
" redis: {version: 8-alpine, persistence: {mode: durable}}\n",
} {
- rendered := renderServices(t, "api_version: onebox.run/v1\napp: sample\n"+
+ rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+
"environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\nservices:\n"+decl)
doc := string(rendered["redis"])
if !strings.Contains(doc, "_redis_data") {
@@ -438,7 +438,7 @@ func TestDurableRedisKeepsItsVolumeAndAppendOnlyLog(t *testing.T) {
// intends to read back.
func TestEphemeralRedisFamilyDisablesBothPersistenceMechanisms(t *testing.T) {
for _, svc := range []string{"redis", "valkey"} {
- rendered := renderServices(t, "api_version: onebox.run/v1\napp: sample\n"+
+ rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+
"environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\n"+
"services:\n "+svc+": {version: 8-alpine, persistence: {mode: ephemeral}}\n")
doc := string(rendered[svc])
@@ -455,7 +455,7 @@ func TestEphemeralRedisFamilyDisablesBothPersistenceMechanisms(t *testing.T) {
// beside it. Appending produced `--appendonly yes --appendonly no`, which is
// what made an author compensate for the driver in the first place.
func TestAuthoredSettingOverridesTheModeDefaultExactlyOnce(t *testing.T) {
- rendered := renderServices(t, `api_version: onebox.run/v1
+ rendered := renderServices(t, `api_version: onebox.run/v2
app: sample
environments: {production: {server: root@h}}
workloads:
@@ -485,7 +485,7 @@ services:
// downgrade on the one mode that says the data matters.
func TestOnlyEphemeralDisablesServerPersistence(t *testing.T) {
for _, mode := range []string{"durable", "external"} {
- rendered := renderServices(t, "api_version: onebox.run/v1\napp: sample\n"+
+ rendered := renderServices(t, "api_version: onebox.run/v2\napp: sample\n"+
"environments: {production: {server: root@h}}\nworkloads:\n web: {role: application, image: x:1}\n"+
"services:\n redis: {version: 8-alpine, persistence: {mode: "+mode+"}}\n")
doc := string(rendered["redis"])
@@ -508,7 +508,7 @@ func TestOnlyEphemeralDisablesServerPersistence(t *testing.T) {
// protected-identity record, while nothing ever created or mounted it — the
// declaration would be silently ignored rather than refused.
func TestEphemeralServiceCannotDeclareVolumes(t *testing.T) {
- src := `api_version: onebox.run/v1
+ src := `api_version: onebox.run/v2
app: sample
environments: {production: {server: root@h}}
workloads:
diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json
index 3f28f5ae..c90af7ed 100644
--- a/internal/app/testdata/contract-verdicts.json
+++ b/internal/app/testdata/contract-verdicts.json
@@ -114,16 +114,6 @@
"loads": false,
"code": "stateful_replicas"
},
- {
- "case": "conformance/domain and routes together",
- "loads": false,
- "code": "routing_exclusive"
- },
- {
- "case": "conformance/domain without port",
- "loads": false,
- "code": "routing_incomplete"
- },
{
"case": "conformance/duration in days",
"loads": true,
@@ -194,6 +184,11 @@
"loads": false,
"code": "project_invalid"
},
+ {
+ "case": "conformance/http route with TLS passthrough",
+ "loads": false,
+ "code": "project_invalid"
+ },
{
"case": "conformance/image reference with registry port",
"loads": true,
@@ -312,7 +307,7 @@
{
"case": "conformance/port out of range",
"loads": false,
- "code": "project_invalid"
+ "code": "unknown_field"
},
{
"case": "conformance/provider-qualified route middlewares",
diff --git a/internal/app/testdata/corpus/README.md b/internal/app/testdata/corpus/README.md
index c4539ff7..2fdcda85 100644
--- a/internal/app/testdata/corpus/README.md
+++ b/internal/app/testdata/corpus/README.md
@@ -1,6 +1,6 @@
# Conformance corpus
-Real projects, authored against `onebox.run/v1`, used to freeze the contract:
+Real projects, authored against `onebox.run/v2`, used to freeze the contract:
each one's accept/reject verdict, error code and generated-runtime digest is
recorded in `../contract-verdicts.json` and asserted on every run.
diff --git a/internal/app/testdata/corpus/ext-authentik-managed.yml b/internal/app/testdata/corpus/ext-authentik-managed.yml
index eba704a7..a1072e23 100644
--- a/internal/app/testdata/corpus/ext-authentik-managed.yml
+++ b/internal/app/testdata/corpus/ext-authentik-managed.yml
@@ -12,7 +12,7 @@
# The secret key is encrypted and the rest of the configuration is not, so the
# frozen digest also covers a list mixing a plaintext entry with an encrypted
# one — including the name the runtime references for the decrypted file.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: authentik
environments:
production: {server: root@example.com}
@@ -26,8 +26,8 @@ workloads:
strategy: recreate
image: ghcr.io/goauthentik/server:2026.5.6
command: [server]
- domain: auth.example.com
- port: 9000
+ routes:
+ - {hostname: auth.example.com, port: 9000}
needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}]
published_ports: [{host: 9000, container: 9000}, {host: 9443, container: 9443}]
volumes:
diff --git a/internal/app/testdata/corpus/ext-authentik.yml b/internal/app/testdata/corpus/ext-authentik.yml
index c00a9491..016b18bc 100644
--- a/internal/app/testdata/corpus/ext-authentik.yml
+++ b/internal/app/testdata/corpus/ext-authentik.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: authentik
environments:
production: {server: root@example.com}
@@ -7,8 +7,8 @@ workloads:
role: application
image: ghcr.io/goauthentik/server:2026.5.6
command: [server]
- domain: auth.example.com
- port: 9000
+ routes:
+ - {hostname: auth.example.com, port: 9000}
needs: [{name: postgresql, condition: healthy}]
env_files: [.env]
published_ports: [{host: 9000, container: 9000}, {host: 9443, container: 9443}]
diff --git a/internal/app/testdata/corpus/ext-frigate.yml b/internal/app/testdata/corpus/ext-frigate.yml
index c9834fbc..3777b6ce 100644
--- a/internal/app/testdata/corpus/ext-frigate.yml
+++ b/internal/app/testdata/corpus/ext-frigate.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: frigate
environments:
production: {server: root@example.com}
@@ -8,8 +8,8 @@ workloads:
# devices, privileged, shm_size and the tmpfs mount are not expressible in
# the declaration and are carried by the Compose reference instead.
compose: docker-compose.yml#frigate
- domain: frigate.example.com
- port: 8971
+ routes:
+ - {hostname: frigate.example.com, port: 8971}
drain: {grace: 30s}
published_ports:
- {host: 8971, container: 8971}
diff --git a/internal/app/testdata/corpus/ext-gitea.yml b/internal/app/testdata/corpus/ext-gitea.yml
index 8a37f67b..d2c2b63c 100644
--- a/internal/app/testdata/corpus/ext-gitea.yml
+++ b/internal/app/testdata/corpus/ext-gitea.yml
@@ -1,5 +1,5 @@
# Deployed and verified against managed services on a throwaway host.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: gitea
environments:
production: {server: root@example.com}
@@ -8,8 +8,8 @@ workloads:
role: application
strategy: recreate
image: docker.gitea.com/gitea:1.27.1
- domain: git.example.com
- port: 3000
+ routes:
+ - {hostname: git.example.com, port: 3000}
health: {http: /api/healthz, port: 3000, interval: 5s, start_period: 20s, within: 240s}
published_ports: [{host: 222, container: 22, bind: "0.0.0.0"}]
volumes: [{name: data, path: /data}]
diff --git a/internal/app/testdata/corpus/ext-immich-sourced.yml b/internal/app/testdata/corpus/ext-immich-sourced.yml
index 4edaa82f..0d301901 100644
--- a/internal/app/testdata/corpus/ext-immich-sourced.yml
+++ b/internal/app/testdata/corpus/ext-immich-sourced.yml
@@ -12,7 +12,7 @@
# that decide what the container reads — the referenced service's own
# `env_file`, the project's declared entries, and the referenced service's own
# `environment` — plus a managed service supplying a connection file.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: immich
environments:
production: {server: root@example.com}
@@ -24,8 +24,8 @@ workloads:
immich-server:
role: application
compose: ext-immich-sourced.compose.yaml#immich-server
- domain: photos.example.com
- port: 2283
+ routes:
+ - {hostname: photos.example.com, port: 2283}
needs: [{name: postgres, condition: healthy}, {name: redis, condition: healthy}]
immich-machine-learning:
role: worker
diff --git a/internal/app/testdata/corpus/ext-immich.yml b/internal/app/testdata/corpus/ext-immich.yml
index 72050ea6..a0bebc50 100644
--- a/internal/app/testdata/corpus/ext-immich.yml
+++ b/internal/app/testdata/corpus/ext-immich.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: immich
environments:
production: {server: root@example.com}
@@ -6,8 +6,8 @@ workloads:
server:
role: application
image: ghcr.io/immich-app/immich-server:v1.119.0
- domain: photos.example.com
- port: 2283
+ routes:
+ - {hostname: photos.example.com, port: 2283}
needs: [{name: redis, condition: healthy}, {name: database, condition: healthy}]
env_files: [.env]
volumes: [{name: upload, path: /data}]
diff --git a/internal/app/testdata/corpus/ext-n8n.yml b/internal/app/testdata/corpus/ext-n8n.yml
index f2b66be3..32d4f823 100644
--- a/internal/app/testdata/corpus/ext-n8n.yml
+++ b/internal/app/testdata/corpus/ext-n8n.yml
@@ -1,6 +1,6 @@
# Deployed and verified: n8n ran its migrations against the managed Postgres
# and served /healthz, reading the connection under its own variable names.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: n8n
environments:
production:
@@ -8,8 +8,8 @@ environments:
workloads:
n8n:
role: application
- domain: n8n.example.com
- port: 5678
+ routes:
+ - {hostname: n8n.example.com, port: 5678}
image: docker.n8n.io/n8nio/n8n:1.70.0
health: {http: /healthz, port: 5678, interval: 3s, start_period: 10s, within: 180s}
volumes: [{name: storage, path: /home/node/.n8n}]
diff --git a/internal/app/testdata/corpus/ext-paperless.yml b/internal/app/testdata/corpus/ext-paperless.yml
index 62874a66..6661b73f 100644
--- a/internal/app/testdata/corpus/ext-paperless.yml
+++ b/internal/app/testdata/corpus/ext-paperless.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: paperless
environments:
production: {server: root@example.com}
@@ -6,8 +6,8 @@ workloads:
webserver:
role: application
image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7
- domain: paperless.example.com
- port: 8000
+ routes:
+ - {hostname: paperless.example.com, port: 8000}
# Bare names: the loader waits for health where the dependency declares a
# check and for start where it does not. gotenberg and tika declare none.
needs: [db, broker, gotenberg, tika]
diff --git a/internal/app/testdata/corpus/ext-plausible.yml b/internal/app/testdata/corpus/ext-plausible.yml
index 53574fe8..39c3f071 100644
--- a/internal/app/testdata/corpus/ext-plausible.yml
+++ b/internal/app/testdata/corpus/ext-plausible.yml
@@ -1,5 +1,5 @@
# Deployed and verified against managed services on a throwaway host.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: plausible
environments:
production: {server: root@example.com}
@@ -7,8 +7,8 @@ workloads:
plausible:
role: application
image: ghcr.io/plausible/community-edition:v3.0.1
- domain: stats.example.com
- port: 8000
+ routes:
+ - {hostname: stats.example.com, port: 8000}
health: {exec: ["/bin/sh", "-c", "wget -qO- http://127.0.0.1:8000/api/health || exit 1"], interval: 5s, start_period: 40s, within: 300s}
volumes: [{name: data, path: /var/lib/plausible}]
env:
diff --git a/internal/app/testdata/corpus/ext-umami.yml b/internal/app/testdata/corpus/ext-umami.yml
index 2e8cd008..af7f7ccd 100644
--- a/internal/app/testdata/corpus/ext-umami.yml
+++ b/internal/app/testdata/corpus/ext-umami.yml
@@ -1,5 +1,5 @@
# Deployed and verified against managed services on a throwaway host.
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: umami
environments:
production: {server: root@example.com}
@@ -7,8 +7,8 @@ workloads:
umami:
role: application
image: ghcr.io/umami-software/umami:postgresql-v2.19.0
- domain: analytics.example.com
- port: 3000
+ routes:
+ - {hostname: analytics.example.com, port: 3000}
health: {exec: ["/usr/local/bin/node", "-e", "fetch('http://127.0.0.1:3000/api/heartbeat').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"], interval: 5s, start_period: 20s, within: 240s}
env: {APP_SECRET: throwaway-secret-for-this-test}
needs:
diff --git a/internal/app/testdata/corpus/goal.yml b/internal/app/testdata/corpus/goal.yml
index 51880cfa..77d0804c 100644
--- a/internal/app/testdata/corpus/goal.yml
+++ b/internal/app/testdata/corpus/goal.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: goal
environments:
production:
@@ -10,8 +10,8 @@ workloads:
image: ghcr.io/labstack/goal-server:2026.8.1
replicas: 3
strategy: rolling
- domain: goal.fit
- port: 7510
+ routes:
+ - {hostname: goal.fit, port: 7510}
health: {http: /healthz, port: 7510, interval: 5s, start_period: 15s, within: 2m, retries: 5}
drain: {signal: TERM, grace: 30s}
env: {ENVIRONMENT: production, OTEL_SERVICE_NAME: goal-server}
diff --git a/internal/app/testdata/corpus/monk.yml b/internal/app/testdata/corpus/monk.yml
index 10754e23..45c22695 100644
--- a/internal/app/testdata/corpus/monk.yml
+++ b/internal/app/testdata/corpus/monk.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: monk
environments:
production:
@@ -10,8 +10,8 @@ workloads:
image: ghcr.io/labstack/monk-server:2026.8.1
replicas: 3
strategy: rolling
- domain: monk.trade
- port: 7500
+ routes:
+ - {hostname: monk.trade, port: 7500}
health:
exec: "curl --fail --silent --show-error http://127.0.0.1:7500/healthz"
interval: 10s
diff --git a/internal/app/testdata/corpus/pursue.yml b/internal/app/testdata/corpus/pursue.yml
index 56fbc7da..ae48d015 100644
--- a/internal/app/testdata/corpus/pursue.yml
+++ b/internal/app/testdata/corpus/pursue.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: pursue
environments:
production:
@@ -10,8 +10,8 @@ workloads:
image: ghcr.io/labstack/pursue-server:2026.8.1
replicas: 1
strategy: rolling
- domain: pursue.run
- port: 8080
+ routes:
+ - {hostname: pursue.run, port: 8080}
health: {http: /healthz, port: 8080, interval: 2s, within: 2m, retries: 3}
drain: {signal: TERM, wait: 2s, grace: 30s}
migrate:
diff --git a/internal/app/testdata/corpus/recast.yml b/internal/app/testdata/corpus/recast.yml
index e5490c98..43ab2b89 100644
--- a/internal/app/testdata/corpus/recast.yml
+++ b/internal/app/testdata/corpus/recast.yml
@@ -1,4 +1,4 @@
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: recast
environments:
production:
@@ -15,8 +15,8 @@ workloads:
image: ghcr.io/labstack/recast-server:2026.8.1
replicas: 1
strategy: rolling
- domain: recast.report
- port: 8080
+ routes:
+ - {hostname: recast.report, port: 8080}
health: {http: /healthz, port: 8080, interval: 2s, within: 2m, retries: 3}
drain: {signal: TERM, wait: 2s, grace: 30s}
migrate:
diff --git a/internal/app/types.go b/internal/app/types.go
index 2edacaf5..94647655 100644
--- a/internal/app/types.go
+++ b/internal/app/types.go
@@ -1,4 +1,4 @@
-// Package app loads the onebox.run/v1 declarative authoring contract: one
+// Package app loads the onebox.run/v2 declarative authoring contract: one
// application, its workloads, the services it needs, and how a release rolls
// out.
//
@@ -28,8 +28,12 @@ type Spec struct {
// file is the exact project path supplied to Load/LoadBytes. Mutating
// operations such as eject must never reconstruct it as Dir/ob.yml.
file string
+ // legacyV1Snapshot is set only while replaying an immutable release created
+ // by a v1 binary. It preserves the validation rules that release originally
+ // passed without reopening v1 as an authoring contract.
+ legacyV1Snapshot bool
- APIVersion string `json:"api_version" description:"Project contract version. Must be onebox.run/v1." example:"onebox.run/v1"`
+ APIVersion string `json:"api_version" description:"Project contract version. Must be onebox.run/v2." example:"onebox.run/v2"`
// Name is the application's name. Spelled Name rather than App because
// inside a package called app, `spec.App` is a stutter and every caller
// then writes `.App.App`. The authored key is still `app:`.
@@ -142,8 +146,7 @@ type Workload struct {
Replicas int `json:"replicas" description:"Desired number of long-running workload containers." default:"1" example:"2"`
Strategy string `json:"strategy,omitempty" description:"Replacement strategy for a changed or uncertain workload. An unchanged healthy workload is retained automatically. Defaults to rolling only for an application workload with health; all other workloads default to recreate."`
- Domain string `json:"domain,omitempty" description:"Domain shorthand for one HTTPS route; requires port and cannot be combined with routes." example:"shop.example.com"`
- Port int `json:"port,omitempty" description:"Container port used with domain shorthand and as the default HTTP health port." example:"3000"`
+ Port int `json:"port,omitempty" description:"Default container port used by HTTP health checks." example:"3000"`
Routes []Route `json:"routes,omitempty" description:"Ingress routes exposed by this workload."`
Health *Health `json:"health,omitempty" description:"Readiness check used to gate rolling replacement."`
@@ -213,15 +216,14 @@ type Image struct {
}
type Route struct {
- Domain string `json:"domain,omitempty" description:"Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix." example:"shop.example.com"`
- WildcardSuffix string `json:"wildcard_suffix,omitempty" description:"DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain." example:"preview.example.com"`
- Path string `json:"path" description:"URL path prefix matched by an HTTP route." default:"/"`
- Port int `json:"port" description:"Container port receiving routed traffic." example:"3000"`
- Entrypoint string `json:"entrypoint" description:"Named proxy listener used for the route." default:"websecure"`
- Protocol string `json:"protocol" description:"Routing protocol: http, tcp, or udp." default:"http"`
- Scheme string `json:"scheme" description:"Backend connection scheme: http, https, h2c, tcp, or udp." default:"http"`
- TLS string `json:"tls" description:"TLS handling: terminate, passthrough, or none." default:"terminate"`
- Middlewares []MiddlewareRef `json:"middlewares,omitempty" description:"Ordered provider-qualified middleware references applied to this route."`
+ Hostname string `json:"hostname" description:"Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes." example:"shop.example.com"`
+ Path string `json:"path" description:"URL path prefix matched by an HTTP route." default:"/"`
+ Port int `json:"port" description:"Container port receiving routed traffic." example:"3000"`
+ Entrypoint string `json:"entrypoint" description:"Named proxy listener used for the route." default:"websecure"`
+ Protocol string `json:"protocol" description:"Routing protocol: http or tcp." default:"http"`
+ Scheme string `json:"scheme" description:"Backend connection scheme for HTTP routes: http, https, or h2c." default:"http"`
+ TLS string `json:"tls" description:"TLS handling: terminate, passthrough, or none." default:"terminate"`
+ Middlewares []MiddlewareRef `json:"middlewares,omitempty" description:"Ordered provider-qualified middleware references applied to this route."`
}
// MiddlewareRef names dynamic proxy configuration without opening the
diff --git a/internal/app/validate.go b/internal/app/validate.go
index 839fef6e..5e59e405 100644
--- a/internal/app/validate.go
+++ b/internal/app/validate.go
@@ -59,7 +59,7 @@ func validateSpec(p *Spec) error {
if err := gIdent.check("workloads."+name, name); err != nil {
return err
}
- if err := validateWorkload(p.Workloads[name], "workloads."+name); err != nil {
+ if err := validateWorkload(p.Workloads[name], "workloads."+name, p.legacyV1Snapshot); err != nil {
return err
}
}
@@ -248,7 +248,7 @@ func validateEnvironment(e Environment, path string) error {
return gCalVer.checkOptional(path+".policy.min_onebox_version", e.Policy.MinOneboxVersion)
}
-func validateWorkload(w Workload, path string) error {
+func validateWorkload(w Workload, path string, legacyV1Snapshot bool) error {
if err := validateJobExecution(w, path); err != nil {
return err
}
@@ -290,34 +290,26 @@ func validateWorkload(w Workload, path string) error {
return err
}
}
- if w.Domain != "" && w.Port != 0 {
- if err := gRouteHost.check(path+".domain", w.Domain); err != nil {
- return err
- }
+ if w.Port != 0 {
if err := checkPort(path+".port", w.Port); err != nil {
return err
}
}
for i, r := range w.Routes {
rp := indexed(path+".routes", i)
- if (r.Domain == "") == (r.WildcardSuffix == "") {
- return errf("project_invalid", rp, "", "a route must declare exactly one of domain or wildcard_suffix")
- }
- if r.Domain != "" {
- if r.Domain == "*" && r.Protocol == "tcp" && (r.TLS == "none" || r.TLS == "passthrough") {
- // HostSNI(`*`) is Traefik's TCP catch-all for plaintext and
- // TLS passthrough. It predates wildcard HTTP routes and remains
- // the one intentional exception to exact-host syntax.
- } else if err := gRouteHost.check(rp+".domain", r.Domain); err != nil {
- return err
- }
+ if r.Hostname == "" {
+ return errf("project_invalid", rp+".hostname", "", "a route must declare hostname")
}
- if r.WildcardSuffix != "" {
- if err := validateWildcardSuffix(rp+".wildcard_suffix", r.WildcardSuffix); err != nil {
- return err
- }
+ if r.Hostname == "*" && r.Protocol == "tcp" && (r.TLS == "none" || r.TLS == "passthrough") {
+ // HostSNI(`*`) is Traefik's TCP catch-all for plaintext and
+ // TLS passthrough. It predates wildcard HTTP routes and remains
+ // the one intentional exception to exact-host syntax.
+ } else if err := validateRouteHostname(rp+".hostname", r.Hostname, legacyV1Snapshot); err != nil {
+ return err
+ }
+ if r.IsWildcard() {
if r.Protocol != "http" {
- return errf("project_invalid", rp+".wildcard_suffix", "", "wildcard_suffix is supported only for HTTP routes")
+ return errf("project_invalid", rp+".hostname", "", "wildcard hostnames are supported only for HTTP routes")
}
}
if err := gURLPath.check(rp+".path", r.Path); err != nil {
@@ -514,11 +506,22 @@ func validateWorkload(w Workload, path string) error {
return nil
}
-func validateWildcardSuffix(path, value string) error {
+func validateRouteHostname(path, value string, legacyV1Snapshot bool) error {
+ if legacyV1Snapshot {
+ if !strings.HasPrefix(value, "*.") {
+ return gLegacyRouteHost.check(path, value)
+ }
+ // v1 measured wildcard_suffix without the authored "*." marker. Keep
+ // that exact bound when replaying a release that already passed v1.
+ if len(strings.TrimPrefix(value, "*.")) > 253 {
+ return errf("project_invalid", path, "", "%q is not a DNS hostname: its suffix exceeds 253 characters", value)
+ }
+ return gRouteHostname.check(path, value)
+ }
if len(value) > 253 {
return errf("project_invalid", path, "", "%q is not a DNS hostname: it exceeds 253 characters", value)
}
- return gWildcardSuffix.check(path, value)
+ return gRouteHostname.check(path, value)
}
func validateHealth(h *Health, path string) error {
diff --git a/internal/app/workload_contract_test.go b/internal/app/workload_contract_test.go
index f880b664..0442f3ae 100644
--- a/internal/app/workload_contract_test.go
+++ b/internal/app/workload_contract_test.go
@@ -54,7 +54,7 @@ func TestSecretInputRevisionsAreScopedByWorkload(t *testing.T) {
t.Fatal(err)
}
}
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -94,7 +94,7 @@ func TestSecretInputRevisionsUseTheProvidedSnapshot(t *testing.T) {
if err := os.WriteFile(path, []byte("cipher-before"), 0o600); err != nil {
t.Fatal(err)
}
- spec, err := LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
diff --git a/internal/engine/backup_identity_test.go b/internal/engine/backup_identity_test.go
index 3ade3012..58a333d3 100644
--- a/internal/engine/backup_identity_test.go
+++ b/internal/engine/backup_identity_test.go
@@ -10,7 +10,7 @@ import (
"github.com/labstack/onebox/internal/transport"
)
-const protectedPostgresProject = `api_version: onebox.run/v1
+const protectedPostgresProject = `api_version: onebox.run/v2
app: shop
environments:
production: {server: deploy@example.net}
diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go
index d2e24cfd..1573f887 100644
--- a/internal/engine/deploy_test.go
+++ b/internal/engine/deploy_test.go
@@ -23,7 +23,7 @@ import (
const guardedHealthcheck = `["CMD-SHELL","[ -f /tmp/ob-drain ] \u0026\u0026 exit 1; curl -fsS 'http://127.0.0.1:80/'"]`
const enginePreviousFrontendProject = `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments:
production:
diff --git a/internal/engine/fixtures_test.go b/internal/engine/fixtures_test.go
index 667d90d6..4575610e 100644
--- a/internal/engine/fixtures_test.go
+++ b/internal/engine/fixtures_test.go
@@ -21,7 +21,7 @@ const (
// that assembles the struct directly can assert on a shape the loader would
// never produce.
const engineProject = `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments:
production:
diff --git a/internal/engine/host_environment_test.go b/internal/engine/host_environment_test.go
index 1a86cbe7..79d1c1e0 100644
--- a/internal/engine/host_environment_test.go
+++ b/internal/engine/host_environment_test.go
@@ -114,7 +114,7 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) {
// host owner record.
func TestEnvironmentSelectsTheBasePath(t *testing.T) {
spec, err := app.LoadBytes([]byte(`
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
base_path: /var/lib/ob
environments:
diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go
index 926e28e2..19d84fd4 100644
--- a/internal/engine/proxy_test.go
+++ b/internal/engine/proxy_test.go
@@ -55,8 +55,7 @@ func TestEnsureProxyRefusesMissingResolverBeforeHostMutation(t *testing.T) {
}
cfg := testConfig()
web := cfg.Workloads["web"]
- web.Domain = "app.example.com"
- web.Port = 7500
+ web.Routes = []app.Route{{Hostname: "app.example.com", Port: 7500, Path: "/", Entrypoint: "websecure", Protocol: "http", Scheme: "http", TLS: "terminate"}}
cfg.Workloads["web"] = web
cfg.Proxy = app.Proxy{Kind: "traefik-docker", Managed: true, Config: "traefik"}
f := &transport.Fake{}
diff --git a/internal/engine/recovery.go b/internal/engine/recovery.go
index 534eb944..2d5a8aa2 100644
--- a/internal/engine/recovery.go
+++ b/internal/engine/recovery.go
@@ -44,7 +44,7 @@ func (e *Engine) engineFromReleaseSnapshotFor(ctx context.Context, releaseID, op
return nil, fmt.Errorf("%s refused: release %s snapshot is empty", operation, releaseID)
}
- snapshot, err := app.LoadBytes([]byte(res.Stdout), path)
+ snapshot, err := app.LoadReleaseSnapshotBytes([]byte(res.Stdout), path)
if err != nil {
return nil, fmt.Errorf("%s refused: release %s snapshot unusable: %w", operation, releaseID, err)
}
diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go
index d5c5a84f..f89fb944 100644
--- a/internal/engine/recovery_test.go
+++ b/internal/engine/recovery_test.go
@@ -49,6 +49,37 @@ func recoveryWriter(engine *Engine) *journal.Writer {
return &journal.Writer{T: engine.T, Names: engine.Names(), DeployID: engineTestDeployReleaseID, Epoch: 2}
}
+func TestLifecycleReplayLoadsV1ReleaseSnapshot(t *testing.T) {
+ target := happyFake()
+ base := target.Dynamic
+ target.Dynamic = func(command string) (transport.Result, bool) {
+ if strings.Contains(command, "/releases/legacy/ob.snapshot.yml") {
+ return transport.Result{Stdout: `api_version: onebox.run/v1
+app: sample
+environments:
+ production:
+ server: deploy@h
+ overrides:
+ workloads:
+ sample:
+ routes: [{domain: override.example.com, path: /, port: 8081, entrypoint: websecure, protocol: http, scheme: http, tls: none}]
+image: nginx
+routes: [{domain: Example.COM., port: 8080}]
+`}, true
+ }
+ return base(command)
+ }
+ engine := New(testConfig(), testProject(t), target, Options{Environment: "production"})
+ replay, err := engine.engineFromReleaseSnapshotFor(context.Background(), "legacy", "retired-workload cleanup")
+ if err != nil {
+ t.Fatalf("load v1 lifecycle snapshot: %v", err)
+ }
+ routes := replay.Spec.Workloads["sample"].Routes
+ if len(routes) != 1 || routes[0].Hostname != "override.example.com" || routes[0].Port != 8081 {
+ t.Fatalf("replayed routes = %+v", routes)
+ }
+}
+
func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing.T) {
target := happyFake()
verifyCalls := 0
diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go
index 065e52f1..3167c1ca 100644
--- a/internal/engine/resume_test.go
+++ b/internal/engine/resume_test.go
@@ -390,7 +390,7 @@ func TestAbortUsesInterruptedExpandOnlyPolicyAfterConfigEdit(t *testing.T) {
}
const interruptedWebSnapshot = `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: { production: { server: deploy@h } }
workloads:
diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go
index 8a2a7ef9..d8c4cd37 100644
--- a/internal/engine/secret_generation_rolling_test.go
+++ b/internal/engine/secret_generation_rolling_test.go
@@ -14,7 +14,7 @@ import (
// web declares a health check, so it defaults to rolling; worker stays a
// recreate workload, which is what keeps the two paths visible in one push.
-const rollingGenerationProject = `api_version: onebox.run/v1
+const rollingGenerationProject = `api_version: onebox.run/v2
app: shop
base_path: /srv/onebox
environments:
@@ -23,7 +23,7 @@ workloads:
web:
image: nginx
port: 3000
- domain: shop.example.com
+ hostname: shop.example.com
health: {exec: ["/health"]}
env_files: [{file: web.enc.env, provider: sops}]
worker:
diff --git a/internal/engine/secret_generation_test.go b/internal/engine/secret_generation_test.go
index 3eeff3da..239546a0 100644
--- a/internal/engine/secret_generation_test.go
+++ b/internal/engine/secret_generation_test.go
@@ -13,7 +13,7 @@ import (
"github.com/labstack/onebox/internal/transport"
)
-const generationProject = `api_version: onebox.run/v1
+const generationProject = `api_version: onebox.run/v2
app: shop
base_path: /srv/onebox
environments:
@@ -22,7 +22,7 @@ workloads:
web:
image: nginx
port: 3000
- domain: shop.example.com
+ hostname: shop.example.com
env_files: [{file: web.enc.env, provider: sops}]
worker:
role: worker
diff --git a/internal/engine/secretspush_test.go b/internal/engine/secretspush_test.go
index 8df773e7..6be31561 100644
--- a/internal/engine/secretspush_test.go
+++ b/internal/engine/secretspush_test.go
@@ -11,7 +11,7 @@ import (
"github.com/labstack/onebox/internal/transport"
)
-const secretGraphProject = `api_version: onebox.run/v1
+const secretGraphProject = `api_version: onebox.run/v2
app: shop
environments:
production: {server: deploy@example.invalid}
@@ -22,7 +22,7 @@ workloads:
web:
image: nginx
port: 3000
- domain: shop.example.com
+ hostname: shop.example.com
env_files:
- {file: first.enc.env, provider: sops}
- {file: second.enc.env, provider: sops}
diff --git a/internal/engine/verify_injection_test.go b/internal/engine/verify_injection_test.go
index 20f22515..4709e6b6 100644
--- a/internal/engine/verify_injection_test.go
+++ b/internal/engine/verify_injection_test.go
@@ -64,7 +64,7 @@ func TestVerifyHTTPProbeStillCarriesThePortAndPath(t *testing.T) {
func verificationProject(t *testing.T, path string) *app.Resolved {
t.Helper()
spec, err := app.LoadBytes([]byte(`
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments:
production:
diff --git a/internal/onebox/bootstrap_test.go b/internal/onebox/bootstrap_test.go
index 41a8e788..7b7a2368 100644
--- a/internal/onebox/bootstrap_test.go
+++ b/internal/onebox/bootstrap_test.go
@@ -10,7 +10,7 @@ import (
"github.com/labstack/onebox/internal/transport"
)
-const bootstrapBuildProject = `api_version: onebox.run/v1
+const bootstrapBuildProject = `api_version: onebox.run/v2
app: demo
environments: {production: {server: deploy@example.invalid}}
runtime:
diff --git a/internal/onebox/exec_test.go b/internal/onebox/exec_test.go
index 1fecad41..870b69a8 100644
--- a/internal/onebox/exec_test.go
+++ b/internal/onebox/exec_test.go
@@ -14,7 +14,7 @@ import (
"github.com/labstack/onebox/internal/transport"
)
-const execProjectYAML = `api_version: onebox.run/v1
+const execProjectYAML = `api_version: onebox.run/v2
app: shop
environments:
production:
@@ -23,7 +23,7 @@ workloads:
api:
image: nginx
port: 3000
- domain: shop.example.com
+ hostname: shop.example.com
`
func execService(t *testing.T, connect Connector) *Service {
diff --git a/internal/onebox/jump_route_test.go b/internal/onebox/jump_route_test.go
index 171f738d..6d77317e 100644
--- a/internal/onebox/jump_route_test.go
+++ b/internal/onebox/jump_route_test.go
@@ -16,15 +16,15 @@ func writeJumpProject(t *testing.T) string {
dir := t.TempDir()
path := filepath.Join(dir, "ob.yml")
body := `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: demo
environments:
production:
server: deploy@example.invalid
jump: bastion@jump.invalid:2222
image: ghcr.io/example/app:v1
-domain: demo.example.com
-port: 8080
+routes:
+ - {hostname: demo.example.com, port: 8080}
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
@@ -83,15 +83,15 @@ func TestChangingOnlyTheJumpChangesTheBinding(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ob.yml")
body := `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: demo
environments:
production:
server: deploy@example.invalid
jump: ` + jump + `
image: ghcr.io/example/app:v1
-domain: demo.example.com
-port: 8080
+routes:
+ - {hostname: demo.example.com, port: 8080}
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
diff --git a/internal/onebox/load_service_runtime_test.go b/internal/onebox/load_service_runtime_test.go
index 765ba60a..1f485c75 100644
--- a/internal/onebox/load_service_runtime_test.go
+++ b/internal/onebox/load_service_runtime_test.go
@@ -16,7 +16,7 @@ func protectedRuntimeProject(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "ob.yml")
- body := `api_version: onebox.run/v1
+ body := `api_version: onebox.run/v2
app: example
environments: {production: {server: root@host}}
workloads: {web: {image: nginx:1}}
diff --git a/internal/onebox/operation_graph_test.go b/internal/onebox/operation_graph_test.go
index 443f0b2f..9d832edd 100644
--- a/internal/onebox/operation_graph_test.go
+++ b/internal/onebox/operation_graph_test.go
@@ -82,7 +82,7 @@ func TestDeploymentGraphNeverContainsHookBodies(t *testing.T) {
func TestDeploymentGraphOmitsAbsentHooksAndJobs(t *testing.T) {
t.Parallel()
spec, err := app.LoadBytes([]byte(`
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: root@h}}
workloads:
@@ -118,7 +118,7 @@ func TestDeploymentClassificationDoesNotOverstateFirstDeployRollback(t *testing.
func operationGraphConfig() *app.Resolved {
spec, err := app.LoadBytes([]byte(`
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: sample
environments: {production: {server: root@h}}
workloads:
diff --git a/internal/onebox/secrets_push_test.go b/internal/onebox/secrets_push_test.go
index 868e4fac..ec34ca13 100644
--- a/internal/onebox/secrets_push_test.go
+++ b/internal/onebox/secrets_push_test.go
@@ -12,7 +12,7 @@ import (
"github.com/labstack/onebox/internal/transport"
)
-const pushProjectYAML = `api_version: onebox.run/v1
+const pushProjectYAML = `api_version: onebox.run/v2
app: shop
environments:
production:
@@ -21,7 +21,7 @@ workloads:
web:
image: nginx
port: 3000
- domain: shop.example.com
+ hostname: shop.example.com
env_files: [{file: api.enc.env, provider: sops}]
jobs:
role: worker
@@ -172,11 +172,11 @@ func TestSecretsPushRotatesEveryEntry(t *testing.T) {
// A project with nothing encrypted is told so, rather than reporting a push.
func TestSecretsPushWithNothingEncryptedIsRefused(t *testing.T) {
dir := t.TempDir()
- if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`api_version: onebox.run/v1
+ if err := os.WriteFile(filepath.Join(dir, "ob.yml"), []byte(`api_version: onebox.run/v2
app: shop
environments: {production: {server: deploy@example.invalid}}
workloads:
- web: {image: nginx, port: 3000, domain: shop.example.com}
+ web: {image: nginx, routes: [{hostname: shop.example.com, port: 3000}]}
`), 0o600); err != nil {
t.Fatal(err)
}
diff --git a/internal/onebox/service_test.go b/internal/onebox/service_test.go
index 827873e7..bc2eae19 100644
--- a/internal/onebox/service_test.go
+++ b/internal/onebox/service_test.go
@@ -27,7 +27,7 @@ services:
image: ghcr.io/example/postgres:` + testSecret + `
`,
"ob.yml": `
-api_version: onebox.run/v1
+api_version: onebox.run/v2
app: demo
environments:
production:
@@ -164,7 +164,7 @@ func writeComposeBuildProject(t *testing.T) string {
pinnedWeb := "ghcr.io/example/app@sha256:" + strings.Repeat("1", 64)
files := map[string]string{
"compose.yaml": "services:\n database:\n build: .\n command: [postgres, -c, shared_buffers=256MB]\n",
- "ob.yml": `api_version: onebox.run/v1
+ "ob.yml": `api_version: onebox.run/v2
app: demo
environments: {production: {server: deploy@example.invalid}}
workloads:
@@ -232,7 +232,7 @@ func TestPlanDeployUsesDeployedSecretGraphDuringTransition(t *testing.T) {
if workerSecret {
workerEnv = "\n env_files: [{file: worker.enc.env, provider: sops}]"
}
- return `api_version: onebox.run/v1
+ return `api_version: onebox.run/v2
app: demo
environments:
production: {server: deploy@example.invalid}
diff --git a/internal/onebox/staging_secrets_test.go b/internal/onebox/staging_secrets_test.go
index 1a295127..9370516b 100644
--- a/internal/onebox/staging_secrets_test.go
+++ b/internal/onebox/staging_secrets_test.go
@@ -38,7 +38,7 @@ func twoEncryptedEntries(t *testing.T) string {
write("api.enc.env", "TOKEN=api-token\n")
write("worker.enc.env", "TOKEN=worker-token\n")
write("shared.env", "REGION=eu\n")
- write("ob.yml", `api_version: onebox.run/v1
+ write("ob.yml", `api_version: onebox.run/v2
app: shop
environments:
production:
@@ -50,7 +50,7 @@ workloads:
web:
image: nginx
port: 3000
- domain: shop.example.com
+ hostname: shop.example.com
volumes:
- {source: ., path: /app, mode: ro}
env_files:
@@ -252,7 +252,7 @@ func TestExternalServiceConnectionIsProjectedLeastPrivilegeIntoRelease(t *testin
if err := os.WriteFile(filepath.Join(dir, "secrets", "database.env"), []byte(secret), 0o600); err != nil {
t.Fatal(err)
}
- project := `api_version: onebox.run/v1
+ project := `api_version: onebox.run/v2
app: shop
environments: {production: {server: root@h}}
workloads:
diff --git a/internal/onebox/workload_contract_test.go b/internal/onebox/workload_contract_test.go
index f6df03d6..914121b0 100644
--- a/internal/onebox/workload_contract_test.go
+++ b/internal/onebox/workload_contract_test.go
@@ -15,7 +15,7 @@ func TestWorkloadContractsScopePlainEnvironmentChanges(t *testing.T) {
t.Fatal(err)
}
}
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -83,7 +83,7 @@ func TestWorkloadContractsTrackRelativeBindMountContent(t *testing.T) {
t.Fatal(err)
}
}
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -121,7 +121,7 @@ deployment: {order: [api, worker]}
}
func TestBindMountContractIsIndependentOfWhereTheReleaseIsStaged(t *testing.T) {
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -157,7 +157,7 @@ deployment: {order: [api]}
func TestWorkloadContractsIgnoreVolumesOnAnAdoptedComposeService(t *testing.T) {
staging := t.TempDir()
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -177,7 +177,7 @@ deployment: {order: [api]}
}
func TestBindMountContractNoticesAnAddedEmptyDirectory(t *testing.T) {
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -216,7 +216,7 @@ deployment: {order: [api]}
func bindMountRevision(t *testing.T, mode os.FileMode) string {
t.Helper()
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
diff --git a/internal/onebox/workload_plan_test.go b/internal/onebox/workload_plan_test.go
index 89fa5df3..ea305623 100644
--- a/internal/onebox/workload_plan_test.go
+++ b/internal/onebox/workload_plan_test.go
@@ -15,7 +15,7 @@ import (
func workloadPlanFixture(t *testing.T) (*app.Resolved, string, []OperationStep, engine.HostState) {
t.Helper()
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
@@ -163,7 +163,7 @@ func TestPlanDeployRetainsUnchangedWorkerWhenAnotherWorkloadChanges(t *testing.T
digestB := strings.Repeat("2", 64)
workerDigest := strings.Repeat("3", 64)
project := func(apiDigest string) string {
- return `api_version: onebox.run/v1
+ return `api_version: onebox.run/v2
app: sample
environments: {production: {server: deploy@example.test}}
workloads:
diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go
index 04a5b9de..877e9b4c 100644
--- a/internal/proxy/proxy_test.go
+++ b/internal/proxy/proxy_test.go
@@ -160,7 +160,7 @@ func TestManagedWildcardTLSRejectsIncompleteDNSConfiguration(t *testing.T) {
}
func TestManagedTLSRouterReferencesDefaultStaticResolver(t *testing.T) {
- spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1
+ spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v2
app: sample
environments:
production: {server: root@example.com}
@@ -168,8 +168,8 @@ workloads:
web:
role: application
image: nginx:1.27
- domain: app.example.com
- port: 80
+ routes:
+ - {hostname: app.example.com, port: 80}
`), "ob.yml")
if err != nil {
t.Fatal(err)
diff --git a/site/public/onebox.run-v2.schema.json b/site/public/onebox.run-v2.schema.json
new file mode 100644
index 00000000..5786c127
--- /dev/null
+++ b/site/public/onebox.run-v2.schema.json
@@ -0,0 +1,3119 @@
+{
+ "$id": "https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "anyOf": [
+ {
+ "properties": {
+ "workloads": {
+ "minProperties": 1
+ }
+ },
+ "required": [
+ "workloads"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "required": [
+ "build"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ },
+ {
+ "required": [
+ "compose"
+ ]
+ }
+ ]
+ }
+ ],
+ "description": "One application, its workloads, the services it needs, and how a release rolls out.",
+ "not": {
+ "allOf": [
+ {
+ "required": [
+ "workloads"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "required": [
+ "build"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ },
+ {
+ "required": [
+ "compose"
+ ]
+ },
+ {
+ "required": [
+ "port"
+ ]
+ },
+ {
+ "required": [
+ "health"
+ ]
+ },
+ {
+ "required": [
+ "routes"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "api_version": {
+ "const": "onebox.run/v2",
+ "description": "Project contract version. Must be onebox.run/v2.",
+ "examples": [
+ "onebox.run/v2"
+ ],
+ "type": "string"
+ },
+ "app": {
+ "description": "Stable application name used in generated container, volume, network, and host paths. The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"ob-\" or be a name the host layout reserves.",
+ "examples": [
+ "shop"
+ ],
+ "not": {
+ "anyOf": [
+ {
+ "pattern": "^ob-"
+ },
+ {
+ "const": "ob"
+ },
+ {
+ "const": "onebox-proxy"
+ },
+ {
+ "const": "_host"
+ }
+ ]
+ },
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "backup_targets": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "bucket": {
+ "description": "Existing destination bucket used by this target. Expects a lower-case S3-compatible bucket name between 3 and 63 characters.",
+ "examples": [
+ "onebox-backups"
+ ],
+ "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$",
+ "type": "string"
+ },
+ "credentials": {
+ "additionalProperties": false,
+ "description": "Trusted encrypted-file entries containing destination credentials; values never appear in the project.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "access_key_entry": {
+ "description": "Variable name containing the destination access key. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "BACKUP_ACCESS_KEY_ID"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "file": {
+ "description": "Repository-relative encrypted credential file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "secrets/backup.env"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "default": "sops",
+ "description": "Trusted secret provider. Only sops is currently executable.",
+ "enum": [
+ "sops"
+ ],
+ "type": "string"
+ },
+ "secret_key_entry": {
+ "description": "Variable name containing the destination secret key. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "BACKUP_SECRET_ACCESS_KEY"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "session_token_entry": {
+ "description": "Optional variable name containing a temporary destination session token. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "BACKUP_SESSION_TOKEN"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "encryption": {
+ "additionalProperties": false,
+ "description": "Required encryption mode for each recovery kind this target may store.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cold": {
+ "description": "Encryption mode required for cold recovery: client-side or server-side.",
+ "enum": [
+ "client-side",
+ "server-side"
+ ],
+ "type": "string"
+ },
+ "pitr": {
+ "description": "Encryption mode required for point-in-time recovery: client-side or server-side.",
+ "enum": [
+ "client-side",
+ "server-side"
+ ],
+ "type": "string"
+ },
+ "snapshot": {
+ "description": "Encryption mode required for snapshot recovery: client-side or server-side.",
+ "enum": [
+ "client-side",
+ "server-side"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "endpoint": {
+ "description": "Destination API endpoint. HTTPS is required unless tls is explicitly insecure. Expects an http or https URL.",
+ "examples": [
+ "https://objects.example.com"
+ ],
+ "pattern": "^https?://",
+ "type": "string"
+ },
+ "failure_domain": {
+ "additionalProperties": false,
+ "description": "Operator-declared identity used to prove the destination does not share the protected host.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "host": {
+ "description": "Destination host identity used to refuse a target on the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.",
+ "examples": [
+ "backup-01.example.net"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$",
+ "type": "string"
+ },
+ "identity": {
+ "description": "Stable operator-owned failure-domain identity, distinct from the protected host. Expects a stable identifier of letters, digits, dots, colons, slashes, underscores and hyphens.",
+ "examples": [
+ "provider-a/us-east-1/account-42"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "kind": {
+ "description": "Destination kind. Only s3-compatible is supported.",
+ "enum": [
+ "s3-compatible"
+ ],
+ "examples": [
+ "s3-compatible"
+ ],
+ "type": "string"
+ },
+ "prefix": {
+ "description": "Non-secret object prefix reserved for Onebox backup data. Expects a relative object prefix with no empty leading component or shell metacharacter.",
+ "examples": [
+ "production/shop"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$",
+ "type": "string"
+ },
+ "region": {
+ "description": "S3-compatible region when the endpoint requires one. Expects a lower-case S3-compatible region of letters, digits and hyphens.",
+ "examples": [
+ "us-east-1"
+ ],
+ "pattern": "^[a-z0-9][a-z0-9-]{0,62}$",
+ "type": "string"
+ },
+ "tls": {
+ "default": "verify",
+ "description": "Transport policy: verify, or skip-verify to accept a plaintext http endpoint.",
+ "enum": [
+ "verify",
+ "skip-verify"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "User-owned off-host repositories available to service backup policies.",
+ "type": "object"
+ },
+ "base_path": {
+ "default": "/var/lib/ob",
+ "description": "Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/srv/ob"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "build": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "args": {
+ "additionalProperties": {},
+ "description": "Build arguments supplied by the external build system.",
+ "type": "object"
+ },
+ "context": {
+ "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "."
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "dockerfile": {
+ "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "Dockerfile"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "target": {
+ "description": "Named Dockerfile stage to build.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path."
+ },
+ "checks": {
+ "additionalProperties": false,
+ "description": "Assertions that must pass before a release becomes current unless marked advisory.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "exec": {
+ "description": "Commands run inside a named workload.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "run": {
+ "description": "Shell command verified inside the workload.",
+ "examples": [
+ "test -f /srv/ready"
+ ],
+ "type": "string"
+ },
+ "workload": {
+ "description": "Workload the command runs inside.",
+ "examples": [
+ "web"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "http": {
+ "description": "HTTP paths probed inside a named workload.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "path": {
+ "description": "HTTP path verified inside the workload. Expects a path beginning with /.",
+ "examples": [
+ "/healthz"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port to probe.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "workload": {
+ "description": "Workload the path is probed inside.",
+ "examples": [
+ "web"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "migrations": {
+ "description": "Migration revisions checked against captured job evidence.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "applied_revisions": {
+ "description": "Revisions the job must report as applied.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "job": {
+ "description": "Job workload whose captured evidence is checked.",
+ "examples": [
+ "migrate"
+ ],
+ "type": "string"
+ },
+ "provider": {
+ "description": "Migration tool that produced the revisions.",
+ "examples": [
+ "alembic"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "url": {
+ "description": "External URLs probed from the operator side.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "advisory": {
+ "default": false,
+ "description": "Report a failure without blocking release activation.",
+ "type": "boolean"
+ },
+ "contains": {
+ "description": "Text the response body must contain.",
+ "type": "string"
+ },
+ "json_assertions": {
+ "description": "Scalar JSON response values that must match exactly.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "equals": {
+ "description": "Exact scalar value required at path."
+ },
+ "path": {
+ "description": "Dot-separated path to a scalar value in the JSON response.",
+ "examples": [
+ "service.ready"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "required_headers": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Exact response headers required for success.",
+ "type": "object"
+ },
+ "status_codes": {
+ "description": "Allowed response status codes. A successful 2xx response is expected when omitted.",
+ "items": {
+ "maximum": 599,
+ "minimum": 100,
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "url": {
+ "description": "External HTTP or HTTPS URL verified from the operator side. Expects an http or https URL.",
+ "examples": [
+ "https://shop.example.com/healthz"
+ ],
+ "pattern": "^https?://",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "compose": {
+ "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.",
+ "examples": [
+ "docker-compose.yml#web"
+ ],
+ "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$",
+ "type": "string"
+ },
+ "deployment": {
+ "additionalProperties": false,
+ "description": "Release ordering, retention, and migration behavior.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "migration_policy": {
+ "default": "manual",
+ "description": "Policy for migration jobs during release and recovery.",
+ "enum": [
+ "manual",
+ "auto",
+ "expand-only"
+ ],
+ "type": "string"
+ },
+ "order": {
+ "description": "Explicit workload release order. Dependency order is derived when omitted.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "retain_releases": {
+ "default": 5,
+ "description": "Number of completed release directories retained for inspection and rollback.",
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "environments": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "base_path": {
+ "description": "Environment-specific replacement for the project base_path. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/srv/ob"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "env_files": {
+ "description": "Default ordered environment-file list for application, worker, and job workloads in this environment.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Decryptor used before staging the file. The supported encrypted provider is sops.",
+ "enum": [
+ "sops"
+ ],
+ "examples": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts a path to an environment file."
+ },
+ "type": "array"
+ },
+ "jump": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent.",
+ "examples": [
+ "deploy@bastion.example.com"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "host": {
+ "description": "Jump host name or IP address.",
+ "examples": [
+ "bastion.example.com"
+ ],
+ "type": "string"
+ },
+ "port": {
+ "description": "SSH port on the jump host. The SSH default is used when omitted.",
+ "examples": [
+ 2222
+ ],
+ "type": "integer"
+ },
+ "user": {
+ "description": "SSH user on the jump host. $USER is used when omitted; ob does not read ~/.ssh/config.",
+ "examples": [
+ "deploy"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Optional SSH jump host tunnelling the connection to this server, written as user@host or as an object with host, user, and port. Onebox verifies and authenticates both hops and never forwards the SSH agent. Also accepts user@host or user@host:port."
+ },
+ "overrides": {
+ "additionalProperties": false,
+ "description": "Environment-specific operational tuning. Overrides cannot change workload identity or data semantics.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "services": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "type": "object"
+ },
+ "description": "Allowed service tuning keyed by service name: resources and settings.",
+ "type": "object"
+ },
+ "workloads": {
+ "additionalProperties": {
+ "additionalProperties": {},
+ "type": "object"
+ },
+ "description": "Allowed workload tuning keyed by workload name: replicas, resources, env, env_files, strategy, and routes.",
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "policy": {
+ "additionalProperties": false,
+ "description": "Approval, runner compatibility, and migration-backup requirements for this environment.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "allow_agent_proposals": {
+ "default": true,
+ "description": "Declared permission for agent-authored proposals. The current CLI does not distinguish agent identity; execution remains approval-gated.",
+ "type": "boolean"
+ },
+ "migrations": {
+ "additionalProperties": false,
+ "description": "What this environment requires of a release carrying migration risk.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "backup_key_material": {
+ "description": "Key-material identities the backup report must name.",
+ "examples": [
+ [
+ "BACKUP_ACCESS_KEY_ID"
+ ]
+ ],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "backup_max_age": {
+ "default": "24h",
+ "description": "Maximum age of a backup report accepted for a migration. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "24h"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "require_backup": {
+ "default": false,
+ "description": "Require a plan-bound backup report before a release with migration risk.",
+ "type": "boolean"
+ },
+ "require_restore_test": {
+ "default": false,
+ "description": "Require the backup report to state that a restore test succeeded.",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "min_onebox_version": {
+ "description": "Oldest released Onebox runner allowed to operate this environment. Expects a CalVer release such as v2026.8.0.",
+ "examples": [
+ "v2026.8.0"
+ ],
+ "pattern": "^v([1-9][0-9]{3})\\.([1-9]|1[0-2])\\.(0|[1-9][0-9]{0,18})$",
+ "type": "string"
+ },
+ "min_plan_schema": {
+ "description": "Oldest executable plan schema accepted by this environment. Expects a plan schema identity such as onebox.run/executable-deploy-plan/v1alpha2.",
+ "examples": [
+ "onebox.run/executable-deploy-plan/v1alpha2"
+ ],
+ "pattern": "^onebox\\.run/executable-deploy-plan/v[1-9][0-9]*((alpha|beta)[1-9][0-9]*)?$",
+ "type": "string"
+ },
+ "require_approval": {
+ "default": true,
+ "description": "Require a plan-bound local confirmation before mutating this environment.",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
+ "server": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "SSH server, written as user@host or as an object with host, user, and port.",
+ "examples": [
+ "root@203.0.113.10"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "host": {
+ "description": "SSH hostname or IP address.",
+ "examples": [
+ "203.0.113.10"
+ ],
+ "type": "string"
+ },
+ "port": {
+ "description": "SSH port. The SSH default is used when omitted.",
+ "examples": [
+ 2222
+ ],
+ "type": "integer"
+ },
+ "user": {
+ "description": "SSH user. $USER is used when omitted; ob does not read ~/.ssh/config.",
+ "examples": [
+ "root"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "SSH server, written as user@host or as an object with host, user, and port. Also accepts user@host."
+ }
+ },
+ "type": "object"
+ },
+ "description": "Named environments, each naming the server it deploys to and the policy applied to it.",
+ "minProperties": 1,
+ "type": "object"
+ },
+ "external_services": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "backup_owner": {
+ "description": "Operator or provider responsible for backup, restore, upgrades, credentials, and durability. Expects a stable operator or provider identity of letters, digits, dots, @, colons, slashes, underscores and hyphens.",
+ "examples": [
+ "platform-team/rds"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@:/-]{0,127}$",
+ "type": "string"
+ },
+ "connection": {
+ "additionalProperties": false,
+ "description": "Trusted connection source and driver-shaped entry mapping.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "entries": {
+ "additionalProperties": {
+ "description": "Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "description": "Maps driver connection parts such as host, port, user, password, database, or url to variable names in the trusted source.",
+ "type": "object"
+ },
+ "source": {
+ "additionalProperties": false,
+ "description": "Trusted encrypted file containing the connection values.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative encrypted environment file staged through the trusted secret flow. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "secrets/production-db.env"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "default": "sops",
+ "description": "Trusted secret provider. Only sops is currently executable.",
+ "enum": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "driver": {
+ "description": "Built-in connection shape used to validate and project this dependency.",
+ "enum": [
+ "clickhouse",
+ "mariadb",
+ "meilisearch",
+ "minio",
+ "mongodb",
+ "mysql",
+ "nats",
+ "postgres",
+ "rabbitmq",
+ "redis",
+ "valkey"
+ ],
+ "examples": [
+ "postgres"
+ ],
+ "type": "string"
+ },
+ "probe": {
+ "additionalProperties": false,
+ "description": "Optional bounded read-only health observation; it never creates or repairs provider resources.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "kind": {
+ "default": "driver-health",
+ "description": "Read-only observation kind: driver-health.",
+ "enum": [
+ "driver-health"
+ ],
+ "type": "string"
+ },
+ "max_age": {
+ "default": "5m",
+ "description": "Maximum age of a probe observation bound into a plan. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "timeout": {
+ "default": "5s",
+ "description": "Maximum duration of one read-only probe. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Typed dependencies operated outside Onebox. Their connection projection is trusted, but their lifecycle and backup remain external.",
+ "type": "object"
+ },
+ "health": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Readiness check used to gate rolling replacement.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "exec": {
+ "description": "Health command as a shell string or direct argument list."
+ },
+ "http": {
+ "description": "HTTP path probed inside the container. Expects a path beginning with /.",
+ "examples": [
+ "/healthz"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "interval": {
+ "default": "5s",
+ "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "2s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port probed by HTTP or TCP health checks.",
+ "examples": [
+ 8080
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "retries": {
+ "default": 3,
+ "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.",
+ "examples": [
+ 3
+ ],
+ "type": "integer"
+ },
+ "start_period": {
+ "default": "30s",
+ "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "tcp": {
+ "default": false,
+ "description": "Probe the configured port by opening a TCP connection.",
+ "type": "boolean"
+ },
+ "within": {
+ "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "120s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path."
+ },
+ "hooks": {
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "local": {
+ "default": false,
+ "description": "Run on the operator machine instead of the server.",
+ "type": "boolean"
+ },
+ "run": {
+ "description": "Command executed at the lifecycle seam.",
+ "examples": [
+ "./scripts/notify.sh"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts the command to run."
+ },
+ "description": "Lifecycle commands keyed by seam: bootstrap, pre_release, post_release, or post_deploy.",
+ "type": "object"
+ },
+ "image": {
+ "anyOf": [
+ {
+ "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Container image source, written as a reference string or an object.",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "pull": {
+ "default": "missing",
+ "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.",
+ "enum": [
+ "always",
+ "missing",
+ "never"
+ ],
+ "type": "string"
+ },
+ "reference": {
+ "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Container image source, written as a reference string or an object. Also accepts an image reference."
+ },
+ "notifications": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "format": {
+ "default": "text",
+ "description": "Notification payload format.",
+ "enum": [
+ "text",
+ "json"
+ ],
+ "type": "string"
+ },
+ "on": {
+ "default": [
+ "success",
+ "failure"
+ ],
+ "description": "Operation outcomes that trigger this notification.",
+ "items": {
+ "enum": [
+ "success",
+ "failure"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "webhook": {
+ "description": "HTTP endpoint that receives outcome notifications.",
+ "examples": [
+ "https://hooks.example.com/onebox"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Named webhooks that receive selected operation and scheduled-job outcomes.",
+ "type": "object"
+ },
+ "port": {
+ "description": "Default container port used by HTTP health checks.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "proxy": {
+ "additionalProperties": false,
+ "description": "Ownership and configuration of the host ingress proxy.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "config": {
+ "description": "Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. A managed DNS challenge may use a directory containing only .env for provider credentials. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "dns_challenge": {
+ "additionalProperties": false,
+ "description": "Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "provider": {
+ "description": "Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env. Expects a lower-case Traefik DNS provider name such as cloudflare or route53.",
+ "examples": [
+ "cloudflare"
+ ],
+ "pattern": "^[a-z][a-z0-9_-]*$",
+ "type": "string"
+ },
+ "resolvers": {
+ "description": "DNS resolvers used to verify challenge propagation, written as host:port.",
+ "examples": [
+ [
+ "1.1.1.1:53"
+ ]
+ ],
+ "items": {
+ "description": "Expects a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port.",
+ "pattern": "^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\\[[0-9A-Fa-f:.]+\\]):[0-9]{1,5}$",
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "provider"
+ ],
+ "type": "object"
+ },
+ "entrypoints": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "port": {
+ "description": "Host and proxy-container TCP port used by this listener.",
+ "examples": [
+ 4317
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints.",
+ "propertyNames": {
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$"
+ },
+ "type": "object"
+ },
+ "image": {
+ "description": "Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ },
+ "kind": {
+ "default": "traefik-docker",
+ "description": "Proxy implementation, or none to disable routing.",
+ "enum": [
+ "traefik-docker",
+ "none"
+ ],
+ "type": "string"
+ },
+ "managed": {
+ "description": "Let Onebox converge the host-scoped proxy when routes are declared.",
+ "type": "boolean"
+ },
+ "network": {
+ "default": "ob-ingress",
+ "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "registries": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "password_env": {
+ "description": "Local environment-variable name containing the registry password or token. Expects a variable name of letters, digits and underscores, not starting with a digit.",
+ "examples": [
+ "GHCR_TOKEN"
+ ],
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
+ "type": "string"
+ },
+ "server": {
+ "description": "Registry hostname, optionally with a port. Expects a host with an optional port and path, such as ghcr.io or registry.example.com:5000.",
+ "examples": [
+ "ghcr.io"
+ ],
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9.-]*(:[0-9]{1,5})?(/[A-Za-z0-9._/-]*)?$",
+ "type": "string"
+ },
+ "username": {
+ "description": "Registry login username. Expects a username of letters, digits and . _ @ + -.",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Named container registries and the environment variables holding their credentials.",
+ "type": "object"
+ },
+ "routes": {
+ "description": "Ingress routes exposed by this workload.",
+ "items": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "const": "*"
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ },
+ "tls": {
+ "enum": [
+ "none",
+ "passthrough"
+ ]
+ }
+ },
+ "required": [
+ "protocol",
+ "tls"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "pattern": "^\\*\\."
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "http"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "tls": {
+ "const": "passthrough"
+ }
+ },
+ "required": [
+ "tls"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ }
+ },
+ "required": [
+ "protocol"
+ ]
+ }
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "entrypoint": {
+ "default": "websecure",
+ "description": "Named proxy listener used for the route.",
+ "type": "string"
+ },
+ "hostname": {
+ "anyOf": [
+ {
+ "maxLength": 253,
+ "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
+ },
+ {
+ "const": "*"
+ }
+ ],
+ "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.",
+ "examples": [
+ "shop.example.com"
+ ],
+ "type": "string"
+ },
+ "middlewares": {
+ "description": "Ordered provider-qualified middleware references applied to this route.",
+ "items": {
+ "description": "Expects a provider-qualified name such as secure-headers@file.",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$",
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "path": {
+ "default": "/",
+ "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.",
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port receiving routed traffic.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "protocol": {
+ "default": "http",
+ "description": "Routing protocol: http or tcp.",
+ "enum": [
+ "http",
+ "tcp"
+ ],
+ "type": "string"
+ },
+ "scheme": {
+ "default": "http",
+ "description": "Backend connection scheme for HTTP routes: http, https, or h2c.",
+ "enum": [
+ "http",
+ "https",
+ "h2c"
+ ],
+ "type": "string"
+ },
+ "tls": {
+ "default": "terminate",
+ "description": "TLS handling: terminate, passthrough, or none.",
+ "enum": [
+ "terminate",
+ "passthrough",
+ "none"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "hostname"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "runtime": {
+ "additionalProperties": false,
+ "description": "Project-wide environment files and local environment-file requirements.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "env_checks": {
+ "description": "Local environment-file assertions checked before planning or deploying.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative dotenv file whose declared keys are checked. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "present": {
+ "description": "Environment keys that must be declared but may be empty.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "require": {
+ "description": "Environment keys that must be declared with non-empty values.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "env_files": {
+ "description": "Project-wide ordered environment-file list for application, worker, and job workloads.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Decryptor used before staging the file. The supported encrypted provider is sops.",
+ "enum": [
+ "sops"
+ ],
+ "examples": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts a path to an environment file."
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "services": {
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "type": [
+ "string",
+ "number",
+ "integer"
+ ]
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "backup": {
+ "additionalProperties": false,
+ "description": "Recovery intent for this service. Onebox selects the qualified native implementation; declaring intent alone does not establish backup.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "allow_downtime": {
+ "default": false,
+ "description": "Whether recurring backup operations may use the driver-declared stopped-service window.",
+ "type": "boolean"
+ },
+ "drill": {
+ "additionalProperties": false,
+ "description": "Exact isolated restore-test schedule, proof age, and optional staging filesystem.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "max_age": {
+ "default": "7d",
+ "description": "Maximum age of the latest passing restore proof. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "7d"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "schedule": {
+ "additionalProperties": false,
+ "description": "Exact recurring isolated restore-test schedule.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cron": {
+ "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.",
+ "examples": [
+ "0 2 * * *"
+ ],
+ "pattern": "^[-0-9*/,A-Za-z ]+$",
+ "type": "string"
+ },
+ "timezone": {
+ "default": "UTC",
+ "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.",
+ "examples": [
+ "Europe/Berlin"
+ ],
+ "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "max_data_loss": {
+ "description": "Maximum tolerable interval between the latest recoverable point and failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "15m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "recovery_kind": {
+ "description": "Required recovery envelope: snapshot, pitr, or cold.",
+ "enum": [
+ "snapshot",
+ "pitr",
+ "cold"
+ ],
+ "examples": [
+ "pitr"
+ ],
+ "type": "string"
+ },
+ "retention": {
+ "additionalProperties": false,
+ "description": "Portable minimum recovery history that the selected native driver must be able to preserve.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "keep": {
+ "default": 7,
+ "description": "Minimum number of independently recoverable base generations to retain.",
+ "examples": [
+ 7
+ ],
+ "minimum": 1,
+ "type": "integer"
+ },
+ "window": {
+ "default": "7d",
+ "description": "Minimum continuous recovery history the native retention mapping must preserve. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "7d"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "schedule": {
+ "additionalProperties": false,
+ "description": "Exact recurring base-backup schedule.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cron": {
+ "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.",
+ "examples": [
+ "0 2 * * *"
+ ],
+ "pattern": "^[-0-9*/,A-Za-z ]+$",
+ "type": "string"
+ },
+ "timezone": {
+ "default": "UTC",
+ "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.",
+ "examples": [
+ "Europe/Berlin"
+ ],
+ "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "target": {
+ "description": "Name of a project-level backup target. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "examples": [
+ "offsite"
+ ],
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "driver": {
+ "description": "Built-in service driver. Defaults to the service map key. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "examples": [
+ "postgres"
+ ],
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "features": {
+ "additionalProperties": false,
+ "description": "Capabilities Onebox must establish before application workloads run.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "extensions": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {},
+ "type": "object"
+ },
+ "description": "PostgreSQL extensions Onebox installs in the managed application database before application migrations run.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_-]*$"
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "persistence": {
+ "additionalProperties": false,
+ "description": "Data-lifetime declaration for this supporting service.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "mode": {
+ "default": "durable",
+ "description": "Data lifetime: durable, ephemeral, or external.",
+ "enum": [
+ "durable",
+ "ephemeral",
+ "external"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "resources": {
+ "additionalProperties": false,
+ "description": "Memory and CPU limits for this supporting service.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cpus": {
+ "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.",
+ "examples": [
+ "0.5"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?$",
+ "type": "string"
+ },
+ "memory": {
+ "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.",
+ "examples": [
+ "512MB"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "settings": {
+ "additionalProperties": {},
+ "description": "Driver-specific settings validated by the selected service driver.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_-]*$"
+ },
+ "type": "object"
+ },
+ "version": {
+ "description": "Driver version or image tag to run.",
+ "examples": [
+ "17"
+ ]
+ },
+ "volumes": {
+ "description": "Additional driver-defined persistent volume names.",
+ "items": {
+ "description": "Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts the version to run."
+ },
+ "description": "Supporting services managed outside application releases, such as databases and caches.",
+ "type": "object"
+ },
+ "workloads": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "required": [
+ "execution"
+ ]
+ },
+ "then": {
+ "not": {
+ "required": [
+ "compose"
+ ]
+ },
+ "properties": {
+ "data_effect": {
+ "const": "none"
+ },
+ "deployment_phase": {
+ "const": "none"
+ },
+ "operator_run": {
+ "const": "allowed"
+ }
+ },
+ "required": [
+ "schedule",
+ "data_effect"
+ ]
+ }
+ },
+ {
+ "oneOf": [
+ {
+ "required": [
+ "build"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ },
+ {
+ "required": [
+ "compose"
+ ]
+ }
+ ]
+ },
+ {
+ "not": {
+ "allOf": [
+ {
+ "required": [
+ "published_ports"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "properties": {
+ "strategy": {
+ "const": "rolling"
+ }
+ },
+ "required": [
+ "strategy"
+ ]
+ },
+ {
+ "allOf": [
+ {
+ "not": {
+ "required": [
+ "strategy"
+ ]
+ }
+ },
+ {
+ "required": [
+ "health"
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "properties": {
+ "role": {
+ "const": "application"
+ }
+ },
+ "required": [
+ "role"
+ ]
+ },
+ {
+ "not": {
+ "required": [
+ "role"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "persistence": {
+ "anyOf": [
+ {
+ "properties": {
+ "mode": {
+ "const": "durable"
+ }
+ },
+ "required": [
+ "mode"
+ ]
+ },
+ {
+ "not": {
+ "required": [
+ "mode"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ "required": [
+ "persistence"
+ ]
+ },
+ "then": {
+ "properties": {
+ "replicas": {
+ "maximum": 1
+ }
+ }
+ }
+ },
+ {
+ "else": {
+ "not": {
+ "anyOf": [
+ {
+ "required": [
+ "deployment_phase"
+ ]
+ },
+ {
+ "required": [
+ "operator_run"
+ ]
+ },
+ {
+ "required": [
+ "data_effect"
+ ]
+ },
+ {
+ "required": [
+ "schedule"
+ ]
+ },
+ {
+ "required": [
+ "inputs"
+ ]
+ },
+ {
+ "required": [
+ "execution"
+ ]
+ }
+ ]
+ }
+ },
+ "if": {
+ "properties": {
+ "role": {
+ "const": "job"
+ }
+ },
+ "required": [
+ "role"
+ ]
+ },
+ "then": {
+ "required": [
+ "data_effect"
+ ]
+ }
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "build": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "args": {
+ "additionalProperties": {},
+ "description": "Build arguments supplied by the external build system.",
+ "type": "object"
+ },
+ "context": {
+ "description": "Repository-relative build context. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "."
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "dockerfile": {
+ "description": "Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ "Dockerfile"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "target": {
+ "description": "Named Dockerfile stage to build.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Build metadata for development. Production requires a resolved image supplied with --image. Also accepts a build context path."
+ },
+ "command": {
+ "anyOf": [
+ {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ {
+ "description": "Container command as a shell string or argument list.",
+ "examples": [
+ "./bin/server"
+ ]
+ }
+ ],
+ "description": "Container command as a shell string or argument list. Also accepts a command line or argument list."
+ },
+ "compose": {
+ "description": "Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service.",
+ "examples": [
+ "docker-compose.yml#web"
+ ],
+ "pattern": "^[^/#][^#]*#[a-zA-Z0-9._-]+$",
+ "type": "string"
+ },
+ "data_effect": {
+ "description": "Job data impact used by rollback and abort gates.",
+ "enum": [
+ "none",
+ "migration",
+ "destructive",
+ "unknown"
+ ],
+ "examples": [
+ "migration"
+ ],
+ "type": "string"
+ },
+ "deployment_phase": {
+ "default": "none",
+ "description": "Deployment phase for this job: none, pre_release, or post_release.",
+ "enum": [
+ "none",
+ "pre_release",
+ "post_release"
+ ],
+ "type": "string"
+ },
+ "drain": {
+ "additionalProperties": false,
+ "description": "Signal and timing used to remove a container from traffic before stopping it.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "grace": {
+ "description": "Maximum graceful-shutdown time before forced termination, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "signal": {
+ "default": "TERM",
+ "description": "Signal sent to begin graceful shutdown. Expects a signal name such as TERM or QUIT.",
+ "pattern": "^[A-Z][A-Z0-9]*$",
+ "type": "string"
+ },
+ "wait": {
+ "description": "Maximum drain window before shutdown continues, at most 7d. Recreate workloads continue sooner when every old container exits. Rolling workloads wait the full interval before stopping each container when their health check supports drain guarding. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "10s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "entrypoint": {
+ "anyOf": [
+ {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ {
+ "description": "Container entrypoint as a string or argument list."
+ }
+ ],
+ "description": "Container entrypoint as a string or argument list. Also accepts an entrypoint or argument list."
+ },
+ "env": {
+ "additionalProperties": {},
+ "description": "Literal container environment values. Managed-service credential variables cannot be overridden.",
+ "type": "object"
+ },
+ "env_files": {
+ "description": "Workload-specific ordered environment-file list. Replaces broader defaults when present.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "file": {
+ "description": "Repository-relative environment file path. Expects a path inside the repository, with no control character or shell metacharacter.",
+ "examples": [
+ ".env.production"
+ ],
+ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Decryptor used before staging the file. The supported encrypted provider is sops.",
+ "enum": [
+ "sops"
+ ],
+ "examples": [
+ "sops"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts a path to an environment file."
+ },
+ "type": "array"
+ },
+ "execution": {
+ "additionalProperties": false,
+ "description": "Opt-in durable scheduled execution. Requires a native operator-runnable phase-none job with data_effect none. Stores non-secret checkpoints on the host and permits explicit same-release resume.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "retention": {
+ "default": "168h",
+ "description": "Time from creation during which an unsuccessful execution may be resumed, at most 30d. Active executions remain protected. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "steps": {
+ "description": "Optional ordered steps using this job's image and entrypoint. Omit to execute the job command as one step. At most 32 steps.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "command": {
+ "description": "Argument vector passed to the job image's entrypoint. No shell evaluation is performed.",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 128,
+ "minItems": 1,
+ "type": "array"
+ },
+ "id": {
+ "description": "Unique stable step identifier, used by output references. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "inputs": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Environment variables populated from a preceding step's declared output, written as step.OUTPUT.",
+ "propertyNames": {
+ "pattern": "^[A-Z][A-Z0-9_]*$"
+ },
+ "type": "object"
+ },
+ "outputs": {
+ "description": "Required string keys in the JSON object written to ONEBOX_OUTPUT_FILE. Values are non-secret, at most 4096 bytes each and 16384 bytes total.",
+ "items": {
+ "description": "Expects upper-case letters, digits and underscores, starting with a letter.",
+ "pattern": "^[A-Z][A-Z0-9_]*$",
+ "type": "string"
+ },
+ "maxItems": 32,
+ "type": "array",
+ "uniqueItems": true
+ },
+ "retry": {
+ "additionalProperties": false,
+ "description": "Per-step retry policy; defaults to schedule.retry. All steps and backoff share the activation timeout.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "attempts": {
+ "default": 1,
+ "description": "Total attempts including the first, 1 to 10.",
+ "examples": [
+ 3
+ ],
+ "maximum": 10,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "backoff": {
+ "default": "30s",
+ "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "1m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "max_backoff": {
+ "default": "10m",
+ "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "required": [
+ "id",
+ "command"
+ ],
+ "type": "object"
+ },
+ "maxItems": 32,
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "extra_hosts": {
+ "description": "Additional host-to-address entries added to the container.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "health": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Readiness check used to gate rolling replacement.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "exec": {
+ "description": "Health command as a shell string or direct argument list."
+ },
+ "http": {
+ "description": "HTTP path probed inside the container. Expects a path beginning with /.",
+ "examples": [
+ "/healthz"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "interval": {
+ "default": "5s",
+ "description": "Delay between container health probes, at most 7d. Always written into the generated healthcheck, so the rollout's drain budget is computed from the value the container actually runs with. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "2s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port probed by HTTP or TCP health checks.",
+ "examples": [
+ 8080
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "retries": {
+ "default": 3,
+ "description": "Consecutive failed probes before the container is unhealthy. A draining container leaves rotation after this many probes, so it sets how long a rolling deploy waits for each replica.",
+ "examples": [
+ 3
+ ],
+ "type": "integer"
+ },
+ "start_period": {
+ "default": "30s",
+ "description": "Startup grace period before failed probes count, at most 7d. Always written into the generated healthcheck, so writing down a fast probe interval does not call a booting container unhealthy. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "5s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "tcp": {
+ "default": false,
+ "description": "Probe the configured port by opening a TCP connection.",
+ "type": "boolean"
+ },
+ "within": {
+ "description": "Maximum time a rollout waits for readiness, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "120s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Readiness check used to gate rolling replacement. Also accepts an HTTP health path."
+ },
+ "hostname": {
+ "description": "Hostname assigned inside the workload container.",
+ "type": "string"
+ },
+ "image": {
+ "anyOf": [
+ {
+ "description": "Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Container image source, written as a reference string or an object.",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "pull": {
+ "default": "missing",
+ "description": "When to fetch the image from the registry: missing fetches only what the host does not already hold, always fetches every release, never fetches at all and fails on a missing image.",
+ "enum": [
+ "always",
+ "missing",
+ "never"
+ ],
+ "type": "string"
+ },
+ "reference": {
+ "description": "Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:….",
+ "examples": [
+ "ghcr.io/acme/shop:1.4.0"
+ ],
+ "pattern": "^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Container image source, written as a reference string or an object. Also accepts an image reference."
+ },
+ "init": {
+ "description": "Run a minimal init process as PID 1 inside the container.",
+ "type": "boolean"
+ },
+ "inputs": {
+ "additionalProperties": {
+ "additionalProperties": false,
+ "oneOf": [
+ {
+ "required": [
+ "enum"
+ ]
+ },
+ {
+ "required": [
+ "pattern"
+ ]
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "default": {
+ "description": "Value used by a timer firing and by an operator run that does not override it. Must satisfy the input's own constraint.",
+ "type": "string"
+ },
+ "description": {
+ "description": "What the input controls.",
+ "type": "string"
+ },
+ "enum": {
+ "description": "Accepted values.",
+ "examples": [
+ [
+ "catalog"
+ ]
+ ],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "pattern": {
+ "description": "Regular expression the whole value must match.",
+ "examples": [
+ "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "default"
+ ],
+ "type": "object"
+ },
+ "description": "Declared parameters of a scheduled job, exposed as environment variables. Names are upper-case identifiers; each declares exactly one of enum or pattern and a default. A timer firing uses the defaults; ob job run may override them.",
+ "propertyNames": {
+ "pattern": "^[A-Z][A-Z0-9_]*$"
+ },
+ "type": "object"
+ },
+ "labels": {
+ "additionalProperties": {},
+ "description": "Additional container labels outside namespaces reserved by Onebox and the proxy.",
+ "type": "object"
+ },
+ "logging": {
+ "additionalProperties": false,
+ "description": "Container logging driver and driver-specific options.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "driver": {
+ "description": "Container runtime logging driver. Expects a log driver name such as local, json-file or an org/plugin:tag.",
+ "examples": [
+ "local"
+ ],
+ "pattern": "^[a-z0-9][a-z0-9_.-]*(/[a-z0-9][a-z0-9_.-]*)?(:[A-Za-z0-9_.-]+)?$",
+ "type": "string"
+ },
+ "options": {
+ "additionalProperties": {},
+ "description": "Driver-specific logging options passed to the container runtime.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_.-]*$"
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "needs": {
+ "description": "Workload or supporting-service prerequisites and optional connection-variable mappings.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "condition": {
+ "description": "Prerequisite condition: started, healthy, or completed.",
+ "enum": [
+ "started",
+ "healthy",
+ "completed"
+ ],
+ "type": "string"
+ },
+ "env": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Maps application environment-variable names to service connection parts such as host, port, user, password, database, or url.",
+ "type": "object"
+ },
+ "name": {
+ "description": "Name of a workload or supporting service that must start first. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Also accepts the name of a prerequisite."
+ },
+ "type": "array"
+ },
+ "operator_run": {
+ "description": "Whether an operator may invoke this job outside deployment: allowed or disabled. Defaults to allowed for phase none and disabled otherwise.",
+ "enum": [
+ "allowed",
+ "disabled"
+ ],
+ "type": "string"
+ },
+ "persistence": {
+ "additionalProperties": false,
+ "description": "Declares whether this workload holds data that must outlive releases.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "mode": {
+ "default": "durable",
+ "description": "Data lifetime: durable, ephemeral, or external.",
+ "enum": [
+ "durable",
+ "ephemeral",
+ "external"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "port": {
+ "description": "Default container port used by HTTP health checks.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "published_ports": {
+ "description": "Host ports published outside the proxy. They bind to loopback by default. A rolling workload cannot publish one, because two replicas cannot hold the same host port during a roll: set strategy: recreate, or route through the proxy instead.",
+ "items": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "bind": {
+ "default": "127.0.0.1",
+ "description": "Host address on which the published port listens.",
+ "type": "string"
+ },
+ "container": {
+ "description": "Port receiving traffic inside the container.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "host": {
+ "description": "Port exposed on the host.",
+ "examples": [
+ 8080
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "protocol": {
+ "default": "tcp",
+ "description": "Published transport protocol: tcp or udp.",
+ "enum": [
+ "tcp",
+ "udp"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "replicas": {
+ "default": 1,
+ "description": "Desired number of long-running workload containers.",
+ "examples": [
+ 2
+ ],
+ "minimum": 1,
+ "type": "integer"
+ },
+ "resources": {
+ "additionalProperties": false,
+ "description": "Container memory and CPU limits.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "cpus": {
+ "description": "Container CPU limit expressed as a positive decimal count. Expects a number of CPUs such as 0.5 or 2.",
+ "examples": [
+ "0.5"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?$",
+ "type": "string"
+ },
+ "memory": {
+ "description": "Container memory limit. Expects a size such as 512MB or 1.5GB.",
+ "examples": [
+ "512MB"
+ ],
+ "pattern": "^[0-9]+(\\.[0-9]+)?(B|KB|MB|GB|TB)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "role": {
+ "description": "Lifecycle role: application, worker, daemon, or job.",
+ "enum": [
+ "application",
+ "worker",
+ "daemon",
+ "job"
+ ],
+ "examples": [
+ "application"
+ ],
+ "type": "string"
+ },
+ "routes": {
+ "description": "Ingress routes exposed by this workload.",
+ "items": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "const": "*"
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ },
+ "tls": {
+ "enum": [
+ "none",
+ "passthrough"
+ ]
+ }
+ },
+ "required": [
+ "protocol",
+ "tls"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "hostname": {
+ "pattern": "^\\*\\."
+ }
+ },
+ "required": [
+ "hostname"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "http"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "tls": {
+ "const": "passthrough"
+ }
+ },
+ "required": [
+ "tls"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocol": {
+ "const": "tcp"
+ }
+ },
+ "required": [
+ "protocol"
+ ]
+ }
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "entrypoint": {
+ "default": "websecure",
+ "description": "Named proxy listener used for the route.",
+ "type": "string"
+ },
+ "hostname": {
+ "anyOf": [
+ {
+ "maxLength": 253,
+ "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
+ },
+ {
+ "const": "*"
+ }
+ ],
+ "description": "Hostname matched by the proxy. Accepts an exact hostname or a wildcard in the complete left-most label, such as *.example.com; a wildcard matches exactly one label and not the suffix itself. The bare * value is reserved for plaintext or TLS-passthrough TCP catch-all routes.",
+ "examples": [
+ "shop.example.com"
+ ],
+ "type": "string"
+ },
+ "middlewares": {
+ "description": "Ordered provider-qualified middleware references applied to this route.",
+ "items": {
+ "description": "Expects a provider-qualified name such as secure-headers@file.",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*@[a-z][a-z0-9-]*$",
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "path": {
+ "default": "/",
+ "description": "URL path prefix matched by an HTTP route. Expects a path beginning with /.",
+ "pattern": "^/[^\\x00-\\x1f'\"$` \\\\]*$",
+ "type": "string"
+ },
+ "port": {
+ "description": "Container port receiving routed traffic.",
+ "examples": [
+ 3000
+ ],
+ "maximum": 65535,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "protocol": {
+ "default": "http",
+ "description": "Routing protocol: http or tcp.",
+ "enum": [
+ "http",
+ "tcp"
+ ],
+ "type": "string"
+ },
+ "scheme": {
+ "default": "http",
+ "description": "Backend connection scheme for HTTP routes: http, https, or h2c.",
+ "enum": [
+ "http",
+ "https",
+ "h2c"
+ ],
+ "type": "string"
+ },
+ "tls": {
+ "default": "terminate",
+ "description": "TLS handling: terminate, passthrough, or none.",
+ "enum": [
+ "terminate",
+ "passthrough",
+ "none"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "hostname"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "schedule": {
+ "additionalProperties": false,
+ "description": "Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "catch_up": {
+ "default": true,
+ "description": "Run once after the host returns if an elapsed schedule was missed while it was offline.",
+ "type": "boolean"
+ },
+ "cron": {
+ "description": "Five-field cron schedule translated to a host timer. Expects five cron fields.",
+ "examples": [
+ "0 2 * * *"
+ ],
+ "pattern": "^[-0-9*/,A-Za-z ]+$",
+ "type": "string"
+ },
+ "deploy_lock": {
+ "default": "exclusive",
+ "description": "Deployment coordination policy: exclusive blocks application operations for the full run; pinned leases the immutable starting release and permits only deployments without data-changing jobs or untyped hooks.",
+ "enum": [
+ "exclusive",
+ "pinned"
+ ],
+ "examples": [
+ "pinned"
+ ],
+ "type": "string"
+ },
+ "notify": {
+ "default": [
+ "failure",
+ "timeout"
+ ],
+ "description": "Run outcomes that send the configured notifications: success, failure, timeout, skipped.",
+ "items": {
+ "enum": [
+ "success",
+ "failure",
+ "timeout",
+ "skipped"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "retry": {
+ "additionalProperties": false,
+ "description": "Bounded retry inside one timer firing. Attempts run under the same locks and the same timeout; a timeout ends the run.",
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "attempts": {
+ "default": 1,
+ "description": "Total attempts including the first, 1 to 10.",
+ "examples": [
+ 3
+ ],
+ "maximum": 10,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "backoff": {
+ "default": "30s",
+ "description": "Sleep before the second attempt; it doubles after each failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "1m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "max_backoff": {
+ "default": "10m",
+ "description": "Upper bound for the doubling sleep. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "shutdown_grace": {
+ "default": "30s",
+ "description": "Time allowed for graceful container shutdown after the run deadline before Onebox forces removal. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "45s"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "timeout": {
+ "default": "1h",
+ "description": "Maximum wall time for one scheduled run before systemd terminates it and records failure. Expects a duration such as 30s, 5m, 1h30m or 14d.",
+ "examples": [
+ "30m"
+ ],
+ "pattern": "^(([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+|[0-9]+d)$",
+ "type": "string"
+ },
+ "timezone": {
+ "default": "UTC",
+ "description": "IANA timezone used to interpret the cron schedule. Expects an IANA zone name such as UTC or Europe/Berlin.",
+ "examples": [
+ "Europe/Berlin"
+ ],
+ "pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "stdin_open": {
+ "description": "Keep standard input open for the container.",
+ "type": "boolean"
+ },
+ "strategy": {
+ "description": "Replacement strategy for a changed or uncertain workload. An unchanged healthy workload is retained automatically. Defaults to rolling only for an application workload with health; all other workloads default to recreate.",
+ "enum": [
+ "rolling",
+ "recreate"
+ ],
+ "type": "string"
+ },
+ "tty": {
+ "description": "Allocate a pseudo-TTY for the container.",
+ "type": "boolean"
+ },
+ "user": {
+ "description": "User or UID used to run the container process.",
+ "type": "string"
+ },
+ "volumes": {
+ "description": "Managed named volumes or bind mounts. Relative bind sources are read-only release content; absolute sources are external host state.",
+ "items": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "source": {
+ "pattern": "^[^/]"
+ }
+ },
+ "required": [
+ "source"
+ ]
+ },
+ "then": {
+ "properties": {
+ "mode": {
+ "const": "ro"
+ }
+ },
+ "required": [
+ "mode"
+ ]
+ }
+ }
+ ],
+ "anyOf": [
+ {
+ "required": [
+ "name",
+ "path"
+ ]
+ },
+ {
+ "required": [
+ "source",
+ "path"
+ ]
+ }
+ ],
+ "patternProperties": {
+ "^x-": {}
+ },
+ "properties": {
+ "mode": {
+ "default": "rw",
+ "description": "Mount access mode: rw or ro. A relative bind source requires ro.",
+ "enum": [
+ "rw",
+ "ro"
+ ],
+ "type": "string"
+ },
+ "name": {
+ "description": "Stable logical name of a Onebox-managed volume. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters.",
+ "examples": [
+ "data"
+ ],
+ "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$",
+ "type": "string"
+ },
+ "path": {
+ "description": "Absolute container path where the volume or bind mount is attached. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/var/lib/app"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ },
+ "source": {
+ "description": "Bind mount source. An absolute path is external host state that outlives releases. A dot-prefixed repository path is read-only release content, kept for as long as a container still mounts it. Expects an absolute host path or a dot-prefixed path inside the repository, with no colon, control character or shell metacharacter.",
+ "examples": [
+ "./config"
+ ],
+ "not": {
+ "pattern": "(^|/)\\.\\.(/|$)"
+ },
+ "pattern": "^(/[^\\x00-\\x1f'\"$`\\\\:]*|\\.(?:/[^\\x00-\\x1f'\"$`\\\\:]*)?)$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "working_dir": {
+ "description": "Absolute working directory for the container process. Expects an absolute path with no control character or shell metacharacter.",
+ "examples": [
+ "/app"
+ ],
+ "pattern": "^/[^\\x00-\\x1f'\"$`\\\\]*$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "description": "Application containers, workers, daemons, and jobs managed as releases.",
+ "type": "object"
+ }
+ },
+ "required": [
+ "api_version",
+ "environments"
+ ],
+ "title": "Onebox project (onebox.run/v2)",
+ "type": "object"
+}
diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro
index 6a1dad3a..47eac2df 100644
--- a/site/src/components/landing/Derivation.astro
+++ b/site/src/components/landing/Derivation.astro
@@ -18,7 +18,7 @@
-
+
@@ -209,7 +209,7 @@
if (root && live && staticList && ymlOut && listOut && countOut) {
const on: Record = {
- domain: true,
+ hostname: true,
replicas: false,
postgres: false,
worker: false,
@@ -219,27 +219,27 @@
const render = () => {
const lines: string[] = [
- `api_version: onebox.run/v1`,
+ `api_version: onebox.run/v2`,
`app: shop`,
`environments:`,
` production:`,
` server: root@203.0.113.10`,
- `image: ghcr.io/acme/shop:1.4.0`,
+ `workloads:`,
+ ` web:`,
+ ` image: ghcr.io/acme/shop:1.4.0`,
];
- if (on.domain) {
- lines.push(`domain: shop.example.com`);
- lines.push(`port: 3000`);
+ if (on.replicas) {
+ lines.push(` replicas: 2`);
+ }
+ if (on.hostname) {
+ lines.push(` routes:`);
+ lines.push(` - {hostname: shop.example.com, port: 3000}`);
}
- if (on.replicas || on.worker) {
- lines.push(`workloads:`);
- if (on.replicas) {
- lines.push(` web:`);
- lines.push(` replicas: 2`);
- }
- if (on.worker) {
- lines.push(` worker:`);
- lines.push(` command: bundle exec sidekiq`);
- }
+ if (on.worker) {
+ lines.push(` worker:`);
+ lines.push(` role: worker`);
+ lines.push(` image: ghcr.io/acme/shop:1.4.0`);
+ lines.push(` command: bundle exec sidekiq`);
}
if (on.postgres) {
lines.push(`services: {postgres: 17}`);
@@ -258,7 +258,7 @@
derived.push(["shop-worker-1", "no route — nothing was declared for it"]);
}
derived.push(["shop_default", "the application's own Compose network"]);
- if (on.domain) {
+ if (on.hostname) {
derived.push(["onebox-proxy", "Traefik, its static configuration, a router and TLS"]);
}
if (on.postgres) {
diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx
index cb675137..69ed9305 100644
--- a/site/src/content/docs/explanation/generated-compose.mdx
+++ b/site/src/content/docs/explanation/generated-compose.mdx
@@ -48,7 +48,7 @@ becomes a question rather than a fact.
- **Refusals that mean something.** `strategy_ungated`, `route_collision`,
`stateful_replicas` are all statements about the generated runtime, which is
the only thing that will actually run.
-- **Typed routing.** Two workloads claiming the same entrypoint, protocol, domain
+- **Typed routing.** Two workloads claiming the same entrypoint, protocol, hostname
and path is `route_collision`, rather than a proxy that accepts both and routes
to one with nothing saying which.
diff --git a/site/src/content/docs/explanation/what-onebox-refuses.mdx b/site/src/content/docs/explanation/what-onebox-refuses.mdx
index fc68a921..1224927c 100644
--- a/site/src/content/docs/explanation/what-onebox-refuses.mdx
+++ b/site/src/content/docs/explanation/what-onebox-refuses.mdx
@@ -40,7 +40,7 @@ managed-service connection supplies. The credential is generated on the server
and exists nowhere else; ordering could not protect it, so validation does.
**`route_collision`** — two workloads claiming the same entrypoint, protocol,
-domain and path. The proxy would accept both and route to one, and nothing would
+hostname and path. The proxy would accept both and route to one, and nothing would
say which.
**`stateful_replicas`** — a workload keeping durable state asking for more than
diff --git a/site/src/content/docs/guides/environment-variables.mdx b/site/src/content/docs/guides/environment-variables.mdx
index b0ed70ab..30eac528 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 still `onebox.run/v2` — 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/reference/cli.mdx b/site/src/content/docs/reference/cli.mdx
index dbedff09..d0395348 100644
--- a/site/src/content/docs/reference/cli.mdx
+++ b/site/src/content/docs/reference/cli.mdx
@@ -953,7 +953,7 @@ Global Flags:
## ob preview
```
-Load an onebox.run/v1 project, resolve the environment's overrides, and print
+Load an onebox.run/v2 project, 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/v2` project file.
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://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.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 96e1e4ac..e390c89b 100644
--- a/site/src/content/docs/reference/errors.mdx
+++ b/site/src/content/docs/reference/errors.mdx
@@ -82,8 +82,6 @@ command.
| `render_failed` | the runtime could not be rendered |
| `route_collision` | two workloads claim the same address |
| `route_without_proxy` | a route is declared with nothing to route it |
-| `routing_exclusive` | the domain shorthand and the routes list say the same thing twice |
-| `routing_incomplete` | domain and port are declared together or not at all |
| `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 |
diff --git a/site/src/content/docs/reference/fields/top-level.mdx b/site/src/content/docs/reference/fields/top-level.mdx
index 43d20e5a..ae1fef88 100644
--- a/site/src/content/docs/reference/fields/top-level.mdx
+++ b/site/src/content/docs/reference/fields/top-level.mdx
@@ -18,13 +18,13 @@ cannot drift from what `ob validate` accepts.
## Fields on this page
-`api_version` · `app` · `args` · `base_path` · `build` · `compose` · `context` · `dockerfile` · `domain` · `entrypoint` · `exec` · `health` · `http` · `image` · `interval` · `middlewares` · `path` · `port` · `protocol` · `pull` · `reference` · `retries` · `routes` · `scheme` · `start_period` · `target` · `tcp` · `tls` · `wildcard_suffix` · `within`
+`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`
## Reference
| Field | Type | Default | What it does |
| --- | --- | --- | --- |
-| `api_version` `*` | string | — | Project contract version. Must be onebox.run/v1. |
+| `api_version` `*` | string | — | Project contract version. Must be onebox.run/v2. |
| `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. |
@@ -33,7 +33,6 @@ cannot drift from what `ob validate` accepts.
| `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. |
-| `domain` | string | — | Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing. |
| `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 /. |
@@ -46,16 +45,15 @@ cannot drift from what `ob validate` accepts.
| `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 | — | Container port used with domain shorthand and as the default HTTP health port. |
+| `port` | integer | — | Default container port used by HTTP health checks. |
| `routes` | list | — | Ingress routes exposed by this workload. |
-| `routes[].domain` | string | — | Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix. |
| `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, tcp, or udp. |
-| `routes[].scheme` | `http` · `https` · `h2c` | `http` | Backend connection scheme: http, https, h2c, tcp, or udp. |
+| `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[].wildcard_suffix` | string | — | DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain. |
`*` 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 e938c99b..7b7278d7 100644
--- a/site/src/content/docs/reference/fields/workloads.mdx
+++ b/site/src/content/docs/reference/fields/workloads.mdx
@@ -19,7 +19,7 @@ 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` · `domain` · `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` · `wildcard_suffix` · `within` · `working_dir`
+`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`
## Reference
@@ -34,7 +34,6 @@ cannot drift from what `ob validate` accepts.
| `.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. |
-| `.domain` | string | — | Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing. |
| `.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. |
@@ -86,7 +85,7 @@ cannot drift from what `ob validate` accepts.
| `.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. |
| `.persistence` | object | — | Declares whether this workload holds data that must outlive releases. |
| `.persistence.mode` | `durable` · `ephemeral` · `external` | `durable` | Data lifetime: durable, ephemeral, or external. |
-| `.port` | integer | — | Container port used with domain shorthand and as the default HTTP health port. |
+| `.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. |
@@ -98,15 +97,14 @@ cannot drift from what `ob validate` accepts.
| `.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. |
| `.routes` | list | — | Ingress routes exposed by this workload. |
-| `.routes[].domain` | string | — | Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix. |
| `.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, tcp, or udp. |
-| `.routes[].scheme` | `http` · `https` · `h2c` | `http` | Backend connection scheme: http, https, h2c, tcp, or udp. |
+| `.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[].wildcard_suffix` | string | — | DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain. |
| `.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.cron` | string | — | Five-field cron schedule translated to a host timer. Expects five cron fields. |
diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx
index 005ce0ad..3cef1757 100644
--- a/site/src/content/docs/reference/naming.mdx
+++ b/site/src/content/docs/reference/naming.mdx
@@ -143,7 +143,7 @@ New codes should be subject-first.
backup_target_unreachable
protection_disable_pending
drill_schedule_too_sparse
-routing_incomplete
+route_collision
```
## Reserved words
diff --git a/site/src/content/docs/reference/policies.mdx b/site/src/content/docs/reference/policies.mdx
index 444eb6a4..a4399b95 100644
--- a/site/src/content/docs/reference/policies.mdx
+++ b/site/src/content/docs/reference/policies.mdx
@@ -1,13 +1,13 @@
---
title: Policies
description: Versioning, runner compatibility, plan and approval schemas, and the guarantees that hold across releases.
-summary: CalVer release identity, minimum runner and plan-schema policy, the executable plan and approval contracts, and the v1 evolution guarantees.
+summary: CalVer release identity, minimum runner and plan-schema policy, the executable plan and approval contracts, and the v2 evolution guarantees.
sidebar:
order: 400
read_when:
- "Pinning a minimum runner version for an environment"
- "Understanding why a checkout build was refused"
- - "Checking what the v1 contract guarantees across upgrades"
+ - "Checking what the v2 contract guarantees across upgrades"
---
## Release identity
diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx
index 55a452cb..e56ac727 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/v2 project file — the block map, every scalar shorthand, and the two environment-value precedence rules.
sidebar:
order: 1
read_when:
@@ -10,7 +10,7 @@ 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/v2` 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.
@@ -20,13 +20,47 @@ from a `workloads` block or the top-level shorthand.
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://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json
+api_version: onebox.run/v2
```
`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 from v1
+
+Version 2 makes every ingress rule explicit and uses the conventional
+`hostname` name for both exact hosts and wildcard hosts. Change
+`api_version: onebox.run/v1` to `api_version: onebox.run/v2`, then migrate route
+fields as follows:
+
+```yaml
+# v1 scalar route shorthand
+domain: shop.example.com
+port: 3000
+
+# v2
+routes:
+ - hostname: shop.example.com
+ port: 3000
+```
+
+```yaml
+# v1 wildcard route
+routes:
+ - wildcard_suffix: preview.example.com
+ port: 3000
+
+# v2: a complete leftmost wildcard label, matching one label only
+routes:
+ - hostname: "*.preview.example.com"
+ port: 3000
+```
+
+The v2 loader rejects v1 project files instead of silently changing their
+meaning. The workload-level `port` field remains available as the default
+container port for HTTP health checks; it no longer creates an ingress route.
+
## The block map
| Block | What it says | Fields |
@@ -55,8 +89,8 @@ generated runtime.
## Shorthand
-A scalar form, once accepted, is accepted **permanently**. These are contract,
-not convenience that might be withdrawn.
+A scalar form accepted by a contract version remains accepted for that version.
+These are contract, not convenience that changes within a version.
| Written as | Means |
| --- | --- |
@@ -73,8 +107,9 @@ not convenience that might be withdrawn.
### 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`, `domain`, `port`,
-`health`, `routes`.
+instead of a `workloads` block: `build`, `image`, `compose`, `port`, `health`,
+`routes`. Ingress is always declared through `routes`; v2 removes the v1 scalar
+`domain`/`port` route shorthand.
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
@@ -179,8 +214,9 @@ guidance rather than silently starting a proxy with missing or stale routes.
### Wildcard host routes
-Use `wildcard_suffix` when one HTTP workload should receive every immediate
-subdomain below a suffix:
+Use a wildcard `hostname` when one HTTP workload should receive every immediate
+subdomain below a suffix. The complete left-most label is `*`, matching the
+convention used by TLS certificates and common ingress APIs:
```yaml
proxy:
@@ -193,19 +229,20 @@ workloads:
preview:
image: ghcr.io/acme/preview:1.0.0
routes:
- - {wildcard_suffix: preview.example.com, port: 3000}
+ - {hostname: "*.preview.example.com", port: 3000}
```
This matches `branch.preview.example.com`, but not the suffix itself or
-`a.branch.preview.example.com`. Declare the apex as a separate exact `domain`
-route when it should be served too. A route declares exactly one of `domain`
-or `wildcard_suffix`; Onebox does not accept authored regular expressions or a
-bare catch-all. It renders the suffix as the anchored Traefik rule
+`a.branch.preview.example.com`. Declare the apex as a separate exact `hostname`
+route when it should be served too. Onebox accepts only exact hostnames or a
+complete left-most wildcard label; it does not accept authored regular
+expressions, partial wildcards, or a bare HTTP catch-all. It renders the
+wildcard hostname as the anchored Traefik rule
``HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.preview\.example\.com$`)``.
Exact and wildcard claims
that overlap on the same entrypoint,
protocol, and path are refused instead of relying on implicit proxy priority.
-The existing `domain: "*"` form remains available only for a plaintext or TLS
+The existing `hostname: "*"` form remains available only for a plaintext or TLS
passthrough TCP route (`protocol: tcp` with `tls: none` or `tls: passthrough`),
where Traefik requires ``HostSNI(`*`)``;
it overlaps every TCP host claim on the same entrypoint and path.
@@ -240,11 +277,11 @@ workloads:
telemetry:
image: ghcr.io/acme/telemetry-gateway:1.0.0
routes:
- - domain: telemetry.example.com
+ - hostname: telemetry.example.com
entrypoint: otlp-grpc
port: 4317
scheme: h2c
- - domain: telemetry.example.com
+ - hostname: telemetry.example.com
entrypoint: otlp-http
port: 4318
```
@@ -275,7 +312,7 @@ workloads:
web:
image: ghcr.io/acme/shop:1.4.0
routes:
- - domain: shop.example.com
+ - hostname: shop.example.com
path: /admin
port: 8080
middlewares:
@@ -304,7 +341,7 @@ generates the runtime from it. Individual services can still be adopted with
## Evolution
-`api_version: onebox.run/v1` is stable. Within it:
+`api_version: onebox.run/v2` is stable. Within it:
- A field is **added**, never repurposed.
- A scalar form once accepted is accepted **permanently**.
diff --git a/site/src/content/docs/start/reading-it-back.mdx b/site/src/content/docs/start/reading-it-back.mdx
index 94e81ea5..d85ed4a2 100644
--- a/site/src/content/docs/start/reading-it-back.mdx
+++ b/site/src/content/docs/start/reading-it-back.mdx
@@ -40,8 +40,8 @@ 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
-api_version: onebox.run/v1
+# yaml-language-server: $schema=https://raw.githubusercontent.com/labstack/onebox/main/docs/onebox.run-v2.schema.json
+api_version: onebox.run/v2
```
The schema is generated from the same declarations the loader enforces and is
diff --git a/site/src/content/docs/status/capabilities.mdx b/site/src/content/docs/status/capabilities.mdx
index 304069ed..1fe19868 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 v1 evolution is
+- A stable, explicit `onebox.run/v2` project schema. Future v2 evolution is
additive.
- Compose generation and validation, SSH transport with known-host checking,
state-bound plans, image pinning, rendered diffs.
diff --git a/site/src/pages/llms.txt.ts b/site/src/pages/llms.txt.ts
index 844b1718..f5213b04 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}/onebox.run-v2.schema.json): The machine contract the loader enforces.`,
`- [Sitemap](${origin}/sitemap-index.xml): Crawler URL index.`,
"",
"## Operating Onebox from an agent",