From dfd32246e829abce5a69459c69255ba8c120e1b5 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 25 Aug 2026 09:36:00 -0700 Subject: [PATCH 1/2] feat(proxy): add named entrypoints Closes #121 --- docs/onebox.run-v1.schema.json | 20 ++++ internal/app/load.go | 8 ++ internal/app/load_test.go | 40 ++++++++ internal/app/purity_test.go | 4 + internal/app/types.go | 17 ++-- internal/app/validate.go | 20 ++++ internal/engine/proxy.go | 2 +- internal/engine/proxy_test.go | 6 +- internal/engine/proxystatus.go | 2 +- internal/engine/proxystatus_test.go | 2 +- internal/proxy/proxy.go | 56 ++++++++--- internal/proxy/proxy_test.go | 95 ++++++++++++++++--- site/public/onebox.run-v1.schema.json | 20 ++++ .../content/docs/reference/fields/proxy.mdx | 4 +- 14 files changed, 261 insertions(+), 35 deletions(-) diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json index dd8b47af..2f57c964 100644 --- a/docs/onebox.run-v1.schema.json +++ b/docs/onebox.run-v1.schema.json @@ -1172,6 +1172,26 @@ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, + "entrypoints": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "port": { + "description": "Host and proxy-container TCP port used by this listener.", + "examples": [ + 4317 + ], + "type": "integer" + } + }, + "type": "object" + }, + "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a declared proxy.config must define matching Traefik entrypoints.", + "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,}))?$", diff --git a/internal/app/load.go b/internal/app/load.go index c083cfa3..7dee3344 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -521,8 +521,16 @@ func crossFieldRules(p *Spec) error { return err } if p.Proxy.Kind != "none" && p.Proxy.Managed && p.Proxy.Config == "" { + knownEntrypoints := map[string]struct{}{"web": {}, "websecure": {}} + for name := range p.Proxy.Entrypoints { + knownEntrypoints[name] = struct{}{} + } for _, name := range sortedKeys(p.Workloads) { for i, route := range p.Workloads[name].Routes { + if _, exists := knownEntrypoints[route.Entrypoint]; !exists { + return errf("project_invalid", indexed("workloads."+name+".routes", i)+".entrypoint", "", + "proxy entrypoint %q is not built in or declared under proxy.entrypoints", route.Entrypoint) + } if len(route.Middlewares) == 0 { continue } diff --git a/internal/app/load_test.go b/internal/app/load_test.go index 4c53c29a..b9c92ce4 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -24,6 +24,45 @@ func TestRoutedProjectRefusesDefaultAsProxyNetwork(t *testing.T) { } } +func TestProxyEntrypointsValidateNamesAndPorts(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + want string + }{ + {"valid", "proxy: {entrypoints: {otlp-grpc: {port: 4317}, otlp-http: {port: 4318}}}\n", ""}, + {"invalid name", "proxy: {entrypoints: {OTLP: {port: 4317}}}\n", "proxy.entrypoints.OTLP"}, + {"built-in name", "proxy: {entrypoints: {web: {port: 4317}}}\n", "built in"}, + {"built-in port", "proxy: {entrypoints: {otlp: {port: 443}}}\n", "websecure"}, + {"duplicate port", "proxy: {entrypoints: {otlp-grpc: {port: 4317}, otlp-http: {port: 4317}}}\n", "both publish port 4317"}, + {"invalid port", "proxy: {entrypoints: {otlp: {port: 70000}}}\n", "proxy.entrypoints.otlp.port"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadBytes([]byte(min+tc.yaml), "ob.yml") + if tc.want == "" && err != nil { + t.Fatalf("valid proxy entrypoints: %v", err) + } + if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatalf("error = %v, want text %q", err, tc.want) + } + }) + } +} + +func TestManagedGeneratedProxyRequiresDeclaredRouteEntrypoint(t *testing.T) { + project := base + `workloads: + grpc: + image: app:1 + routes: [{domain: 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) + } + if _, err := LoadBytes([]byte(project+"proxy: {config: traefik}\n"), "ob.yml"); err != nil { + t.Fatalf("custom static proxy config owns its entrypoints: %v", err) + } +} + type conformanceCase struct { name string yaml string @@ -454,6 +493,7 @@ workloads: image: y:1 routes: - {domain: shop.example.com, path: /, port: 90, entrypoint: grpc, scheme: h2c} +proxy: {entrypoints: {grpc: {port: 8443}}} `), "ob.yml") if err != nil { t.Fatalf("distinct entrypoints are distinct addresses: %v", err) diff --git a/internal/app/purity_test.go b/internal/app/purity_test.go index 82f80e8f..531c2890 100644 --- a/internal/app/purity_test.go +++ b/internal/app/purity_test.go @@ -50,6 +50,10 @@ workloads: when: pre_release services: postgres: 16 +proxy: + entrypoints: + grpc: {port: 9000} + pg: {port: 5432} ` func purityFixture(t *testing.T) *Spec { diff --git a/internal/app/types.go b/internal/app/types.go index 36fe6b52..6bc30452 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -490,12 +490,17 @@ type Registry struct { } type Proxy struct { - Managed bool `json:"managed" description:"Let Onebox converge the host-scoped proxy when routes are declared."` - Kind string `json:"kind" description:"Proxy implementation, or none to disable routing." default:"traefik-docker"` - Image string `json:"image,omitempty" description:"Container image used for the managed proxy."` - Config string `json:"config,omitempty" description:"Repository-relative static proxy configuration directory owned by the project; it must contain exactly one of traefik.yml or traefik.yaml."` - Network string `json:"network" description:"External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved." default:"ob-ingress"` - CertResolver string `json:"cert_resolver,omitempty" description:"Traefik certificate resolver used by terminating TLS routes."` + Managed bool `json:"managed" description:"Let Onebox converge the host-scoped proxy when routes are declared."` + Kind string `json:"kind" description:"Proxy implementation, or none to disable routing." default:"traefik-docker"` + Image string `json:"image,omitempty" description:"Container image used for the managed proxy."` + Config string `json:"config,omitempty" description:"Repository-relative static proxy configuration directory owned by the project; it must contain exactly one of traefik.yml or traefik.yaml."` + Network string `json:"network" description:"External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved." default:"ob-ingress"` + CertResolver string `json:"cert_resolver,omitempty" description:"Traefik certificate resolver used by terminating TLS routes."` + Entrypoints map[string]ProxyEntrypoint `json:"entrypoints,omitempty" description:"Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a declared proxy.config must define matching Traefik entrypoints."` +} + +type ProxyEntrypoint struct { + Port int `json:"port" description:"Host and proxy-container TCP port used by this listener." example:"4317"` } // EnvFile is one contributor of environment values. diff --git a/internal/app/validate.go b/internal/app/validate.go index 63122265..f96978e2 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -82,6 +82,26 @@ func validateTopLevel(p *Spec) error { if err := gRepoPath.checkOptional("proxy.config", p.Proxy.Config); err != nil { return err } + seenEntrypointPorts := map[int]string{80: "web", 443: "websecure"} + for _, name := range sortedKeys(p.Proxy.Entrypoints) { + path := "proxy.entrypoints." + name + if err := gIdent.check(path, name); err != nil { + return err + } + if name == "web" || name == "websecure" { + return errf("project_invalid", path, "", + "proxy entrypoint %q is built in and cannot be redeclared", name) + } + port := p.Proxy.Entrypoints[name].Port + if err := checkPort(path+".port", port); err != nil { + return err + } + if previous, exists := seenEntrypointPorts[port]; exists { + return errf("project_invalid", path+".port", "", + "proxy entrypoints %q and %q both publish port %d", previous, name, port) + } + seenEntrypointPorts[port] = name + } // Compose reserves `default` for the application's implicit network, and // Onebox owns the two derived app-scoped networks. Letting ingress reuse one // makes the proxy create it first under different Compose ownership, after diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index 87cbf4a1..8ae6df6e 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -42,7 +42,7 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo return err } defer os.RemoveAll(staging) - hash, err := proxy.Stage(localCfg, staging, e.Spec.Proxy.Image, e.Spec.Proxy.Network) + hash, err := proxy.Stage(localCfg, staging, e.Spec.Proxy.Image, e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints) if err != nil { return err } diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go index f6462c02..9c37e8f6 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -31,7 +31,7 @@ func proxyFixture(t *testing.T, f *transport.Fake) (*Engine, string, *bytes.Buff } cfg := testConfig() cfg.Proxy = app.Proxy{Kind: "traefik-docker", Managed: true, Config: "traefik"} - hash, err := proxy.Stage(filepath.Join(dir, "traefik"), t.TempDir(), "", "") + hash, err := proxy.Stage(filepath.Join(dir, "traefik"), t.TempDir(), "", "", nil) if err != nil { t.Fatal(err) } @@ -142,7 +142,7 @@ func TestEnsureProxyConfigOnlyChangeRestarts(t *testing.T) { f := &transport.Fake{} e, _, _ := proxyFixture(t, f) // remote compose identical to what we render; only the config hash differs - rendered := string(proxy.RenderCompose("", "", true)) + rendered := string(proxy.RenderCompose("", "", true, nil)) ps := proxyPS(f, true) f.Dynamic = func(cmd string) (transport.Result, bool) { if strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'") { @@ -186,7 +186,7 @@ func TestEnsureProxyConfigOnlyChangeRestarts(t *testing.T) { func TestEnsureProxyFailedConvergeLeavesHashUnwritten(t *testing.T) { f := &transport.Fake{} e, _, _ := proxyFixture(t, f) - rendered := string(proxy.RenderCompose("", "", true)) + rendered := string(proxy.RenderCompose("", "", true, nil)) ps := proxyPS(f, true) f.Dynamic = func(cmd string) (transport.Result, bool) { if strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'") { diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index 892227ed..774309fd 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -155,7 +155,7 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { return err } defer os.RemoveAll(staging) - px.localHash, err = proxy.Stage(localCfg, staging, e.Spec.Proxy.Image, e.Spec.Proxy.Network) + px.localHash, err = proxy.Stage(localCfg, staging, e.Spec.Proxy.Image, e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints) return err }, } diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index 438f6728..bf6845aa 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -55,7 +55,7 @@ func statusProxyEngine(t *testing.T, appliedHash *string, acme string, proxyHeal if err := os.WriteFile(filepath.Join(dir, "traefik", "traefik.yml"), []byte("ping: {}\n"), 0o600); err != nil { t.Fatal(err) } - localHash, err := proxy.Stage(filepath.Join(dir, "traefik"), t.TempDir(), "", "") + localHash, err := proxy.Stage(filepath.Join(dir, "traefik"), t.TempDir(), "", "", nil) if err != nil { t.Fatal(err) } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index d9f46193..daee3681 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -70,7 +70,7 @@ func HostPaths(n app.Names) Paths { // RenderCompose emits the proxy's own compose file. Deliberately a template, // not compose-go construction: the shape IS the contract and a reviewer should // be able to read it whole. -func RenderCompose(image, network string, hasEnv bool) []byte { +func RenderCompose(image, network string, hasEnv bool, entrypoints map[string]app.ProxyEntrypoint) []byte { if image == "" { image = DefaultImage } @@ -81,13 +81,18 @@ func RenderCompose(image, network string, hasEnv bool) []byte { if hasEnv { envFile = " env_file: [config/.env]\n" } + ports := []string{`"80:80"`, `"443:443"`} + for _, name := range sortedEntrypointNames(entrypoints) { + port := entrypoints[name].Port + ports = append(ports, fmt.Sprintf(`"%d:%d"`, port, port)) + } return []byte(fmt.Sprintf(`# generated by onebox — do not edit (ob proxy apply owns this file) services: proxy: container_name: %s image: %s restart: unless-stopped - ports: ["80:80", "443:443"] + ports: [%s] %s volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./config:/etc/traefik:ro @@ -102,7 +107,22 @@ services: networks: ingress: name: %s -`, ContainerName, image, envFile, network)) +`, ContainerName, image, strings.Join(ports, ", "), envFile, network)) +} + +func sortedEntrypointNames(entrypoints map[string]app.ProxyEntrypoint) []string { + names := make([]string, 0, len(entrypoints)) + for name := range entrypoints { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { + left, right := entrypoints[names[i]].Port, entrypoints[names[j]].Port + if left != right { + return left < right + } + return names[i] < names[j] + }) + return names } // CertExpiry is one issued certificate's identity and lifetime — the ONLY @@ -169,7 +189,7 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { // // The certificate resolver is defined but no email is set, so it is inert // until a route asks for it via `proxy.cert_resolver`. -const DefaultStaticConfig = `# Written by Onebox because the project declared no proxy.config. +const defaultStaticConfigHeader = `# Written by Onebox because the project declared no proxy.config. # Declare one to take ownership of Traefik's static configuration. ping: {} providers: @@ -183,7 +203,9 @@ entryPoints: entryPoint: {to: websecure, scheme: https} websecure: address: ":443" -certificatesResolvers: +` + +const defaultStaticConfigFooter = `certificatesResolvers: letsencrypt: acme: storage: /letsencrypt/acme.json @@ -191,9 +213,21 @@ certificatesResolvers: entryPoint: web ` -func Stage(localCfgDir, stagingDir, image, network string) (string, error) { +const DefaultStaticConfig = defaultStaticConfigHeader + defaultStaticConfigFooter + +func renderStaticConfig(entrypoints map[string]app.ProxyEntrypoint) []byte { + var out strings.Builder + out.WriteString(defaultStaticConfigHeader) + for _, name := range sortedEntrypointNames(entrypoints) { + fmt.Fprintf(&out, " %s:\n address: \":%d\"\n", name, entrypoints[name].Port) + } + out.WriteString(defaultStaticConfigFooter) + return []byte(out.String()) +} + +func Stage(localCfgDir, stagingDir, image, network string, entrypoints map[string]app.ProxyEntrypoint) (string, error) { if localCfgDir == "" { - return stageDefault(stagingDir, image, network) + return stageDefault(stagingDir, image, network, entrypoints) } entries, err := os.ReadDir(localCfgDir) if err != nil { @@ -243,7 +277,7 @@ func Stage(localCfgDir, stagingDir, image, network string) (string, error) { fmt.Fprintf(h, "%s\x00%d\x00", name, len(b)) h.Write(b) } - compose := RenderCompose(image, network, hasEnv) + compose := RenderCompose(image, network, hasEnv, entrypoints) if err := os.WriteFile(filepath.Join(stagingDir, "compose.yaml"), compose, 0o644); err != nil { return "", err } @@ -254,12 +288,12 @@ func Stage(localCfgDir, stagingDir, image, network string) (string, error) { // stageDefault writes the configuration Onebox owns, so a project that declares // a domain and nothing else can bootstrap. -func stageDefault(stagingDir, image, network string) (string, error) { +func stageDefault(stagingDir, image, network string, entrypoints map[string]app.ProxyEntrypoint) (string, error) { cfgOut := filepath.Join(stagingDir, "config") if err := os.MkdirAll(cfgOut, 0o755); err != nil { return "", err } - body := []byte(DefaultStaticConfig) + body := renderStaticConfig(entrypoints) if err := os.WriteFile(filepath.Join(cfgOut, "traefik.yml"), body, 0o600); err != nil { return "", err } @@ -267,7 +301,7 @@ func stageDefault(stagingDir, image, network string) (string, error) { fmt.Fprintf(h, "%s\x00%d\x00", "traefik.yml", len(body)) h.Write(body) - compose := RenderCompose(image, network, false) + compose := RenderCompose(image, network, false, entrypoints) if err := os.WriteFile(filepath.Join(stagingDir, "compose.yaml"), compose, 0o644); err != nil { return "", err } diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 9251d594..4ff4936e 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -47,7 +47,7 @@ func TestPathsHostScoped(t *testing.T) { } func TestRenderCompose(t *testing.T) { - b := string(RenderCompose("traefik:v3.7", "ob-ingress", true)) + b := string(RenderCompose("traefik:v3.7", "ob-ingress", true, nil)) for _, want := range []string{ "container_name: onebox-proxy", "image: traefik:v3.7", @@ -63,12 +63,38 @@ func TestRenderCompose(t *testing.T) { t.Fatalf("rendered compose missing %q:\n%s", want, b) } } - noEnv := string(RenderCompose("traefik:v3.7", "ob-ingress", false)) + noEnv := string(RenderCompose("traefik:v3.7", "ob-ingress", false, nil)) if strings.Contains(noEnv, ".env") { t.Fatalf("env_file must be omitted without .env:\n%s", noEnv) } } +func TestRenderAdditionalEntrypoints(t *testing.T) { + entrypoints := map[string]app.ProxyEntrypoint{ + "otlp-http": {Port: 4318}, + "otlp-grpc": {Port: 4317}, + } + compose := string(RenderCompose("", "", false, entrypoints)) + for _, want := range []string{`"4317:4317"`, `"4318:4318"`} { + if !strings.Contains(compose, want) { + t.Errorf("rendered compose missing %q:\n%s", want, compose) + } + } + if strings.Index(compose, `"4317:4317"`) > strings.Index(compose, `"4318:4318"`) { + t.Fatalf("entrypoint ports must render deterministically by port:\n%s", compose) + } + + static := string(renderStaticConfig(entrypoints)) + for _, want := range []string{ + "otlp-grpc:\n address: \":4317\"", + "otlp-http:\n address: \":4318\"", + } { + if !strings.Contains(static, want) { + t.Errorf("rendered static config missing %q:\n%s", want, static) + } + } +} + func TestStage(t *testing.T) { cfgDir := writeCfg(t, map[string]string{ "traefik.yml": "ping: {}\n", @@ -76,7 +102,7 @@ func TestStage(t *testing.T) { ".env": "CF_DNS_API_TOKEN=x\n", }) staging := t.TempDir() - hash, err := Stage(cfgDir, staging, "", "") + hash, err := Stage(cfgDir, staging, "", "", nil) if err != nil { t.Fatal(err) } @@ -102,7 +128,7 @@ func TestStage(t *testing.T) { // determinism + sensitivity staging2 := t.TempDir() - hash2, err := Stage(cfgDir, staging2, "", "") + hash2, err := Stage(cfgDir, staging2, "", "", nil) if err != nil { t.Fatal(err) } @@ -112,7 +138,7 @@ func TestStage(t *testing.T) { if err := os.WriteFile(filepath.Join(cfgDir, "dynamic.yml"), []byte("http: {middlewares: {}}\n"), 0o600); err != nil { t.Fatal(err) } - hash3, err := Stage(cfgDir, t.TempDir(), "", "") + hash3, err := Stage(cfgDir, t.TempDir(), "", "", nil) if err != nil { t.Fatal(err) } @@ -121,9 +147,32 @@ func TestStage(t *testing.T) { } } +func TestStageCustomConfigPublishesEntrypointsWithoutRewritingIt(t *testing.T) { + cfgDir := writeCfg(t, map[string]string{"traefik.yml": "ping: {}\nentryPoints: {}\n"}) + staging := t.TempDir() + entrypoints := map[string]app.ProxyEntrypoint{"otlp-grpc": {Port: 4317}} + if _, err := Stage(cfgDir, staging, "", "", entrypoints); err != nil { + t.Fatal(err) + } + compose, err := os.ReadFile(filepath.Join(staging, "compose.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(compose), `"4317:4317"`) { + t.Fatalf("custom static config must not prevent publishing the declared listener:\n%s", compose) + } + static, err := os.ReadFile(filepath.Join(staging, "config", "traefik.yml")) + if err != nil { + t.Fatal(err) + } + if string(static) != "ping: {}\nentryPoints: {}\n" { + t.Fatalf("custom static configuration was rewritten:\n%s", static) + } +} + func TestStageRequiresTraefikConfig(t *testing.T) { cfgDir := writeCfg(t, map[string]string{"dynamic.yml": "http: {}\n"}) - if _, err := Stage(cfgDir, t.TempDir(), "", ""); err == nil || + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil || !strings.Contains(err.Error(), "traefik.yml") || !strings.Contains(err.Error(), "traefik.yaml") { t.Fatalf("want both supported static config names in the contract error, got %v", err) } @@ -132,7 +181,7 @@ func TestStageRequiresTraefikConfig(t *testing.T) { func TestStageAcceptsTraefikYAML(t *testing.T) { cfgDir := writeCfg(t, map[string]string{"traefik.yaml": "ping: {}\n"}) staging := t.TempDir() - if _, err := Stage(cfgDir, staging, "", ""); err != nil { + if _, err := Stage(cfgDir, staging, "", "", nil); err != nil { t.Fatalf("traefik.yaml must be accepted: %v", err) } if _, err := os.Stat(filepath.Join(staging, "config", "traefik.yaml")); err != nil { @@ -145,7 +194,7 @@ func TestStageRejectsAmbiguousTraefikConfig(t *testing.T) { "traefik.yml": "ping: {}\n", "traefik.yaml": "ping: {}\n", }) - if _, err := Stage(cfgDir, t.TempDir(), "", ""); err == nil || !strings.Contains(err.Error(), "both") { + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil || !strings.Contains(err.Error(), "both") { t.Fatalf("two static config files must be refused as ambiguous: %v", err) } } @@ -155,7 +204,7 @@ func TestStageRejectsSubdirs(t *testing.T) { if err := os.Mkdir(filepath.Join(cfgDir, "extra"), 0o755); err != nil { t.Fatal(err) } - if _, err := Stage(cfgDir, t.TempDir(), "", ""); err == nil || !strings.Contains(err.Error(), "flat") { + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil || !strings.Contains(err.Error(), "flat") { t.Fatalf("want flat-dir contract error, got %v", err) } } @@ -211,7 +260,7 @@ func TestCertExpiries(t *testing.T) { // same one every time. func TestDefaultStaticConfigIsWrittenWhenNoneIsDeclared(t *testing.T) { staging := t.TempDir() - hash, err := Stage("", staging, "traefik:v3.7", "ob-ingress") + hash, err := Stage("", staging, "traefik:v3.7", "ob-ingress", nil) if err != nil { t.Fatalf("a project without proxy.config must still bootstrap: %v", err) } @@ -229,6 +278,30 @@ func TestDefaultStaticConfigIsWrittenWhenNoneIsDeclared(t *testing.T) { } } +func TestDeclaredEntrypointsChangeProxyIdentity(t *testing.T) { + plainDir := t.TempDir() + plainHash, err := Stage("", plainDir, "", "", nil) + if err != nil { + t.Fatal(err) + } + entrypoints := map[string]app.ProxyEntrypoint{"otlp-grpc": {Port: 4317}} + staging := t.TempDir() + entrypointHash, err := Stage("", staging, "", "", entrypoints) + if err != nil { + t.Fatal(err) + } + if entrypointHash == plainHash { + t.Fatal("an additional listener must change the managed proxy identity") + } + compose, err := os.ReadFile(filepath.Join(staging, "compose.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(compose), `"4317:4317"`) { + t.Fatalf("staged proxy does not publish the declared listener:\n%s", compose) + } +} + // A declared directory still owns the configuration entirely, and one missing // either supported static file says what to do about it. func TestDeclaredConfigStillOwnsItAndSaysWhatIsMissing(t *testing.T) { @@ -236,7 +309,7 @@ func TestDeclaredConfigStillOwnsItAndSaysWhatIsMissing(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "other.yml"), []byte("x: 1\n"), 0o600); err != nil { t.Fatal(err) } - _, err := Stage(dir, t.TempDir(), "traefik:v3.7", "ob-ingress") + _, err := Stage(dir, t.TempDir(), "traefik:v3.7", "ob-ingress", nil) if err == nil { t.Fatal("a declared config directory without traefik.yml or traefik.yaml must be refused") } diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json index dd8b47af..2f57c964 100644 --- a/site/public/onebox.run-v1.schema.json +++ b/site/public/onebox.run-v1.schema.json @@ -1172,6 +1172,26 @@ "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, + "entrypoints": { + "additionalProperties": { + "additionalProperties": false, + "patternProperties": { + "^x-": {} + }, + "properties": { + "port": { + "description": "Host and proxy-container TCP port used by this listener.", + "examples": [ + 4317 + ], + "type": "integer" + } + }, + "type": "object" + }, + "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a declared proxy.config must define matching Traefik entrypoints.", + "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,}))?$", diff --git a/site/src/content/docs/reference/fields/proxy.mdx b/site/src/content/docs/reference/fields/proxy.mdx index efaf4bdf..b479018e 100644 --- a/site/src/content/docs/reference/fields/proxy.mdx +++ b/site/src/content/docs/reference/fields/proxy.mdx @@ -17,7 +17,7 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`cert_resolver` · `config` · `image` · `kind` · `managed` · `network` +`cert_resolver` · `config` · `entrypoints` · `image` · `kind` · `managed` · `network` · `port` ## Reference @@ -25,6 +25,8 @@ cannot drift from what `ob validate` accepts. | --- | --- | --- | --- | | `cert_resolver` | string | — | Traefik certificate resolver used by terminating TLS routes. | | `config` | string | — | Repository-relative static proxy configuration directory owned by the project; it must contain exactly one of traefik.yml or traefik.yaml. Expects a path inside the repository, with no control character or shell metacharacter. | +| `entrypoints` | map | — | Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a declared proxy.config must define matching Traefik entrypoints. | +| `entrypoints..port` | integer | — | Host and proxy-container TCP port used by this listener. | | `image` | string | — | Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. | | `kind` | `traefik-docker` · `none` | `traefik-docker` | Proxy implementation, or none to disable routing. | | `managed` | boolean | — | Let Onebox converge the host-scoped proxy when routes are declared. | From 730cc13d93c9a67de2ce6540a26a6b7750eb3b31 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 25 Aug 2026 09:44:07 -0700 Subject: [PATCH 2/2] fix(proxy): align entrypoint contract Keep published schemas consistent with runtime validation and document custom listener ownership. Lock legacy rendering for projects that do not declare additional entrypoints. --- docs/onebox.run-v1.schema.json | 5 +++ internal/app/jsonschema.go | 2 ++ internal/app/jsonschema_test.go | 26 ++++++++++++++ internal/proxy/proxy_test.go | 32 +++++++++++++++++ site/public/onebox.run-v1.schema.json | 5 +++ .../content/docs/reference/project-file.mdx | 35 +++++++++++++++++++ 6 files changed, 105 insertions(+) diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json index 2f57c964..bb6820d3 100644 --- a/docs/onebox.run-v1.schema.json +++ b/docs/onebox.run-v1.schema.json @@ -1184,12 +1184,17 @@ "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 declared proxy.config must define matching Traefik entrypoints.", + "propertyNames": { + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" + }, "type": "object" }, "image": { diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index 032963cf..cd15dc6a 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -432,6 +432,8 @@ var schemaConstraints = []struct { {[]string{"proxy", "kind"}, enum(eProxyKind)}, {[]string{"proxy", "image"}, pattern(gImageRef)}, {[]string{"proxy", "config"}, pattern(gRepoPath)}, + {[]string{"proxy", "entrypoints"}, propertyNames(gIdent)}, + {[]string{"proxy", "entrypoints", "*", "port"}, portBounds()}, {[]string{"deployment", "migration_policy"}, enum(eMigrationPolicy)}, {[]string{"deployment", "retain_releases"}, map[string]any{"minimum": 1}}, {[]string{"registries", "*", "server"}, pattern(gRegistryHost)}, diff --git a/internal/app/jsonschema_test.go b/internal/app/jsonschema_test.go index 67249c19..34f3c764 100644 --- a/internal/app/jsonschema_test.go +++ b/internal/app/jsonschema_test.go @@ -147,6 +147,32 @@ 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" + + for _, tc := range []struct { + name string + entrypoint string + valid bool + }{ + {name: "valid", entrypoint: " otlp-grpc: {port: 4317}\n", valid: true}, + {name: "invalid name", entrypoint: " OTLP: {port: 4317}\n"}, + {name: "port below range", entrypoint: " otlp: {port: 0}\n"}, + {name: "port above range", entrypoint: " otlp: {port: 70000}\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := schema.Validate(asJSON(t, base+tc.entrypoint)) + if tc.valid && err != nil { + t.Fatalf("valid proxy entrypoint rejected: %v", err) + } + if !tc.valid && err == nil { + t.Fatal("invalid proxy entrypoint accepted") + } + }) + } +} + func TestPublishedSchemaDocumentsEveryPublicField(t *testing.T) { body, err := JSONSchema() if err != nil { diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 4ff4936e..6e83501d 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -69,6 +69,38 @@ func TestRenderCompose(t *testing.T) { } } +func TestDefaultProxyRenderingRemainsStable(t *testing.T) { + const legacyCompose = `# generated by onebox — do not edit (ob proxy apply owns this file) +services: + proxy: + container_name: onebox-proxy + image: traefik:v3.7 + restart: unless-stopped + ports: ["80:80", "443:443"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config:/etc/traefik:ro + - ./acme:/letsencrypt + healthcheck: + test: ["CMD", "traefik", "healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + networks: [ingress] +networks: + ingress: + name: ob-ingress +` + + if got := string(RenderCompose("", "", false, nil)); got != legacyCompose { + t.Fatalf("projects without additional entrypoints must keep the legacy Compose output:\n%s", got) + } + if got := string(renderStaticConfig(nil)); got != DefaultStaticConfig { + t.Fatalf("projects without additional entrypoints must keep the legacy static config:\n%s", got) + } +} + func TestRenderAdditionalEntrypoints(t *testing.T) { entrypoints := map[string]app.ProxyEntrypoint{ "otlp-http": {Port: 4318}, diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json index 2f57c964..bb6820d3 100644 --- a/site/public/onebox.run-v1.schema.json +++ b/site/public/onebox.run-v1.schema.json @@ -1184,12 +1184,17 @@ "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 declared proxy.config must define matching Traefik entrypoints.", + "propertyNames": { + "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" + }, "type": "object" }, "image": { diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 28873c60..ed866025 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -136,6 +136,41 @@ external `proxy.network` may be changed, but `default` is reserved for the application's own Compose network. The derived `_default` and `ob_` names are reserved too; routed projects must use a distinct ingress network. +### Additional proxy entrypoints + +Declare an entrypoint when clients must reach the managed proxy on a port other +than HTTP `80` or HTTPS `443`. Name the same entrypoint on the route that should +receive that traffic: + +```yaml +proxy: + entrypoints: + otlp-grpc: {port: 4317} + otlp-http: {port: 4318} + +workloads: + telemetry: + image: ghcr.io/acme/telemetry-gateway:1.0.0 + routes: + - domain: telemetry.example.com + entrypoint: otlp-grpc + port: 4317 + scheme: h2c + - domain: telemetry.example.com + entrypoint: otlp-http + port: 4318 +``` + +Each declared port is published on the host by the managed proxy. Use this for +remote clients; workloads on the same host can communicate over Onebox's +internal networks without opening another host port. The route's `port` is the +workload's listening port, while `entrypoint` selects the proxy listener. + +When Onebox writes the static proxy configuration, it also writes these named +entrypoints. If `proxy.config` supplies your own static configuration, Onebox +still publishes the ports, but that configuration must define matching +entrypoint names and addresses. + ### Route middleware Attach dynamic proxy middleware to the exact route that needs it with an