From 1a217b95a35342042ff7105213bd3b6a13687c18 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:41:07 +1000 Subject: [PATCH 1/2] Rebuild when Dockerfile path changes --- agent/internal/build/build.go | 90 ++++++++++++++----- agent/internal/build/build_test.go | 82 +++++++++++++++++ .../details/pending-changes-banner.tsx | 14 +-- web/lib/service-config.ts | 13 +++ web/tests/service-config.test.ts | 35 ++++++++ 5 files changed, 206 insertions(+), 28 deletions(-) diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go index a8f3efbb..4d58e36b 100644 --- a/agent/internal/build/build.go +++ b/agent/internal/build/build.go @@ -48,8 +48,15 @@ type Builder struct { const ( staleBuildDirAge = 1 * time.Hour staleTempArtifactAge = 24 * time.Hour + dockerfilePathKey = "TECHULUS_DOCKERFILE_PATH" ) +type dockerfileConfig struct { + directory string + filename string + found bool +} + var managedTempArtifactPattern = regexp.MustCompile(`^(backup|restore)-[0-9a-fA-F-]{36}\.tar\.gz$|^restore-extract-[0-9a-fA-F-]{36}$`) func NewBuilder(dataDir string, logSender LogSender) *Builder { @@ -226,23 +233,9 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str b.sendLog(config, fmt.Sprintf("Using root directory: %s", config.RootDir)) } - dockerfileRelPath := "Dockerfile" - hasDockerfile := false - forcedDockerfile := false - if customPath := config.Secrets["TECHULUS_DOCKERFILE_PATH"]; customPath != "" { - if !filepath.IsLocal(customPath) { - return fmt.Errorf("TECHULUS_DOCKERFILE_PATH must be a relative path within the build context, got %q", customPath) - } - resolved := filepath.Join(contextDir, customPath) - if info, err := os.Stat(resolved); err != nil || info.IsDir() { - b.sendLog(config, fmt.Sprintf("TECHULUS_DOCKERFILE_PATH is set but %s was not found in the repository", customPath)) - return fmt.Errorf("TECHULUS_DOCKERFILE_PATH %q not found in build context", customPath) - } - dockerfileRelPath = filepath.Clean(customPath) - hasDockerfile = true - forcedDockerfile = true - } else if _, err := os.Stat(filepath.Join(contextDir, "Dockerfile")); err == nil { - hasDockerfile = true + dockerfile, err := resolveDockerfile(contextDir, config.Secrets) + if err != nil { + return err } buildkitAddr := os.Getenv("BUILDKIT_HOST") @@ -253,6 +246,9 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str var secretArgs []string var secretEnv []string for key, value := range config.Secrets { + if key == dockerfilePathKey { + continue + } secretArgs = append(secretArgs, "--secret", fmt.Sprintf("id=%s,env=%s", key, key)) secretEnv = append(secretEnv, fmt.Sprintf("%s=%s", key, value)) } @@ -266,10 +262,10 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str archImageUri := config.ImageURI + "-" + arch archOutputFlag := fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", archImageUri) - if hasDockerfile { - log.Printf("[build:%s] building with Dockerfile %s via buildctl for %s", truncateStr(config.BuildID, 8), dockerfileRelPath, platform) - if forcedDockerfile { - b.sendLog(config, fmt.Sprintf("Using Dockerfile %s (from TECHULUS_DOCKERFILE_PATH)", dockerfileRelPath)) + if dockerfile.found { + log.Printf("[build:%s] building with Dockerfile via buildctl for %s", truncateStr(config.BuildID, 8), platform) + if configuredPath := strings.TrimSpace(config.Secrets[dockerfilePathKey]); configuredPath != "" { + b.sendLog(config, fmt.Sprintf("Using Dockerfile: %s", configuredPath)) } else { b.sendLog(config, "Using existing Dockerfile") } @@ -280,8 +276,8 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str "build", "--frontend", "dockerfile.v0", "--local", "context=.", - "--local", fmt.Sprintf("dockerfile=%s", filepath.Dir(dockerfileRelPath)), - "--opt", fmt.Sprintf("filename=%s", filepath.Base(dockerfileRelPath)), + "--local", fmt.Sprintf("dockerfile=%s", dockerfile.directory), + "--opt", fmt.Sprintf("filename=%s", dockerfile.filename), "--opt", fmt.Sprintf("platform=%s", platform), "--output", archOutputFlag, } @@ -350,6 +346,54 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str return nil } +func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfileConfig, error) { + configuredPath, configured := secrets[dockerfilePathKey] + configuredPath = strings.TrimSpace(configuredPath) + if configured && configuredPath == "" { + return dockerfileConfig{}, fmt.Errorf("%s cannot be empty", dockerfilePathKey) + } + + if !configured { + if _, err := os.Stat(filepath.Join(contextDir, "Dockerfile")); err == nil { + return dockerfileConfig{directory: ".", filename: "Dockerfile", found: true}, nil + } else if !os.IsNotExist(err) { + return dockerfileConfig{}, fmt.Errorf("failed to inspect Dockerfile: %w", err) + } + return dockerfileConfig{}, nil + } + + cleanedPath := filepath.Clean(configuredPath) + if filepath.IsAbs(cleanedPath) || cleanedPath == ".." || strings.HasPrefix(cleanedPath, ".."+string(filepath.Separator)) { + return dockerfileConfig{}, fmt.Errorf("%s must be relative to the service root directory", dockerfilePathKey) + } + + info, err := os.Stat(filepath.Join(contextDir, cleanedPath)) + if err != nil { + return dockerfileConfig{}, fmt.Errorf("Dockerfile %s does not exist: %w", configuredPath, err) + } + if info.IsDir() { + return dockerfileConfig{}, fmt.Errorf("Dockerfile path %s is a directory", configuredPath) + } + resolvedContextDir, err := filepath.EvalSymlinks(contextDir) + if err != nil { + return dockerfileConfig{}, fmt.Errorf("failed to resolve service root directory: %w", err) + } + resolvedPath, err := filepath.EvalSymlinks(filepath.Join(contextDir, cleanedPath)) + if err != nil { + return dockerfileConfig{}, fmt.Errorf("failed to resolve Dockerfile %s: %w", configuredPath, err) + } + relativePath, err := filepath.Rel(resolvedContextDir, resolvedPath) + if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { + return dockerfileConfig{}, fmt.Errorf("%s must resolve inside the service root directory", dockerfilePathKey) + } + + return dockerfileConfig{ + directory: filepath.Dir(cleanedPath), + filename: filepath.Base(cleanedPath), + found: true, + }, nil +} + func (b *Builder) runCommand(cmd *exec.Cmd, config *Config) (string, error) { output, err := cmd.CombinedOutput() outputStr := string(output) diff --git a/agent/internal/build/build_test.go b/agent/internal/build/build_test.go index e8caa91f..235ad092 100644 --- a/agent/internal/build/build_test.go +++ b/agent/internal/build/build_test.go @@ -119,6 +119,88 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) { } } +func TestResolveDockerfile(t *testing.T) { + contextDir := t.TempDir() + if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte("FROM scratch"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(contextDir, "docker"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(contextDir, "docker", "Dockerfile.prod"), []byte("FROM scratch"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile.custom"), []byte("FROM scratch"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(contextDir, "dockerfiles"), 0700); err != nil { + t.Fatal(err) + } + outsideDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outsideDir, "Dockerfile"), []byte("FROM scratch"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideDir, filepath.Join(contextDir, "outside")); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + secrets map[string]string + directory string + filename string + wantErr bool + }{ + {name: "default", secrets: map[string]string{}, directory: ".", filename: "Dockerfile"}, + { + name: "nested custom path", + secrets: map[string]string{dockerfilePathKey: "docker/Dockerfile.prod"}, + directory: "docker", + filename: "Dockerfile.prod", + }, + { + name: "root custom filename", + secrets: map[string]string{dockerfilePathKey: "Dockerfile.custom"}, + directory: ".", + filename: "Dockerfile.custom", + }, + {name: "missing custom path", secrets: map[string]string{dockerfilePathKey: "missing.Dockerfile"}, wantErr: true}, + {name: "empty custom path", secrets: map[string]string{dockerfilePathKey: " "}, wantErr: true}, + {name: "absolute custom path", secrets: map[string]string{dockerfilePathKey: "/tmp/Dockerfile"}, wantErr: true}, + {name: "escaping custom path", secrets: map[string]string{dockerfilePathKey: "../Dockerfile"}, wantErr: true}, + {name: "directory custom path", secrets: map[string]string{dockerfilePathKey: "dockerfiles"}, wantErr: true}, + {name: "symlink escape", secrets: map[string]string{dockerfilePathKey: "outside/Dockerfile"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveDockerfile(contextDir, tt.secrets) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatal(err) + } + if !got.found || got.directory != tt.directory || got.filename != tt.filename { + t.Fatalf("resolveDockerfile() = %+v, want directory=%q filename=%q", got, tt.directory, tt.filename) + } + }) + } +} + +func TestResolveDockerfileFallsBackToRailpack(t *testing.T) { + got, err := resolveDockerfile(t.TempDir(), map[string]string{}) + if err != nil { + t.Fatal(err) + } + if got.found { + t.Fatalf("resolveDockerfile() = %+v, want no Dockerfile", got) + } +} + func runGit(t *testing.T, args ...string) string { t.Helper() output, err := exec.Command("git", args...).CombinedOutput() diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx index 5c79c488..46cd2c03 100644 --- a/web/components/service/details/pending-changes-banner.tsx +++ b/web/components/service/details/pending-changes-banner.tsx @@ -9,7 +9,10 @@ import { deployService } from "@/actions/projects"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import type { ServiceWithDetails as Service } from "@/db/types"; -import type { ConfigChange } from "@/lib/service-config"; +import { + type ConfigChange, + hasBuildAffectingChanges, +} from "@/lib/service-config"; interface PendingChangesBannerProps { service: Service; @@ -37,8 +40,9 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ 0, ); const hasNoDeployments = service.deployments.length === 0; - const isGithubWithNoDeployments = - service.sourceType === "github" && hasNoDeployments; + const shouldBuild = + service.sourceType === "github" && + (hasNoDeployments || hasBuildAffectingChanges(changes)); const hasChanges = changes.length > 0; const showBanner = @@ -48,7 +52,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ const handleDeploy = async () => { setIsDeploying(true); try { - if (isGithubWithNoDeployments) { + if (shouldBuild) { await triggerBuild(service.id); router.push( `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}/builds`, @@ -92,7 +96,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ ) : ( )} - {isGithubWithNoDeployments ? "Build" : "Deploy"} + {shouldBuild ? "Build" : "Deploy"} {hasChanges ? ( diff --git a/web/lib/service-config.ts b/web/lib/service-config.ts index 22d56181..eafe1d78 100644 --- a/web/lib/service-config.ts +++ b/web/lib/service-config.ts @@ -76,8 +76,17 @@ export type ConfigChange = { field: string; from: string; to: string; + secretKey?: string; }; +export const TECHULUS_DOCKERFILE_PATH = "TECHULUS_DOCKERFILE_PATH"; + +export function hasBuildAffectingChanges(changes: ConfigChange[]): boolean { + return changes.some( + (change) => change.secretKey === TECHULUS_DOCKERFILE_PATH, + ); +} + export function buildCurrentConfig( service: { image: string; @@ -238,6 +247,7 @@ export function diffConfigs( field: "Secret", from: "(none)", to: secret.key, + secretKey: secret.key, }); } for (const volume of current.volumes || []) { @@ -482,12 +492,14 @@ export function diffConfigs( field: "Secret", from: "(none)", to: key, + secretKey: key, }); } else if (deployedSecret.updatedAt !== currentSecret.updatedAt) { changes.push({ field: "Secret", from: key, to: `${key} (updated)`, + secretKey: key, }); } } @@ -498,6 +510,7 @@ export function diffConfigs( field: "Secret", from: key, to: "(removed)", + secretKey: key, }); } } diff --git a/web/tests/service-config.test.ts b/web/tests/service-config.test.ts index 10a54340..605bffaa 100644 --- a/web/tests/service-config.test.ts +++ b/web/tests/service-config.test.ts @@ -3,8 +3,10 @@ import { type DeployedConfig, diffConfigs, getCurrentServerlessConfig, + hasBuildAffectingChanges, MIN_SERVERLESS_SLEEP_AFTER_SECONDS, revisionSpecToDeployedConfig, + TECHULUS_DOCKERFILE_PATH, } from "@/lib/service-config"; function deployedConfig( @@ -95,4 +97,37 @@ describe("service config", () => { to: "Disabled", }); }); + + it("requires a build when the Dockerfile path is added, updated, or removed", () => { + const dockerfileSecret = { + key: TECHULUS_DOCKERFILE_PATH, + updatedAt: "2026-07-20T00:00:00.000Z", + }; + const withoutSecret = deployedConfig({ secrets: [] }); + const withSecret = deployedConfig({ secrets: [dockerfileSecret] }); + const updatedSecret = deployedConfig({ + secrets: [{ ...dockerfileSecret, updatedAt: "2026-07-20T01:00:00.000Z" }], + }); + + expect( + hasBuildAffectingChanges(diffConfigs(withoutSecret, withSecret)), + ).toBe(true); + expect( + hasBuildAffectingChanges(diffConfigs(withSecret, updatedSecret)), + ).toBe(true); + expect( + hasBuildAffectingChanges(diffConfigs(withSecret, withoutSecret)), + ).toBe(true); + }); + + it("does not require a build for unrelated environment variables", () => { + const changes = diffConfigs( + deployedConfig({ secrets: [] }), + deployedConfig({ + secrets: [{ key: "DATABASE_URL", updatedAt: "2026-07-20" }], + }), + ); + + expect(hasBuildAffectingChanges(changes)).toBe(false); + }); }); From a130910266eb15cad4cdf6576f4f9bf4c2e85ca4 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:35:50 +1000 Subject: [PATCH 2/2] Fix staticcheck error messages --- agent/internal/build/build.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go index 4d58e36b..3fc7cf17 100644 --- a/agent/internal/build/build.go +++ b/agent/internal/build/build.go @@ -369,10 +369,10 @@ func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfile info, err := os.Stat(filepath.Join(contextDir, cleanedPath)) if err != nil { - return dockerfileConfig{}, fmt.Errorf("Dockerfile %s does not exist: %w", configuredPath, err) + return dockerfileConfig{}, fmt.Errorf("dockerfile %s does not exist: %w", configuredPath, err) } if info.IsDir() { - return dockerfileConfig{}, fmt.Errorf("Dockerfile path %s is a directory", configuredPath) + return dockerfileConfig{}, fmt.Errorf("dockerfile path %s is a directory", configuredPath) } resolvedContextDir, err := filepath.EvalSymlinks(contextDir) if err != nil {