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 docs/onebox.run-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
"const": "ob"
},
{
"const": "proxy"
"const": "onebox-proxy"
},
{
"const": "_host"
Expand Down
5 changes: 5 additions & 0 deletions docs/product.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ generated runtime, and any host proxy or supporting services selected in the
project. It does not silently claim infrastructure or protection it has not
actually established.

Owned application containers have one visible grammar:
`<app>-<component>-<replica>`, with a one-based replica ordinal that is never
omitted. The managed host proxy is `onebox-proxy`. These names are generated
identity, not user configuration.

The broader managed-operations goal is direction, not an inventory. Owned today:
host bootstrap, the container runtime check, the proxy and its TLS, the host
ingress network, release
Expand Down
26 changes: 26 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -133,6 +134,7 @@ func TestZeroDowntimeDeploy(t *testing.T) {
if err := deploy("v1"); err != nil {
t.Fatalf("deploy v1: %v", err)
}
assertContainerNames(t, "obe2e", "web", "obe2e-web-1")
waitBody(t, "http://localhost:18080/", "v1\n", 30*time.Second)
assertPayloadDigestsAgree("after v1")

Expand Down Expand Up @@ -173,6 +175,7 @@ func TestZeroDowntimeDeploy(t *testing.T) {
if err != nil {
t.Fatalf("deploy v2: %v", err)
}
assertContainerNames(t, "obe2e", "web", "obe2e-web-1")

// The redeploy case matters more than the first: this release directory now
// sits alongside a predecessor and carries the manifest the lifecycle wrote
Expand All @@ -199,6 +202,29 @@ func TestZeroDowntimeDeploy(t *testing.T) {
fmt.Printf("zero-downtime proven: %d requests, 0 failures\n", total.Load())
}

func assertContainerNames(t *testing.T, project, service string, want ...string) {
t.Helper()
ids, err := exec.Command("docker", "ps", "-q",
"--filter", "label=com.docker.compose.project="+project,
"--filter", "label=com.docker.compose.service="+service).Output()
if err != nil {
t.Fatalf("list %s/%s containers: %v", project, service, err)
}
var got []string
for _, id := range strings.Fields(string(ids)) {
name, err := exec.Command("docker", "inspect", "-f", "{{.Name}}", id).Output()
if err != nil {
t.Fatalf("inspect %s/%s container %s: %v", project, service, id, err)
}
got = append(got, strings.TrimPrefix(strings.TrimSpace(string(name)), "/"))
}
slices.Sort(got)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Fatalf("%s/%s container names = %v, want %v", project, service, got, want)
}
}

func waitHealthy(t *testing.T, project, svc string, budget time.Duration) {
t.Helper()
deadline := time.Now().Add(budget)
Expand Down
2 changes: 1 addition & 1 deletion internal/app/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ var (
// application taking one of them would derive names that collide with the
// proxy's or the host namespace's, and the collision would appear as a
// container that vanishes rather than as an error.
var reservedAppNames = []string{"ob", "proxy", "_host"}
var reservedAppNames = []string{"ob", "onebox-proxy", "_host"}

// checkAppName is the identifier grammar plus the reservations.
func checkAppName(name string) error {
Expand Down
3 changes: 2 additions & 1 deletion internal/app/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ func conformanceCases() []conformanceCase {
{"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-proxy\nenvironments: {p: {server: h}}\nimage: nginx\n", false},
{"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},
{"unknown top-level field", min + "bogus: 1\n", false},
{"x- extension accepted", min + "x-note: anything\n", true},
Expand Down
55 changes: 34 additions & 21 deletions internal/app/names.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ import (

// Derived names are contract. Once a volume exists its name can never change
// without moving data, so every pattern here is fixed and pinned by a golden
// test.
// test. Runtime containers use the human-facing <app>-<component>-<replica>
// grammar; persistent and provider-internal names use the injective join below.
//
// Underscore joins identifiers. Hyphen cannot: identifiers may themselves
// contain hyphens, so `ob-<app>-<service>` maps both (a-b, c) and (a, b-c) to
// `ob-a-b-c`. Underscore is excluded from the identifier grammar and accepted by
// the container runtime in project and volume names, which makes the derivation
// injective. Application identifiers additionally may not begin `ob-`, which
// reserves the two pre-existing hyphenated host-scoped names.
// Persistent and provider-internal identifiers are joined with underscores.
// Hyphens would be ambiguous there: `ob-<app>-<service>` maps both (a-b, c) and
// (a, b-c) to `ob-a-b-c`. Underscore is excluded from the identifier grammar and
// accepted in project and volume names, which makes that derivation injective.
// Runtime segments escape an authored hyphen as `--`, leaving a single hyphen as
// an unambiguous separator while ordinary names retain the simple form.
const (
// ProxyProject and IngressNetwork are host-scoped and predate this contract.
ProxyProject = "ob-proxy"
// ProxyProject and IngressNetwork are host-scoped.
ProxyProject = "onebox-proxy"
IngressNetwork = "ob-ingress"

// HostNamespace holds state shared by everything on the box.
Expand Down Expand Up @@ -60,11 +61,10 @@ func (n Names) ServiceProject(service string) string {
return join("ob", n.App, service)
}

// ServiceContainer is a service's container name. It is fixed — unlike a
// workload, a service is never replaced by a second copy running beside it, so
// there is no handover that a fixed name would forbid.
// ServiceContainer is a service's stable singleton slot. The explicit ordinal
// keeps every application-owned runtime name in one predictable grammar.
func (n Names) ServiceContainer(service string) string {
return join(n.App, service)
return containerName(n.App, service, 1)
}

// ServiceNetwork joins the application to its services. It is one network per
Expand Down Expand Up @@ -162,7 +162,7 @@ func (n Names) ProtectionRestoreProject(service string) string {
}

func (n Names) ProtectionRestoreContainer(service string) string {
return join(n.App, service, "restore")
return runtimeName(n.App, service, "restore", "1")
}

func (n Names) ProtectionRestoreNetwork(service string) string {
Expand All @@ -181,20 +181,18 @@ func (n Names) ProtectionTimerForEnvironment(environment, service, operation str
return "ob-" + n.App + "-" + environment + "-" + service + "-" + operation + ".timer"
}

// Container is the stable name of a workload's container. Container names are
// host-global in the container runtime, so every one carries the application.
// Container is a workload's stable runtime slot. Container names are
// host-global, so every one carries the application, component, and a
// one-based replica ordinal — including singleton workloads.
func (n Names) Container(workload string, replica int) string {
if replica <= 1 {
return join(n.App, workload)
}
return join(n.App, workload, fmt.Sprint(replica))
return containerName(n.App, workload, replica)
}

// TransientContainer is the name a rollout gives a new container before it takes
// a stable slot. It is application-scoped like every other name, and belongs in
// the preflight collision check.
func (n Names) TransientContainer(workload string) string {
return join(n.App, workload, "new")
return runtimeName(n.App, workload, "new")
}

// Router and ProxyService name the proxy's routing objects. They are part of the
Expand Down Expand Up @@ -311,3 +309,18 @@ func join(parts ...string) string {
}
return out
}

func containerName(app, component string, replica int) string {
if replica < 1 {
panic("container replica ordinal must be positive")
}
return runtimeName(app, component, fmt.Sprint(replica))
}

func runtimeName(parts ...string) string {
escaped := make([]string, len(parts))
for i, part := range parts {
escaped[i] = strings.ReplaceAll(part, "-", "--")
}
return strings.Join(escaped, "-")
}
67 changes: 52 additions & 15 deletions internal/app/names_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"fmt"
"strings"
"testing"
)
Expand Down Expand Up @@ -41,15 +42,15 @@ func TestDerivedNamesGolden(t *testing.T) {
}
want := []string{
"ledger",
"ledger_migrate",
"ledger_migrate_new",
"ledger_postgres",
"ledger_web",
"ledger_web_2",
"ledger_web_3",
"ledger_web_new",
"ledger_worker",
"ledger_worker_new",
"ledger-migrate-1",
"ledger-migrate-new",
"ledger-postgres-1",
"ledger-web-1",
"ledger-web-2",
"ledger-web-3",
"ledger-web-new",
"ledger-worker-1",
"ledger-worker-new",
"ob_ledger_postgres",
"ob_ledger_postgres_data",
"ob_ledger_postgres_wal",
Expand Down Expand Up @@ -117,14 +118,50 @@ func TestBasePathPerEnvironment(t *testing.T) {
}
}

// TestSingleReplicaContainerHasNoIndex keeps the common case readable.
func TestSingleReplicaContainerHasNoIndex(t *testing.T) {
// TestEveryContainerHasAnOrdinal keeps the runtime grammar uniform for users,
// scripts, and language models.
func TestEveryContainerHasAnOrdinal(t *testing.T) {
n := Names{App: "ledger"}
if got := n.Container("web", 1); got != "ledger_web" {
t.Errorf("single replica = %q, want ledger_web", got)
if got := n.Container("web", 1); got != "ledger-web-1" {
t.Errorf("single replica = %q, want ledger-web-1", got)
}
if got := n.Container("web", 2); got != "ledger_web_2" {
t.Errorf("second replica = %q, want ledger_web_2", got)
if got := n.Container("web", 2); got != "ledger-web-2" {
t.Errorf("second replica = %q, want ledger-web-2", got)
}
}

func TestContainerNamesEscapeSegmentHyphens(t *testing.T) {
n := Names{App: "help-desk"}
if got := n.Container("web-api", 1); got != "help--desk-web--api-1" {
t.Errorf("hyphenated container = %q, want help--desk-web--api-1", got)
}
if got := n.TransientContainer("web-api"); got != "help--desk-web--api-new" {
t.Errorf("hyphenated transient = %q, want help--desk-web--api-new", got)
}
if restore, workload := n.ProtectionRestoreContainer("database"), n.Container("database-restore", 1); restore == workload {
t.Fatalf("restore container collides with declared workload: %q", restore)
}
}

func TestRuntimeContainerDerivationIsInjective(t *testing.T) {
idents := []string{"a", "a-b", "a--b", "b", "b-c", "restore", "web-1"}
seen := map[string]string{}
add := func(name, source string) {
t.Helper()
if previous, exists := seen[name]; exists {
t.Fatalf("runtime name %q derives from both %s and %s", name, previous, source)
}
seen[name] = source
}
for _, application := range idents {
n := Names{App: application}
for _, component := range idents {
for replica := 1; replica <= 3; replica++ {
add(n.Container(component, replica), fmt.Sprintf("container %s/%s/%d", application, component, replica))
}
add(n.TransientContainer(component), "transient "+application+"/"+component)
add(n.ProtectionRestoreContainer(component), "restore "+application+"/"+component)
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/app/preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func TestPreflightOnlyReads(t *testing.T) {
func TestPreflightReportsEveryProblem(t *testing.T) {
run := healthyRunner()
run.answers["-w"] = transport.Result{ExitCode: 1, Stdout: "/var/lib\n"}
run.answers["docker ps"] = transport.Result{Stdout: "ledger_web\t\n"}
run.answers["docker ps"] = transport.Result{Stdout: "ledger-web-1\t\n"}
run.answers["docker network inspect"] = transport.Result{ExitCode: 1}

rep := preflight(t, run, preflightProject)
Expand All @@ -146,7 +146,7 @@ func TestPreflightReportsEveryProblem(t *testing.T) {
// normal case. Treating it as a conflict would make the second deploy fail.
func TestPreviousReleaseIsNotACollision(t *testing.T) {
run := healthyRunner()
run.answers["docker ps"] = transport.Result{Stdout: "ledger_web\tledger\n"}
run.answers["docker ps"] = transport.Result{Stdout: "ledger-web-1\tledger\n"}

rep := preflight(t, run, preflightProject)
for _, c := range rep.Failures() {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/protection_names_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func TestProtectedServiceReservesRestoreRuntimeNames(t *testing.T) {
all := spec.All("production")
for _, reserved := range []string{
"ob_example_database_restore",
"example_database_restore",
"example-database-restore-1",
"ob_example_database_restore-net",
"ob_example_database_restore-stage",
} {
Expand Down
Loading