detached a5eba19

fix(deploy,backup): tolerate slow healthchecks on restart and detect pgsql attachments

Auto-readiness after Gordon restart now retries containers still in
"starting" health state with 2x timeout instead of immediately removing
them. Backup detection recognizes all common PostgreSQL naming variants
(postgres, postgresql, pgsql, postgis) and falls back to port 5432.
commit
a5eba19f82e683103e4f9cd45ef838ee86c6e813
author
brice · · 2026-02-08 12:49 UTC
committer
brice · · 2026-02-08 12:54 UTC
parent
d255d28
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +274 -36
M internal/domain/event.go +19 -0
@@ -81,3 +81,22 @@ func IsInternalDeploy(ctx context.Context) bool {
8181 func WithInternalDeploy(ctx context.Context) context.Context {
8282 return context.WithValue(ctx, ContextKeyInternalDeploy, true)
8383 }
84+
85+const (
86+ // ContextKeyHealthTimeoutMultiplier overrides the health timeout with a multiplier.
87+ ContextKeyHealthTimeoutMultiplier contextKey = "health_timeout_multiplier"
88+)
89+
90+// WithHealthTimeoutMultiplier returns a context that scales the health timeout.
91+func WithHealthTimeoutMultiplier(ctx context.Context, multiplier float64) context.Context {
92+ return context.WithValue(ctx, ContextKeyHealthTimeoutMultiplier, multiplier)
93+}
94+
95+// HealthTimeoutMultiplier returns the timeout multiplier from ctx, or 1.0 if unset.
96+func HealthTimeoutMultiplier(ctx context.Context) float64 {
97+ v, ok := ctx.Value(ContextKeyHealthTimeoutMultiplier).(float64)
98+ if !ok || v <= 0 {
99+ return 1.0
100+ }
101+ return v
102+}
@@ -81,3 +81,22 @@ 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+ // ContextKeyHealthTimeoutMultiplier overrides the health timeout with a multiplier.
87+ ContextKeyHealthTimeoutMultiplier contextKey = "health_timeout_multiplier"
88+)
89+
90+// WithHealthTimeoutMultiplier returns a context that scales the health timeout.
91+func WithHealthTimeoutMultiplier(ctx context.Context, multiplier float64) context.Context {
92+ return context.WithValue(ctx, ContextKeyHealthTimeoutMultiplier, multiplier)
93+}
94+
95+// HealthTimeoutMultiplier returns the timeout multiplier from ctx, or 1.0 if unset.
96+func HealthTimeoutMultiplier(ctx context.Context) float64 {
97+ v, ok := ctx.Value(ContextKeyHealthTimeoutMultiplier).(float64)
98+ if !ok || v <= 0 {
99+ return 1.0
100+ }
101+ return v
102+}
M internal/usecase/backup/detector.go +53 -27
@@ -1,49 +1,75 @@
11 package backup
22
33 import (
4+ "slices"
45 "strings"
56
67 "github.com/bnema/gordon/internal/domain"
78 )
89
10+// postgresAliases contains substrings that identify a PostgreSQL image or service.
11+var postgresAliases = []string{"postgres", "postgresql", "pgsql", "postgis"}
12+
913 // detectDatabaseFromAttachment maps a container attachment to DB metadata.
1014 func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) {
1115 image := strings.ToLower(a.Image)
1216 name := strings.ToLower(a.Name)
1317
1418 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
4224 default:
4325 return domain.DBInfo{}, false
4426 }
4527 }
4628
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+ hasPostgresPort := false
48+ firstPositive := 0
49+ for _, p := range a.Ports {
50+ if p == 5432 {
51+ hasPostgresPort = true
52+ break
53+ }
54+ if p > 0 && firstPositive == 0 {
55+ firstPositive = p
56+ }
57+ }
58+ if !hasPostgresPort && firstPositive > 0 {
59+ port = firstPositive
60+ }
61+ return domain.DBInfo{
62+ Type: domain.DBTypePostgreSQL,
63+ Version: postgresVersionFromImage(a.Image),
64+ Domain: domainName,
65+ Name: a.Name,
66+ Host: a.Name,
67+ Port: port,
68+ ContainerID: a.ContainerID,
69+ ImageName: a.Image,
70+ }
71+}
72+
4773 func postgresVersionFromImage(image string) string {
4874 lastColon := strings.LastIndex(image, ":")
4975 if lastColon == -1 {
@@ -1,49 +1,75 @@
1 package backup1 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 := 543220+ return buildPostgresInfo(domainName, a), true
17- hasPostgresPort := false21+ case hasPort(a.Ports, 5432):
18- firstPositive := 022+ // 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{}, false25 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+ hasPostgresPort := false
48+ firstPositive := 0
49+ for _, p := range a.Ports {
50+ if p == 5432 {
51+ hasPostgresPort = true
52+ break
53+ }
54+ if p > 0 && firstPositive == 0 {
55+ firstPositive = p
56+ }
57+ }
58+ if !hasPostgresPort && firstPositive > 0 {
59+ port = firstPositive
60+ }
61+ return domain.DBInfo{
62+ Type: domain.DBTypePostgreSQL,
63+ Version: postgresVersionFromImage(a.Image),
64+ Domain: domainName,
65+ Name: a.Name,
66+ Host: a.Name,
67+ Port: port,
68+ ContainerID: a.ContainerID,
69+ ImageName: a.Image,
70+ }
71+}
72+
47 func postgresVersionFromImage(image string) string {73 func postgresVersionFromImage(image string) string {
48 lastColon := strings.LastIndex(image, ":")74 lastColon := strings.LastIndex(image, ":")
49 if lastColon == -1 {75 if lastColon == -1 {
M internal/usecase/backup/detector_test.go +93 -0
@@ -76,3 +76,96 @@ func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) {
7676 })
7777 assert.False(t, ok)
7878 }
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+ // Positive cases
158+ assert.True(t, looksLikePostgres("postgres"))
159+ assert.True(t, looksLikePostgres("postgres:16"))
160+ assert.True(t, looksLikePostgres("my-postgres-db"))
161+ assert.True(t, looksLikePostgres("postgresql"))
162+ assert.True(t, looksLikePostgres("pgsql"))
163+ assert.True(t, looksLikePostgres("mon-fauteuil-prod-pgsql"))
164+ assert.True(t, looksLikePostgres("postgis/postgis"))
165+
166+ // Negative cases
167+ assert.False(t, looksLikePostgres("mysql"))
168+ assert.False(t, looksLikePostgres("redis"))
169+ assert.False(t, looksLikePostgres("custom-db"))
170+ assert.False(t, looksLikePostgres(""))
171+}
@@ -76,3 +76,96 @@ 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+ // Positive cases
158+ assert.True(t, looksLikePostgres("postgres"))
159+ assert.True(t, looksLikePostgres("postgres:16"))
160+ assert.True(t, looksLikePostgres("my-postgres-db"))
161+ assert.True(t, looksLikePostgres("postgresql"))
162+ assert.True(t, looksLikePostgres("pgsql"))
163+ assert.True(t, looksLikePostgres("mon-fauteuil-prod-pgsql"))
164+ assert.True(t, looksLikePostgres("postgis/postgis"))
165+
166+ // Negative cases
167+ assert.False(t, looksLikePostgres("mysql"))
168+ assert.False(t, looksLikePostgres("redis"))
169+ assert.False(t, looksLikePostgres("custom-db"))
170+ assert.False(t, looksLikePostgres(""))
171+}
M internal/usecase/container/service.go +109 -9
@@ -56,6 +56,28 @@ const (
5656 internalPullMaxAttempts = 3
5757 )
5858
59+// healthTimeoutError is returned when a container's health check times out.
60+// It carries the last observed health status to distinguish retryable (starting)
61+// from fatal (unhealthy) timeouts.
62+type healthTimeoutError struct {
63+ lastStatus string
64+ timeout time.Duration
65+}
66+
67+func (e *healthTimeoutError) Error() string {
68+ return fmt.Sprintf("container healthcheck timeout after %s (last status: %s)", e.timeout, e.lastStatus)
69+}
70+
71+// isHealthTimeoutStarting returns true if err is a health timeout where the
72+// container was still in "starting" state (i.e., not unhealthy, just slow).
73+func isHealthTimeoutStarting(err error) bool {
74+ var hte *healthTimeoutError
75+ if errors.As(err, &hte) {
76+ return hte.lastStatus == "starting"
77+ }
78+ return false
79+}
80+
5981 // Service implements the ContainerService interface.
6082 type Service struct {
6183 runtime out.ContainerRuntime
@@ -348,7 +370,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
348370 return nil, log.WrapErr(err, "failed to start container")
349371 }
350372 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
351- s.cleanupFailedContainer(ctx, newContainer.ID)
373+ // If health timed out but the container is still starting (not unhealthy),
374+ // keep it running for potential retry by the caller.
375+ if !isHealthTimeoutStarting(err) {
376+ s.cleanupFailedContainer(ctx, newContainer.ID)
377+ }
352378 return nil, log.WrapErr(err, "container failed readiness check")
353379 }
354380
@@ -814,9 +840,10 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
814840
815841 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
816842
817- var started, skipped, errors int
843+ var started, skipped, errCount int
844+ var retryable []domain.Route
845+
818846 for _, route := range routes {
819- // Check if container already exists and is running
820847 if _, exists := s.Get(ctx, route.Domain); exists {
821848 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
822849 skipped++
@@ -829,26 +856,95 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
829856 Msg("auto-starting container for route")
830857
831858 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++
859+ if isHealthTimeoutStarting(err) {
860+ log.Warn().Str("domain", route.Domain).Msg("health check still starting, will retry")
861+ retryable = append(retryable, route)
862+ } else {
863+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
864+ errCount++
865+ }
834866 continue
835867 }
836868
837869 started++
838870 }
839871
872+ // Retry routes whose containers are still starting with extended timeout.
873+ if len(retryable) > 0 {
874+ log.Info().Int("retry_count", len(retryable)).Msg("retrying slow-starting containers with extended timeout")
875+ retryCtx := domain.WithHealthTimeoutMultiplier(ctx, 2.0)
876+
877+ for _, route := range retryable {
878+ // Container is still running from first attempt, try readiness again.
879+ if _, err := s.retryReadiness(retryCtx, route); err != nil {
880+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container after retry")
881+ errCount++
882+ continue
883+ }
884+ started++
885+ }
886+ }
887+
840888 log.Info().
841889 Int("started", started).
842890 Int("skipped", skipped).
843- Int("errors", errors).
891+ Int("errors", errCount).
844892 Msg("auto-start completed")
845893
846- if errors > 0 {
847- return fmt.Errorf("auto-start completed with %d errors", errors)
894+ if errCount > 0 {
895+ return fmt.Errorf("auto-start completed with %d errors", errCount)
848896 }
849897 return nil
850898 }
851899
900+// retryReadiness re-checks readiness for a container that was left running
901+// after a health timeout during auto-start. If readiness succeeds, the
902+// container is activated. If it fails, the container is cleaned up.
903+func (s *Service) retryReadiness(ctx context.Context, route domain.Route) (*domain.Container, error) {
904+ log := zerowrap.FromCtx(ctx)
905+
906+ unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
907+ if err != nil {
908+ return nil, err
909+ }
910+ defer unlock()
911+
912+ // Find the container that was left running from the first attempt.
913+ containers, err := s.runtime.ListContainers(ctx, false)
914+ if err != nil {
915+ return nil, err
916+ }
917+
918+ var containerID string
919+ for _, c := range containers {
920+ if c.Labels != nil && c.Labels[domain.LabelRoute] == route.Domain && c.Status == string(domain.ContainerStatusRunning) {
921+ containerID = c.ID
922+ break
923+ }
924+ }
925+ if containerID == "" {
926+ // Container is no longer running, do a full deploy.
927+ return s.Deploy(ctx, route)
928+ }
929+
930+ log.Info().Str("domain", route.Domain).Str("container_id", containerID).Msg("retrying readiness check")
931+
932+ if err := s.waitForReady(ctx, containerID); err != nil {
933+ s.cleanupFailedContainer(ctx, containerID)
934+ return nil, log.WrapErr(err, "container failed readiness check on retry")
935+ }
936+
937+ inspected, err := s.runtime.InspectContainer(ctx, containerID)
938+ if err != nil {
939+ s.cleanupFailedContainer(ctx, containerID)
940+ return nil, log.WrapErr(err, "failed to inspect container after retry")
941+ }
942+
943+ s.activateDeployedContainer(ctx, route.Domain, inspected)
944+
945+ return inspected, nil
946+}
947+
852948 // Shutdown gracefully shuts down all managed containers.
853949 func (s *Service) Shutdown(ctx context.Context) error {
854950 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1966,6 +2062,10 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
19662062 if timeout == 0 {
19672063 timeout = 90 * time.Second
19682064 }
2065+ // Apply multiplier from context (used for auto-start retries).
2066+ if m := domain.HealthTimeoutMultiplier(ctx); m > 1.0 {
2067+ timeout = time.Duration(float64(timeout) * m)
2068+ }
19692069 waitCtx, cancel := context.WithTimeout(ctx, timeout)
19702070 defer cancel()
19712071
@@ -1988,7 +2088,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
19882088 select {
19892089 case <-time.After(time.Second):
19902090 case <-waitCtx.Done():
1991- return fmt.Errorf("container healthcheck timeout after %s (last status: %s)", timeout, lastStatus)
2091+ return &healthTimeoutError{lastStatus: lastStatus, timeout: timeout}
19922092 }
19932093 }
19942094 }
@@ -56,6 +56,28 @@ const (
56 internalPullMaxAttempts = 356 internalPullMaxAttempts = 3
57 )57 )
58 58
59+// healthTimeoutError is returned when a container's health check times out.
60+// It carries the last observed health status to distinguish retryable (starting)
61+// from fatal (unhealthy) timeouts.
62+type healthTimeoutError struct {
63+ lastStatus string
64+ timeout time.Duration
65+}
66+
67+func (e *healthTimeoutError) Error() string {
68+ return fmt.Sprintf("container healthcheck timeout after %s (last status: %s)", e.timeout, e.lastStatus)
69+}
70+
71+// isHealthTimeoutStarting returns true if err is a health timeout where the
72+// container was still in "starting" state (i.e., not unhealthy, just slow).
73+func isHealthTimeoutStarting(err error) bool {
74+ var hte *healthTimeoutError
75+ if errors.As(err, &hte) {
76+ return hte.lastStatus == "starting"
77+ }
78+ return false
79+}
80+
59 // Service implements the ContainerService interface.81 // Service implements the ContainerService interface.
60 type Service struct {82 type Service struct {
61 runtime out.ContainerRuntime83 runtime out.ContainerRuntime
@@ -348,7 +370,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
348 return nil, log.WrapErr(err, "failed to start container")370 return nil, log.WrapErr(err, "failed to start container")
349 }371 }
350 if err := s.waitForReady(ctx, newContainer.ID); err != nil {372 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
351- s.cleanupFailedContainer(ctx, newContainer.ID)373+ // If health timed out but the container is still starting (not unhealthy),
374+ // keep it running for potential retry by the caller.
375+ if !isHealthTimeoutStarting(err) {
376+ s.cleanupFailedContainer(ctx, newContainer.ID)
377+ }
352 return nil, log.WrapErr(err, "container failed readiness check")378 return nil, log.WrapErr(err, "container failed readiness check")
353 }379 }
354 380
@@ -814,9 +840,10 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
814 840
815 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")841 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
816 842
817- var started, skipped, errors int843+ var started, skipped, errCount int
844+ var retryable []domain.Route
845+
818 for _, route := range routes {846 for _, route := range routes {
819- // Check if container already exists and is running
820 if _, exists := s.Get(ctx, route.Domain); exists {847 if _, exists := s.Get(ctx, route.Domain); exists {
821 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")848 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
822 skipped++849 skipped++
@@ -829,26 +856,95 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
829 Msg("auto-starting container for route")856 Msg("auto-starting container for route")
830 857
831 if _, err := s.Deploy(ctx, route); err != nil {858 if _, err := s.Deploy(ctx, route); err != nil {
832- log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")859+ if isHealthTimeoutStarting(err) {
833- errors++860+ log.Warn().Str("domain", route.Domain).Msg("health check still starting, will retry")
861+ retryable = append(retryable, route)
862+ } else {
863+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
864+ errCount++
865+ }
834 continue866 continue
835 }867 }
836 868
837 started++869 started++
838 }870 }
839 871
872+ // Retry routes whose containers are still starting with extended timeout.
873+ if len(retryable) > 0 {
874+ log.Info().Int("retry_count", len(retryable)).Msg("retrying slow-starting containers with extended timeout")
875+ retryCtx := domain.WithHealthTimeoutMultiplier(ctx, 2.0)
876+
877+ for _, route := range retryable {
878+ // Container is still running from first attempt, try readiness again.
879+ if _, err := s.retryReadiness(retryCtx, route); err != nil {
880+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container after retry")
881+ errCount++
882+ continue
883+ }
884+ started++
885+ }
886+ }
887+
840 log.Info().888 log.Info().
841 Int("started", started).889 Int("started", started).
842 Int("skipped", skipped).890 Int("skipped", skipped).
843- Int("errors", errors).891+ Int("errors", errCount).
844 Msg("auto-start completed")892 Msg("auto-start completed")
845 893
846- if errors > 0 {894+ if errCount > 0 {
847- return fmt.Errorf("auto-start completed with %d errors", errors)895+ return fmt.Errorf("auto-start completed with %d errors", errCount)
848 }896 }
849 return nil897 return nil
850 }898 }
851 899
900+// retryReadiness re-checks readiness for a container that was left running
901+// after a health timeout during auto-start. If readiness succeeds, the
902+// container is activated. If it fails, the container is cleaned up.
903+func (s *Service) retryReadiness(ctx context.Context, route domain.Route) (*domain.Container, error) {
904+ log := zerowrap.FromCtx(ctx)
905+
906+ unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
907+ if err != nil {
908+ return nil, err
909+ }
910+ defer unlock()
911+
912+ // Find the container that was left running from the first attempt.
913+ containers, err := s.runtime.ListContainers(ctx, false)
914+ if err != nil {
915+ return nil, err
916+ }
917+
918+ var containerID string
919+ for _, c := range containers {
920+ if c.Labels != nil && c.Labels[domain.LabelRoute] == route.Domain && c.Status == string(domain.ContainerStatusRunning) {
921+ containerID = c.ID
922+ break
923+ }
924+ }
925+ if containerID == "" {
926+ // Container is no longer running, do a full deploy.
927+ return s.Deploy(ctx, route)
928+ }
929+
930+ log.Info().Str("domain", route.Domain).Str("container_id", containerID).Msg("retrying readiness check")
931+
932+ if err := s.waitForReady(ctx, containerID); err != nil {
933+ s.cleanupFailedContainer(ctx, containerID)
934+ return nil, log.WrapErr(err, "container failed readiness check on retry")
935+ }
936+
937+ inspected, err := s.runtime.InspectContainer(ctx, containerID)
938+ if err != nil {
939+ s.cleanupFailedContainer(ctx, containerID)
940+ return nil, log.WrapErr(err, "failed to inspect container after retry")
941+ }
942+
943+ s.activateDeployedContainer(ctx, route.Domain, inspected)
944+
945+ return inspected, nil
946+}
947+
852 // Shutdown gracefully shuts down all managed containers.948 // Shutdown gracefully shuts down all managed containers.
853 func (s *Service) Shutdown(ctx context.Context) error {949 func (s *Service) Shutdown(ctx context.Context) error {
854 ctx = zerowrap.CtxWithFields(ctx, map[string]any{950 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1966,6 +2062,10 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
1966 if timeout == 0 {2062 if timeout == 0 {
1967 timeout = 90 * time.Second2063 timeout = 90 * time.Second
1968 }2064 }
2065+ // Apply multiplier from context (used for auto-start retries).
2066+ if m := domain.HealthTimeoutMultiplier(ctx); m > 1.0 {
2067+ timeout = time.Duration(float64(timeout) * m)
2068+ }
1969 waitCtx, cancel := context.WithTimeout(ctx, timeout)2069 waitCtx, cancel := context.WithTimeout(ctx, timeout)
1970 defer cancel()2070 defer cancel()
1971 2071
@@ -1988,7 +2088,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
1988 select {2088 select {
1989 case <-time.After(time.Second):2089 case <-time.After(time.Second):
1990 case <-waitCtx.Done():2090 case <-waitCtx.Done():
1991- return fmt.Errorf("container healthcheck timeout after %s (last status: %s)", timeout, lastStatus)2091+ return &healthTimeoutError{lastStatus: lastStatus, timeout: timeout}
1992 }2092 }
1993 }2093 }
1994 }2094 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close