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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 81 | 81 | func WithInternalDeploy(ctx context.Context) context.Context { | |
| 82 | 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 | + | } | |
| @@ -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 @@ | |||
| 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 | + | 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 | 73 | func postgresVersionFromImage(image string) string { | |
| 48 | 74 | lastColon := strings.LastIndex(image, ":") | |
| 49 | 75 | if lastColon == -1 { | |
| @@ -1,49 +1,75 @@ | |||||
| 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 | + | 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) { | |||
| 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 | + | // 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 ( | |||
| 56 | 56 | 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 | 81 | // Service implements the ContainerService interface. | |
| 60 | 82 | type Service struct { | |
| 61 | 83 | runtime out.ContainerRuntime | |
| @@ -348,7 +370,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 348 | 370 | return nil, log.WrapErr(err, "failed to start container") | |
| 349 | 371 | } | |
| 350 | 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 | 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 | 841 | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | |
| 816 | 842 | ||
| 817 | - | var started, skipped, errors int | |
| 843 | + | var started, skipped, errCount int | |
| 844 | + | var retryable []domain.Route | |
| 845 | + | ||
| 818 | 846 | for _, route := range routes { | |
| 819 | - | // Check if container already exists and is running | |
| 820 | 847 | if _, exists := s.Get(ctx, route.Domain); exists { | |
| 821 | 848 | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | |
| 822 | 849 | skipped++ | |
| @@ -829,26 +856,95 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |||
| 829 | 856 | Msg("auto-starting container for route") | |
| 830 | 857 | ||
| 831 | 858 | 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 | + | } | |
| 834 | 866 | continue | |
| 835 | 867 | } | |
| 836 | 868 | ||
| 837 | 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 | 888 | log.Info(). | |
| 841 | 889 | Int("started", started). | |
| 842 | 890 | Int("skipped", skipped). | |
| 843 | - | Int("errors", errors). | |
| 891 | + | Int("errors", errCount). | |
| 844 | 892 | Msg("auto-start completed") | |
| 845 | 893 | ||
| 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) | |
| 848 | 896 | } | |
| 849 | 897 | 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 | 948 | // Shutdown gracefully shuts down all managed containers. | |
| 853 | 949 | func (s *Service) Shutdown(ctx context.Context) error { | |
| 854 | 950 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -1966,6 +2062,10 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 1966 | 2062 | if timeout == 0 { | |
| 1967 | 2063 | 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 | 2069 | waitCtx, cancel := context.WithTimeout(ctx, timeout) | |
| 1970 | 2070 | defer cancel() | |
| 1971 | 2071 | ||
| @@ -1988,7 +2088,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 1988 | 2088 | select { | |
| 1989 | 2089 | case <-time.After(time.Second): | |
| 1990 | 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 | } | |
| @@ -56,6 +56,28 @@ const ( | |||||
| 56 | internalPullMaxAttempts = 3 | 56 | 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.ContainerRuntime | 83 | 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 int | 843 | + | 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 | continue | 866 | 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 nil | 897 | 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.Second | 2063 | 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 | } | ||