Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/ob-docgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ var blocks = []block{
Summary: "Named container registries and the local environment variables holding their credentials.",
ReadWhen: []string{"Pulling from a private registry"}},
{Key: "notifications", Title: "notifications", Order: 100, Status: statusShipped,
Summary: "Named webhooks that receive selected operation outcomes.",
Summary: "Named webhooks that receive selected operation and scheduled-job outcomes.",
ReadWhen: []string{"Sending deploy outcomes to Slack, Discord or an incident tool"}},
{Key: "backup_targets", Title: "backup_targets", Order: 200, Status: statusShipped,
Summary: "User-owned off-host S3-compatible repositories a protected service writes its backups to. Executable for the postgres driver; every other driver refuses a policy rather than accepting one it cannot honour.",
Expand Down
54 changes: 48 additions & 6 deletions docs/onebox.run-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,7 @@
"type": "array"
},
"webhook": {
"description": "HTTP endpoint that receives operation notifications.",
"description": "HTTP endpoint that receives outcome notifications.",
"examples": [
"https://hooks.example.com/onebox"
],
Expand All @@ -1099,7 +1099,7 @@
},
"type": "object"
},
"description": "Named webhooks that receive selected operation outcomes.",
"description": "Named webhooks that receive selected operation and scheduled-job outcomes.",
"type": "object"
},
"port": {
Expand Down Expand Up @@ -2412,11 +2412,16 @@
},
"schedule": {
"additionalProperties": false,
"description": "Host-resident recurring schedule for a job.",
"description": "Host-resident recurring schedule and run policy for a job.",
"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": [
Expand All @@ -2425,6 +2430,15 @@
"pattern": "^[-0-9*/,A-Za-z ]+$",
"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.",
Expand Down Expand Up @@ -2458,9 +2472,33 @@
"type": "string"
},
"volumes": {
"description": "Managed named volumes or repository bind mounts.",
"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": [
Expand All @@ -2481,7 +2519,7 @@
"properties": {
"mode": {
"default": "rw",
"description": "Mount access mode: rw or ro.",
"description": "Mount access mode: rw or ro. A relative bind source requires ro.",
"enum": [
"rw",
"ro"
Expand All @@ -2505,10 +2543,14 @@
"type": "string"
},
"source": {
"description": "Repository-relative source path of a bind mount.",
"description": "Bind mount source. An absolute path is external host state that outlives releases. A dot-prefixed repository path is read-only release content removed by retention. 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"
}
},
Expand Down
70 changes: 70 additions & 0 deletions e2e/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,76 @@ func TestServerLifecycle(t *testing.T) {
}
})

t.Run("scheduled jobs are bounded and failures reach status", func(t *testing.T) {
s.run(t, "systemd-analyze verify "+
"/etc/systemd/system/ob-observer-chore.service "+
"/etc/systemd/system/ob-observer-chore.timer "+
"/etc/systemd/system/ob-observer-timeout--chore.service "+
"/etc/systemd/system/ob-observer-timeout--chore.timer")

// A normal host-fired run proves the generated runner, current-release
// lookup, Docker invocation and app-wide schedule lock compose on systemd.
s.run(t, "systemctl start ob-observer-chore.service")
if result := strings.TrimSpace(s.run(t,
"systemctl show ob-observer-chore.service --property=Result --value")); result != "success" {
t.Fatalf("normal scheduled run result = %q, want success", result)
}

// The receiver lives on the target because host-fired notifications do
// too. It accepts one POST, records the body, and exits.
receiver := `from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
size = int(self.headers.get("Content-Length", "0"))
with open("/tmp/onebox-schedule-notify", "wb") as out:
out.write(self.rfile.read(size))
self.send_response(204)
self.end_headers()
def log_message(self, format, *args):
pass
HTTPServer(("127.0.0.1", 18080), Handler).handle_request()
`
encoded := base64.StdEncoding.EncodeToString([]byte(receiver))
s.run(t, strings.Join([]string{
"set -e",
"command -v python3 >/dev/null",
"systemctl stop ob-e2e-schedule-receiver.service >/dev/null 2>&1 || true",
"systemctl reset-failed ob-e2e-schedule-receiver.service >/dev/null 2>&1 || true",
"rm -f /tmp/onebox-schedule-notify",
"printf '%s' '" + encoded + "' | base64 -d > /tmp/onebox-schedule-receiver.py",
"systemd-run --quiet --collect --unit=ob-e2e-schedule-receiver /usr/bin/python3 /tmp/onebox-schedule-receiver.py",
"for i in $(seq 1 50); do ss -ltn | grep -q '127.0.0.1:18080' && break; sleep .1; done",
"ss -ltn | grep -q '127.0.0.1:18080'",
}, "\n"))

// systemctl returns non-zero because TimeoutStartSec terminates the job.
if err := s.try(t, "systemctl start ob-observer-timeout--chore.service"); err == nil {
t.Fatal("wedged scheduled job was not terminated by its timeout")
}
if result := strings.TrimSpace(s.run(t,
"systemctl show ob-observer-timeout--chore.service --property=Result --value")); result != "timeout" {
t.Fatalf("timed-out scheduled run result = %q, want timeout", result)
}
out, err := s.ob(t, dir, "status")
if err == nil || !strings.Contains(out, "schedule timeout-chore") || !strings.Contains(out, "last run failed: timeout") {
t.Fatalf("status did not expose the scheduled failure (err=%v):\n%s", err, out)
}
notification := strings.TrimSpace(s.run(t, "cat /tmp/onebox-schedule-notify"))
for _, want := range []string{
`"app":"observer"`,
`"verb":"scheduled job timeout-chore"`,
`"status":"fail"`,
`"error":"operation failed; inspect trusted local diagnostics"`,
} {
if !strings.Contains(notification, want) {
t.Fatalf("scheduled failure notification is missing %q: %s", want, notification)
}
}
// Do not make the deliberately induced failure pollute later lifecycle
// assertions; systemd resets Result to success with the failed state.
s.run(t, "systemctl reset-failed ob-observer-timeout--chore.service")
})

t.Run("preflight", func(t *testing.T) {
s.mustOb(t, dir, "preflight")
})
Expand Down
17 changes: 17 additions & 0 deletions e2e/testdata/postgres/ob.yml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ workloads:
image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0
command: ["sh", "-c", "echo chore-ran"]
data_effect: none
schedule: { cron: "0 0 1 1 *", timeout: 20s, catch_up: false }
# A deliberately wedged timer run proves the host-enforced timeout becomes a
# failed systemd result and that `ob status` exposes it. Its annual timer never
# fires during the suite; the test starts the service directly.
timeout-chore:
role: job
image: public.ecr.aws/docker/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0
command: ["sh", "-c", "sleep 30"]
data_effect: none
schedule: { cron: "0 0 1 1 *", timeout: 1s, catch_up: false }
deployment:
order: [app]
services:
Expand Down Expand Up @@ -66,5 +76,12 @@ backup_targets:
access_key_entry: BACKUP_ACCESS_KEY_ID
secret_key_entry: BACKUP_SECRET_ACCESS_KEY
encryption: { pitr: client-side }
notifications:
# The server test starts a one-shot receiver before inducing a scheduled-job
# timeout. Because the timer fires on the host, localhost is the guest itself.
schedule-failures:
webhook: http://127.0.0.1:18080/notify
on: [failure]
format: json
proxy:
managed: false
6 changes: 6 additions & 0 deletions internal/app/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ var (
gAbsPath = grammar{"absolute path", regexp.MustCompile("^/[^\\x00-\\x1f'\"$`\\\\]*$"),
"an absolute path with no control character or shell metacharacter"}

// Bind sources are rendered with Compose's short volume syntax, where a
// colon separates source, target, and mode. Accept both supported lifetime
// forms, but never a value that can add another field to that syntax.
gBindSource = grammar{"bind mount source", regexp.MustCompile("^(/[^\\x00-\\x1f'\"$`\\\\:]*|\\.(?:/[^\\x00-\\x1f'\"$`\\\\:]*)?)$"),
"an absolute host path or a dot-prefixed path inside the repository, with no colon, control character or shell metacharacter"}

gURLPath = grammar{"url path", regexp.MustCompile("^/[^\\x00-\\x1f'\"$` \\\\]*$"),
"a path beginning with /"}

Expand Down
16 changes: 13 additions & 3 deletions internal/app/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,19 @@ func applyDefaults(p *Spec, raw map[string]any, derived map[string]Origin) {
w.When = "manual"
mark(path + ".when")
}
if w.Schedule != nil && w.Schedule.Timezone == "" {
w.Schedule.Timezone = "UTC"
mark(path + ".schedule.timezone")
if w.Schedule != nil {
if w.Schedule.Timezone == "" {
w.Schedule.Timezone = "UTC"
mark(path + ".schedule.timezone")
}
if w.Schedule.Timeout == "" {
w.Schedule.Timeout = "1h"
mark(path + ".schedule.timeout")
}
if !stated(raw, path+".schedule.catch_up") {
w.Schedule.CatchUp = true
mark(path + ".schedule.catch_up")
}
}
for i := range w.Routes {
rp := indexed(path+".routes", i)
Expand Down
1 change: 1 addition & 0 deletions internal/app/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ var errorCodes = map[string]string{
// Repository paths.
"path_absolute": "a repository path may not be absolute",
"path_escapes_repository": "a path resolves outside the project directory",
"path_parent_reference": "a bind source contains a parent-directory segment",
"path_unresolvable": "a path could not be resolved",

// Environment overrides.
Expand Down
15 changes: 15 additions & 0 deletions internal/app/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@ func TestRenderedRuntime(t *testing.T) {
}
}

func TestBindMountLifetimesRenderWithoutChangingTheirScope(t *testing.T) {
y := strings.Replace(appFixture,
" volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}]\n",
" volumes: [{source: /data/postgres, path: /var/lib/postgresql/data}, {source: ./postgres.conf, path: /etc/postgres.conf, mode: ro}]\n", 1)
out := string(render(t, y))
for _, want := range []string{
"/data/postgres:/var/lib/postgresql/data",
"./postgres.conf:/etc/postgres.conf:ro",
} {
if !strings.Contains(out, want) {
t.Errorf("rendered runtime is missing bind mount %q\n%s", want, out)
}
}
}

// TestEnvFilesAreNotProjectedIntoDaemons is the rule seven real projects forced:
// a database must not receive the application's secrets.
func TestEnvFilesAreNotProjectedIntoDaemons(t *testing.T) {
Expand Down
24 changes: 24 additions & 0 deletions internal/app/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ var schemaConstraints = []struct {
{[]string{"workloads", "*", "routes", "items", "middlewares", "items"}, pattern(gMiddlewareRef)},
{[]string{"workloads", "*", "volumes", "items", "name"}, pattern(gIdent)},
{[]string{"workloads", "*", "volumes", "items", "path"}, pattern(gAbsPath)},
{[]string{"workloads", "*", "volumes", "items", "source"}, bindSourceConstraint()},

{[]string{"workloads", "*", "volumes", "items", "mode"}, enum(eMountMode)},
// A named volume says where it mounts, or it is a bind pair. Either way
Expand All @@ -359,6 +360,22 @@ var schemaConstraints = []struct {
map[string]any{"required": []any{"name", "path"}},
map[string]any{"required": []any{"source", "path"}},
},
"allOf": []any{
map[string]any{
"if": map[string]any{
"required": []any{"source"},
"properties": map[string]any{
"source": map[string]any{"pattern": "^[^/]"},
},
},
"then": map[string]any{
"required": []any{"mode"},
"properties": map[string]any{
"mode": map[string]any{"const": "ro"},
},
},
},
},
}},
{[]string{"workloads", "*", "published_ports", "items", "host"}, portBounds()},
{[]string{"workloads", "*", "published_ports", "items", "container"}, portBounds()},
Expand All @@ -367,6 +384,7 @@ var schemaConstraints = []struct {
{[]string{"workloads", "*", "needs", "items", "condition"}, enum(eNeedCondition)},
{[]string{"workloads", "*", "schedule", "cron"}, pattern(gCron)},
{[]string{"workloads", "*", "schedule", "timezone"}, pattern(gTZ)},
{[]string{"workloads", "*", "schedule", "timeout"}, pattern(gDur)},

{[]string{"services", "*", "driver"}, pattern(gIdent)},
{[]string{"services", "*", "persistence", "mode"}, enum(ePersistence)},
Expand Down Expand Up @@ -563,6 +581,12 @@ func appNameConstraint() map[string]any {
return out
}

func bindSourceConstraint() map[string]any {
out := pattern(gBindSource)
out["not"] = map[string]any{"pattern": `(^|/)\.\.(/|$)`}
return out
}

func pattern(g grammar) map[string]any {
return map[string]any{"pattern": g.pattern.String(), "description": "Expects " + g.means + "."}
}
Expand Down
Loading