Skip to content

Commit 97440fd

Browse files
add background build system with tool version pinning
1 parent fcdcf8b commit 97440fd

6 files changed

Lines changed: 568 additions & 65 deletions

File tree

‎main.go‎

Lines changed: 99 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ Configuration is loaded from (in order, merged):
122122
rootCmd.Flags().Bool("force-build", false, "Force rebuild of container image")
123123
rootCmd.Flags().Bool("no-cache", false, "Disable build cache (implies --force-build)")
124124
rootCmd.Flags().BoolP("verbose", "v", false, "Show detailed output instead of progress bar")
125+
rootCmd.Flags().String("tool-version", "", "Pin a specific tool version (forces synchronous build)")
125126

126127
// Define command groups (order here determines display order in --help)
127128
rootCmd.AddGroup(
@@ -147,6 +148,7 @@ Configuration is loaded from (in order, merged):
147148
toolCmd.Flags().Bool("no-cache", false, "Disable build cache (implies --force-build)")
148149
toolCmd.Flags().BoolP("verbose", "v", false, "Show detailed output instead of progress bar")
149150
toolCmd.Flags().String("entrypoint", "", "Run a custom command instead of the tool (e.g. /bin/bash)")
151+
toolCmd.Flags().String("tool-version", "", "Pin a specific tool version (forces synchronous build)")
150152
rootCmd.AddCommand(toolCmd)
151153
}
152154

@@ -282,6 +284,18 @@ Use --local or --global to skip the prompt.`,
282284
shellCmd.Flags().String("backend", "", "Backend to use: docker, container (default: both)")
283285
rootCmd.AddCommand(shellCmd)
284286

287+
// Hidden __build subcommand — used by background build launcher.
288+
buildCmd := &cobra.Command{
289+
Use: "__build",
290+
Hidden: true,
291+
RunE: func(cmd *cobra.Command, args []string) error {
292+
dir, _ := cmd.Flags().GetString("dir")
293+
return runBackgroundBuild(dir, stderr)
294+
},
295+
}
296+
buildCmd.Flags().String("dir", "", "Build state directory")
297+
rootCmd.AddCommand(buildCmd)
298+
285299
rootCmd.Version = version
286300
rootCmd.SetVersionTemplate("silo version {{.Version}}\n")
287301

@@ -346,16 +360,20 @@ func runSilo(cmd *cobra.Command, args []string, stdout, stderr io.Writer) error
346360
// Get verbose flag
347361
verbose, _ := cmd.Flags().GetBool("verbose")
348362

363+
// Get tool-version flag
364+
toolVersion, _ := cmd.Flags().GetString("tool-version")
365+
349366
// Run the tool
350367
return run.Tool(run.Options{
351-
ToolDef: *toolDef,
352-
Config: cfg,
353-
Dockerfile: Dockerfile(supportedTools),
354-
ForceBuild: forceBuild,
355-
NoCache: noCache,
356-
Verbose: verbose,
357-
Stdout: stdout,
358-
Stderr: stderr,
368+
ToolDef: *toolDef,
369+
Config: cfg,
370+
Dockerfile: Dockerfile(supportedTools),
371+
ForceBuild: forceBuild,
372+
NoCache: noCache,
373+
Verbose: verbose,
374+
ToolVersion: toolVersion,
375+
Stdout: stdout,
376+
Stderr: stderr,
359377
})
360378
}
361379

@@ -389,18 +407,22 @@ func runTool(cmd *cobra.Command, toolDef tools.Tool, args []string, stdout, stde
389407
// Get entrypoint flag
390408
entrypoint, _ := cmd.Flags().GetString("entrypoint")
391409

410+
// Get tool-version flag
411+
toolVersion, _ := cmd.Flags().GetString("tool-version")
412+
392413
// Run the tool
393414
return run.Tool(run.Options{
394-
ToolDef: toolDef,
395-
ToolArgs: toolArgs,
396-
Config: cfg,
397-
Dockerfile: Dockerfile(supportedTools),
398-
ForceBuild: forceBuild,
399-
NoCache: noCache,
400-
Verbose: verbose,
401-
Entrypoint: entrypoint,
402-
Stdout: stdout,
403-
Stderr: stderr,
415+
ToolDef: toolDef,
416+
ToolArgs: toolArgs,
417+
Config: cfg,
418+
Dockerfile: Dockerfile(supportedTools),
419+
ForceBuild: forceBuild,
420+
NoCache: noCache,
421+
Verbose: verbose,
422+
Entrypoint: entrypoint,
423+
ToolVersion: toolVersion,
424+
Stdout: stdout,
425+
Stderr: stderr,
404426
})
405427
}
406428

@@ -911,3 +933,62 @@ func formatMemoryUsage(bytes uint64, isRunning bool) string {
911933
}
912934
return humanize.IBytes(bytes)
913935
}
936+
937+
// runBackgroundBuild is the entry point for the hidden `silo __build` command.
938+
// It reads all build parameters from the manifest in the build state directory,
939+
// acquires a build lock, builds the image, and updates state.
940+
func runBackgroundBuild(dir string, stderr io.Writer) error {
941+
if dir == "" {
942+
return fmt.Errorf("__build: --dir is required")
943+
}
944+
945+
// Read build manifest.
946+
imageTag, tool, backendType, dockerfile, buildArgs, err := run.ReadBuildManifest(dir)
947+
if err != nil {
948+
return fmt.Errorf("__build: %w", err)
949+
}
950+
951+
// Acquire build lock — exit cleanly if another build is already running.
952+
lock, err := run.TryLock(imageTag)
953+
if err != nil {
954+
return fmt.Errorf("__build: lock: %w", err)
955+
}
956+
if lock == nil {
957+
// Another process is already building this image.
958+
return nil
959+
}
960+
defer lock.Unlock()
961+
962+
// Create backend.
963+
backendClient, err := run.CreateBackend(backendType, stderr, false)
964+
if err != nil {
965+
lock.WriteStatus("failed")
966+
return fmt.Errorf("__build: backend: %w", err)
967+
}
968+
defer backendClient.Close()
969+
970+
// Build image.
971+
fmt.Fprintf(stderr, "==> Background build: %s (%s)\n", tool, imageTag)
972+
ctx := context.Background()
973+
_, err = backendClient.Build(ctx, backend.BuildOptions{
974+
Dockerfile: dockerfile,
975+
Target: tool,
976+
Tag: imageTag,
977+
BuildArgs: buildArgs,
978+
OnProgress: func(msg string) {
979+
fmt.Fprint(stderr, msg)
980+
},
981+
})
982+
if err != nil {
983+
lock.WriteStatus("failed")
984+
return fmt.Errorf("__build: build: %w", err)
985+
}
986+
987+
lock.WriteStatus("done")
988+
fmt.Fprintf(stderr, "==> Background build complete: %s\n", imageTag)
989+
990+
// Update last-image so next run picks up the new image.
991+
run.SaveLastImage(tool, imageTag)
992+
993+
return nil
994+
}

‎run/bgbuild.go‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package run
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"syscall"
10+
"time"
11+
12+
"github.com/leighmcculloch/silo/config"
13+
)
14+
15+
// BackgroundBuildOptions contains the parameters needed to launch a
16+
// detached background build process.
17+
type BackgroundBuildOptions struct {
18+
ImageTag string
19+
Dockerfile string
20+
BuildArgs map[string]string
21+
Backend string
22+
Tool string
23+
}
24+
25+
// buildManifest is the JSON structure written to the build state directory.
26+
// The __build command reads this single file to get all build parameters.
27+
type buildManifest struct {
28+
ImageTag string `json:"image_tag"`
29+
Tool string `json:"tool"`
30+
Backend string `json:"backend,omitempty"`
31+
BuildArgs map[string]string `json:"build_args"`
32+
Dockerfile string `json:"dockerfile"`
33+
}
34+
35+
// ReadBuildManifest reads the build manifest from a state directory.
36+
func ReadBuildManifest(dir string) (imageTag, tool, backend, dockerfile string, buildArgs map[string]string, err error) {
37+
data, err := os.ReadFile(filepath.Join(dir, "build.json"))
38+
if err != nil {
39+
return "", "", "", "", nil, fmt.Errorf("read manifest: %w", err)
40+
}
41+
var m buildManifest
42+
if err := json.Unmarshal(data, &m); err != nil {
43+
return "", "", "", "", nil, fmt.Errorf("parse manifest: %w", err)
44+
}
45+
if m.ImageTag == "" || m.Tool == "" {
46+
return "", "", "", "", nil, fmt.Errorf("manifest missing required fields (image_tag, tool)")
47+
}
48+
return m.ImageTag, m.Tool, m.Backend, m.Dockerfile, m.BuildArgs, nil
49+
}
50+
51+
// LaunchBackgroundBuild writes the build inputs to the build state directory
52+
// and spawns a detached `silo __build` subprocess that will survive if the
53+
// parent session exits.
54+
func LaunchBackgroundBuild(opts BackgroundBuildOptions) error {
55+
dir := buildDir(opts.ImageTag)
56+
if err := os.MkdirAll(dir, 0o755); err != nil {
57+
return fmt.Errorf("bgbuild: mkdir: %w", err)
58+
}
59+
60+
// Write all build parameters to a single manifest file.
61+
manifest := buildManifest{
62+
ImageTag: opts.ImageTag,
63+
Tool: opts.Tool,
64+
Backend: opts.Backend,
65+
BuildArgs: opts.BuildArgs,
66+
Dockerfile: opts.Dockerfile,
67+
}
68+
manifestJSON, err := json.Marshal(manifest)
69+
if err != nil {
70+
return fmt.Errorf("bgbuild: marshal manifest: %w", err)
71+
}
72+
if err := os.WriteFile(filepath.Join(dir, "build.json"), manifestJSON, 0o644); err != nil {
73+
return fmt.Errorf("bgbuild: write manifest: %w", err)
74+
}
75+
76+
// Find the silo binary (same binary that is currently running).
77+
selfPath, err := os.Executable()
78+
if err != nil {
79+
return fmt.Errorf("bgbuild: find executable: %w", err)
80+
}
81+
82+
// Open log file in the shared logs directory so it appears in `silo logs`.
83+
logDir := filepath.Join(config.XDGStateHomeDir(), "silo", "logs")
84+
_ = os.MkdirAll(logDir, 0o755)
85+
logName := fmt.Sprintf("%s-build-%s.log", time.Now().Format("20060102-150405"), opts.Tool)
86+
logPath := filepath.Join(logDir, logName)
87+
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
88+
if err != nil {
89+
return fmt.Errorf("bgbuild: open log: %w", err)
90+
}
91+
92+
cmd := exec.Command(selfPath, "__build", "--dir", dir)
93+
cmd.Stdout = logFile
94+
cmd.Stderr = logFile
95+
cmd.SysProcAttr = &syscall.SysProcAttr{
96+
Setsid: true, // detach from parent session
97+
}
98+
99+
if err := cmd.Start(); err != nil {
100+
logFile.Close()
101+
return fmt.Errorf("bgbuild: start: %w", err)
102+
}
103+
104+
// Release the file and process handles — the child is fully detached.
105+
logFile.Close()
106+
_ = cmd.Process.Release()
107+
108+
return nil
109+
}

‎run/buildlock.go‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package run
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strconv"
8+
"syscall"
9+
10+
"github.com/leighmcculloch/silo/config"
11+
)
12+
13+
// BuildLock holds a filesystem-based advisory lock for a build.
14+
type BuildLock struct {
15+
file *os.File
16+
dir string
17+
}
18+
19+
// buildDir returns the state directory for a given image tag build.
20+
func buildDir(imageTag string) string {
21+
return filepath.Join(config.XDGStateHomeDir(), "silo", "builds", imageTag)
22+
}
23+
24+
// TryLock attempts to acquire an exclusive non-blocking flock for the given
25+
// image tag. Returns a *BuildLock on success. Returns nil, nil if another
26+
// process already holds the lock (i.e. a build is in progress).
27+
func TryLock(imageTag string) (*BuildLock, error) {
28+
dir := buildDir(imageTag)
29+
if err := os.MkdirAll(dir, 0o755); err != nil {
30+
return nil, fmt.Errorf("buildlock: mkdir: %w", err)
31+
}
32+
33+
lockPath := filepath.Join(dir, "lock")
34+
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
35+
if err != nil {
36+
return nil, fmt.Errorf("buildlock: open: %w", err)
37+
}
38+
39+
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
40+
if err != nil {
41+
f.Close()
42+
// EWOULDBLOCK means another process holds the lock.
43+
if err == syscall.EWOULDBLOCK {
44+
return nil, nil
45+
}
46+
return nil, fmt.Errorf("buildlock: flock: %w", err)
47+
}
48+
49+
// Write PID for observability.
50+
_ = f.Truncate(0)
51+
_, _ = f.Seek(0, 0)
52+
_, _ = fmt.Fprintf(f, "%d\n", os.Getpid())
53+
54+
// Write status file.
55+
_ = os.WriteFile(filepath.Join(dir, "status"), []byte("building\n"), 0o644)
56+
57+
return &BuildLock{file: f, dir: dir}, nil
58+
}
59+
60+
// Unlock releases the flock and removes the build directory.
61+
func (bl *BuildLock) Unlock() {
62+
if bl.file != nil {
63+
_ = syscall.Flock(int(bl.file.Fd()), syscall.LOCK_UN)
64+
bl.file.Close()
65+
}
66+
_ = os.RemoveAll(bl.dir)
67+
}
68+
69+
// WriteStatus writes a status string to the build directory.
70+
func (bl *BuildLock) WriteStatus(status string) {
71+
_ = os.WriteFile(filepath.Join(bl.dir, "status"), []byte(status+"\n"), 0o644)
72+
}
73+
74+
// Dir returns the build directory path.
75+
func (bl *BuildLock) Dir() string {
76+
return bl.dir
77+
}
78+
79+
// IsBuilding checks whether a build for the given image tag is currently in
80+
// progress. It attempts a non-blocking flock: if it fails, a live process
81+
// holds the lock. If it succeeds, the old lock was stale (process died) and
82+
// we clean it up and return false.
83+
func IsBuilding(imageTag string) bool {
84+
dir := buildDir(imageTag)
85+
lockPath := filepath.Join(dir, "lock")
86+
87+
f, err := os.OpenFile(lockPath, os.O_RDWR, 0o644)
88+
if err != nil {
89+
// No lock file means no build.
90+
return false
91+
}
92+
defer f.Close()
93+
94+
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
95+
if err != nil {
96+
// Could not acquire → another process holds it → building.
97+
return true
98+
}
99+
100+
// We acquired the lock → stale. Release and clean up.
101+
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
102+
f.Close()
103+
_ = os.RemoveAll(dir)
104+
return false
105+
}
106+
107+
// ReadBuildPID reads the PID from a build lock directory, or 0 if unavailable.
108+
func ReadBuildPID(imageTag string) int {
109+
data, err := os.ReadFile(filepath.Join(buildDir(imageTag), "lock"))
110+
if err != nil {
111+
return 0
112+
}
113+
pid, _ := strconv.Atoi(string(data[:len(data)-1])) // trim newline
114+
return pid
115+
}

0 commit comments

Comments
 (0)