feat(preview): inherit base route env vars at deploy time
Wire out.EnvLoader into the preview Service so CreatePreview loads env vars from the base route and passes them through Route.Env to Deploy, without writing secrets to the persisted route config.
- commit
- 1e74e4b6d73b38541a1304ad61d2390bfeca4b62
- author
- brice · · 2026-03-18 18:31 UTC
- committer
- brice · · 2026-03-18 18:57 UTC
- parent
- c2b0666
- signature
- unsigned
2 files · +74 -0
M internal/usecase/auto/preview/integration_test.go +58 -0
| @@ -132,6 +132,64 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) { | |||
| 132 | 132 | assert.Empty(t, all) | |
| 133 | 133 | } | |
| 134 | 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 | + | }, | |
| 186 | + | }) | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | ||
| 189 | + | // Deployer should receive base route's env vars via Route.Env | |
| 190 | + | assert.Equal(t, []string{"DB_HOST=prod-db", "API_KEY=secret123"}, deployedRoute.Env) | |
| 191 | + | } | |
| 192 | + | ||
| 135 | 193 | func TestPreviewLifecycle_WithDeploy(t *testing.T) { | |
| 136 | 194 | store := &fakeStore{} | |
| 137 | 195 | deployed := false | |
| @@ -132,6 +132,64 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) { | |||||
| 132 | assert.Empty(t, all) | 132 | assert.Empty(t, all) | ||
| 133 | } | 133 | } | ||
| 134 | 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 | + | }, | |||
| 186 | + | }) | |||
| 187 | + | require.NoError(t, err) | |||
| 188 | + | ||||
| 189 | + | // Deployer should receive base route's env vars via Route.Env | |||
| 190 | + | assert.Equal(t, []string{"DB_HOST=prod-db", "API_KEY=secret123"}, deployedRoute.Env) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 135 | func TestPreviewLifecycle_WithDeploy(t *testing.T) { | 193 | func TestPreviewLifecycle_WithDeploy(t *testing.T) { | ||
| 136 | store := &fakeStore{} | 194 | store := &fakeStore{} | ||
| 137 | deployed := false | 195 | deployed := false | ||
M internal/usecase/auto/preview/service.go +16 -0
| @@ -38,6 +38,7 @@ type Service struct { | |||
| 38 | 38 | deployer Deployer | |
| 39 | 39 | routeManager RouteManager | |
| 40 | 40 | volumeCloner VolumeCloner | |
| 41 | + | envLoader out.EnvLoader | |
| 41 | 42 | registryDomain string | |
| 42 | 43 | previews []domain.PreviewRoute | |
| 43 | 44 | mu sync.RWMutex | |
| @@ -73,6 +74,11 @@ func (s *Service) WithRegistryDomain(d string) *Service { | |||
| 73 | 74 | return s | |
| 74 | 75 | } | |
| 75 | 76 | ||
| 77 | + | func (s *Service) WithEnvLoader(el out.EnvLoader) *Service { | |
| 78 | + | s.envLoader = el | |
| 79 | + | return s | |
| 80 | + | } | |
| 81 | + | ||
| 76 | 82 | func (s *Service) Load(ctx context.Context) error { | |
| 77 | 83 | previews, err := s.store.Load(ctx) | |
| 78 | 84 | if err != nil { | |
| @@ -367,6 +373,15 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||
| 367 | 373 | } | |
| 368 | 374 | } | |
| 369 | 375 | ||
| 376 | + | var baseEnv []string | |
| 377 | + | if s.envLoader != nil { | |
| 378 | + | var envErr error | |
| 379 | + | baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute) | |
| 380 | + | if envErr != nil { | |
| 381 | + | log.Warn().Err(envErr).Str("base_route", req.BaseRoute).Msg("failed to load base route env, deploying without env vars") | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 370 | 385 | if s.deployer != nil { | |
| 371 | 386 | imageRef := s.qualifyImage(req.Image) | |
| 372 | 387 | deployCtx := domain.WithInternalDeploy(ctx) | |
| @@ -374,6 +389,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||
| 374 | 389 | Domain: req.Domain, | |
| 375 | 390 | Image: imageRef, | |
| 376 | 391 | HTTPS: req.HTTPS, | |
| 392 | + | Env: baseEnv, | |
| 377 | 393 | }) | |
| 378 | 394 | if err != nil { | |
| 379 | 395 | preview.Status = domain.PreviewStatusFailed | |
| @@ -38,6 +38,7 @@ type Service struct { | |||||
| 38 | deployer Deployer | 38 | deployer Deployer | ||
| 39 | routeManager RouteManager | 39 | routeManager RouteManager | ||
| 40 | volumeCloner VolumeCloner | 40 | volumeCloner VolumeCloner | ||
| 41 | + | envLoader out.EnvLoader | |||
| 41 | registryDomain string | 42 | registryDomain string | ||
| 42 | previews []domain.PreviewRoute | 43 | previews []domain.PreviewRoute | ||
| 43 | mu sync.RWMutex | 44 | mu sync.RWMutex | ||
| @@ -73,6 +74,11 @@ func (s *Service) WithRegistryDomain(d string) *Service { | |||||
| 73 | return s | 74 | return s | ||
| 74 | } | 75 | } | ||
| 75 | 76 | ||||
| 77 | + | func (s *Service) WithEnvLoader(el out.EnvLoader) *Service { | |||
| 78 | + | s.envLoader = el | |||
| 79 | + | return s | |||
| 80 | + | } | |||
| 81 | + | ||||
| 76 | func (s *Service) Load(ctx context.Context) error { | 82 | func (s *Service) Load(ctx context.Context) error { | ||
| 77 | previews, err := s.store.Load(ctx) | 83 | previews, err := s.store.Load(ctx) | ||
| 78 | if err != nil { | 84 | if err != nil { | ||
| @@ -367,6 +373,15 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||||
| 367 | } | 373 | } | ||
| 368 | } | 374 | } | ||
| 369 | 375 | ||||
| 376 | + | var baseEnv []string | |||
| 377 | + | if s.envLoader != nil { | |||
| 378 | + | var envErr error | |||
| 379 | + | baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute) | |||
| 380 | + | if envErr != nil { | |||
| 381 | + | log.Warn().Err(envErr).Str("base_route", req.BaseRoute).Msg("failed to load base route env, deploying without env vars") | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | ||||
| 370 | if s.deployer != nil { | 385 | if s.deployer != nil { | ||
| 371 | imageRef := s.qualifyImage(req.Image) | 386 | imageRef := s.qualifyImage(req.Image) | ||
| 372 | deployCtx := domain.WithInternalDeploy(ctx) | 387 | deployCtx := domain.WithInternalDeploy(ctx) | ||
| @@ -374,6 +389,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||||
| 374 | Domain: req.Domain, | 389 | Domain: req.Domain, | ||
| 375 | Image: imageRef, | 390 | Image: imageRef, | ||
| 376 | HTTPS: req.HTTPS, | 391 | HTTPS: req.HTTPS, | ||
| 392 | + | Env: baseEnv, | |||
| 377 | }) | 393 | }) | ||
| 378 | if err != nil { | 394 | if err != nil { | ||
| 379 | preview.Status = domain.PreviewStatusFailed | 395 | preview.Status = domain.PreviewStatusFailed | ||