Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 67 additions & 23 deletions agent/internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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))
}
Expand All @@ -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")
}
Expand All @@ -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,
}
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions agent/internal/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 9 additions & 5 deletions web/components/service/details/pending-changes-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand All @@ -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`,
Expand Down Expand Up @@ -92,7 +96,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({
) : (
<Rocket className="size-4" data-icon="inline-start" />
)}
{isGithubWithNoDeployments ? "Build" : "Deploy"}
{shouldBuild ? "Build" : "Deploy"}
</Button>
</div>
{hasChanges ? (
Expand Down
13 changes: 13 additions & 0 deletions web/lib/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -238,6 +247,7 @@ export function diffConfigs(
field: "Secret",
from: "(none)",
to: secret.key,
secretKey: secret.key,
});
}
for (const volume of current.volumes || []) {
Expand Down Expand Up @@ -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,
});
}
}
Expand All @@ -498,6 +510,7 @@ export function diffConfigs(
field: "Secret",
from: key,
to: "(removed)",
secretKey: key,
});
}
}
Expand Down
35 changes: 35 additions & 0 deletions web/tests/service-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
});
});
Loading