Merge pull request #68 from bnema/fix/readiness-and-backup-detect
Improve deploy readiness handling and push UX
23 files · +1512 -227
M docs/config/server.md +24 -0
| @@ -69,6 +69,30 @@ With TLS enabled: | |||
| 69 | 69 | - `port` remains available for plain HTTP unless you restrict it externally | |
| 70 | 70 | - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/` | |
| 71 | 71 | ||
| 72 | + | For rootless user services, do not bind directly to `443`. Use an unprivileged TLS port (for example `8443`) and forward `443` to it at the firewall or edge proxy. | |
| 73 | + | ||
| 74 | + | ```toml | |
| 75 | + | [server] | |
| 76 | + | tls_enabled = true | |
| 77 | + | tls_port = 8443 | |
| 78 | + | ``` | |
| 79 | + | ||
| 80 | + | With firewalld and Tailscale: | |
| 81 | + | ||
| 82 | + | ```bash | |
| 83 | + | # Verify tailscale0 is in trusted zone | |
| 84 | + | sudo firewall-cmd --get-zone-of-interface=tailscale0 | |
| 85 | + | ||
| 86 | + | # If needed, place tailscale0 in trusted | |
| 87 | + | sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0 | |
| 88 | + | sudo firewall-cmd --reload | |
| 89 | + | ||
| 90 | + | # Forward 443 -> 8443 permanently (IPv4 + IPv6) | |
| 91 | + | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv4 forward-port port=443 protocol=tcp to-port=8443' | |
| 92 | + | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv6 forward-port port=443 protocol=tcp to-port=8443' | |
| 93 | + | sudo firewall-cmd --reload | |
| 94 | + | ``` | |
| 95 | + | ||
| 72 | 96 | ### Registry Port | |
| 73 | 97 | ||
| 74 | 98 | The `registry_port` setting controls where Gordon accepts image pushes: | |
| @@ -69,6 +69,30 @@ With TLS enabled: | |||||
| 69 | - `port` remains available for plain HTTP unless you restrict it externally | 69 | - `port` remains available for plain HTTP unless you restrict it externally | ||
| 70 | - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/` | 70 | - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/` | ||
| 71 | 71 | ||||
| 72 | + | For rootless user services, do not bind directly to `443`. Use an unprivileged TLS port (for example `8443`) and forward `443` to it at the firewall or edge proxy. | |||
| 73 | + | ||||
| 74 | + | ```toml | |||
| 75 | + | [server] | |||
| 76 | + | tls_enabled = true | |||
| 77 | + | tls_port = 8443 | |||
| 78 | + | ``` | |||
| 79 | + | ||||
| 80 | + | With firewalld and Tailscale: | |||
| 81 | + | ||||
| 82 | + | ```bash | |||
| 83 | + | # Verify tailscale0 is in trusted zone | |||
| 84 | + | sudo firewall-cmd --get-zone-of-interface=tailscale0 | |||
| 85 | + | ||||
| 86 | + | # If needed, place tailscale0 in trusted | |||
| 87 | + | sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0 | |||
| 88 | + | sudo firewall-cmd --reload | |||
| 89 | + | ||||
| 90 | + | # Forward 443 -> 8443 permanently (IPv4 + IPv6) | |||
| 91 | + | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv4 forward-port port=443 protocol=tcp to-port=8443' | |||
| 92 | + | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv6 forward-port port=443 protocol=tcp to-port=8443' | |||
| 93 | + | sudo firewall-cmd --reload | |||
| 94 | + | ``` | |||
| 95 | + | ||||
| 72 | ### Registry Port | 96 | ### Registry Port | ||
| 73 | 97 | ||||
| 74 | The `registry_port` setting controls where Gordon accepts image pushes: | 98 | The `registry_port` setting controls where Gordon accepts image pushes: | ||
M internal/adapters/in/cli/push.go +98 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "regexp" | |
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | 12 | "github.com/spf13/cobra" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -236,7 +237,7 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri | |||
| 236 | 237 | } | |
| 237 | 238 | } | |
| 238 | 239 | ||
| 239 | - | result, err := client.Deploy(ctx, pushDomain) | |
| 240 | + | result, err := deployWithSpinner(ctx, client, pushDomain) | |
| 240 | 241 | if err != nil { | |
| 241 | 242 | return fmt.Errorf("failed to deploy: %w", err) | |
| 242 | 243 | } | |
| @@ -248,6 +249,102 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri | |||
| 248 | 249 | return nil | |
| 249 | 250 | } | |
| 250 | 251 | ||
| 252 | + | func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) { | |
| 253 | + | if !isInteractiveTerminal() { | |
| 254 | + | fmt.Printf("Deploying %s...\n", pushDomain) | |
| 255 | + | return client.Deploy(ctx, pushDomain) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | done := make(chan deployOutcome, 1) | |
| 259 | + | go func() { | |
| 260 | + | result, err := client.Deploy(ctx, pushDomain) | |
| 261 | + | done <- deployOutcome{result: result, err: err} | |
| 262 | + | }() | |
| 263 | + | ||
| 264 | + | model := newDeploySpinnerModel(pushDomain, done) | |
| 265 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |
| 266 | + | fmt.Print("\r\033[K") | |
| 267 | + | if err != nil { | |
| 268 | + | return nil, err | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | deployModel, ok := final.(deploySpinnerModel) | |
| 272 | + | if !ok { | |
| 273 | + | return nil, fmt.Errorf("spinner exited with unexpected model type %T", final) | |
| 274 | + | } | |
| 275 | + | if !deployModel.finished { | |
| 276 | + | return nil, fmt.Errorf("deploy spinner exited before deploy result was received") | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | return deployModel.outcome.result, deployModel.outcome.err | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | type deployOutcome struct { | |
| 283 | + | result *remote.DeployResult | |
| 284 | + | err error | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | type deployDoneMsg deployOutcome | |
| 288 | + | ||
| 289 | + | type deploySpinnerModel struct { | |
| 290 | + | spinner components.SpinnerModel | |
| 291 | + | done <-chan deployOutcome | |
| 292 | + | outcome deployOutcome | |
| 293 | + | finished bool | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel { | |
| 297 | + | return deploySpinnerModel{ | |
| 298 | + | spinner: components.NewSpinner( | |
| 299 | + | components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)), | |
| 300 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |
| 301 | + | ), | |
| 302 | + | done: done, | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (m deploySpinnerModel) Init() tea.Cmd { | |
| 307 | + | return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done)) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 311 | + | switch msg := msg.(type) { | |
| 312 | + | case deployDoneMsg: | |
| 313 | + | m.outcome = deployOutcome(msg) | |
| 314 | + | m.finished = true | |
| 315 | + | return m, tea.Quit | |
| 316 | + | default: | |
| 317 | + | updated, cmd := m.spinner.Update(msg) | |
| 318 | + | spinnerModel, ok := updated.(components.SpinnerModel) | |
| 319 | + | if ok { | |
| 320 | + | m.spinner = spinnerModel | |
| 321 | + | } | |
| 322 | + | return m, cmd | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (m deploySpinnerModel) View() string { | |
| 327 | + | return m.spinner.View() | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func waitForDeployDone(done <-chan deployOutcome) tea.Cmd { | |
| 331 | + | return func() tea.Msg { | |
| 332 | + | return deployDoneMsg(<-done) | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func isInteractiveTerminal() bool { | |
| 337 | + | term := os.Getenv("TERM") | |
| 338 | + | if term == "" || term == "dumb" { | |
| 339 | + | return false | |
| 340 | + | } | |
| 341 | + | info, err := os.Stdout.Stat() | |
| 342 | + | if err != nil { | |
| 343 | + | return false | |
| 344 | + | } | |
| 345 | + | return (info.Mode() & os.ModeCharDevice) != 0 | |
| 346 | + | } | |
| 347 | + | ||
| 251 | 348 | // parseImageRef splits "registry/name:tag" into components. | |
| 252 | 349 | func parseImageRef(image string) (registry, name, tag string) { | |
| 253 | 350 | // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot" | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "regexp" | 8 | "regexp" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | 10 | ||||
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 12 | 13 | ||||
| 13 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -236,7 +237,7 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri | |||||
| 236 | } | 237 | } | ||
| 237 | } | 238 | } | ||
| 238 | 239 | ||||
| 239 | - | result, err := client.Deploy(ctx, pushDomain) | 240 | + | result, err := deployWithSpinner(ctx, client, pushDomain) |
| 240 | if err != nil { | 241 | if err != nil { | ||
| 241 | return fmt.Errorf("failed to deploy: %w", err) | 242 | return fmt.Errorf("failed to deploy: %w", err) | ||
| 242 | } | 243 | } | ||
| @@ -248,6 +249,102 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri | |||||
| 248 | return nil | 249 | return nil | ||
| 249 | } | 250 | } | ||
| 250 | 251 | ||||
| 252 | + | func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) { | |||
| 253 | + | if !isInteractiveTerminal() { | |||
| 254 | + | fmt.Printf("Deploying %s...\n", pushDomain) | |||
| 255 | + | return client.Deploy(ctx, pushDomain) | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | done := make(chan deployOutcome, 1) | |||
| 259 | + | go func() { | |||
| 260 | + | result, err := client.Deploy(ctx, pushDomain) | |||
| 261 | + | done <- deployOutcome{result: result, err: err} | |||
| 262 | + | }() | |||
| 263 | + | ||||
| 264 | + | model := newDeploySpinnerModel(pushDomain, done) | |||
| 265 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |||
| 266 | + | fmt.Print("\r\033[K") | |||
| 267 | + | if err != nil { | |||
| 268 | + | return nil, err | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | deployModel, ok := final.(deploySpinnerModel) | |||
| 272 | + | if !ok { | |||
| 273 | + | return nil, fmt.Errorf("spinner exited with unexpected model type %T", final) | |||
| 274 | + | } | |||
| 275 | + | if !deployModel.finished { | |||
| 276 | + | return nil, fmt.Errorf("deploy spinner exited before deploy result was received") | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | return deployModel.outcome.result, deployModel.outcome.err | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | type deployOutcome struct { | |||
| 283 | + | result *remote.DeployResult | |||
| 284 | + | err error | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | type deployDoneMsg deployOutcome | |||
| 288 | + | ||||
| 289 | + | type deploySpinnerModel struct { | |||
| 290 | + | spinner components.SpinnerModel | |||
| 291 | + | done <-chan deployOutcome | |||
| 292 | + | outcome deployOutcome | |||
| 293 | + | finished bool | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel { | |||
| 297 | + | return deploySpinnerModel{ | |||
| 298 | + | spinner: components.NewSpinner( | |||
| 299 | + | components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)), | |||
| 300 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |||
| 301 | + | ), | |||
| 302 | + | done: done, | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func (m deploySpinnerModel) Init() tea.Cmd { | |||
| 307 | + | return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done)) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 311 | + | switch msg := msg.(type) { | |||
| 312 | + | case deployDoneMsg: | |||
| 313 | + | m.outcome = deployOutcome(msg) | |||
| 314 | + | m.finished = true | |||
| 315 | + | return m, tea.Quit | |||
| 316 | + | default: | |||
| 317 | + | updated, cmd := m.spinner.Update(msg) | |||
| 318 | + | spinnerModel, ok := updated.(components.SpinnerModel) | |||
| 319 | + | if ok { | |||
| 320 | + | m.spinner = spinnerModel | |||
| 321 | + | } | |||
| 322 | + | return m, cmd | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (m deploySpinnerModel) View() string { | |||
| 327 | + | return m.spinner.View() | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func waitForDeployDone(done <-chan deployOutcome) tea.Cmd { | |||
| 331 | + | return func() tea.Msg { | |||
| 332 | + | return deployDoneMsg(<-done) | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | func isInteractiveTerminal() bool { | |||
| 337 | + | term := os.Getenv("TERM") | |||
| 338 | + | if term == "" || term == "dumb" { | |||
| 339 | + | return false | |||
| 340 | + | } | |||
| 341 | + | info, err := os.Stdout.Stat() | |||
| 342 | + | if err != nil { | |||
| 343 | + | return false | |||
| 344 | + | } | |||
| 345 | + | return (info.Mode() & os.ModeCharDevice) != 0 | |||
| 346 | + | } | |||
| 347 | + | ||||
| 251 | // parseImageRef splits "registry/name:tag" into components. | 348 | // parseImageRef splits "registry/name:tag" into components. | ||
| 252 | func parseImageRef(image string) (registry, name, tag string) { | 349 | func parseImageRef(image string) (registry, name, tag string) { | ||
| 253 | // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot" | 350 | // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot" | ||
M internal/adapters/out/docker/runtime.go +24 -7
| @@ -350,12 +350,13 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do | |||
| 350 | 350 | name := strings.TrimPrefix(resp.Name, "/") | |
| 351 | 351 | ||
| 352 | 352 | return &domain.Container{ | |
| 353 | - | ID: resp.ID, | |
| 354 | - | Image: resp.Config.Image, | |
| 355 | - | Name: name, | |
| 356 | - | Status: resp.State.Status, | |
| 357 | - | Ports: ports, | |
| 358 | - | Labels: resp.Config.Labels, | |
| 353 | + | ID: resp.ID, | |
| 354 | + | Image: resp.Config.Image, | |
| 355 | + | Name: name, | |
| 356 | + | Status: resp.State.Status, | |
| 357 | + | ExitCode: resp.State.ExitCode, | |
| 358 | + | Ports: ports, | |
| 359 | + | Labels: resp.Config.Labels, | |
| 359 | 360 | }, nil | |
| 360 | 361 | } | |
| 361 | 362 | ||
| @@ -688,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri | |||
| 688 | 689 | if err != nil { | |
| 689 | 690 | return "", false, log.WrapErr(err, "failed to inspect container") | |
| 690 | 691 | } | |
| 691 | - | if resp.State == nil || resp.State.Health == nil { | |
| 692 | + | ||
| 693 | + | // Detect configured healthchecks from container config rather than relying on | |
| 694 | + | // State.Health presence. Some runtimes can expose an empty State.Health struct | |
| 695 | + | // even when no healthcheck is configured. | |
| 696 | + | hasHealthcheck := hasConfiguredHealthcheck(resp.Config) | |
| 697 | + | if !hasHealthcheck { | |
| 692 | 698 | return "", false, nil | |
| 693 | 699 | } | |
| 700 | + | if resp.State == nil || resp.State.Health == nil { | |
| 701 | + | return "", true, nil | |
| 702 | + | } | |
| 694 | 703 | ||
| 695 | 704 | return resp.State.Health.Status, true, nil | |
| 696 | 705 | } | |
| 697 | 706 | ||
| 707 | + | func hasConfiguredHealthcheck(cfg *container.Config) bool { | |
| 708 | + | if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 { | |
| 709 | + | return false | |
| 710 | + | } | |
| 711 | + | // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images. | |
| 712 | + | return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE") | |
| 713 | + | } | |
| 714 | + | ||
| 698 | 715 | // GetContainerPort gets the host port for a container's internal port. | |
| 699 | 716 | func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | |
| 700 | 717 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -350,12 +350,13 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do | |||||
| 350 | name := strings.TrimPrefix(resp.Name, "/") | 350 | name := strings.TrimPrefix(resp.Name, "/") | ||
| 351 | 351 | ||||
| 352 | return &domain.Container{ | 352 | return &domain.Container{ | ||
| 353 | - | ID: resp.ID, | 353 | + | ID: resp.ID, |
| 354 | - | Image: resp.Config.Image, | 354 | + | Image: resp.Config.Image, |
| 355 | - | Name: name, | 355 | + | Name: name, |
| 356 | - | Status: resp.State.Status, | 356 | + | Status: resp.State.Status, |
| 357 | - | Ports: ports, | 357 | + | ExitCode: resp.State.ExitCode, |
| 358 | - | Labels: resp.Config.Labels, | 358 | + | Ports: ports, |
| 359 | + | Labels: resp.Config.Labels, | |||
| 359 | }, nil | 360 | }, nil | ||
| 360 | } | 361 | } | ||
| 361 | 362 | ||||
| @@ -688,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri | |||||
| 688 | if err != nil { | 689 | if err != nil { | ||
| 689 | return "", false, log.WrapErr(err, "failed to inspect container") | 690 | return "", false, log.WrapErr(err, "failed to inspect container") | ||
| 690 | } | 691 | } | ||
| 691 | - | if resp.State == nil || resp.State.Health == nil { | 692 | + | |
| 693 | + | // Detect configured healthchecks from container config rather than relying on | |||
| 694 | + | // State.Health presence. Some runtimes can expose an empty State.Health struct | |||
| 695 | + | // even when no healthcheck is configured. | |||
| 696 | + | hasHealthcheck := hasConfiguredHealthcheck(resp.Config) | |||
| 697 | + | if !hasHealthcheck { | |||
| 692 | return "", false, nil | 698 | return "", false, nil | ||
| 693 | } | 699 | } | ||
| 700 | + | if resp.State == nil || resp.State.Health == nil { | |||
| 701 | + | return "", true, nil | |||
| 702 | + | } | |||
| 694 | 703 | ||||
| 695 | return resp.State.Health.Status, true, nil | 704 | return resp.State.Health.Status, true, nil | ||
| 696 | } | 705 | } | ||
| 697 | 706 | ||||
| 707 | + | func hasConfiguredHealthcheck(cfg *container.Config) bool { | |||
| 708 | + | if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 { | |||
| 709 | + | return false | |||
| 710 | + | } | |||
| 711 | + | // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images. | |||
| 712 | + | return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE") | |||
| 713 | + | } | |||
| 714 | + | ||||
| 698 | // GetContainerPort gets the host port for a container's internal port. | 715 | // GetContainerPort gets the host port for a container's internal port. | ||
| 699 | func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | 716 | func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | ||
| 700 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 717 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
A internal/adapters/out/docker/runtime_health_status_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package docker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRuntime_GetContainerHealthStatus_ConfiguredHealthcheck(t *testing.T) { | |
| 14 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 15 | + | assert.Equal(t, http.MethodGet, r.Method) | |
| 16 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |
| 17 | + | ||
| 18 | + | w.Header().Set("Content-Type", "application/json") | |
| 19 | + | _, _ = w.Write([]byte(`{ | |
| 20 | + | "Id":"test-container", | |
| 21 | + | "State":{"Status":"running","Health":{"Status":"healthy","FailingStreak":0,"Log":[]}}, | |
| 22 | + | "Config":{"Healthcheck":{"Test":["CMD-SHELL","curl -f http://localhost/health || exit 1"]}} | |
| 23 | + | }`)) | |
| 24 | + | })) | |
| 25 | + | defer server.Close() | |
| 26 | + | ||
| 27 | + | runtime := newRuntimeForHTTPServer(t, server) | |
| 28 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |
| 29 | + | ||
| 30 | + | require.NoError(t, err) | |
| 31 | + | assert.True(t, hasHealthcheck) | |
| 32 | + | assert.Equal(t, "healthy", status) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestRuntime_GetContainerHealthStatus_NoConfiguredHealthcheckWithStateHealthObject(t *testing.T) { | |
| 36 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 37 | + | assert.Equal(t, http.MethodGet, r.Method) | |
| 38 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |
| 39 | + | ||
| 40 | + | w.Header().Set("Content-Type", "application/json") | |
| 41 | + | _, _ = w.Write([]byte(`{ | |
| 42 | + | "Id":"test-container", | |
| 43 | + | "State":{"Status":"running","Health":{"Status":"","FailingStreak":0,"Log":null}}, | |
| 44 | + | "Config":{"Healthcheck":null} | |
| 45 | + | }`)) | |
| 46 | + | })) | |
| 47 | + | defer server.Close() | |
| 48 | + | ||
| 49 | + | runtime := newRuntimeForHTTPServer(t, server) | |
| 50 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |
| 51 | + | ||
| 52 | + | require.NoError(t, err) | |
| 53 | + | assert.False(t, hasHealthcheck) | |
| 54 | + | assert.Equal(t, "", status) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) { | |
| 58 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 59 | + | assert.Equal(t, http.MethodGet, r.Method) | |
| 60 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |
| 61 | + | ||
| 62 | + | w.Header().Set("Content-Type", "application/json") | |
| 63 | + | _, _ = w.Write([]byte(`{ | |
| 64 | + | "Id":"test-container", | |
| 65 | + | "State":{"Status":"running","Health":{"Status":"starting","FailingStreak":0,"Log":[]}}, | |
| 66 | + | "Config":{"Healthcheck":{"Test":["NONE"]}} | |
| 67 | + | }`)) | |
| 68 | + | })) | |
| 69 | + | defer server.Close() | |
| 70 | + | ||
| 71 | + | runtime := newRuntimeForHTTPServer(t, server) | |
| 72 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |
| 73 | + | ||
| 74 | + | require.NoError(t, err) | |
| 75 | + | assert.False(t, hasHealthcheck) | |
| 76 | + | assert.Equal(t, "", status) | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package docker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRuntime_GetContainerHealthStatus_ConfiguredHealthcheck(t *testing.T) { | |||
| 14 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 15 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 16 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |||
| 17 | + | ||||
| 18 | + | w.Header().Set("Content-Type", "application/json") | |||
| 19 | + | _, _ = w.Write([]byte(`{ | |||
| 20 | + | "Id":"test-container", | |||
| 21 | + | "State":{"Status":"running","Health":{"Status":"healthy","FailingStreak":0,"Log":[]}}, | |||
| 22 | + | "Config":{"Healthcheck":{"Test":["CMD-SHELL","curl -f http://localhost/health || exit 1"]}} | |||
| 23 | + | }`)) | |||
| 24 | + | })) | |||
| 25 | + | defer server.Close() | |||
| 26 | + | ||||
| 27 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 28 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |||
| 29 | + | ||||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | assert.True(t, hasHealthcheck) | |||
| 32 | + | assert.Equal(t, "healthy", status) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestRuntime_GetContainerHealthStatus_NoConfiguredHealthcheckWithStateHealthObject(t *testing.T) { | |||
| 36 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 37 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 38 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |||
| 39 | + | ||||
| 40 | + | w.Header().Set("Content-Type", "application/json") | |||
| 41 | + | _, _ = w.Write([]byte(`{ | |||
| 42 | + | "Id":"test-container", | |||
| 43 | + | "State":{"Status":"running","Health":{"Status":"","FailingStreak":0,"Log":null}}, | |||
| 44 | + | "Config":{"Healthcheck":null} | |||
| 45 | + | }`)) | |||
| 46 | + | })) | |||
| 47 | + | defer server.Close() | |||
| 48 | + | ||||
| 49 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 50 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |||
| 51 | + | ||||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | assert.False(t, hasHealthcheck) | |||
| 54 | + | assert.Equal(t, "", status) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) { | |||
| 58 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 59 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 60 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |||
| 61 | + | ||||
| 62 | + | w.Header().Set("Content-Type", "application/json") | |||
| 63 | + | _, _ = w.Write([]byte(`{ | |||
| 64 | + | "Id":"test-container", | |||
| 65 | + | "State":{"Status":"running","Health":{"Status":"starting","FailingStreak":0,"Log":[]}}, | |||
| 66 | + | "Config":{"Healthcheck":{"Test":["NONE"]}} | |||
| 67 | + | }`)) | |||
| 68 | + | })) | |||
| 69 | + | defer server.Close() | |||
| 70 | + | ||||
| 71 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 72 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |||
| 73 | + | ||||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | assert.False(t, hasHealthcheck) | |||
| 76 | + | assert.Equal(t, "", status) | |||
| 77 | + | } | |||
M internal/adapters/out/filesystem/backup_storage.go +13 -0
| @@ -27,6 +27,8 @@ type BackupStorage struct { | |||
| 27 | 27 | ||
| 28 | 28 | // NewBackupStorage creates a new filesystem backup storage. | |
| 29 | 29 | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | |
| 30 | + | rootDir = expandTilde(rootDir) | |
| 31 | + | ||
| 30 | 32 | if err := os.MkdirAll(rootDir, 0750); err != nil { | |
| 31 | 33 | return nil, fmt.Errorf("failed to create backup directory: %w", err) | |
| 32 | 34 | } | |
| @@ -34,6 +36,17 @@ func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, erro | |||
| 34 | 36 | return &BackupStorage{rootDir: rootDir, log: log}, nil | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 39 | + | // expandTilde replaces a leading "~/" with the user's home directory. | |
| 40 | + | func expandTilde(path string) string { | |
| 41 | + | if !strings.HasPrefix(path, "~/") { | |
| 42 | + | return path | |
| 43 | + | } | |
| 44 | + | if home, err := os.UserHomeDir(); err == nil { | |
| 45 | + | return filepath.Join(home, path[2:]) | |
| 46 | + | } | |
| 47 | + | return path | |
| 48 | + | } | |
| 49 | + | ||
| 37 | 50 | // Store saves backup data and returns the absolute storage path. | |
| 38 | 51 | func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | |
| 39 | 52 | domainPart := sanitizeBackupPathComponent(domainName) | |
| @@ -27,6 +27,8 @@ type BackupStorage struct { | |||||
| 27 | 27 | ||||
| 28 | // NewBackupStorage creates a new filesystem backup storage. | 28 | // NewBackupStorage creates a new filesystem backup storage. | ||
| 29 | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | 29 | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | ||
| 30 | + | rootDir = expandTilde(rootDir) | |||
| 31 | + | ||||
| 30 | if err := os.MkdirAll(rootDir, 0750); err != nil { | 32 | if err := os.MkdirAll(rootDir, 0750); err != nil { | ||
| 31 | return nil, fmt.Errorf("failed to create backup directory: %w", err) | 33 | return nil, fmt.Errorf("failed to create backup directory: %w", err) | ||
| 32 | } | 34 | } | ||
| @@ -34,6 +36,17 @@ func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, erro | |||||
| 34 | return &BackupStorage{rootDir: rootDir, log: log}, nil | 36 | return &BackupStorage{rootDir: rootDir, log: log}, nil | ||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| 39 | + | // expandTilde replaces a leading "~/" with the user's home directory. | |||
| 40 | + | func expandTilde(path string) string { | |||
| 41 | + | if !strings.HasPrefix(path, "~/") { | |||
| 42 | + | return path | |||
| 43 | + | } | |||
| 44 | + | if home, err := os.UserHomeDir(); err == nil { | |||
| 45 | + | return filepath.Join(home, path[2:]) | |||
| 46 | + | } | |||
| 47 | + | return path | |||
| 48 | + | } | |||
| 49 | + | ||||
| 37 | // Store saves backup data and returns the absolute storage path. | 50 | // Store saves backup data and returns the absolute storage path. | ||
| 38 | func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | 51 | func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | ||
| 39 | domainPart := sanitizeBackupPathComponent(domainName) | 52 | domainPart := sanitizeBackupPathComponent(domainName) | ||
M internal/app/run.go +6 -1
| @@ -492,7 +492,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 492 | 492 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 493 | 493 | ||
| 494 | 494 | // Create log service for accessing logs via admin API | |
| 495 | - | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | |
| 495 | + | logSvc := logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | |
| 496 | 496 | ||
| 497 | 497 | // Create admin handler for admin API | |
| 498 | 498 | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc) | |
| @@ -1254,6 +1254,9 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||
| 1254 | 1254 | log.Warn().Err(err).Msg("failed to auto-start containers") | |
| 1255 | 1255 | } | |
| 1256 | 1256 | } | |
| 1257 | + | ||
| 1258 | + | // Start background monitor to restart crashed containers. | |
| 1259 | + | svc.containerSvc.StartMonitor(ctx) | |
| 1257 | 1260 | } | |
| 1258 | 1261 | ||
| 1259 | 1262 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| @@ -1662,6 +1665,8 @@ func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *containe | |||
| 1662 | 1665 | log.Warn().Err(err).Msg("proxy server shutdown error") | |
| 1663 | 1666 | } | |
| 1664 | 1667 | ||
| 1668 | + | containerSvc.StopMonitor() | |
| 1669 | + | ||
| 1665 | 1670 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | |
| 1666 | 1671 | log.Warn().Err(err).Msg("error during container shutdown") | |
| 1667 | 1672 | } | |
| @@ -492,7 +492,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 492 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | 492 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | ||
| 493 | 493 | ||||
| 494 | // Create log service for accessing logs via admin API | 494 | // Create log service for accessing logs via admin API | ||
| 495 | - | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | 495 | + | logSvc := logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) |
| 496 | 496 | ||||
| 497 | // Create admin handler for admin API | 497 | // Create admin handler for admin API | ||
| 498 | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc) | 498 | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc) | ||
| @@ -1254,6 +1254,9 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||||
| 1254 | log.Warn().Err(err).Msg("failed to auto-start containers") | 1254 | log.Warn().Err(err).Msg("failed to auto-start containers") | ||
| 1255 | } | 1255 | } | ||
| 1256 | } | 1256 | } | ||
| 1257 | + | ||||
| 1258 | + | // Start background monitor to restart crashed containers. | |||
| 1259 | + | svc.containerSvc.StartMonitor(ctx) | |||
| 1257 | } | 1260 | } | ||
| 1258 | 1261 | ||||
| 1259 | // createHTTPHandlers creates HTTP handlers with middleware. | 1262 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| @@ -1662,6 +1665,8 @@ func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *containe | |||||
| 1662 | log.Warn().Err(err).Msg("proxy server shutdown error") | 1665 | log.Warn().Err(err).Msg("proxy server shutdown error") | ||
| 1663 | } | 1666 | } | ||
| 1664 | 1667 | ||||
| 1668 | + | containerSvc.StopMonitor() | |||
| 1669 | + | ||||
| 1665 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | 1670 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | ||
| 1666 | log.Warn().Err(err).Msg("error during container shutdown") | 1671 | log.Warn().Err(err).Msg("error during container shutdown") | ||
| 1667 | } | 1672 | } | ||
M internal/domain/container.go +7 -6
| @@ -4,12 +4,13 @@ package domain | |||
| 4 | 4 | ||
| 5 | 5 | // Container represents a running container in the system. | |
| 6 | 6 | type Container struct { | |
| 7 | - | ID string | |
| 8 | - | Image string | |
| 9 | - | Name string | |
| 10 | - | Status string | |
| 11 | - | Ports []int | |
| 12 | - | Labels map[string]string | |
| 7 | + | ID string | |
| 8 | + | Image string | |
| 9 | + | Name string | |
| 10 | + | Status string | |
| 11 | + | ExitCode int | |
| 12 | + | Ports []int | |
| 13 | + | Labels map[string]string | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 16 | // NetworkInfo represents network configuration and state. | |
| @@ -4,12 +4,13 @@ package domain | |||||
| 4 | 4 | ||||
| 5 | // Container represents a running container in the system. | 5 | // Container represents a running container in the system. | ||
| 6 | type Container struct { | 6 | type Container struct { | ||
| 7 | - | ID string | 7 | + | ID string |
| 8 | - | Image string | 8 | + | Image string |
| 9 | - | Name string | 9 | + | Name string |
| 10 | - | Status string | 10 | + | Status string |
| 11 | - | Ports []int | 11 | + | ExitCode int |
| 12 | - | Labels map[string]string | 12 | + | Ports []int |
| 13 | + | Labels map[string]string | |||
| 13 | } | 14 | } | ||
| 14 | 15 | ||||
| 15 | // NetworkInfo represents network configuration and state. | 16 | // NetworkInfo represents network configuration and state. | ||
M internal/domain/event.go +17 -0
| @@ -81,3 +81,20 @@ func IsInternalDeploy(ctx context.Context) bool { | |||
| 81 | 81 | func WithInternalDeploy(ctx context.Context) context.Context { | |
| 82 | 82 | return context.WithValue(ctx, ContextKeyInternalDeploy, true) | |
| 83 | 83 | } | |
| 84 | + | ||
| 85 | + | const ( | |
| 86 | + | // ContextKeySkipReadiness indicates that readiness checks should be skipped | |
| 87 | + | // (e.g., during AutoStart where the background monitor handles crash recovery). | |
| 88 | + | ContextKeySkipReadiness contextKey = "skip_readiness" | |
| 89 | + | ) | |
| 90 | + | ||
| 91 | + | // WithSkipReadiness returns a context that skips readiness checks on deploy. | |
| 92 | + | func WithSkipReadiness(ctx context.Context) context.Context { | |
| 93 | + | return context.WithValue(ctx, ContextKeySkipReadiness, true) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // IsSkipReadiness checks if the context indicates readiness should be skipped. | |
| 97 | + | func IsSkipReadiness(ctx context.Context) bool { | |
| 98 | + | v, ok := ctx.Value(ContextKeySkipReadiness).(bool) | |
| 99 | + | return ok && v | |
| 100 | + | } | |
| @@ -81,3 +81,20 @@ func IsInternalDeploy(ctx context.Context) bool { | |||||
| 81 | func WithInternalDeploy(ctx context.Context) context.Context { | 81 | func WithInternalDeploy(ctx context.Context) context.Context { | ||
| 82 | return context.WithValue(ctx, ContextKeyInternalDeploy, true) | 82 | return context.WithValue(ctx, ContextKeyInternalDeploy, true) | ||
| 83 | } | 83 | } | ||
| 84 | + | ||||
| 85 | + | const ( | |||
| 86 | + | // ContextKeySkipReadiness indicates that readiness checks should be skipped | |||
| 87 | + | // (e.g., during AutoStart where the background monitor handles crash recovery). | |||
| 88 | + | ContextKeySkipReadiness contextKey = "skip_readiness" | |||
| 89 | + | ) | |||
| 90 | + | ||||
| 91 | + | // WithSkipReadiness returns a context that skips readiness checks on deploy. | |||
| 92 | + | func WithSkipReadiness(ctx context.Context) context.Context { | |||
| 93 | + | return context.WithValue(ctx, ContextKeySkipReadiness, true) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // IsSkipReadiness checks if the context indicates readiness should be skipped. | |||
| 97 | + | func IsSkipReadiness(ctx context.Context) bool { | |||
| 98 | + | v, ok := ctx.Value(ContextKeySkipReadiness).(bool) | |||
| 99 | + | return ok && v | |||
| 100 | + | } | |||
M internal/usecase/backup/detector.go +49 -27
| @@ -1,49 +1,71 @@ | |||
| 1 | 1 | package backup | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "slices" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/gordon/internal/domain" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 10 | + | // postgresAliases contains substrings that identify a PostgreSQL image or service. | |
| 11 | + | var postgresAliases = []string{"postgres", "postgresql", "pgsql", "postgis"} | |
| 12 | + | ||
| 9 | 13 | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | |
| 10 | 14 | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | |
| 11 | 15 | image := strings.ToLower(a.Image) | |
| 12 | 16 | name := strings.ToLower(a.Name) | |
| 13 | 17 | ||
| 14 | 18 | switch { | |
| 15 | - | case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"): | |
| 16 | - | port := 5432 | |
| 17 | - | hasPostgresPort := false | |
| 18 | - | firstPositive := 0 | |
| 19 | - | for _, p := range a.Ports { | |
| 20 | - | if p == 5432 { | |
| 21 | - | port = 5432 | |
| 22 | - | hasPostgresPort = true | |
| 23 | - | break | |
| 24 | - | } | |
| 25 | - | if p > 0 && firstPositive == 0 { | |
| 26 | - | firstPositive = p | |
| 27 | - | } | |
| 28 | - | } | |
| 29 | - | if !hasPostgresPort && firstPositive > 0 { | |
| 30 | - | port = firstPositive | |
| 31 | - | } | |
| 32 | - | return domain.DBInfo{ | |
| 33 | - | Type: domain.DBTypePostgreSQL, | |
| 34 | - | Version: postgresVersionFromImage(a.Image), | |
| 35 | - | Domain: domainName, | |
| 36 | - | Name: a.Name, | |
| 37 | - | Host: a.Name, | |
| 38 | - | Port: port, | |
| 39 | - | ContainerID: a.ContainerID, | |
| 40 | - | ImageName: a.Image, | |
| 41 | - | }, true | |
| 19 | + | case looksLikePostgres(image) || looksLikePostgres(name): | |
| 20 | + | return buildPostgresInfo(domainName, a), true | |
| 21 | + | case hasPort(a.Ports, 5432): | |
| 22 | + | // Port 5432 fallback: likely PostgreSQL even with non-standard naming. | |
| 23 | + | return buildPostgresInfo(domainName, a), true | |
| 42 | 24 | default: | |
| 43 | 25 | return domain.DBInfo{}, false | |
| 44 | 26 | } | |
| 45 | 27 | } | |
| 46 | 28 | ||
| 29 | + | // looksLikePostgres returns true if s contains any known PostgreSQL alias. | |
| 30 | + | func looksLikePostgres(s string) bool { | |
| 31 | + | for _, alias := range postgresAliases { | |
| 32 | + | if strings.Contains(s, alias) { | |
| 33 | + | return true | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | return false | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // hasPort returns true if the port list contains p. | |
| 40 | + | func hasPort(ports []int, p int) bool { | |
| 41 | + | return slices.Contains(ports, p) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // buildPostgresInfo constructs a DBInfo for a PostgreSQL attachment. | |
| 45 | + | func buildPostgresInfo(domainName string, a domain.Attachment) domain.DBInfo { | |
| 46 | + | port := 5432 | |
| 47 | + | for _, p := range a.Ports { | |
| 48 | + | if p == 5432 { | |
| 49 | + | port = 5432 | |
| 50 | + | break | |
| 51 | + | } | |
| 52 | + | if p > 0 && port == 5432 { | |
| 53 | + | port = p | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | return domain.DBInfo{ | |
| 58 | + | Type: domain.DBTypePostgreSQL, | |
| 59 | + | Version: postgresVersionFromImage(a.Image), | |
| 60 | + | Domain: domainName, | |
| 61 | + | Name: a.Name, | |
| 62 | + | Host: a.Name, | |
| 63 | + | Port: port, | |
| 64 | + | ContainerID: a.ContainerID, | |
| 65 | + | ImageName: a.Image, | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 47 | 69 | func postgresVersionFromImage(image string) string { | |
| 48 | 70 | lastColon := strings.LastIndex(image, ":") | |
| 49 | 71 | if lastColon == -1 { | |
| @@ -1,49 +1,71 @@ | |||||
| 1 | package backup | 1 | package backup | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "slices" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/gordon/internal/domain" | 7 | "github.com/bnema/gordon/internal/domain" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 10 | + | // postgresAliases contains substrings that identify a PostgreSQL image or service. | |||
| 11 | + | var postgresAliases = []string{"postgres", "postgresql", "pgsql", "postgis"} | |||
| 12 | + | ||||
| 9 | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | 13 | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | ||
| 10 | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | 14 | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | ||
| 11 | image := strings.ToLower(a.Image) | 15 | image := strings.ToLower(a.Image) | ||
| 12 | name := strings.ToLower(a.Name) | 16 | name := strings.ToLower(a.Name) | ||
| 13 | 17 | ||||
| 14 | switch { | 18 | switch { | ||
| 15 | - | case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"): | 19 | + | case looksLikePostgres(image) || looksLikePostgres(name): |
| 16 | - | port := 5432 | 20 | + | return buildPostgresInfo(domainName, a), true |
| 17 | - | hasPostgresPort := false | 21 | + | case hasPort(a.Ports, 5432): |
| 18 | - | firstPositive := 0 | 22 | + | // Port 5432 fallback: likely PostgreSQL even with non-standard naming. |
| 19 | - | for _, p := range a.Ports { | 23 | + | return buildPostgresInfo(domainName, a), true |
| 20 | - | if p == 5432 { | |||
| 21 | - | port = 5432 | |||
| 22 | - | hasPostgresPort = true | |||
| 23 | - | break | |||
| 24 | - | } | |||
| 25 | - | if p > 0 && firstPositive == 0 { | |||
| 26 | - | firstPositive = p | |||
| 27 | - | } | |||
| 28 | - | } | |||
| 29 | - | if !hasPostgresPort && firstPositive > 0 { | |||
| 30 | - | port = firstPositive | |||
| 31 | - | } | |||
| 32 | - | return domain.DBInfo{ | |||
| 33 | - | Type: domain.DBTypePostgreSQL, | |||
| 34 | - | Version: postgresVersionFromImage(a.Image), | |||
| 35 | - | Domain: domainName, | |||
| 36 | - | Name: a.Name, | |||
| 37 | - | Host: a.Name, | |||
| 38 | - | Port: port, | |||
| 39 | - | ContainerID: a.ContainerID, | |||
| 40 | - | ImageName: a.Image, | |||
| 41 | - | }, true | |||
| 42 | default: | 24 | default: | ||
| 43 | return domain.DBInfo{}, false | 25 | return domain.DBInfo{}, false | ||
| 44 | } | 26 | } | ||
| 45 | } | 27 | } | ||
| 46 | 28 | ||||
| 29 | + | // looksLikePostgres returns true if s contains any known PostgreSQL alias. | |||
| 30 | + | func looksLikePostgres(s string) bool { | |||
| 31 | + | for _, alias := range postgresAliases { | |||
| 32 | + | if strings.Contains(s, alias) { | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return false | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // hasPort returns true if the port list contains p. | |||
| 40 | + | func hasPort(ports []int, p int) bool { | |||
| 41 | + | return slices.Contains(ports, p) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // buildPostgresInfo constructs a DBInfo for a PostgreSQL attachment. | |||
| 45 | + | func buildPostgresInfo(domainName string, a domain.Attachment) domain.DBInfo { | |||
| 46 | + | port := 5432 | |||
| 47 | + | for _, p := range a.Ports { | |||
| 48 | + | if p == 5432 { | |||
| 49 | + | port = 5432 | |||
| 50 | + | break | |||
| 51 | + | } | |||
| 52 | + | if p > 0 && port == 5432 { | |||
| 53 | + | port = p | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | return domain.DBInfo{ | |||
| 58 | + | Type: domain.DBTypePostgreSQL, | |||
| 59 | + | Version: postgresVersionFromImage(a.Image), | |||
| 60 | + | Domain: domainName, | |||
| 61 | + | Name: a.Name, | |||
| 62 | + | Host: a.Name, | |||
| 63 | + | Port: port, | |||
| 64 | + | ContainerID: a.ContainerID, | |||
| 65 | + | ImageName: a.Image, | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 47 | func postgresVersionFromImage(image string) string { | 69 | func postgresVersionFromImage(image string) string { | ||
| 48 | lastColon := strings.LastIndex(image, ":") | 70 | lastColon := strings.LastIndex(image, ":") | ||
| 49 | if lastColon == -1 { | 71 | if lastColon == -1 { | ||
M internal/usecase/backup/detector_test.go +102 -0
| @@ -76,3 +76,105 @@ func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) { | |||
| 76 | 76 | }) | |
| 77 | 77 | assert.False(t, ok) | |
| 78 | 78 | } | |
| 79 | + | ||
| 80 | + | func TestDetectDatabaseFromAttachment_PgsqlImage(t *testing.T) { | |
| 81 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |
| 82 | + | Name: "pgsql", | |
| 83 | + | Image: "pgsql:16", | |
| 84 | + | ContainerID: "pgsql-1", | |
| 85 | + | Ports: []int{5432}, | |
| 86 | + | }) | |
| 87 | + | ||
| 88 | + | require.True(t, ok) | |
| 89 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |
| 90 | + | assert.Equal(t, "16", db.Version) | |
| 91 | + | assert.Equal(t, 5432, db.Port) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestDetectDatabaseFromAttachment_PgsqlInCompositeName(t *testing.T) { | |
| 95 | + | db, ok := detectDatabaseFromAttachment("mon-fauteuil.fr", domain.Attachment{ | |
| 96 | + | Name: "mon-fauteuil-prod-pgsql", | |
| 97 | + | Image: "mon-fauteuil-prod-pgsql:v18", | |
| 98 | + | ContainerID: "pgsql-2", | |
| 99 | + | Ports: []int{5432}, | |
| 100 | + | }) | |
| 101 | + | ||
| 102 | + | require.True(t, ok) | |
| 103 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |
| 104 | + | assert.Equal(t, "", db.Version) // v18 tag starts with non-digit, no version extracted | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestDetectDatabaseFromAttachment_PostgresqlFullName(t *testing.T) { | |
| 108 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |
| 109 | + | Name: "postgresql", | |
| 110 | + | Image: "postgresql:15", | |
| 111 | + | ContainerID: "pg-full-1", | |
| 112 | + | Ports: []int{5432}, | |
| 113 | + | }) | |
| 114 | + | ||
| 115 | + | require.True(t, ok) | |
| 116 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |
| 117 | + | assert.Equal(t, "15", db.Version) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestDetectDatabaseFromAttachment_PostgisImage(t *testing.T) { | |
| 121 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |
| 122 | + | Name: "postgis", | |
| 123 | + | Image: "postgis/postgis:16-3.4", | |
| 124 | + | ContainerID: "postgis-1", | |
| 125 | + | Ports: []int{5432}, | |
| 126 | + | }) | |
| 127 | + | ||
| 128 | + | require.True(t, ok) | |
| 129 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |
| 130 | + | assert.Equal(t, "16", db.Version) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestDetectDatabaseFromAttachment_Port5432Fallback(t *testing.T) { | |
| 134 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |
| 135 | + | Name: "custom-db", | |
| 136 | + | Image: "custom-db:latest", | |
| 137 | + | ContainerID: "fallback-1", | |
| 138 | + | Ports: []int{5432}, | |
| 139 | + | }) | |
| 140 | + | ||
| 141 | + | require.True(t, ok, "port 5432 should trigger PostgreSQL fallback detection") | |
| 142 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |
| 143 | + | assert.Equal(t, 5432, db.Port) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestDetectDatabaseFromAttachment_UnknownImageNoPostgresPort(t *testing.T) { | |
| 147 | + | _, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |
| 148 | + | Name: "custom-db", | |
| 149 | + | Image: "custom-db:latest", | |
| 150 | + | ContainerID: "unknown-1", | |
| 151 | + | Ports: []int{3306}, | |
| 152 | + | }) | |
| 153 | + | assert.False(t, ok, "non-postgres port with unknown image should not match") | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestLooksLikePostgres(t *testing.T) { | |
| 157 | + | tests := []struct { | |
| 158 | + | name string | |
| 159 | + | image string | |
| 160 | + | want bool | |
| 161 | + | }{ | |
| 162 | + | {name: "postgres base image", image: "postgres", want: true}, | |
| 163 | + | {name: "postgres with version tag", image: "postgres:16", want: true}, | |
| 164 | + | {name: "name contains postgres", image: "my-postgres-db", want: true}, | |
| 165 | + | {name: "postgresql alias", image: "postgresql", want: true}, | |
| 166 | + | {name: "pgsql alias", image: "pgsql", want: true}, | |
| 167 | + | {name: "project specific pgsql alias", image: "mon-fauteuil-prod-pgsql", want: true}, | |
| 168 | + | {name: "postgis alias", image: "postgis/postgis", want: true}, | |
| 169 | + | {name: "mysql negative", image: "mysql", want: false}, | |
| 170 | + | {name: "redis negative", image: "redis", want: false}, | |
| 171 | + | {name: "custom negative", image: "custom-db", want: false}, | |
| 172 | + | {name: "empty negative", image: "", want: false}, | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | for _, tc := range tests { | |
| 176 | + | t.Run(tc.name, func(t *testing.T) { | |
| 177 | + | assert.Equal(t, tc.want, looksLikePostgres(tc.image)) | |
| 178 | + | }) | |
| 179 | + | } | |
| 180 | + | } | |
| @@ -76,3 +76,105 @@ func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) { | |||||
| 76 | }) | 76 | }) | ||
| 77 | assert.False(t, ok) | 77 | assert.False(t, ok) | ||
| 78 | } | 78 | } | ||
| 79 | + | ||||
| 80 | + | func TestDetectDatabaseFromAttachment_PgsqlImage(t *testing.T) { | |||
| 81 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 82 | + | Name: "pgsql", | |||
| 83 | + | Image: "pgsql:16", | |||
| 84 | + | ContainerID: "pgsql-1", | |||
| 85 | + | Ports: []int{5432}, | |||
| 86 | + | }) | |||
| 87 | + | ||||
| 88 | + | require.True(t, ok) | |||
| 89 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 90 | + | assert.Equal(t, "16", db.Version) | |||
| 91 | + | assert.Equal(t, 5432, db.Port) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestDetectDatabaseFromAttachment_PgsqlInCompositeName(t *testing.T) { | |||
| 95 | + | db, ok := detectDatabaseFromAttachment("mon-fauteuil.fr", domain.Attachment{ | |||
| 96 | + | Name: "mon-fauteuil-prod-pgsql", | |||
| 97 | + | Image: "mon-fauteuil-prod-pgsql:v18", | |||
| 98 | + | ContainerID: "pgsql-2", | |||
| 99 | + | Ports: []int{5432}, | |||
| 100 | + | }) | |||
| 101 | + | ||||
| 102 | + | require.True(t, ok) | |||
| 103 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 104 | + | assert.Equal(t, "", db.Version) // v18 tag starts with non-digit, no version extracted | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestDetectDatabaseFromAttachment_PostgresqlFullName(t *testing.T) { | |||
| 108 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 109 | + | Name: "postgresql", | |||
| 110 | + | Image: "postgresql:15", | |||
| 111 | + | ContainerID: "pg-full-1", | |||
| 112 | + | Ports: []int{5432}, | |||
| 113 | + | }) | |||
| 114 | + | ||||
| 115 | + | require.True(t, ok) | |||
| 116 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 117 | + | assert.Equal(t, "15", db.Version) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestDetectDatabaseFromAttachment_PostgisImage(t *testing.T) { | |||
| 121 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 122 | + | Name: "postgis", | |||
| 123 | + | Image: "postgis/postgis:16-3.4", | |||
| 124 | + | ContainerID: "postgis-1", | |||
| 125 | + | Ports: []int{5432}, | |||
| 126 | + | }) | |||
| 127 | + | ||||
| 128 | + | require.True(t, ok) | |||
| 129 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 130 | + | assert.Equal(t, "16", db.Version) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestDetectDatabaseFromAttachment_Port5432Fallback(t *testing.T) { | |||
| 134 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 135 | + | Name: "custom-db", | |||
| 136 | + | Image: "custom-db:latest", | |||
| 137 | + | ContainerID: "fallback-1", | |||
| 138 | + | Ports: []int{5432}, | |||
| 139 | + | }) | |||
| 140 | + | ||||
| 141 | + | require.True(t, ok, "port 5432 should trigger PostgreSQL fallback detection") | |||
| 142 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 143 | + | assert.Equal(t, 5432, db.Port) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestDetectDatabaseFromAttachment_UnknownImageNoPostgresPort(t *testing.T) { | |||
| 147 | + | _, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 148 | + | Name: "custom-db", | |||
| 149 | + | Image: "custom-db:latest", | |||
| 150 | + | ContainerID: "unknown-1", | |||
| 151 | + | Ports: []int{3306}, | |||
| 152 | + | }) | |||
| 153 | + | assert.False(t, ok, "non-postgres port with unknown image should not match") | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestLooksLikePostgres(t *testing.T) { | |||
| 157 | + | tests := []struct { | |||
| 158 | + | name string | |||
| 159 | + | image string | |||
| 160 | + | want bool | |||
| 161 | + | }{ | |||
| 162 | + | {name: "postgres base image", image: "postgres", want: true}, | |||
| 163 | + | {name: "postgres with version tag", image: "postgres:16", want: true}, | |||
| 164 | + | {name: "name contains postgres", image: "my-postgres-db", want: true}, | |||
| 165 | + | {name: "postgresql alias", image: "postgresql", want: true}, | |||
| 166 | + | {name: "pgsql alias", image: "pgsql", want: true}, | |||
| 167 | + | {name: "project specific pgsql alias", image: "mon-fauteuil-prod-pgsql", want: true}, | |||
| 168 | + | {name: "postgis alias", image: "postgis/postgis", want: true}, | |||
| 169 | + | {name: "mysql negative", image: "mysql", want: false}, | |||
| 170 | + | {name: "redis negative", image: "redis", want: false}, | |||
| 171 | + | {name: "custom negative", image: "custom-db", want: false}, | |||
| 172 | + | {name: "empty negative", image: "", want: false}, | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | for _, tc := range tests { | |||
| 176 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 177 | + | assert.Equal(t, tc.want, looksLikePostgres(tc.image)) | |||
| 178 | + | }) | |||
| 179 | + | } | |||
| 180 | + | } | |||
M internal/usecase/backup/service.go +2 -6
| @@ -291,18 +291,14 @@ func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error | |||
| 291 | 291 | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | |
| 292 | 292 | } | |
| 293 | 293 | ||
| 294 | - | func postgresDumpCommand(dbName string) string { | |
| 295 | - | return fmt.Sprintf("PGDATABASE=%s pg_dump -Fc --dbname=\"$PGDATABASE\" --username=\"${POSTGRES_USER:-postgres}\"", shellQuote(dbName)) | |
| 294 | + | func postgresDumpCommand(_ string) string { | |
| 295 | + | return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\"" | |
| 296 | 296 | } | |
| 297 | 297 | ||
| 298 | 298 | func pgDumpToPathCommand(path, dbName string) string { | |
| 299 | 299 | return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path) | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | - | func shellQuote(value string) string { | |
| 303 | - | return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" | |
| 304 | - | } | |
| 305 | - | ||
| 306 | 302 | func (s *Service) cleanupDumpFile(containerID, dumpPath string) { | |
| 307 | 303 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 308 | 304 | defer cancel() | |
| @@ -291,18 +291,14 @@ func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error | |||||
| 291 | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | 291 | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | ||
| 292 | } | 292 | } | ||
| 293 | 293 | ||||
| 294 | - | func postgresDumpCommand(dbName string) string { | 294 | + | func postgresDumpCommand(_ string) string { |
| 295 | - | return fmt.Sprintf("PGDATABASE=%s pg_dump -Fc --dbname=\"$PGDATABASE\" --username=\"${POSTGRES_USER:-postgres}\"", shellQuote(dbName)) | 295 | + | return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\"" |
| 296 | } | 296 | } | ||
| 297 | 297 | ||||
| 298 | func pgDumpToPathCommand(path, dbName string) string { | 298 | func pgDumpToPathCommand(path, dbName string) string { | ||
| 299 | return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path) | 299 | return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path) | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | - | func shellQuote(value string) string { | |||
| 303 | - | return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" | |||
| 304 | - | } | |||
| 305 | - | ||||
| 306 | func (s *Service) cleanupDumpFile(containerID, dumpPath string) { | 302 | func (s *Service) cleanupDumpFile(containerID, dumpPath string) { | ||
| 307 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | 303 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| 308 | defer cancel() | 304 | defer cancel() | ||
M internal/usecase/backup/service_test.go +5 -4
| @@ -50,7 +50,7 @@ func TestService_RunBackup_Postgres(t *testing.T) { | |||
| 50 | 50 | return false | |
| 51 | 51 | } | |
| 52 | 52 | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) && | |
| 53 | - | bytes.Contains([]byte(cmd[2]), []byte("PGDATABASE='postgres'")) && | |
| 53 | + | bytes.Contains([]byte(cmd[2]), []byte("${POSTGRES_DB:-postgres}")) && | |
| 54 | 54 | bytes.Contains([]byte(cmd[2]), []byte(" > ")) | |
| 55 | 55 | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | |
| 56 | 56 | ||
| @@ -125,9 +125,10 @@ func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) { | |||
| 125 | 125 | assert.Equal(t, "postgres", db.Name) | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | - | func TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) { | |
| 129 | - | cmd := postgresDumpCommand("customer data") | |
| 130 | - | assert.Contains(t, cmd, "PGDATABASE='customer data'") | |
| 128 | + | func TestPostgresDumpCommandUsesEnvVar(t *testing.T) { | |
| 129 | + | cmd := postgresDumpCommand("ignored") | |
| 130 | + | assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}") | |
| 131 | + | assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}") | |
| 131 | 132 | } | |
| 132 | 133 | ||
| 133 | 134 | func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) { | |
| @@ -50,7 +50,7 @@ func TestService_RunBackup_Postgres(t *testing.T) { | |||||
| 50 | return false | 50 | return false | ||
| 51 | } | 51 | } | ||
| 52 | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) && | 52 | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) && | ||
| 53 | - | bytes.Contains([]byte(cmd[2]), []byte("PGDATABASE='postgres'")) && | 53 | + | bytes.Contains([]byte(cmd[2]), []byte("${POSTGRES_DB:-postgres}")) && |
| 54 | bytes.Contains([]byte(cmd[2]), []byte(" > ")) | 54 | bytes.Contains([]byte(cmd[2]), []byte(" > ")) | ||
| 55 | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | 55 | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | ||
| 56 | 56 | ||||
| @@ -125,9 +125,10 @@ func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) { | |||||
| 125 | assert.Equal(t, "postgres", db.Name) | 125 | assert.Equal(t, "postgres", db.Name) | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | func TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) { | 128 | + | func TestPostgresDumpCommandUsesEnvVar(t *testing.T) { |
| 129 | - | cmd := postgresDumpCommand("customer data") | 129 | + | cmd := postgresDumpCommand("ignored") |
| 130 | - | assert.Contains(t, cmd, "PGDATABASE='customer data'") | 130 | + | assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}") |
| 131 | + | assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}") | |||
| 131 | } | 132 | } | ||
| 132 | 133 | ||||
| 133 | func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) { | 134 | func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) { | ||
A internal/usecase/container/monitor.go +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | monitorDefaultInterval = 15 * time.Second | |
| 15 | + | crashLoopThreshold = 3 | |
| 16 | + | crashLoopWindow = 5 * time.Minute | |
| 17 | + | backoffCap = 15 * time.Minute | |
| 18 | + | stableRunningDuration = 5 * time.Minute | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // restartRecord tracks restart attempts for crash loop detection. | |
| 22 | + | type restartRecord struct { | |
| 23 | + | attempts []time.Time | |
| 24 | + | backoffEnd time.Time | |
| 25 | + | consecutive int | |
| 26 | + | lastSeen time.Time // last time the container was seen running | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Monitor watches tracked containers and restarts crashed ones. | |
| 30 | + | type Monitor struct { | |
| 31 | + | service *Service | |
| 32 | + | stopCh chan struct{} | |
| 33 | + | stopped chan struct{} | |
| 34 | + | interval time.Duration | |
| 35 | + | mu sync.Mutex | |
| 36 | + | history map[string]*restartRecord // keyed by domain | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // newMonitor creates a new container monitor. | |
| 40 | + | func newMonitor(service *Service) *Monitor { | |
| 41 | + | return &Monitor{ | |
| 42 | + | service: service, | |
| 43 | + | stopCh: make(chan struct{}), | |
| 44 | + | stopped: make(chan struct{}), | |
| 45 | + | interval: monitorDefaultInterval, | |
| 46 | + | history: make(map[string]*restartRecord), | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Start begins the background monitoring loop. | |
| 51 | + | func (m *Monitor) Start(ctx context.Context) { | |
| 52 | + | log := zerowrap.FromCtx(ctx) | |
| 53 | + | log.Info().Dur("interval", m.interval).Msg("container monitor started") | |
| 54 | + | ||
| 55 | + | go m.run(ctx) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Stop signals the monitor to stop and waits for it to finish. | |
| 59 | + | func (m *Monitor) Stop() { | |
| 60 | + | close(m.stopCh) | |
| 61 | + | <-m.stopped | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (m *Monitor) run(ctx context.Context) { | |
| 65 | + | defer close(m.stopped) | |
| 66 | + | ||
| 67 | + | ticker := time.NewTicker(m.interval) | |
| 68 | + | defer ticker.Stop() | |
| 69 | + | ||
| 70 | + | for { | |
| 71 | + | select { | |
| 72 | + | case <-m.stopCh: | |
| 73 | + | return | |
| 74 | + | case <-ctx.Done(): | |
| 75 | + | return | |
| 76 | + | case <-ticker.C: | |
| 77 | + | m.check(ctx) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (m *Monitor) check(ctx context.Context) { | |
| 83 | + | log := zerowrap.FromCtx(ctx) | |
| 84 | + | ||
| 85 | + | // Snapshot tracked containers under lock. | |
| 86 | + | m.service.mu.RLock() | |
| 87 | + | snapshot := make(map[string]*domain.Container, len(m.service.containers)) | |
| 88 | + | for d, c := range m.service.containers { | |
| 89 | + | snapshot[d] = c | |
| 90 | + | } | |
| 91 | + | m.service.mu.RUnlock() | |
| 92 | + | ||
| 93 | + | now := time.Now() | |
| 94 | + | ||
| 95 | + | for domainName, tracked := range snapshot { | |
| 96 | + | m.checkContainer(ctx, log, domainName, tracked, now) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (m *Monitor) checkContainer(ctx context.Context, log zerowrap.Logger, domainName string, tracked *domain.Container, now time.Time) { | |
| 101 | + | if tracked == nil { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | inspected, err := m.service.runtime.InspectContainer(ctx, tracked.ID) | |
| 106 | + | if err != nil { | |
| 107 | + | log.Debug().Err(err).Str("domain", domainName).Str("container_id", tracked.ID). | |
| 108 | + | Msg("monitor: failed to inspect container, may have been replaced by deploy") | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | switch { | |
| 113 | + | case inspected.Status == string(domain.ContainerStatusRunning): | |
| 114 | + | m.handleRunning(ctx, log, domainName, tracked.ID, now) | |
| 115 | + | ||
| 116 | + | case inspected.Status == string(domain.ContainerStatusExited): | |
| 117 | + | if inspected.ExitCode == 0 { | |
| 118 | + | // Graceful stop (exit 0): respect user intent, don't restart. | |
| 119 | + | log.Debug().Str("domain", domainName).Msg("monitor: container exited gracefully (code 0), skipping") | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | // Crashed (non-zero exit): restart if not in backoff. | |
| 123 | + | m.handleCrash(ctx, log, domainName, tracked.ID, inspected.ExitCode, now) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domainName, containerID string, now time.Time) { | |
| 128 | + | // Check Docker health status for running containers. | |
| 129 | + | healthStatus, hasHealthcheck, err := m.service.runtime.GetContainerHealthStatus(ctx, containerID) | |
| 130 | + | if err != nil { | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | if hasHealthcheck && healthStatus == "unhealthy" { | |
| 135 | + | log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting") | |
| 136 | + | if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil { | |
| 137 | + | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart unhealthy container") | |
| 138 | + | } | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Container is running and healthy — clear backoff if stable long enough. | |
| 143 | + | m.mu.Lock() | |
| 144 | + | rec, exists := m.history[domainName] | |
| 145 | + | if exists { | |
| 146 | + | if rec.lastSeen.IsZero() { | |
| 147 | + | rec.lastSeen = now | |
| 148 | + | } | |
| 149 | + | if now.Sub(rec.lastSeen) >= stableRunningDuration { | |
| 150 | + | // Container has been running for 5+ min — clear crash history. | |
| 151 | + | delete(m.history, domainName) | |
| 152 | + | m.mu.Unlock() | |
| 153 | + | log.Info().Str("domain", domainName).Msg("monitor: container stable, cleared crash history") | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | m.mu.Unlock() | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) { | |
| 161 | + | m.mu.Lock() | |
| 162 | + | rec := m.history[domainName] | |
| 163 | + | if rec == nil { | |
| 164 | + | rec = &restartRecord{} | |
| 165 | + | m.history[domainName] = rec | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Check backoff. | |
| 169 | + | if now.Before(rec.backoffEnd) { | |
| 170 | + | m.mu.Unlock() | |
| 171 | + | log.Debug().Str("domain", domainName). | |
| 172 | + | Time("backoff_until", rec.backoffEnd). | |
| 173 | + | Msg("monitor: in crash loop backoff, skipping restart") | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Record this crash attempt. | |
| 178 | + | rec.attempts = append(rec.attempts, now) | |
| 179 | + | rec.consecutive++ | |
| 180 | + | rec.lastSeen = time.Time{} // reset stable timer | |
| 181 | + | ||
| 182 | + | // Prune old attempts outside the crash loop window. | |
| 183 | + | cutoff := now.Add(-crashLoopWindow) | |
| 184 | + | pruned := rec.attempts[:0] | |
| 185 | + | for _, t := range rec.attempts { | |
| 186 | + | if t.After(cutoff) { | |
| 187 | + | pruned = append(pruned, t) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | rec.attempts = pruned | |
| 191 | + | ||
| 192 | + | // Check for crash loop. | |
| 193 | + | if len(rec.attempts) >= crashLoopThreshold { | |
| 194 | + | backoff := m.computeBackoff(rec.consecutive) | |
| 195 | + | rec.backoffEnd = now.Add(backoff) | |
| 196 | + | m.mu.Unlock() | |
| 197 | + | ||
| 198 | + | log.Error().Str("domain", domainName). | |
| 199 | + | Int("consecutive", rec.consecutive). | |
| 200 | + | Dur("backoff", backoff). | |
| 201 | + | Msg("monitor: crash loop detected, backing off") | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | m.mu.Unlock() | |
| 205 | + | ||
| 206 | + | // Verify container ID still matches tracked state (deploy may have replaced it). | |
| 207 | + | m.service.mu.RLock() | |
| 208 | + | current := m.service.containers[domainName] | |
| 209 | + | m.service.mu.RUnlock() | |
| 210 | + | if current == nil || current.ID != containerID { | |
| 211 | + | log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart") | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | log.Warn().Str("domain", domainName).Str("container_id", containerID). | |
| 216 | + | Int("exit_code", exitCode). | |
| 217 | + | Msg("monitor: restarting crashed container") | |
| 218 | + | ||
| 219 | + | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | |
| 220 | + | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (m *Monitor) computeBackoff(consecutive int) time.Duration { | |
| 225 | + | // Exponential backoff: 1min, 2min, 4min, 8min, cap 15min. | |
| 226 | + | base := time.Minute | |
| 227 | + | shift := consecutive - crashLoopThreshold | |
| 228 | + | if shift < 0 { | |
| 229 | + | shift = 0 | |
| 230 | + | } | |
| 231 | + | if shift > 4 { | |
| 232 | + | shift = 4 // cap shift to avoid overflow (max 16min, then capped to backoffCap) | |
| 233 | + | } | |
| 234 | + | backoff := base << uint(shift) // #nosec G115 -- shift is bounded [0,4] | |
| 235 | + | if backoff > backoffCap { | |
| 236 | + | backoff = backoffCap | |
| 237 | + | } | |
| 238 | + | return backoff | |
| 239 | + | } | |
| @@ -0,0 +1,239 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | monitorDefaultInterval = 15 * time.Second | |||
| 15 | + | crashLoopThreshold = 3 | |||
| 16 | + | crashLoopWindow = 5 * time.Minute | |||
| 17 | + | backoffCap = 15 * time.Minute | |||
| 18 | + | stableRunningDuration = 5 * time.Minute | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // restartRecord tracks restart attempts for crash loop detection. | |||
| 22 | + | type restartRecord struct { | |||
| 23 | + | attempts []time.Time | |||
| 24 | + | backoffEnd time.Time | |||
| 25 | + | consecutive int | |||
| 26 | + | lastSeen time.Time // last time the container was seen running | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Monitor watches tracked containers and restarts crashed ones. | |||
| 30 | + | type Monitor struct { | |||
| 31 | + | service *Service | |||
| 32 | + | stopCh chan struct{} | |||
| 33 | + | stopped chan struct{} | |||
| 34 | + | interval time.Duration | |||
| 35 | + | mu sync.Mutex | |||
| 36 | + | history map[string]*restartRecord // keyed by domain | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // newMonitor creates a new container monitor. | |||
| 40 | + | func newMonitor(service *Service) *Monitor { | |||
| 41 | + | return &Monitor{ | |||
| 42 | + | service: service, | |||
| 43 | + | stopCh: make(chan struct{}), | |||
| 44 | + | stopped: make(chan struct{}), | |||
| 45 | + | interval: monitorDefaultInterval, | |||
| 46 | + | history: make(map[string]*restartRecord), | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Start begins the background monitoring loop. | |||
| 51 | + | func (m *Monitor) Start(ctx context.Context) { | |||
| 52 | + | log := zerowrap.FromCtx(ctx) | |||
| 53 | + | log.Info().Dur("interval", m.interval).Msg("container monitor started") | |||
| 54 | + | ||||
| 55 | + | go m.run(ctx) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Stop signals the monitor to stop and waits for it to finish. | |||
| 59 | + | func (m *Monitor) Stop() { | |||
| 60 | + | close(m.stopCh) | |||
| 61 | + | <-m.stopped | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (m *Monitor) run(ctx context.Context) { | |||
| 65 | + | defer close(m.stopped) | |||
| 66 | + | ||||
| 67 | + | ticker := time.NewTicker(m.interval) | |||
| 68 | + | defer ticker.Stop() | |||
| 69 | + | ||||
| 70 | + | for { | |||
| 71 | + | select { | |||
| 72 | + | case <-m.stopCh: | |||
| 73 | + | return | |||
| 74 | + | case <-ctx.Done(): | |||
| 75 | + | return | |||
| 76 | + | case <-ticker.C: | |||
| 77 | + | m.check(ctx) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (m *Monitor) check(ctx context.Context) { | |||
| 83 | + | log := zerowrap.FromCtx(ctx) | |||
| 84 | + | ||||
| 85 | + | // Snapshot tracked containers under lock. | |||
| 86 | + | m.service.mu.RLock() | |||
| 87 | + | snapshot := make(map[string]*domain.Container, len(m.service.containers)) | |||
| 88 | + | for d, c := range m.service.containers { | |||
| 89 | + | snapshot[d] = c | |||
| 90 | + | } | |||
| 91 | + | m.service.mu.RUnlock() | |||
| 92 | + | ||||
| 93 | + | now := time.Now() | |||
| 94 | + | ||||
| 95 | + | for domainName, tracked := range snapshot { | |||
| 96 | + | m.checkContainer(ctx, log, domainName, tracked, now) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (m *Monitor) checkContainer(ctx context.Context, log zerowrap.Logger, domainName string, tracked *domain.Container, now time.Time) { | |||
| 101 | + | if tracked == nil { | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | inspected, err := m.service.runtime.InspectContainer(ctx, tracked.ID) | |||
| 106 | + | if err != nil { | |||
| 107 | + | log.Debug().Err(err).Str("domain", domainName).Str("container_id", tracked.ID). | |||
| 108 | + | Msg("monitor: failed to inspect container, may have been replaced by deploy") | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | switch { | |||
| 113 | + | case inspected.Status == string(domain.ContainerStatusRunning): | |||
| 114 | + | m.handleRunning(ctx, log, domainName, tracked.ID, now) | |||
| 115 | + | ||||
| 116 | + | case inspected.Status == string(domain.ContainerStatusExited): | |||
| 117 | + | if inspected.ExitCode == 0 { | |||
| 118 | + | // Graceful stop (exit 0): respect user intent, don't restart. | |||
| 119 | + | log.Debug().Str("domain", domainName).Msg("monitor: container exited gracefully (code 0), skipping") | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | // Crashed (non-zero exit): restart if not in backoff. | |||
| 123 | + | m.handleCrash(ctx, log, domainName, tracked.ID, inspected.ExitCode, now) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domainName, containerID string, now time.Time) { | |||
| 128 | + | // Check Docker health status for running containers. | |||
| 129 | + | healthStatus, hasHealthcheck, err := m.service.runtime.GetContainerHealthStatus(ctx, containerID) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | if hasHealthcheck && healthStatus == "unhealthy" { | |||
| 135 | + | log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting") | |||
| 136 | + | if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil { | |||
| 137 | + | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart unhealthy container") | |||
| 138 | + | } | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Container is running and healthy — clear backoff if stable long enough. | |||
| 143 | + | m.mu.Lock() | |||
| 144 | + | rec, exists := m.history[domainName] | |||
| 145 | + | if exists { | |||
| 146 | + | if rec.lastSeen.IsZero() { | |||
| 147 | + | rec.lastSeen = now | |||
| 148 | + | } | |||
| 149 | + | if now.Sub(rec.lastSeen) >= stableRunningDuration { | |||
| 150 | + | // Container has been running for 5+ min — clear crash history. | |||
| 151 | + | delete(m.history, domainName) | |||
| 152 | + | m.mu.Unlock() | |||
| 153 | + | log.Info().Str("domain", domainName).Msg("monitor: container stable, cleared crash history") | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | m.mu.Unlock() | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) { | |||
| 161 | + | m.mu.Lock() | |||
| 162 | + | rec := m.history[domainName] | |||
| 163 | + | if rec == nil { | |||
| 164 | + | rec = &restartRecord{} | |||
| 165 | + | m.history[domainName] = rec | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Check backoff. | |||
| 169 | + | if now.Before(rec.backoffEnd) { | |||
| 170 | + | m.mu.Unlock() | |||
| 171 | + | log.Debug().Str("domain", domainName). | |||
| 172 | + | Time("backoff_until", rec.backoffEnd). | |||
| 173 | + | Msg("monitor: in crash loop backoff, skipping restart") | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Record this crash attempt. | |||
| 178 | + | rec.attempts = append(rec.attempts, now) | |||
| 179 | + | rec.consecutive++ | |||
| 180 | + | rec.lastSeen = time.Time{} // reset stable timer | |||
| 181 | + | ||||
| 182 | + | // Prune old attempts outside the crash loop window. | |||
| 183 | + | cutoff := now.Add(-crashLoopWindow) | |||
| 184 | + | pruned := rec.attempts[:0] | |||
| 185 | + | for _, t := range rec.attempts { | |||
| 186 | + | if t.After(cutoff) { | |||
| 187 | + | pruned = append(pruned, t) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | rec.attempts = pruned | |||
| 191 | + | ||||
| 192 | + | // Check for crash loop. | |||
| 193 | + | if len(rec.attempts) >= crashLoopThreshold { | |||
| 194 | + | backoff := m.computeBackoff(rec.consecutive) | |||
| 195 | + | rec.backoffEnd = now.Add(backoff) | |||
| 196 | + | m.mu.Unlock() | |||
| 197 | + | ||||
| 198 | + | log.Error().Str("domain", domainName). | |||
| 199 | + | Int("consecutive", rec.consecutive). | |||
| 200 | + | Dur("backoff", backoff). | |||
| 201 | + | Msg("monitor: crash loop detected, backing off") | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | m.mu.Unlock() | |||
| 205 | + | ||||
| 206 | + | // Verify container ID still matches tracked state (deploy may have replaced it). | |||
| 207 | + | m.service.mu.RLock() | |||
| 208 | + | current := m.service.containers[domainName] | |||
| 209 | + | m.service.mu.RUnlock() | |||
| 210 | + | if current == nil || current.ID != containerID { | |||
| 211 | + | log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart") | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | log.Warn().Str("domain", domainName).Str("container_id", containerID). | |||
| 216 | + | Int("exit_code", exitCode). | |||
| 217 | + | Msg("monitor: restarting crashed container") | |||
| 218 | + | ||||
| 219 | + | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | |||
| 220 | + | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func (m *Monitor) computeBackoff(consecutive int) time.Duration { | |||
| 225 | + | // Exponential backoff: 1min, 2min, 4min, 8min, cap 15min. | |||
| 226 | + | base := time.Minute | |||
| 227 | + | shift := consecutive - crashLoopThreshold | |||
| 228 | + | if shift < 0 { | |||
| 229 | + | shift = 0 | |||
| 230 | + | } | |||
| 231 | + | if shift > 4 { | |||
| 232 | + | shift = 4 // cap shift to avoid overflow (max 16min, then capped to backoffCap) | |||
| 233 | + | } | |||
| 234 | + | backoff := base << uint(shift) // #nosec G115 -- shift is bounded [0,4] | |||
| 235 | + | if backoff > backoffCap { | |||
| 236 | + | backoff = backoffCap | |||
| 237 | + | } | |||
| 238 | + | return backoff | |||
| 239 | + | } | |||
A internal/usecase/container/monitor_test.go +324 -0
| @@ -0,0 +1,324 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func newTestService(runtime *mocks.MockContainerRuntime) *Service { | |
| 17 | + | return &Service{ | |
| 18 | + | runtime: runtime, | |
| 19 | + | config: Config{}, | |
| 20 | + | containers: make(map[string]*domain.Container), | |
| 21 | + | attachments: make(map[string][]string), | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func monitorTestContext() context.Context { | |
| 26 | + | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestMonitor_RestartsCrashedContainer(t *testing.T) { | |
| 30 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 31 | + | svc := newTestService(runtime) | |
| 32 | + | ||
| 33 | + | // Track a container. | |
| 34 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 35 | + | ID: "ctr-1", | |
| 36 | + | Image: "myapp:latest", | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Inspect returns exited with non-zero exit code. | |
| 40 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |
| 41 | + | ID: "ctr-1", | |
| 42 | + | Status: string(domain.ContainerStatusExited), | |
| 43 | + | ExitCode: 1, | |
| 44 | + | }, nil) | |
| 45 | + | ||
| 46 | + | // Monitor should restart it. | |
| 47 | + | runtime.EXPECT().StartContainer(mock.Anything, "ctr-1").Return(nil) | |
| 48 | + | ||
| 49 | + | m := newMonitor(svc) | |
| 50 | + | m.check(monitorTestContext()) | |
| 51 | + | ||
| 52 | + | runtime.AssertExpectations(t) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestMonitor_SkipsGracefulExit(t *testing.T) { | |
| 56 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 57 | + | svc := newTestService(runtime) | |
| 58 | + | ||
| 59 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 60 | + | ID: "ctr-1", | |
| 61 | + | Image: "myapp:latest", | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Exited with code 0 — graceful stop. | |
| 65 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |
| 66 | + | ID: "ctr-1", | |
| 67 | + | Status: string(domain.ContainerStatusExited), | |
| 68 | + | ExitCode: 0, | |
| 69 | + | }, nil) | |
| 70 | + | ||
| 71 | + | // Should NOT call StartContainer. | |
| 72 | + | ||
| 73 | + | m := newMonitor(svc) | |
| 74 | + | m.check(monitorTestContext()) | |
| 75 | + | ||
| 76 | + | runtime.AssertExpectations(t) | |
| 77 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestMonitor_SkipsRunningContainer(t *testing.T) { | |
| 81 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 82 | + | svc := newTestService(runtime) | |
| 83 | + | ||
| 84 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 85 | + | ID: "ctr-1", | |
| 86 | + | Image: "myapp:latest", | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |
| 90 | + | ID: "ctr-1", | |
| 91 | + | Status: string(domain.ContainerStatusRunning), | |
| 92 | + | }, nil) | |
| 93 | + | ||
| 94 | + | // Running container — check health status. | |
| 95 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil) | |
| 96 | + | ||
| 97 | + | m := newMonitor(svc) | |
| 98 | + | m.check(monitorTestContext()) | |
| 99 | + | ||
| 100 | + | runtime.AssertExpectations(t) | |
| 101 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestMonitor_RestartsUnhealthyContainer(t *testing.T) { | |
| 105 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 106 | + | svc := newTestService(runtime) | |
| 107 | + | ||
| 108 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 109 | + | ID: "ctr-1", | |
| 110 | + | Image: "myapp:latest", | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |
| 114 | + | ID: "ctr-1", | |
| 115 | + | Status: string(domain.ContainerStatusRunning), | |
| 116 | + | }, nil) | |
| 117 | + | ||
| 118 | + | // Running but Docker health says unhealthy. | |
| 119 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("unhealthy", true, nil) | |
| 120 | + | ||
| 121 | + | // Should restart the unhealthy container. | |
| 122 | + | runtime.EXPECT().RestartContainer(mock.Anything, "ctr-1").Return(nil) | |
| 123 | + | ||
| 124 | + | m := newMonitor(svc) | |
| 125 | + | m.check(monitorTestContext()) | |
| 126 | + | ||
| 127 | + | runtime.AssertExpectations(t) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestMonitor_CrashLoopBackoff(t *testing.T) { | |
| 131 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 132 | + | svc := newTestService(runtime) | |
| 133 | + | ||
| 134 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 135 | + | ID: "ctr-1", | |
| 136 | + | Image: "myapp:latest", | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | m := newMonitor(svc) | |
| 140 | + | ctx := monitorTestContext() | |
| 141 | + | now := time.Now() | |
| 142 | + | ||
| 143 | + | // Simulate 3 crashes within the crash loop window. | |
| 144 | + | m.mu.Lock() | |
| 145 | + | m.history["app.example.com"] = &restartRecord{ | |
| 146 | + | attempts: []time.Time{now.Add(-2 * time.Minute), now.Add(-1 * time.Minute)}, | |
| 147 | + | consecutive: 2, | |
| 148 | + | } | |
| 149 | + | m.mu.Unlock() | |
| 150 | + | ||
| 151 | + | // Third crash — this should trigger backoff. | |
| 152 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |
| 153 | + | ID: "ctr-1", | |
| 154 | + | Status: string(domain.ContainerStatusExited), | |
| 155 | + | ExitCode: 137, | |
| 156 | + | }, nil) | |
| 157 | + | ||
| 158 | + | // Should NOT call StartContainer because crash loop is detected. | |
| 159 | + | m.check(ctx) | |
| 160 | + | ||
| 161 | + | runtime.AssertExpectations(t) | |
| 162 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |
| 163 | + | ||
| 164 | + | // Verify backoff was set. | |
| 165 | + | m.mu.Lock() | |
| 166 | + | rec := m.history["app.example.com"] | |
| 167 | + | assert.False(t, rec.backoffEnd.IsZero(), "backoff should be set after crash loop detection") | |
| 168 | + | assert.True(t, rec.backoffEnd.After(time.Now()), "backoff end should be in the future") | |
| 169 | + | m.mu.Unlock() | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestMonitor_BackoffPreventsRestart(t *testing.T) { | |
| 173 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 174 | + | svc := newTestService(runtime) | |
| 175 | + | ||
| 176 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 177 | + | ID: "ctr-1", | |
| 178 | + | Image: "myapp:latest", | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | m := newMonitor(svc) | |
| 182 | + | ||
| 183 | + | // Set backoff into the future. | |
| 184 | + | m.mu.Lock() | |
| 185 | + | m.history["app.example.com"] = &restartRecord{ | |
| 186 | + | backoffEnd: time.Now().Add(5 * time.Minute), | |
| 187 | + | consecutive: 3, | |
| 188 | + | } | |
| 189 | + | m.mu.Unlock() | |
| 190 | + | ||
| 191 | + | // Container is crashed. | |
| 192 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |
| 193 | + | ID: "ctr-1", | |
| 194 | + | Status: string(domain.ContainerStatusExited), | |
| 195 | + | ExitCode: 1, | |
| 196 | + | }, nil) | |
| 197 | + | ||
| 198 | + | // Should NOT restart — in backoff. | |
| 199 | + | m.check(monitorTestContext()) | |
| 200 | + | ||
| 201 | + | runtime.AssertExpectations(t) | |
| 202 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestMonitor_ClearsCrashHistoryAfterStableRunning(t *testing.T) { | |
| 206 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 207 | + | svc := newTestService(runtime) | |
| 208 | + | ||
| 209 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 210 | + | ID: "ctr-1", | |
| 211 | + | Image: "myapp:latest", | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | m := newMonitor(svc) | |
| 215 | + | ||
| 216 | + | // Container has been running for longer than stableRunningDuration. | |
| 217 | + | m.mu.Lock() | |
| 218 | + | m.history["app.example.com"] = &restartRecord{ | |
| 219 | + | attempts: []time.Time{time.Now().Add(-10 * time.Minute)}, | |
| 220 | + | consecutive: 2, | |
| 221 | + | lastSeen: time.Now().Add(-6 * time.Minute), // 6 min ago > stableRunningDuration (5 min) | |
| 222 | + | } | |
| 223 | + | m.mu.Unlock() | |
| 224 | + | ||
| 225 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |
| 226 | + | ID: "ctr-1", | |
| 227 | + | Status: string(domain.ContainerStatusRunning), | |
| 228 | + | }, nil) | |
| 229 | + | ||
| 230 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil) | |
| 231 | + | ||
| 232 | + | m.check(monitorTestContext()) | |
| 233 | + | ||
| 234 | + | // History should be cleared. | |
| 235 | + | m.mu.Lock() | |
| 236 | + | _, exists := m.history["app.example.com"] | |
| 237 | + | m.mu.Unlock() | |
| 238 | + | assert.False(t, exists, "crash history should be cleared after stable running") | |
| 239 | + | ||
| 240 | + | runtime.AssertExpectations(t) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestMonitor_SkipsReplacedContainer(t *testing.T) { | |
| 244 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 245 | + | svc := newTestService(runtime) | |
| 246 | + | ||
| 247 | + | // Track the OLD container ID. | |
| 248 | + | svc.containers["app.example.com"] = &domain.Container{ | |
| 249 | + | ID: "ctr-NEW", | |
| 250 | + | Image: "myapp:latest", | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | m := newMonitor(svc) | |
| 254 | + | ||
| 255 | + | // Monitor snapshot captured "ctr-OLD" but a deploy replaced it with "ctr-NEW". | |
| 256 | + | // We simulate this by directly calling checkContainer with the old ID. | |
| 257 | + | ctx := monitorTestContext() | |
| 258 | + | log := zerowrap.FromCtx(ctx) | |
| 259 | + | ||
| 260 | + | // Inspect the old container — it's exited. | |
| 261 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-OLD").Return(&domain.Container{ | |
| 262 | + | ID: "ctr-OLD", | |
| 263 | + | Status: string(domain.ContainerStatusExited), | |
| 264 | + | ExitCode: 1, | |
| 265 | + | }, nil) | |
| 266 | + | ||
| 267 | + | // Should NOT restart — tracked container has been replaced. | |
| 268 | + | oldTracked := &domain.Container{ID: "ctr-OLD", Image: "myapp:latest"} | |
| 269 | + | m.checkContainer(ctx, log, "app.example.com", oldTracked, time.Now()) | |
| 270 | + | ||
| 271 | + | runtime.AssertExpectations(t) | |
| 272 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestMonitor_ComputeBackoff(t *testing.T) { | |
| 276 | + | m := &Monitor{} | |
| 277 | + | ||
| 278 | + | tests := []struct { | |
| 279 | + | consecutive int | |
| 280 | + | expected time.Duration | |
| 281 | + | }{ | |
| 282 | + | {3, 1 * time.Minute}, // first backoff | |
| 283 | + | {4, 2 * time.Minute}, // second | |
| 284 | + | {5, 4 * time.Minute}, // third | |
| 285 | + | {6, 8 * time.Minute}, // fourth | |
| 286 | + | {7, 15 * time.Minute}, // capped | |
| 287 | + | {10, 15 * time.Minute}, // still capped | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | for _, tt := range tests { | |
| 291 | + | got := m.computeBackoff(tt.consecutive) | |
| 292 | + | assert.Equal(t, tt.expected, got, "consecutive=%d", tt.consecutive) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestMonitor_StartStop(t *testing.T) { | |
| 297 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 298 | + | svc := newTestService(runtime) | |
| 299 | + | ||
| 300 | + | m := newMonitor(svc) | |
| 301 | + | m.interval = 50 * time.Millisecond // fast ticking for test | |
| 302 | + | ||
| 303 | + | ctx, cancel := context.WithCancel(monitorTestContext()) | |
| 304 | + | defer cancel() | |
| 305 | + | ||
| 306 | + | m.Start(ctx) | |
| 307 | + | ||
| 308 | + | // Let it tick a couple times. | |
| 309 | + | time.Sleep(150 * time.Millisecond) | |
| 310 | + | ||
| 311 | + | // Stop should return promptly. | |
| 312 | + | done := make(chan struct{}) | |
| 313 | + | go func() { | |
| 314 | + | m.Stop() | |
| 315 | + | close(done) | |
| 316 | + | }() | |
| 317 | + | ||
| 318 | + | select { | |
| 319 | + | case <-done: | |
| 320 | + | // OK | |
| 321 | + | case <-time.After(2 * time.Second): | |
| 322 | + | t.Fatal("monitor.Stop() did not return within 2 seconds") | |
| 323 | + | } | |
| 324 | + | } | |
| @@ -0,0 +1,324 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func newTestService(runtime *mocks.MockContainerRuntime) *Service { | |||
| 17 | + | return &Service{ | |||
| 18 | + | runtime: runtime, | |||
| 19 | + | config: Config{}, | |||
| 20 | + | containers: make(map[string]*domain.Container), | |||
| 21 | + | attachments: make(map[string][]string), | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func monitorTestContext() context.Context { | |||
| 26 | + | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestMonitor_RestartsCrashedContainer(t *testing.T) { | |||
| 30 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 31 | + | svc := newTestService(runtime) | |||
| 32 | + | ||||
| 33 | + | // Track a container. | |||
| 34 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 35 | + | ID: "ctr-1", | |||
| 36 | + | Image: "myapp:latest", | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Inspect returns exited with non-zero exit code. | |||
| 40 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 41 | + | ID: "ctr-1", | |||
| 42 | + | Status: string(domain.ContainerStatusExited), | |||
| 43 | + | ExitCode: 1, | |||
| 44 | + | }, nil) | |||
| 45 | + | ||||
| 46 | + | // Monitor should restart it. | |||
| 47 | + | runtime.EXPECT().StartContainer(mock.Anything, "ctr-1").Return(nil) | |||
| 48 | + | ||||
| 49 | + | m := newMonitor(svc) | |||
| 50 | + | m.check(monitorTestContext()) | |||
| 51 | + | ||||
| 52 | + | runtime.AssertExpectations(t) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestMonitor_SkipsGracefulExit(t *testing.T) { | |||
| 56 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 57 | + | svc := newTestService(runtime) | |||
| 58 | + | ||||
| 59 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 60 | + | ID: "ctr-1", | |||
| 61 | + | Image: "myapp:latest", | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Exited with code 0 — graceful stop. | |||
| 65 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 66 | + | ID: "ctr-1", | |||
| 67 | + | Status: string(domain.ContainerStatusExited), | |||
| 68 | + | ExitCode: 0, | |||
| 69 | + | }, nil) | |||
| 70 | + | ||||
| 71 | + | // Should NOT call StartContainer. | |||
| 72 | + | ||||
| 73 | + | m := newMonitor(svc) | |||
| 74 | + | m.check(monitorTestContext()) | |||
| 75 | + | ||||
| 76 | + | runtime.AssertExpectations(t) | |||
| 77 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestMonitor_SkipsRunningContainer(t *testing.T) { | |||
| 81 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 82 | + | svc := newTestService(runtime) | |||
| 83 | + | ||||
| 84 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 85 | + | ID: "ctr-1", | |||
| 86 | + | Image: "myapp:latest", | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 90 | + | ID: "ctr-1", | |||
| 91 | + | Status: string(domain.ContainerStatusRunning), | |||
| 92 | + | }, nil) | |||
| 93 | + | ||||
| 94 | + | // Running container — check health status. | |||
| 95 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil) | |||
| 96 | + | ||||
| 97 | + | m := newMonitor(svc) | |||
| 98 | + | m.check(monitorTestContext()) | |||
| 99 | + | ||||
| 100 | + | runtime.AssertExpectations(t) | |||
| 101 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestMonitor_RestartsUnhealthyContainer(t *testing.T) { | |||
| 105 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 106 | + | svc := newTestService(runtime) | |||
| 107 | + | ||||
| 108 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 109 | + | ID: "ctr-1", | |||
| 110 | + | Image: "myapp:latest", | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 114 | + | ID: "ctr-1", | |||
| 115 | + | Status: string(domain.ContainerStatusRunning), | |||
| 116 | + | }, nil) | |||
| 117 | + | ||||
| 118 | + | // Running but Docker health says unhealthy. | |||
| 119 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("unhealthy", true, nil) | |||
| 120 | + | ||||
| 121 | + | // Should restart the unhealthy container. | |||
| 122 | + | runtime.EXPECT().RestartContainer(mock.Anything, "ctr-1").Return(nil) | |||
| 123 | + | ||||
| 124 | + | m := newMonitor(svc) | |||
| 125 | + | m.check(monitorTestContext()) | |||
| 126 | + | ||||
| 127 | + | runtime.AssertExpectations(t) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestMonitor_CrashLoopBackoff(t *testing.T) { | |||
| 131 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 132 | + | svc := newTestService(runtime) | |||
| 133 | + | ||||
| 134 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 135 | + | ID: "ctr-1", | |||
| 136 | + | Image: "myapp:latest", | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | m := newMonitor(svc) | |||
| 140 | + | ctx := monitorTestContext() | |||
| 141 | + | now := time.Now() | |||
| 142 | + | ||||
| 143 | + | // Simulate 3 crashes within the crash loop window. | |||
| 144 | + | m.mu.Lock() | |||
| 145 | + | m.history["app.example.com"] = &restartRecord{ | |||
| 146 | + | attempts: []time.Time{now.Add(-2 * time.Minute), now.Add(-1 * time.Minute)}, | |||
| 147 | + | consecutive: 2, | |||
| 148 | + | } | |||
| 149 | + | m.mu.Unlock() | |||
| 150 | + | ||||
| 151 | + | // Third crash — this should trigger backoff. | |||
| 152 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 153 | + | ID: "ctr-1", | |||
| 154 | + | Status: string(domain.ContainerStatusExited), | |||
| 155 | + | ExitCode: 137, | |||
| 156 | + | }, nil) | |||
| 157 | + | ||||
| 158 | + | // Should NOT call StartContainer because crash loop is detected. | |||
| 159 | + | m.check(ctx) | |||
| 160 | + | ||||
| 161 | + | runtime.AssertExpectations(t) | |||
| 162 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 163 | + | ||||
| 164 | + | // Verify backoff was set. | |||
| 165 | + | m.mu.Lock() | |||
| 166 | + | rec := m.history["app.example.com"] | |||
| 167 | + | assert.False(t, rec.backoffEnd.IsZero(), "backoff should be set after crash loop detection") | |||
| 168 | + | assert.True(t, rec.backoffEnd.After(time.Now()), "backoff end should be in the future") | |||
| 169 | + | m.mu.Unlock() | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestMonitor_BackoffPreventsRestart(t *testing.T) { | |||
| 173 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 174 | + | svc := newTestService(runtime) | |||
| 175 | + | ||||
| 176 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 177 | + | ID: "ctr-1", | |||
| 178 | + | Image: "myapp:latest", | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | m := newMonitor(svc) | |||
| 182 | + | ||||
| 183 | + | // Set backoff into the future. | |||
| 184 | + | m.mu.Lock() | |||
| 185 | + | m.history["app.example.com"] = &restartRecord{ | |||
| 186 | + | backoffEnd: time.Now().Add(5 * time.Minute), | |||
| 187 | + | consecutive: 3, | |||
| 188 | + | } | |||
| 189 | + | m.mu.Unlock() | |||
| 190 | + | ||||
| 191 | + | // Container is crashed. | |||
| 192 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 193 | + | ID: "ctr-1", | |||
| 194 | + | Status: string(domain.ContainerStatusExited), | |||
| 195 | + | ExitCode: 1, | |||
| 196 | + | }, nil) | |||
| 197 | + | ||||
| 198 | + | // Should NOT restart — in backoff. | |||
| 199 | + | m.check(monitorTestContext()) | |||
| 200 | + | ||||
| 201 | + | runtime.AssertExpectations(t) | |||
| 202 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestMonitor_ClearsCrashHistoryAfterStableRunning(t *testing.T) { | |||
| 206 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 207 | + | svc := newTestService(runtime) | |||
| 208 | + | ||||
| 209 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 210 | + | ID: "ctr-1", | |||
| 211 | + | Image: "myapp:latest", | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | m := newMonitor(svc) | |||
| 215 | + | ||||
| 216 | + | // Container has been running for longer than stableRunningDuration. | |||
| 217 | + | m.mu.Lock() | |||
| 218 | + | m.history["app.example.com"] = &restartRecord{ | |||
| 219 | + | attempts: []time.Time{time.Now().Add(-10 * time.Minute)}, | |||
| 220 | + | consecutive: 2, | |||
| 221 | + | lastSeen: time.Now().Add(-6 * time.Minute), // 6 min ago > stableRunningDuration (5 min) | |||
| 222 | + | } | |||
| 223 | + | m.mu.Unlock() | |||
| 224 | + | ||||
| 225 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 226 | + | ID: "ctr-1", | |||
| 227 | + | Status: string(domain.ContainerStatusRunning), | |||
| 228 | + | }, nil) | |||
| 229 | + | ||||
| 230 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil) | |||
| 231 | + | ||||
| 232 | + | m.check(monitorTestContext()) | |||
| 233 | + | ||||
| 234 | + | // History should be cleared. | |||
| 235 | + | m.mu.Lock() | |||
| 236 | + | _, exists := m.history["app.example.com"] | |||
| 237 | + | m.mu.Unlock() | |||
| 238 | + | assert.False(t, exists, "crash history should be cleared after stable running") | |||
| 239 | + | ||||
| 240 | + | runtime.AssertExpectations(t) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestMonitor_SkipsReplacedContainer(t *testing.T) { | |||
| 244 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 245 | + | svc := newTestService(runtime) | |||
| 246 | + | ||||
| 247 | + | // Track the OLD container ID. | |||
| 248 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 249 | + | ID: "ctr-NEW", | |||
| 250 | + | Image: "myapp:latest", | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | m := newMonitor(svc) | |||
| 254 | + | ||||
| 255 | + | // Monitor snapshot captured "ctr-OLD" but a deploy replaced it with "ctr-NEW". | |||
| 256 | + | // We simulate this by directly calling checkContainer with the old ID. | |||
| 257 | + | ctx := monitorTestContext() | |||
| 258 | + | log := zerowrap.FromCtx(ctx) | |||
| 259 | + | ||||
| 260 | + | // Inspect the old container — it's exited. | |||
| 261 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-OLD").Return(&domain.Container{ | |||
| 262 | + | ID: "ctr-OLD", | |||
| 263 | + | Status: string(domain.ContainerStatusExited), | |||
| 264 | + | ExitCode: 1, | |||
| 265 | + | }, nil) | |||
| 266 | + | ||||
| 267 | + | // Should NOT restart — tracked container has been replaced. | |||
| 268 | + | oldTracked := &domain.Container{ID: "ctr-OLD", Image: "myapp:latest"} | |||
| 269 | + | m.checkContainer(ctx, log, "app.example.com", oldTracked, time.Now()) | |||
| 270 | + | ||||
| 271 | + | runtime.AssertExpectations(t) | |||
| 272 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestMonitor_ComputeBackoff(t *testing.T) { | |||
| 276 | + | m := &Monitor{} | |||
| 277 | + | ||||
| 278 | + | tests := []struct { | |||
| 279 | + | consecutive int | |||
| 280 | + | expected time.Duration | |||
| 281 | + | }{ | |||
| 282 | + | {3, 1 * time.Minute}, // first backoff | |||
| 283 | + | {4, 2 * time.Minute}, // second | |||
| 284 | + | {5, 4 * time.Minute}, // third | |||
| 285 | + | {6, 8 * time.Minute}, // fourth | |||
| 286 | + | {7, 15 * time.Minute}, // capped | |||
| 287 | + | {10, 15 * time.Minute}, // still capped | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | for _, tt := range tests { | |||
| 291 | + | got := m.computeBackoff(tt.consecutive) | |||
| 292 | + | assert.Equal(t, tt.expected, got, "consecutive=%d", tt.consecutive) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestMonitor_StartStop(t *testing.T) { | |||
| 297 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 298 | + | svc := newTestService(runtime) | |||
| 299 | + | ||||
| 300 | + | m := newMonitor(svc) | |||
| 301 | + | m.interval = 50 * time.Millisecond // fast ticking for test | |||
| 302 | + | ||||
| 303 | + | ctx, cancel := context.WithCancel(monitorTestContext()) | |||
| 304 | + | defer cancel() | |||
| 305 | + | ||||
| 306 | + | m.Start(ctx) | |||
| 307 | + | ||||
| 308 | + | // Let it tick a couple times. | |||
| 309 | + | time.Sleep(150 * time.Millisecond) | |||
| 310 | + | ||||
| 311 | + | // Stop should return promptly. | |||
| 312 | + | done := make(chan struct{}) | |||
| 313 | + | go func() { | |||
| 314 | + | m.Stop() | |||
| 315 | + | close(done) | |||
| 316 | + | }() | |||
| 317 | + | ||||
| 318 | + | select { | |||
| 319 | + | case <-done: | |||
| 320 | + | // OK | |||
| 321 | + | case <-time.After(2 * time.Second): | |||
| 322 | + | t.Fatal("monitor.Stop() did not return within 2 seconds") | |||
| 323 | + | } | |||
| 324 | + | } | |||
M internal/usecase/container/service.go +90 -66
| @@ -69,6 +69,7 @@ type Service struct { | |||
| 69 | 69 | attachments map[string][]string // ownerDomain → []containerIDs | |
| 70 | 70 | mu sync.RWMutex | |
| 71 | 71 | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | |
| 72 | + | monitor *Monitor | |
| 72 | 73 | } | |
| 73 | 74 | ||
| 74 | 75 | // domainDeployLock is a context-aware mutex using a buffered channel. | |
| @@ -347,9 +348,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 347 | 348 | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | |
| 348 | 349 | return nil, log.WrapErr(err, "failed to start container") | |
| 349 | 350 | } | |
| 350 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | |
| 351 | - | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 352 | - | return nil, log.WrapErr(err, "container failed readiness check") | |
| 351 | + | if !domain.IsSkipReadiness(ctx) { | |
| 352 | + | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | |
| 353 | + | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 354 | + | return nil, log.WrapErr(err, "container failed readiness check") | |
| 355 | + | } | |
| 353 | 356 | } | |
| 354 | 357 | ||
| 355 | 358 | inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID) | |
| @@ -805,6 +808,8 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||
| 805 | 808 | } | |
| 806 | 809 | ||
| 807 | 810 | // AutoStart starts containers for the provided routes that aren't running. | |
| 811 | + | // It skips readiness checks to avoid blocking boot; the background monitor | |
| 812 | + | // handles crash recovery. | |
| 808 | 813 | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |
| 809 | 814 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 810 | 815 | zerowrap.FieldLayer: "usecase", | |
| @@ -814,37 +819,67 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |||
| 814 | 819 | ||
| 815 | 820 | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | |
| 816 | 821 | ||
| 817 | - | var started, skipped, errors int | |
| 822 | + | // Filter out routes that already have a running container. | |
| 823 | + | var pending []domain.Route | |
| 824 | + | skipped := 0 | |
| 818 | 825 | for _, route := range routes { | |
| 819 | - | // Check if container already exists and is running | |
| 820 | 826 | if _, exists := s.Get(ctx, route.Domain); exists { | |
| 821 | 827 | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | |
| 822 | 828 | skipped++ | |
| 823 | - | continue | |
| 829 | + | } else { | |
| 830 | + | pending = append(pending, route) | |
| 824 | 831 | } | |
| 832 | + | } | |
| 825 | 833 | ||
| 826 | - | log.Info(). | |
| 827 | - | Str("domain", route.Domain). | |
| 828 | - | Str("image", route.Image). | |
| 829 | - | Msg("auto-starting container for route") | |
| 834 | + | if len(pending) == 0 { | |
| 835 | + | log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running") | |
| 836 | + | return nil | |
| 837 | + | } | |
| 830 | 838 | ||
| 831 | - | if _, err := s.Deploy(ctx, route); err != nil { | |
| 832 | - | log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container") | |
| 833 | - | errors++ | |
| 834 | - | continue | |
| 835 | - | } | |
| 839 | + | // Deploy all pending routes concurrently with readiness checks skipped. | |
| 840 | + | deployCtx := domain.WithSkipReadiness(ctx) | |
| 841 | + | ||
| 842 | + | type result struct { | |
| 843 | + | route domain.Route | |
| 844 | + | err error | |
| 845 | + | } | |
| 846 | + | results := make(chan result, len(pending)) | |
| 847 | + | sem := make(chan struct{}, 4) // limit concurrency | |
| 836 | 848 | ||
| 837 | - | started++ | |
| 849 | + | for _, route := range pending { | |
| 850 | + | sem <- struct{}{} | |
| 851 | + | go func(r domain.Route) { | |
| 852 | + | defer func() { <-sem }() | |
| 853 | + | ||
| 854 | + | log.Info().Str("domain", r.Domain).Str("image", r.Image).Msg("auto-starting container for route") | |
| 855 | + | ||
| 856 | + | if _, err := s.Deploy(deployCtx, r); err != nil { | |
| 857 | + | results <- result{route: r, err: err} | |
| 858 | + | return | |
| 859 | + | } | |
| 860 | + | results <- result{route: r} | |
| 861 | + | }(route) | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | var started, errCount int | |
| 865 | + | for i := 0; i < len(pending); i++ { | |
| 866 | + | res := <-results | |
| 867 | + | if res.err != nil { | |
| 868 | + | log.Warn().Err(res.err).Str("domain", res.route.Domain).Msg("failed to auto-start container") | |
| 869 | + | errCount++ | |
| 870 | + | } else { | |
| 871 | + | started++ | |
| 872 | + | } | |
| 838 | 873 | } | |
| 839 | 874 | ||
| 840 | 875 | log.Info(). | |
| 841 | 876 | Int("started", started). | |
| 842 | 877 | Int("skipped", skipped). | |
| 843 | - | Int("errors", errors). | |
| 878 | + | Int("errors", errCount). | |
| 844 | 879 | Msg("auto-start completed") | |
| 845 | 880 | ||
| 846 | - | if errors > 0 { | |
| 847 | - | return fmt.Errorf("auto-start completed with %d errors", errors) | |
| 881 | + | if errCount > 0 { | |
| 882 | + | return fmt.Errorf("auto-start completed with %d errors", errCount) | |
| 848 | 883 | } | |
| 849 | 884 | return nil | |
| 850 | 885 | } | |
| @@ -858,9 +893,8 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||
| 858 | 893 | log := zerowrap.FromCtx(ctx) | |
| 859 | 894 | log.Info().Msg("shutting down container manager...") | |
| 860 | 895 | ||
| 861 | - | if err := s.stopAllManagedContainers(ctx); err != nil { | |
| 862 | - | log.WrapErr(err, "failed to stop all containers during shutdown") | |
| 863 | - | } | |
| 896 | + | // Containers are left running across Gordon restarts. | |
| 897 | + | // SyncContainers + AutoStart will pick them back up on next boot. | |
| 864 | 898 | ||
| 865 | 899 | // Close log writer to stop all log collection | |
| 866 | 900 | if s.logWriter != nil { | |
| @@ -873,6 +907,33 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||
| 873 | 907 | return nil | |
| 874 | 908 | } | |
| 875 | 909 | ||
| 910 | + | // StartMonitor begins background monitoring of tracked containers. | |
| 911 | + | // The monitor restarts crashed containers and detects crash loops. | |
| 912 | + | // Safe to call multiple times; subsequent calls are no-ops. | |
| 913 | + | func (s *Service) StartMonitor(ctx context.Context) { | |
| 914 | + | s.mu.Lock() | |
| 915 | + | if s.monitor != nil { | |
| 916 | + | s.mu.Unlock() | |
| 917 | + | return | |
| 918 | + | } | |
| 919 | + | s.monitor = newMonitor(s) | |
| 920 | + | m := s.monitor | |
| 921 | + | s.mu.Unlock() | |
| 922 | + | m.Start(ctx) | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | // StopMonitor stops the background container monitor. | |
| 926 | + | // Safe to call multiple times; subsequent calls are no-ops. | |
| 927 | + | func (s *Service) StopMonitor() { | |
| 928 | + | s.mu.Lock() | |
| 929 | + | m := s.monitor | |
| 930 | + | s.monitor = nil | |
| 931 | + | s.mu.Unlock() | |
| 932 | + | if m != nil { | |
| 933 | + | m.Stop() | |
| 934 | + | } | |
| 935 | + | } | |
| 936 | + | ||
| 876 | 937 | // UpdateConfig updates the service configuration. | |
| 877 | 938 | func (s *Service) UpdateConfig(config Config) { | |
| 878 | 939 | s.mu.Lock() | |
| @@ -1720,46 +1781,6 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 1720 | 1781 | return nil | |
| 1721 | 1782 | } | |
| 1722 | 1783 | ||
| 1723 | - | func (s *Service) stopAllManagedContainers(ctx context.Context) error { | |
| 1724 | - | log := zerowrap.FromCtx(ctx) | |
| 1725 | - | ||
| 1726 | - | s.mu.RLock() | |
| 1727 | - | containers := make(map[string]*domain.Container, len(s.containers)) | |
| 1728 | - | maps.Copy(containers, s.containers) | |
| 1729 | - | s.mu.RUnlock() | |
| 1730 | - | ||
| 1731 | - | if len(containers) == 0 { | |
| 1732 | - | log.Info().Msg("no managed containers to stop") | |
| 1733 | - | return nil | |
| 1734 | - | } | |
| 1735 | - | ||
| 1736 | - | log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...") | |
| 1737 | - | ||
| 1738 | - | errorCount := 0 | |
| 1739 | - | for d, c := range containers { | |
| 1740 | - | log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container") | |
| 1741 | - | ||
| 1742 | - | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |
| 1743 | - | log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID}) | |
| 1744 | - | errorCount++ | |
| 1745 | - | continue | |
| 1746 | - | } | |
| 1747 | - | ||
| 1748 | - | s.mu.Lock() | |
| 1749 | - | delete(s.containers, d) | |
| 1750 | - | s.mu.Unlock() | |
| 1751 | - | ||
| 1752 | - | log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully") | |
| 1753 | - | } | |
| 1754 | - | ||
| 1755 | - | if errorCount > 0 { | |
| 1756 | - | return fmt.Errorf("failed to stop %d containers", errorCount) | |
| 1757 | - | } | |
| 1758 | - | ||
| 1759 | - | log.Info().Msg("all managed containers stopped successfully") | |
| 1760 | - | return nil | |
| 1761 | - | } | |
| 1762 | - | ||
| 1763 | 1784 | // Utility functions | |
| 1764 | 1785 | ||
| 1765 | 1786 | func normalizeImageRef(image string) string { | |
| @@ -1969,7 +1990,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 1969 | 1990 | waitCtx, cancel := context.WithTimeout(ctx, timeout) | |
| 1970 | 1991 | defer cancel() | |
| 1971 | 1992 | ||
| 1972 | - | lastStatus := "starting" | |
| 1993 | + | var lastStatus string | |
| 1973 | 1994 | for { | |
| 1974 | 1995 | status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID) | |
| 1975 | 1996 | if err != nil { | |
| @@ -1978,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 1978 | 1999 | if !hasHealthcheck { | |
| 1979 | 2000 | return errors.New("no healthcheck detected") | |
| 1980 | 2001 | } | |
| 2002 | + | // Treat empty health status as transitional startup. Some runtimes can | |
| 2003 | + | // temporarily report an empty status before first probe results. | |
| 2004 | + | if status == "" { | |
| 2005 | + | status = "starting" | |
| 2006 | + | } | |
| 1981 | 2007 | if status == "healthy" { | |
| 1982 | 2008 | return nil | |
| 1983 | 2009 | } | |
| 1984 | - | if status != "" { | |
| 1985 | - | lastStatus = status | |
| 1986 | - | } | |
| 2010 | + | lastStatus = status | |
| 1987 | 2011 | ||
| 1988 | 2012 | select { | |
| 1989 | 2013 | case <-time.After(time.Second): | |
| @@ -69,6 +69,7 @@ type Service struct { | |||||
| 69 | attachments map[string][]string // ownerDomain → []containerIDs | 69 | attachments map[string][]string // ownerDomain → []containerIDs | ||
| 70 | mu sync.RWMutex | 70 | mu sync.RWMutex | ||
| 71 | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | 71 | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | ||
| 72 | + | monitor *Monitor | |||
| 72 | } | 73 | } | ||
| 73 | 74 | ||||
| 74 | // domainDeployLock is a context-aware mutex using a buffered channel. | 75 | // domainDeployLock is a context-aware mutex using a buffered channel. | ||
| @@ -347,9 +348,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||||
| 347 | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | 348 | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | ||
| 348 | return nil, log.WrapErr(err, "failed to start container") | 349 | return nil, log.WrapErr(err, "failed to start container") | ||
| 349 | } | 350 | } | ||
| 350 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | 351 | + | if !domain.IsSkipReadiness(ctx) { |
| 351 | - | s.cleanupFailedContainer(ctx, newContainer.ID) | 352 | + | if err := s.waitForReady(ctx, newContainer.ID); err != nil { |
| 352 | - | return nil, log.WrapErr(err, "container failed readiness check") | 353 | + | s.cleanupFailedContainer(ctx, newContainer.ID) |
| 354 | + | return nil, log.WrapErr(err, "container failed readiness check") | |||
| 355 | + | } | |||
| 353 | } | 356 | } | ||
| 354 | 357 | ||||
| 355 | inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID) | 358 | inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID) | ||
| @@ -805,6 +808,8 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||||
| 805 | } | 808 | } | ||
| 806 | 809 | ||||
| 807 | // AutoStart starts containers for the provided routes that aren't running. | 810 | // AutoStart starts containers for the provided routes that aren't running. | ||
| 811 | + | // It skips readiness checks to avoid blocking boot; the background monitor | |||
| 812 | + | // handles crash recovery. | |||
| 808 | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | 813 | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | ||
| 809 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 814 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 810 | zerowrap.FieldLayer: "usecase", | 815 | zerowrap.FieldLayer: "usecase", | ||
| @@ -814,37 +819,67 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |||||
| 814 | 819 | ||||
| 815 | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | 820 | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | ||
| 816 | 821 | ||||
| 817 | - | var started, skipped, errors int | 822 | + | // Filter out routes that already have a running container. |
| 823 | + | var pending []domain.Route | |||
| 824 | + | skipped := 0 | |||
| 818 | for _, route := range routes { | 825 | for _, route := range routes { | ||
| 819 | - | // Check if container already exists and is running | |||
| 820 | if _, exists := s.Get(ctx, route.Domain); exists { | 826 | if _, exists := s.Get(ctx, route.Domain); exists { | ||
| 821 | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | 827 | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | ||
| 822 | skipped++ | 828 | skipped++ | ||
| 823 | - | continue | 829 | + | } else { |
| 830 | + | pending = append(pending, route) | |||
| 824 | } | 831 | } | ||
| 832 | + | } | |||
| 825 | 833 | ||||
| 826 | - | log.Info(). | 834 | + | if len(pending) == 0 { |
| 827 | - | Str("domain", route.Domain). | 835 | + | log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running") |
| 828 | - | Str("image", route.Image). | 836 | + | return nil |
| 829 | - | Msg("auto-starting container for route") | 837 | + | } |
| 830 | 838 | ||||
| 831 | - | if _, err := s.Deploy(ctx, route); err != nil { | 839 | + | // Deploy all pending routes concurrently with readiness checks skipped. |
| 832 | - | log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container") | 840 | + | deployCtx := domain.WithSkipReadiness(ctx) |
| 833 | - | errors++ | 841 | + | |
| 834 | - | continue | 842 | + | type result struct { |
| 835 | - | } | 843 | + | route domain.Route |
| 844 | + | err error | |||
| 845 | + | } | |||
| 846 | + | results := make(chan result, len(pending)) | |||
| 847 | + | sem := make(chan struct{}, 4) // limit concurrency | |||
| 836 | 848 | ||||
| 837 | - | started++ | 849 | + | for _, route := range pending { |
| 850 | + | sem <- struct{}{} | |||
| 851 | + | go func(r domain.Route) { | |||
| 852 | + | defer func() { <-sem }() | |||
| 853 | + | ||||
| 854 | + | log.Info().Str("domain", r.Domain).Str("image", r.Image).Msg("auto-starting container for route") | |||
| 855 | + | ||||
| 856 | + | if _, err := s.Deploy(deployCtx, r); err != nil { | |||
| 857 | + | results <- result{route: r, err: err} | |||
| 858 | + | return | |||
| 859 | + | } | |||
| 860 | + | results <- result{route: r} | |||
| 861 | + | }(route) | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | var started, errCount int | |||
| 865 | + | for i := 0; i < len(pending); i++ { | |||
| 866 | + | res := <-results | |||
| 867 | + | if res.err != nil { | |||
| 868 | + | log.Warn().Err(res.err).Str("domain", res.route.Domain).Msg("failed to auto-start container") | |||
| 869 | + | errCount++ | |||
| 870 | + | } else { | |||
| 871 | + | started++ | |||
| 872 | + | } | |||
| 838 | } | 873 | } | ||
| 839 | 874 | ||||
| 840 | log.Info(). | 875 | log.Info(). | ||
| 841 | Int("started", started). | 876 | Int("started", started). | ||
| 842 | Int("skipped", skipped). | 877 | Int("skipped", skipped). | ||
| 843 | - | Int("errors", errors). | 878 | + | Int("errors", errCount). |
| 844 | Msg("auto-start completed") | 879 | Msg("auto-start completed") | ||
| 845 | 880 | ||||
| 846 | - | if errors > 0 { | 881 | + | if errCount > 0 { |
| 847 | - | return fmt.Errorf("auto-start completed with %d errors", errors) | 882 | + | return fmt.Errorf("auto-start completed with %d errors", errCount) |
| 848 | } | 883 | } | ||
| 849 | return nil | 884 | return nil | ||
| 850 | } | 885 | } | ||
| @@ -858,9 +893,8 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||||
| 858 | log := zerowrap.FromCtx(ctx) | 893 | log := zerowrap.FromCtx(ctx) | ||
| 859 | log.Info().Msg("shutting down container manager...") | 894 | log.Info().Msg("shutting down container manager...") | ||
| 860 | 895 | ||||
| 861 | - | if err := s.stopAllManagedContainers(ctx); err != nil { | 896 | + | // Containers are left running across Gordon restarts. |
| 862 | - | log.WrapErr(err, "failed to stop all containers during shutdown") | 897 | + | // SyncContainers + AutoStart will pick them back up on next boot. |
| 863 | - | } | |||
| 864 | 898 | ||||
| 865 | // Close log writer to stop all log collection | 899 | // Close log writer to stop all log collection | ||
| 866 | if s.logWriter != nil { | 900 | if s.logWriter != nil { | ||
| @@ -873,6 +907,33 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||||
| 873 | return nil | 907 | return nil | ||
| 874 | } | 908 | } | ||
| 875 | 909 | ||||
| 910 | + | // StartMonitor begins background monitoring of tracked containers. | |||
| 911 | + | // The monitor restarts crashed containers and detects crash loops. | |||
| 912 | + | // Safe to call multiple times; subsequent calls are no-ops. | |||
| 913 | + | func (s *Service) StartMonitor(ctx context.Context) { | |||
| 914 | + | s.mu.Lock() | |||
| 915 | + | if s.monitor != nil { | |||
| 916 | + | s.mu.Unlock() | |||
| 917 | + | return | |||
| 918 | + | } | |||
| 919 | + | s.monitor = newMonitor(s) | |||
| 920 | + | m := s.monitor | |||
| 921 | + | s.mu.Unlock() | |||
| 922 | + | m.Start(ctx) | |||
| 923 | + | } | |||
| 924 | + | ||||
| 925 | + | // StopMonitor stops the background container monitor. | |||
| 926 | + | // Safe to call multiple times; subsequent calls are no-ops. | |||
| 927 | + | func (s *Service) StopMonitor() { | |||
| 928 | + | s.mu.Lock() | |||
| 929 | + | m := s.monitor | |||
| 930 | + | s.monitor = nil | |||
| 931 | + | s.mu.Unlock() | |||
| 932 | + | if m != nil { | |||
| 933 | + | m.Stop() | |||
| 934 | + | } | |||
| 935 | + | } | |||
| 936 | + | ||||
| 876 | // UpdateConfig updates the service configuration. | 937 | // UpdateConfig updates the service configuration. | ||
| 877 | func (s *Service) UpdateConfig(config Config) { | 938 | func (s *Service) UpdateConfig(config Config) { | ||
| 878 | s.mu.Lock() | 939 | s.mu.Lock() | ||
| @@ -1720,46 +1781,6 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 1720 | return nil | 1781 | return nil | ||
| 1721 | } | 1782 | } | ||
| 1722 | 1783 | ||||
| 1723 | - | func (s *Service) stopAllManagedContainers(ctx context.Context) error { | |||
| 1724 | - | log := zerowrap.FromCtx(ctx) | |||
| 1725 | - | ||||
| 1726 | - | s.mu.RLock() | |||
| 1727 | - | containers := make(map[string]*domain.Container, len(s.containers)) | |||
| 1728 | - | maps.Copy(containers, s.containers) | |||
| 1729 | - | s.mu.RUnlock() | |||
| 1730 | - | ||||
| 1731 | - | if len(containers) == 0 { | |||
| 1732 | - | log.Info().Msg("no managed containers to stop") | |||
| 1733 | - | return nil | |||
| 1734 | - | } | |||
| 1735 | - | ||||
| 1736 | - | log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...") | |||
| 1737 | - | ||||
| 1738 | - | errorCount := 0 | |||
| 1739 | - | for d, c := range containers { | |||
| 1740 | - | log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container") | |||
| 1741 | - | ||||
| 1742 | - | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |||
| 1743 | - | log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID}) | |||
| 1744 | - | errorCount++ | |||
| 1745 | - | continue | |||
| 1746 | - | } | |||
| 1747 | - | ||||
| 1748 | - | s.mu.Lock() | |||
| 1749 | - | delete(s.containers, d) | |||
| 1750 | - | s.mu.Unlock() | |||
| 1751 | - | ||||
| 1752 | - | log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully") | |||
| 1753 | - | } | |||
| 1754 | - | ||||
| 1755 | - | if errorCount > 0 { | |||
| 1756 | - | return fmt.Errorf("failed to stop %d containers", errorCount) | |||
| 1757 | - | } | |||
| 1758 | - | ||||
| 1759 | - | log.Info().Msg("all managed containers stopped successfully") | |||
| 1760 | - | return nil | |||
| 1761 | - | } | |||
| 1762 | - | ||||
| 1763 | // Utility functions | 1784 | // Utility functions | ||
| 1764 | 1785 | ||||
| 1765 | func normalizeImageRef(image string) string { | 1786 | func normalizeImageRef(image string) string { | ||
| @@ -1969,7 +1990,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||||
| 1969 | waitCtx, cancel := context.WithTimeout(ctx, timeout) | 1990 | waitCtx, cancel := context.WithTimeout(ctx, timeout) | ||
| 1970 | defer cancel() | 1991 | defer cancel() | ||
| 1971 | 1992 | ||||
| 1972 | - | lastStatus := "starting" | 1993 | + | var lastStatus string |
| 1973 | for { | 1994 | for { | ||
| 1974 | status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID) | 1995 | status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID) | ||
| 1975 | if err != nil { | 1996 | if err != nil { | ||
| @@ -1978,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||||
| 1978 | if !hasHealthcheck { | 1999 | if !hasHealthcheck { | ||
| 1979 | return errors.New("no healthcheck detected") | 2000 | return errors.New("no healthcheck detected") | ||
| 1980 | } | 2001 | } | ||
| 2002 | + | // Treat empty health status as transitional startup. Some runtimes can | |||
| 2003 | + | // temporarily report an empty status before first probe results. | |||
| 2004 | + | if status == "" { | |||
| 2005 | + | status = "starting" | |||
| 2006 | + | } | |||
| 1981 | if status == "healthy" { | 2007 | if status == "healthy" { | ||
| 1982 | return nil | 2008 | return nil | ||
| 1983 | } | 2009 | } | ||
| 1984 | - | if status != "" { | 2010 | + | lastStatus = status |
| 1985 | - | lastStatus = status | |||
| 1986 | - | } | |||
| 1987 | 2011 | ||||
| 1988 | select { | 2012 | select { | ||
| 1989 | case <-time.After(time.Second): | 2013 | case <-time.After(time.Second): | ||
A internal/usecase/container/service_readiness_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | |
| 15 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 16 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 17 | + | ReadinessMode: "auto", | |
| 18 | + | ReadinessDelay: time.Millisecond, | |
| 19 | + | }) | |
| 20 | + | ||
| 21 | + | ctx := testContext() | |
| 22 | + | containerID := "container-1" | |
| 23 | + | ||
| 24 | + | // Initial running poll | |
| 25 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 26 | + | // Auto mode probes for healthcheck support first | |
| 27 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 28 | + | // Delay-mode fallback verification | |
| 29 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 30 | + | ||
| 31 | + | err := svc.waitForReady(ctx, containerID) | |
| 32 | + | assert.NoError(t, err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) { | |
| 36 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 37 | + | svc := NewService(runtime, nil, nil, nil, Config{}) | |
| 38 | + | ||
| 39 | + | ctx := testContext() | |
| 40 | + | containerID := "container-1" | |
| 41 | + | ||
| 42 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", true, nil).Once() | |
| 43 | + | ||
| 44 | + | err := svc.waitForHealthy(ctx, containerID, 10*time.Millisecond) | |
| 45 | + | assert.Error(t, err) | |
| 46 | + | assert.True(t, strings.Contains(err.Error(), "last status: starting")) | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | |||
| 15 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 16 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 17 | + | ReadinessMode: "auto", | |||
| 18 | + | ReadinessDelay: time.Millisecond, | |||
| 19 | + | }) | |||
| 20 | + | ||||
| 21 | + | ctx := testContext() | |||
| 22 | + | containerID := "container-1" | |||
| 23 | + | ||||
| 24 | + | // Initial running poll | |||
| 25 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 26 | + | // Auto mode probes for healthcheck support first | |||
| 27 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 28 | + | // Delay-mode fallback verification | |||
| 29 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 30 | + | ||||
| 31 | + | err := svc.waitForReady(ctx, containerID) | |||
| 32 | + | assert.NoError(t, err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) { | |||
| 36 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 37 | + | svc := NewService(runtime, nil, nil, nil, Config{}) | |||
| 38 | + | ||||
| 39 | + | ctx := testContext() | |||
| 40 | + | containerID := "container-1" | |||
| 41 | + | ||||
| 42 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", true, nil).Once() | |||
| 43 | + | ||||
| 44 | + | err := svc.waitForHealthy(ctx, containerID, 10*time.Millisecond) | |||
| 45 | + | assert.Error(t, err) | |||
| 46 | + | assert.True(t, strings.Contains(err.Error(), "last status: starting")) | |||
| 47 | + | } | |||
M internal/usecase/container/service_test.go +9 -33
| @@ -630,35 +630,13 @@ func TestService_Shutdown(t *testing.T) { | |||
| 630 | 630 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | |
| 631 | 631 | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | |
| 632 | 632 | ||
| 633 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil) | |
| 634 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil) | |
| 635 | - | ||
| 633 | + | // Shutdown no longer stops containers — they are left running for | |
| 634 | + | // SyncContainers + AutoStart to pick back up on next boot. | |
| 636 | 635 | err := svc.Shutdown(ctx) | |
| 637 | 636 | ||
| 638 | 637 | assert.NoError(t, err) | |
| 639 | - | assert.Empty(t, svc.containers) | |
| 640 | - | } | |
| 641 | - | ||
| 642 | - | func TestService_Shutdown_PartialFailure(t *testing.T) { | |
| 643 | - | runtime := mocks.NewMockContainerRuntime(t) | |
| 644 | - | envLoader := mocks.NewMockEnvLoader(t) | |
| 645 | - | eventBus := mocks.NewMockEventPublisher(t) | |
| 646 | - | ||
| 647 | - | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 648 | - | ctx := testContext() | |
| 649 | - | ||
| 650 | - | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | |
| 651 | - | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | |
| 652 | - | ||
| 653 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil) | |
| 654 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(errors.New("stop failed")) | |
| 655 | - | ||
| 656 | - | err := svc.Shutdown(ctx) | |
| 657 | - | ||
| 658 | - | // Shutdown logs errors but always returns nil for graceful degradation | |
| 659 | - | assert.NoError(t, err) | |
| 660 | - | // One container should still be tracked (the one that failed to stop) | |
| 661 | - | assert.Len(t, svc.containers, 1) | |
| 638 | + | // Containers remain tracked (not stopped). | |
| 639 | + | assert.Len(t, svc.containers, 2) | |
| 662 | 640 | } | |
| 663 | 641 | ||
| 664 | 642 | func TestService_Restart_NotFound(t *testing.T) { | |
| @@ -1196,14 +1174,14 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1196 | 1174 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | |
| 1197 | 1175 | } | |
| 1198 | 1176 | ||
| 1199 | - | // Setup mocks for first route deployment | |
| 1177 | + | // Setup mocks for route deployments | |
| 1200 | 1178 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |
| 1201 | 1179 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | |
| 1202 | 1180 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | |
| 1203 | 1181 | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | |
| 1204 | 1182 | runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | |
| 1205 | 1183 | ||
| 1206 | - | // Create and start containers | |
| 1184 | + | // Create and start containers — readiness is skipped so no IsContainerRunning calls. | |
| 1207 | 1185 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |
| 1208 | 1186 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | |
| 1209 | 1187 | }, nil).Once() | |
| @@ -1211,7 +1189,6 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1211 | 1189 | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | |
| 1212 | 1190 | }, nil).Once() | |
| 1213 | 1191 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | |
| 1214 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4) | |
| 1215 | 1192 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | |
| 1216 | 1193 | Status: "running", | |
| 1217 | 1194 | }, nil).Times(2) | |
| @@ -1245,7 +1222,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 1245 | 1222 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | |
| 1246 | 1223 | } | |
| 1247 | 1224 | ||
| 1248 | - | // Only deploy for app2 (app1 is skipped) | |
| 1225 | + | // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning. | |
| 1249 | 1226 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |
| 1250 | 1227 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | |
| 1251 | 1228 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once() | |
| @@ -1255,7 +1232,6 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 1255 | 1232 | ID: "container-2", Status: "created", | |
| 1256 | 1233 | }, nil).Once() | |
| 1257 | 1234 | runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once() | |
| 1258 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2) | |
| 1259 | 1235 | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | |
| 1260 | 1236 | Status: "running", | |
| 1261 | 1237 | }, nil).Once() | |
| @@ -1313,7 +1289,8 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 1313 | 1289 | } | |
| 1314 | 1290 | ||
| 1315 | 1291 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | |
| 1316 | - | // NOT the original reg.example.com/myapp:latest | |
| 1292 | + | // NOT the original reg.example.com/myapp:latest. | |
| 1293 | + | // Readiness is skipped — no IsContainerRunning calls. | |
| 1317 | 1294 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 1318 | 1295 | runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | |
| 1319 | 1296 | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil) | |
| @@ -1325,7 +1302,6 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 1325 | 1302 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | |
| 1326 | 1303 | }, nil) | |
| 1327 | 1304 | runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil) | |
| 1328 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2) | |
| 1329 | 1305 | runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{ | |
| 1330 | 1306 | Status: "running", | |
| 1331 | 1307 | }, nil) | |
| @@ -630,35 +630,13 @@ func TestService_Shutdown(t *testing.T) { | |||||
| 630 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | 630 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | ||
| 631 | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | 631 | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | ||
| 632 | 632 | ||||
| 633 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil) | 633 | + | // Shutdown no longer stops containers — they are left running for |
| 634 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil) | 634 | + | // SyncContainers + AutoStart to pick back up on next boot. |
| 635 | - | ||||
| 636 | err := svc.Shutdown(ctx) | 635 | err := svc.Shutdown(ctx) | ||
| 637 | 636 | ||||
| 638 | assert.NoError(t, err) | 637 | assert.NoError(t, err) | ||
| 639 | - | assert.Empty(t, svc.containers) | 638 | + | // Containers remain tracked (not stopped). |
| 640 | - | } | 639 | + | assert.Len(t, svc.containers, 2) |
| 641 | - | ||||
| 642 | - | func TestService_Shutdown_PartialFailure(t *testing.T) { | |||
| 643 | - | runtime := mocks.NewMockContainerRuntime(t) | |||
| 644 | - | envLoader := mocks.NewMockEnvLoader(t) | |||
| 645 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 646 | - | ||||
| 647 | - | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |||
| 648 | - | ctx := testContext() | |||
| 649 | - | ||||
| 650 | - | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | |||
| 651 | - | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | |||
| 652 | - | ||||
| 653 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil) | |||
| 654 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(errors.New("stop failed")) | |||
| 655 | - | ||||
| 656 | - | err := svc.Shutdown(ctx) | |||
| 657 | - | ||||
| 658 | - | // Shutdown logs errors but always returns nil for graceful degradation | |||
| 659 | - | assert.NoError(t, err) | |||
| 660 | - | // One container should still be tracked (the one that failed to stop) | |||
| 661 | - | assert.Len(t, svc.containers, 1) | |||
| 662 | } | 640 | } | ||
| 663 | 641 | ||||
| 664 | func TestService_Restart_NotFound(t *testing.T) { | 642 | func TestService_Restart_NotFound(t *testing.T) { | ||
| @@ -1196,14 +1174,14 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 1196 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | 1174 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | ||
| 1197 | } | 1175 | } | ||
| 1198 | 1176 | ||||
| 1199 | - | // Setup mocks for first route deployment | 1177 | + | // Setup mocks for route deployments |
| 1200 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | 1178 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | ||
| 1201 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | 1179 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | ||
| 1202 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | 1180 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | ||
| 1203 | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | 1181 | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | ||
| 1204 | runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | 1182 | runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | ||
| 1205 | 1183 | ||||
| 1206 | - | // Create and start containers | 1184 | + | // Create and start containers — readiness is skipped so no IsContainerRunning calls. |
| 1207 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | 1185 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | ||
| 1208 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | 1186 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | ||
| 1209 | }, nil).Once() | 1187 | }, nil).Once() | ||
| @@ -1211,7 +1189,6 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 1211 | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | 1189 | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | ||
| 1212 | }, nil).Once() | 1190 | }, nil).Once() | ||
| 1213 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | 1191 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | ||
| 1214 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4) | |||
| 1215 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | 1192 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | ||
| 1216 | Status: "running", | 1193 | Status: "running", | ||
| 1217 | }, nil).Times(2) | 1194 | }, nil).Times(2) | ||
| @@ -1245,7 +1222,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 1245 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | 1222 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | ||
| 1246 | } | 1223 | } | ||
| 1247 | 1224 | ||||
| 1248 | - | // Only deploy for app2 (app1 is skipped) | 1225 | + | // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning. |
| 1249 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | 1226 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | ||
| 1250 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | 1227 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | ||
| 1251 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once() | 1228 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once() | ||
| @@ -1255,7 +1232,6 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 1255 | ID: "container-2", Status: "created", | 1232 | ID: "container-2", Status: "created", | ||
| 1256 | }, nil).Once() | 1233 | }, nil).Once() | ||
| 1257 | runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once() | 1234 | runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once() | ||
| 1258 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2) | |||
| 1259 | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | 1235 | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | ||
| 1260 | Status: "running", | 1236 | Status: "running", | ||
| 1261 | }, nil).Once() | 1237 | }, nil).Once() | ||
| @@ -1313,7 +1289,8 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||||
| 1313 | } | 1289 | } | ||
| 1314 | 1290 | ||||
| 1315 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | 1291 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | ||
| 1316 | - | // NOT the original reg.example.com/myapp:latest | 1292 | + | // NOT the original reg.example.com/myapp:latest. |
| 1293 | + | // Readiness is skipped — no IsContainerRunning calls. | |||
| 1317 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 1294 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 1318 | runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | 1295 | runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | ||
| 1319 | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil) | 1296 | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil) | ||
| @@ -1325,7 +1302,6 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||||
| 1325 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | 1302 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | ||
| 1326 | }, nil) | 1303 | }, nil) | ||
| 1327 | runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil) | 1304 | runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil) | ||
| 1328 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2) | |||
| 1329 | runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{ | 1305 | runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{ | ||
| 1330 | Status: "running", | 1306 | Status: "running", | ||
| 1331 | }, nil) | 1307 | }, nil) | ||
M internal/usecase/logs/service.go +243 -57
| @@ -3,11 +3,14 @@ package logs | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bufio" | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "encoding/binary" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| 10 | 11 | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "strconv" | |
| 11 | 14 | "strings" | |
| 12 | 15 | "time" | |
| 13 | 16 | ||
| @@ -19,24 +22,29 @@ import ( | |||
| 19 | 22 | ||
| 20 | 23 | // Service implements the LogService interface. | |
| 21 | 24 | type Service struct { | |
| 22 | - | logFilePath string | |
| 23 | - | containerSvc in.ContainerService | |
| 24 | - | runtime out.ContainerRuntime | |
| 25 | - | log zerowrap.Logger | |
| 25 | + | logFilePath string | |
| 26 | + | fileLoggingEnabled bool | |
| 27 | + | containerSvc in.ContainerService | |
| 28 | + | runtime out.ContainerRuntime | |
| 29 | + | log zerowrap.Logger | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 32 | + | var execCommandContext = exec.CommandContext | |
| 33 | + | ||
| 28 | 34 | // NewService creates a new log service. | |
| 29 | 35 | func NewService( | |
| 30 | 36 | logFilePath string, | |
| 37 | + | fileLoggingEnabled bool, | |
| 31 | 38 | containerSvc in.ContainerService, | |
| 32 | 39 | runtime out.ContainerRuntime, | |
| 33 | 40 | log zerowrap.Logger, | |
| 34 | 41 | ) *Service { | |
| 35 | 42 | return &Service{ | |
| 36 | - | logFilePath: logFilePath, | |
| 37 | - | containerSvc: containerSvc, | |
| 38 | - | runtime: runtime, | |
| 39 | - | log: log, | |
| 43 | + | logFilePath: logFilePath, | |
| 44 | + | fileLoggingEnabled: fileLoggingEnabled, | |
| 45 | + | containerSvc: containerSvc, | |
| 46 | + | runtime: runtime, | |
| 47 | + | log: log, | |
| 40 | 48 | } | |
| 41 | 49 | } | |
| 42 | 50 | ||
| @@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro | |||
| 49 | 57 | }) | |
| 50 | 58 | log := zerowrap.FromCtx(ctx) | |
| 51 | 59 | ||
| 52 | - | if s.logFilePath == "" { | |
| 53 | - | return nil, fmt.Errorf("log file path not configured") | |
| 54 | - | } | |
| 60 | + | if s.useFileProcessLogs() { | |
| 61 | + | if s.logFilePath == "" { | |
| 62 | + | return nil, fmt.Errorf("log file path not configured") | |
| 63 | + | } | |
| 55 | 64 | ||
| 56 | - | file, err := os.Open(s.logFilePath) | |
| 57 | - | if err != nil { | |
| 58 | - | if os.IsNotExist(err) { | |
| 59 | - | return []string{}, nil | |
| 65 | + | file, err := os.Open(s.logFilePath) | |
| 66 | + | if err != nil { | |
| 67 | + | if os.IsNotExist(err) { | |
| 68 | + | log.Warn().Str("log_path", s.logFilePath). | |
| 69 | + | Msg("process log file missing, falling back to journalctl") | |
| 70 | + | return s.getProcessLogsFromJournal(ctx, lines) | |
| 71 | + | } | |
| 72 | + | return nil, log.WrapErr(err, "failed to open log file") | |
| 60 | 73 | } | |
| 61 | - | return nil, log.WrapErr(err, "failed to open log file") | |
| 74 | + | defer file.Close() | |
| 75 | + | ||
| 76 | + | return tailLines(file, lines) | |
| 62 | 77 | } | |
| 63 | - | defer file.Close() | |
| 64 | 78 | ||
| 65 | - | return tailLines(file, lines) | |
| 79 | + | return s.getProcessLogsFromJournal(ctx, lines) | |
| 66 | 80 | } | |
| 67 | 81 | ||
| 68 | 82 | // FollowProcessLogs returns a channel that streams Gordon process log lines. | |
| @@ -72,67 +86,198 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||
| 72 | 86 | zerowrap.FieldUseCase: "FollowProcessLogs", | |
| 73 | 87 | "initial_lines": initialLines, | |
| 74 | 88 | }) | |
| 89 | + | if !s.useFileProcessLogs() { | |
| 90 | + | return s.followProcessLogsFromJournal(ctx, initialLines) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | return s.followProcessLogsFromFileOrJournal(ctx, initialLines) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (s *Service) useFileProcessLogs() bool { | |
| 97 | + | return s.fileLoggingEnabled | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (s *Service) followProcessLogsFromFileOrJournal( | |
| 101 | + | ctx context.Context, | |
| 102 | + | initialLines int, | |
| 103 | + | ) (<-chan string, error) { | |
| 75 | 104 | log := zerowrap.FromCtx(ctx) | |
| 76 | 105 | ||
| 106 | + | file, err := s.openProcessLogFile() | |
| 107 | + | if err != nil { | |
| 108 | + | if os.IsNotExist(err) { | |
| 109 | + | log.Warn().Str("log_path", s.logFilePath). | |
| 110 | + | Msg("process log file missing, falling back to journalctl follow") | |
| 111 | + | return s.followProcessLogsFromJournal(ctx, initialLines) | |
| 112 | + | } | |
| 113 | + | return nil, log.WrapErr(err, "failed to open log file") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return s.followProcessLogsFromFile(ctx, file, initialLines), nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (s *Service) openProcessLogFile() (*os.File, error) { | |
| 77 | 120 | if s.logFilePath == "" { | |
| 78 | 121 | return nil, fmt.Errorf("log file path not configured") | |
| 79 | 122 | } | |
| 80 | 123 | ||
| 81 | - | file, err := os.Open(s.logFilePath) | |
| 82 | - | if err != nil { | |
| 83 | - | return nil, log.WrapErr(err, "failed to open log file") | |
| 84 | - | } | |
| 124 | + | return os.Open(s.logFilePath) | |
| 125 | + | } | |
| 85 | 126 | ||
| 127 | + | func (s *Service) followProcessLogsFromFile( | |
| 128 | + | ctx context.Context, | |
| 129 | + | file *os.File, | |
| 130 | + | initialLines int, | |
| 131 | + | ) <-chan string { | |
| 132 | + | log := zerowrap.FromCtx(ctx) | |
| 86 | 133 | ch := make(chan string, 100) | |
| 87 | 134 | ||
| 88 | 135 | go func() { | |
| 89 | 136 | defer close(ch) | |
| 90 | 137 | defer file.Close() | |
| 91 | 138 | ||
| 92 | - | // First, get initial lines | |
| 93 | - | if initialLines > 0 { | |
| 94 | - | lines, err := tailLines(file, initialLines) | |
| 95 | - | if err != nil { | |
| 96 | - | log.Warn().Err(err).Msg("failed to read initial lines") | |
| 97 | - | } else { | |
| 98 | - | for _, line := range lines { | |
| 99 | - | select { | |
| 100 | - | case ch <- line: | |
| 101 | - | case <-ctx.Done(): | |
| 102 | - | return | |
| 103 | - | } | |
| 104 | - | } | |
| 139 | + | if !s.emitInitialFileLogs(ctx, file, initialLines, ch) { | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | if err := s.seekToEndForFileFollow(file); err != nil { | |
| 143 | + | log.Warn().Err(err).Msg("failed to seek to end") | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | s.streamFileFollowLogs(ctx, file, ch) | |
| 148 | + | }() | |
| 149 | + | ||
| 150 | + | return ch | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (s *Service) emitInitialFileLogs( | |
| 154 | + | ctx context.Context, | |
| 155 | + | file *os.File, | |
| 156 | + | initialLines int, | |
| 157 | + | ch chan<- string, | |
| 158 | + | ) bool { | |
| 159 | + | if initialLines <= 0 { | |
| 160 | + | return true | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | log := zerowrap.FromCtx(ctx) | |
| 164 | + | lines, err := tailLines(file, initialLines) | |
| 165 | + | if err != nil { | |
| 166 | + | log.Warn().Err(err).Msg("failed to read initial lines") | |
| 167 | + | return true | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | for _, line := range lines { | |
| 171 | + | if !sendLogLineWithChannel(ctx, ch, line) { | |
| 172 | + | return false | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | return true | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (s *Service) seekToEndForFileFollow(file *os.File) error { | |
| 180 | + | _, err := file.Seek(0, io.SeekEnd) | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (s *Service) streamFileFollowLogs(ctx context.Context, file *os.File, ch chan<- string) { | |
| 185 | + | log := zerowrap.FromCtx(ctx) | |
| 186 | + | reader := bufio.NewReader(file) | |
| 187 | + | ||
| 188 | + | for { | |
| 189 | + | if ctx.Err() != nil { | |
| 190 | + | return | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | line, err := reader.ReadString('\n') | |
| 194 | + | if err != nil { | |
| 195 | + | if err == io.EOF { | |
| 196 | + | time.Sleep(100 * time.Millisecond) | |
| 197 | + | continue | |
| 105 | 198 | } | |
| 199 | + | log.Warn().Err(err).Msg("error reading log file") | |
| 200 | + | return | |
| 106 | 201 | } | |
| 107 | 202 | ||
| 108 | - | // Seek to end for following | |
| 109 | - | if _, err := file.Seek(0, io.SeekEnd); err != nil { | |
| 110 | - | log.Warn().Err(err).Msg("failed to seek to end") | |
| 203 | + | line = strings.TrimRight(line, "\n\r") | |
| 204 | + | if !sendLogLineWithChannel(ctx, ch, line) { | |
| 111 | 205 | return | |
| 112 | 206 | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func sendLogLineWithChannel(ctx context.Context, ch chan<- string, line string) bool { | |
| 211 | + | select { | |
| 212 | + | case ch <- line: | |
| 213 | + | return true | |
| 214 | + | case <-ctx.Done(): | |
| 215 | + | return false | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (s *Service) getProcessLogsFromJournal(ctx context.Context, lines int) ([]string, error) { | |
| 220 | + | userScope, err := resolveJournalctlScope(ctx) | |
| 221 | + | if err != nil { | |
| 222 | + | return nil, err | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | args := []string{"-u", "gordon", "-n", strconv.Itoa(lines), "--no-pager", "-o", "cat"} | |
| 226 | + | if userScope { | |
| 227 | + | args = append([]string{"--user"}, args...) | |
| 228 | + | } | |
| 113 | 229 | ||
| 114 | - | // Follow new lines | |
| 115 | - | reader := bufio.NewReader(file) | |
| 116 | - | for { | |
| 230 | + | out, err := execCommandContext(ctx, "journalctl", args...).Output() | |
| 231 | + | if err != nil { | |
| 232 | + | return nil, fmt.Errorf("failed to read journalctl logs: %w", err) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | return outputLines(out), nil | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) { | |
| 239 | + | userScope, err := resolveJournalctlScope(ctx) | |
| 240 | + | if err != nil { | |
| 241 | + | return nil, err | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | args := []string{ | |
| 245 | + | "-u", "gordon", | |
| 246 | + | "-n", strconv.Itoa(initialLines), | |
| 247 | + | "--no-pager", | |
| 248 | + | "-o", "cat", | |
| 249 | + | "-f", | |
| 250 | + | } | |
| 251 | + | if userScope { | |
| 252 | + | args = append([]string{"--user"}, args...) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only | |
| 256 | + | stdout, err := cmd.StdoutPipe() | |
| 257 | + | if err != nil { | |
| 258 | + | return nil, fmt.Errorf("failed to open journalctl stdout: %w", err) | |
| 259 | + | } | |
| 260 | + | cmd.Stderr = io.Discard | |
| 261 | + | ||
| 262 | + | if err := cmd.Start(); err != nil { | |
| 263 | + | return nil, fmt.Errorf("failed to start journalctl follow: %w", err) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | ch := make(chan string, 100) | |
| 267 | + | ||
| 268 | + | go func() { | |
| 269 | + | defer close(ch) | |
| 270 | + | defer stdout.Close() | |
| 271 | + | defer cmd.Wait() //nolint:errcheck // best-effort cleanup | |
| 272 | + | ||
| 273 | + | scanner := bufio.NewScanner(stdout) | |
| 274 | + | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |
| 275 | + | ||
| 276 | + | for scanner.Scan() { | |
| 117 | 277 | select { | |
| 278 | + | case ch <- scanner.Text(): | |
| 118 | 279 | case <-ctx.Done(): | |
| 119 | 280 | return | |
| 120 | - | default: | |
| 121 | - | line, err := reader.ReadString('\n') | |
| 122 | - | if err != nil { | |
| 123 | - | if err == io.EOF { | |
| 124 | - | time.Sleep(100 * time.Millisecond) | |
| 125 | - | continue | |
| 126 | - | } | |
| 127 | - | log.Warn().Err(err).Msg("error reading log file") | |
| 128 | - | return | |
| 129 | - | } | |
| 130 | - | line = strings.TrimRight(line, "\n\r") | |
| 131 | - | select { | |
| 132 | - | case ch <- line: | |
| 133 | - | case <-ctx.Done(): | |
| 134 | - | return | |
| 135 | - | } | |
| 136 | 281 | } | |
| 137 | 282 | } | |
| 138 | 283 | }() | |
| @@ -140,6 +285,47 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||
| 140 | 285 | return ch, nil | |
| 141 | 286 | } | |
| 142 | 287 | ||
| 288 | + | func resolveJournalctlScope(ctx context.Context) (bool, error) { | |
| 289 | + | userLogs, userErr := hasJournalctlLogs(ctx, true) | |
| 290 | + | if userErr == nil && userLogs { | |
| 291 | + | return true, nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | systemLogs, systemErr := hasJournalctlLogs(ctx, false) | |
| 295 | + | if systemErr == nil && systemLogs { | |
| 296 | + | return false, nil | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | if userErr != nil && systemErr != nil { | |
| 300 | + | return false, fmt.Errorf("journalctl unavailable for user and system services") | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // Default to user scope when logs are currently empty; this matches rootless setups. | |
| 304 | + | return true, nil | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func hasJournalctlLogs(ctx context.Context, userService bool) (bool, error) { | |
| 308 | + | args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q", "-o", "cat"} | |
| 309 | + | if userService { | |
| 310 | + | args = append([]string{"--user"}, args...) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | out, err := execCommandContext(ctx, "journalctl", args...).Output() | |
| 314 | + | if err != nil { | |
| 315 | + | return false, err | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | return len(bytes.TrimSpace(out)) > 0, nil | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func outputLines(out []byte) []string { | |
| 322 | + | trimmed := strings.TrimRight(string(out), "\n\r") | |
| 323 | + | if trimmed == "" { | |
| 324 | + | return []string{} | |
| 325 | + | } | |
| 326 | + | return strings.Split(trimmed, "\n") | |
| 327 | + | } | |
| 328 | + | ||
| 143 | 329 | // validateDomain checks that domain is safe for use in logs and error messages. | |
| 144 | 330 | func validateDomain(domain string) error { | |
| 145 | 331 | if domain == "" { | |
| @@ -3,11 +3,14 @@ package logs | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bufio" | 5 | "bufio" | ||
| 6 | + | "bytes" | |||
| 6 | "context" | 7 | "context" | ||
| 7 | "encoding/binary" | 8 | "encoding/binary" | ||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "io" | 10 | "io" | ||
| 10 | "os" | 11 | "os" | ||
| 12 | + | "os/exec" | |||
| 13 | + | "strconv" | |||
| 11 | "strings" | 14 | "strings" | ||
| 12 | "time" | 15 | "time" | ||
| 13 | 16 | ||||
| @@ -19,24 +22,29 @@ import ( | |||||
| 19 | 22 | ||||
| 20 | // Service implements the LogService interface. | 23 | // Service implements the LogService interface. | ||
| 21 | type Service struct { | 24 | type Service struct { | ||
| 22 | - | logFilePath string | 25 | + | logFilePath string |
| 23 | - | containerSvc in.ContainerService | 26 | + | fileLoggingEnabled bool |
| 24 | - | runtime out.ContainerRuntime | 27 | + | containerSvc in.ContainerService |
| 25 | - | log zerowrap.Logger | 28 | + | runtime out.ContainerRuntime |
| 29 | + | log zerowrap.Logger | |||
| 26 | } | 30 | } | ||
| 27 | 31 | ||||
| 32 | + | var execCommandContext = exec.CommandContext | |||
| 33 | + | ||||
| 28 | // NewService creates a new log service. | 34 | // NewService creates a new log service. | ||
| 29 | func NewService( | 35 | func NewService( | ||
| 30 | logFilePath string, | 36 | logFilePath string, | ||
| 37 | + | fileLoggingEnabled bool, | |||
| 31 | containerSvc in.ContainerService, | 38 | containerSvc in.ContainerService, | ||
| 32 | runtime out.ContainerRuntime, | 39 | runtime out.ContainerRuntime, | ||
| 33 | log zerowrap.Logger, | 40 | log zerowrap.Logger, | ||
| 34 | ) *Service { | 41 | ) *Service { | ||
| 35 | return &Service{ | 42 | return &Service{ | ||
| 36 | - | logFilePath: logFilePath, | 43 | + | logFilePath: logFilePath, |
| 37 | - | containerSvc: containerSvc, | 44 | + | fileLoggingEnabled: fileLoggingEnabled, |
| 38 | - | runtime: runtime, | 45 | + | containerSvc: containerSvc, |
| 39 | - | log: log, | 46 | + | runtime: runtime, |
| 47 | + | log: log, | |||
| 40 | } | 48 | } | ||
| 41 | } | 49 | } | ||
| 42 | 50 | ||||
| @@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro | |||||
| 49 | }) | 57 | }) | ||
| 50 | log := zerowrap.FromCtx(ctx) | 58 | log := zerowrap.FromCtx(ctx) | ||
| 51 | 59 | ||||
| 52 | - | if s.logFilePath == "" { | 60 | + | if s.useFileProcessLogs() { |
| 53 | - | return nil, fmt.Errorf("log file path not configured") | 61 | + | if s.logFilePath == "" { |
| 54 | - | } | 62 | + | return nil, fmt.Errorf("log file path not configured") |
| 63 | + | } | |||
| 55 | 64 | ||||
| 56 | - | file, err := os.Open(s.logFilePath) | 65 | + | file, err := os.Open(s.logFilePath) |
| 57 | - | if err != nil { | 66 | + | if err != nil { |
| 58 | - | if os.IsNotExist(err) { | 67 | + | if os.IsNotExist(err) { |
| 59 | - | return []string{}, nil | 68 | + | log.Warn().Str("log_path", s.logFilePath). |
| 69 | + | Msg("process log file missing, falling back to journalctl") | |||
| 70 | + | return s.getProcessLogsFromJournal(ctx, lines) | |||
| 71 | + | } | |||
| 72 | + | return nil, log.WrapErr(err, "failed to open log file") | |||
| 60 | } | 73 | } | ||
| 61 | - | return nil, log.WrapErr(err, "failed to open log file") | 74 | + | defer file.Close() |
| 75 | + | ||||
| 76 | + | return tailLines(file, lines) | |||
| 62 | } | 77 | } | ||
| 63 | - | defer file.Close() | |||
| 64 | 78 | ||||
| 65 | - | return tailLines(file, lines) | 79 | + | return s.getProcessLogsFromJournal(ctx, lines) |
| 66 | } | 80 | } | ||
| 67 | 81 | ||||
| 68 | // FollowProcessLogs returns a channel that streams Gordon process log lines. | 82 | // FollowProcessLogs returns a channel that streams Gordon process log lines. | ||
| @@ -72,67 +86,198 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||||
| 72 | zerowrap.FieldUseCase: "FollowProcessLogs", | 86 | zerowrap.FieldUseCase: "FollowProcessLogs", | ||
| 73 | "initial_lines": initialLines, | 87 | "initial_lines": initialLines, | ||
| 74 | }) | 88 | }) | ||
| 89 | + | if !s.useFileProcessLogs() { | |||
| 90 | + | return s.followProcessLogsFromJournal(ctx, initialLines) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | return s.followProcessLogsFromFileOrJournal(ctx, initialLines) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (s *Service) useFileProcessLogs() bool { | |||
| 97 | + | return s.fileLoggingEnabled | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (s *Service) followProcessLogsFromFileOrJournal( | |||
| 101 | + | ctx context.Context, | |||
| 102 | + | initialLines int, | |||
| 103 | + | ) (<-chan string, error) { | |||
| 75 | log := zerowrap.FromCtx(ctx) | 104 | log := zerowrap.FromCtx(ctx) | ||
| 76 | 105 | ||||
| 106 | + | file, err := s.openProcessLogFile() | |||
| 107 | + | if err != nil { | |||
| 108 | + | if os.IsNotExist(err) { | |||
| 109 | + | log.Warn().Str("log_path", s.logFilePath). | |||
| 110 | + | Msg("process log file missing, falling back to journalctl follow") | |||
| 111 | + | return s.followProcessLogsFromJournal(ctx, initialLines) | |||
| 112 | + | } | |||
| 113 | + | return nil, log.WrapErr(err, "failed to open log file") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | return s.followProcessLogsFromFile(ctx, file, initialLines), nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (s *Service) openProcessLogFile() (*os.File, error) { | |||
| 77 | if s.logFilePath == "" { | 120 | if s.logFilePath == "" { | ||
| 78 | return nil, fmt.Errorf("log file path not configured") | 121 | return nil, fmt.Errorf("log file path not configured") | ||
| 79 | } | 122 | } | ||
| 80 | 123 | ||||
| 81 | - | file, err := os.Open(s.logFilePath) | 124 | + | return os.Open(s.logFilePath) |
| 82 | - | if err != nil { | 125 | + | } |
| 83 | - | return nil, log.WrapErr(err, "failed to open log file") | |||
| 84 | - | } | |||
| 85 | 126 | ||||
| 127 | + | func (s *Service) followProcessLogsFromFile( | |||
| 128 | + | ctx context.Context, | |||
| 129 | + | file *os.File, | |||
| 130 | + | initialLines int, | |||
| 131 | + | ) <-chan string { | |||
| 132 | + | log := zerowrap.FromCtx(ctx) | |||
| 86 | ch := make(chan string, 100) | 133 | ch := make(chan string, 100) | ||
| 87 | 134 | ||||
| 88 | go func() { | 135 | go func() { | ||
| 89 | defer close(ch) | 136 | defer close(ch) | ||
| 90 | defer file.Close() | 137 | defer file.Close() | ||
| 91 | 138 | ||||
| 92 | - | // First, get initial lines | 139 | + | if !s.emitInitialFileLogs(ctx, file, initialLines, ch) { |
| 93 | - | if initialLines > 0 { | 140 | + | return |
| 94 | - | lines, err := tailLines(file, initialLines) | 141 | + | } |
| 95 | - | if err != nil { | 142 | + | if err := s.seekToEndForFileFollow(file); err != nil { |
| 96 | - | log.Warn().Err(err).Msg("failed to read initial lines") | 143 | + | log.Warn().Err(err).Msg("failed to seek to end") |
| 97 | - | } else { | 144 | + | return |
| 98 | - | for _, line := range lines { | 145 | + | } |
| 99 | - | select { | 146 | + | |
| 100 | - | case ch <- line: | 147 | + | s.streamFileFollowLogs(ctx, file, ch) |
| 101 | - | case <-ctx.Done(): | 148 | + | }() |
| 102 | - | return | 149 | + | |
| 103 | - | } | 150 | + | return ch |
| 104 | - | } | 151 | + | } |
| 152 | + | ||||
| 153 | + | func (s *Service) emitInitialFileLogs( | |||
| 154 | + | ctx context.Context, | |||
| 155 | + | file *os.File, | |||
| 156 | + | initialLines int, | |||
| 157 | + | ch chan<- string, | |||
| 158 | + | ) bool { | |||
| 159 | + | if initialLines <= 0 { | |||
| 160 | + | return true | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | log := zerowrap.FromCtx(ctx) | |||
| 164 | + | lines, err := tailLines(file, initialLines) | |||
| 165 | + | if err != nil { | |||
| 166 | + | log.Warn().Err(err).Msg("failed to read initial lines") | |||
| 167 | + | return true | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | for _, line := range lines { | |||
| 171 | + | if !sendLogLineWithChannel(ctx, ch, line) { | |||
| 172 | + | return false | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | return true | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (s *Service) seekToEndForFileFollow(file *os.File) error { | |||
| 180 | + | _, err := file.Seek(0, io.SeekEnd) | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (s *Service) streamFileFollowLogs(ctx context.Context, file *os.File, ch chan<- string) { | |||
| 185 | + | log := zerowrap.FromCtx(ctx) | |||
| 186 | + | reader := bufio.NewReader(file) | |||
| 187 | + | ||||
| 188 | + | for { | |||
| 189 | + | if ctx.Err() != nil { | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | line, err := reader.ReadString('\n') | |||
| 194 | + | if err != nil { | |||
| 195 | + | if err == io.EOF { | |||
| 196 | + | time.Sleep(100 * time.Millisecond) | |||
| 197 | + | continue | |||
| 105 | } | 198 | } | ||
| 199 | + | log.Warn().Err(err).Msg("error reading log file") | |||
| 200 | + | return | |||
| 106 | } | 201 | } | ||
| 107 | 202 | ||||
| 108 | - | // Seek to end for following | 203 | + | line = strings.TrimRight(line, "\n\r") |
| 109 | - | if _, err := file.Seek(0, io.SeekEnd); err != nil { | 204 | + | if !sendLogLineWithChannel(ctx, ch, line) { |
| 110 | - | log.Warn().Err(err).Msg("failed to seek to end") | |||
| 111 | return | 205 | return | ||
| 112 | } | 206 | } | ||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func sendLogLineWithChannel(ctx context.Context, ch chan<- string, line string) bool { | |||
| 211 | + | select { | |||
| 212 | + | case ch <- line: | |||
| 213 | + | return true | |||
| 214 | + | case <-ctx.Done(): | |||
| 215 | + | return false | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func (s *Service) getProcessLogsFromJournal(ctx context.Context, lines int) ([]string, error) { | |||
| 220 | + | userScope, err := resolveJournalctlScope(ctx) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return nil, err | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | args := []string{"-u", "gordon", "-n", strconv.Itoa(lines), "--no-pager", "-o", "cat"} | |||
| 226 | + | if userScope { | |||
| 227 | + | args = append([]string{"--user"}, args...) | |||
| 228 | + | } | |||
| 113 | 229 | ||||
| 114 | - | // Follow new lines | 230 | + | out, err := execCommandContext(ctx, "journalctl", args...).Output() |
| 115 | - | reader := bufio.NewReader(file) | 231 | + | if err != nil { |
| 116 | - | for { | 232 | + | return nil, fmt.Errorf("failed to read journalctl logs: %w", err) |
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | return outputLines(out), nil | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) { | |||
| 239 | + | userScope, err := resolveJournalctlScope(ctx) | |||
| 240 | + | if err != nil { | |||
| 241 | + | return nil, err | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | args := []string{ | |||
| 245 | + | "-u", "gordon", | |||
| 246 | + | "-n", strconv.Itoa(initialLines), | |||
| 247 | + | "--no-pager", | |||
| 248 | + | "-o", "cat", | |||
| 249 | + | "-f", | |||
| 250 | + | } | |||
| 251 | + | if userScope { | |||
| 252 | + | args = append([]string{"--user"}, args...) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only | |||
| 256 | + | stdout, err := cmd.StdoutPipe() | |||
| 257 | + | if err != nil { | |||
| 258 | + | return nil, fmt.Errorf("failed to open journalctl stdout: %w", err) | |||
| 259 | + | } | |||
| 260 | + | cmd.Stderr = io.Discard | |||
| 261 | + | ||||
| 262 | + | if err := cmd.Start(); err != nil { | |||
| 263 | + | return nil, fmt.Errorf("failed to start journalctl follow: %w", err) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | ch := make(chan string, 100) | |||
| 267 | + | ||||
| 268 | + | go func() { | |||
| 269 | + | defer close(ch) | |||
| 270 | + | defer stdout.Close() | |||
| 271 | + | defer cmd.Wait() //nolint:errcheck // best-effort cleanup | |||
| 272 | + | ||||
| 273 | + | scanner := bufio.NewScanner(stdout) | |||
| 274 | + | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |||
| 275 | + | ||||
| 276 | + | for scanner.Scan() { | |||
| 117 | select { | 277 | select { | ||
| 278 | + | case ch <- scanner.Text(): | |||
| 118 | case <-ctx.Done(): | 279 | case <-ctx.Done(): | ||
| 119 | return | 280 | return | ||
| 120 | - | default: | |||
| 121 | - | line, err := reader.ReadString('\n') | |||
| 122 | - | if err != nil { | |||
| 123 | - | if err == io.EOF { | |||
| 124 | - | time.Sleep(100 * time.Millisecond) | |||
| 125 | - | continue | |||
| 126 | - | } | |||
| 127 | - | log.Warn().Err(err).Msg("error reading log file") | |||
| 128 | - | return | |||
| 129 | - | } | |||
| 130 | - | line = strings.TrimRight(line, "\n\r") | |||
| 131 | - | select { | |||
| 132 | - | case ch <- line: | |||
| 133 | - | case <-ctx.Done(): | |||
| 134 | - | return | |||
| 135 | - | } | |||
| 136 | } | 281 | } | ||
| 137 | } | 282 | } | ||
| 138 | }() | 283 | }() | ||
| @@ -140,6 +285,47 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||||
| 140 | return ch, nil | 285 | return ch, nil | ||
| 141 | } | 286 | } | ||
| 142 | 287 | ||||
| 288 | + | func resolveJournalctlScope(ctx context.Context) (bool, error) { | |||
| 289 | + | userLogs, userErr := hasJournalctlLogs(ctx, true) | |||
| 290 | + | if userErr == nil && userLogs { | |||
| 291 | + | return true, nil | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | systemLogs, systemErr := hasJournalctlLogs(ctx, false) | |||
| 295 | + | if systemErr == nil && systemLogs { | |||
| 296 | + | return false, nil | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | if userErr != nil && systemErr != nil { | |||
| 300 | + | return false, fmt.Errorf("journalctl unavailable for user and system services") | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // Default to user scope when logs are currently empty; this matches rootless setups. | |||
| 304 | + | return true, nil | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func hasJournalctlLogs(ctx context.Context, userService bool) (bool, error) { | |||
| 308 | + | args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q", "-o", "cat"} | |||
| 309 | + | if userService { | |||
| 310 | + | args = append([]string{"--user"}, args...) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | out, err := execCommandContext(ctx, "journalctl", args...).Output() | |||
| 314 | + | if err != nil { | |||
| 315 | + | return false, err | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | return len(bytes.TrimSpace(out)) > 0, nil | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func outputLines(out []byte) []string { | |||
| 322 | + | trimmed := strings.TrimRight(string(out), "\n\r") | |||
| 323 | + | if trimmed == "" { | |||
| 324 | + | return []string{} | |||
| 325 | + | } | |||
| 326 | + | return strings.Split(trimmed, "\n") | |||
| 327 | + | } | |||
| 328 | + | ||||
| 143 | // validateDomain checks that domain is safe for use in logs and error messages. | 329 | // validateDomain checks that domain is safe for use in logs and error messages. | ||
| 144 | func validateDomain(domain string) error { | 330 | func validateDomain(domain string) error { | ||
| 145 | if domain == "" { | 331 | if domain == "" { | ||
M internal/usecase/logs/service_test.go +28 -8
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | 6 | "os" | |
| 7 | + | "os/exec" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| @@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 32 | 33 | containerSvc := mocks.NewMockContainerService(t) | |
| 33 | 34 | runtime := outMocks.NewMockContainerRuntime(t) | |
| 34 | 35 | ||
| 35 | - | svc := NewService(logPath, containerSvc, runtime, log) | |
| 36 | + | svc := NewService(logPath, true, containerSvc, runtime, log) | |
| 36 | 37 | ||
| 37 | 38 | lines, err := svc.GetProcessLogs(context.Background(), 3) | |
| 38 | 39 | require.NoError(t, err) | |
| @@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 44 | 45 | containerSvc := mocks.NewMockContainerService(t) | |
| 45 | 46 | runtime := outMocks.NewMockContainerRuntime(t) | |
| 46 | 47 | ||
| 47 | - | svc := NewService("/nonexistent/file.log", containerSvc, runtime, log) | |
| 48 | + | svc := NewService("/nonexistent/file.log", true, containerSvc, runtime, log) | |
| 48 | 49 | ||
| 49 | 50 | lines, err := svc.GetProcessLogs(context.Background(), 10) | |
| 50 | 51 | require.NoError(t, err) | |
| @@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 55 | 56 | containerSvc := mocks.NewMockContainerService(t) | |
| 56 | 57 | runtime := outMocks.NewMockContainerRuntime(t) | |
| 57 | 58 | ||
| 58 | - | svc := NewService("", containerSvc, runtime, log) | |
| 59 | + | svc := NewService("", true, containerSvc, runtime, log) | |
| 59 | 60 | ||
| 60 | 61 | _, err := svc.GetProcessLogs(context.Background(), 10) | |
| 61 | 62 | assert.Error(t, err) | |
| @@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 72 | 73 | containerSvc := mocks.NewMockContainerService(t) | |
| 73 | 74 | runtime := outMocks.NewMockContainerRuntime(t) | |
| 74 | 75 | ||
| 75 | - | svc := NewService(logPath, containerSvc, runtime, log) | |
| 76 | + | svc := NewService(logPath, true, containerSvc, runtime, log) | |
| 76 | 77 | ||
| 77 | 78 | lines, err := svc.GetProcessLogs(context.Background(), 10) | |
| 78 | 79 | require.NoError(t, err) | |
| 79 | 80 | assert.Equal(t, []string{"line1", "line2"}, lines) | |
| 80 | 81 | }) | |
| 82 | + | ||
| 83 | + | t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) { | |
| 84 | + | origExec := execCommandContext | |
| 85 | + | execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 86 | + | return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204 | |
| 87 | + | } | |
| 88 | + | defer func() { | |
| 89 | + | execCommandContext = origExec | |
| 90 | + | }() | |
| 91 | + | ||
| 92 | + | containerSvc := mocks.NewMockContainerService(t) | |
| 93 | + | runtime := outMocks.NewMockContainerRuntime(t) | |
| 94 | + | ||
| 95 | + | svc := NewService("/tmp/unused.log", false, containerSvc, runtime, log) | |
| 96 | + | ||
| 97 | + | lines, err := svc.GetProcessLogs(context.Background(), 10) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines) | |
| 100 | + | }) | |
| 81 | 101 | } | |
| 82 | 102 | ||
| 83 | 103 | func TestService_FollowProcessLogs(t *testing.T) { | |
| @@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) { | |||
| 93 | 113 | containerSvc := mocks.NewMockContainerService(t) | |
| 94 | 114 | runtime := outMocks.NewMockContainerRuntime(t) | |
| 95 | 115 | ||
| 96 | - | svc := NewService(logPath, containerSvc, runtime, log) | |
| 116 | + | svc := NewService(logPath, true, containerSvc, runtime, log) | |
| 97 | 117 | ||
| 98 | 118 | ctx, cancel := context.WithCancel(context.Background()) | |
| 99 | 119 | defer cancel() | |
| @@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||
| 135 | 155 | ||
| 136 | 156 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | |
| 137 | 157 | ||
| 138 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | |
| 158 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) | |
| 139 | 159 | ||
| 140 | 160 | _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10) | |
| 141 | 161 | assert.Error(t, err) | |
| @@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||
| 156 | 176 | // Create a mock reader that returns empty content | |
| 157 | 177 | runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil) | |
| 158 | 178 | ||
| 159 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | |
| 179 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) | |
| 160 | 180 | ||
| 161 | 181 | lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10) | |
| 162 | 182 | require.NoError(t, err) | |
| @@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) { | |||
| 173 | 193 | ||
| 174 | 194 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | |
| 175 | 195 | ||
| 176 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | |
| 196 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) | |
| 177 | 197 | ||
| 178 | 198 | _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10) | |
| 179 | 199 | assert.Error(t, err) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | + | "os/exec" | |||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 32 | containerSvc := mocks.NewMockContainerService(t) | 33 | containerSvc := mocks.NewMockContainerService(t) | ||
| 33 | runtime := outMocks.NewMockContainerRuntime(t) | 34 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 34 | 35 | ||||
| 35 | - | svc := NewService(logPath, containerSvc, runtime, log) | 36 | + | svc := NewService(logPath, true, containerSvc, runtime, log) |
| 36 | 37 | ||||
| 37 | lines, err := svc.GetProcessLogs(context.Background(), 3) | 38 | lines, err := svc.GetProcessLogs(context.Background(), 3) | ||
| 38 | require.NoError(t, err) | 39 | require.NoError(t, err) | ||
| @@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 44 | containerSvc := mocks.NewMockContainerService(t) | 45 | containerSvc := mocks.NewMockContainerService(t) | ||
| 45 | runtime := outMocks.NewMockContainerRuntime(t) | 46 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 46 | 47 | ||||
| 47 | - | svc := NewService("/nonexistent/file.log", containerSvc, runtime, log) | 48 | + | svc := NewService("/nonexistent/file.log", true, containerSvc, runtime, log) |
| 48 | 49 | ||||
| 49 | lines, err := svc.GetProcessLogs(context.Background(), 10) | 50 | lines, err := svc.GetProcessLogs(context.Background(), 10) | ||
| 50 | require.NoError(t, err) | 51 | require.NoError(t, err) | ||
| @@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 55 | containerSvc := mocks.NewMockContainerService(t) | 56 | containerSvc := mocks.NewMockContainerService(t) | ||
| 56 | runtime := outMocks.NewMockContainerRuntime(t) | 57 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 57 | 58 | ||||
| 58 | - | svc := NewService("", containerSvc, runtime, log) | 59 | + | svc := NewService("", true, containerSvc, runtime, log) |
| 59 | 60 | ||||
| 60 | _, err := svc.GetProcessLogs(context.Background(), 10) | 61 | _, err := svc.GetProcessLogs(context.Background(), 10) | ||
| 61 | assert.Error(t, err) | 62 | assert.Error(t, err) | ||
| @@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 72 | containerSvc := mocks.NewMockContainerService(t) | 73 | containerSvc := mocks.NewMockContainerService(t) | ||
| 73 | runtime := outMocks.NewMockContainerRuntime(t) | 74 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 74 | 75 | ||||
| 75 | - | svc := NewService(logPath, containerSvc, runtime, log) | 76 | + | svc := NewService(logPath, true, containerSvc, runtime, log) |
| 76 | 77 | ||||
| 77 | lines, err := svc.GetProcessLogs(context.Background(), 10) | 78 | lines, err := svc.GetProcessLogs(context.Background(), 10) | ||
| 78 | require.NoError(t, err) | 79 | require.NoError(t, err) | ||
| 79 | assert.Equal(t, []string{"line1", "line2"}, lines) | 80 | assert.Equal(t, []string{"line1", "line2"}, lines) | ||
| 80 | }) | 81 | }) | ||
| 82 | + | ||||
| 83 | + | t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) { | |||
| 84 | + | origExec := execCommandContext | |||
| 85 | + | execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { | |||
| 86 | + | return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204 | |||
| 87 | + | } | |||
| 88 | + | defer func() { | |||
| 89 | + | execCommandContext = origExec | |||
| 90 | + | }() | |||
| 91 | + | ||||
| 92 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 93 | + | runtime := outMocks.NewMockContainerRuntime(t) | |||
| 94 | + | ||||
| 95 | + | svc := NewService("/tmp/unused.log", false, containerSvc, runtime, log) | |||
| 96 | + | ||||
| 97 | + | lines, err := svc.GetProcessLogs(context.Background(), 10) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines) | |||
| 100 | + | }) | |||
| 81 | } | 101 | } | ||
| 82 | 102 | ||||
| 83 | func TestService_FollowProcessLogs(t *testing.T) { | 103 | func TestService_FollowProcessLogs(t *testing.T) { | ||
| @@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) { | |||||
| 93 | containerSvc := mocks.NewMockContainerService(t) | 113 | containerSvc := mocks.NewMockContainerService(t) | ||
| 94 | runtime := outMocks.NewMockContainerRuntime(t) | 114 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 95 | 115 | ||||
| 96 | - | svc := NewService(logPath, containerSvc, runtime, log) | 116 | + | svc := NewService(logPath, true, containerSvc, runtime, log) |
| 97 | 117 | ||||
| 98 | ctx, cancel := context.WithCancel(context.Background()) | 118 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 99 | defer cancel() | 119 | defer cancel() | ||
| @@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||||
| 135 | 155 | ||||
| 136 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | 156 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | ||
| 137 | 157 | ||||
| 138 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | 158 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) |
| 139 | 159 | ||||
| 140 | _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10) | 160 | _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10) | ||
| 141 | assert.Error(t, err) | 161 | assert.Error(t, err) | ||
| @@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||||
| 156 | // Create a mock reader that returns empty content | 176 | // Create a mock reader that returns empty content | ||
| 157 | runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil) | 177 | runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil) | ||
| 158 | 178 | ||||
| 159 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | 179 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) |
| 160 | 180 | ||||
| 161 | lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10) | 181 | lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10) | ||
| 162 | require.NoError(t, err) | 182 | require.NoError(t, err) | ||
| @@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) { | |||||
| 173 | 193 | ||||
| 174 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | 194 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | ||
| 175 | 195 | ||||
| 176 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | 196 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) |
| 177 | 197 | ||||
| 178 | _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10) | 198 | _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10) | ||
| 179 | assert.Error(t, err) | 199 | assert.Error(t, err) | ||
M internal/usecase/proxy/service.go +27 -8
| @@ -24,8 +24,8 @@ import ( | |||
| 24 | 24 | "github.com/bnema/gordon/internal/domain" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | - | // proxyTransport is a shared HTTP transport with proper timeouts. | |
| 28 | - | // This prevents resource exhaustion from slow backends or network issues. | |
| 27 | + | // proxyTransport is a shared HTTP transport for proxying to application containers. | |
| 28 | + | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | |
| 29 | 29 | var proxyTransport = &http.Transport{ | |
| 30 | 30 | DialContext: (&net.Dialer{ | |
| 31 | 31 | Timeout: 10 * time.Second, | |
| @@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{ | |||
| 38 | 38 | IdleConnTimeout: 90 * time.Second, | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | // registryTransport is used for the registry reverse proxy (localhost loopback). | |
| 42 | + | // It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy | |
| 43 | + | // perform blocking operations (image pull, container start, readiness checks) that | |
| 44 | + | // can take well over 30 seconds. | |
| 45 | + | var registryTransport = &http.Transport{ | |
| 46 | + | DialContext: (&net.Dialer{ | |
| 47 | + | Timeout: 10 * time.Second, | |
| 48 | + | KeepAlive: 30 * time.Second, | |
| 49 | + | }).DialContext, | |
| 50 | + | ResponseHeaderTimeout: 3 * time.Minute, | |
| 51 | + | MaxIdleConns: 20, | |
| 52 | + | MaxIdleConnsPerHost: 10, | |
| 53 | + | IdleConnTimeout: 90 * time.Second, | |
| 54 | + | } | |
| 55 | + | ||
| 41 | 56 | // Config holds configuration needed by the proxy service. | |
| 42 | 57 | type Config struct { | |
| 43 | 58 | RegistryDomain string | |
| @@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 558 | 573 | return | |
| 559 | 574 | } | |
| 560 | 575 | ||
| 561 | - | proxy := newReverseProxy(targetURL, "", | |
| 562 | - | func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 576 | + | proxy := &httputil.ReverseProxy{ | |
| 577 | + | Rewrite: func(pr *httputil.ProxyRequest) { | |
| 578 | + | pr.SetURL(targetURL) | |
| 579 | + | pr.SetXForwarded() | |
| 580 | + | pr.Out.Host = targetURL.Host | |
| 581 | + | }, | |
| 582 | + | Transport: registryTransport, | |
| 583 | + | ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 563 | 584 | var maxBytesErr *http.MaxBytesError | |
| 564 | 585 | if errors.As(err, &maxBytesErr) { | |
| 565 | 586 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | |
| @@ -569,13 +590,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 569 | 590 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | |
| 570 | 591 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | |
| 571 | 592 | }, | |
| 572 | - | func(resp *http.Response) error { | |
| 593 | + | ModifyResponse: func(resp *http.Response) error { | |
| 573 | 594 | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 574 | 595 | resp.Header.Set("X-Registry-Backend", "gordon-registry") | |
| 575 | 596 | ||
| 576 | 597 | // Remove security headers from registry response to prevent duplicates. | |
| 577 | - | // The proxy middleware already adds these headers, so the registry's | |
| 578 | - | // copies would create duplicates in the final response. | |
| 579 | 598 | resp.Header.Del("X-Content-Type-Options") | |
| 580 | 599 | resp.Header.Del("X-Frame-Options") | |
| 581 | 600 | resp.Header.Del("X-XSS-Protection") | |
| @@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 586 | 605 | ||
| 587 | 606 | return nil | |
| 588 | 607 | }, | |
| 589 | - | ) | |
| 608 | + | } | |
| 590 | 609 | ||
| 591 | 610 | log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry") | |
| 592 | 611 | ||
| @@ -24,8 +24,8 @@ import ( | |||||
| 24 | "github.com/bnema/gordon/internal/domain" | 24 | "github.com/bnema/gordon/internal/domain" | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | - | // proxyTransport is a shared HTTP transport with proper timeouts. | 27 | + | // proxyTransport is a shared HTTP transport for proxying to application containers. |
| 28 | - | // This prevents resource exhaustion from slow backends or network issues. | 28 | + | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. |
| 29 | var proxyTransport = &http.Transport{ | 29 | var proxyTransport = &http.Transport{ | ||
| 30 | DialContext: (&net.Dialer{ | 30 | DialContext: (&net.Dialer{ | ||
| 31 | Timeout: 10 * time.Second, | 31 | Timeout: 10 * time.Second, | ||
| @@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{ | |||||
| 38 | IdleConnTimeout: 90 * time.Second, | 38 | IdleConnTimeout: 90 * time.Second, | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | // registryTransport is used for the registry reverse proxy (localhost loopback). | |||
| 42 | + | // It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy | |||
| 43 | + | // perform blocking operations (image pull, container start, readiness checks) that | |||
| 44 | + | // can take well over 30 seconds. | |||
| 45 | + | var registryTransport = &http.Transport{ | |||
| 46 | + | DialContext: (&net.Dialer{ | |||
| 47 | + | Timeout: 10 * time.Second, | |||
| 48 | + | KeepAlive: 30 * time.Second, | |||
| 49 | + | }).DialContext, | |||
| 50 | + | ResponseHeaderTimeout: 3 * time.Minute, | |||
| 51 | + | MaxIdleConns: 20, | |||
| 52 | + | MaxIdleConnsPerHost: 10, | |||
| 53 | + | IdleConnTimeout: 90 * time.Second, | |||
| 54 | + | } | |||
| 55 | + | ||||
| 41 | // Config holds configuration needed by the proxy service. | 56 | // Config holds configuration needed by the proxy service. | ||
| 42 | type Config struct { | 57 | type Config struct { | ||
| 43 | RegistryDomain string | 58 | RegistryDomain string | ||
| @@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 558 | return | 573 | return | ||
| 559 | } | 574 | } | ||
| 560 | 575 | ||||
| 561 | - | proxy := newReverseProxy(targetURL, "", | 576 | + | proxy := &httputil.ReverseProxy{ |
| 562 | - | func(w http.ResponseWriter, _ *http.Request, err error) { | 577 | + | Rewrite: func(pr *httputil.ProxyRequest) { |
| 578 | + | pr.SetURL(targetURL) | |||
| 579 | + | pr.SetXForwarded() | |||
| 580 | + | pr.Out.Host = targetURL.Host | |||
| 581 | + | }, | |||
| 582 | + | Transport: registryTransport, | |||
| 583 | + | ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { | |||
| 563 | var maxBytesErr *http.MaxBytesError | 584 | var maxBytesErr *http.MaxBytesError | ||
| 564 | if errors.As(err, &maxBytesErr) { | 585 | if errors.As(err, &maxBytesErr) { | ||
| 565 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | 586 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | ||
| @@ -569,13 +590,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 569 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | 590 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | ||
| 570 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | 591 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | ||
| 571 | }, | 592 | }, | ||
| 572 | - | func(resp *http.Response) error { | 593 | + | ModifyResponse: func(resp *http.Response) error { |
| 573 | resp.Header.Set("X-Proxied-By", "Gordon") | 594 | resp.Header.Set("X-Proxied-By", "Gordon") | ||
| 574 | resp.Header.Set("X-Registry-Backend", "gordon-registry") | 595 | resp.Header.Set("X-Registry-Backend", "gordon-registry") | ||
| 575 | 596 | ||||
| 576 | // Remove security headers from registry response to prevent duplicates. | 597 | // Remove security headers from registry response to prevent duplicates. | ||
| 577 | - | // The proxy middleware already adds these headers, so the registry's | |||
| 578 | - | // copies would create duplicates in the final response. | |||
| 579 | resp.Header.Del("X-Content-Type-Options") | 598 | resp.Header.Del("X-Content-Type-Options") | ||
| 580 | resp.Header.Del("X-Frame-Options") | 599 | resp.Header.Del("X-Frame-Options") | ||
| 581 | resp.Header.Del("X-XSS-Protection") | 600 | resp.Header.Del("X-XSS-Protection") | ||
| @@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 586 | 605 | ||||
| 587 | return nil | 606 | return nil | ||
| 588 | }, | 607 | }, | ||
| 589 | - | ) | 608 | + | } |
| 590 | 609 | ||||
| 591 | log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry") | 610 | log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry") | ||
| 592 | 611 | ||||
M internal/usecase/registry/service.go +5 -2
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "crypto/sha256" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | + | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| 11 | 12 | ||
| @@ -74,8 +75,10 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 74 | 75 | return "", log.WrapErr(err, "failed to store manifest") | |
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | - | // Publish image pushed event | |
| 78 | - | if s.eventBus != nil { | |
| 78 | + | // Publish image pushed event only for tag references (not digests). | |
| 79 | + | // A docker push sends manifests by both digest and tag; firing only on | |
| 80 | + | // tag prevents duplicate deploy triggers for the same push. | |
| 81 | + | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | |
| 79 | 82 | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | |
| 80 | 83 | Name: manifest.Name, | |
| 81 | 84 | Reference: manifest.Reference, | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "crypto/sha256" | 6 | "crypto/sha256" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | + | "strings" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 11 | 12 | ||||
| @@ -74,8 +75,10 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 74 | return "", log.WrapErr(err, "failed to store manifest") | 75 | return "", log.WrapErr(err, "failed to store manifest") | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 77 | - | // Publish image pushed event | 78 | + | // Publish image pushed event only for tag references (not digests). |
| 78 | - | if s.eventBus != nil { | 79 | + | // A docker push sends manifests by both digest and tag; firing only on |
| 80 | + | // tag prevents duplicate deploy triggers for the same push. | |||
| 81 | + | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | |||
| 79 | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | 82 | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | ||
| 80 | Name: manifest.Name, | 83 | Name: manifest.Name, | ||
| 81 | Reference: manifest.Reference, | 84 | Reference: manifest.Reference, | ||
M wiki/guides/remote-cli.md +27 -1
| @@ -18,7 +18,7 @@ Manage Gordon instances remotely using the CLI with the `--remote` flag or saved | |||
| 18 | 18 | ||
| 19 | 19 | ## Understanding the Remote URL | |
| 20 | 20 | ||
| 21 | - | The `--remote` URL must be the `gordon_domain` configured on the remote Gordon instance. This domain serves both the container registry and the Admin API. | |
| 21 | + | Default: use the Gordon domain configured on the remote instance. | |
| 22 | 22 | ||
| 23 | 23 | ```toml | |
| 24 | 24 | # On the remote Gordon server | |
| @@ -28,6 +28,31 @@ gordon_domain = "gordon.example.com" # Use this as --remote URL | |||
| 28 | 28 | ||
| 29 | 29 | The CLI connects to `https://gordon.example.com/admin/*` endpoints. | |
| 30 | 30 | ||
| 31 | + | Private tailnet setup (recommended for hardened VPS): keep using the HTTPS domain, point that DNS record to the VPS tailnet IP, and set insecure TLS for self-signed certs. | |
| 32 | + | ||
| 33 | + | ```bash | |
| 34 | + | # Save remote once | |
| 35 | + | gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure | |
| 36 | + | gordon remotes use tailnet-reg | |
| 37 | + | ||
| 38 | + | # Use it for auth/admin commands | |
| 39 | + | gordon auth login | |
| 40 | + | gordon routes list | |
| 41 | + | ``` | |
| 42 | + | ||
| 43 | + | Equivalent `remotes.toml` entry: | |
| 44 | + | ||
| 45 | + | ```toml | |
| 46 | + | active = "tailnet-reg" | |
| 47 | + | ||
| 48 | + | [remotes.tailnet-reg] | |
| 49 | + | url = "https://gordon.example.com" | |
| 50 | + | token_env = "GORDON_TOKEN" | |
| 51 | + | insecure_tls = true | |
| 52 | + | ``` | |
| 53 | + | ||
| 54 | + | Use this mode when registry/auth/admin access is intentionally restricted to tailnet CIDRs. | |
| 55 | + | ||
| 31 | 56 | ## Quick Start | |
| 32 | 57 | ||
| 33 | 58 | ### One-off Remote Command | |
| @@ -65,6 +90,7 @@ These flags are available on all commands: | |||
| 65 | 90 | |------|-------------| | |
| 66 | 91 | | `--remote <URL>` | Remote Gordon URL | | |
| 67 | 92 | | `--token <TOKEN>` | Authentication token | | |
| 93 | + | | `--insecure` | Skip TLS verification (self-signed/private certs) | | |
| 68 | 94 | ||
| 69 | 95 | ```bash | |
| 70 | 96 | # List routes on remote | |
| @@ -18,7 +18,7 @@ Manage Gordon instances remotely using the CLI with the `--remote` flag or saved | |||||
| 18 | 18 | ||||
| 19 | ## Understanding the Remote URL | 19 | ## Understanding the Remote URL | ||
| 20 | 20 | ||||
| 21 | - | The `--remote` URL must be the `gordon_domain` configured on the remote Gordon instance. This domain serves both the container registry and the Admin API. | 21 | + | Default: use the Gordon domain configured on the remote instance. |
| 22 | 22 | ||||
| 23 | ```toml | 23 | ```toml | ||
| 24 | # On the remote Gordon server | 24 | # On the remote Gordon server | ||
| @@ -28,6 +28,31 @@ gordon_domain = "gordon.example.com" # Use this as --remote URL | |||||
| 28 | 28 | ||||
| 29 | The CLI connects to `https://gordon.example.com/admin/*` endpoints. | 29 | The CLI connects to `https://gordon.example.com/admin/*` endpoints. | ||
| 30 | 30 | ||||
| 31 | + | Private tailnet setup (recommended for hardened VPS): keep using the HTTPS domain, point that DNS record to the VPS tailnet IP, and set insecure TLS for self-signed certs. | |||
| 32 | + | ||||
| 33 | + | ```bash | |||
| 34 | + | # Save remote once | |||
| 35 | + | gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure | |||
| 36 | + | gordon remotes use tailnet-reg | |||
| 37 | + | ||||
| 38 | + | # Use it for auth/admin commands | |||
| 39 | + | gordon auth login | |||
| 40 | + | gordon routes list | |||
| 41 | + | ``` | |||
| 42 | + | ||||
| 43 | + | Equivalent `remotes.toml` entry: | |||
| 44 | + | ||||
| 45 | + | ```toml | |||
| 46 | + | active = "tailnet-reg" | |||
| 47 | + | ||||
| 48 | + | [remotes.tailnet-reg] | |||
| 49 | + | url = "https://gordon.example.com" | |||
| 50 | + | token_env = "GORDON_TOKEN" | |||
| 51 | + | insecure_tls = true | |||
| 52 | + | ``` | |||
| 53 | + | ||||
| 54 | + | Use this mode when registry/auth/admin access is intentionally restricted to tailnet CIDRs. | |||
| 55 | + | ||||
| 31 | ## Quick Start | 56 | ## Quick Start | ||
| 32 | 57 | ||||
| 33 | ### One-off Remote Command | 58 | ### One-off Remote Command | ||
| @@ -65,6 +90,7 @@ These flags are available on all commands: | |||||
| 65 | |------|-------------| | 90 | |------|-------------| | ||
| 66 | | `--remote <URL>` | Remote Gordon URL | | 91 | | `--remote <URL>` | Remote Gordon URL | | ||
| 67 | | `--token <TOKEN>` | Authentication token | | 92 | | `--token <TOKEN>` | Authentication token | | ||
| 93 | + | | `--insecure` | Skip TLS verification (self-signed/private certs) | | |||
| 68 | 94 | ||||
| 69 | ```bash | 95 | ```bash | ||
| 70 | # List routes on remote | 96 | # List routes on remote | ||
M wiki/guides/secure-vps-setup.md +49 -0
| @@ -142,6 +142,55 @@ Verify public SSH is blocked: | |||
| 142 | 142 | ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout | |
| 143 | 143 | ``` | |
| 144 | 144 | ||
| 145 | + | ## Step 6.5: Restrict Registry and Auth to Tailnet CIDR | |
| 146 | + | ||
| 147 | + | If you want `/auth/*` and registry (`/v2/*`) reachable only from your tailnet, set Gordon's registry allowlist to the Tailscale CGNAT range. | |
| 148 | + | ||
| 149 | + | Edit `~/.config/gordon/gordon.toml`: | |
| 150 | + | ||
| 151 | + | ```toml | |
| 152 | + | [server] | |
| 153 | + | registry_allowed_ips = ["100.64.0.0/10"] | |
| 154 | + | ``` | |
| 155 | + | ||
| 156 | + | Apply the change: | |
| 157 | + | ||
| 158 | + | ```bash | |
| 159 | + | systemctl --user restart gordon | |
| 160 | + | ``` | |
| 161 | + | ||
| 162 | + | Behavior: | |
| 163 | + | ||
| 164 | + | - Requests from tailnet IPs (`100.64.0.0/10`) are allowed. | |
| 165 | + | - Requests outside that CIDR get `403 Forbidden` on registry/auth endpoints. | |
| 166 | + | - App routes still use normal host-based routing through your proxy ports. | |
| 167 | + | ||
| 168 | + | For private operations (auth, push/pull, admin API), keep using the HTTPS registry domain, but resolve that domain to the VPS tailnet IP (for example, via Cloudflare DNS to the tailnet address). With self-signed certs, configure the CLI remote with insecure TLS enabled. | |
| 169 | + | ||
| 170 | + | ```bash | |
| 171 | + | # Example: save remote once | |
| 172 | + | gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure | |
| 173 | + | gordon remotes use tailnet-reg | |
| 174 | + | ||
| 175 | + | # Then use commands normally (auth + admin API) | |
| 176 | + | gordon auth login | |
| 177 | + | gordon routes list | |
| 178 | + | gordon backup status | |
| 179 | + | ``` | |
| 180 | + | ||
| 181 | + | `~/.config/gordon/remotes.toml` should contain an entry like: | |
| 182 | + | ||
| 183 | + | ```toml | |
| 184 | + | active = "tailnet-reg" | |
| 185 | + | ||
| 186 | + | [remotes.tailnet-reg] | |
| 187 | + | url = "https://gordon.example.com" | |
| 188 | + | token_env = "GORDON_TOKEN" | |
| 189 | + | insecure_tls = true | |
| 190 | + | ``` | |
| 191 | + | ||
| 192 | + | This keeps transport on HTTPS while allowing self-signed certificates in tailnet-only deployments. | |
| 193 | + | ||
| 145 | 194 | --- | |
| 146 | 195 | ||
| 147 | 196 | ## Step 7: Install CrowdSec | |
| @@ -142,6 +142,55 @@ Verify public SSH is blocked: | |||||
| 142 | ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout | 142 | ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout | ||
| 143 | ``` | 143 | ``` | ||
| 144 | 144 | ||||
| 145 | + | ## Step 6.5: Restrict Registry and Auth to Tailnet CIDR | |||
| 146 | + | ||||
| 147 | + | If you want `/auth/*` and registry (`/v2/*`) reachable only from your tailnet, set Gordon's registry allowlist to the Tailscale CGNAT range. | |||
| 148 | + | ||||
| 149 | + | Edit `~/.config/gordon/gordon.toml`: | |||
| 150 | + | ||||
| 151 | + | ```toml | |||
| 152 | + | [server] | |||
| 153 | + | registry_allowed_ips = ["100.64.0.0/10"] | |||
| 154 | + | ``` | |||
| 155 | + | ||||
| 156 | + | Apply the change: | |||
| 157 | + | ||||
| 158 | + | ```bash | |||
| 159 | + | systemctl --user restart gordon | |||
| 160 | + | ``` | |||
| 161 | + | ||||
| 162 | + | Behavior: | |||
| 163 | + | ||||
| 164 | + | - Requests from tailnet IPs (`100.64.0.0/10`) are allowed. | |||
| 165 | + | - Requests outside that CIDR get `403 Forbidden` on registry/auth endpoints. | |||
| 166 | + | - App routes still use normal host-based routing through your proxy ports. | |||
| 167 | + | ||||
| 168 | + | For private operations (auth, push/pull, admin API), keep using the HTTPS registry domain, but resolve that domain to the VPS tailnet IP (for example, via Cloudflare DNS to the tailnet address). With self-signed certs, configure the CLI remote with insecure TLS enabled. | |||
| 169 | + | ||||
| 170 | + | ```bash | |||
| 171 | + | # Example: save remote once | |||
| 172 | + | gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure | |||
| 173 | + | gordon remotes use tailnet-reg | |||
| 174 | + | ||||
| 175 | + | # Then use commands normally (auth + admin API) | |||
| 176 | + | gordon auth login | |||
| 177 | + | gordon routes list | |||
| 178 | + | gordon backup status | |||
| 179 | + | ``` | |||
| 180 | + | ||||
| 181 | + | `~/.config/gordon/remotes.toml` should contain an entry like: | |||
| 182 | + | ||||
| 183 | + | ```toml | |||
| 184 | + | active = "tailnet-reg" | |||
| 185 | + | ||||
| 186 | + | [remotes.tailnet-reg] | |||
| 187 | + | url = "https://gordon.example.com" | |||
| 188 | + | token_env = "GORDON_TOKEN" | |||
| 189 | + | insecure_tls = true | |||
| 190 | + | ``` | |||
| 191 | + | ||||
| 192 | + | This keeps transport on HTTPS while allowing self-signed certificates in tailnet-only deployments. | |||
| 193 | + | ||||
| 145 | --- | 194 | --- | ||
| 146 | 195 | ||||
| 147 | ## Step 7: Install CrowdSec | 196 | ## Step 7: Install CrowdSec | ||