detached 1e74e4b

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) {
132132 assert.Empty(t, all)
133133 }
134134
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+
135193 func TestPreviewLifecycle_WithDeploy(t *testing.T) {
136194 store := &fakeStore{}
137195 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 := false195 deployed := false
M internal/usecase/auto/preview/service.go +16 -0
@@ -38,6 +38,7 @@ type Service struct {
3838 deployer Deployer
3939 routeManager RouteManager
4040 volumeCloner VolumeCloner
41+ envLoader out.EnvLoader
4142 registryDomain string
4243 previews []domain.PreviewRoute
4344 mu sync.RWMutex
@@ -73,6 +74,11 @@ func (s *Service) WithRegistryDomain(d string) *Service {
7374 return s
7475 }
7576
77+func (s *Service) WithEnvLoader(el out.EnvLoader) *Service {
78+ s.envLoader = el
79+ return s
80+}
81+
7682 func (s *Service) Load(ctx context.Context) error {
7783 previews, err := s.store.Load(ctx)
7884 if err != nil {
@@ -367,6 +373,15 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
367373 }
368374 }
369375
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+
370385 if s.deployer != nil {
371386 imageRef := s.qualifyImage(req.Image)
372387 deployCtx := domain.WithInternalDeploy(ctx)
@@ -374,6 +389,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
374389 Domain: req.Domain,
375390 Image: imageRef,
376391 HTTPS: req.HTTPS,
392+ Env: baseEnv,
377393 })
378394 if err != nil {
379395 preview.Status = domain.PreviewStatusFailed
@@ -38,6 +38,7 @@ type Service struct {
38 deployer Deployer38 deployer Deployer
39 routeManager RouteManager39 routeManager RouteManager
40 volumeCloner VolumeCloner40 volumeCloner VolumeCloner
41+ envLoader out.EnvLoader
41 registryDomain string42 registryDomain string
42 previews []domain.PreviewRoute43 previews []domain.PreviewRoute
43 mu sync.RWMutex44 mu sync.RWMutex
@@ -73,6 +74,11 @@ func (s *Service) WithRegistryDomain(d string) *Service {
73 return s74 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.PreviewStatusFailed395 preview.Status = domain.PreviewStatusFailed

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close