From d49e50b284153b7af6aab56199df0b02a67aeeec Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 23 Aug 2026 18:34:15 -0400 Subject: [PATCH] fix: stage runtime-access.sql at the recipe context root (#68) The build recipe rendered runtime-access.sql into builder/, but the CLI treats the Dockerfile's builder/ directory as recipe metadata and drops it from the staged build context, so `COPY builder/runtime-access.sql` failed to resolve. Render it at the context root beside migrations/ (which the CLI does stage) and COPY it from there. Co-Authored-By: Claude Opus 4.8 --- bootstrap_template_test.go | 22 +++++++++++-------- build_test.go | 7 +++++- builder.go | 21 ++++++++++++++++++ templates/builder/Dockerfile.tmpl | 2 +- .../runtime-access.sql.tmpl | 0 5 files changed, 41 insertions(+), 11 deletions(-) rename templates/{builder => runtime}/runtime-access.sql.tmpl (100%) diff --git a/bootstrap_template_test.go b/bootstrap_template_test.go index 5733803..72c885d 100644 --- a/bootstrap_template_test.go +++ b/bootstrap_template_test.go @@ -61,7 +61,7 @@ func TestBootstrapImageAlwaysReconcilesRuntimeAccess(t *testing.T) { t.Fatalf("migration command present = %t, want %t", hasMigration, test.withMigrations) } - accessSQL := renderBuilderTemplate(t, "templates/builder/runtime-access.sql.tmpl", parameters) + accessSQL := renderRuntimeAccessTemplate(t, parameters) for _, required := range []string{ "NOBYPASSRLS", "NOCREATEROLE", @@ -100,11 +100,7 @@ func TestBootstrapImageBuildsWhenDockerOmitsTargetArchitecture(t *testing.T) { ); err != nil { t.Fatal(err) } - builderDir := filepath.Join(root, "builder") - if err := os.MkdirAll(builderDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(builderDir, "runtime-access.sql"), []byte("SELECT 1;\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, "runtime-access.sql"), []byte("SELECT 1;\n"), 0o644); err != nil { t.Fatal(err) } tag := fmt.Sprintf("service-postgres-bootstrap-targetarch-test:%d", time.Now().UnixNano()) @@ -126,7 +122,7 @@ func TestRuntimeAccessTemplateUsesDelegatedRolesAsExclusiveWriteAuthority(t *tes ReadWriteRoles: []string{"app_tenant", "app_worker"}, } - accessSQL := renderBuilderTemplate(t, "templates/builder/runtime-access.sql.tmpl", parameters) + accessSQL := renderRuntimeAccessTemplate(t, parameters) for _, forbidden := range []string{ "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES", "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES", @@ -158,7 +154,7 @@ func TestRuntimeAccessTemplatePreservesDirectWriterWithoutDelegatedRoles(t *test Schemas: []string{"public"}, } - accessSQL := renderBuilderTemplate(t, "templates/builder/runtime-access.sql.tmpl", parameters) + accessSQL := renderRuntimeAccessTemplate(t, parameters) for _, required := range []string{ "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES", "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES", @@ -172,8 +168,16 @@ func TestRuntimeAccessTemplatePreservesDirectWriterWithoutDelegatedRoles(t *test } func renderBuilderTemplate(t *testing.T, name string, parameters DockerTemplating) string { + return renderTemplate(t, builderFS, name, parameters) +} + +func renderRuntimeAccessTemplate(t *testing.T, parameters DockerTemplating) string { + return renderTemplate(t, runtimeFS, "templates/runtime/runtime-access.sql.tmpl", parameters) +} + +func renderTemplate(t *testing.T, fsys fs.FS, name string, parameters DockerTemplating) string { t.Helper() - source, err := fs.ReadFile(builderFS, name) + source, err := fs.ReadFile(fsys, name) if err != nil { t.Fatal(err) } diff --git a/build_test.go b/build_test.go index 4200a3c..6907c6e 100644 --- a/build_test.go +++ b/build_test.go @@ -79,10 +79,15 @@ func TestBuildEmitsRecipeToOutputDirectory(t *testing.T) { // The rendered tree is a self-contained context: a consumer with no codefly // toolchain can build it from the emitted files alone. require.FileExists(t, filepath.Join(outputDirectory, "builder", "Dockerfile")) - require.FileExists(t, filepath.Join(outputDirectory, "builder", "runtime-access.sql")) require.FileExists(t, filepath.Join(outputDirectory, "migrations", "1_create_table.up.sql")) require.FileExists(t, filepath.Join(outputDirectory, "migrations", "1_create_table.down.sql")) + // runtime-access.sql the Dockerfile COPYs must sit at the context root beside + // migrations/, not inside the builder/ recipe-metadata directory the CLI drops + // from the staged build context — otherwise the COPY fails to resolve. + require.FileExists(t, filepath.Join(outputDirectory, "runtime-access.sql")) + require.NoFileExists(t, filepath.Join(outputDirectory, "builder", "runtime-access.sql")) + // The plan the agent emits verifies against the tree it wrote, so the CLI // builds it without the recipe drifting from the inventory. require.NoError(t, services.VerifyDockerBuildPlan(outputDirectory, plan)) diff --git a/builder.go b/builder.go index 08d4476..5d70aca 100644 --- a/builder.go +++ b/builder.go @@ -166,6 +166,10 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil return s.Builder.BuildError(err) } + if err = s.renderRuntimeAccess(ctx, docker, s.Location); err != nil { + return s.Builder.BuildError(err) + } + builder, err := dockerhelpers.NewBuilder(dockerhelpers.BuilderConfiguration{ Root: s.Location, Dockerfile: "builder/Dockerfile", @@ -204,6 +208,13 @@ func (s *Builder) buildRecipe(ctx context.Context, outputDirectory string, img * return s.Builder.BuildError(err) } + // runtime-access.sql is a build-context file the Dockerfile COPYs, so it lives + // at the context root beside migrations/ — not inside builder/, which the CLI + // treats as recipe metadata and drops from the staged build context. + if err := s.renderRuntimeAccess(ctx, docker, outputDirectory); err != nil { + return s.Builder.BuildError(err) + } + if s.WithMigration() { // The Dockerfile's COPY migrations needs the directory to exist even when // no migrations are authored yet, so create it before copying rather than @@ -232,6 +243,13 @@ func (s *Builder) buildRecipe(ctx context.Context, outputDirectory string, img * return s.Builder.BuildResponse() } +// renderRuntimeAccess renders runtime-access.sql into the build context root, the +// directory docker builds from. The Dockerfile COPYs it from there, so it must +// sit beside migrations/ rather than under builder/. +func (s *Builder) renderRuntimeAccess(ctx context.Context, docker DockerTemplating, contextRoot string) error { + return s.Templates(ctx, docker, services.WithTemplate(runtimeFS, "runtime", "").WithDestination("%s", contextRoot)) +} + // copyTree copies every regular file under from into to, preserving the relative // layout. Directory entries themselves are not created here; the caller // provisions any directory a build step requires to exist. @@ -529,5 +547,8 @@ var factoryFS embed.FS //go:embed templates/builder var builderFS embed.FS +//go:embed templates/runtime +var runtimeFS embed.FS + //go:embed templates/deployment var deploymentFS embed.FS diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index 7ceb9e0..4624c4d 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -20,7 +20,7 @@ COPY . . {{- if .WithMigration }} COPY migrations /app/migrations {{- end }} -COPY builder/runtime-access.sql /app/runtime-access.sql +COPY runtime-access.sql /app/runtime-access.sql CMD set -eu; \ until pg_isready -d "${{.MigrationConnectionKeyHolder}}" >/dev/null 2>&1; do sleep 2; done; \ diff --git a/templates/builder/runtime-access.sql.tmpl b/templates/runtime/runtime-access.sql.tmpl similarity index 100% rename from templates/builder/runtime-access.sql.tmpl rename to templates/runtime/runtime-access.sql.tmpl