feat: preview environment variable inheritance and CLI fixes
- Preview containers inherit env vars from base route at deploy time - Add env_copy config option (default true) gated by auto.preview.env_copy - Route.Env field allows pre-resolved env vars to skip EnvLoader - Delete now removes routes from config (prevents stale routes) - CLI: no more production route selector during preview create - CLI: bare 'gordon preview' shows help instead of defaulting to create - CLI: filter preview routes from push route resolution - DefaultPreviewSeparator constant replaces hardcoded strings - PreviewStatusTimeout typed constant - Documented env var inheritance with override and disable options
20 files · +767 -71
M docs/config/preview.md +37 -3
| @@ -13,6 +13,7 @@ ttl = "48h" | |||
| 13 | 13 | separator = "--" | |
| 14 | 14 | tag_patterns = ["preview-*", "pr-*"] | |
| 15 | 15 | data_copy = true | |
| 16 | + | env_copy = true | |
| 16 | 17 | ``` | |
| 17 | 18 | ||
| 18 | 19 | ## Options | |
| @@ -24,6 +25,7 @@ data_copy = true | |||
| 24 | 25 | | `separator` | string | `"--"` | String inserted between the base domain and the branch slug | | |
| 25 | 26 | | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation | | |
| 26 | 27 | | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation | | |
| 28 | + | | `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container | | |
| 27 | 29 | ||
| 28 | 30 | ### `ttl` Format | |
| 29 | 31 | ||
| @@ -179,8 +181,9 @@ When a push matches a `tag_patterns` entry: | |||
| 179 | 181 | ||
| 180 | 182 | 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image | |
| 181 | 183 | 2. If `data_copy = true`, volumes from the base route are cloned into the preview | |
| 182 | - | 3. The preview TTL timer starts | |
| 183 | - | 4. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | |
| 184 | + | 3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them) | |
| 185 | + | 4. The preview TTL timer starts | |
| 186 | + | 5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | |
| 184 | 187 | ||
| 185 | 188 | ### TTL and Automatic Teardown | |
| 186 | 189 | ||
| @@ -201,6 +204,35 @@ When `data_copy = true`, Gordon copies the named volumes attached to the base ro | |||
| 201 | 204 | - Cloned volumes are removed on preview teardown unless `volumes.preserve = true` | |
| 202 | 205 | - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data) | |
| 203 | 206 | ||
| 207 | + | ### Environment Variable Inheritance | |
| 208 | + | ||
| 209 | + | Preview containers receive environment variables from three sources, merged in this order (last wins): | |
| 210 | + | ||
| 211 | + | 1. **Dockerfile `ENV`** — baked into the image at build time | |
| 212 | + | 2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`) | |
| 213 | + | 3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy | |
| 214 | + | ||
| 215 | + | On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time. | |
| 216 | + | ||
| 217 | + | #### Overriding variables for a preview | |
| 218 | + | ||
| 219 | + | To change a variable for a specific preview, set it on the preview domain: | |
| 220 | + | ||
| 221 | + | ```bash | |
| 222 | + | gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com | |
| 223 | + | ``` | |
| 224 | + | ||
| 225 | + | This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones. | |
| 226 | + | ||
| 227 | + | #### Disabling env inheritance | |
| 228 | + | ||
| 229 | + | To start previews with a clean environment (Dockerfile `ENV` only): | |
| 230 | + | ||
| 231 | + | ```toml | |
| 232 | + | [auto.preview] | |
| 233 | + | env_copy = false | |
| 234 | + | ``` | |
| 235 | + | ||
| 204 | 236 | ## Example Config | |
| 205 | 237 | ||
| 206 | 238 | ```toml | |
| @@ -216,6 +248,7 @@ ttl = "48h" | |||
| 216 | 248 | separator = "--" | |
| 217 | 249 | tag_patterns = ["preview-*", "pr-*"] | |
| 218 | 250 | data_copy = true | |
| 251 | + | env_copy = true | |
| 219 | 252 | ||
| 220 | 253 | [routes] | |
| 221 | 254 | "app.example.com" = "myapp:latest" | |
| @@ -227,9 +260,10 @@ preserve = false | |||
| 227 | 260 | ``` | |
| 228 | 261 | ||
| 229 | 262 | With this config: | |
| 230 | - | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes | |
| 263 | + | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars | |
| 231 | 264 | - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com` | |
| 232 | 265 | - Both previews expire after 48 hours and volumes are removed on teardown | |
| 266 | + | - Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain | |
| 233 | 267 | ||
| 234 | 268 | ## Related | |
| 235 | 269 | ||
| @@ -13,6 +13,7 @@ ttl = "48h" | |||||
| 13 | separator = "--" | 13 | separator = "--" | ||
| 14 | tag_patterns = ["preview-*", "pr-*"] | 14 | tag_patterns = ["preview-*", "pr-*"] | ||
| 15 | data_copy = true | 15 | data_copy = true | ||
| 16 | + | env_copy = true | |||
| 16 | ``` | 17 | ``` | ||
| 17 | 18 | ||||
| 18 | ## Options | 19 | ## Options | ||
| @@ -24,6 +25,7 @@ data_copy = true | |||||
| 24 | | `separator` | string | `"--"` | String inserted between the base domain and the branch slug | | 25 | | `separator` | string | `"--"` | String inserted between the base domain and the branch slug | | ||
| 25 | | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation | | 26 | | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation | | ||
| 26 | | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation | | 27 | | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation | | ||
| 28 | + | | `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container | | |||
| 27 | 29 | ||||
| 28 | ### `ttl` Format | 30 | ### `ttl` Format | ||
| 29 | 31 | ||||
| @@ -179,8 +181,9 @@ When a push matches a `tag_patterns` entry: | |||||
| 179 | 181 | ||||
| 180 | 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image | 182 | 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image | ||
| 181 | 2. If `data_copy = true`, volumes from the base route are cloned into the preview | 183 | 2. If `data_copy = true`, volumes from the base route are cloned into the preview | ||
| 182 | - | 3. The preview TTL timer starts | 184 | + | 3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them) |
| 183 | - | 4. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | 185 | + | 4. The preview TTL timer starts |
| 186 | + | 5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | |||
| 184 | 187 | ||||
| 185 | ### TTL and Automatic Teardown | 188 | ### TTL and Automatic Teardown | ||
| 186 | 189 | ||||
| @@ -201,6 +204,35 @@ When `data_copy = true`, Gordon copies the named volumes attached to the base ro | |||||
| 201 | - Cloned volumes are removed on preview teardown unless `volumes.preserve = true` | 204 | - Cloned volumes are removed on preview teardown unless `volumes.preserve = true` | ||
| 202 | - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data) | 205 | - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data) | ||
| 203 | 206 | ||||
| 207 | + | ### Environment Variable Inheritance | |||
| 208 | + | ||||
| 209 | + | Preview containers receive environment variables from three sources, merged in this order (last wins): | |||
| 210 | + | ||||
| 211 | + | 1. **Dockerfile `ENV`** — baked into the image at build time | |||
| 212 | + | 2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`) | |||
| 213 | + | 3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy | |||
| 214 | + | ||||
| 215 | + | On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time. | |||
| 216 | + | ||||
| 217 | + | #### Overriding variables for a preview | |||
| 218 | + | ||||
| 219 | + | To change a variable for a specific preview, set it on the preview domain: | |||
| 220 | + | ||||
| 221 | + | ```bash | |||
| 222 | + | gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com | |||
| 223 | + | ``` | |||
| 224 | + | ||||
| 225 | + | This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones. | |||
| 226 | + | ||||
| 227 | + | #### Disabling env inheritance | |||
| 228 | + | ||||
| 229 | + | To start previews with a clean environment (Dockerfile `ENV` only): | |||
| 230 | + | ||||
| 231 | + | ```toml | |||
| 232 | + | [auto.preview] | |||
| 233 | + | env_copy = false | |||
| 234 | + | ``` | |||
| 235 | + | ||||
| 204 | ## Example Config | 236 | ## Example Config | ||
| 205 | 237 | ||||
| 206 | ```toml | 238 | ```toml | ||
| @@ -216,6 +248,7 @@ ttl = "48h" | |||||
| 216 | separator = "--" | 248 | separator = "--" | ||
| 217 | tag_patterns = ["preview-*", "pr-*"] | 249 | tag_patterns = ["preview-*", "pr-*"] | ||
| 218 | data_copy = true | 250 | data_copy = true | ||
| 251 | + | env_copy = true | |||
| 219 | 252 | ||||
| 220 | [routes] | 253 | [routes] | ||
| 221 | "app.example.com" = "myapp:latest" | 254 | "app.example.com" = "myapp:latest" | ||
| @@ -227,9 +260,10 @@ preserve = false | |||||
| 227 | ``` | 260 | ``` | ||
| 228 | 261 | ||||
| 229 | With this config: | 262 | With this config: | ||
| 230 | - | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes | 263 | + | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars |
| 231 | - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com` | 264 | - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com` | ||
| 232 | - Both previews expire after 48 hours and volumes are removed on teardown | 265 | - Both previews expire after 48 hours and volumes are removed on teardown | ||
| 266 | + | - Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain | |||
| 233 | 267 | ||||
| 234 | ## Related | 268 | ## Related | ||
| 235 | 269 | ||||
M internal/adapters/in/cli/preview.go +170 -17
| @@ -10,17 +10,20 @@ import ( | |||
| 10 | 10 | "text/tabwriter" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | + | tea "github.com/charmbracelet/bubbletea" | |
| 13 | 14 | "github.com/spf13/cobra" | |
| 14 | 15 | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 15 | 18 | "github.com/bnema/gordon/internal/domain" | |
| 16 | 19 | "github.com/bnema/gordon/internal/usecase/auto/preview" | |
| 17 | 20 | ) | |
| 18 | 21 | ||
| 19 | 22 | func newPreviewCmd() *cobra.Command { | |
| 20 | 23 | cmd := &cobra.Command{ | |
| 21 | - | Use: "preview [name]", | |
| 24 | + | Use: "preview", | |
| 22 | 25 | Short: "Create or manage preview environments", | |
| 23 | - | Long: "Build, push, and deploy an ephemeral preview environment. Defaults to current git branch name.", | |
| 26 | + | Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).", | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | createCmd := newPreviewCreateCmd() | |
| @@ -32,11 +35,6 @@ func newPreviewCmd() *cobra.Command { | |||
| 32 | 35 | newPreviewExtendCmd(), | |
| 33 | 36 | ) | |
| 34 | 37 | ||
| 35 | - | // Default action (no subcommand) = create | |
| 36 | - | cmd.RunE = createCmd.RunE | |
| 37 | - | cmd.Args = cobra.MaximumNArgs(1) | |
| 38 | - | cmd.Flags().AddFlagSet(createCmd.Flags()) | |
| 39 | - | ||
| 40 | 38 | return cmd | |
| 41 | 39 | } | |
| 42 | 40 | ||
| @@ -84,7 +82,7 @@ func newPreviewCreateCmd() *cobra.Command { | |||
| 84 | 82 | return err | |
| 85 | 83 | } | |
| 86 | 84 | ||
| 87 | - | return printPreviewCreateSummary(out, name, ttl, noData) | |
| 85 | + | return waitForPreview(ctx, out, name, ttl, noData) | |
| 88 | 86 | }, | |
| 89 | 87 | } | |
| 90 | 88 | ||
| @@ -234,9 +232,11 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, | |||
| 234 | 232 | return "", err | |
| 235 | 233 | } | |
| 236 | 234 | ||
| 237 | - | registry, _, _, err := resolveRoute(ctx, cp, "", "", dockerfile) | |
| 235 | + | // Resolve registry from existing routes without showing the interactive | |
| 236 | + | // selector — preview only needs the registry URL, not a target domain. | |
| 237 | + | registry, err := resolveRegistryForImage(ctx, cp, imageName) | |
| 238 | 238 | if err != nil { | |
| 239 | - | return "", fmt.Errorf("failed to resolve registry: %w", err) | |
| 239 | + | return "", err | |
| 240 | 240 | } | |
| 241 | 241 | ||
| 242 | 242 | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | |
| @@ -246,6 +246,27 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, | |||
| 246 | 246 | return previewRef, nil | |
| 247 | 247 | } | |
| 248 | 248 | ||
| 249 | + | // resolveRegistryForImage finds the registry URL from any existing non-preview | |
| 250 | + | // route for the given image, without prompting. | |
| 251 | + | func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) { | |
| 252 | + | routes, err := cp.FindRoutesByImage(ctx, imageName) | |
| 253 | + | if err != nil { | |
| 254 | + | return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | for _, r := range routes { | |
| 258 | + | if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) { | |
| 259 | + | continue | |
| 260 | + | } | |
| 261 | + | reg, _, _ := parseImageRef(r.Image) | |
| 262 | + | if reg != "" { | |
| 263 | + | return reg, nil | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | return "", fmt.Errorf("no route configured for image %q", imageName) | |
| 268 | + | } | |
| 269 | + | ||
| 249 | 270 | func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error { | |
| 250 | 271 | imageOps, err := newImageOpsFn() | |
| 251 | 272 | if err != nil { | |
| @@ -275,13 +296,7 @@ func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, preview | |||
| 275 | 296 | return nil | |
| 276 | 297 | } | |
| 277 | 298 | ||
| 278 | - | func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) error { | |
| 279 | - | if err := cliWriteLine(out, cliRenderSuccess("Push complete")); err != nil { | |
| 280 | - | return err | |
| 281 | - | } | |
| 282 | - | if err := cliWritef(out, "Preview %s will be created by the server's autopreview handler.\n", name); err != nil { | |
| 283 | - | return err | |
| 284 | - | } | |
| 299 | + | func printPreviewFlags(out io.Writer, ttl string, noData bool) error { | |
| 285 | 300 | if ttl != "" { | |
| 286 | 301 | if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil { | |
| 287 | 302 | return err | |
| @@ -295,6 +310,144 @@ func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) err | |||
| 295 | 310 | return nil | |
| 296 | 311 | } | |
| 297 | 312 | ||
| 313 | + | func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error { | |
| 314 | + | if err := printPreviewFlags(out, ttl, noData); err != nil { | |
| 315 | + | return err | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | client, isRemote := GetRemoteClient() | |
| 319 | + | if !isRemote { | |
| 320 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name))) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | result, err := pollPreviewWithSpinner(ctx, out, client, name) | |
| 324 | + | if err != nil { | |
| 325 | + | return err | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | switch result.Status { | |
| 329 | + | case domain.PreviewStatusRunning: | |
| 330 | + | scheme := "http" | |
| 331 | + | if result.HTTPS { | |
| 332 | + | scheme = "https" | |
| 333 | + | } | |
| 334 | + | previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain) | |
| 335 | + | if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil { | |
| 336 | + | return err | |
| 337 | + | } | |
| 338 | + | return cliWriteLine(out, cliRenderMeta("URL:", previewURL)) | |
| 339 | + | case domain.PreviewStatusFailed: | |
| 340 | + | return fmt.Errorf("preview deployment failed") | |
| 341 | + | default: | |
| 342 | + | return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote")) | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | type previewPollResult struct { | |
| 347 | + | preview *domain.PreviewRoute | |
| 348 | + | err error | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | type previewPollDoneMsg previewPollResult | |
| 352 | + | ||
| 353 | + | type previewSpinnerModel struct { | |
| 354 | + | spinner components.SpinnerModel | |
| 355 | + | done <-chan previewPollResult | |
| 356 | + | outcome previewPollResult | |
| 357 | + | finished bool | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel { | |
| 361 | + | return previewSpinnerModel{ | |
| 362 | + | spinner: components.NewSpinner( | |
| 363 | + | components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)), | |
| 364 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |
| 365 | + | ), | |
| 366 | + | done: done, | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func (m previewSpinnerModel) Init() tea.Cmd { | |
| 371 | + | return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done)) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 375 | + | switch msg := msg.(type) { | |
| 376 | + | case previewPollDoneMsg: | |
| 377 | + | m.outcome = previewPollResult(msg) | |
| 378 | + | m.finished = true | |
| 379 | + | return m, tea.Quit | |
| 380 | + | default: | |
| 381 | + | updated, cmd := m.spinner.Update(msg) | |
| 382 | + | if sm, ok := updated.(components.SpinnerModel); ok { | |
| 383 | + | m.spinner = sm | |
| 384 | + | } | |
| 385 | + | return m, cmd | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func (m previewSpinnerModel) View() string { | |
| 390 | + | return m.spinner.View() | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd { | |
| 394 | + | return func() tea.Msg { | |
| 395 | + | return previewPollDoneMsg(<-done) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |
| 400 | + | done := make(chan previewPollResult, 1) | |
| 401 | + | go func() { | |
| 402 | + | result, err := pollPreviewStatus(ctx, client, name) | |
| 403 | + | done <- previewPollResult{preview: result, err: err} | |
| 404 | + | }() | |
| 405 | + | ||
| 406 | + | if !isInteractiveTerminal() { | |
| 407 | + | if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil { | |
| 408 | + | return nil, err | |
| 409 | + | } | |
| 410 | + | r := <-done | |
| 411 | + | return r.preview, r.err | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | model := newPreviewSpinnerModel(name, done) | |
| 415 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |
| 416 | + | fmt.Print("\r\033[K") | |
| 417 | + | if err != nil { | |
| 418 | + | return nil, err | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | m, ok := final.(previewSpinnerModel) | |
| 422 | + | if !ok || !m.finished { | |
| 423 | + | return nil, fmt.Errorf("preview spinner exited unexpectedly") | |
| 424 | + | } | |
| 425 | + | return m.outcome.preview, m.outcome.err | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |
| 429 | + | ticker := time.NewTicker(2 * time.Second) | |
| 430 | + | defer ticker.Stop() | |
| 431 | + | timeout := time.After(3 * time.Minute) | |
| 432 | + | ||
| 433 | + | for { | |
| 434 | + | select { | |
| 435 | + | case <-ctx.Done(): | |
| 436 | + | return nil, ctx.Err() | |
| 437 | + | case <-timeout: | |
| 438 | + | return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil | |
| 439 | + | case <-ticker.C: | |
| 440 | + | p, err := client.GetPreview(ctx, name) | |
| 441 | + | if err != nil { | |
| 442 | + | continue | |
| 443 | + | } | |
| 444 | + | if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed { | |
| 445 | + | return p, nil | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | ||
| 298 | 451 | func detectGitBranch(ctx context.Context) (string, error) { | |
| 299 | 452 | out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204 | |
| 300 | 453 | if err != nil { | |
| @@ -10,17 +10,20 @@ import ( | |||||
| 10 | "text/tabwriter" | 10 | "text/tabwriter" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 13 | "github.com/spf13/cobra" | 14 | "github.com/spf13/cobra" | ||
| 14 | 15 | ||||
| 16 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |||
| 15 | "github.com/bnema/gordon/internal/domain" | 18 | "github.com/bnema/gordon/internal/domain" | ||
| 16 | "github.com/bnema/gordon/internal/usecase/auto/preview" | 19 | "github.com/bnema/gordon/internal/usecase/auto/preview" | ||
| 17 | ) | 20 | ) | ||
| 18 | 21 | ||||
| 19 | func newPreviewCmd() *cobra.Command { | 22 | func newPreviewCmd() *cobra.Command { | ||
| 20 | cmd := &cobra.Command{ | 23 | cmd := &cobra.Command{ | ||
| 21 | - | Use: "preview [name]", | 24 | + | Use: "preview", |
| 22 | Short: "Create or manage preview environments", | 25 | Short: "Create or manage preview environments", | ||
| 23 | - | Long: "Build, push, and deploy an ephemeral preview environment. Defaults to current git branch name.", | 26 | + | Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).", |
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 26 | createCmd := newPreviewCreateCmd() | 29 | createCmd := newPreviewCreateCmd() | ||
| @@ -32,11 +35,6 @@ func newPreviewCmd() *cobra.Command { | |||||
| 32 | newPreviewExtendCmd(), | 35 | newPreviewExtendCmd(), | ||
| 33 | ) | 36 | ) | ||
| 34 | 37 | ||||
| 35 | - | // Default action (no subcommand) = create | |||
| 36 | - | cmd.RunE = createCmd.RunE | |||
| 37 | - | cmd.Args = cobra.MaximumNArgs(1) | |||
| 38 | - | cmd.Flags().AddFlagSet(createCmd.Flags()) | |||
| 39 | - | ||||
| 40 | return cmd | 38 | return cmd | ||
| 41 | } | 39 | } | ||
| 42 | 40 | ||||
| @@ -84,7 +82,7 @@ func newPreviewCreateCmd() *cobra.Command { | |||||
| 84 | return err | 82 | return err | ||
| 85 | } | 83 | } | ||
| 86 | 84 | ||||
| 87 | - | return printPreviewCreateSummary(out, name, ttl, noData) | 85 | + | return waitForPreview(ctx, out, name, ttl, noData) |
| 88 | }, | 86 | }, | ||
| 89 | } | 87 | } | ||
| 90 | 88 | ||||
| @@ -234,9 +232,11 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, | |||||
| 234 | return "", err | 232 | return "", err | ||
| 235 | } | 233 | } | ||
| 236 | 234 | ||||
| 237 | - | registry, _, _, err := resolveRoute(ctx, cp, "", "", dockerfile) | 235 | + | // Resolve registry from existing routes without showing the interactive |
| 236 | + | // selector — preview only needs the registry URL, not a target domain. | |||
| 237 | + | registry, err := resolveRegistryForImage(ctx, cp, imageName) | |||
| 238 | if err != nil { | 238 | if err != nil { | ||
| 239 | - | return "", fmt.Errorf("failed to resolve registry: %w", err) | 239 | + | return "", err |
| 240 | } | 240 | } | ||
| 241 | 241 | ||||
| 242 | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | 242 | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | ||
| @@ -246,6 +246,27 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, | |||||
| 246 | return previewRef, nil | 246 | return previewRef, nil | ||
| 247 | } | 247 | } | ||
| 248 | 248 | ||||
| 249 | + | // resolveRegistryForImage finds the registry URL from any existing non-preview | |||
| 250 | + | // route for the given image, without prompting. | |||
| 251 | + | func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) { | |||
| 252 | + | routes, err := cp.FindRoutesByImage(ctx, imageName) | |||
| 253 | + | if err != nil { | |||
| 254 | + | return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | for _, r := range routes { | |||
| 258 | + | if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) { | |||
| 259 | + | continue | |||
| 260 | + | } | |||
| 261 | + | reg, _, _ := parseImageRef(r.Image) | |||
| 262 | + | if reg != "" { | |||
| 263 | + | return reg, nil | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | return "", fmt.Errorf("no route configured for image %q", imageName) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 249 | func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error { | 270 | func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error { | ||
| 250 | imageOps, err := newImageOpsFn() | 271 | imageOps, err := newImageOpsFn() | ||
| 251 | if err != nil { | 272 | if err != nil { | ||
| @@ -275,13 +296,7 @@ func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, preview | |||||
| 275 | return nil | 296 | return nil | ||
| 276 | } | 297 | } | ||
| 277 | 298 | ||||
| 278 | - | func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) error { | 299 | + | func printPreviewFlags(out io.Writer, ttl string, noData bool) error { |
| 279 | - | if err := cliWriteLine(out, cliRenderSuccess("Push complete")); err != nil { | |||
| 280 | - | return err | |||
| 281 | - | } | |||
| 282 | - | if err := cliWritef(out, "Preview %s will be created by the server's autopreview handler.\n", name); err != nil { | |||
| 283 | - | return err | |||
| 284 | - | } | |||
| 285 | if ttl != "" { | 300 | if ttl != "" { | ||
| 286 | if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil { | 301 | if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil { | ||
| 287 | return err | 302 | return err | ||
| @@ -295,6 +310,144 @@ func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) err | |||||
| 295 | return nil | 310 | return nil | ||
| 296 | } | 311 | } | ||
| 297 | 312 | ||||
| 313 | + | func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error { | |||
| 314 | + | if err := printPreviewFlags(out, ttl, noData); err != nil { | |||
| 315 | + | return err | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | client, isRemote := GetRemoteClient() | |||
| 319 | + | if !isRemote { | |||
| 320 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name))) | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | result, err := pollPreviewWithSpinner(ctx, out, client, name) | |||
| 324 | + | if err != nil { | |||
| 325 | + | return err | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | switch result.Status { | |||
| 329 | + | case domain.PreviewStatusRunning: | |||
| 330 | + | scheme := "http" | |||
| 331 | + | if result.HTTPS { | |||
| 332 | + | scheme = "https" | |||
| 333 | + | } | |||
| 334 | + | previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain) | |||
| 335 | + | if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil { | |||
| 336 | + | return err | |||
| 337 | + | } | |||
| 338 | + | return cliWriteLine(out, cliRenderMeta("URL:", previewURL)) | |||
| 339 | + | case domain.PreviewStatusFailed: | |||
| 340 | + | return fmt.Errorf("preview deployment failed") | |||
| 341 | + | default: | |||
| 342 | + | return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote")) | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | type previewPollResult struct { | |||
| 347 | + | preview *domain.PreviewRoute | |||
| 348 | + | err error | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | type previewPollDoneMsg previewPollResult | |||
| 352 | + | ||||
| 353 | + | type previewSpinnerModel struct { | |||
| 354 | + | spinner components.SpinnerModel | |||
| 355 | + | done <-chan previewPollResult | |||
| 356 | + | outcome previewPollResult | |||
| 357 | + | finished bool | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel { | |||
| 361 | + | return previewSpinnerModel{ | |||
| 362 | + | spinner: components.NewSpinner( | |||
| 363 | + | components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)), | |||
| 364 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |||
| 365 | + | ), | |||
| 366 | + | done: done, | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func (m previewSpinnerModel) Init() tea.Cmd { | |||
| 371 | + | return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done)) | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 375 | + | switch msg := msg.(type) { | |||
| 376 | + | case previewPollDoneMsg: | |||
| 377 | + | m.outcome = previewPollResult(msg) | |||
| 378 | + | m.finished = true | |||
| 379 | + | return m, tea.Quit | |||
| 380 | + | default: | |||
| 381 | + | updated, cmd := m.spinner.Update(msg) | |||
| 382 | + | if sm, ok := updated.(components.SpinnerModel); ok { | |||
| 383 | + | m.spinner = sm | |||
| 384 | + | } | |||
| 385 | + | return m, cmd | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func (m previewSpinnerModel) View() string { | |||
| 390 | + | return m.spinner.View() | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd { | |||
| 394 | + | return func() tea.Msg { | |||
| 395 | + | return previewPollDoneMsg(<-done) | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |||
| 400 | + | done := make(chan previewPollResult, 1) | |||
| 401 | + | go func() { | |||
| 402 | + | result, err := pollPreviewStatus(ctx, client, name) | |||
| 403 | + | done <- previewPollResult{preview: result, err: err} | |||
| 404 | + | }() | |||
| 405 | + | ||||
| 406 | + | if !isInteractiveTerminal() { | |||
| 407 | + | if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil { | |||
| 408 | + | return nil, err | |||
| 409 | + | } | |||
| 410 | + | r := <-done | |||
| 411 | + | return r.preview, r.err | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | model := newPreviewSpinnerModel(name, done) | |||
| 415 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |||
| 416 | + | fmt.Print("\r\033[K") | |||
| 417 | + | if err != nil { | |||
| 418 | + | return nil, err | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | m, ok := final.(previewSpinnerModel) | |||
| 422 | + | if !ok || !m.finished { | |||
| 423 | + | return nil, fmt.Errorf("preview spinner exited unexpectedly") | |||
| 424 | + | } | |||
| 425 | + | return m.outcome.preview, m.outcome.err | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |||
| 429 | + | ticker := time.NewTicker(2 * time.Second) | |||
| 430 | + | defer ticker.Stop() | |||
| 431 | + | timeout := time.After(3 * time.Minute) | |||
| 432 | + | ||||
| 433 | + | for { | |||
| 434 | + | select { | |||
| 435 | + | case <-ctx.Done(): | |||
| 436 | + | return nil, ctx.Err() | |||
| 437 | + | case <-timeout: | |||
| 438 | + | return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil | |||
| 439 | + | case <-ticker.C: | |||
| 440 | + | p, err := client.GetPreview(ctx, name) | |||
| 441 | + | if err != nil { | |||
| 442 | + | continue | |||
| 443 | + | } | |||
| 444 | + | if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed { | |||
| 445 | + | return p, nil | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | } | |||
| 449 | + | } | |||
| 450 | + | ||||
| 298 | func detectGitBranch(ctx context.Context) (string, error) { | 451 | func detectGitBranch(ctx context.Context) (string, error) { | ||
| 299 | out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204 | 452 | out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204 | ||
| 300 | if err != nil { | 453 | if err != nil { | ||
M internal/adapters/in/cli/push.go +10 -0
| @@ -276,6 +276,16 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||
| 276 | 276 | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | |
| 277 | 277 | } | |
| 278 | 278 | ||
| 279 | + | // Filter out preview routes (domains containing "--") so they don't | |
| 280 | + | // pollute the selection when pushing the base image. | |
| 281 | + | filtered := routes[:0] | |
| 282 | + | for _, r := range routes { | |
| 283 | + | if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) { | |
| 284 | + | filtered = append(filtered, r) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | routes = filtered | |
| 288 | + | ||
| 279 | 289 | if len(routes) == 0 { | |
| 280 | 290 | // bootstrap command is registered in bootstrap.go (see issue #98) | |
| 281 | 291 | return "", "", "", fmt.Errorf( | |
| @@ -276,6 +276,16 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||||
| 276 | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | 276 | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | ||
| 277 | } | 277 | } | ||
| 278 | 278 | ||||
| 279 | + | // Filter out preview routes (domains containing "--") so they don't | |||
| 280 | + | // pollute the selection when pushing the base image. | |||
| 281 | + | filtered := routes[:0] | |||
| 282 | + | for _, r := range routes { | |||
| 283 | + | if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) { | |||
| 284 | + | filtered = append(filtered, r) | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | routes = filtered | |||
| 288 | + | ||||
| 279 | if len(routes) == 0 { | 289 | if len(routes) == 0 { | ||
| 280 | // bootstrap command is registered in bootstrap.go (see issue #98) | 290 | // bootstrap command is registered in bootstrap.go (see issue #98) | ||
| 281 | return "", "", "", fmt.Errorf( | 291 | return "", "", "", fmt.Errorf( | ||
M internal/adapters/in/cli/remote/preview.go +14 -0
| @@ -54,3 +54,17 @@ func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) err | |||
| 54 | 54 | } | |
| 55 | 55 | return parseResponse(resp, nil) | |
| 56 | 56 | } | |
| 57 | + | ||
| 58 | + | // GetPreview returns a single preview by name. | |
| 59 | + | func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) { | |
| 60 | + | previews, err := c.ListPreviews(ctx) | |
| 61 | + | if err != nil { | |
| 62 | + | return nil, fmt.Errorf("list previews: %w", err) | |
| 63 | + | } | |
| 64 | + | for _, p := range previews { | |
| 65 | + | if p.Name == name { | |
| 66 | + | return &p, nil | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound) | |
| 70 | + | } | |
| @@ -54,3 +54,17 @@ func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) err | |||||
| 54 | } | 54 | } | ||
| 55 | return parseResponse(resp, nil) | 55 | return parseResponse(resp, nil) | ||
| 56 | } | 56 | } | ||
| 57 | + | ||||
| 58 | + | // GetPreview returns a single preview by name. | |||
| 59 | + | func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) { | |||
| 60 | + | previews, err := c.ListPreviews(ctx) | |||
| 61 | + | if err != nil { | |||
| 62 | + | return nil, fmt.Errorf("list previews: %w", err) | |||
| 63 | + | } | |||
| 64 | + | for _, p := range previews { | |||
| 65 | + | if p.Name == name { | |||
| 66 | + | return &p, nil | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound) | |||
| 70 | + | } | |||
M internal/adapters/out/envloader/pass_loader.go +5 -2
| @@ -86,8 +86,11 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||
| 86 | 86 | // This is an acceptable trade-off for simplicity, but if such domains become common, | |
| 87 | 87 | // this should be replaced with a more robust detection mechanism (e.g., checking both | |
| 88 | 88 | // GetAll() and GetAllAttachment() and seeing which returns results). | |
| 89 | - | func isAttachmentContainer(domain string) bool { | |
| 90 | - | return strings.HasPrefix(domain, "gordon-") | |
| 89 | + | func isAttachmentContainer(name string) bool { | |
| 90 | + | // Attachment container names start with "gordon-" and never contain dots. | |
| 91 | + | // Domain names (including preview domains like "gordon--test.bnema.dev") | |
| 92 | + | // always contain dots, so checking for dots distinguishes them. | |
| 93 | + | return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".") | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 93 | 96 | // CreateEnvFile is a no-op for pass-backed loader. | |
| @@ -86,8 +86,11 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||||
| 86 | // This is an acceptable trade-off for simplicity, but if such domains become common, | 86 | // This is an acceptable trade-off for simplicity, but if such domains become common, | ||
| 87 | // this should be replaced with a more robust detection mechanism (e.g., checking both | 87 | // this should be replaced with a more robust detection mechanism (e.g., checking both | ||
| 88 | // GetAll() and GetAllAttachment() and seeing which returns results). | 88 | // GetAll() and GetAllAttachment() and seeing which returns results). | ||
| 89 | - | func isAttachmentContainer(domain string) bool { | 89 | + | func isAttachmentContainer(name string) bool { |
| 90 | - | return strings.HasPrefix(domain, "gordon-") | 90 | + | // Attachment container names start with "gordon-" and never contain dots. |
| 91 | + | // Domain names (including preview domains like "gordon--test.bnema.dev") | |||
| 92 | + | // always contain dots, so checking for dots distinguishes them. | |||
| 93 | + | return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".") | |||
| 91 | } | 94 | } | ||
| 92 | 95 | ||||
| 93 | // CreateEnvFile is a no-op for pass-backed loader. | 96 | // CreateEnvFile is a no-op for pass-backed loader. | ||
M internal/app/run.go +14 -1
| @@ -572,7 +572,12 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero | |||
| 572 | 572 | previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json") | |
| 573 | 573 | svc.previewStore = filesystem.NewPreviewStore(previewStorePath) | |
| 574 | 574 | previewConfig := svc.configSvc.GetPreviewConfig() | |
| 575 | - | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL) | |
| 575 | + | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL). | |
| 576 | + | WithDeployer(svc.containerSvc). | |
| 577 | + | WithRouteManager(svc.configSvc). | |
| 578 | + | WithVolumeCloner(svc.runtime). | |
| 579 | + | WithRegistryDomain(svc.configSvc.GetRegistryDomain()). | |
| 580 | + | WithEnvLoader(svc.envLoader) | |
| 576 | 581 | if err := svc.previewService.Load(ctx); err != nil { | |
| 577 | 582 | log.Warn().Err(err).Msg("failed to load previews") | |
| 578 | 583 | } | |
| @@ -592,6 +597,14 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero | |||
| 592 | 597 | log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume") | |
| 593 | 598 | } | |
| 594 | 599 | } | |
| 600 | + | // Remove route from config so proxy stops routing to this domain | |
| 601 | + | if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil { | |
| 602 | + | if errors.Is(err, domain.ErrRouteNotFound) { | |
| 603 | + | log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config") | |
| 604 | + | } else { | |
| 605 | + | log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config") | |
| 606 | + | } | |
| 607 | + | } | |
| 595 | 608 | svc.proxySvc.InvalidateTarget(ctx, p.Domain) | |
| 596 | 609 | }) | |
| 597 | 610 | } | |
| @@ -572,7 +572,12 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero | |||||
| 572 | previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json") | 572 | previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json") | ||
| 573 | svc.previewStore = filesystem.NewPreviewStore(previewStorePath) | 573 | svc.previewStore = filesystem.NewPreviewStore(previewStorePath) | ||
| 574 | previewConfig := svc.configSvc.GetPreviewConfig() | 574 | previewConfig := svc.configSvc.GetPreviewConfig() | ||
| 575 | - | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL) | 575 | + | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL). |
| 576 | + | WithDeployer(svc.containerSvc). | |||
| 577 | + | WithRouteManager(svc.configSvc). | |||
| 578 | + | WithVolumeCloner(svc.runtime). | |||
| 579 | + | WithRegistryDomain(svc.configSvc.GetRegistryDomain()). | |||
| 580 | + | WithEnvLoader(svc.envLoader) | |||
| 576 | if err := svc.previewService.Load(ctx); err != nil { | 581 | if err := svc.previewService.Load(ctx); err != nil { | ||
| 577 | log.Warn().Err(err).Msg("failed to load previews") | 582 | log.Warn().Err(err).Msg("failed to load previews") | ||
| 578 | } | 583 | } | ||
| @@ -592,6 +597,14 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero | |||||
| 592 | log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume") | 597 | log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume") | ||
| 593 | } | 598 | } | ||
| 594 | } | 599 | } | ||
| 600 | + | // Remove route from config so proxy stops routing to this domain | |||
| 601 | + | if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil { | |||
| 602 | + | if errors.Is(err, domain.ErrRouteNotFound) { | |||
| 603 | + | log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config") | |||
| 604 | + | } else { | |||
| 605 | + | log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config") | |||
| 606 | + | } | |||
| 607 | + | } | |||
| 595 | svc.proxySvc.InvalidateTarget(ctx, p.Domain) | 608 | svc.proxySvc.InvalidateTarget(ctx, p.Domain) | ||
| 596 | }) | 609 | }) | ||
| 597 | } | 610 | } | ||
M internal/domain/auto.go +1 -0
| @@ -20,4 +20,5 @@ type PreviewConfig struct { | |||
| 20 | 20 | Separator string | |
| 21 | 21 | TagPatterns []string | |
| 22 | 22 | DataCopy bool | |
| 23 | + | EnvCopy bool | |
| 23 | 24 | } | |
| @@ -20,4 +20,5 @@ type PreviewConfig struct { | |||||
| 20 | Separator string | 20 | Separator string | ||
| 21 | TagPatterns []string | 21 | TagPatterns []string | ||
| 22 | DataCopy bool | 22 | DataCopy bool | ||
| 23 | + | EnvCopy bool | |||
| 23 | } | 24 | } | ||
M internal/domain/preview.go +24 -9
| @@ -2,17 +2,32 @@ package domain | |||
| 2 | 2 | ||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | + | // PreviewStatus represents the lifecycle state of a preview environment. | |
| 6 | + | type PreviewStatus string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | PreviewStatusDeploying PreviewStatus = "deploying" | |
| 10 | + | PreviewStatusRunning PreviewStatus = "running" | |
| 11 | + | PreviewStatusFailed PreviewStatus = "failed" | |
| 12 | + | PreviewStatusTimeout PreviewStatus = "timeout" | |
| 13 | + | ||
| 14 | + | // DefaultPreviewSeparator is the subdomain separator used to construct | |
| 15 | + | // preview domain names (e.g. "myapp--feat.example.com"). | |
| 16 | + | DefaultPreviewSeparator = "--" | |
| 17 | + | ) | |
| 18 | + | ||
| 5 | 19 | // PreviewRoute represents an ephemeral preview environment. | |
| 6 | 20 | type PreviewRoute struct { | |
| 7 | - | Domain string `json:"domain"` | |
| 8 | - | Image string `json:"image"` | |
| 9 | - | BaseRoute string `json:"base_route"` | |
| 10 | - | Name string `json:"name"` | |
| 11 | - | CreatedAt time.Time `json:"created_at"` | |
| 12 | - | ExpiresAt time.Time `json:"expires_at"` | |
| 13 | - | HTTPS bool `json:"https"` | |
| 14 | - | Volumes []string `json:"volumes"` | |
| 15 | - | Containers []string `json:"containers"` | |
| 21 | + | Domain string `json:"domain"` | |
| 22 | + | Image string `json:"image"` | |
| 23 | + | BaseRoute string `json:"base_route"` | |
| 24 | + | Name string `json:"name"` | |
| 25 | + | CreatedAt time.Time `json:"created_at"` | |
| 26 | + | ExpiresAt time.Time `json:"expires_at"` | |
| 27 | + | HTTPS bool `json:"https"` | |
| 28 | + | Status PreviewStatus `json:"status"` | |
| 29 | + | Volumes []string `json:"volumes"` | |
| 30 | + | Containers []string `json:"containers"` | |
| 16 | 31 | } | |
| 17 | 32 | ||
| 18 | 33 | // IsExpired returns true if the preview has exceeded its TTL. | |
| @@ -2,17 +2,32 @@ package domain | |||||
| 2 | 2 | ||||
| 3 | import "time" | 3 | import "time" | ||
| 4 | 4 | ||||
| 5 | + | // PreviewStatus represents the lifecycle state of a preview environment. | |||
| 6 | + | type PreviewStatus string | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | PreviewStatusDeploying PreviewStatus = "deploying" | |||
| 10 | + | PreviewStatusRunning PreviewStatus = "running" | |||
| 11 | + | PreviewStatusFailed PreviewStatus = "failed" | |||
| 12 | + | PreviewStatusTimeout PreviewStatus = "timeout" | |||
| 13 | + | ||||
| 14 | + | // DefaultPreviewSeparator is the subdomain separator used to construct | |||
| 15 | + | // preview domain names (e.g. "myapp--feat.example.com"). | |||
| 16 | + | DefaultPreviewSeparator = "--" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 5 | // PreviewRoute represents an ephemeral preview environment. | 19 | // PreviewRoute represents an ephemeral preview environment. | ||
| 6 | type PreviewRoute struct { | 20 | type PreviewRoute struct { | ||
| 7 | - | Domain string `json:"domain"` | 21 | + | Domain string `json:"domain"` |
| 8 | - | Image string `json:"image"` | 22 | + | Image string `json:"image"` |
| 9 | - | BaseRoute string `json:"base_route"` | 23 | + | BaseRoute string `json:"base_route"` |
| 10 | - | Name string `json:"name"` | 24 | + | Name string `json:"name"` |
| 11 | - | CreatedAt time.Time `json:"created_at"` | 25 | + | CreatedAt time.Time `json:"created_at"` |
| 12 | - | ExpiresAt time.Time `json:"expires_at"` | 26 | + | ExpiresAt time.Time `json:"expires_at"` |
| 13 | - | HTTPS bool `json:"https"` | 27 | + | HTTPS bool `json:"https"` |
| 14 | - | Volumes []string `json:"volumes"` | 28 | + | Status PreviewStatus `json:"status"` |
| 15 | - | Containers []string `json:"containers"` | 29 | + | Volumes []string `json:"volumes"` |
| 30 | + | Containers []string `json:"containers"` | |||
| 16 | } | 31 | } | ||
| 17 | 32 | ||||
| 18 | // IsExpired returns true if the preview has exceeded its TTL. | 33 | // IsExpired returns true if the preview has exceeded its TTL. | ||
M internal/domain/route.go +1 -0
| @@ -5,6 +5,7 @@ type Route struct { | |||
| 5 | 5 | Domain string | |
| 6 | 6 | Image string | |
| 7 | 7 | HTTPS bool | |
| 8 | + | Env []string // Pre-resolved env vars ("KEY=VALUE"); when set, Deploy skips EnvLoader lookup. | |
| 8 | 9 | } | |
| 9 | 10 | ||
| 10 | 11 | // ProxyTarget represents the destination for proxying requests. | |
| @@ -5,6 +5,7 @@ type Route struct { | |||||
| 5 | Domain string | 5 | Domain string | ||
| 6 | Image string | 6 | Image string | ||
| 7 | HTTPS bool | 7 | HTTPS bool | ||
| 8 | + | Env []string // Pre-resolved env vars ("KEY=VALUE"); when set, Deploy skips EnvLoader lookup. | |||
| 8 | } | 9 | } | ||
| 9 | 10 | ||||
| 10 | // ProxyTarget represents the destination for proxying requests. | 11 | // ProxyTarget represents the destination for proxying requests. | ||
M internal/usecase/auto/dispatcher.go +1 -0
| @@ -17,6 +17,7 @@ type AutoConfigProvider interface { | |||
| 17 | 17 | GetPreviewTagPatterns() []string | |
| 18 | 18 | GetPreviewConfig() domain.PreviewConfig | |
| 19 | 19 | GetAllowedDomains() []string | |
| 20 | + | FindRoutesByImage(ctx context.Context, imageName string) []domain.Route | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // ImagePushDispatcher classifies image push events and delegates | |
| @@ -17,6 +17,7 @@ type AutoConfigProvider interface { | |||||
| 17 | GetPreviewTagPatterns() []string | 17 | GetPreviewTagPatterns() []string | ||
| 18 | GetPreviewConfig() domain.PreviewConfig | 18 | GetPreviewConfig() domain.PreviewConfig | ||
| 19 | GetAllowedDomains() []string | 19 | GetAllowedDomains() []string | ||
| 20 | + | FindRoutesByImage(ctx context.Context, imageName string) []domain.Route | |||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | // ImagePushDispatcher classifies image push events and delegates | 23 | // ImagePushDispatcher classifies image push events and delegates | ||
M internal/usecase/auto/dispatcher_test.go +3 -0
| @@ -32,6 +32,9 @@ func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.prev | |||
| 32 | 32 | func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns } | |
| 33 | 33 | func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} } | |
| 34 | 34 | func (m *mockAutoConfig) GetAllowedDomains() []string { return nil } | |
| 35 | + | func (m *mockAutoConfig) FindRoutesByImage(_ context.Context, _ string) []domain.Route { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 35 | 38 | ||
| 36 | 39 | func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) { | |
| 37 | 40 | route := &mockHandler{} | |
| @@ -32,6 +32,9 @@ func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.prev | |||||
| 32 | func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns } | 32 | func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns } | ||
| 33 | func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} } | 33 | func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} } | ||
| 34 | func (m *mockAutoConfig) GetAllowedDomains() []string { return nil } | 34 | func (m *mockAutoConfig) GetAllowedDomains() []string { return nil } | ||
| 35 | + | func (m *mockAutoConfig) FindRoutesByImage(_ context.Context, _ string) []domain.Route { | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 35 | 38 | ||||
| 36 | func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) { | 39 | func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) { | ||
| 37 | route := &mockHandler{} | 40 | route := &mockHandler{} | ||
M internal/usecase/auto/preview/handler.go +13 -2
| @@ -3,6 +3,7 @@ package preview | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "strings" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/zerowrap" | |
| 8 | 9 | ||
| @@ -62,10 +63,20 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err | |||
| 62 | 63 | previewConfig := h.config.GetPreviewConfig() | |
| 63 | 64 | previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns) | |
| 64 | 65 | ||
| 65 | - | // Resolve base route from labels | |
| 66 | + | // Resolve base route: labels first, then route config fallback. | |
| 67 | + | // When falling back to routes, skip preview domains (they contain the separator) | |
| 68 | + | // to avoid using a preview as the base for another preview. | |
| 66 | 69 | domains := collectDomains(labels) | |
| 67 | 70 | if len(domains) == 0 { | |
| 68 | - | log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping preview handler") | |
| 71 | + | routes := h.config.FindRoutesByImage(ctx, payload.Name) | |
| 72 | + | for _, r := range routes { | |
| 73 | + | if !strings.Contains(r.Domain, previewConfig.Separator) { | |
| 74 | + | domains = append(domains, r.Domain) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | if len(domains) == 0 { | |
| 79 | + | log.Debug().Str("image", payload.Name).Msg("no domain found from labels or routes, skipping preview") | |
| 69 | 80 | return nil | |
| 70 | 81 | } | |
| 71 | 82 | baseRouteDomain := domains[0] | |
| @@ -3,6 +3,7 @@ package preview | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "strings" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/zerowrap" | 8 | "github.com/bnema/zerowrap" | ||
| 8 | 9 | ||||
| @@ -62,10 +63,20 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err | |||||
| 62 | previewConfig := h.config.GetPreviewConfig() | 63 | previewConfig := h.config.GetPreviewConfig() | ||
| 63 | previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns) | 64 | previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns) | ||
| 64 | 65 | ||||
| 65 | - | // Resolve base route from labels | 66 | + | // Resolve base route: labels first, then route config fallback. |
| 67 | + | // When falling back to routes, skip preview domains (they contain the separator) | |||
| 68 | + | // to avoid using a preview as the base for another preview. | |||
| 66 | domains := collectDomains(labels) | 69 | domains := collectDomains(labels) | ||
| 67 | if len(domains) == 0 { | 70 | if len(domains) == 0 { | ||
| 68 | - | log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping preview handler") | 71 | + | routes := h.config.FindRoutesByImage(ctx, payload.Name) |
| 72 | + | for _, r := range routes { | |||
| 73 | + | if !strings.Contains(r.Domain, previewConfig.Separator) { | |||
| 74 | + | domains = append(domains, r.Domain) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | if len(domains) == 0 { | |||
| 79 | + | log.Debug().Str("image", payload.Name).Msg("no domain found from labels or routes, skipping preview") | |||
| 69 | return nil | 80 | return nil | ||
| 70 | } | 81 | } | ||
| 71 | baseRouteDomain := domains[0] | 82 | baseRouteDomain := domains[0] | ||
M internal/usecase/auto/preview/integration_test.go +149 -2
| @@ -2,6 +2,7 @@ package preview | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -11,11 +12,50 @@ import ( | |||
| 11 | 12 | "github.com/bnema/gordon/internal/domain" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 15 | + | type fakeDeployer struct { | |
| 16 | + | deployFn func(ctx context.Context, route domain.Route) (*domain.Container, error) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (f *fakeDeployer) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) { | |
| 20 | + | return f.deployFn(ctx, route) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type fakeRouteManager struct { | |
| 24 | + | addRouteFn func(ctx context.Context, route domain.Route) error | |
| 25 | + | removeRouteFn func(ctx context.Context, domain string) error | |
| 26 | + | getRouteFn func(ctx context.Context, domain string) (*domain.Route, error) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (f *fakeRouteManager) AddRoute(ctx context.Context, route domain.Route) error { | |
| 30 | + | if f.addRouteFn != nil { | |
| 31 | + | return f.addRouteFn(ctx, route) | |
| 32 | + | } | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (f *fakeRouteManager) RemoveRoute(ctx context.Context, d string) error { | |
| 37 | + | if f.removeRouteFn != nil { | |
| 38 | + | return f.removeRouteFn(ctx, d) | |
| 39 | + | } | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (f *fakeRouteManager) GetRoute(ctx context.Context, d string) (*domain.Route, error) { | |
| 44 | + | if f.getRouteFn != nil { | |
| 45 | + | return f.getRouteFn(ctx, d) | |
| 46 | + | } | |
| 47 | + | return nil, fmt.Errorf("route %q: %w", d, domain.ErrRouteNotFound) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (f *fakeRouteManager) GetVolumeConfig() (bool, string, bool) { | |
| 51 | + | return true, "gordon", false | |
| 52 | + | } | |
| 53 | + | ||
| 14 | 54 | func TestPreviewLifecycle_Integration(t *testing.T) { | |
| 15 | 55 | store := &fakeStore{} | |
| 16 | 56 | svc := NewService(store, 2*time.Second) | |
| 17 | 57 | ||
| 18 | - | ctx := context.Background() | |
| 58 | + | ctx := t.Context() | |
| 19 | 59 | ||
| 20 | 60 | // Create | |
| 21 | 61 | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |
| @@ -61,7 +101,7 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) { | |||
| 61 | 101 | store := &fakeStore{} | |
| 62 | 102 | svc := NewService(store, 1*time.Millisecond) | |
| 63 | 103 | ||
| 64 | - | ctx := context.Background() | |
| 104 | + | ctx := t.Context() | |
| 65 | 105 | ||
| 66 | 106 | // Create with very short TTL | |
| 67 | 107 | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |
| @@ -91,3 +131,110 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) { | |||
| 91 | 131 | require.NoError(t, err) | |
| 92 | 132 | assert.Empty(t, all) | |
| 93 | 133 | } | |
| 134 | + | ||
| 135 | + | type fakeEnvLoader struct { | |
| 136 | + | loadEnvFn func(ctx context.Context, domain string) ([]string, error) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (f *fakeEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |
| 140 | + | if f.loadEnvFn != nil { | |
| 141 | + | return f.loadEnvFn(ctx, domain) | |
| 142 | + | } | |
| 143 | + | return nil, nil | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (f *fakeEnvLoader) CreateEnvFile(_ context.Context, _ string) error { return nil } | |
| 147 | + | func (f *fakeEnvLoader) EnvFileExists(_ string) (bool, error) { return false, nil } | |
| 148 | + | ||
| 149 | + | func TestPreviewLifecycle_WithEnvInheritance(t *testing.T) { | |
| 150 | + | store := &fakeStore{} | |
| 151 | + | var deployedRoute domain.Route | |
| 152 | + | ||
| 153 | + | mockDeployer := &fakeDeployer{ | |
| 154 | + | deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) { | |
| 155 | + | deployedRoute = route | |
| 156 | + | return &domain.Container{Name: "gordon-" + route.Domain}, nil | |
| 157 | + | }, | |
| 158 | + | } | |
| 159 | + | mockRouteManager := &fakeRouteManager{} | |
| 160 | + | mockEnvLoader := &fakeEnvLoader{ | |
| 161 | + | loadEnvFn: func(_ context.Context, d string) ([]string, error) { | |
| 162 | + | if d == "myapp.example.com" { | |
| 163 | + | return []string{"DB_HOST=prod-db", "API_KEY=secret123"}, nil | |
| 164 | + | } | |
| 165 | + | return nil, nil | |
| 166 | + | }, | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | svc := NewService(store, 2*time.Second). | |
| 170 | + | WithDeployer(mockDeployer). | |
| 171 | + | WithRouteManager(mockRouteManager). | |
| 172 | + | WithEnvLoader(mockEnvLoader). | |
| 173 | + | WithRegistryDomain("reg.example.com") | |
| 174 | + | ||
| 175 | + | ctx := t.Context() | |
| 176 | + | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |
| 177 | + | Name: "test-feat", | |
| 178 | + | Domain: "myapp--test-feat.example.com", | |
| 179 | + | BaseRoute: "myapp.example.com", | |
| 180 | + | Image: "myapp:preview-test-feat", | |
| 181 | + | HTTPS: true, | |
| 182 | + | PreviewConfig: domain.PreviewConfig{ | |
| 183 | + | TTL: 2 * time.Second, | |
| 184 | + | Separator: "--", | |
| 185 | + | EnvCopy: true, | |
| 186 | + | }, | |
| 187 | + | }) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | ||
| 190 | + | // Deployer should receive base route's env vars via Route.Env | |
| 191 | + | assert.Equal(t, []string{"DB_HOST=prod-db", "API_KEY=secret123"}, deployedRoute.Env) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestPreviewLifecycle_WithDeploy(t *testing.T) { | |
| 195 | + | store := &fakeStore{} | |
| 196 | + | deployed := false | |
| 197 | + | routeAdded := "" | |
| 198 | + | ||
| 199 | + | mockDeployer := &fakeDeployer{ | |
| 200 | + | deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) { | |
| 201 | + | deployed = true | |
| 202 | + | return &domain.Container{Name: "gordon-" + route.Domain}, nil | |
| 203 | + | }, | |
| 204 | + | } | |
| 205 | + | mockRouteManager := &fakeRouteManager{ | |
| 206 | + | addRouteFn: func(_ context.Context, route domain.Route) error { | |
| 207 | + | routeAdded = route.Domain | |
| 208 | + | return nil | |
| 209 | + | }, | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | svc := NewService(store, 2*time.Second). | |
| 213 | + | WithDeployer(mockDeployer). | |
| 214 | + | WithRouteManager(mockRouteManager). | |
| 215 | + | WithRegistryDomain("reg.example.com") | |
| 216 | + | ||
| 217 | + | ctx := t.Context() | |
| 218 | + | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |
| 219 | + | Name: "test-feat", | |
| 220 | + | Domain: "myapp--test-feat.example.com", | |
| 221 | + | BaseRoute: "myapp.example.com", | |
| 222 | + | Image: "myapp:preview-test-feat", | |
| 223 | + | HTTPS: true, | |
| 224 | + | PreviewConfig: domain.PreviewConfig{ | |
| 225 | + | TTL: 2 * time.Second, | |
| 226 | + | Separator: "--", | |
| 227 | + | DataCopy: false, | |
| 228 | + | }, | |
| 229 | + | }) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | ||
| 232 | + | assert.True(t, deployed, "deployer should have been called") | |
| 233 | + | assert.Equal(t, "myapp--test-feat.example.com", routeAdded) | |
| 234 | + | ||
| 235 | + | all, err := svc.List(ctx) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | require.Len(t, all, 1) | |
| 238 | + | assert.Equal(t, domain.PreviewStatusRunning, all[0].Status) | |
| 239 | + | assert.Equal(t, []string{"gordon-myapp--test-feat.example.com"}, all[0].Containers) | |
| 240 | + | } | |
| @@ -2,6 +2,7 @@ package preview | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -11,11 +12,50 @@ import ( | |||||
| 11 | "github.com/bnema/gordon/internal/domain" | 12 | "github.com/bnema/gordon/internal/domain" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 15 | + | type fakeDeployer struct { | |||
| 16 | + | deployFn func(ctx context.Context, route domain.Route) (*domain.Container, error) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (f *fakeDeployer) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) { | |||
| 20 | + | return f.deployFn(ctx, route) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type fakeRouteManager struct { | |||
| 24 | + | addRouteFn func(ctx context.Context, route domain.Route) error | |||
| 25 | + | removeRouteFn func(ctx context.Context, domain string) error | |||
| 26 | + | getRouteFn func(ctx context.Context, domain string) (*domain.Route, error) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (f *fakeRouteManager) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 30 | + | if f.addRouteFn != nil { | |||
| 31 | + | return f.addRouteFn(ctx, route) | |||
| 32 | + | } | |||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (f *fakeRouteManager) RemoveRoute(ctx context.Context, d string) error { | |||
| 37 | + | if f.removeRouteFn != nil { | |||
| 38 | + | return f.removeRouteFn(ctx, d) | |||
| 39 | + | } | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (f *fakeRouteManager) GetRoute(ctx context.Context, d string) (*domain.Route, error) { | |||
| 44 | + | if f.getRouteFn != nil { | |||
| 45 | + | return f.getRouteFn(ctx, d) | |||
| 46 | + | } | |||
| 47 | + | return nil, fmt.Errorf("route %q: %w", d, domain.ErrRouteNotFound) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (f *fakeRouteManager) GetVolumeConfig() (bool, string, bool) { | |||
| 51 | + | return true, "gordon", false | |||
| 52 | + | } | |||
| 53 | + | ||||
| 14 | func TestPreviewLifecycle_Integration(t *testing.T) { | 54 | func TestPreviewLifecycle_Integration(t *testing.T) { | ||
| 15 | store := &fakeStore{} | 55 | store := &fakeStore{} | ||
| 16 | svc := NewService(store, 2*time.Second) | 56 | svc := NewService(store, 2*time.Second) | ||
| 17 | 57 | ||||
| 18 | - | ctx := context.Background() | 58 | + | ctx := t.Context() |
| 19 | 59 | ||||
| 20 | // Create | 60 | // Create | ||
| 21 | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | 61 | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | ||
| @@ -61,7 +101,7 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) { | |||||
| 61 | store := &fakeStore{} | 101 | store := &fakeStore{} | ||
| 62 | svc := NewService(store, 1*time.Millisecond) | 102 | svc := NewService(store, 1*time.Millisecond) | ||
| 63 | 103 | ||||
| 64 | - | ctx := context.Background() | 104 | + | ctx := t.Context() |
| 65 | 105 | ||||
| 66 | // Create with very short TTL | 106 | // Create with very short TTL | ||
| 67 | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | 107 | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | ||
| @@ -91,3 +131,110 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) { | |||||
| 91 | require.NoError(t, err) | 131 | require.NoError(t, err) | ||
| 92 | assert.Empty(t, all) | 132 | assert.Empty(t, all) | ||
| 93 | } | 133 | } | ||
| 134 | + | ||||
| 135 | + | type fakeEnvLoader struct { | |||
| 136 | + | loadEnvFn func(ctx context.Context, domain string) ([]string, error) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (f *fakeEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |||
| 140 | + | if f.loadEnvFn != nil { | |||
| 141 | + | return f.loadEnvFn(ctx, domain) | |||
| 142 | + | } | |||
| 143 | + | return nil, nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func (f *fakeEnvLoader) CreateEnvFile(_ context.Context, _ string) error { return nil } | |||
| 147 | + | func (f *fakeEnvLoader) EnvFileExists(_ string) (bool, error) { return false, nil } | |||
| 148 | + | ||||
| 149 | + | func TestPreviewLifecycle_WithEnvInheritance(t *testing.T) { | |||
| 150 | + | store := &fakeStore{} | |||
| 151 | + | var deployedRoute domain.Route | |||
| 152 | + | ||||
| 153 | + | mockDeployer := &fakeDeployer{ | |||
| 154 | + | deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) { | |||
| 155 | + | deployedRoute = route | |||
| 156 | + | return &domain.Container{Name: "gordon-" + route.Domain}, nil | |||
| 157 | + | }, | |||
| 158 | + | } | |||
| 159 | + | mockRouteManager := &fakeRouteManager{} | |||
| 160 | + | mockEnvLoader := &fakeEnvLoader{ | |||
| 161 | + | loadEnvFn: func(_ context.Context, d string) ([]string, error) { | |||
| 162 | + | if d == "myapp.example.com" { | |||
| 163 | + | return []string{"DB_HOST=prod-db", "API_KEY=secret123"}, nil | |||
| 164 | + | } | |||
| 165 | + | return nil, nil | |||
| 166 | + | }, | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | svc := NewService(store, 2*time.Second). | |||
| 170 | + | WithDeployer(mockDeployer). | |||
| 171 | + | WithRouteManager(mockRouteManager). | |||
| 172 | + | WithEnvLoader(mockEnvLoader). | |||
| 173 | + | WithRegistryDomain("reg.example.com") | |||
| 174 | + | ||||
| 175 | + | ctx := t.Context() | |||
| 176 | + | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |||
| 177 | + | Name: "test-feat", | |||
| 178 | + | Domain: "myapp--test-feat.example.com", | |||
| 179 | + | BaseRoute: "myapp.example.com", | |||
| 180 | + | Image: "myapp:preview-test-feat", | |||
| 181 | + | HTTPS: true, | |||
| 182 | + | PreviewConfig: domain.PreviewConfig{ | |||
| 183 | + | TTL: 2 * time.Second, | |||
| 184 | + | Separator: "--", | |||
| 185 | + | EnvCopy: true, | |||
| 186 | + | }, | |||
| 187 | + | }) | |||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | ||||
| 190 | + | // Deployer should receive base route's env vars via Route.Env | |||
| 191 | + | assert.Equal(t, []string{"DB_HOST=prod-db", "API_KEY=secret123"}, deployedRoute.Env) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestPreviewLifecycle_WithDeploy(t *testing.T) { | |||
| 195 | + | store := &fakeStore{} | |||
| 196 | + | deployed := false | |||
| 197 | + | routeAdded := "" | |||
| 198 | + | ||||
| 199 | + | mockDeployer := &fakeDeployer{ | |||
| 200 | + | deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) { | |||
| 201 | + | deployed = true | |||
| 202 | + | return &domain.Container{Name: "gordon-" + route.Domain}, nil | |||
| 203 | + | }, | |||
| 204 | + | } | |||
| 205 | + | mockRouteManager := &fakeRouteManager{ | |||
| 206 | + | addRouteFn: func(_ context.Context, route domain.Route) error { | |||
| 207 | + | routeAdded = route.Domain | |||
| 208 | + | return nil | |||
| 209 | + | }, | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | svc := NewService(store, 2*time.Second). | |||
| 213 | + | WithDeployer(mockDeployer). | |||
| 214 | + | WithRouteManager(mockRouteManager). | |||
| 215 | + | WithRegistryDomain("reg.example.com") | |||
| 216 | + | ||||
| 217 | + | ctx := t.Context() | |||
| 218 | + | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |||
| 219 | + | Name: "test-feat", | |||
| 220 | + | Domain: "myapp--test-feat.example.com", | |||
| 221 | + | BaseRoute: "myapp.example.com", | |||
| 222 | + | Image: "myapp:preview-test-feat", | |||
| 223 | + | HTTPS: true, | |||
| 224 | + | PreviewConfig: domain.PreviewConfig{ | |||
| 225 | + | TTL: 2 * time.Second, | |||
| 226 | + | Separator: "--", | |||
| 227 | + | DataCopy: false, | |||
| 228 | + | }, | |||
| 229 | + | }) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | ||||
| 232 | + | assert.True(t, deployed, "deployer should have been called") | |||
| 233 | + | assert.Equal(t, "myapp--test-feat.example.com", routeAdded) | |||
| 234 | + | ||||
| 235 | + | all, err := svc.List(ctx) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | require.Len(t, all, 1) | |||
| 238 | + | assert.Equal(t, domain.PreviewStatusRunning, all[0].Status) | |||
| 239 | + | assert.Equal(t, []string{"gordon-myapp--test-feat.example.com"}, all[0].Containers) | |||
| 240 | + | } | |||
M internal/usecase/auto/preview/service.go +205 -8
| @@ -3,6 +3,7 @@ package preview | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -17,14 +18,32 @@ type nameLockEntry struct { | |||
| 17 | 18 | refs int | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 21 | + | // Deployer abstracts the container service deploy method. | |
| 22 | + | type Deployer interface { | |
| 23 | + | Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // RouteManager abstracts config service methods needed for preview route lifecycle. | |
| 27 | + | type RouteManager interface { | |
| 28 | + | AddRoute(ctx context.Context, route domain.Route) error | |
| 29 | + | RemoveRoute(ctx context.Context, domain string) error | |
| 30 | + | GetRoute(ctx context.Context, domain string) (*domain.Route, error) | |
| 31 | + | GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) | |
| 32 | + | } | |
| 33 | + | ||
| 20 | 34 | // Service manages preview lifecycle: CRUD, TTL, persistence. | |
| 21 | 35 | type Service struct { | |
| 22 | - | store out.PreviewStore | |
| 23 | - | defaultTTL time.Duration | |
| 24 | - | previews []domain.PreviewRoute | |
| 25 | - | mu sync.RWMutex | |
| 26 | - | nameLocks map[string]*nameLockEntry | |
| 27 | - | nameLockMu sync.Mutex | |
| 36 | + | store out.PreviewStore | |
| 37 | + | defaultTTL time.Duration | |
| 38 | + | deployer Deployer | |
| 39 | + | routeManager RouteManager | |
| 40 | + | volumeCloner VolumeCloner | |
| 41 | + | envLoader out.EnvLoader | |
| 42 | + | registryDomain string | |
| 43 | + | previews []domain.PreviewRoute | |
| 44 | + | mu sync.RWMutex | |
| 45 | + | nameLocks map[string]*nameLockEntry | |
| 46 | + | nameLockMu sync.Mutex | |
| 28 | 47 | } | |
| 29 | 48 | ||
| 30 | 49 | func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service { | |
| @@ -35,6 +54,31 @@ func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service { | |||
| 35 | 54 | } | |
| 36 | 55 | } | |
| 37 | 56 | ||
| 57 | + | func (s *Service) WithDeployer(d Deployer) *Service { | |
| 58 | + | s.deployer = d | |
| 59 | + | return s | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (s *Service) WithRouteManager(rm RouteManager) *Service { | |
| 63 | + | s.routeManager = rm | |
| 64 | + | return s | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (s *Service) WithVolumeCloner(vc VolumeCloner) *Service { | |
| 68 | + | s.volumeCloner = vc | |
| 69 | + | return s | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (s *Service) WithRegistryDomain(d string) *Service { | |
| 73 | + | s.registryDomain = d | |
| 74 | + | return s | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (s *Service) WithEnvLoader(el out.EnvLoader) *Service { | |
| 78 | + | s.envLoader = el | |
| 79 | + | return s | |
| 80 | + | } | |
| 81 | + | ||
| 38 | 82 | func (s *Service) Load(ctx context.Context) error { | |
| 39 | 83 | previews, err := s.store.Load(ctx) | |
| 40 | 84 | if err != nil { | |
| @@ -55,13 +99,30 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | |||
| 55 | 99 | return s.store.Save(ctx, cp) | |
| 56 | 100 | } | |
| 57 | 101 | ||
| 102 | + | func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error { | |
| 103 | + | s.mu.Lock() | |
| 104 | + | for i := range s.previews { | |
| 105 | + | if s.previews[i].Name == p.Name { | |
| 106 | + | s.previews[i] = p | |
| 107 | + | cp := make([]domain.PreviewRoute, len(s.previews)) | |
| 108 | + | copy(cp, s.previews) | |
| 109 | + | s.mu.Unlock() | |
| 110 | + | return s.store.Save(ctx, cp) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | s.mu.Unlock() | |
| 114 | + | return fmt.Errorf("preview %q: %w", p.Name, domain.ErrPreviewNotFound) | |
| 115 | + | } | |
| 116 | + | ||
| 58 | 117 | func (s *Service) Delete(ctx context.Context, name string) error { | |
| 59 | 118 | s.mu.Lock() | |
| 119 | + | var deleted domain.PreviewRoute | |
| 60 | 120 | found := false | |
| 61 | 121 | filtered := make([]domain.PreviewRoute, 0, len(s.previews)) | |
| 62 | 122 | for _, p := range s.previews { | |
| 63 | 123 | if p.Name == name { | |
| 64 | 124 | found = true | |
| 125 | + | deleted = p | |
| 65 | 126 | } else { | |
| 66 | 127 | filtered = append(filtered, p) | |
| 67 | 128 | } | |
| @@ -74,7 +135,19 @@ func (s *Service) Delete(ctx context.Context, name string) error { | |||
| 74 | 135 | cp := make([]domain.PreviewRoute, len(s.previews)) | |
| 75 | 136 | copy(cp, s.previews) | |
| 76 | 137 | s.mu.Unlock() | |
| 77 | - | return s.store.Save(ctx, cp) | |
| 138 | + | ||
| 139 | + | if err := s.store.Save(ctx, cp); err != nil { | |
| 140 | + | return err | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Clean up the route from config so proxy stops routing to this domain. | |
| 144 | + | if s.routeManager != nil && deleted.Domain != "" { | |
| 145 | + | log := zerowrap.FromCtx(ctx) | |
| 146 | + | if err := s.routeManager.RemoveRoute(ctx, deleted.Domain); err != nil { | |
| 147 | + | log.Debug().Err(err).Str("domain", deleted.Domain).Msg("preview route already removed from config") | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | return nil | |
| 78 | 151 | } | |
| 79 | 152 | ||
| 80 | 153 | func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) { | |
| @@ -198,6 +271,63 @@ func (s *Service) ReleaseNameLock(name string) { | |||
| 198 | 271 | } | |
| 199 | 272 | } | |
| 200 | 273 | ||
| 274 | + | func (s *Service) qualifyImage(image string) string { | |
| 275 | + | if s.registryDomain != "" && !strings.Contains(image, "/") { | |
| 276 | + | return s.registryDomain + "/" + image | |
| 277 | + | } | |
| 278 | + | return image | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // generateVolumeName mirrors the naming convention in container/service.go. | |
| 282 | + | // This MUST stay in sync with the container service's generateVolumeName. | |
| 283 | + | func generateVolumeName(prefix, domainName, volumePath string) string { | |
| 284 | + | return fmt.Sprintf("%s-%s-%s", | |
| 285 | + | prefix, | |
| 286 | + | strings.ReplaceAll(domainName, ".", "-"), | |
| 287 | + | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) { | |
| 291 | + | if s.volumeCloner == nil || s.routeManager == nil { | |
| 292 | + | return nil, nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | _, prefix, _ := s.routeManager.GetVolumeConfig() | |
| 296 | + | if prefix == "" { | |
| 297 | + | prefix = "gordon" | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | log := zerowrap.FromCtx(ctx) | |
| 301 | + | basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-" | |
| 302 | + | ||
| 303 | + | vols, err := s.volumeCloner.ListVolumes(ctx) | |
| 304 | + | if err != nil { | |
| 305 | + | return nil, fmt.Errorf("list volumes: %w", err) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | var sourceVols []string | |
| 309 | + | for _, v := range vols { | |
| 310 | + | if strings.HasPrefix(v.Name, basePrefix) { | |
| 311 | + | sourceVols = append(sourceVols, v.Name) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | if len(sourceVols) == 0 { | |
| 316 | + | return nil, nil | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | namer := func(sourceVolName string) string { | |
| 320 | + | pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix) | |
| 321 | + | return generateVolumeName(prefix, previewDomain, pathSuffix) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | for _, src := range sourceVols { | |
| 325 | + | log.Debug().Str("source", src).Str("target", namer(src)).Msg("will clone volume for preview") | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | return CloneVolumes(ctx, s.volumeCloner, namer, sourceVols) | |
| 329 | + | } | |
| 330 | + | ||
| 201 | 331 | // CreatePreviewRequest contains parameters for creating a preview. | |
| 202 | 332 | type CreatePreviewRequest struct { | |
| 203 | 333 | Name string | |
| @@ -217,7 +347,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||
| 217 | 347 | s.ReleaseNameLock(req.Name) | |
| 218 | 348 | }() | |
| 219 | 349 | ||
| 350 | + | log := zerowrap.FromCtx(ctx) | |
| 220 | 351 | now := time.Now() | |
| 352 | + | ||
| 221 | 353 | preview := domain.PreviewRoute{ | |
| 222 | 354 | Domain: req.Domain, | |
| 223 | 355 | Image: req.Image, | |
| @@ -226,11 +358,76 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||
| 226 | 358 | CreatedAt: now, | |
| 227 | 359 | ExpiresAt: now.Add(req.PreviewConfig.TTL), | |
| 228 | 360 | HTTPS: req.HTTPS, | |
| 361 | + | Status: domain.PreviewStatusDeploying, | |
| 229 | 362 | } | |
| 230 | 363 | ||
| 231 | - | // TODO: Volume cloning and container startup will be wired in Task 13 | |
| 232 | 364 | if err := s.Add(ctx, preview); err != nil { | |
| 233 | 365 | return fmt.Errorf("register preview %q: %w", req.Name, err) | |
| 234 | 366 | } | |
| 367 | + | ||
| 368 | + | if req.PreviewConfig.DataCopy { | |
| 369 | + | cloned, err := s.cloneBaseRouteVolumes(ctx, req.BaseRoute, req.Domain) | |
| 370 | + | if err != nil { | |
| 371 | + | log.Warn().Err(err).Str("base_route", req.BaseRoute).Msg("failed to clone volumes, deploying with empty volumes") | |
| 372 | + | } else if len(cloned) > 0 { | |
| 373 | + | preview.Volumes = cloned | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | if s.routeManager != nil { | |
| 378 | + | imageRef := s.qualifyImage(req.Image) | |
| 379 | + | if err := s.routeManager.AddRoute(ctx, domain.Route{ | |
| 380 | + | Domain: req.Domain, | |
| 381 | + | Image: imageRef, | |
| 382 | + | HTTPS: req.HTTPS, | |
| 383 | + | }); err != nil { | |
| 384 | + | preview.Status = domain.PreviewStatusFailed | |
| 385 | + | if updateErr := s.Update(ctx, preview); updateErr != nil { | |
| 386 | + | log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status") | |
| 387 | + | } | |
| 388 | + | return fmt.Errorf("register preview route %q: %w", req.Domain, err) | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | var baseEnv []string | |
| 393 | + | if s.envLoader != nil && req.PreviewConfig.EnvCopy { | |
| 394 | + | var envErr error | |
| 395 | + | baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute) | |
| 396 | + | if envErr != nil { | |
| 397 | + | log.Warn().Err(envErr).Str("base_route", req.BaseRoute).Msg("failed to load base route env, deploying without env vars") | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | if s.deployer != nil { | |
| 402 | + | imageRef := s.qualifyImage(req.Image) | |
| 403 | + | deployCtx := domain.WithInternalDeploy(ctx) | |
| 404 | + | container, err := s.deployer.Deploy(deployCtx, domain.Route{ | |
| 405 | + | Domain: req.Domain, | |
| 406 | + | Image: imageRef, | |
| 407 | + | HTTPS: req.HTTPS, | |
| 408 | + | Env: baseEnv, | |
| 409 | + | }) | |
| 410 | + | if err != nil { | |
| 411 | + | preview.Status = domain.PreviewStatusFailed | |
| 412 | + | if updateErr := s.Update(ctx, preview); updateErr != nil { | |
| 413 | + | log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status") | |
| 414 | + | } | |
| 415 | + | return fmt.Errorf("deploy preview %q: %w", req.Name, err) | |
| 416 | + | } | |
| 417 | + | preview.Containers = []string{container.Name} | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | preview.Status = domain.PreviewStatusRunning | |
| 421 | + | if err := s.Update(ctx, preview); err != nil { | |
| 422 | + | return fmt.Errorf("finalize preview %q: %w", req.Name, err) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | log.Info(). | |
| 426 | + | Str("name", req.Name). | |
| 427 | + | Str("domain", req.Domain). | |
| 428 | + | Str("image", req.Image). | |
| 429 | + | Str("base_route", req.BaseRoute). | |
| 430 | + | Msg("preview environment created") | |
| 431 | + | ||
| 235 | 432 | return nil | |
| 236 | 433 | } | |
| @@ -3,6 +3,7 @@ package preview | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "strings" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -17,14 +18,32 @@ type nameLockEntry struct { | |||||
| 17 | refs int | 18 | refs int | ||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 21 | + | // Deployer abstracts the container service deploy method. | |||
| 22 | + | type Deployer interface { | |||
| 23 | + | Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // RouteManager abstracts config service methods needed for preview route lifecycle. | |||
| 27 | + | type RouteManager interface { | |||
| 28 | + | AddRoute(ctx context.Context, route domain.Route) error | |||
| 29 | + | RemoveRoute(ctx context.Context, domain string) error | |||
| 30 | + | GetRoute(ctx context.Context, domain string) (*domain.Route, error) | |||
| 31 | + | GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 20 | // Service manages preview lifecycle: CRUD, TTL, persistence. | 34 | // Service manages preview lifecycle: CRUD, TTL, persistence. | ||
| 21 | type Service struct { | 35 | type Service struct { | ||
| 22 | - | store out.PreviewStore | 36 | + | store out.PreviewStore |
| 23 | - | defaultTTL time.Duration | 37 | + | defaultTTL time.Duration |
| 24 | - | previews []domain.PreviewRoute | 38 | + | deployer Deployer |
| 25 | - | mu sync.RWMutex | 39 | + | routeManager RouteManager |
| 26 | - | nameLocks map[string]*nameLockEntry | 40 | + | volumeCloner VolumeCloner |
| 27 | - | nameLockMu sync.Mutex | 41 | + | envLoader out.EnvLoader |
| 42 | + | registryDomain string | |||
| 43 | + | previews []domain.PreviewRoute | |||
| 44 | + | mu sync.RWMutex | |||
| 45 | + | nameLocks map[string]*nameLockEntry | |||
| 46 | + | nameLockMu sync.Mutex | |||
| 28 | } | 47 | } | ||
| 29 | 48 | ||||
| 30 | func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service { | 49 | func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service { | ||
| @@ -35,6 +54,31 @@ func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service { | |||||
| 35 | } | 54 | } | ||
| 36 | } | 55 | } | ||
| 37 | 56 | ||||
| 57 | + | func (s *Service) WithDeployer(d Deployer) *Service { | |||
| 58 | + | s.deployer = d | |||
| 59 | + | return s | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (s *Service) WithRouteManager(rm RouteManager) *Service { | |||
| 63 | + | s.routeManager = rm | |||
| 64 | + | return s | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (s *Service) WithVolumeCloner(vc VolumeCloner) *Service { | |||
| 68 | + | s.volumeCloner = vc | |||
| 69 | + | return s | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (s *Service) WithRegistryDomain(d string) *Service { | |||
| 73 | + | s.registryDomain = d | |||
| 74 | + | return s | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (s *Service) WithEnvLoader(el out.EnvLoader) *Service { | |||
| 78 | + | s.envLoader = el | |||
| 79 | + | return s | |||
| 80 | + | } | |||
| 81 | + | ||||
| 38 | func (s *Service) Load(ctx context.Context) error { | 82 | func (s *Service) Load(ctx context.Context) error { | ||
| 39 | previews, err := s.store.Load(ctx) | 83 | previews, err := s.store.Load(ctx) | ||
| 40 | if err != nil { | 84 | if err != nil { | ||
| @@ -55,13 +99,30 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | |||||
| 55 | return s.store.Save(ctx, cp) | 99 | return s.store.Save(ctx, cp) | ||
| 56 | } | 100 | } | ||
| 57 | 101 | ||||
| 102 | + | func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error { | |||
| 103 | + | s.mu.Lock() | |||
| 104 | + | for i := range s.previews { | |||
| 105 | + | if s.previews[i].Name == p.Name { | |||
| 106 | + | s.previews[i] = p | |||
| 107 | + | cp := make([]domain.PreviewRoute, len(s.previews)) | |||
| 108 | + | copy(cp, s.previews) | |||
| 109 | + | s.mu.Unlock() | |||
| 110 | + | return s.store.Save(ctx, cp) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | s.mu.Unlock() | |||
| 114 | + | return fmt.Errorf("preview %q: %w", p.Name, domain.ErrPreviewNotFound) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 58 | func (s *Service) Delete(ctx context.Context, name string) error { | 117 | func (s *Service) Delete(ctx context.Context, name string) error { | ||
| 59 | s.mu.Lock() | 118 | s.mu.Lock() | ||
| 119 | + | var deleted domain.PreviewRoute | |||
| 60 | found := false | 120 | found := false | ||
| 61 | filtered := make([]domain.PreviewRoute, 0, len(s.previews)) | 121 | filtered := make([]domain.PreviewRoute, 0, len(s.previews)) | ||
| 62 | for _, p := range s.previews { | 122 | for _, p := range s.previews { | ||
| 63 | if p.Name == name { | 123 | if p.Name == name { | ||
| 64 | found = true | 124 | found = true | ||
| 125 | + | deleted = p | |||
| 65 | } else { | 126 | } else { | ||
| 66 | filtered = append(filtered, p) | 127 | filtered = append(filtered, p) | ||
| 67 | } | 128 | } | ||
| @@ -74,7 +135,19 @@ func (s *Service) Delete(ctx context.Context, name string) error { | |||||
| 74 | cp := make([]domain.PreviewRoute, len(s.previews)) | 135 | cp := make([]domain.PreviewRoute, len(s.previews)) | ||
| 75 | copy(cp, s.previews) | 136 | copy(cp, s.previews) | ||
| 76 | s.mu.Unlock() | 137 | s.mu.Unlock() | ||
| 77 | - | return s.store.Save(ctx, cp) | 138 | + | |
| 139 | + | if err := s.store.Save(ctx, cp); err != nil { | |||
| 140 | + | return err | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Clean up the route from config so proxy stops routing to this domain. | |||
| 144 | + | if s.routeManager != nil && deleted.Domain != "" { | |||
| 145 | + | log := zerowrap.FromCtx(ctx) | |||
| 146 | + | if err := s.routeManager.RemoveRoute(ctx, deleted.Domain); err != nil { | |||
| 147 | + | log.Debug().Err(err).Str("domain", deleted.Domain).Msg("preview route already removed from config") | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | return nil | |||
| 78 | } | 151 | } | ||
| 79 | 152 | ||||
| 80 | func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) { | 153 | func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) { | ||
| @@ -198,6 +271,63 @@ func (s *Service) ReleaseNameLock(name string) { | |||||
| 198 | } | 271 | } | ||
| 199 | } | 272 | } | ||
| 200 | 273 | ||||
| 274 | + | func (s *Service) qualifyImage(image string) string { | |||
| 275 | + | if s.registryDomain != "" && !strings.Contains(image, "/") { | |||
| 276 | + | return s.registryDomain + "/" + image | |||
| 277 | + | } | |||
| 278 | + | return image | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // generateVolumeName mirrors the naming convention in container/service.go. | |||
| 282 | + | // This MUST stay in sync with the container service's generateVolumeName. | |||
| 283 | + | func generateVolumeName(prefix, domainName, volumePath string) string { | |||
| 284 | + | return fmt.Sprintf("%s-%s-%s", | |||
| 285 | + | prefix, | |||
| 286 | + | strings.ReplaceAll(domainName, ".", "-"), | |||
| 287 | + | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) { | |||
| 291 | + | if s.volumeCloner == nil || s.routeManager == nil { | |||
| 292 | + | return nil, nil | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | _, prefix, _ := s.routeManager.GetVolumeConfig() | |||
| 296 | + | if prefix == "" { | |||
| 297 | + | prefix = "gordon" | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | log := zerowrap.FromCtx(ctx) | |||
| 301 | + | basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-" | |||
| 302 | + | ||||
| 303 | + | vols, err := s.volumeCloner.ListVolumes(ctx) | |||
| 304 | + | if err != nil { | |||
| 305 | + | return nil, fmt.Errorf("list volumes: %w", err) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | var sourceVols []string | |||
| 309 | + | for _, v := range vols { | |||
| 310 | + | if strings.HasPrefix(v.Name, basePrefix) { | |||
| 311 | + | sourceVols = append(sourceVols, v.Name) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | if len(sourceVols) == 0 { | |||
| 316 | + | return nil, nil | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | namer := func(sourceVolName string) string { | |||
| 320 | + | pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix) | |||
| 321 | + | return generateVolumeName(prefix, previewDomain, pathSuffix) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | for _, src := range sourceVols { | |||
| 325 | + | log.Debug().Str("source", src).Str("target", namer(src)).Msg("will clone volume for preview") | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | return CloneVolumes(ctx, s.volumeCloner, namer, sourceVols) | |||
| 329 | + | } | |||
| 330 | + | ||||
| 201 | // CreatePreviewRequest contains parameters for creating a preview. | 331 | // CreatePreviewRequest contains parameters for creating a preview. | ||
| 202 | type CreatePreviewRequest struct { | 332 | type CreatePreviewRequest struct { | ||
| 203 | Name string | 333 | Name string | ||
| @@ -217,7 +347,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||||
| 217 | s.ReleaseNameLock(req.Name) | 347 | s.ReleaseNameLock(req.Name) | ||
| 218 | }() | 348 | }() | ||
| 219 | 349 | ||||
| 350 | + | log := zerowrap.FromCtx(ctx) | |||
| 220 | now := time.Now() | 351 | now := time.Now() | ||
| 352 | + | ||||
| 221 | preview := domain.PreviewRoute{ | 353 | preview := domain.PreviewRoute{ | ||
| 222 | Domain: req.Domain, | 354 | Domain: req.Domain, | ||
| 223 | Image: req.Image, | 355 | Image: req.Image, | ||
| @@ -226,11 +358,76 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||||
| 226 | CreatedAt: now, | 358 | CreatedAt: now, | ||
| 227 | ExpiresAt: now.Add(req.PreviewConfig.TTL), | 359 | ExpiresAt: now.Add(req.PreviewConfig.TTL), | ||
| 228 | HTTPS: req.HTTPS, | 360 | HTTPS: req.HTTPS, | ||
| 361 | + | Status: domain.PreviewStatusDeploying, | |||
| 229 | } | 362 | } | ||
| 230 | 363 | ||||
| 231 | - | // TODO: Volume cloning and container startup will be wired in Task 13 | |||
| 232 | if err := s.Add(ctx, preview); err != nil { | 364 | if err := s.Add(ctx, preview); err != nil { | ||
| 233 | return fmt.Errorf("register preview %q: %w", req.Name, err) | 365 | return fmt.Errorf("register preview %q: %w", req.Name, err) | ||
| 234 | } | 366 | } | ||
| 367 | + | ||||
| 368 | + | if req.PreviewConfig.DataCopy { | |||
| 369 | + | cloned, err := s.cloneBaseRouteVolumes(ctx, req.BaseRoute, req.Domain) | |||
| 370 | + | if err != nil { | |||
| 371 | + | log.Warn().Err(err).Str("base_route", req.BaseRoute).Msg("failed to clone volumes, deploying with empty volumes") | |||
| 372 | + | } else if len(cloned) > 0 { | |||
| 373 | + | preview.Volumes = cloned | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | if s.routeManager != nil { | |||
| 378 | + | imageRef := s.qualifyImage(req.Image) | |||
| 379 | + | if err := s.routeManager.AddRoute(ctx, domain.Route{ | |||
| 380 | + | Domain: req.Domain, | |||
| 381 | + | Image: imageRef, | |||
| 382 | + | HTTPS: req.HTTPS, | |||
| 383 | + | }); err != nil { | |||
| 384 | + | preview.Status = domain.PreviewStatusFailed | |||
| 385 | + | if updateErr := s.Update(ctx, preview); updateErr != nil { | |||
| 386 | + | log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status") | |||
| 387 | + | } | |||
| 388 | + | return fmt.Errorf("register preview route %q: %w", req.Domain, err) | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | var baseEnv []string | |||
| 393 | + | if s.envLoader != nil && req.PreviewConfig.EnvCopy { | |||
| 394 | + | var envErr error | |||
| 395 | + | baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute) | |||
| 396 | + | if envErr != nil { | |||
| 397 | + | log.Warn().Err(envErr).Str("base_route", req.BaseRoute).Msg("failed to load base route env, deploying without env vars") | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | if s.deployer != nil { | |||
| 402 | + | imageRef := s.qualifyImage(req.Image) | |||
| 403 | + | deployCtx := domain.WithInternalDeploy(ctx) | |||
| 404 | + | container, err := s.deployer.Deploy(deployCtx, domain.Route{ | |||
| 405 | + | Domain: req.Domain, | |||
| 406 | + | Image: imageRef, | |||
| 407 | + | HTTPS: req.HTTPS, | |||
| 408 | + | Env: baseEnv, | |||
| 409 | + | }) | |||
| 410 | + | if err != nil { | |||
| 411 | + | preview.Status = domain.PreviewStatusFailed | |||
| 412 | + | if updateErr := s.Update(ctx, preview); updateErr != nil { | |||
| 413 | + | log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status") | |||
| 414 | + | } | |||
| 415 | + | return fmt.Errorf("deploy preview %q: %w", req.Name, err) | |||
| 416 | + | } | |||
| 417 | + | preview.Containers = []string{container.Name} | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | preview.Status = domain.PreviewStatusRunning | |||
| 421 | + | if err := s.Update(ctx, preview); err != nil { | |||
| 422 | + | return fmt.Errorf("finalize preview %q: %w", req.Name, err) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | log.Info(). | |||
| 426 | + | Str("name", req.Name). | |||
| 427 | + | Str("domain", req.Domain). | |||
| 428 | + | Str("image", req.Image). | |||
| 429 | + | Str("base_route", req.BaseRoute). | |||
| 430 | + | Msg("preview environment created") | |||
| 431 | + | ||||
| 235 | return nil | 432 | return nil | ||
| 236 | } | 433 | } | ||
M internal/usecase/auto/preview/service_test.go +45 -12
| @@ -33,10 +33,10 @@ func TestPreviewService_Add(t *testing.T) { | |||
| 33 | 33 | BaseRoute: "myapp.example.com", | |
| 34 | 34 | Image: "myapp:preview-feat", | |
| 35 | 35 | } | |
| 36 | - | err := svc.Add(context.Background(), p) | |
| 36 | + | err := svc.Add(t.Context(), p) | |
| 37 | 37 | require.NoError(t, err) | |
| 38 | 38 | ||
| 39 | - | all, err := svc.List(context.Background()) | |
| 39 | + | all, err := svc.List(t.Context()) | |
| 40 | 40 | require.NoError(t, err) | |
| 41 | 41 | assert.Len(t, all, 1) | |
| 42 | 42 | assert.Equal(t, "feat", all[0].Name) | |
| @@ -49,12 +49,12 @@ func TestPreviewService_Delete(t *testing.T) { | |||
| 49 | 49 | }, | |
| 50 | 50 | } | |
| 51 | 51 | svc := NewService(store, 48*time.Hour) | |
| 52 | - | require.NoError(t, svc.Load(context.Background())) | |
| 52 | + | require.NoError(t, svc.Load(t.Context())) | |
| 53 | 53 | ||
| 54 | - | err := svc.Delete(context.Background(), "feat") | |
| 54 | + | err := svc.Delete(t.Context(), "feat") | |
| 55 | 55 | require.NoError(t, err) | |
| 56 | 56 | ||
| 57 | - | all, err := svc.List(context.Background()) | |
| 57 | + | all, err := svc.List(t.Context()) | |
| 58 | 58 | require.NoError(t, err) | |
| 59 | 59 | assert.Empty(t, all) | |
| 60 | 60 | } | |
| @@ -66,13 +66,13 @@ func TestPreviewService_Get(t *testing.T) { | |||
| 66 | 66 | }, | |
| 67 | 67 | } | |
| 68 | 68 | svc := NewService(store, 48*time.Hour) | |
| 69 | - | require.NoError(t, svc.Load(context.Background())) | |
| 69 | + | require.NoError(t, svc.Load(t.Context())) | |
| 70 | 70 | ||
| 71 | - | p, err := svc.Get(context.Background(), "feat") | |
| 71 | + | p, err := svc.Get(t.Context(), "feat") | |
| 72 | 72 | require.NoError(t, err) | |
| 73 | 73 | assert.Equal(t, "myapp--feat.example.com", p.Domain) | |
| 74 | 74 | ||
| 75 | - | _, err = svc.Get(context.Background(), "nonexistent") | |
| 75 | + | _, err = svc.Get(t.Context(), "nonexistent") | |
| 76 | 76 | assert.Error(t, err) | |
| 77 | 77 | } | |
| 78 | 78 | ||
| @@ -84,12 +84,13 @@ func TestPreviewService_Extend(t *testing.T) { | |||
| 84 | 84 | }, | |
| 85 | 85 | } | |
| 86 | 86 | svc := NewService(store, 48*time.Hour) | |
| 87 | - | require.NoError(t, svc.Load(context.Background())) | |
| 87 | + | require.NoError(t, svc.Load(t.Context())) | |
| 88 | 88 | ||
| 89 | - | err := svc.Extend(context.Background(), "feat", 24*time.Hour) | |
| 89 | + | err := svc.Extend(t.Context(), "feat", 24*time.Hour) | |
| 90 | 90 | require.NoError(t, err) | |
| 91 | 91 | ||
| 92 | - | p, _ := svc.Get(context.Background(), "feat") | |
| 92 | + | p, err := svc.Get(t.Context(), "feat") | |
| 93 | + | require.NoError(t, err) | |
| 93 | 94 | assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour))) | |
| 94 | 95 | } | |
| 95 | 96 | ||
| @@ -101,9 +102,41 @@ func TestPreviewService_GetExpired(t *testing.T) { | |||
| 101 | 102 | }, | |
| 102 | 103 | } | |
| 103 | 104 | svc := NewService(store, 48*time.Hour) | |
| 104 | - | require.NoError(t, svc.Load(context.Background())) | |
| 105 | + | require.NoError(t, svc.Load(t.Context())) | |
| 105 | 106 | ||
| 106 | 107 | expired := svc.GetExpired() | |
| 107 | 108 | assert.Len(t, expired, 1) | |
| 108 | 109 | assert.Equal(t, "expired", expired[0].Name) | |
| 109 | 110 | } | |
| 111 | + | ||
| 112 | + | func TestPreviewService_Update(t *testing.T) { | |
| 113 | + | store := &fakeStore{ | |
| 114 | + | previews: []domain.PreviewRoute{ | |
| 115 | + | {Name: "feat", Domain: "myapp--feat.example.com", Status: domain.PreviewStatusDeploying}, | |
| 116 | + | }, | |
| 117 | + | } | |
| 118 | + | svc := NewService(store, 48*time.Hour) | |
| 119 | + | require.NoError(t, svc.Load(t.Context())) | |
| 120 | + | ||
| 121 | + | updated := domain.PreviewRoute{ | |
| 122 | + | Name: "feat", | |
| 123 | + | Domain: "myapp--feat.example.com", | |
| 124 | + | Status: domain.PreviewStatusRunning, | |
| 125 | + | Containers: []string{"gordon-myapp--feat.example.com"}, | |
| 126 | + | } | |
| 127 | + | err := svc.Update(t.Context(), updated) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | ||
| 130 | + | p, err := svc.Get(t.Context(), "feat") | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | assert.Equal(t, domain.PreviewStatusRunning, p.Status) | |
| 133 | + | assert.Equal(t, []string{"gordon-myapp--feat.example.com"}, p.Containers) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestPreviewService_Update_NotFound(t *testing.T) { | |
| 137 | + | store := &fakeStore{} | |
| 138 | + | svc := NewService(store, 48*time.Hour) | |
| 139 | + | ||
| 140 | + | err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"}) | |
| 141 | + | assert.ErrorIs(t, err, domain.ErrPreviewNotFound) | |
| 142 | + | } | |
| @@ -33,10 +33,10 @@ func TestPreviewService_Add(t *testing.T) { | |||||
| 33 | BaseRoute: "myapp.example.com", | 33 | BaseRoute: "myapp.example.com", | ||
| 34 | Image: "myapp:preview-feat", | 34 | Image: "myapp:preview-feat", | ||
| 35 | } | 35 | } | ||
| 36 | - | err := svc.Add(context.Background(), p) | 36 | + | err := svc.Add(t.Context(), p) |
| 37 | require.NoError(t, err) | 37 | require.NoError(t, err) | ||
| 38 | 38 | ||||
| 39 | - | all, err := svc.List(context.Background()) | 39 | + | all, err := svc.List(t.Context()) |
| 40 | require.NoError(t, err) | 40 | require.NoError(t, err) | ||
| 41 | assert.Len(t, all, 1) | 41 | assert.Len(t, all, 1) | ||
| 42 | assert.Equal(t, "feat", all[0].Name) | 42 | assert.Equal(t, "feat", all[0].Name) | ||
| @@ -49,12 +49,12 @@ func TestPreviewService_Delete(t *testing.T) { | |||||
| 49 | }, | 49 | }, | ||
| 50 | } | 50 | } | ||
| 51 | svc := NewService(store, 48*time.Hour) | 51 | svc := NewService(store, 48*time.Hour) | ||
| 52 | - | require.NoError(t, svc.Load(context.Background())) | 52 | + | require.NoError(t, svc.Load(t.Context())) |
| 53 | 53 | ||||
| 54 | - | err := svc.Delete(context.Background(), "feat") | 54 | + | err := svc.Delete(t.Context(), "feat") |
| 55 | require.NoError(t, err) | 55 | require.NoError(t, err) | ||
| 56 | 56 | ||||
| 57 | - | all, err := svc.List(context.Background()) | 57 | + | all, err := svc.List(t.Context()) |
| 58 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 59 | assert.Empty(t, all) | 59 | assert.Empty(t, all) | ||
| 60 | } | 60 | } | ||
| @@ -66,13 +66,13 @@ func TestPreviewService_Get(t *testing.T) { | |||||
| 66 | }, | 66 | }, | ||
| 67 | } | 67 | } | ||
| 68 | svc := NewService(store, 48*time.Hour) | 68 | svc := NewService(store, 48*time.Hour) | ||
| 69 | - | require.NoError(t, svc.Load(context.Background())) | 69 | + | require.NoError(t, svc.Load(t.Context())) |
| 70 | 70 | ||||
| 71 | - | p, err := svc.Get(context.Background(), "feat") | 71 | + | p, err := svc.Get(t.Context(), "feat") |
| 72 | require.NoError(t, err) | 72 | require.NoError(t, err) | ||
| 73 | assert.Equal(t, "myapp--feat.example.com", p.Domain) | 73 | assert.Equal(t, "myapp--feat.example.com", p.Domain) | ||
| 74 | 74 | ||||
| 75 | - | _, err = svc.Get(context.Background(), "nonexistent") | 75 | + | _, err = svc.Get(t.Context(), "nonexistent") |
| 76 | assert.Error(t, err) | 76 | assert.Error(t, err) | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| @@ -84,12 +84,13 @@ func TestPreviewService_Extend(t *testing.T) { | |||||
| 84 | }, | 84 | }, | ||
| 85 | } | 85 | } | ||
| 86 | svc := NewService(store, 48*time.Hour) | 86 | svc := NewService(store, 48*time.Hour) | ||
| 87 | - | require.NoError(t, svc.Load(context.Background())) | 87 | + | require.NoError(t, svc.Load(t.Context())) |
| 88 | 88 | ||||
| 89 | - | err := svc.Extend(context.Background(), "feat", 24*time.Hour) | 89 | + | err := svc.Extend(t.Context(), "feat", 24*time.Hour) |
| 90 | require.NoError(t, err) | 90 | require.NoError(t, err) | ||
| 91 | 91 | ||||
| 92 | - | p, _ := svc.Get(context.Background(), "feat") | 92 | + | p, err := svc.Get(t.Context(), "feat") |
| 93 | + | require.NoError(t, err) | |||
| 93 | assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour))) | 94 | assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour))) | ||
| 94 | } | 95 | } | ||
| 95 | 96 | ||||
| @@ -101,9 +102,41 @@ func TestPreviewService_GetExpired(t *testing.T) { | |||||
| 101 | }, | 102 | }, | ||
| 102 | } | 103 | } | ||
| 103 | svc := NewService(store, 48*time.Hour) | 104 | svc := NewService(store, 48*time.Hour) | ||
| 104 | - | require.NoError(t, svc.Load(context.Background())) | 105 | + | require.NoError(t, svc.Load(t.Context())) |
| 105 | 106 | ||||
| 106 | expired := svc.GetExpired() | 107 | expired := svc.GetExpired() | ||
| 107 | assert.Len(t, expired, 1) | 108 | assert.Len(t, expired, 1) | ||
| 108 | assert.Equal(t, "expired", expired[0].Name) | 109 | assert.Equal(t, "expired", expired[0].Name) | ||
| 109 | } | 110 | } | ||
| 111 | + | ||||
| 112 | + | func TestPreviewService_Update(t *testing.T) { | |||
| 113 | + | store := &fakeStore{ | |||
| 114 | + | previews: []domain.PreviewRoute{ | |||
| 115 | + | {Name: "feat", Domain: "myapp--feat.example.com", Status: domain.PreviewStatusDeploying}, | |||
| 116 | + | }, | |||
| 117 | + | } | |||
| 118 | + | svc := NewService(store, 48*time.Hour) | |||
| 119 | + | require.NoError(t, svc.Load(t.Context())) | |||
| 120 | + | ||||
| 121 | + | updated := domain.PreviewRoute{ | |||
| 122 | + | Name: "feat", | |||
| 123 | + | Domain: "myapp--feat.example.com", | |||
| 124 | + | Status: domain.PreviewStatusRunning, | |||
| 125 | + | Containers: []string{"gordon-myapp--feat.example.com"}, | |||
| 126 | + | } | |||
| 127 | + | err := svc.Update(t.Context(), updated) | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | ||||
| 130 | + | p, err := svc.Get(t.Context(), "feat") | |||
| 131 | + | require.NoError(t, err) | |||
| 132 | + | assert.Equal(t, domain.PreviewStatusRunning, p.Status) | |||
| 133 | + | assert.Equal(t, []string{"gordon-myapp--feat.example.com"}, p.Containers) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestPreviewService_Update_NotFound(t *testing.T) { | |||
| 137 | + | store := &fakeStore{} | |||
| 138 | + | svc := NewService(store, 48*time.Hour) | |||
| 139 | + | ||||
| 140 | + | err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"}) | |||
| 141 | + | assert.ErrorIs(t, err, domain.ErrPreviewNotFound) | |||
| 142 | + | } | |||
M internal/usecase/auto/preview/ticker_test.go +3 -4
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package preview | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -19,13 +18,13 @@ func TestPreviewService_CleanupExpired(t *testing.T) { | |||
| 19 | 18 | }, | |
| 20 | 19 | } | |
| 21 | 20 | svc := NewService(store, 48*time.Hour) | |
| 22 | - | require.NoError(t, svc.Load(context.Background())) | |
| 21 | + | require.NoError(t, svc.Load(t.Context())) | |
| 23 | 22 | ||
| 24 | - | expired := svc.CleanupExpired(context.Background()) | |
| 23 | + | expired := svc.CleanupExpired(t.Context()) | |
| 25 | 24 | assert.Len(t, expired, 1) | |
| 26 | 25 | assert.Equal(t, "expired", expired[0].Name) | |
| 27 | 26 | ||
| 28 | - | all, err := svc.List(context.Background()) | |
| 27 | + | all, err := svc.List(t.Context()) | |
| 29 | 28 | require.NoError(t, err) | |
| 30 | 29 | assert.Len(t, all, 1) | |
| 31 | 30 | assert.Equal(t, "active", all[0].Name) | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package preview | 1 | package preview | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | "time" | 5 | "time" | ||
| 7 | 6 | ||||
| @@ -19,13 +18,13 @@ func TestPreviewService_CleanupExpired(t *testing.T) { | |||||
| 19 | }, | 18 | }, | ||
| 20 | } | 19 | } | ||
| 21 | svc := NewService(store, 48*time.Hour) | 20 | svc := NewService(store, 48*time.Hour) | ||
| 22 | - | require.NoError(t, svc.Load(context.Background())) | 21 | + | require.NoError(t, svc.Load(t.Context())) |
| 23 | 22 | ||||
| 24 | - | expired := svc.CleanupExpired(context.Background()) | 23 | + | expired := svc.CleanupExpired(t.Context()) |
| 25 | assert.Len(t, expired, 1) | 24 | assert.Len(t, expired, 1) | ||
| 26 | assert.Equal(t, "expired", expired[0].Name) | 25 | assert.Equal(t, "expired", expired[0].Name) | ||
| 27 | 26 | ||||
| 28 | - | all, err := svc.List(context.Background()) | 27 | + | all, err := svc.List(t.Context()) |
| 29 | require.NoError(t, err) | 28 | require.NoError(t, err) | ||
| 30 | assert.Len(t, all, 1) | 29 | assert.Len(t, all, 1) | ||
| 31 | assert.Equal(t, "active", all[0].Name) | 30 | assert.Equal(t, "active", all[0].Name) | ||
M internal/usecase/auto/preview/volumes.go +15 -4
| @@ -16,6 +16,17 @@ type VolumeCloner interface { | |||
| 16 | 16 | StartContainer(ctx context.Context, containerID string) error | |
| 17 | 17 | WaitForContainer(ctx context.Context, containerID string) error | |
| 18 | 18 | RemoveContainer(ctx context.Context, containerID string, force bool) error | |
| 19 | + | ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // VolumeNamer is a function that maps a source volume name to a destination volume name. | |
| 23 | + | type VolumeNamer func(sourceVolName string) string | |
| 24 | + | ||
| 25 | + | // DefaultNamer returns a VolumeNamer that uses BuildCloneVolumeName with the given preview name. | |
| 26 | + | func DefaultNamer(previewName string) VolumeNamer { | |
| 27 | + | return func(sourceVolName string) string { | |
| 28 | + | return BuildCloneVolumeName(previewName, sourceVolName) | |
| 29 | + | } | |
| 19 | 30 | } | |
| 20 | 31 | ||
| 21 | 32 | // BuildCloneVolumeName generates a preview volume name. | |
| @@ -36,11 +47,11 @@ func BuildCloneContainerName(previewName, image string) string { | |||
| 36 | 47 | } | |
| 37 | 48 | ||
| 38 | 49 | // CloneVolumes copies volumes from source using a read-only helper container. | |
| 39 | - | // sourceVolumes maps volume display names to actual Docker volume names. | |
| 40 | - | func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string, sourceVolumes map[string]string) ([]string, error) { | |
| 50 | + | // sourceVolumes is a list of actual Docker volume names to clone. | |
| 51 | + | func CloneVolumes(ctx context.Context, cloner VolumeCloner, namer VolumeNamer, sourceVolumes []string) ([]string, error) { | |
| 41 | 52 | var clonedNames []string | |
| 42 | - | for volName, sourceVolName := range sourceVolumes { | |
| 43 | - | destName := BuildCloneVolumeName(previewName, volName) | |
| 53 | + | for _, sourceVolName := range sourceVolumes { | |
| 54 | + | destName := namer(sourceVolName) | |
| 44 | 55 | ||
| 45 | 56 | if err := cloner.CreateVolume(ctx, destName); err != nil { | |
| 46 | 57 | cleanupVolumes(ctx, cloner, clonedNames) | |
| @@ -16,6 +16,17 @@ type VolumeCloner interface { | |||||
| 16 | StartContainer(ctx context.Context, containerID string) error | 16 | StartContainer(ctx context.Context, containerID string) error | ||
| 17 | WaitForContainer(ctx context.Context, containerID string) error | 17 | WaitForContainer(ctx context.Context, containerID string) error | ||
| 18 | RemoveContainer(ctx context.Context, containerID string, force bool) error | 18 | RemoveContainer(ctx context.Context, containerID string, force bool) error | ||
| 19 | + | ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // VolumeNamer is a function that maps a source volume name to a destination volume name. | |||
| 23 | + | type VolumeNamer func(sourceVolName string) string | |||
| 24 | + | ||||
| 25 | + | // DefaultNamer returns a VolumeNamer that uses BuildCloneVolumeName with the given preview name. | |||
| 26 | + | func DefaultNamer(previewName string) VolumeNamer { | |||
| 27 | + | return func(sourceVolName string) string { | |||
| 28 | + | return BuildCloneVolumeName(previewName, sourceVolName) | |||
| 29 | + | } | |||
| 19 | } | 30 | } | ||
| 20 | 31 | ||||
| 21 | // BuildCloneVolumeName generates a preview volume name. | 32 | // BuildCloneVolumeName generates a preview volume name. | ||
| @@ -36,11 +47,11 @@ func BuildCloneContainerName(previewName, image string) string { | |||||
| 36 | } | 47 | } | ||
| 37 | 48 | ||||
| 38 | // CloneVolumes copies volumes from source using a read-only helper container. | 49 | // CloneVolumes copies volumes from source using a read-only helper container. | ||
| 39 | - | // sourceVolumes maps volume display names to actual Docker volume names. | 50 | + | // sourceVolumes is a list of actual Docker volume names to clone. |
| 40 | - | func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string, sourceVolumes map[string]string) ([]string, error) { | 51 | + | func CloneVolumes(ctx context.Context, cloner VolumeCloner, namer VolumeNamer, sourceVolumes []string) ([]string, error) { |
| 41 | var clonedNames []string | 52 | var clonedNames []string | ||
| 42 | - | for volName, sourceVolName := range sourceVolumes { | 53 | + | for _, sourceVolName := range sourceVolumes { |
| 43 | - | destName := BuildCloneVolumeName(previewName, volName) | 54 | + | destName := namer(sourceVolName) |
| 44 | 55 | ||||
| 45 | if err := cloner.CreateVolume(ctx, destName); err != nil { | 56 | if err := cloner.CreateVolume(ctx, destName); err != nil { | ||
| 46 | cleanupVolumes(ctx, cloner, clonedNames) | 57 | cleanupVolumes(ctx, cloner, clonedNames) | ||
M internal/usecase/config/service.go +3 -0
| @@ -38,6 +38,7 @@ type Config struct { | |||
| 38 | 38 | PreviewSeparator string | |
| 39 | 39 | PreviewTagPatterns []string | |
| 40 | 40 | PreviewDataCopy bool | |
| 41 | + | PreviewEnvCopy bool | |
| 41 | 42 | NetworkIsolation bool | |
| 42 | 43 | NetworkPrefix string | |
| 43 | 44 | Routes map[string]string | |
| @@ -164,6 +165,7 @@ func (s *Service) loadConfigValues() Config { | |||
| 164 | 165 | PreviewSeparator: previewSep, | |
| 165 | 166 | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | |
| 166 | 167 | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | |
| 168 | + | PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"), | |
| 167 | 169 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | |
| 168 | 170 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | |
| 169 | 171 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | |
| @@ -1104,6 +1106,7 @@ func (s *Service) GetPreviewConfig() domain.PreviewConfig { | |||
| 1104 | 1106 | Separator: s.config.PreviewSeparator, | |
| 1105 | 1107 | TagPatterns: s.config.PreviewTagPatterns, | |
| 1106 | 1108 | DataCopy: s.config.PreviewDataCopy, | |
| 1109 | + | EnvCopy: s.config.PreviewEnvCopy, | |
| 1107 | 1110 | } | |
| 1108 | 1111 | } | |
| 1109 | 1112 | ||
| @@ -38,6 +38,7 @@ type Config struct { | |||||
| 38 | PreviewSeparator string | 38 | PreviewSeparator string | ||
| 39 | PreviewTagPatterns []string | 39 | PreviewTagPatterns []string | ||
| 40 | PreviewDataCopy bool | 40 | PreviewDataCopy bool | ||
| 41 | + | PreviewEnvCopy bool | |||
| 41 | NetworkIsolation bool | 42 | NetworkIsolation bool | ||
| 42 | NetworkPrefix string | 43 | NetworkPrefix string | ||
| 43 | Routes map[string]string | 44 | Routes map[string]string | ||
| @@ -164,6 +165,7 @@ func (s *Service) loadConfigValues() Config { | |||||
| 164 | PreviewSeparator: previewSep, | 165 | PreviewSeparator: previewSep, | ||
| 165 | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | 166 | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | ||
| 166 | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | 167 | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | ||
| 168 | + | PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"), | |||
| 167 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | 169 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | ||
| 168 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | 170 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | ||
| 169 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | 171 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | ||
| @@ -1104,6 +1106,7 @@ func (s *Service) GetPreviewConfig() domain.PreviewConfig { | |||||
| 1104 | Separator: s.config.PreviewSeparator, | 1106 | Separator: s.config.PreviewSeparator, | ||
| 1105 | TagPatterns: s.config.PreviewTagPatterns, | 1107 | TagPatterns: s.config.PreviewTagPatterns, | ||
| 1106 | DataCopy: s.config.PreviewDataCopy, | 1108 | DataCopy: s.config.PreviewDataCopy, | ||
| 1109 | + | EnvCopy: s.config.PreviewEnvCopy, | |||
| 1107 | } | 1110 | } | ||
| 1108 | } | 1111 | } | ||
| 1109 | 1112 | ||||
M internal/usecase/container/service.go +13 -7
| @@ -568,7 +568,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||
| 568 | 568 | imageLabels = nil | |
| 569 | 569 | } | |
| 570 | 570 | ||
| 571 | - | envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef) | |
| 571 | + | envVars, err := s.loadEnvironment(ctx, route.Env, route.Domain, actualImageRef) | |
| 572 | 572 | if err != nil { | |
| 573 | 573 | return nil, err | |
| 574 | 574 | } | |
| @@ -1774,12 +1774,18 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str | |||
| 1774 | 1774 | return nil | |
| 1775 | 1775 | } | |
| 1776 | 1776 | ||
| 1777 | - | func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) { | |
| 1777 | + | func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, domainName, imageRef string) ([]string, error) { | |
| 1778 | 1778 | log := zerowrap.FromCtx(ctx) | |
| 1779 | 1779 | ||
| 1780 | - | userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName) | |
| 1781 | - | if err != nil { | |
| 1782 | - | return nil, log.WrapErr(err, "failed to load environment variables") | |
| 1780 | + | var userEnvVars []string | |
| 1781 | + | if len(preResolved) > 0 { | |
| 1782 | + | userEnvVars = preResolved | |
| 1783 | + | } else { | |
| 1784 | + | var err error | |
| 1785 | + | userEnvVars, err = s.envLoader.LoadEnv(ctx, domainName) | |
| 1786 | + | if err != nil { | |
| 1787 | + | return nil, log.WrapErr(err, "failed to load environment variables") | |
| 1788 | + | } | |
| 1783 | 1789 | } | |
| 1784 | 1790 | ||
| 1785 | 1791 | dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef) | |
| @@ -2219,7 +2225,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2219 | 2225 | } | |
| 2220 | 2226 | ||
| 2221 | 2227 | // Load environment (attachment-specific env file) | |
| 2222 | - | envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef) | |
| 2228 | + | envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef) | |
| 2223 | 2229 | if err != nil { | |
| 2224 | 2230 | log.WrapErr(err, "failed to load environment for attachment") | |
| 2225 | 2231 | envVars = []string{} | |
| @@ -2268,7 +2274,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2268 | 2274 | } | |
| 2269 | 2275 | ||
| 2270 | 2276 | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |
| 2271 | - | currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage)) | |
| 2277 | + | currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage)) | |
| 2272 | 2278 | if err != nil { | |
| 2273 | 2279 | return false, err | |
| 2274 | 2280 | } | |
| @@ -568,7 +568,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||||
| 568 | imageLabels = nil | 568 | imageLabels = nil | ||
| 569 | } | 569 | } | ||
| 570 | 570 | ||||
| 571 | - | envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef) | 571 | + | envVars, err := s.loadEnvironment(ctx, route.Env, route.Domain, actualImageRef) |
| 572 | if err != nil { | 572 | if err != nil { | ||
| 573 | return nil, err | 573 | return nil, err | ||
| 574 | } | 574 | } | ||
| @@ -1774,12 +1774,18 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str | |||||
| 1774 | return nil | 1774 | return nil | ||
| 1775 | } | 1775 | } | ||
| 1776 | 1776 | ||||
| 1777 | - | func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) { | 1777 | + | func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, domainName, imageRef string) ([]string, error) { |
| 1778 | log := zerowrap.FromCtx(ctx) | 1778 | log := zerowrap.FromCtx(ctx) | ||
| 1779 | 1779 | ||||
| 1780 | - | userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName) | 1780 | + | var userEnvVars []string |
| 1781 | - | if err != nil { | 1781 | + | if len(preResolved) > 0 { |
| 1782 | - | return nil, log.WrapErr(err, "failed to load environment variables") | 1782 | + | userEnvVars = preResolved |
| 1783 | + | } else { | |||
| 1784 | + | var err error | |||
| 1785 | + | userEnvVars, err = s.envLoader.LoadEnv(ctx, domainName) | |||
| 1786 | + | if err != nil { | |||
| 1787 | + | return nil, log.WrapErr(err, "failed to load environment variables") | |||
| 1788 | + | } | |||
| 1783 | } | 1789 | } | ||
| 1784 | 1790 | ||||
| 1785 | dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef) | 1791 | dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef) | ||
| @@ -2219,7 +2225,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2219 | } | 2225 | } | ||
| 2220 | 2226 | ||||
| 2221 | // Load environment (attachment-specific env file) | 2227 | // Load environment (attachment-specific env file) | ||
| 2222 | - | envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef) | 2228 | + | envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef) |
| 2223 | if err != nil { | 2229 | if err != nil { | ||
| 2224 | log.WrapErr(err, "failed to load environment for attachment") | 2230 | log.WrapErr(err, "failed to load environment for attachment") | ||
| 2225 | envVars = []string{} | 2231 | envVars = []string{} | ||
| @@ -2268,7 +2274,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2268 | } | 2274 | } | ||
| 2269 | 2275 | ||||
| 2270 | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | 2276 | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | ||
| 2271 | - | currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage)) | 2277 | + | currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage)) |
| 2272 | if err != nil { | 2278 | if err != nil { | ||
| 2273 | return false, err | 2279 | return false, err | ||
| 2274 | } | 2280 | } | ||
M internal/usecase/container/service_test.go +41 -0
| @@ -3794,3 +3794,44 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) { | |||
| 3794 | 3794 | assert.Equal(t, "c-gitea-123", result.ID) | |
| 3795 | 3795 | assert.Equal(t, "running", result.Status) | |
| 3796 | 3796 | } | |
| 3797 | + | ||
| 3798 | + | func TestLoadEnvironment_PreResolvedEnv(t *testing.T) { | |
| 3799 | + | ctx := testContext() | |
| 3800 | + | ||
| 3801 | + | mockEnvLoader := mocks.NewMockEnvLoader(t) | |
| 3802 | + | mockRuntime := mocks.NewMockContainerRuntime(t) | |
| 3803 | + | ||
| 3804 | + | // EnvLoader should NOT be called when pre-resolved env is provided | |
| 3805 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest"). | |
| 3806 | + | Return([]string{"FROM_DOCKERFILE=base"}, nil) | |
| 3807 | + | ||
| 3808 | + | svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |
| 3809 | + | ||
| 3810 | + | preResolved := []string{"DB_HOST=localhost", "API_KEY=secret"} | |
| 3811 | + | result, err := svc.loadEnvironment(ctx, preResolved, "ignored-domain", "myapp:latest") | |
| 3812 | + | require.NoError(t, err) | |
| 3813 | + | ||
| 3814 | + | assert.Contains(t, result, "DB_HOST=localhost") | |
| 3815 | + | assert.Contains(t, result, "API_KEY=secret") | |
| 3816 | + | assert.Contains(t, result, "FROM_DOCKERFILE=base") | |
| 3817 | + | } | |
| 3818 | + | ||
| 3819 | + | func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) { | |
| 3820 | + | ctx := testContext() | |
| 3821 | + | ||
| 3822 | + | mockEnvLoader := mocks.NewMockEnvLoader(t) | |
| 3823 | + | mockRuntime := mocks.NewMockContainerRuntime(t) | |
| 3824 | + | ||
| 3825 | + | mockEnvLoader.EXPECT().LoadEnv(ctx, "myapp.example.com"). | |
| 3826 | + | Return([]string{"DB_HOST=prod"}, nil) | |
| 3827 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest"). | |
| 3828 | + | Return([]string{"FROM_DOCKERFILE=base"}, nil) | |
| 3829 | + | ||
| 3830 | + | svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |
| 3831 | + | ||
| 3832 | + | result, err := svc.loadEnvironment(ctx, nil, "myapp.example.com", "myapp:latest") | |
| 3833 | + | require.NoError(t, err) | |
| 3834 | + | ||
| 3835 | + | assert.Contains(t, result, "DB_HOST=prod") | |
| 3836 | + | assert.Contains(t, result, "FROM_DOCKERFILE=base") | |
| 3837 | + | } | |
| @@ -3794,3 +3794,44 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) { | |||||
| 3794 | assert.Equal(t, "c-gitea-123", result.ID) | 3794 | assert.Equal(t, "c-gitea-123", result.ID) | ||
| 3795 | assert.Equal(t, "running", result.Status) | 3795 | assert.Equal(t, "running", result.Status) | ||
| 3796 | } | 3796 | } | ||
| 3797 | + | ||||
| 3798 | + | func TestLoadEnvironment_PreResolvedEnv(t *testing.T) { | |||
| 3799 | + | ctx := testContext() | |||
| 3800 | + | ||||
| 3801 | + | mockEnvLoader := mocks.NewMockEnvLoader(t) | |||
| 3802 | + | mockRuntime := mocks.NewMockContainerRuntime(t) | |||
| 3803 | + | ||||
| 3804 | + | // EnvLoader should NOT be called when pre-resolved env is provided | |||
| 3805 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest"). | |||
| 3806 | + | Return([]string{"FROM_DOCKERFILE=base"}, nil) | |||
| 3807 | + | ||||
| 3808 | + | svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |||
| 3809 | + | ||||
| 3810 | + | preResolved := []string{"DB_HOST=localhost", "API_KEY=secret"} | |||
| 3811 | + | result, err := svc.loadEnvironment(ctx, preResolved, "ignored-domain", "myapp:latest") | |||
| 3812 | + | require.NoError(t, err) | |||
| 3813 | + | ||||
| 3814 | + | assert.Contains(t, result, "DB_HOST=localhost") | |||
| 3815 | + | assert.Contains(t, result, "API_KEY=secret") | |||
| 3816 | + | assert.Contains(t, result, "FROM_DOCKERFILE=base") | |||
| 3817 | + | } | |||
| 3818 | + | ||||
| 3819 | + | func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) { | |||
| 3820 | + | ctx := testContext() | |||
| 3821 | + | ||||
| 3822 | + | mockEnvLoader := mocks.NewMockEnvLoader(t) | |||
| 3823 | + | mockRuntime := mocks.NewMockContainerRuntime(t) | |||
| 3824 | + | ||||
| 3825 | + | mockEnvLoader.EXPECT().LoadEnv(ctx, "myapp.example.com"). | |||
| 3826 | + | Return([]string{"DB_HOST=prod"}, nil) | |||
| 3827 | + | mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest"). | |||
| 3828 | + | Return([]string{"FROM_DOCKERFILE=base"}, nil) | |||
| 3829 | + | ||||
| 3830 | + | svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |||
| 3831 | + | ||||
| 3832 | + | result, err := svc.loadEnvironment(ctx, nil, "myapp.example.com", "myapp:latest") | |||
| 3833 | + | require.NoError(t, err) | |||
| 3834 | + | ||||
| 3835 | + | assert.Contains(t, result, "DB_HOST=prod") | |||
| 3836 | + | assert.Contains(t, result, "FROM_DOCKERFILE=base") | |||
| 3837 | + | } | |||