From 28913b99b37734170faf16e3518b4bf29e6bca49 Mon Sep 17 00:00:00 2001 From: Malico Date: Thu, 18 Jun 2026 17:47:53 +0100 Subject: [PATCH 1/2] feat: add bundled nginx image --- Dockerfile | 2 +- Makefile | 44 +++++- README.md | 7 + cmd/docker-release/bundled.go | 187 +++++++++++++++++++++++ cmd/docker-release/bundled_disabled.go | 26 ++++ cmd/docker-release/bundled_nginx.go | 180 ++++++++++++++++++++++ cmd/docker-release/bundled_nginx_test.go | 56 +++++++ cmd/docker-release/main.go | 11 ++ dockerfiles/nginx.Dockerfile | 32 ++++ docs/providers/nginx.md | 74 +++++++++ docs/readme.md | 29 +++- internal/config/labels.go | 24 ++- internal/config/labels_test.go | 70 +++++++++ internal/controller/configsync.go | 30 ++-- internal/docker/client.go | 11 +- internal/docker/client_test.go | 58 +++++++ internal/provider/factory.go | 2 +- internal/provider/nginx.go | 59 ++++++- internal/provider/nginx_test.go | 30 +++- packaging/nginx/nginx.conf | 38 +++++ tests/nginx-bundled/docker-compose.yml | 55 +++++++ 21 files changed, 1000 insertions(+), 25 deletions(-) create mode 100644 cmd/docker-release/bundled.go create mode 100644 cmd/docker-release/bundled_disabled.go create mode 100644 cmd/docker-release/bundled_nginx.go create mode 100644 cmd/docker-release/bundled_nginx_test.go create mode 100644 dockerfiles/nginx.Dockerfile create mode 100644 packaging/nginx/nginx.conf create mode 100644 tests/nginx-bundled/docker-compose.yml diff --git a/Dockerfile b/Dockerfile index f7eb809..aaff6a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o /bin/dr ./cmd/docker-release/ -FROM alpine:3.21 +FROM alpine:3.21 AS runtime RUN apk add --no-cache ca-certificates COPY --from=builder /bin/dr /usr/local/bin/dr LABEL org.opencontainers.image.title="docker-release" diff --git a/Makefile b/Makefile index 1be7d44..d3aa349 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,33 @@ VERSION ?= $(shell v=$$(git tag --points-at HEAD 2>/dev/null | head -1); echo $${v:-dev}) IMAGE ?= malico/docker-release +NGINX_IMAGE ?= $(IMAGE)-nginx # Bump type passed as a goal: make publish major|minor|fix BUMP := $(filter major minor fix,$(MAKECMDGOALS)) -.PHONY: dev dev-remove test build publish tag buildx-builder \ +.PHONY: dev dev-remove test build build-regular build-nginx publish tag buildx-builder \ major minor fix print-version \ - up-nginx up-angie up-traefik up-nginx-proxy up-caddy up-haproxy \ - down-nginx down-angie down-traefik down-nginx-proxy down-caddy down-haproxy + up-nginx up-nginx-bundled up-angie up-traefik up-nginx-proxy up-caddy up-haproxy \ + down-nginx down-nginx-bundled down-angie down-traefik down-nginx-proxy down-caddy down-haproxy -build: buildx-builder - docker buildx build \ - --builder docker-release-builder \ - --platform linux/amd64,linux/arm64 \ +build: build-regular build-nginx + +build-regular: + docker build \ --build-arg VERSION=$(VERSION) \ -t $(IMAGE):$(VERSION) \ -t $(IMAGE):latest \ . +build-nginx: + docker build \ + -f dockerfiles/nginx.Dockerfile \ + --build-arg BASE_IMAGE=$(IMAGE):$(VERSION) \ + --build-arg VERSION=$(VERSION) \ + -t $(NGINX_IMAGE):$(VERSION) \ + -t $(NGINX_IMAGE):latest \ + . + tag: @test "$(VERSION)" != "dev" || (echo "ERROR: set VERSION=x.y.z"; exit 1) @git fetch --tags origin 2>/dev/null; \ @@ -76,11 +86,23 @@ else docker buildx build \ --builder docker-release-builder \ --platform linux/amd64,linux/arm64 \ + --target runtime \ --build-arg VERSION=$(VERSION) \ -t $(IMAGE):$(VERSION) \ -t $(IMAGE):latest \ $${major:+-t $(IMAGE):$$major} \ --push \ + . && \ + docker buildx build \ + --builder docker-release-builder \ + --platform linux/amd64,linux/arm64 \ + -f dockerfiles/nginx.Dockerfile \ + --build-arg BASE_IMAGE=$(IMAGE):$(VERSION) \ + --build-arg VERSION=$(VERSION) \ + -t $(NGINX_IMAGE):$(VERSION) \ + -t $(NGINX_IMAGE):latest \ + $${major:+-t $(NGINX_IMAGE):$$major} \ + --push \ . && $(MAKE) tag endif @@ -101,10 +123,15 @@ dev-remove: test: go test ./... + go test -tags bundled_nginx ./cmd/docker-release up-nginx: docker compose -f tests/nginx/docker-compose.yml up --build +up-nginx-bundled: + $(MAKE) build-regular IMAGE=docker-release VERSION=local + docker compose -f tests/nginx-bundled/docker-compose.yml up --build + up-angie: docker compose -f tests/angie/docker-compose.yml up --build @@ -123,6 +150,9 @@ up-haproxy: down-nginx: docker compose -f tests/nginx/docker-compose.yml down -v +down-nginx-bundled: + docker compose -f tests/nginx-bundled/docker-compose.yml down -v + down-angie: docker compose -f tests/angie/docker-compose.yml down -v diff --git a/README.md b/README.md index 26bf177..2497653 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ Zero-downtime deploys for Docker Compose. Starts new containers, waits for healt Your proxy still serves all traffic. `docker-release` only manages the container lifecycle and proxy config. +## Images + +| Image | Use | +|---|---| +| `malico/docker-release` | controller-only sidecar | +| `malico/docker-release-nginx` | draft bundled Nginx + controller | + ## Pick Your Proxy | Proxy | Guide | diff --git a/cmd/docker-release/bundled.go b/cmd/docker-release/bundled.go new file mode 100644 index 0000000..162dbbc --- /dev/null +++ b/cmd/docker-release/bundled.go @@ -0,0 +1,187 @@ +//go:build bundled_nginx + +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/malico/docker-release/internal/health" +) + +type bundledProxy struct { + name string + args []string +} + +func bundledProxyFromEnv() (*bundledProxy, error) { + v := strings.ToLower(strings.TrimSpace(os.Getenv("DR_BUNDLED_PROXY"))) + switch v { + case "", "none": + return nil, nil + case "nginx": + return &bundledProxy{name: "nginx", args: []string{"nginx", "-g", "daemon off;"}}, nil + default: + return nil, fmt.Errorf("unknown DR_BUNDLED_PROXY %q", os.Getenv("DR_BUNDLED_PROXY")) + } +} + +func runWithBundledProxy(ctx context.Context, proxy *bundledProxy, run func(context.Context) error) error { + if proxy == nil { + return run(ctx) + } + + _ = health.ClearReady() + _ = clearBundledProxyStarted(proxy) + if err := prepareBundledProxyConfig(proxy); err != nil { + return err + } + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + controllerErr := make(chan error, 1) + go func() { + controllerErr <- run(runCtx) + }() + + if err := waitForInitialConfigs(ctx, controllerErr); err != nil { + cancel() + if ctx.Err() != nil { + return nil + } + return err + } + + cmd := exec.Command(proxy.args[0], proxy.args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + cancel() + return fmt.Errorf("starting bundled %s: %w", proxy.name, err) + } + if err := markBundledProxyStarted(proxy); err != nil { + cancel() + stopBundledProxy(cmd, proxy, nil) + return err + } + slog.Info("started bundled proxy", "component", "main", "proxy", proxy.name) + + proxyErr := make(chan error, 1) + go func() { + proxyErr <- cmd.Wait() + }() + + select { + case err := <-controllerErr: + cancel() + stopBundledProxy(cmd, proxy, proxyErr) + return err + case err := <-proxyErr: + cancel() + <-controllerErr + if err != nil { + return fmt.Errorf("bundled %s exited: %w", proxy.name, err) + } + return fmt.Errorf("bundled %s exited", proxy.name) + case <-ctx.Done(): + cancel() + stopBundledProxy(cmd, proxy, proxyErr) + if err := <-controllerErr; err != nil { + return err + } + return nil + } +} + +func prepareBundledProxyConfig(proxy *bundledProxy) error { + switch proxy.name { + case "nginx": + return prepareBundledNginxConfig() + default: + return nil + } +} + +func bundledProxyStartedPath(proxy *bundledProxy) string { + return filepath.Join("/run/docker-release", proxy.name+".started") +} + +func clearBundledProxyStarted(proxy *bundledProxy) error { + err := os.Remove(bundledProxyStartedPath(proxy)) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func markBundledProxyStarted(proxy *bundledProxy) error { + path := bundledProxyStartedPath(proxy) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, nil, 0o644) +} + +func waitForInitialConfigs(ctx context.Context, controllerErr <-chan error) error { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + if health.IsReady() { + return nil + } + + select { + case err := <-controllerErr: + if err != nil { + return err + } + return fmt.Errorf("controller exited before bundled proxy started") + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func stopBundledProxy(cmd *exec.Cmd, proxy *bundledProxy, proxyErr <-chan error) { + if cmd.Process == nil { + return + } + + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + slog.Warn("could not stop bundled proxy", "component", "main", "proxy", proxy.name, "err", err) + return + } + + if proxyErr == nil { + done := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + <-done + } + return + } + + select { + case <-proxyErr: + return + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + <-proxyErr + } +} diff --git a/cmd/docker-release/bundled_disabled.go b/cmd/docker-release/bundled_disabled.go new file mode 100644 index 0000000..eee3643 --- /dev/null +++ b/cmd/docker-release/bundled_disabled.go @@ -0,0 +1,26 @@ +//go:build !bundled_nginx + +package main + +import ( + "context" + "fmt" + "os" + "strings" +) + +type bundledProxy struct{} + +func bundledProxyFromEnv() (*bundledProxy, error) { + v := strings.ToLower(strings.TrimSpace(os.Getenv("DR_BUNDLED_PROXY"))) + switch v { + case "", "none": + return nil, nil + default: + return nil, fmt.Errorf("DR_BUNDLED_PROXY=%s requires a bundled dr build", v) + } +} + +func runWithBundledProxy(ctx context.Context, _ *bundledProxy, run func(context.Context) error) error { + return run(ctx) +} diff --git a/cmd/docker-release/bundled_nginx.go b/cmd/docker-release/bundled_nginx.go new file mode 100644 index 0000000..6fd21eb --- /dev/null +++ b/cmd/docker-release/bundled_nginx.go @@ -0,0 +1,180 @@ +//go:build bundled_nginx + +package main + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +const bundledNginxConfigPath = "/etc/nginx/nginx.conf" + +type bundledNginxConfig struct { + HTTPPort int + HTTPSPort int + ServerName string + SSLCert string + SSLKey string + RedirectHTTPS bool +} + +func prepareBundledNginxConfig() error { + if truthy(os.Getenv("DR_NGINX_SKIP_CONFIG")) { + return nil + } + + cfg, err := bundledNginxConfigFromEnv() + if err != nil { + return err + } + + return os.WriteFile(bundledNginxConfigPath, []byte(renderBundledNginxConfig(cfg)), 0o644) +} + +func bundledNginxConfigFromEnv() (bundledNginxConfig, error) { + cfg := bundledNginxConfig{ + HTTPPort: 80, + HTTPSPort: 443, + ServerName: envOr("DR_NGINX_SERVER_NAME", "_"), + SSLCert: strings.TrimSpace(os.Getenv("DR_NGINX_SSL_CERT")), + SSLKey: strings.TrimSpace(os.Getenv("DR_NGINX_SSL_KEY")), + } + + var err error + cfg.HTTPPort, err = envPort("DR_NGINX_HTTP_PORT", cfg.HTTPPort) + if err != nil { + return bundledNginxConfig{}, err + } + cfg.HTTPSPort, err = envPort("DR_NGINX_HTTPS_PORT", cfg.HTTPSPort) + if err != nil { + return bundledNginxConfig{}, err + } + cfg.RedirectHTTPS = truthy(os.Getenv("DR_NGINX_REDIRECT_HTTPS")) + + for name, value := range map[string]string{ + "DR_NGINX_SERVER_NAME": cfg.ServerName, + "DR_NGINX_SSL_CERT": cfg.SSLCert, + "DR_NGINX_SSL_KEY": cfg.SSLKey, + } { + if err := safeNginxValue(name, value); err != nil { + return bundledNginxConfig{}, err + } + } + + if cfg.RedirectHTTPS && !cfg.sslEnabled() { + return bundledNginxConfig{}, fmt.Errorf("DR_NGINX_REDIRECT_HTTPS requires DR_NGINX_SSL_CERT and DR_NGINX_SSL_KEY") + } + + return cfg, nil +} + +func (c bundledNginxConfig) sslEnabled() bool { + return c.SSLCert != "" && c.SSLKey != "" +} + +func renderBundledNginxConfig(cfg bundledNginxConfig) string { + var b strings.Builder + + b.WriteString("user nginx;\n") + b.WriteString("worker_processes auto;\n") + b.WriteString("error_log /dev/stderr notice;\n") + b.WriteString("pid /run/nginx/nginx.pid;\n\n") + b.WriteString("events {\n") + b.WriteString(" worker_connections 1024;\n") + b.WriteString("}\n\n") + b.WriteString("http {\n") + b.WriteString(" include /etc/nginx/mime.types;\n") + b.WriteString(" default_type application/octet-stream;\n\n") + b.WriteString(" log_format main '$remote_addr - $remote_user [$time_local] \"$request\" '\n") + b.WriteString(" '$status $body_bytes_sent \"$http_referer\" '\n") + b.WriteString(" '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n") + b.WriteString(" access_log /dev/stdout main;\n\n") + b.WriteString(" sendfile on;\n") + b.WriteString(" keepalive_timeout 65;\n\n") + b.WriteString(" include /shared/nginx-config/*.conf;\n") + b.WriteString(" include /etc/docker-release/nginx/http.d/*.conf;\n") + b.WriteString(" include /etc/docker-release/nginx/conf.d/*.conf;\n\n") + + renderBundledNginxServer(&b, cfg, false) + if cfg.sslEnabled() { + b.WriteString("\n") + renderBundledNginxServer(&b, cfg, true) + } + + b.WriteString("}\n") + return b.String() +} + +func renderBundledNginxServer(b *strings.Builder, cfg bundledNginxConfig, ssl bool) { + b.WriteString(" server {\n") + if ssl { + fmt.Fprintf(b, " listen %d ssl default_server;\n", cfg.HTTPSPort) + } else { + fmt.Fprintf(b, " listen %d default_server;\n", cfg.HTTPPort) + } + fmt.Fprintf(b, " server_name %s;\n\n", cfg.ServerName) + + if ssl { + fmt.Fprintf(b, " ssl_certificate %s;\n", cfg.SSLCert) + fmt.Fprintf(b, " ssl_certificate_key %s;\n", cfg.SSLKey) + b.WriteString(" ssl_session_cache shared:SSL:10m;\n") + b.WriteString(" ssl_session_timeout 10m;\n") + b.WriteString(" include /etc/docker-release/nginx/ssl.d/*.conf;\n\n") + } + + b.WriteString(" location = /health {\n") + b.WriteString(" add_header Content-Type text/plain;\n") + b.WriteString(" return 200 \"ok\\n\";\n") + b.WriteString(" }\n\n") + + if !ssl && cfg.RedirectHTTPS { + b.WriteString(" location / {\n") + b.WriteString(" return 308 https://$host$request_uri;\n") + b.WriteString(" }\n") + } else { + b.WriteString(" include /shared/nginx-routes/*.location;\n") + b.WriteString(" include /etc/docker-release/nginx/server.d/*.conf;\n") + if ssl { + b.WriteString(" include /etc/docker-release/nginx/https.d/*.conf;\n") + } + } + + b.WriteString(" }\n") +} + +func envOr(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func envPort(key string, fallback int) (int, error) { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback, nil + } + port, err := strconv.Atoi(v) + if err != nil || port < 1 || port > 65535 { + return 0, fmt.Errorf("%s must be a port 1-65535", key) + } + return port, nil +} + +func truthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func safeNginxValue(name, value string) error { + if strings.ContainsAny(value, ";{}\n\r\t\"") { + return fmt.Errorf("%s contains invalid nginx config characters", name) + } + return nil +} diff --git a/cmd/docker-release/bundled_nginx_test.go b/cmd/docker-release/bundled_nginx_test.go new file mode 100644 index 0000000..2c8883e --- /dev/null +++ b/cmd/docker-release/bundled_nginx_test.go @@ -0,0 +1,56 @@ +//go:build bundled_nginx + +package main + +import ( + "strings" + "testing" +) + +func TestRenderBundledNginxConfigHTTPOnly(t *testing.T) { + cfg := bundledNginxConfig{HTTPPort: 8080, HTTPSPort: 443, ServerName: "_"} + + got := renderBundledNginxConfig(cfg) + + if !strings.Contains(got, "listen 8080 default_server;") { + t.Error("missing HTTP listener") + } + if strings.Contains(got, "ssl_certificate") { + t.Error("HTTP-only config should not include SSL directives") + } + if !strings.Contains(got, "include /shared/nginx-routes/*.location;") { + t.Error("missing generated route include") + } +} + +func TestRenderBundledNginxConfigHTTPS(t *testing.T) { + cfg := bundledNginxConfig{ + HTTPPort: 80, + HTTPSPort: 8443, + ServerName: "example.com", + SSLCert: "/certs/fullchain.pem", + SSLKey: "/certs/privkey.pem", + RedirectHTTPS: true, + } + + got := renderBundledNginxConfig(cfg) + + if !strings.Contains(got, "listen 8443 ssl default_server;") { + t.Error("missing HTTPS listener") + } + if !strings.Contains(got, "ssl_certificate /certs/fullchain.pem;") { + t.Error("missing SSL cert") + } + if !strings.Contains(got, "return 308 https://$host$request_uri;") { + t.Error("missing HTTP to HTTPS redirect") + } +} + +func TestBundledNginxConfigRejectsRedirectWithoutCert(t *testing.T) { + t.Setenv("DR_NGINX_REDIRECT_HTTPS", "true") + + _, err := bundledNginxConfigFromEnv() + if err == nil { + t.Fatal("expected redirect without cert/key to fail") + } +} diff --git a/cmd/docker-release/main.go b/cmd/docker-release/main.go index a06416c..75837ca 100644 --- a/cmd/docker-release/main.go +++ b/cmd/docker-release/main.go @@ -234,6 +234,17 @@ func cmdWatch(ctrl *controller.Controller) error { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + proxy, err := bundledProxyFromEnv() + if err != nil { + return err + } + + return runWithBundledProxy(ctx, proxy, func(ctx context.Context) error { + return runWatch(ctx, ctrl) + }) +} + +func runWatch(ctx context.Context, ctrl *controller.Controller) error { cfg := server.ConfigFromEnv() cfg.Version = version if cfg.APIEnabled || cfg.WebEnabled { diff --git a/dockerfiles/nginx.Dockerfile b/dockerfiles/nginx.Dockerfile new file mode 100644 index 0000000..d616e56 --- /dev/null +++ b/dockerfiles/nginx.Dockerfile @@ -0,0 +1,32 @@ +ARG BASE_IMAGE=malico/docker-release:latest + +FROM golang:1.24-alpine AS builder +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags bundled_nginx -ldflags="-s -w -X main.version=${VERSION}" -o /bin/dr ./cmd/docker-release/ + +FROM ${BASE_IMAGE} + +RUN apk add --no-cache nginx \ + && mkdir -p /run/nginx /shared/nginx-config /shared/nginx-routes \ + /etc/docker-release/nginx/conf.d /etc/docker-release/nginx/http.d \ + /etc/docker-release/nginx/server.d /etc/docker-release/nginx/ssl.d \ + /etc/docker-release/nginx/https.d + +COPY --from=builder /bin/dr /usr/local/bin/dr +COPY packaging/nginx/nginx.conf /etc/nginx/nginx.conf + +LABEL org.opencontainers.image.title="docker-release" \ + com.malico.docker-release.bundled-proxy="nginx" + +ENV DR_BUNDLED_PROXY=nginx \ + DR_DEFAULT_PROVIDER=nginx + +EXPOSE 80 443 9080 9081 +HEALTHCHECK --interval=5s --timeout=2s --start-period=30s --retries=6 \ + CMD ["dr", "healthcheck"] +ENTRYPOINT ["dr"] +CMD ["watch"] diff --git a/docs/providers/nginx.md b/docs/providers/nginx.md index c6677ae..1eada42 100644 --- a/docs/providers/nginx.md +++ b/docs/providers/nginx.md @@ -58,6 +58,76 @@ volumes: nginx-config: ``` +## Bundled Image (Draft) + +Use `malico/docker-release-nginx` when you want one service instead of a controller sidecar plus a separate Nginx service. + +```yaml +services: + docker-release: + image: malico/docker-release-nginx:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - docker-release-state:/var/lib/docker-release + + app: + image: your-registry/app:latest + labels: + release.enable: "true" + release.nginx.path: "/" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/health"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + docker-release-state: +``` + +`docker-release-nginx` sets `DR_DEFAULT_PROVIDER=nginx`, so app services can omit `release.provider`. Explicit `release.provider` labels still win. + +The base `malico/docker-release` image does not include bundled Nginx runtime code. This image overlays a `dr` binary built with the `bundled_nginx` tag. + +Publish ports only when this container should receive traffic directly: + +```yaml +ports: + - "80:80" + - "443:443" +``` + +Enable HTTPS by mounting your cert/key and pointing Nginx at them: + +```yaml +services: + docker-release: + image: malico/docker-release-nginx:latest + environment: + DR_NGINX_SERVER_NAME: example.com + DR_NGINX_SSL_CERT: /certs/fullchain.pem + DR_NGINX_SSL_KEY: /certs/privkey.pem + DR_NGINX_REDIRECT_HTTPS: "true" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./certs:/certs:ro +``` + +Custom config paths: + +| Path | Purpose | +|---|---| +| `/shared/nginx-config/*.conf` | generated upstreams, managed by `docker-release` | +| `/shared/nginx-routes/*.location` | generated `release.nginx.path` routes | +| `/etc/docker-release/nginx/http.d/*.conf` | custom `http` context snippets | +| `/etc/docker-release/nginx/conf.d/*.conf` | custom top-level `http` snippets, including extra `server` blocks | +| `/etc/docker-release/nginx/server.d/*.conf` | custom snippets inside the generated HTTP/HTTPS server | +| `/etc/docker-release/nginx/ssl.d/*.conf` | custom snippets inside the generated HTTPS server before routes | +| `/etc/docker-release/nginx/https.d/*.conf` | custom snippets inside the generated HTTPS server after routes | +| `/etc/nginx/nginx.conf` | full bundled config override; set `DR_NGINX_SKIP_CONFIG=true` | + +The bundled image waits for initial upstream and route files before starting Nginx. This lets custom mounted config reference generated upstreams like `app_upstream` on first boot. + ## Nginx Config ```nginx @@ -84,6 +154,8 @@ release.enable: "true" release.provider: nginx ``` +With `malico/docker-release-nginx`, `release.provider` is optional because the image sets `DR_DEFAULT_PROVIDER=nginx`. + ## Deploy ```sh @@ -97,6 +169,8 @@ docker release app |---|---|---| | `release.nginx.service` | auto-detected | multiple Nginx containers in the project | | `release.nginx.config_dir` | `/shared/nginx-config` | volume mounted at a different path | +| `release.nginx.route_dir` | `/shared/nginx-routes` | generated `release.nginx.path` route files need a different path | +| `release.nginx.path` | empty | bundled Nginx should generate a route for this service | ## Multiple Apps diff --git a/docs/readme.md b/docs/readme.md index 034c8e1..8e9c42d 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -14,6 +14,17 @@ When you run `docker release app`, it: It never touches traffic directly. Your proxy — nginx-proxy, Nginx, Caddy, Traefik, HAProxy, or Angie — serves all requests. +## Images + +| Image | Use | +|---|---| +| `malico/docker-release` | controller-only sidecar; bring your own proxy service | +| `malico/docker-release-nginx` | draft bundled Nginx + controller image | + +The bundled Nginx image keeps the same `dr` entrypoint, so `docker release help` and all host plugin commands still work. + +The controller-only image is built without bundled proxy code. Bundled images overlay a `dr` binary built with the provider-specific build tag, so dormant proxy code does not ship in the default runtime. + ## Deploy Strategies Three strategies control how traffic shifts from old to new containers during a deploy. @@ -74,18 +85,20 @@ release.affinity: cookie # sticky sessions (Angie, Caddy, HAProxy, Traefik only | Label | Value | |---|---| | `release.enable` | `"true"` — marks this service for management | -| `release.provider` | `nginx-proxy`, `nginx`, `caddy`, `traefik`, `angie`, `haproxy`, or `none` | ### Common | Label | Default | Description | |---|---|---| | `release.strategy` | `linear` | Deploy strategy: `linear`, `blue-green`, or `canary` | +| `release.provider` | `nginx-proxy` or `DR_DEFAULT_PROVIDER` | `nginx-proxy`, `nginx`, `caddy`, `traefik`, `angie`, `haproxy`, or `none` | | `release.health_check_timeout` | `60s` | Max time to wait for a new container to become healthy | | `release.drain_timeout` | `10s` | Time to wait for in-flight requests before stopping old containers | | `release.affinity` | `ip` | Session affinity: `ip`, `cookie`, or empty | | `release.upstream` | service name | Override the upstream name used in proxy config | +`DR_DEFAULT_PROVIDER` can set the provider default for a controller image. `malico/docker-release-nginx` sets it to `nginx`, so managed app services only need `release.enable: "true"` unless they need an override. + ### Strategy Labels | Label | Default | Description | @@ -102,6 +115,8 @@ release.affinity: cookie # sticky sessions (Angie, Caddy, HAProxy, Traefik only |---|---|---| | `release.nginx.service` | auto-detected | Compose service name of Nginx in this stack | | `release.nginx.config_dir` | `/shared/nginx-config` | Shared volume path for Nginx upstream files | +| `release.nginx.route_dir` | `/shared/nginx-routes` | Bundled Nginx route snippet path | +| `release.nginx.path` | empty | Bundled Nginx route path for this service | | `release.angie.service` | auto-detected | Compose service name of Angie | | `release.angie.config_dir` | `/shared/angie-config` | Shared volume path for Angie upstream files | | `release.caddy.service` | auto-detected | Compose service name of Caddy | @@ -111,6 +126,18 @@ release.affinity: cookie # sticky sessions (Angie, Caddy, HAProxy, Traefik only | `release.traefik.config_dir` | `/shared/traefik-config` | Shared volume path for Traefik dynamic config files | | `release.nginx_proxy.config_dir` | `/shared/nginx-tmpl` | Shared volume path for the nginx-proxy template | +### Bundled Nginx Env + +| Env var | Default | Description | +|---|---|---| +| `DR_NGINX_HTTP_PORT` | `80` | HTTP listen port inside the container | +| `DR_NGINX_HTTPS_PORT` | `443` | HTTPS listen port inside the container | +| `DR_NGINX_SERVER_NAME` | `_` | Generated Nginx `server_name` | +| `DR_NGINX_SSL_CERT` | empty | Mounted certificate path; enables HTTPS with `DR_NGINX_SSL_KEY` | +| `DR_NGINX_SSL_KEY` | empty | Mounted key path; enables HTTPS with `DR_NGINX_SSL_CERT` | +| `DR_NGINX_REDIRECT_HTTPS` | off | Redirect HTTP to HTTPS; requires cert/key | +| `DR_NGINX_SKIP_CONFIG` | off | Use a fully mounted `/etc/nginx/nginx.conf` instead of generated config | + ## Health Checks Every managed service needs a Docker health check. `docker-release` waits for `healthy` before it sends traffic to a new container. Without a health check, Docker never reports `healthy` and the deploy stalls. diff --git a/internal/config/labels.go b/internal/config/labels.go index 2a42fbd..bcf6e2c 100644 --- a/internal/config/labels.go +++ b/internal/config/labels.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "os" "regexp" "strconv" "strings" @@ -9,6 +10,7 @@ import ( ) var validUpstreamName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) +var validRoutePath = regexp.MustCompile(`^/[a-zA-Z0-9._~/%-]*$`) type Strategy string @@ -39,6 +41,7 @@ type ServiceConfig struct { Affinity string NginxService string NginxConfigDir string + NginxRouteDir string NginxKeepalive int AngieService string AngieConfigDir string @@ -51,6 +54,7 @@ type ServiceConfig struct { HAProxyService string HAProxyConfigDir string UpstreamName string + NginxPath string BlueGreen BlueGreenConfig Canary CanaryConfig @@ -74,13 +78,14 @@ func ParseLabels(labels map[string]string) (*ServiceConfig, error) { cfg := &ServiceConfig{ Enabled: true, - Provider: ProviderType(getOr(labels, "release.provider", "nginx-proxy")), + Provider: ProviderType(getOr(labels, "release.provider", defaultProvider())), Strategy: Strategy(getOr(labels, "release.strategy", "linear")), HealthCheckTimeout: parseDurationOr(labels, "release.health_check_timeout", 60*time.Second), DrainTimeout: parseDurationOr(labels, "release.drain_timeout", 10*time.Second), Affinity: resolveAffinity(labels), NginxService: getOr(labels, "release.nginx.service", ""), NginxConfigDir: getOr(labels, "release.nginx.config_dir", ""), + NginxRouteDir: getOr(labels, "release.nginx.route_dir", ""), NginxKeepalive: parseIntOr(labels, "release.nginx.keepalive", -1), AngieService: getOr(labels, "release.angie.service", ""), AngieConfigDir: getOr(labels, "release.angie.config_dir", ""), @@ -93,6 +98,7 @@ func ParseLabels(labels map[string]string) (*ServiceConfig, error) { HAProxyService: getOr(labels, "release.haproxy.service", ""), HAProxyConfigDir: getOr(labels, "release.haproxy.config_dir", ""), UpstreamName: getOr(labels, "release.upstream", ""), + NginxPath: getOr(labels, "release.nginx.path", ""), BlueGreen: BlueGreenConfig{ SoakTime: parseDurationOr(labels, "release.bg.soak_time", 5*time.Minute), @@ -115,12 +121,22 @@ func ParseLabels(labels map[string]string) (*ServiceConfig, error) { return cfg, nil } +func defaultProvider() string { + if provider := strings.TrimSpace(os.Getenv("DR_DEFAULT_PROVIDER")); provider != "" { + return provider + } + return "nginx-proxy" +} + func applyProviderDefaults(cfg *ServiceConfig) { switch cfg.Provider { case ProviderNginx: if cfg.NginxConfigDir == "" { cfg.NginxConfigDir = "/shared/nginx-config" } + if cfg.NginxRouteDir == "" { + cfg.NginxRouteDir = "/shared/nginx-routes" + } case ProviderNginxProxy: if cfg.NginxConfigDir == "" { cfg.NginxConfigDir = "/shared/nginx-tmpl" @@ -191,7 +207,7 @@ func (c *ServiceConfig) validate() error { return fmt.Errorf("caddy.keepalive must be >= 0, got %d", c.CaddyKeepalive) } - for _, dir := range []string{c.NginxConfigDir, c.AngieConfigDir, c.TraefikConfigDir, c.CaddyConfigDir, c.HAProxyConfigDir} { + for _, dir := range []string{c.NginxConfigDir, c.NginxRouteDir, c.AngieConfigDir, c.TraefikConfigDir, c.CaddyConfigDir, c.HAProxyConfigDir} { if containsDotDot(dir) { return fmt.Errorf("config_dir must not contain '..' path components") } @@ -201,6 +217,10 @@ func (c *ServiceConfig) validate() error { return fmt.Errorf("upstream name %q must match [a-zA-Z0-9._-]+", c.UpstreamName) } + if c.NginxPath != "" && !validRoutePath.MatchString(c.NginxPath) { + return fmt.Errorf("release.nginx.path %q must start with / and contain only URL path characters", c.NginxPath) + } + return nil } diff --git a/internal/config/labels_test.go b/internal/config/labels_test.go index 3ecd3aa..ae0f051 100644 --- a/internal/config/labels_test.go +++ b/internal/config/labels_test.go @@ -19,6 +19,7 @@ func TestParseLabels(t *testing.T) { "release.canary.interval": "1m", "release.nginx.service": "my-nginx", "release.nginx.keepalive": "20", + "release.nginx.path": "/app/", } cfg, err := ParseLabels(labels) @@ -59,6 +60,9 @@ func TestParseLabels(t *testing.T) { if cfg.NginxKeepalive != 20 { t.Errorf("nginx.keepalive = %d, want 20", cfg.NginxKeepalive) } + if cfg.NginxPath != "/app/" { + t.Errorf("nginx_path = %s, want /app/", cfg.NginxPath) + } } func TestParseLabelsDefaults(t *testing.T) { @@ -103,6 +107,44 @@ func TestParseLabelsDefaults(t *testing.T) { } } +func TestParseLabelsDefaultProviderFromEnv(t *testing.T) { + t.Setenv("DR_DEFAULT_PROVIDER", "nginx") + + labels := map[string]string{ + "release.enable": "true", + } + + cfg, err := ParseLabels(labels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Provider != ProviderNginx { + t.Errorf("default provider = %s, want nginx", cfg.Provider) + } + if cfg.NginxConfigDir != "/shared/nginx-config" { + t.Errorf("nginx_config_dir = %s, want /shared/nginx-config", cfg.NginxConfigDir) + } +} + +func TestParseLabelsExplicitProviderOverridesEnvDefault(t *testing.T) { + t.Setenv("DR_DEFAULT_PROVIDER", "nginx") + + labels := map[string]string{ + "release.enable": "true", + "release.provider": "caddy", + } + + cfg, err := ParseLabels(labels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Provider != ProviderCaddy { + t.Errorf("provider = %s, want caddy", cfg.Provider) + } +} + func TestProviderDefaults(t *testing.T) { cases := []struct { provider string @@ -134,6 +176,22 @@ func TestProviderDefaults(t *testing.T) { } } +func TestParseLabelsNginxRouteDirDefault(t *testing.T) { + labels := map[string]string{ + "release.enable": "true", + "release.provider": "nginx", + } + + cfg, err := ParseLabels(labels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.NginxRouteDir != "/shared/nginx-routes" { + t.Errorf("nginx_route_dir = %s, want /shared/nginx-routes", cfg.NginxRouteDir) + } +} + func TestProviderDefaultsNotOverridden(t *testing.T) { labels := map[string]string{ "release.enable": "true", @@ -362,6 +420,18 @@ func TestParseLabelsConfigDirTraversal(t *testing.T) { } } +func TestParseLabelsInvalidRoutePath(t *testing.T) { + labels := map[string]string{ + "release.enable": "true", + "release.nginx.path": `/{return 200;}`, + } + + _, err := ParseLabels(labels) + if err == nil { + t.Fatal("expected error for invalid release.nginx.path") + } +} + func TestParseLabelsUpstreamNameInjection(t *testing.T) { labels := map[string]string{ "release.enable": "true", diff --git a/internal/controller/configsync.go b/internal/controller/configsync.go index b817002..42c1d21 100644 --- a/internal/controller/configsync.go +++ b/internal/controller/configsync.go @@ -16,6 +16,11 @@ import ( "github.com/docker/docker/api/types" ) +type configDir struct { + dir string + ext string +} + func (c *Controller) generateInitialConfigs(ctx context.Context, services map[serviceKey][]types.Container) { c.syncServicesConfigs(ctx, services, false, false) } @@ -55,11 +60,6 @@ func (c *Controller) syncServicesConfigs(ctx context.Context, services map[servi } func (c *Controller) cleanStaleConfigs(activeConfigs map[string]*config.ServiceConfig) { - type configDir struct { - dir string - ext string - } - active := make(map[configDir]map[string]bool) for name, cfg := range activeConfigs { @@ -76,11 +76,16 @@ func (c *Controller) cleanStaleConfigs(activeConfigs map[string]*config.ServiceC if dir == "" { continue } - cd := configDir{dir: dir, ext: ext} - if active[cd] == nil { - active[cd] = make(map[string]bool) + markActiveConfig(active, configDir{dir: dir, ext: ext}, name) + if cfg.Provider == config.ProviderNginx && cfg.NginxRouteDir != "" { + cd := configDir{dir: cfg.NginxRouteDir, ext: ".location"} + if active[cd] == nil { + active[cd] = make(map[string]bool) + } + if cfg.NginxPath != "" { + active[cd][name] = true + } } - active[cd][name] = true } for cd, services := range active { @@ -116,6 +121,13 @@ func (c *Controller) cleanStaleConfigs(activeConfigs map[string]*config.ServiceC } } +func markActiveConfig(active map[configDir]map[string]bool, cd configDir, name string) { + if active[cd] == nil { + active[cd] = make(map[string]bool) + } + active[cd][name] = true +} + // supportedInGlobalMode reports whether a provider is safe to manage when one // controller serves every Compose project. Per-service-file providers are not: // each derives its config filename and upstream name from the bare service diff --git a/internal/docker/client.go b/internal/docker/client.go index 462a9d8..cc3ffdd 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -21,6 +21,11 @@ type Client struct { api client.APIClient } +const ( + imageTitleLabel = "org.opencontainers.image.title" + bundledProxyLabel = "com.malico.docker-release.bundled-proxy" +) + func NewClient() (*Client, error) { api, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { @@ -188,7 +193,11 @@ func (c *Client) findContainerByImage(ctx context.Context, project, keyword stri kw := strings.ToLower(keyword) for _, ctr := range containers { - if ctr.Labels["org.opencontainers.image.title"] == "docker-release" { + if strings.ToLower(ctr.Labels[bundledProxyLabel]) == kw { + return ctr, nil + } + + if ctr.Labels[imageTitleLabel] == "docker-release" { continue } diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index 639447e..cf3653c 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -24,6 +24,8 @@ type mockDockerAPI struct { createResult container.CreateResponse createErr error startErr error + listResult []types.Container + listErr error capturedConfig *container.Config capturedHostConfig *container.HostConfig @@ -35,6 +37,10 @@ func (m *mockDockerAPI) ContainerInspect(_ context.Context, _ string) (types.Con return m.inspectResult, nil } +func (m *mockDockerAPI) ContainerList(_ context.Context, _ container.ListOptions) ([]types.Container, error) { + return m.listResult, m.listErr +} + func (m *mockDockerAPI) ContainerCreate(_ context.Context, cfg *container.Config, hostCfg *container.HostConfig, _ *network.NetworkingConfig, _ *ocispec.Platform, _ string) (container.CreateResponse, error) { m.capturedConfig = cfg m.capturedHostConfig = hostCfg @@ -123,6 +129,58 @@ func newClient(mock *mockDockerAPI) *Client { return &Client{api: mock} } +func TestFindContainerByImageSkipsControllerOnlyImage(t *testing.T) { + t.Parallel() + + mock := &mockDockerAPI{listResult: []types.Container{ + { + ID: "controller-id", + Image: "malico/docker-release-nginx:latest", + Labels: map[string]string{ + imageTitleLabel: "docker-release", + }, + }, + { + ID: "nginx-id", + Image: "nginx:alpine", + Labels: map[string]string{}, + }, + }} + + ctr, err := newClient(mock).FindContainerByImage(context.Background(), "", "nginx") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ctr.ID != "nginx-id" { + t.Errorf("container ID = %s, want nginx-id", ctr.ID) + } +} + +func TestFindContainerByImageAllowsBundledProxyImage(t *testing.T) { + t.Parallel() + + mock := &mockDockerAPI{listResult: []types.Container{ + { + ID: "bundled-id", + Image: "malico/docker-release-nginx:latest", + Labels: map[string]string{ + imageTitleLabel: "docker-release", + bundledProxyLabel: "nginx", + }, + }, + }} + + ctr, err := newClient(mock).FindContainerByImage(context.Background(), "", "nginx") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ctr.ID != "bundled-id" { + t.Errorf("container ID = %s, want bundled-id", ctr.ID) + } +} + func TestCreateContainerFromImage_CopiesConfigFields(t *testing.T) { t.Parallel() diff --git a/internal/provider/factory.go b/internal/provider/factory.go index 90be92a..5ebe9fb 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -33,7 +33,7 @@ func NewFactory(dockerClient *docker.Client, project string) *Factory { func (f *Factory) Provider(cfg *config.ServiceConfig) (Provider, error) { switch cfg.Provider { case config.ProviderNginx: - return NewNginx(cfg.NginxConfigDir, f.docker, cfg.NginxService, f.project), nil + return NewNginx(cfg.NginxConfigDir, cfg.NginxRouteDir, cfg.NginxPath, f.docker, cfg.NginxService, f.project), nil case config.ProviderAngie: return NewAngie(cfg.AngieConfigDir, f.docker, cfg.AngieService, f.project), nil case config.ProviderTraefik: diff --git a/internal/provider/nginx.go b/internal/provider/nginx.go index fad6377..0bf0b30 100644 --- a/internal/provider/nginx.go +++ b/internal/provider/nginx.go @@ -13,14 +13,18 @@ import ( type NginxProvider struct { configDir string + routeDir string + routePath string docker *docker.Client serviceName string project string } -func NewNginx(configDir string, dockerClient *docker.Client, serviceName, project string) *NginxProvider { +func NewNginx(configDir, routeDir, routePath string, dockerClient *docker.Client, serviceName, project string) *NginxProvider { return &NginxProvider{ configDir: configDir, + routeDir: routeDir, + routePath: routePath, docker: dockerClient, serviceName: serviceName, project: project, @@ -49,17 +53,52 @@ func (p *NginxProvider) GenerateConfig(ctx context.Context, state *UpstreamState return fmt.Errorf("renaming config: %w", err) } + health.RecordFile(path) + + if err := p.generateRoute(state); err != nil { + return err + } + + return nil +} + +func (p *NginxProvider) generateRoute(state *UpstreamState) error { + if p.routePath == "" { + return nil + } + + if err := os.MkdirAll(p.routeDir, 0o755); err != nil { + return fmt.Errorf("creating route dir: %w", err) + } + + path := filepath.Join(p.routeDir, state.Service+".location") + tmp := path + ".tmp" + + if err := os.WriteFile(tmp, []byte(renderNginxRoute(state, p.routePath)), 0o644); err != nil { + return fmt.Errorf("writing route config: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("renaming route config: %w", err) + } + health.RecordFile(path) return nil } func (p *NginxProvider) Reload(ctx context.Context) error { + if os.Getenv("DR_BUNDLED_PROXY") == "nginx" { + if _, err := os.Stat("/run/docker-release/nginx.started"); os.IsNotExist(err) { + return nil + } + } + ctr, running, err := resolveProxyContainer(ctx, p.docker, p.project, p.serviceName, "nginx") if err != nil { return fmt.Errorf("resolving nginx container: %w", err) } - if !matchesImage(ctr.Image, "nginx", "alpine") { + if ctr.Labels["com.malico.docker-release.bundled-proxy"] != "nginx" && !matchesImage(ctr.Image, "nginx", "alpine") { return fmt.Errorf("container %q has unexpected image %q (want nginx or alpine-based)", ctr.ID[:12], ctr.Image) } @@ -108,3 +147,19 @@ func renderUpstream(state *UpstreamState) string { return b.String() } + +func renderNginxRoute(state *UpstreamState, routePath string) string { + var b strings.Builder + + fmt.Fprintf(&b, "# Generated by docker-release\n") + fmt.Fprintf(&b, "location %s {\n", routePath) + fmt.Fprintf(&b, " proxy_set_header Host $host;\n") + fmt.Fprintf(&b, " proxy_set_header X-Real-IP $remote_addr;\n") + fmt.Fprintf(&b, " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + fmt.Fprintf(&b, " proxy_set_header X-Forwarded-Proto $scheme;\n") + fmt.Fprintf(&b, " proxy_http_version 1.1;\n") + fmt.Fprintf(&b, " proxy_pass http://%s_upstream/;\n", state.ResolveUpstreamName()) + fmt.Fprintf(&b, "}\n") + + return b.String() +} diff --git a/internal/provider/nginx_test.go b/internal/provider/nginx_test.go index 89b568b..6ff7d05 100644 --- a/internal/provider/nginx_test.go +++ b/internal/provider/nginx_test.go @@ -201,7 +201,7 @@ func TestRenderUpstreamBackupSkippedWithIpHash(t *testing.T) { func TestGenerateConfigWritesFile(t *testing.T) { dir := t.TempDir() - p := NewNginx(dir, nil, "", "") + p := NewNginx(dir, "", "", nil, "", "") state := &UpstreamState{ Service: "webapp", @@ -234,3 +234,31 @@ func TestGenerateConfigWritesFile(t *testing.T) { t.Error("temp file not cleaned up") } } + +func TestGenerateConfigWritesRouteFile(t *testing.T) { + dir := t.TempDir() + routeDir := t.TempDir() + p := NewNginx(dir, routeDir, "/app/", nil, "", "") + + state := &UpstreamState{ + Service: "webapp", + Servers: []Server{{Addr: "172.18.0.5:3000"}}, + } + + if err := p.GenerateConfig(context.Background(), state); err != nil { + t.Fatalf("generate error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(routeDir, "webapp.location")) + if err != nil { + t.Fatalf("read route error: %v", err) + } + + content := string(data) + if !strings.Contains(content, "location /app/ {") { + t.Error("route missing location block") + } + if !strings.Contains(content, "proxy_pass http://webapp_upstream/;") { + t.Error("route missing proxy_pass") + } +} diff --git a/packaging/nginx/nginx.conf b/packaging/nginx/nginx.conf new file mode 100644 index 0000000..8f6b5d9 --- /dev/null +++ b/packaging/nginx/nginx.conf @@ -0,0 +1,38 @@ +user nginx; +worker_processes auto; +error_log /dev/stderr notice; +pid /run/nginx/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /dev/stdout main; + + sendfile on; + keepalive_timeout 65; + + include /shared/nginx-config/*.conf; + include /etc/docker-release/nginx/http.d/*.conf; + include /etc/docker-release/nginx/conf.d/*.conf; + + server { + listen 80 default_server; + server_name _; + + location = /health { + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + include /shared/nginx-routes/*.location; + include /etc/docker-release/nginx/server.d/*.conf; + } +} diff --git a/tests/nginx-bundled/docker-compose.yml b/tests/nginx-bundled/docker-compose.yml new file mode 100644 index 0000000..b7bf1a0 --- /dev/null +++ b/tests/nginx-bundled/docker-compose.yml @@ -0,0 +1,55 @@ +services: + docker-release: + build: + context: ../../ + dockerfile: dockerfiles/nginx.Dockerfile + args: + BASE_IMAGE: docker-release:local + VERSION: local + image: docker-release-nginx:local + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - docker-release-state:/var/lib/docker-release:rw + command: ["watch"] + ports: + - "8089:80" + + linear_app: + image: traefik/whoami + environment: + - WHOAMI_NAME=linear + labels: + - "release.enable=true" + - "release.nginx.path=/linear/" + - "release.strategy=linear" + - "release.health_check_timeout=20s" + - "release.drain_timeout=5s" + + canary_app: + image: traefik/whoami + environment: + - WHOAMI_NAME=canary + labels: + - "release.enable=true" + - "release.nginx.path=/canary/" + - "release.strategy=canary" + - "release.canary.start_percentage=25" + - "release.canary.step=25" + - "release.canary.interval=10s" + - "release.affinity=cookie" + - "release.health_check_timeout=20s" + + blue_green_app: + image: traefik/whoami + environment: + - WHOAMI_NAME=blue_green + labels: + - "release.enable=true" + - "release.nginx.path=/bluegreen/" + - "release.strategy=blue-green" + - "release.bg.soak_time=30s" + - "release.bg.green_weight=50" + - "release.health_check_timeout=20s" + +volumes: + docker-release-state: From 022d786b1158726b53bd10546b4d0678b60e23b0 Mon Sep 17 00:00:00 2001 From: Malico Date: Thu, 18 Jun 2026 18:13:52 +0100 Subject: [PATCH 2/2] fix(nginx): use per-service bundled hosts --- cmd/docker-release/bundled_nginx.go | 112 ++------------------ cmd/docker-release/bundled_nginx_test.go | 45 ++------ docs/providers/nginx.md | 27 +++-- docs/readme.md | 10 +- internal/config/labels.go | 54 +++++++++- internal/config/labels_test.go | 52 ++++++++++ internal/controller/configsync.go | 16 ++- internal/provider/factory.go | 8 +- internal/provider/nginx.go | 127 +++++++++++++++++++++-- internal/provider/nginx_test.go | 58 ++++++++++- packaging/nginx/nginx.conf | 1 + tests/nginx-bundled/docker-compose.yml | 3 + 12 files changed, 336 insertions(+), 177 deletions(-) diff --git a/cmd/docker-release/bundled_nginx.go b/cmd/docker-release/bundled_nginx.go index 6fd21eb..c296d87 100644 --- a/cmd/docker-release/bundled_nginx.go +++ b/cmd/docker-release/bundled_nginx.go @@ -3,21 +3,13 @@ package main import ( - "fmt" "os" - "strconv" "strings" ) const bundledNginxConfigPath = "/etc/nginx/nginx.conf" type bundledNginxConfig struct { - HTTPPort int - HTTPSPort int - ServerName string - SSLCert string - SSLKey string - RedirectHTTPS bool } func prepareBundledNginxConfig() error { @@ -34,44 +26,7 @@ func prepareBundledNginxConfig() error { } func bundledNginxConfigFromEnv() (bundledNginxConfig, error) { - cfg := bundledNginxConfig{ - HTTPPort: 80, - HTTPSPort: 443, - ServerName: envOr("DR_NGINX_SERVER_NAME", "_"), - SSLCert: strings.TrimSpace(os.Getenv("DR_NGINX_SSL_CERT")), - SSLKey: strings.TrimSpace(os.Getenv("DR_NGINX_SSL_KEY")), - } - - var err error - cfg.HTTPPort, err = envPort("DR_NGINX_HTTP_PORT", cfg.HTTPPort) - if err != nil { - return bundledNginxConfig{}, err - } - cfg.HTTPSPort, err = envPort("DR_NGINX_HTTPS_PORT", cfg.HTTPSPort) - if err != nil { - return bundledNginxConfig{}, err - } - cfg.RedirectHTTPS = truthy(os.Getenv("DR_NGINX_REDIRECT_HTTPS")) - - for name, value := range map[string]string{ - "DR_NGINX_SERVER_NAME": cfg.ServerName, - "DR_NGINX_SSL_CERT": cfg.SSLCert, - "DR_NGINX_SSL_KEY": cfg.SSLKey, - } { - if err := safeNginxValue(name, value); err != nil { - return bundledNginxConfig{}, err - } - } - - if cfg.RedirectHTTPS && !cfg.sslEnabled() { - return bundledNginxConfig{}, fmt.Errorf("DR_NGINX_REDIRECT_HTTPS requires DR_NGINX_SSL_CERT and DR_NGINX_SSL_KEY") - } - - return cfg, nil -} - -func (c bundledNginxConfig) sslEnabled() bool { - return c.SSLCert != "" && c.SSLKey != "" + return bundledNginxConfig{}, nil } func renderBundledNginxConfig(cfg bundledNginxConfig) string { @@ -94,75 +49,31 @@ func renderBundledNginxConfig(cfg bundledNginxConfig) string { b.WriteString(" sendfile on;\n") b.WriteString(" keepalive_timeout 65;\n\n") b.WriteString(" include /shared/nginx-config/*.conf;\n") + b.WriteString(" include /shared/nginx-routes/*.server;\n") b.WriteString(" include /etc/docker-release/nginx/http.d/*.conf;\n") b.WriteString(" include /etc/docker-release/nginx/conf.d/*.conf;\n\n") - renderBundledNginxServer(&b, cfg, false) - if cfg.sslEnabled() { - b.WriteString("\n") - renderBundledNginxServer(&b, cfg, true) - } + renderBundledNginxDefaultServer(&b) b.WriteString("}\n") return b.String() } -func renderBundledNginxServer(b *strings.Builder, cfg bundledNginxConfig, ssl bool) { +func renderBundledNginxDefaultServer(b *strings.Builder) { b.WriteString(" server {\n") - if ssl { - fmt.Fprintf(b, " listen %d ssl default_server;\n", cfg.HTTPSPort) - } else { - fmt.Fprintf(b, " listen %d default_server;\n", cfg.HTTPPort) - } - fmt.Fprintf(b, " server_name %s;\n\n", cfg.ServerName) - - if ssl { - fmt.Fprintf(b, " ssl_certificate %s;\n", cfg.SSLCert) - fmt.Fprintf(b, " ssl_certificate_key %s;\n", cfg.SSLKey) - b.WriteString(" ssl_session_cache shared:SSL:10m;\n") - b.WriteString(" ssl_session_timeout 10m;\n") - b.WriteString(" include /etc/docker-release/nginx/ssl.d/*.conf;\n\n") - } + b.WriteString(" listen 80 default_server;\n") + b.WriteString(" server_name _;\n\n") b.WriteString(" location = /health {\n") b.WriteString(" add_header Content-Type text/plain;\n") b.WriteString(" return 200 \"ok\\n\";\n") b.WriteString(" }\n\n") - - if !ssl && cfg.RedirectHTTPS { - b.WriteString(" location / {\n") - b.WriteString(" return 308 https://$host$request_uri;\n") - b.WriteString(" }\n") - } else { - b.WriteString(" include /shared/nginx-routes/*.location;\n") - b.WriteString(" include /etc/docker-release/nginx/server.d/*.conf;\n") - if ssl { - b.WriteString(" include /etc/docker-release/nginx/https.d/*.conf;\n") - } - } + b.WriteString(" include /shared/nginx-routes/*.location;\n") + b.WriteString(" include /etc/docker-release/nginx/server.d/*.conf;\n") b.WriteString(" }\n") } -func envOr(key, fallback string) string { - if v := strings.TrimSpace(os.Getenv(key)); v != "" { - return v - } - return fallback -} - -func envPort(key string, fallback int) (int, error) { - v := strings.TrimSpace(os.Getenv(key)) - if v == "" { - return fallback, nil - } - port, err := strconv.Atoi(v) - if err != nil || port < 1 || port > 65535 { - return 0, fmt.Errorf("%s must be a port 1-65535", key) - } - return port, nil -} - func truthy(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on": @@ -171,10 +82,3 @@ func truthy(v string) bool { return false } } - -func safeNginxValue(name, value string) error { - if strings.ContainsAny(value, ";{}\n\r\t\"") { - return fmt.Errorf("%s contains invalid nginx config characters", name) - } - return nil -} diff --git a/cmd/docker-release/bundled_nginx_test.go b/cmd/docker-release/bundled_nginx_test.go index 2c8883e..a3d2dad 100644 --- a/cmd/docker-release/bundled_nginx_test.go +++ b/cmd/docker-release/bundled_nginx_test.go @@ -8,49 +8,18 @@ import ( ) func TestRenderBundledNginxConfigHTTPOnly(t *testing.T) { - cfg := bundledNginxConfig{HTTPPort: 8080, HTTPSPort: 443, ServerName: "_"} + got := renderBundledNginxConfig(bundledNginxConfig{}) - got := renderBundledNginxConfig(cfg) - - if !strings.Contains(got, "listen 8080 default_server;") { + if !strings.Contains(got, "listen 80 default_server;") { t.Error("missing HTTP listener") } if strings.Contains(got, "ssl_certificate") { - t.Error("HTTP-only config should not include SSL directives") - } - if !strings.Contains(got, "include /shared/nginx-routes/*.location;") { - t.Error("missing generated route include") - } -} - -func TestRenderBundledNginxConfigHTTPS(t *testing.T) { - cfg := bundledNginxConfig{ - HTTPPort: 80, - HTTPSPort: 8443, - ServerName: "example.com", - SSLCert: "/certs/fullchain.pem", - SSLKey: "/certs/privkey.pem", - RedirectHTTPS: true, - } - - got := renderBundledNginxConfig(cfg) - - if !strings.Contains(got, "listen 8443 ssl default_server;") { - t.Error("missing HTTPS listener") + t.Error("base config should not include SSL directives") } - if !strings.Contains(got, "ssl_certificate /certs/fullchain.pem;") { - t.Error("missing SSL cert") + if !strings.Contains(got, "include /shared/nginx-routes/*.server;") { + t.Error("missing generated server include") } - if !strings.Contains(got, "return 308 https://$host$request_uri;") { - t.Error("missing HTTP to HTTPS redirect") - } -} - -func TestBundledNginxConfigRejectsRedirectWithoutCert(t *testing.T) { - t.Setenv("DR_NGINX_REDIRECT_HTTPS", "true") - - _, err := bundledNginxConfigFromEnv() - if err == nil { - t.Fatal("expected redirect without cert/key to fail") + if !strings.Contains(got, "include /shared/nginx-routes/*.location;") { + t.Error("missing fallback location include") } } diff --git a/docs/providers/nginx.md b/docs/providers/nginx.md index 1eada42..4bfe4cd 100644 --- a/docs/providers/nginx.md +++ b/docs/providers/nginx.md @@ -74,6 +74,7 @@ services: image: your-registry/app:latest labels: release.enable: "true" + release.nginx.host: app.example.com release.nginx.path: "/" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost/health"] @@ -97,20 +98,25 @@ ports: - "443:443" ``` -Enable HTTPS by mounting your cert/key and pointing Nginx at them: +Each app can have its own host and certs, like nginx-proxy's `VIRTUAL_HOST` model: ```yaml services: docker-release: image: malico/docker-release-nginx:latest - environment: - DR_NGINX_SERVER_NAME: example.com - DR_NGINX_SSL_CERT: /certs/fullchain.pem - DR_NGINX_SSL_KEY: /certs/privkey.pem - DR_NGINX_REDIRECT_HTTPS: "true" volumes: - /var/run/docker.sock:/var/run/docker.sock - ./certs:/certs:ro + + app: + image: your-registry/app:latest + labels: + release.enable: "true" + release.nginx.host: app.example.com + release.nginx.path: "/" + release.nginx.ssl.cert: /certs/app/fullchain.pem + release.nginx.ssl.key: /certs/app/privkey.pem + release.nginx.ssl.redirect: "true" ``` Custom config paths: @@ -118,7 +124,8 @@ Custom config paths: | Path | Purpose | |---|---| | `/shared/nginx-config/*.conf` | generated upstreams, managed by `docker-release` | -| `/shared/nginx-routes/*.location` | generated `release.nginx.path` routes | +| `/shared/nginx-routes/*.server` | generated per-service `release.nginx.host` server blocks | +| `/shared/nginx-routes/*.location` | generated hostless `release.nginx.path` fallback routes | | `/etc/docker-release/nginx/http.d/*.conf` | custom `http` context snippets | | `/etc/docker-release/nginx/conf.d/*.conf` | custom top-level `http` snippets, including extra `server` blocks | | `/etc/docker-release/nginx/server.d/*.conf` | custom snippets inside the generated HTTP/HTTPS server | @@ -169,8 +176,12 @@ docker release app |---|---|---| | `release.nginx.service` | auto-detected | multiple Nginx containers in the project | | `release.nginx.config_dir` | `/shared/nginx-config` | volume mounted at a different path | -| `release.nginx.route_dir` | `/shared/nginx-routes` | generated `release.nginx.path` route files need a different path | +| `release.nginx.route_dir` | `/shared/nginx-routes` | generated Nginx route files need a different path | +| `release.nginx.host` | empty | bundled Nginx should generate a server block for this host | | `release.nginx.path` | empty | bundled Nginx should generate a route for this service | +| `release.nginx.ssl.cert` | empty | mounted TLS certificate path for this service | +| `release.nginx.ssl.key` | empty | mounted TLS key path for this service | +| `release.nginx.ssl.redirect` | `false` | redirect HTTP to HTTPS for this service | ## Multiple Apps diff --git a/docs/readme.md b/docs/readme.md index 8e9c42d..6840879 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -116,7 +116,11 @@ release.affinity: cookie # sticky sessions (Angie, Caddy, HAProxy, Traefik only | `release.nginx.service` | auto-detected | Compose service name of Nginx in this stack | | `release.nginx.config_dir` | `/shared/nginx-config` | Shared volume path for Nginx upstream files | | `release.nginx.route_dir` | `/shared/nginx-routes` | Bundled Nginx route snippet path | +| `release.nginx.host` | empty | Bundled Nginx hostname for this service | | `release.nginx.path` | empty | Bundled Nginx route path for this service | +| `release.nginx.ssl.cert` | empty | Mounted TLS certificate path for this service | +| `release.nginx.ssl.key` | empty | Mounted TLS key path for this service | +| `release.nginx.ssl.redirect` | `false` | Redirect HTTP to HTTPS for this service | | `release.angie.service` | auto-detected | Compose service name of Angie | | `release.angie.config_dir` | `/shared/angie-config` | Shared volume path for Angie upstream files | | `release.caddy.service` | auto-detected | Compose service name of Caddy | @@ -130,12 +134,6 @@ release.affinity: cookie # sticky sessions (Angie, Caddy, HAProxy, Traefik only | Env var | Default | Description | |---|---|---| -| `DR_NGINX_HTTP_PORT` | `80` | HTTP listen port inside the container | -| `DR_NGINX_HTTPS_PORT` | `443` | HTTPS listen port inside the container | -| `DR_NGINX_SERVER_NAME` | `_` | Generated Nginx `server_name` | -| `DR_NGINX_SSL_CERT` | empty | Mounted certificate path; enables HTTPS with `DR_NGINX_SSL_KEY` | -| `DR_NGINX_SSL_KEY` | empty | Mounted key path; enables HTTPS with `DR_NGINX_SSL_CERT` | -| `DR_NGINX_REDIRECT_HTTPS` | off | Redirect HTTP to HTTPS; requires cert/key | | `DR_NGINX_SKIP_CONFIG` | off | Use a fully mounted `/etc/nginx/nginx.conf` instead of generated config | ## Health Checks diff --git a/internal/config/labels.go b/internal/config/labels.go index bcf6e2c..51a3e9d 100644 --- a/internal/config/labels.go +++ b/internal/config/labels.go @@ -11,6 +11,7 @@ import ( var validUpstreamName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) var validRoutePath = regexp.MustCompile(`^/[a-zA-Z0-9._~/%-]*$`) +var validNginxHost = regexp.MustCompile(`^[a-zA-Z0-9*._,-]+$`) type Strategy string @@ -42,6 +43,11 @@ type ServiceConfig struct { NginxService string NginxConfigDir string NginxRouteDir string + NginxHost string + NginxPath string + NginxSSLCert string + NginxSSLKey string + NginxSSLRedirect bool NginxKeepalive int AngieService string AngieConfigDir string @@ -54,7 +60,6 @@ type ServiceConfig struct { HAProxyService string HAProxyConfigDir string UpstreamName string - NginxPath string BlueGreen BlueGreenConfig Canary CanaryConfig @@ -86,6 +91,11 @@ func ParseLabels(labels map[string]string) (*ServiceConfig, error) { NginxService: getOr(labels, "release.nginx.service", ""), NginxConfigDir: getOr(labels, "release.nginx.config_dir", ""), NginxRouteDir: getOr(labels, "release.nginx.route_dir", ""), + NginxHost: getOr(labels, "release.nginx.host", ""), + NginxPath: getOr(labels, "release.nginx.path", ""), + NginxSSLCert: getOr(labels, "release.nginx.ssl.cert", ""), + NginxSSLKey: getOr(labels, "release.nginx.ssl.key", ""), + NginxSSLRedirect: parseBoolOr(labels, "release.nginx.ssl.redirect", false), NginxKeepalive: parseIntOr(labels, "release.nginx.keepalive", -1), AngieService: getOr(labels, "release.angie.service", ""), AngieConfigDir: getOr(labels, "release.angie.config_dir", ""), @@ -98,8 +108,6 @@ func ParseLabels(labels map[string]string) (*ServiceConfig, error) { HAProxyService: getOr(labels, "release.haproxy.service", ""), HAProxyConfigDir: getOr(labels, "release.haproxy.config_dir", ""), UpstreamName: getOr(labels, "release.upstream", ""), - NginxPath: getOr(labels, "release.nginx.path", ""), - BlueGreen: BlueGreenConfig{ SoakTime: parseDurationOr(labels, "release.bg.soak_time", 5*time.Minute), GreenWeight: parseIntOr(labels, "release.bg.green_weight", 50), @@ -221,6 +229,30 @@ func (c *ServiceConfig) validate() error { return fmt.Errorf("release.nginx.path %q must start with / and contain only URL path characters", c.NginxPath) } + if c.NginxHost != "" && !validNginxHost.MatchString(c.NginxHost) { + return fmt.Errorf("release.nginx.host %q contains invalid hostname characters", c.NginxHost) + } + + if (c.NginxSSLCert == "") != (c.NginxSSLKey == "") { + return fmt.Errorf("release.nginx.ssl.cert and release.nginx.ssl.key must be set together") + } + + if c.NginxSSLRedirect && c.NginxSSLCert == "" { + return fmt.Errorf("release.nginx.ssl.redirect requires release.nginx.ssl.cert and release.nginx.ssl.key") + } + + for _, p := range []string{c.NginxSSLCert, c.NginxSSLKey} { + if p == "" { + continue + } + if !strings.HasPrefix(p, "/") { + return fmt.Errorf("nginx ssl paths must be absolute") + } + if containsDotDot(p) || strings.ContainsAny(p, ";{}\n\r\t\"") { + return fmt.Errorf("nginx ssl paths contain invalid characters") + } + } + return nil } @@ -305,6 +337,22 @@ func parseIntOr(labels map[string]string, key string, fallback int) int { return n } +func parseBoolOr(labels map[string]string, key string, fallback bool) bool { + v, ok := labels[key] + if !ok || v == "" { + return fallback + } + + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + func parseDurationOr(labels map[string]string, key string, fallback time.Duration) time.Duration { v, ok := labels[key] if !ok || v == "" { diff --git a/internal/config/labels_test.go b/internal/config/labels_test.go index ae0f051..29eabca 100644 --- a/internal/config/labels_test.go +++ b/internal/config/labels_test.go @@ -19,7 +19,11 @@ func TestParseLabels(t *testing.T) { "release.canary.interval": "1m", "release.nginx.service": "my-nginx", "release.nginx.keepalive": "20", + "release.nginx.host": "app.localhost,www.localhost", "release.nginx.path": "/app/", + "release.nginx.ssl.cert": "/certs/app/fullchain.pem", + "release.nginx.ssl.key": "/certs/app/privkey.pem", + "release.nginx.ssl.redirect": "true", } cfg, err := ParseLabels(labels) @@ -63,6 +67,18 @@ func TestParseLabels(t *testing.T) { if cfg.NginxPath != "/app/" { t.Errorf("nginx_path = %s, want /app/", cfg.NginxPath) } + if cfg.NginxHost != "app.localhost,www.localhost" { + t.Errorf("nginx_host = %s, want app.localhost,www.localhost", cfg.NginxHost) + } + if cfg.NginxSSLCert != "/certs/app/fullchain.pem" { + t.Errorf("nginx_ssl_cert = %s, want /certs/app/fullchain.pem", cfg.NginxSSLCert) + } + if cfg.NginxSSLKey != "/certs/app/privkey.pem" { + t.Errorf("nginx_ssl_key = %s, want /certs/app/privkey.pem", cfg.NginxSSLKey) + } + if !cfg.NginxSSLRedirect { + t.Error("nginx_ssl_redirect = false, want true") + } } func TestParseLabelsDefaults(t *testing.T) { @@ -432,6 +448,42 @@ func TestParseLabelsInvalidRoutePath(t *testing.T) { } } +func TestParseLabelsInvalidNginxHost(t *testing.T) { + labels := map[string]string{ + "release.enable": "true", + "release.nginx.host": `app.localhost;return 200`, + } + + _, err := ParseLabels(labels) + if err == nil { + t.Fatal("expected error for invalid release.nginx.host") + } +} + +func TestParseLabelsNginxSSLCertRequiresKey(t *testing.T) { + labels := map[string]string{ + "release.enable": "true", + "release.nginx.ssl.cert": "/certs/app/fullchain.pem", + } + + _, err := ParseLabels(labels) + if err == nil { + t.Fatal("expected error for nginx ssl cert without key") + } +} + +func TestParseLabelsNginxSSLRedirectRequiresCert(t *testing.T) { + labels := map[string]string{ + "release.enable": "true", + "release.nginx.ssl.redirect": "true", + } + + _, err := ParseLabels(labels) + if err == nil { + t.Fatal("expected error for nginx ssl redirect without cert") + } +} + func TestParseLabelsUpstreamNameInjection(t *testing.T) { labels := map[string]string{ "release.enable": "true", diff --git a/internal/controller/configsync.go b/internal/controller/configsync.go index 42c1d21..10544c4 100644 --- a/internal/controller/configsync.go +++ b/internal/controller/configsync.go @@ -78,12 +78,18 @@ func (c *Controller) cleanStaleConfigs(activeConfigs map[string]*config.ServiceC } markActiveConfig(active, configDir{dir: dir, ext: ext}, name) if cfg.Provider == config.ProviderNginx && cfg.NginxRouteDir != "" { - cd := configDir{dir: cfg.NginxRouteDir, ext: ".location"} - if active[cd] == nil { - active[cd] = make(map[string]bool) + locationDir := configDir{dir: cfg.NginxRouteDir, ext: ".location"} + serverDir := configDir{dir: cfg.NginxRouteDir, ext: ".server"} + if active[locationDir] == nil { + active[locationDir] = make(map[string]bool) } - if cfg.NginxPath != "" { - active[cd][name] = true + if active[serverDir] == nil { + active[serverDir] = make(map[string]bool) + } + if cfg.NginxHost != "" { + active[serverDir][name] = true + } else if cfg.NginxPath != "" { + active[locationDir][name] = true } } } diff --git a/internal/provider/factory.go b/internal/provider/factory.go index 5ebe9fb..0a01fcc 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -33,7 +33,13 @@ func NewFactory(dockerClient *docker.Client, project string) *Factory { func (f *Factory) Provider(cfg *config.ServiceConfig) (Provider, error) { switch cfg.Provider { case config.ProviderNginx: - return NewNginx(cfg.NginxConfigDir, cfg.NginxRouteDir, cfg.NginxPath, f.docker, cfg.NginxService, f.project), nil + return NewNginx(cfg.NginxConfigDir, cfg.NginxRouteDir, NginxRoute{ + Host: cfg.NginxHost, + Path: cfg.NginxPath, + SSLCert: cfg.NginxSSLCert, + SSLKey: cfg.NginxSSLKey, + SSLRedirect: cfg.NginxSSLRedirect, + }, f.docker, cfg.NginxService, f.project), nil case config.ProviderAngie: return NewAngie(cfg.AngieConfigDir, f.docker, cfg.AngieService, f.project), nil case config.ProviderTraefik: diff --git a/internal/provider/nginx.go b/internal/provider/nginx.go index 0bf0b30..165c12c 100644 --- a/internal/provider/nginx.go +++ b/internal/provider/nginx.go @@ -12,19 +12,28 @@ import ( ) type NginxProvider struct { - configDir string - routeDir string - routePath string + configDir string + routeDir string + route NginxRoute + docker *docker.Client serviceName string project string } -func NewNginx(configDir, routeDir, routePath string, dockerClient *docker.Client, serviceName, project string) *NginxProvider { +type NginxRoute struct { + Host string + Path string + SSLCert string + SSLKey string + SSLRedirect bool +} + +func NewNginx(configDir, routeDir string, route NginxRoute, dockerClient *docker.Client, serviceName, project string) *NginxProvider { return &NginxProvider{ configDir: configDir, routeDir: routeDir, - routePath: routePath, + route: route, docker: dockerClient, serviceName: serviceName, project: project, @@ -63,7 +72,7 @@ func (p *NginxProvider) GenerateConfig(ctx context.Context, state *UpstreamState } func (p *NginxProvider) generateRoute(state *UpstreamState) error { - if p.routePath == "" { + if p.routeDir == "" { return nil } @@ -71,10 +80,28 @@ func (p *NginxProvider) generateRoute(state *UpstreamState) error { return fmt.Errorf("creating route dir: %w", err) } - path := filepath.Join(p.routeDir, state.Service+".location") + if p.route.Host == "" && p.route.Path == "" { + return p.removeRouteFiles(state.Service) + } + + if p.route.Host != "" { + if err := p.writeRouteFile(state.Service, ".server", renderNginxServer(state, p.route)); err != nil { + return err + } + return removeGeneratedFile(filepath.Join(p.routeDir, state.Service+".location")) + } + + if err := p.writeRouteFile(state.Service, ".location", renderNginxLocation(state, p.route)); err != nil { + return err + } + return removeGeneratedFile(filepath.Join(p.routeDir, state.Service+".server")) +} + +func (p *NginxProvider) writeRouteFile(service, ext, content string) error { + path := filepath.Join(p.routeDir, service+ext) tmp := path + ".tmp" - if err := os.WriteFile(tmp, []byte(renderNginxRoute(state, p.routePath)), 0o644); err != nil { + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { return fmt.Errorf("writing route config: %w", err) } @@ -86,6 +113,29 @@ func (p *NginxProvider) generateRoute(state *UpstreamState) error { return nil } +func (p *NginxProvider) removeRouteFiles(service string) error { + for _, ext := range []string{".location", ".server"} { + if err := removeGeneratedFile(filepath.Join(p.routeDir, service+ext)); err != nil { + return err + } + } + return nil +} + +func removeGeneratedFile(path string) error { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if !strings.HasPrefix(string(data), "# Generated by docker-release") { + return nil + } + return os.Remove(path) +} + func (p *NginxProvider) Reload(ctx context.Context) error { if os.Getenv("DR_BUNDLED_PROXY") == "nginx" { if _, err := os.Stat("/run/docker-release/nginx.started"); os.IsNotExist(err) { @@ -148,11 +198,64 @@ func renderUpstream(state *UpstreamState) string { return b.String() } -func renderNginxRoute(state *UpstreamState, routePath string) string { +func renderNginxServer(state *UpstreamState, route NginxRoute) string { var b strings.Builder + if route.SSLCert != "" { + renderNginxServerBlock(&b, state, route, false, route.SSLRedirect) + b.WriteString("\n") + renderNginxServerBlock(&b, state, route, true, false) + return b.String() + } + + renderNginxServerBlock(&b, state, route, false, false) + return b.String() +} + +func renderNginxServerBlock(b *strings.Builder, state *UpstreamState, route NginxRoute, ssl, redirectOnly bool) { + fmt.Fprintf(b, "# Generated by docker-release\n") + fmt.Fprintf(b, "server {\n") + if ssl { + fmt.Fprintf(b, " listen 443 ssl;\n") + } else { + fmt.Fprintf(b, " listen 80;\n") + } + fmt.Fprintf(b, " server_name %s;\n", normalizeNginxHost(route.Host)) + + if ssl { + fmt.Fprintf(b, " ssl_certificate %s;\n", route.SSLCert) + fmt.Fprintf(b, " ssl_certificate_key %s;\n", route.SSLKey) + fmt.Fprintf(b, " include /etc/docker-release/nginx/ssl.d/*.conf;\n") + } + + fmt.Fprintf(b, "\n") + if redirectOnly { + fmt.Fprintf(b, " location / {\n") + fmt.Fprintf(b, " return 308 https://$host$request_uri;\n") + fmt.Fprintf(b, " }\n") + fmt.Fprintf(b, "}\n") + return + } + + for _, line := range strings.Split(strings.TrimSuffix(renderNginxLocation(state, route), "\n"), "\n") { + fmt.Fprintf(b, " %s\n", line) + } + fmt.Fprintf(b, " include /etc/docker-release/nginx/server.d/*.conf;\n") + if ssl { + fmt.Fprintf(b, " include /etc/docker-release/nginx/https.d/*.conf;\n") + } + fmt.Fprintf(b, "}\n") +} + +func renderNginxLocation(state *UpstreamState, route NginxRoute) string { + var b strings.Builder + path := route.Path + if path == "" { + path = "/" + } + fmt.Fprintf(&b, "# Generated by docker-release\n") - fmt.Fprintf(&b, "location %s {\n", routePath) + fmt.Fprintf(&b, "location %s {\n", path) fmt.Fprintf(&b, " proxy_set_header Host $host;\n") fmt.Fprintf(&b, " proxy_set_header X-Real-IP $remote_addr;\n") fmt.Fprintf(&b, " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") @@ -163,3 +266,7 @@ func renderNginxRoute(state *UpstreamState, routePath string) string { return b.String() } + +func normalizeNginxHost(host string) string { + return strings.Join(strings.FieldsFunc(host, func(r rune) bool { return r == ',' }), " ") +} diff --git a/internal/provider/nginx_test.go b/internal/provider/nginx_test.go index 6ff7d05..4d9f41c 100644 --- a/internal/provider/nginx_test.go +++ b/internal/provider/nginx_test.go @@ -201,7 +201,7 @@ func TestRenderUpstreamBackupSkippedWithIpHash(t *testing.T) { func TestGenerateConfigWritesFile(t *testing.T) { dir := t.TempDir() - p := NewNginx(dir, "", "", nil, "", "") + p := NewNginx(dir, "", NginxRoute{}, nil, "", "") state := &UpstreamState{ Service: "webapp", @@ -238,7 +238,7 @@ func TestGenerateConfigWritesFile(t *testing.T) { func TestGenerateConfigWritesRouteFile(t *testing.T) { dir := t.TempDir() routeDir := t.TempDir() - p := NewNginx(dir, routeDir, "/app/", nil, "", "") + p := NewNginx(dir, routeDir, NginxRoute{Path: "/app/"}, nil, "", "") state := &UpstreamState{ Service: "webapp", @@ -262,3 +262,57 @@ func TestGenerateConfigWritesRouteFile(t *testing.T) { t.Error("route missing proxy_pass") } } + +func TestGenerateConfigWritesServerFile(t *testing.T) { + dir := t.TempDir() + routeDir := t.TempDir() + p := NewNginx(dir, routeDir, NginxRoute{Host: "app.localhost", Path: "/"}, nil, "", "") + + state := &UpstreamState{ + Service: "webapp", + Servers: []Server{{Addr: "172.18.0.5:3000"}}, + } + + if err := p.GenerateConfig(context.Background(), state); err != nil { + t.Fatalf("generate error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(routeDir, "webapp.server")) + if err != nil { + t.Fatalf("read server error: %v", err) + } + + content := string(data) + if !strings.Contains(content, "server_name app.localhost;") { + t.Error("server route missing server_name") + } + if !strings.Contains(content, "proxy_pass http://webapp_upstream/;") { + t.Error("server route missing proxy_pass") + } +} + +func TestRenderNginxServerHTTPSRedirect(t *testing.T) { + state := &UpstreamState{Service: "webapp"} + route := NginxRoute{ + Host: "app.localhost,www.localhost", + Path: "/", + SSLCert: "/certs/app/fullchain.pem", + SSLKey: "/certs/app/privkey.pem", + SSLRedirect: true, + } + + got := renderNginxServer(state, route) + + if !strings.Contains(got, "server_name app.localhost www.localhost;") { + t.Error("server route should normalize comma-separated hosts") + } + if !strings.Contains(got, "return 308 https://$host$request_uri;") { + t.Error("server route missing HTTP redirect") + } + if !strings.Contains(got, "listen 443 ssl;") { + t.Error("server route missing HTTPS listener") + } + if !strings.Contains(got, "ssl_certificate /certs/app/fullchain.pem;") { + t.Error("server route missing cert") + } +} diff --git a/packaging/nginx/nginx.conf b/packaging/nginx/nginx.conf index 8f6b5d9..b7f64a8 100644 --- a/packaging/nginx/nginx.conf +++ b/packaging/nginx/nginx.conf @@ -20,6 +20,7 @@ http { keepalive_timeout 65; include /shared/nginx-config/*.conf; + include /shared/nginx-routes/*.server; include /etc/docker-release/nginx/http.d/*.conf; include /etc/docker-release/nginx/conf.d/*.conf; diff --git a/tests/nginx-bundled/docker-compose.yml b/tests/nginx-bundled/docker-compose.yml index b7bf1a0..48cf72a 100644 --- a/tests/nginx-bundled/docker-compose.yml +++ b/tests/nginx-bundled/docker-compose.yml @@ -20,6 +20,7 @@ services: - WHOAMI_NAME=linear labels: - "release.enable=true" + - "release.nginx.host=linear.localhost" - "release.nginx.path=/linear/" - "release.strategy=linear" - "release.health_check_timeout=20s" @@ -31,6 +32,7 @@ services: - WHOAMI_NAME=canary labels: - "release.enable=true" + - "release.nginx.host=canary.localhost" - "release.nginx.path=/canary/" - "release.strategy=canary" - "release.canary.start_percentage=25" @@ -45,6 +47,7 @@ services: - WHOAMI_NAME=blue_green labels: - "release.enable=true" + - "release.nginx.host=bluegreen.localhost" - "release.nginx.path=/bluegreen/" - "release.strategy=blue-green" - "release.bg.soak_time=30s"