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
25 changes: 25 additions & 0 deletions docs/onebox.run-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,31 @@
"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
],
"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": {
"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,}))?$",
Expand Down
2 changes: 2 additions & 0 deletions internal/app/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down
26 changes: 26 additions & 0 deletions internal/app/jsonschema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions internal/app/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
40 changes: 40 additions & 0 deletions internal/app/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions internal/app/purity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 11 additions & 6 deletions internal/app/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions internal/app/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 3 additions & 3 deletions internal/engine/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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'") {
Expand Down Expand Up @@ -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'") {
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/proxystatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/proxystatus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
56 changes: 45 additions & 11 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -183,17 +203,31 @@ entryPoints:
entryPoint: {to: websecure, scheme: https}
websecure:
address: ":443"
certificatesResolvers:
`

const defaultStaticConfigFooter = `certificatesResolvers:
letsencrypt:
acme:
storage: /letsencrypt/acme.json
httpChallenge:
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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -254,20 +288,20 @@ 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
}
h := sha256.New()
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
}
Expand Down
Loading