From 18257de58f5ad6cc0c17edc16b59be078aff186c Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Mon, 11 May 2026 22:59:49 -0400 Subject: [PATCH 01/11] Introduce enums for config validation and mode/method definitions --- internal/config/config.go | 85 +++++++++++++++++++++++++++++++----- internal/config/validate.go | 16 +++---- internal/dockerfile/build.go | 56 ++++++++++++++++++++---- internal/dockerfile/spec.go | 8 ++-- 4 files changed, 134 insertions(+), 31 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 086defb..c35eb45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,56 @@ package config -import "strings" +import ( + "fmt" + "strings" +) + +// Mode describes how tool versions are managed. +type Mode string + +const ( + ModePinned Mode = "pinned" // version and checksums are static in deps.yaml + ModeStatic Mode = "static" // version is static, checksums are in deps.yaml + ModeReleaseChecksums Mode = "release-checksums" // checksums fetched from release artifacts at generate time +) + +// String returns the mode as a string. +func (m Mode) String() string { + return string(m) +} + +// Validate checks if the mode is a known value. +func (m Mode) Validate() error { + switch m { + case ModePinned, ModeStatic, ModeReleaseChecksums: + return nil + default: + return fmt.Errorf("invalid mode: %q (must be one of: pinned, static, release-checksums)", m) + } +} + +// Method describes how a tool is installed in the Dockerfile. +type Method string + +const ( + MethodCurl Method = "curl" // download via curl, verify checksums + MethodGoInstall Method = "go-install" // install via go install +) + +// String returns the method as a string. +func (m Method) String() string { + return string(m) +} + +// Validate checks if the method is a known value. +func (m Method) Validate() error { + switch m { + case MethodCurl, MethodGoInstall: + return nil + default: + return fmt.Errorf("invalid method: %q (must be one of: curl, go-install)", m) + } +} // Config is the top-level structure of deps.yaml. type Config struct { @@ -32,17 +82,17 @@ type Tool struct { Source string `yaml:"source"` Version string `yaml:"version"` VersionCommit string `yaml:"version_commit,omitempty"` - Mode string `yaml:"mode,omitempty"` // default: "pinned" + Mode Mode `yaml:"mode,omitempty"` // defaults to ModePinned Universal bool `yaml:"-"` // set by loader; use universal: section in deps.yaml Checksums ChecksumList `yaml:"checksums,omitempty"` Release *ReleaseConfig `yaml:"release,omitempty"` Install InstallConfig `yaml:"install"` } -// EffectiveMode returns the tool's mode, defaulting to "pinned". -func (t *Tool) EffectiveMode() string { +// EffectiveMode returns the tool's mode, defaulting to ModePinned. +func (t *Tool) EffectiveMode() Mode { if t.Mode == "" { - return "pinned" + return ModePinned } return t.Mode } @@ -58,7 +108,7 @@ func (t *Tool) EffectiveMode() string { // For non-GitHub or non-release-checksums tools, the release block is returned // as-is (or nil if absent). func (t *Tool) EffectiveRelease() *ReleaseConfig { - if t.EffectiveMode() == "release-checksums" && isGitHubSource(t.Source) { + if t.EffectiveMode() == ModeReleaseChecksums && isGitHubSource(t.Source) { merged := ReleaseConfig{ DownloadTemplate: "{name}_{os}_{arch}", ChecksumTemplate: "checksums.txt", @@ -74,6 +124,9 @@ func (t *Tool) EffectiveRelease() *ReleaseConfig { if t.Release.Extract != "" { merged.Extract = t.Release.Extract } + if t.Release.InstallToPath != nil { + merged.InstallToPath = t.Release.InstallToPath + } } return &merged } @@ -102,18 +155,28 @@ type ReleaseConfig struct { DownloadTemplate string `yaml:"download_template"` ChecksumTemplate string `yaml:"checksum_template,omitempty"` Extract string `yaml:"extract"` + InstallToPath *bool `yaml:"install_to_path,omitempty"` // if false, extract to /var/ci-tools/{name} and leave for hooks; defaults to true +} + +// ShouldInstallToPath returns whether the tool should be installed to /usr/local/bin after extraction. +// Defaults to true; set to false for self-installing archives that hooks will handle. +func (r *ReleaseConfig) ShouldInstallToPath() bool { + if r == nil || r.InstallToPath == nil { + return true + } + return *r.InstallToPath } // InstallConfig specifies how to install the tool in a Dockerfile. type InstallConfig struct { - Method string `yaml:"method,omitempty"` // "curl" | "go-install"; defaults to "curl" - Package string `yaml:"package,omitempty"` // required for go-install; {var|modifier} template + Method Method `yaml:"method,omitempty"` // defaults to MethodCurl + Package string `yaml:"package,omitempty"` // required for MethodGoInstall; {var|modifier} template } -// EffectiveMethod returns the install method, defaulting to "curl". -func (i InstallConfig) EffectiveMethod() string { +// EffectiveMethod returns the install method, defaulting to MethodCurl. +func (i InstallConfig) EffectiveMethod() Method { if i.Method == "" { - return "curl" + return MethodCurl } return i.Method } diff --git a/internal/config/validate.go b/internal/config/validate.go index 808347a..e968bf7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -189,7 +189,7 @@ func validateConfig(cfg *Config) error { mode := t.EffectiveMode() switch mode { - case "pinned", "static", "release-checksums": + case ModePinned, ModeStatic, ModeReleaseChecksums: // valid modes default: errs = append(errs, fmt.Sprintf("tool %q: mode %q is not supported (supported: 'pinned', 'static', 'release-checksums')", t.Name, mode)) @@ -197,7 +197,7 @@ func validateConfig(cfg *Config) error { } // release-checksums requires an allowlisted source. - if mode == "release-checksums" && !inAllowlist(t.Source) { + if mode == ModeReleaseChecksums && !inAllowlist(t.Source) { errs = append(errs, fmt.Sprintf("tool %q: mode 'release-checksums' requires source to be in the allowlist; %q is not listed", t.Name, t.Source)) } @@ -206,12 +206,12 @@ func validateConfig(cfg *Config) error { } if t.Version == "" { errs = append(errs, fmt.Sprintf("tool %q: missing required field 'version'", t.Name)) - } else if t.Version == "latest" && mode != "release-checksums" { + } else if t.Version == "latest" && mode != ModeReleaseChecksums { // version: latest is only valid for release-checksums; pinned and static must pin explicitly. errs = append(errs, fmt.Sprintf("tool %q: version 'latest' is not allowed in mode %q", t.Name, mode)) } switch t.Install.EffectiveMethod() { - case "curl": + case MethodCurl: effectiveRelease := t.EffectiveRelease() if effectiveRelease == nil { errs = append(errs, fmt.Sprintf("tool %q: method 'curl' requires a 'release:' block (or a GitHub source so defaults apply)", t.Name)) @@ -219,11 +219,11 @@ func validateConfig(cfg *Config) error { if effectiveRelease.DownloadTemplate == "" { errs = append(errs, fmt.Sprintf("tool %q: release.download_template is required for method 'curl'", t.Name)) } - if effectiveRelease.Extract == "" && mode != "release-checksums" && isArchiveTemplate(effectiveRelease.DownloadTemplate) { + if effectiveRelease.Extract == "" && mode != ModeReleaseChecksums && isArchiveTemplate(effectiveRelease.DownloadTemplate) { errs = append(errs, fmt.Sprintf("tool %q: release.extract is required for method 'curl' when download_template is an archive", t.Name)) } } - if mode == "release-checksums" { + if mode == ModeReleaseChecksums { // Checksums are fetched at generate time; they must not be declared statically. if len(t.Checksums) > 0 { errs = append(errs, fmt.Sprintf("tool %q: checksums must be absent for mode 'release-checksums' (they are fetched at generate time)", t.Name)) @@ -246,7 +246,7 @@ func validateConfig(cfg *Config) error { errs = append(errs, fmt.Sprintf("tool %q: install.package is forbidden for method 'curl'", t.Name)) } - case "go-install": + case MethodGoInstall: if t.Install.Package == "" { errs = append(errs, fmt.Sprintf("tool %q: install.package is required for method 'go-install'", t.Name)) } @@ -268,7 +268,7 @@ func validateConfig(cfg *Config) error { // Pinned/static curl tools: verify checksum coverage for every image that includes the tool. // release-checksums tools have checksums resolved at generate time. - if t.Install.EffectiveMethod() == "curl" && mode != "release-checksums" && len(t.Checksums) > 0 { + if t.Install.EffectiveMethod() == MethodCurl && mode != ModeReleaseChecksums && len(t.Checksums) > 0 { for _, img := range cfg.Images { if !imageIncludesTool(img, &t) { continue diff --git a/internal/dockerfile/build.go b/internal/dockerfile/build.go index 2d481b3..de5eaf3 100644 --- a/internal/dockerfile/build.go +++ b/internal/dockerfile/build.go @@ -11,6 +11,30 @@ import ( gh "github.com/rancher/ci-image/internal/github" ) +// Format describes the packaging/compression type of a download artifact. +type Format string + +const ( + FormatArchive Format = "archive" // tar/zip archive + FormatGzip Format = "gzip" // gzipped file (.gz) + FormatExecutable Format = "binary" // raw executable file (binary or script) +) + +// String returns the format as a string. +func (f Format) String() string { + return string(f) +} + +// Validate checks if the format is a known value. +func (f Format) Validate() error { + switch f { + case FormatArchive, FormatGzip, FormatExecutable: + return nil + default: + return fmt.Errorf("invalid format: %q (must be one of: archive, gzip, binary)", f) + } +} + // NewDockerfileVars builds a fully-resolved DockerfileVars for img. // All template rendering is performed here; if construction succeeds, // Render() is guaranteed to succeed. @@ -108,9 +132,9 @@ func NewDockerfileVars(cfg *config.Config, img config.Image, sourceURL string) ( func buildItemInstall(t config.Tool, imgPlatforms map[string]bool) (ItemInstall, error) { switch t.Install.EffectiveMethod() { - case "curl": + case config.MethodCurl: return buildCurlInstall(t, imgPlatforms) - case "go-install": + case config.MethodGoInstall: return buildGoInstall(t) default: return nil, fmt.Errorf("unknown install method %q", t.Install.EffectiveMethod()) @@ -190,14 +214,28 @@ func buildGoInstall(t config.Tool) (GoInstall, error) { return GoInstall{Package: pkg}, nil } -// detectFormat classifies a rendered download URL as "archive", "gzip", or "binary", -// and returns the archive extension (non-empty only for "archive"). -func detectFormat(url string) (format, ext string) { - if ext = archiveExt(url); ext != "" { - return "archive", ext +// detectFormat classifies a download URL into a format based on packaging/compression. +// Returns one of: FormatArchive, FormatGzip, FormatExecutable. +// For archives, also returns the archive extension (.tar.gz, .zip, etc). +// +// Detection logic: +// 1. If URL is an archive (.tar.gz, .zip, etc) → FormatArchive +// 2. If URL ends with .gz (but not .tar.gz) → FormatGzip +// 3. Otherwise → FormatExecutable +// +// Format describes the artifact packaging, NOT what to do with it (copy vs run). +// That distinction is handled at template selection time based on ScriptArgs. +func detectFormat(url string) (Format, string) { + // Check for archives first + if ext := archiveExt(url); ext != "" { + return FormatArchive, ext } + + // Gzipped executable if isGzipBinaryURL(url) { - return "gzip", "" + return FormatGzip, "" } - return "binary", "" + + // Default to raw executable (binary or script) + return FormatExecutable, "" } diff --git a/internal/dockerfile/spec.go b/internal/dockerfile/spec.go index e942e4a..3414f91 100644 --- a/internal/dockerfile/spec.go +++ b/internal/dockerfile/spec.go @@ -5,6 +5,8 @@ import ( "slices" "strings" "text/template" + + "github.com/rancher/ci-image/internal/config" ) //go:embed tmpl @@ -50,7 +52,7 @@ type PlatformInstall struct { // Implements ItemInstall. type CurlInstall struct { Name string // tool name; used in shell commands - Format string // "archive" | "gzip" | "binary" + Format Format // "archive" | "gzip" | "binary" ArchiveExt string // ".tar.gz", ".zip", etc.; empty unless Format == "archive" Platforms []PlatformInstall // one entry per platform, sorted by Arch } @@ -58,7 +60,7 @@ type CurlInstall struct { func (c CurlInstall) Method() string { return "curl" } func (c CurlInstall) Render() string { - return executeTemplate("curl_"+c.Format+".tmpl", c) + return executeTemplate("curl_"+c.Format.String()+".tmpl", c) } // GoInstall is the resolved spec for a go-install tool. @@ -121,7 +123,7 @@ func (v DockerfileVars) SelectorSetupCmd() string { // Called by dockerfile.tmpl to conditionally emit the Go cache cleanup block. func (v DockerfileVars) HasGoInstall() bool { for _, t := range v.Tools { - if t.Install.Method() == "go-install" { + if t.Install.Method() == string(config.MethodGoInstall) { return true } } From bf22b51949f03a70d40b8afea66d2d4a05d26620 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Mon, 11 May 2026 23:08:44 -0400 Subject: [PATCH 02/11] Update `curl_archive.tmpl` with install location logic --- internal/config/config_test.go | 204 +++++++++++++++++++ internal/dockerfile/build.go | 9 +- internal/dockerfile/generate_test.go | 217 +++++++++++++++++++++ internal/dockerfile/spec.go | 9 +- internal/dockerfile/tmpl/curl_archive.tmpl | 16 +- 5 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5412b68 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,204 @@ +package config + +import "testing" + +func TestReleaseConfig_ShouldInstallToPath(t *testing.T) { + tests := []struct { + name string + rel *ReleaseConfig + want bool + }{ + { + name: "nil config defaults to true", + rel: nil, + want: true, + }, + { + name: "nil InstallToPath field defaults to true", + rel: &ReleaseConfig{ + DownloadTemplate: "tool-{version}.tar.gz", + Extract: "tool", + }, + want: true, + }, + { + name: "explicit true", + rel: &ReleaseConfig{ + DownloadTemplate: "tool-{version}.tar.gz", + Extract: "tool", + InstallToPath: boolPtr(true), + }, + want: true, + }, + { + name: "explicit false", + rel: &ReleaseConfig{ + DownloadTemplate: "nix-{version}.tar.xz", + Extract: "nix-{version}/install", + InstallToPath: boolPtr(false), + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rel.ShouldInstallToPath() + if got != tt.want { + t.Errorf("ShouldInstallToPath() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMode_Validate(t *testing.T) { + tests := []struct { + name string + mode Mode + wantErr bool + }{ + { + name: "valid: pinned", + mode: ModePinned, + wantErr: false, + }, + { + name: "valid: static", + mode: ModeStatic, + wantErr: false, + }, + { + name: "valid: release-checksums", + mode: ModeReleaseChecksums, + wantErr: false, + }, + { + name: "invalid: unknown", + mode: Mode("unknown"), + wantErr: true, + }, + { + name: "invalid: empty", + mode: Mode(""), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.mode.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Mode.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestMethod_Validate(t *testing.T) { + tests := []struct { + name string + method Method + wantErr bool + }{ + { + name: "valid: curl", + method: MethodCurl, + wantErr: false, + }, + { + name: "valid: go-install", + method: MethodGoInstall, + wantErr: false, + }, + { + name: "invalid: unknown", + method: Method("unknown"), + wantErr: true, + }, + { + name: "invalid: empty", + method: Method(""), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.method.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Method.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestTool_EffectiveMode(t *testing.T) { + tests := []struct { + name string + tool Tool + want Mode + }{ + { + name: "empty mode defaults to pinned", + tool: Tool{Name: "test"}, + want: ModePinned, + }, + { + name: "explicit static mode", + tool: Tool{Name: "test", Mode: ModeStatic}, + want: ModeStatic, + }, + { + name: "explicit release-checksums mode", + tool: Tool{Name: "test", Mode: ModeReleaseChecksums}, + want: ModeReleaseChecksums, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.tool.EffectiveMode() + if got != tt.want { + t.Errorf("EffectiveMode() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestInstallConfig_EffectiveMethod(t *testing.T) { + tests := []struct { + name string + config InstallConfig + want Method + }{ + { + name: "empty method defaults to curl", + config: InstallConfig{}, + want: MethodCurl, + }, + { + name: "explicit curl method", + config: InstallConfig{Method: MethodCurl}, + want: MethodCurl, + }, + { + name: "explicit go-install method", + config: InstallConfig{Method: MethodGoInstall}, + want: MethodGoInstall, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.EffectiveMethod() + if got != tt.want { + t.Errorf("EffectiveMethod() = %v, want %v", got, tt.want) + } + }) + } +} + +// boolPtr returns a pointer to the given bool value. +func boolPtr(b bool) *bool { + return &b +} diff --git a/internal/dockerfile/build.go b/internal/dockerfile/build.go index de5eaf3..1f2fc14 100644 --- a/internal/dockerfile/build.go +++ b/internal/dockerfile/build.go @@ -193,10 +193,11 @@ func buildCurlInstall(t config.Tool, imgPlatforms map[string]bool) (CurlInstall, format, ext := detectFormat(platforms[0].DownloadURL) return CurlInstall{ - Name: t.Name, - Format: format, - ArchiveExt: ext, - Platforms: platforms, + Name: t.Name, + Format: format, + ArchiveExt: ext, + Platforms: platforms, + InstallToPath: rel.ShouldInstallToPath(), }, nil } diff --git a/internal/dockerfile/generate_test.go b/internal/dockerfile/generate_test.go index 0133812..1a95765 100644 --- a/internal/dockerfile/generate_test.go +++ b/internal/dockerfile/generate_test.go @@ -532,3 +532,220 @@ func TestGenerate_NoGitConfigWithoutGit(t *testing.T) { t.Errorf("Generate() should not emit git config when neither 'git' nor 'git-core' packages are present\n\nFull output:\n%s", content) } } + +func TestGenerate_InstallToPath_Default(t *testing.T) { + // Default behavior: install_to_path defaults to true, tools are copied to /usr/local/bin + cfg := &config.Config{ + Images: []config.Image{ + { + Name: "test", + Base: "base@sha256:" + strings.Repeat("a", 64), + Platforms: []string{"linux/amd64"}, + Packages: []string{"wget"}, + Tools: []string{"helm"}, + }, + }, + Tools: []config.Tool{helmUniversalTool()}, + } + + result, err := Generate(cfg, "") + if err != nil { + t.Fatalf("Generate() unexpected error: %v", err) + } + content := result["test"] + + // Should use temporary directory + if !strings.Contains(content, "export TMP_DIR=$(mktemp -d)") { + t.Errorf("Generate() should use TMP_DIR for install_to_path: true (default)\n\nFull output:\n%s", content) + } + + // Should install to /usr/local/bin + if !strings.Contains(content, `install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm"`) { + t.Errorf("Generate() should install to /usr/local/bin when install_to_path: true (default)\n\nFull output:\n%s", content) + } + + // Should cleanup temp directory + if !strings.Contains(content, "rm -rf \"${TMP_DIR}\"") { + t.Errorf("Generate() should cleanup TMP_DIR when install_to_path: true (default)\n\nFull output:\n%s", content) + } + + // Should NOT use /var/ci-tools + if strings.Contains(content, "/var/ci-tools/helm") { + t.Errorf("Generate() should not use /var/ci-tools when install_to_path: true (default)\n\nFull output:\n%s", content) + } +} + +func TestGenerate_InstallToPath_False(t *testing.T) { + // When install_to_path: false, extract to /var/ci-tools and leave for hooks + installToPathFalse := false + cfg := &config.Config{ + Images: []config.Image{ + { + Name: "test", + Base: "base@sha256:" + strings.Repeat("a", 64), + Platforms: []string{"linux/amd64"}, + Packages: []string{"wget"}, + Tools: []string{"nix"}, + }, + }, + Tools: []config.Tool{ + { + Name: "nix", + Source: "https://releases.nixos.org/nix", + Mode: config.ModeStatic, + Version: "2.34.5", + Checksums: map[string]string{ + "linux/amd64": strings.Repeat("a", 64), + }, + Release: &config.ReleaseConfig{ + DownloadTemplate: "{source}/nix-{version}/nix-{version}-x86_64-linux.tar.xz", + Extract: "nix-{version}-x86_64-linux/install", + InstallToPath: &installToPathFalse, + }, + Install: config.InstallConfig{Method: config.MethodCurl}, + }, + }, + } + + result, err := Generate(cfg, "") + if err != nil { + t.Fatalf("Generate() unexpected error: %v", err) + } + content := result["test"] + + // Should use /var/ci-tools directory + if !strings.Contains(content, `export INSTALL_DIR="/var/ci-tools/nix"`) { + t.Errorf("Generate() should use /var/ci-tools when install_to_path: false\n\nFull output:\n%s", content) + } + + // Should create the directory + if !strings.Contains(content, `mkdir -p "${INSTALL_DIR}"`) { + t.Errorf("Generate() should create INSTALL_DIR when install_to_path: false\n\nFull output:\n%s", content) + } + + // Should extract to INSTALL_DIR + if !strings.Contains(content, `cd "${INSTALL_DIR}"`) { + t.Errorf("Generate() should cd to INSTALL_DIR when install_to_path: false\n\nFull output:\n%s", content) + } + + // Should NOT install to /usr/local/bin + if strings.Contains(content, `install "${TMP_DIR}`) || strings.Contains(content, `/usr/local/bin/nix`) { + t.Errorf("Generate() should not install to /usr/local/bin when install_to_path: false\n\nFull output:\n%s", content) + } + + // Should NOT cleanup the entire directory (only archive and checksum) + if strings.Contains(content, `rm -rf "${INSTALL_DIR}"`) { + t.Errorf("Generate() should not rm -rf INSTALL_DIR when install_to_path: false\n\nFull output:\n%s", content) + } + + // Should cleanup only the archive and checksum file + if !strings.Contains(content, `rm "${TMP_FILE}" "${INSTALL_DIR}/checksum.sha256"`) { + t.Errorf("Generate() should cleanup archive and checksum when install_to_path: false\n\nFull output:\n%s", content) + } + + // Should NOT use mktemp + if strings.Contains(content, "mktemp -d") { + t.Errorf("Generate() should not use mktemp when install_to_path: false\n\nFull output:\n%s", content) + } +} + +func TestFormat_Validate(t *testing.T) { + tests := []struct { + name string + format Format + wantErr bool + }{ + { + name: "valid: archive", + format: FormatArchive, + wantErr: false, + }, + { + name: "valid: gzip", + format: FormatGzip, + wantErr: false, + }, + { + name: "valid: binary", + format: FormatExecutable, + wantErr: false, + }, + { + name: "invalid: unknown", + format: Format("unknown"), + wantErr: true, + }, + { + name: "invalid: empty", + format: Format(""), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.format.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Format.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDetectFormat(t *testing.T) { + tests := []struct { + name string + url string + wantFormat Format + wantExt string + }{ + { + name: "tar.gz archive", + url: "https://example.com/tool-v1.0.0.tar.gz", + wantFormat: FormatArchive, + wantExt: ".tar.gz", + }, + { + name: "zip archive", + url: "https://example.com/tool-v1.0.0.zip", + wantFormat: FormatArchive, + wantExt: ".zip", + }, + { + name: "tar.xz archive", + url: "https://example.com/tool-v1.0.0.tar.xz", + wantFormat: FormatArchive, + wantExt: ".tar.xz", + }, + { + name: "gzipped binary", + url: "https://example.com/tool.gz", + wantFormat: FormatGzip, + wantExt: "", + }, + { + name: "raw binary", + url: "https://example.com/tool", + wantFormat: FormatExecutable, + wantExt: "", + }, + { + name: "archive with query params", + url: "https://example.com/tool.tar.gz?v=1.0", + wantFormat: FormatArchive, + wantExt: ".tar.gz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFormat, gotExt := detectFormat(tt.url) + if gotFormat != tt.wantFormat { + t.Errorf("detectFormat() format = %v, want %v", gotFormat, tt.wantFormat) + } + if gotExt != tt.wantExt { + t.Errorf("detectFormat() ext = %v, want %v", gotExt, tt.wantExt) + } + }) + } +} diff --git a/internal/dockerfile/spec.go b/internal/dockerfile/spec.go index 3414f91..b05cd81 100644 --- a/internal/dockerfile/spec.go +++ b/internal/dockerfile/spec.go @@ -51,10 +51,11 @@ type PlatformInstall struct { // CurlInstall is the resolved spec for a curl-installed tool. // Implements ItemInstall. type CurlInstall struct { - Name string // tool name; used in shell commands - Format Format // "archive" | "gzip" | "binary" - ArchiveExt string // ".tar.gz", ".zip", etc.; empty unless Format == "archive" - Platforms []PlatformInstall // one entry per platform, sorted by Arch + Name string // tool name; used in shell commands + Format Format // "archive" | "gzip" | "binary" + ArchiveExt string // ".tar.gz", ".zip", etc.; empty unless Format == "archive" + Platforms []PlatformInstall // one entry per platform, sorted by Arch + InstallToPath bool // if true, install to /usr/local/bin; if false, extract to /var/ci-tools/{name} for hooks } func (c CurlInstall) Method() string { return "curl" } diff --git a/internal/dockerfile/tmpl/curl_archive.tmpl b/internal/dockerfile/tmpl/curl_archive.tmpl index 541b9e5..0aae3ce 100644 --- a/internal/dockerfile/tmpl/curl_archive.tmpl +++ b/internal/dockerfile/tmpl/curl_archive.tmpl @@ -4,16 +4,30 @@ RUN case "${ARCH}" in \ {{- end}} *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ +{{- if .InstallToPath}} export TMP_DIR=$(mktemp -d) && \ export TMP_FILE="${TMP_DIR}/{{.Name}}{{.ArchiveExt}}" && \ +{{- else}} + export INSTALL_DIR="/var/ci-tools/{{.Name}}" && \ + mkdir -p "${INSTALL_DIR}" && \ + export TMP_FILE="${INSTALL_DIR}/{{.Name}}{{.ArchiveExt}}" && \ +{{- end}} case "${ARCH}" in \ {{- range .Platforms}} {{.Arch}}) DOWNLOAD_URL={{printf "%q" .DownloadURL}}; EXTRACT={{printf "%q" .Extract}} ;; \ {{- end}} esac && \ curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors "${DOWNLOAD_URL}" > "${TMP_FILE}" && \ +{{- if .InstallToPath}} printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ {{extractCmd .ArchiveExt}} && \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/{{.Name}}" && \ - rm -rf "${TMP_DIR}" \ No newline at end of file + rm -rf "${TMP_DIR}" +{{- else}} + printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${INSTALL_DIR}/checksum.sha256" && \ + sha256sum -c "${INSTALL_DIR}/checksum.sha256" && \ + cd "${INSTALL_DIR}" && \ + {{extractCmd .ArchiveExt}} && \ + rm "${TMP_FILE}" "${INSTALL_DIR}/checksum.sha256" +{{- end}} \ No newline at end of file From 95a7831195933a4543459cb21153135497e70280 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Mon, 11 May 2026 23:14:58 -0400 Subject: [PATCH 03/11] Add tests and implementation for loading optional hook templates --- internal/dockerfile/hooks_test.go | 293 ++++++++++++++++++++++++++++++ internal/dockerfile/spec.go | 40 ++++ 2 files changed, 333 insertions(+) create mode 100644 internal/dockerfile/hooks_test.go diff --git a/internal/dockerfile/hooks_test.go b/internal/dockerfile/hooks_test.go new file mode 100644 index 0000000..3190a68 --- /dev/null +++ b/internal/dockerfile/hooks_test.go @@ -0,0 +1,293 @@ +package dockerfile + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadHookTemplates_NoHooksDirectory(t *testing.T) { + // Change to a temp directory where hooks/ doesn't exist + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + tmpl, err := loadHookTemplates() + if err != nil { + t.Fatalf("loadHookTemplates() error = %v, want nil", err) + } + if tmpl == nil { + t.Fatal("loadHookTemplates() returned nil template") + } + + // Should return base templates without hooks + if tmpl.Lookup("dockerfile.tmpl") == nil { + t.Error("loadHookTemplates() should include base templates") + } +} + +func TestLoadHookTemplates_EmptyHooksDirectory(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create empty hooks/ directory + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + tmpl, err := loadHookTemplates() + if err != nil { + t.Fatalf("loadHookTemplates() error = %v, want nil", err) + } + if tmpl == nil { + t.Fatal("loadHookTemplates() returned nil template") + } +} + +func TestLoadHookTemplates_ValidPreHook(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create hooks/ directory with pre-hook + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + preHook := `RUN echo "pre-install setup" +RUN mkdir -p /some/dir` + + if err := os.WriteFile("hooks/test-pre.tmpl", []byte(preHook), 0644); err != nil { + t.Fatal(err) + } + + tmpl, err := loadHookTemplates() + if err != nil { + t.Fatalf("loadHookTemplates() error = %v, want nil", err) + } + + // Verify the template was loaded + if tmpl.Lookup("test-pre.tmpl") == nil { + t.Error("loadHookTemplates() should include test-pre.tmpl") + } +} + +func TestLoadHookTemplates_ValidPostHook(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create hooks/ directory with post-hook + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + postHook := `RUN echo "post-install cleanup" +RUN rm -rf /tmp/install` + + if err := os.WriteFile("hooks/test-post.tmpl", []byte(postHook), 0644); err != nil { + t.Fatal(err) + } + + tmpl, err := loadHookTemplates() + if err != nil { + t.Fatalf("loadHookTemplates() error = %v, want nil", err) + } + + // Verify the template was loaded + if tmpl.Lookup("test-post.tmpl") == nil { + t.Error("loadHookTemplates() should include test-post.tmpl") + } +} + +func TestLoadHookTemplates_BothPreAndPostHooks(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create hooks/ directory + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + preHook := `RUN echo "pre"` + postHook := `RUN echo "post"` + + if err := os.WriteFile("hooks/tool-pre.tmpl", []byte(preHook), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("hooks/tool-post.tmpl", []byte(postHook), 0644); err != nil { + t.Fatal(err) + } + + tmpl, err := loadHookTemplates() + if err != nil { + t.Fatalf("loadHookTemplates() error = %v, want nil", err) + } + + // Verify both templates were loaded + if tmpl.Lookup("tool-pre.tmpl") == nil { + t.Error("loadHookTemplates() should include tool-pre.tmpl") + } + if tmpl.Lookup("tool-post.tmpl") == nil { + t.Error("loadHookTemplates() should include tool-post.tmpl") + } +} + +func TestLoadHookTemplates_MultipleTools(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create hooks/ directory + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + // Create hooks for multiple tools + hooks := map[string]string{ + "nix-pre.tmpl": "RUN echo nix-pre", + "nix-post.tmpl": "RUN echo nix-post", + "helm-pre.tmpl": "RUN echo helm-pre", + "helm-post.tmpl": "RUN echo helm-post", + } + + for name, content := range hooks { + path := filepath.Join("hooks", name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + tmpl, err := loadHookTemplates() + if err != nil { + t.Fatalf("loadHookTemplates() error = %v, want nil", err) + } + + // Verify all templates were loaded + for name := range hooks { + if tmpl.Lookup(name) == nil { + t.Errorf("loadHookTemplates() should include %s", name) + } + } +} + +func TestLoadHookTemplates_IgnoresNonHookFiles(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create hooks/ directory + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + // Create valid hook + if err := os.WriteFile("hooks/tool-pre.tmpl", []byte("RUN echo pre"), 0644); err != nil { + t.Fatal(err) + } + + // Create files that should be ignored + if err := os.WriteFile("hooks/README.md", []byte("# Hooks"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("hooks/example.txt", []byte("example"), 0644); err != nil { + t.Fatal(err) + } + + tmpl, err := loadHookTemplates() + if err != nil { + t.Fatalf("loadHookTemplates() error = %v, want nil", err) + } + + // Verify only the hook template was loaded + if tmpl.Lookup("tool-pre.tmpl") == nil { + t.Error("loadHookTemplates() should include tool-pre.tmpl") + } + if tmpl.Lookup("README.md") != nil { + t.Error("loadHookTemplates() should not include README.md") + } + if tmpl.Lookup("example.txt") != nil { + t.Error("loadHookTemplates() should not include example.txt") + } +} + +func TestLoadHookTemplates_MalformedTemplate(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create hooks/ directory + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + // Create malformed template (invalid Go template syntax) + malformed := `RUN echo "unclosed {{.Variable` + + if err := os.WriteFile("hooks/bad-pre.tmpl", []byte(malformed), 0644); err != nil { + t.Fatal(err) + } + + _, err = loadHookTemplates() + if err == nil { + t.Error("loadHookTemplates() should return error for malformed template") + } +} diff --git a/internal/dockerfile/spec.go b/internal/dockerfile/spec.go index b05cd81..7eaff60 100644 --- a/internal/dockerfile/spec.go +++ b/internal/dockerfile/spec.go @@ -2,6 +2,8 @@ package dockerfile import ( "embed" + "os" + "path/filepath" "slices" "strings" "text/template" @@ -18,6 +20,44 @@ var templates = template.Must( }).ParseFS(templateFS, "tmpl/*.tmpl"), ) +// loadHookTemplates loads optional tool hook templates from the hooks/ directory. +// Called during Dockerfile generation; returns a new template set with hooks added. +// If hooks/ directory doesn't exist, returns the base template set unchanged. +func loadHookTemplates() (*template.Template, error) { + // Clone base templates so we don't modify the global set + t := template.Must(templates.Clone()) + + hooksDir := "hooks" + if _, err := os.Stat(hooksDir); os.IsNotExist(err) { + return t, nil // No hooks directory - return base templates + } + + // Find all pre-hook templates + preMatches, err := filepath.Glob(filepath.Join(hooksDir, "*-pre.tmpl")) + if err != nil { + return nil, err + } + + // Find all post-hook templates + postMatches, err := filepath.Glob(filepath.Join(hooksDir, "*-post.tmpl")) + if err != nil { + return nil, err + } + + // Combine all hook templates + matches := append(preMatches, postMatches...) + + // If we found hook templates, parse them + if len(matches) > 0 { + t, err = t.ParseFiles(matches...) + if err != nil { + return nil, err + } + } + + return t, nil +} + // executeTemplate renders a named template against data and returns the result. // Panics on error: templates are static and data is validated; any failure is a programmer bug. func executeTemplate(name string, data any) string { From e627e0a1264e21a4b0dd5941cf9c4aa5f0b42382 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Mon, 11 May 2026 23:21:35 -0400 Subject: [PATCH 04/11] Extend `ToolSetup` support with pre/post hooks for tools and add comprehensive tests --- internal/dockerfile/build.go | 33 +++ internal/dockerfile/generate_test.go | 90 ++++++++ internal/dockerfile/hooks_test.go | 271 +++++++++++++++++++++++ internal/dockerfile/spec.go | 33 +++ internal/dockerfile/tmpl/dockerfile.tmpl | 6 + 5 files changed, 433 insertions(+) diff --git a/internal/dockerfile/build.go b/internal/dockerfile/build.go index 1f2fc14..31d8f17 100644 --- a/internal/dockerfile/build.go +++ b/internal/dockerfile/build.go @@ -5,6 +5,7 @@ import ( "maps" "slices" "strings" + "text/template" "github.com/rancher/ci-image/internal/config" "github.com/rancher/ci-image/internal/config/renderer" @@ -42,6 +43,12 @@ func (f Format) Validate() error { // cfg.Tools must already have checksums populated for release-checksums tools // (call resolveReleaseChecksums before this). func NewDockerfileVars(cfg *config.Config, img config.Image, sourceURL string) (DockerfileVars, error) { + // Load hook templates from hooks/ directory (if it exists) + hookTemplates, err := loadHookTemplates() + if err != nil { + return DockerfileVars{}, fmt.Errorf("loading hook templates: %w", err) + } + // Collect tools: universal first (in config order), then image-specific. toolsByName := make(map[string]config.Tool, len(cfg.Tools)) for _, t := range cfg.Tools { @@ -76,6 +83,7 @@ func NewDockerfileVars(cfg *config.Config, img config.Image, sourceURL string) ( Name: t.Name, Version: t.Version, Install: install, + Setup: buildToolSetup(t, hookTemplates), }) } if len(errs) > 0 { @@ -215,6 +223,31 @@ func buildGoInstall(t config.Tool) (GoInstall, error) { return GoInstall{Package: pkg}, nil } +// buildToolSetup checks for pre/post hook templates for the given tool. +// Returns nil if no hooks exist, otherwise returns a ToolSetup with template names set. +func buildToolSetup(t config.Tool, tmpl *template.Template) *ToolSetup { + preTemplate := t.Name + "-pre.tmpl" + postTemplate := t.Name + "-post.tmpl" + + hasPre := tmpl.Lookup(preTemplate) != nil + hasPost := tmpl.Lookup(postTemplate) != nil + + if !hasPre && !hasPost { + return nil // No hooks for this tool + } + + setup := &ToolSetup{ + templates: tmpl, + } + if hasPre { + setup.PreTemplate = preTemplate + } + if hasPost { + setup.PostTemplate = postTemplate + } + return setup +} + // detectFormat classifies a download URL into a format based on packaging/compression. // Returns one of: FormatArchive, FormatGzip, FormatExecutable. // For archives, also returns the archive extension (.tar.gz, .zip, etc). diff --git a/internal/dockerfile/generate_test.go b/internal/dockerfile/generate_test.go index 1a95765..8c09283 100644 --- a/internal/dockerfile/generate_test.go +++ b/internal/dockerfile/generate_test.go @@ -1,6 +1,7 @@ package dockerfile import ( + "os" "strings" "testing" @@ -749,3 +750,92 @@ func TestDetectFormat(t *testing.T) { }) } } + +func TestGenerate_WithHooks(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Create hooks/ directory with pre and post hooks for test tool + if err := os.Mkdir("hooks", 0755); err != nil { + t.Fatal(err) + } + + preHook := `RUN echo "Running pre-install hook for testtool" +RUN mkdir -p /opt/testtool-setup` + postHook := `RUN echo "Running post-install hook for testtool" +RUN ln -sf /usr/local/bin/testtool /usr/bin/testtool` + + if err := os.WriteFile("hooks/testtool-pre.tmpl", []byte(preHook), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("hooks/testtool-post.tmpl", []byte(postHook), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Tools: []config.Tool{ + { + Name: "testtool", + Version: "1.0.0", + Source: "owner/repo", + Release: &config.ReleaseConfig{ + DownloadTemplate: "https://github.com/owner/repo/releases/download/v{version}/tool-{os}-{arch}.tar.gz", + Extract: "testtool", + }, + Install: config.InstallConfig{ + Method: config.MethodCurl, + }, + Checksums: map[string]string{ + "linux/amd64": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + }, + }, + } + + img := config.Image{ + Name: "test", + Base: "alpine:3.18", + Platforms: []string{"linux/amd64"}, + Tools: []string{"testtool"}, + } + + vars, err := NewDockerfileVars(cfg, img, "https://github.com/test/repo") + if err != nil { + t.Fatalf("NewDockerfileVars() error = %v", err) + } + + // Verify the tool has setup hooks + if len(vars.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(vars.Tools)) + } + if vars.Tools[0].Setup == nil { + t.Fatal("expected tool to have Setup hooks, got nil") + } + + // Render the Dockerfile + output := vars.Render() + + // Verify pre-hook appears in output + if !strings.Contains(output, "Running pre-install hook for testtool") { + t.Error("Dockerfile should contain pre-hook content") + } + if !strings.Contains(output, "mkdir -p /opt/testtool-setup") { + t.Error("Dockerfile should contain pre-hook commands") + } + + // Verify post-hook appears in output + if !strings.Contains(output, "Running post-install hook for testtool") { + t.Error("Dockerfile should contain post-hook content") + } + if !strings.Contains(output, "ln -sf /usr/local/bin/testtool /usr/bin/testtool") { + t.Error("Dockerfile should contain post-hook commands") + } +} diff --git a/internal/dockerfile/hooks_test.go b/internal/dockerfile/hooks_test.go index 3190a68..66bee11 100644 --- a/internal/dockerfile/hooks_test.go +++ b/internal/dockerfile/hooks_test.go @@ -4,6 +4,9 @@ import ( "os" "path/filepath" "testing" + "text/template" + + "github.com/rancher/ci-image/internal/config" ) func TestLoadHookTemplates_NoHooksDirectory(t *testing.T) { @@ -291,3 +294,271 @@ func TestLoadHookTemplates_MalformedTemplate(t *testing.T) { t.Error("loadHookTemplates() should return error for malformed template") } } + +// ToolSetup tests + +func TestToolSetup_RenderPre_NilSetup(t *testing.T) { + var setup *ToolSetup + result := setup.RenderPre() + if result != "" { + t.Errorf("RenderPre() on nil ToolSetup = %q, want empty string", result) + } +} + +func TestToolSetup_RenderPost_NilSetup(t *testing.T) { + var setup *ToolSetup + result := setup.RenderPost() + if result != "" { + t.Errorf("RenderPost() on nil ToolSetup = %q, want empty string", result) + } +} + +func TestToolSetup_RenderPre_EmptyTemplate(t *testing.T) { + tmpl := template.Must(template.New("").Parse("")) + setup := &ToolSetup{ + PreTemplate: "", + templates: tmpl, + } + result := setup.RenderPre() + if result != "" { + t.Errorf("RenderPre() with empty template = %q, want empty string", result) + } +} + +func TestToolSetup_RenderPost_EmptyTemplate(t *testing.T) { + tmpl := template.Must(template.New("").Parse("")) + setup := &ToolSetup{ + PostTemplate: "", + templates: tmpl, + } + result := setup.RenderPost() + if result != "" { + t.Errorf("RenderPost() with empty template = %q, want empty string", result) + } +} + +func TestToolSetup_RenderPre_ValidTemplate(t *testing.T) { + tmpl := template.Must(template.New("test-pre.tmpl").Parse("RUN echo 'pre-install setup'")) + setup := &ToolSetup{ + PreTemplate: "test-pre.tmpl", + templates: tmpl, + } + + result := setup.RenderPre() + expected := "RUN echo 'pre-install setup'" + if result != expected { + t.Errorf("RenderPre() = %q, want %q", result, expected) + } +} + +func TestToolSetup_RenderPost_ValidTemplate(t *testing.T) { + tmpl := template.Must(template.New("test-post.tmpl").Parse("RUN echo 'post-install cleanup'")) + setup := &ToolSetup{ + PostTemplate: "test-post.tmpl", + templates: tmpl, + } + + result := setup.RenderPost() + expected := "RUN echo 'post-install cleanup'" + if result != expected { + t.Errorf("RenderPost() = %q, want %q", result, expected) + } +} + +func TestToolSetup_RenderPre_MultilineTemplate(t *testing.T) { + content := `RUN useradd -m myuser && \ + mkdir -p /app && \ + chown myuser:myuser /app` + + tmpl := template.Must(template.New("multi-pre.tmpl").Parse(content)) + setup := &ToolSetup{ + PreTemplate: "multi-pre.tmpl", + templates: tmpl, + } + + result := setup.RenderPre() + if result != content { + t.Errorf("RenderPre() did not preserve multiline content") + } +} + +func TestToolSetup_RenderPost_TrimsTrailingNewline(t *testing.T) { + content := "RUN echo 'test'\n\n" + expected := "RUN echo 'test'" + + tmpl := template.Must(template.New("test-post.tmpl").Parse(content)) + setup := &ToolSetup{ + PostTemplate: "test-post.tmpl", + templates: tmpl, + } + + result := setup.RenderPost() + if result != expected { + t.Errorf("RenderPost() = %q, want %q (should trim trailing newlines)", result, expected) + } +} + +func TestToolSetup_RenderPre_TemplateNotFound(t *testing.T) { + tmpl := template.Must(template.New("").Parse("")) + setup := &ToolSetup{ + PreTemplate: "nonexistent.tmpl", + templates: tmpl, + } + + defer func() { + if r := recover(); r == nil { + t.Error("RenderPre() should panic when template not found") + } + }() + + setup.RenderPre() +} + +func TestToolSetup_RenderPost_TemplateNotFound(t *testing.T) { + tmpl := template.Must(template.New("").Parse("")) + setup := &ToolSetup{ + PostTemplate: "nonexistent.tmpl", + templates: tmpl, + } + + defer func() { + if r := recover(); r == nil { + t.Error("RenderPost() should panic when template not found") + } + }() + + setup.RenderPost() +} + +func TestToolSetup_BothPreAndPost(t *testing.T) { + tmpl := template.Must(template.New("tool-pre.tmpl").Parse("RUN echo pre")) + tmpl = template.Must(tmpl.New("tool-post.tmpl").Parse("RUN echo post")) + + setup := &ToolSetup{ + PreTemplate: "tool-pre.tmpl", + PostTemplate: "tool-post.tmpl", + templates: tmpl, + } + + pre := setup.RenderPre() + post := setup.RenderPost() + + if pre != "RUN echo pre" { + t.Errorf("RenderPre() = %q, want %q", pre, "RUN echo pre") + } + if post != "RUN echo post" { + t.Errorf("RenderPost() = %q, want %q", post, "RUN echo post") + } +} + +func TestToolSetup_OnlyPre(t *testing.T) { + tmpl := template.Must(template.New("tool-pre.tmpl").Parse("RUN echo pre")) + setup := &ToolSetup{ + PreTemplate: "tool-pre.tmpl", + PostTemplate: "", // No post template + templates: tmpl, + } + + pre := setup.RenderPre() + post := setup.RenderPost() + + if pre != "RUN echo pre" { + t.Errorf("RenderPre() = %q, want %q", pre, "RUN echo pre") + } + if post != "" { + t.Errorf("RenderPost() = %q, want empty string", post) + } +} + +func TestToolSetup_OnlyPost(t *testing.T) { + tmpl := template.Must(template.New("tool-post.tmpl").Parse("RUN echo post")) + setup := &ToolSetup{ + PreTemplate: "", // No pre template + PostTemplate: "tool-post.tmpl", + templates: tmpl, + } + + pre := setup.RenderPre() + post := setup.RenderPost() + + if pre != "" { + t.Errorf("RenderPre() = %q, want empty string", pre) + } + if post != "RUN echo post" { + t.Errorf("RenderPost() = %q, want %q", post, "RUN echo post") + } +} + +// buildToolSetup tests + +func TestBuildToolSetup_NoHooks(t *testing.T) { + tmpl := template.Must(template.New("").Parse("")) + tool := config.Tool{Name: "kubectl"} + + result := buildToolSetup(tool, tmpl) + + if result != nil { + t.Errorf("buildToolSetup() = %+v, want nil when no hooks exist", result) + } +} + +func TestBuildToolSetup_OnlyPre(t *testing.T) { + tmpl := template.Must(template.New("helm-pre.tmpl").Parse("RUN echo pre")) + tool := config.Tool{Name: "helm"} + + result := buildToolSetup(tool, tmpl) + + if result == nil { + t.Fatal("buildToolSetup() returned nil, want ToolSetup") + } + if result.PreTemplate != "helm-pre.tmpl" { + t.Errorf("buildToolSetup() PreTemplate = %q, want %q", result.PreTemplate, "helm-pre.tmpl") + } + if result.PostTemplate != "" { + t.Errorf("buildToolSetup() PostTemplate = %q, want empty string", result.PostTemplate) + } + if result.templates != tmpl { + t.Error("buildToolSetup() should set templates field") + } +} + +func TestBuildToolSetup_OnlyPost(t *testing.T) { + tmpl := template.Must(template.New("nix-post.tmpl").Parse("RUN echo post")) + tool := config.Tool{Name: "nix"} + + result := buildToolSetup(tool, tmpl) + + if result == nil { + t.Fatal("buildToolSetup() returned nil, want ToolSetup") + } + if result.PreTemplate != "" { + t.Errorf("buildToolSetup() PreTemplate = %q, want empty string", result.PreTemplate) + } + if result.PostTemplate != "nix-post.tmpl" { + t.Errorf("buildToolSetup() PostTemplate = %q, want %q", result.PostTemplate, "nix-post.tmpl") + } + if result.templates != tmpl { + t.Error("buildToolSetup() should set templates field") + } +} + +func TestBuildToolSetup_BothHooks(t *testing.T) { + tmpl := template.Must(template.New("terraform-pre.tmpl").Parse("RUN echo pre")) + tmpl = template.Must(tmpl.New("terraform-post.tmpl").Parse("RUN echo post")) + tool := config.Tool{Name: "terraform"} + + result := buildToolSetup(tool, tmpl) + + if result == nil { + t.Fatal("buildToolSetup() returned nil, want ToolSetup") + } + if result.PreTemplate != "terraform-pre.tmpl" { + t.Errorf("buildToolSetup() PreTemplate = %q, want %q", result.PreTemplate, "terraform-pre.tmpl") + } + if result.PostTemplate != "terraform-post.tmpl" { + t.Errorf("buildToolSetup() PostTemplate = %q, want %q", result.PostTemplate, "terraform-post.tmpl") + } + if result.templates != tmpl { + t.Error("buildToolSetup() should set templates field") + } +} diff --git a/internal/dockerfile/spec.go b/internal/dockerfile/spec.go index 7eaff60..00669fe 100644 --- a/internal/dockerfile/spec.go +++ b/internal/dockerfile/spec.go @@ -113,11 +113,44 @@ type GoInstall struct { func (g GoInstall) Method() string { return "go-install" } func (g GoInstall) Render() string { return "RUN go install " + g.Package } +// ToolSetup describes optional setup steps that run before/after the main install. +// If template names are set, those templates are rendered; otherwise the phase is skipped. +type ToolSetup struct { + PreTemplate string // optional template name for pre-install steps (e.g., "nix-pre.tmpl") + PostTemplate string // optional template name for post-install steps (e.g., "nix-post.tmpl") + templates *template.Template // template set with hooks loaded +} + +// RenderPre renders the pre-install template if present. +func (s *ToolSetup) RenderPre() string { + if s == nil || s.PreTemplate == "" { + return "" + } + var b strings.Builder + if err := s.templates.ExecuteTemplate(&b, s.PreTemplate, nil); err != nil { + panic("dockerfile: executing " + s.PreTemplate + ": " + err.Error()) + } + return strings.TrimRight(b.String(), "\n") +} + +// RenderPost renders the post-install template if present. +func (s *ToolSetup) RenderPost() string { + if s == nil || s.PostTemplate == "" { + return "" + } + var b strings.Builder + if err := s.templates.ExecuteTemplate(&b, s.PostTemplate, nil); err != nil { + panic("dockerfile: executing " + s.PostTemplate + ": " + err.Error()) + } + return strings.TrimRight(b.String(), "\n") +} + // ToolInstall is one resolved tool entry in a Dockerfile. type ToolInstall struct { Name string Version string Install ItemInstall // CurlInstall or GoInstall + Setup *ToolSetup // optional pre/post hooks; nil if no hooks } // AliasInstall describes a symlink to create in /usr/local/bin after tools are installed. diff --git a/internal/dockerfile/tmpl/dockerfile.tmpl b/internal/dockerfile/tmpl/dockerfile.tmpl index 9a3df38..7ca1957 100644 --- a/internal/dockerfile/tmpl/dockerfile.tmpl +++ b/internal/dockerfile/tmpl/dockerfile.tmpl @@ -13,7 +13,13 @@ ENV DO_NOT_TRACK=true {{template "zypper.tmpl" .Packages}} {{range .Tools}}# {{.Name}} {{.Version}} +{{- if .Setup}} +{{.Setup.RenderPre}} +{{end -}} {{.Install.Render}} +{{- if .Setup}} +{{.Setup.RenderPost}} +{{end}} {{end}}{{if .Selectors}}# Family selectors — copy scripts and set up manifest + active symlinks. # /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update From 2e76ecf166c18b86155a234fee74c0da1f653a37 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Mon, 11 May 2026 23:26:55 -0400 Subject: [PATCH 05/11] Add changelog support for hooks --- internal/changelog/changelog.go | 11 +++++ internal/changelog/diff.go | 73 +++++++++++++++++++++++++++++- internal/changelog/types.go | 46 ++++++++++++++----- internal/cli/generate.go | 76 ++++++++++++++++++++++++++++++++ internal/fileutil/atomic.go | 12 +++++ internal/fileutil/atomic_test.go | 28 ++++++++++++ 6 files changed, 233 insertions(+), 13 deletions(-) diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go index c5670a4..611e650 100644 --- a/internal/changelog/changelog.go +++ b/internal/changelog/changelog.go @@ -144,6 +144,17 @@ func renderEntry(entry Entry) string { for _, sc := range ic.SelectorDefaultChanged { fmt.Fprintf(&sb, "- `%s` selector default: `%s` → `%s`\n", sc.Family, sc.From, sc.To) } + for _, hc := range ic.ToolHooksChanged { + switch hc.ChangeType { + case "added": + fmt.Fprintf(&sb, "- `%s`: added %s-install hook (`%s`)\n", hc.Tool, hc.HookType, hc.NewChecksum[:8]) + case "removed": + fmt.Fprintf(&sb, "- `%s`: removed %s-install hook\n", hc.Tool, hc.HookType) + case "modified": + fmt.Fprintf(&sb, "- `%s`: updated %s-install hook (`%s` → `%s`)\n", + hc.Tool, hc.HookType, hc.OldChecksum[:8], hc.NewChecksum[:8]) + } + } if len(entry.Changes.PackagesAdded) > 0 || len(entry.Changes.PackagesRemoved) > 0 { sb.WriteString("- Universal package changes\n") } diff --git a/internal/changelog/diff.go b/internal/changelog/diff.go index 3e268d5..6485f9a 100644 --- a/internal/changelog/diff.go +++ b/internal/changelog/diff.go @@ -123,7 +123,7 @@ func Diff(prev, next *ImagesLock) *Changes { } prevCfg := prev.Configs[imgName] nextCfg := next.Configs[imgName] - ic := computeImageChanges(imgName, prev.Tools, next.Tools, prevCfg, nextCfg) + ic := computeImageChanges(imgName, prev.Tools, next.Tools, prev.Hooks, next.Hooks, prevCfg, nextCfg) if ic.HasChanges() { c.ImageChanges = append(c.ImageChanges, ic) } @@ -132,7 +132,7 @@ func Diff(prev, next *ImagesLock) *Changes { return c } -func computeImageChanges(imgName string, prevTools, nextTools map[string]string, prev, next ImageConfig) ImageChanges { +func computeImageChanges(imgName string, prevTools, nextTools map[string]string, prevHooks, nextHooks map[string]HookFiles, prev, next ImageConfig) ImageChanges { ic := ImageChanges{Image: imgName} if prev.Base != next.Base { @@ -190,6 +190,75 @@ func computeImageChanges(imgName string, prevTools, nextTools map[string]string, slices.SortFunc(ic.ToolsRemoved, func(a, b ToolChange) int { return strings.Compare(a.Tool, b.Tool) }) slices.SortFunc(ic.ToolVersionChanged, func(a, b ToolVersionChange) int { return strings.Compare(a.Tool, b.Tool) }) + // Hook template changes: detect when hooks are added, removed, or modified. + // Only check hooks for tools that are actually in this image. + for _, toolName := range next.Tools { + prevHook := prevHooks[toolName] + nextHook := nextHooks[toolName] + + // Check pre-hook changes + if prevHook.Pre == nil && nextHook.Pre != nil { + // Pre-hook added + ic.ToolHooksChanged = append(ic.ToolHooksChanged, ToolHookChange{ + Tool: toolName, + HookType: "pre", + ChangeType: "added", + NewChecksum: nextHook.Pre.Checksum, + }) + } else if prevHook.Pre != nil && nextHook.Pre == nil { + // Pre-hook removed + ic.ToolHooksChanged = append(ic.ToolHooksChanged, ToolHookChange{ + Tool: toolName, + HookType: "pre", + ChangeType: "removed", + OldChecksum: prevHook.Pre.Checksum, + }) + } else if prevHook.Pre != nil && nextHook.Pre != nil && prevHook.Pre.Checksum != nextHook.Pre.Checksum { + // Pre-hook modified + ic.ToolHooksChanged = append(ic.ToolHooksChanged, ToolHookChange{ + Tool: toolName, + HookType: "pre", + ChangeType: "modified", + OldChecksum: prevHook.Pre.Checksum, + NewChecksum: nextHook.Pre.Checksum, + }) + } + + // Check post-hook changes + if prevHook.Post == nil && nextHook.Post != nil { + // Post-hook added + ic.ToolHooksChanged = append(ic.ToolHooksChanged, ToolHookChange{ + Tool: toolName, + HookType: "post", + ChangeType: "added", + NewChecksum: nextHook.Post.Checksum, + }) + } else if prevHook.Post != nil && nextHook.Post == nil { + // Post-hook removed + ic.ToolHooksChanged = append(ic.ToolHooksChanged, ToolHookChange{ + Tool: toolName, + HookType: "post", + ChangeType: "removed", + OldChecksum: prevHook.Post.Checksum, + }) + } else if prevHook.Post != nil && nextHook.Post != nil && prevHook.Post.Checksum != nextHook.Post.Checksum { + // Post-hook modified + ic.ToolHooksChanged = append(ic.ToolHooksChanged, ToolHookChange{ + Tool: toolName, + HookType: "post", + ChangeType: "modified", + OldChecksum: prevHook.Post.Checksum, + NewChecksum: nextHook.Post.Checksum, + }) + } + } + slices.SortFunc(ic.ToolHooksChanged, func(a, b ToolHookChange) int { + if a.Tool != b.Tool { + return strings.Compare(a.Tool, b.Tool) + } + return strings.Compare(a.HookType, b.HookType) + }) + // Alias diffs: an alias is "removed" if its name disappears or its target changes. for name, prevTarget := range prev.Aliases { nextTarget, ok := next.Aliases[name] diff --git a/internal/changelog/types.go b/internal/changelog/types.go index 248bb6d..054d37a 100644 --- a/internal/changelog/types.go +++ b/internal/changelog/types.go @@ -8,6 +8,7 @@ type ImagesLock struct { Packages []string `yaml:"packages,omitempty"` // universal packages installed in every image Tools map[string]string `yaml:"tools,omitempty"` Selectors []string `yaml:"selectors,omitempty"` // active family selector names, e.g. ["helm"] + Hooks map[string]HookFiles `yaml:"hooks,omitempty"` // tool_name → hook files with checksums Configs map[string]ImageConfig `yaml:"configs"` } @@ -15,7 +16,7 @@ type ImagesLock struct { type ImageConfig struct { Base string `yaml:"base"` Platforms []string `yaml:"platforms"` - Packages []string `yaml:"packages,omitempty"` // image-specific packages only (excludes universal) + Packages []string `yaml:"packages,omitempty"` // image-specific packages only (excludes universal) Tools []string `yaml:"tools,omitempty"` Aliases map[string]string `yaml:"aliases,omitempty"` // symlink_name: tool_name FamilySelectors map[string]string `yaml:"family_selectors,omitempty"` // family → default tool @@ -72,16 +73,17 @@ func (c *Changes) AffectedImages() []string { // ImageChanges holds all the changes for a single image. type ImageChanges struct { - Image string - BaseImageUpdated *BaseImageChange - PlatformsChanged *PlatformsChange - PackagesAdded []string - PackagesRemoved []string - ToolVersionChanged []ToolVersionChange - ToolsAdded []ToolChange - ToolsRemoved []ToolChange - AliasesAdded []AliasChange - AliasesRemoved []AliasChange + Image string + BaseImageUpdated *BaseImageChange + PlatformsChanged *PlatformsChange + PackagesAdded []string + PackagesRemoved []string + ToolVersionChanged []ToolVersionChange + ToolsAdded []ToolChange + ToolsRemoved []ToolChange + ToolHooksChanged []ToolHookChange // hook templates added/removed/modified + AliasesAdded []AliasChange + AliasesRemoved []AliasChange SelectorDefaultChanged []SelectorDefaultChange // family selector default tool changed } @@ -92,6 +94,7 @@ func (ic ImageChanges) HasChanges() bool { len(ic.PackagesAdded) > 0 || len(ic.PackagesRemoved) > 0 || len(ic.ToolVersionChanged) > 0 || len(ic.ToolsAdded) > 0 || len(ic.ToolsRemoved) > 0 || + len(ic.ToolHooksChanged) > 0 || len(ic.AliasesAdded) > 0 || len(ic.AliasesRemoved) > 0 || len(ic.SelectorDefaultChanged) > 0 } @@ -140,3 +143,24 @@ type ToolChange struct { Tool string Version string } + +// HookFile represents a single hook template file with its checksum. +type HookFile struct { + Name string `yaml:"name"` + Checksum string `yaml:"checksum"` // MD5 hex +} + +// HookFiles holds pre and post hook files for a tool. +type HookFiles struct { + Pre *HookFile `yaml:"pre,omitempty"` + Post *HookFile `yaml:"post,omitempty"` +} + +// ToolHookChange records a hook template being added, removed, or modified. +type ToolHookChange struct { + Tool string + HookType string // "pre" or "post" + ChangeType string // "added", "removed", "modified" + OldChecksum string // for "modified" only + NewChecksum string // for "added" and "modified" +} diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 63d6c9b..9f6abd7 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -212,6 +212,7 @@ type imagesLock struct { Packages []string `yaml:"packages,omitempty"` // universal packages installed in every image Tools map[string]string `yaml:"tools,omitempty"` // name → version, all tools across all images Selectors []string `yaml:"selectors,omitempty"` // active family selector names, e.g. ["helm"] + Hooks map[string]hookFiles `yaml:"hooks,omitempty"` // tool_name → hook files with checksums Configs map[string]imageLockConfig `yaml:"configs"` } @@ -241,6 +242,74 @@ func extractGoVersion(base string) string { return tag } +// collectHookFiles scans the hooks/ directory and returns hook file metadata +// indexed by tool name. Returns empty map if hooks/ doesn't exist. +func collectHookFiles() (map[string]hookFiles, error) { + const hooksDir = "hooks" + + // Check if hooks directory exists + if _, err := os.Stat(hooksDir); os.IsNotExist(err) { + return make(map[string]hookFiles), nil + } + + // Find all hook template files + preMatches, err := filepath.Glob(filepath.Join(hooksDir, "*-pre.tmpl")) + if err != nil { + return nil, fmt.Errorf("scanning for pre-hook templates: %w", err) + } + + postMatches, err := filepath.Glob(filepath.Join(hooksDir, "*-post.tmpl")) + if err != nil { + return nil, fmt.Errorf("scanning for post-hook templates: %w", err) + } + + result := make(map[string]hookFiles) + + // Process pre-hook files + for _, path := range preMatches { + toolName := strings.TrimSuffix(filepath.Base(path), "-pre.tmpl") + checksum, err := fileutil.MD5File(path) + if err != nil { + return nil, fmt.Errorf("computing checksum for %s: %w", path, err) + } + + hf := result[toolName] + hf.Pre = &hookFile{ + Name: filepath.Base(path), + Checksum: checksum, + } + result[toolName] = hf + } + + // Process post-hook files + for _, path := range postMatches { + toolName := strings.TrimSuffix(filepath.Base(path), "-post.tmpl") + checksum, err := fileutil.MD5File(path) + if err != nil { + return nil, fmt.Errorf("computing checksum for %s: %w", path, err) + } + + hf := result[toolName] + hf.Post = &hookFile{ + Name: filepath.Base(path), + Checksum: checksum, + } + result[toolName] = hf + } + + return result, nil +} + +type hookFile struct { + Name string `yaml:"name"` + Checksum string `yaml:"checksum"` +} + +type hookFiles struct { + Pre *hookFile `yaml:"pre,omitempty"` + Post *hookFile `yaml:"post,omitempty"` +} + const imagesLockHeader = "# images-lock.yaml — compiled image index generated by 'generate'.\n" + "# Records the active image names, universal packages, tool versions, and\n" + "# per-image configuration (base, platforms, image-specific packages, tools, aliases).\n" + @@ -251,10 +320,17 @@ const imagesLockHeader = "# images-lock.yaml — compiled image index generated // resolved base, platforms, image-specific packages, tool memberships, and // optional metadata such as Go version and description. func writeImagesLock(cfg *config.Config, path string) error { + // Collect hook file metadata + hooks, err := collectHookFiles() + if err != nil { + return fmt.Errorf("collecting hook files: %w", err) + } + lk := imagesLock{ Packages: cfg.Packages, Tools: make(map[string]string), Selectors: dockerfile.FamilySelectorNames(cfg), + Hooks: hooks, Configs: make(map[string]imageLockConfig, len(cfg.Images)), } diff --git a/internal/fileutil/atomic.go b/internal/fileutil/atomic.go index 3eccbfc..8d299f2 100644 --- a/internal/fileutil/atomic.go +++ b/internal/fileutil/atomic.go @@ -2,6 +2,8 @@ package fileutil import ( "bytes" + "crypto/md5" + "encoding/hex" "os" "path/filepath" ) @@ -47,3 +49,13 @@ func AtomicWrite(path string, data []byte, perm os.FileMode) error { } return nil } + +// MD5File computes the MD5 checksum of a file and returns it as a hex string. +func MD5File(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + hash := md5.Sum(data) + return hex.EncodeToString(hash[:]), nil +} diff --git a/internal/fileutil/atomic_test.go b/internal/fileutil/atomic_test.go index b0464e9..eaf200d 100644 --- a/internal/fileutil/atomic_test.go +++ b/internal/fileutil/atomic_test.go @@ -52,3 +52,31 @@ func TestAtomicWrite_Overwrite(t *testing.T) { t.Errorf("AtomicWrite() overwrite got %q, want %q", got, "second\n") } } + +func TestMD5File(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.txt") + + // Test with known content + content := []byte("hello world\n") + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("WriteFile() unexpected error: %v", err) + } + + // Expected MD5 of "hello world\n" is 6f5902ac237024bdd0c176cb93063dc4 + expected := "6f5902ac237024bdd0c176cb93063dc4" + got, err := MD5File(path) + if err != nil { + t.Fatalf("MD5File() unexpected error: %v", err) + } + if got != expected { + t.Errorf("MD5File() = %s, want %s", got, expected) + } +} + +func TestMD5File_NonExistent(t *testing.T) { + _, err := MD5File("/nonexistent/file") + if err == nil { + t.Error("MD5File() on nonexistent file should return error") + } +} From e625ba862c644c187736b7e5feaae4a827847084 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Mon, 11 May 2026 23:41:40 -0400 Subject: [PATCH 06/11] feat: add comment headers for hooks --- internal/dockerfile/build.go | 1 + internal/dockerfile/generate_test.go | 10 ++++- internal/dockerfile/hooks_test.go | 47 +++++++++++++++++------- internal/dockerfile/spec.go | 9 ++++- internal/dockerfile/tmpl/dockerfile.tmpl | 8 +--- 5 files changed, 51 insertions(+), 24 deletions(-) diff --git a/internal/dockerfile/build.go b/internal/dockerfile/build.go index 31d8f17..6fd9901 100644 --- a/internal/dockerfile/build.go +++ b/internal/dockerfile/build.go @@ -237,6 +237,7 @@ func buildToolSetup(t config.Tool, tmpl *template.Template) *ToolSetup { } setup := &ToolSetup{ + Name: t.Name, templates: tmpl, } if hasPre { diff --git a/internal/dockerfile/generate_test.go b/internal/dockerfile/generate_test.go index 8c09283..88557a7 100644 --- a/internal/dockerfile/generate_test.go +++ b/internal/dockerfile/generate_test.go @@ -823,7 +823,10 @@ RUN ln -sf /usr/local/bin/testtool /usr/bin/testtool` // Render the Dockerfile output := vars.Render() - // Verify pre-hook appears in output + // Verify pre-hook appears in output with comment + if !strings.Contains(output, "# Pre-install setup for testtool") { + t.Error("Dockerfile should contain pre-hook comment") + } if !strings.Contains(output, "Running pre-install hook for testtool") { t.Error("Dockerfile should contain pre-hook content") } @@ -831,7 +834,10 @@ RUN ln -sf /usr/local/bin/testtool /usr/bin/testtool` t.Error("Dockerfile should contain pre-hook commands") } - // Verify post-hook appears in output + // Verify post-hook appears in output with comment + if !strings.Contains(output, "# Post-install setup for testtool") { + t.Error("Dockerfile should contain post-hook comment") + } if !strings.Contains(output, "Running post-install hook for testtool") { t.Error("Dockerfile should contain post-hook content") } diff --git a/internal/dockerfile/hooks_test.go b/internal/dockerfile/hooks_test.go index 66bee11..99e65a1 100644 --- a/internal/dockerfile/hooks_test.go +++ b/internal/dockerfile/hooks_test.go @@ -340,12 +340,13 @@ func TestToolSetup_RenderPost_EmptyTemplate(t *testing.T) { func TestToolSetup_RenderPre_ValidTemplate(t *testing.T) { tmpl := template.Must(template.New("test-pre.tmpl").Parse("RUN echo 'pre-install setup'")) setup := &ToolSetup{ + Name: "testtool", PreTemplate: "test-pre.tmpl", templates: tmpl, } result := setup.RenderPre() - expected := "RUN echo 'pre-install setup'" + expected := "\n# Pre-install setup for testtool\nRUN echo 'pre-install setup'\n\n" if result != expected { t.Errorf("RenderPre() = %q, want %q", result, expected) } @@ -354,12 +355,13 @@ func TestToolSetup_RenderPre_ValidTemplate(t *testing.T) { func TestToolSetup_RenderPost_ValidTemplate(t *testing.T) { tmpl := template.Must(template.New("test-post.tmpl").Parse("RUN echo 'post-install cleanup'")) setup := &ToolSetup{ + Name: "testtool", PostTemplate: "test-post.tmpl", templates: tmpl, } result := setup.RenderPost() - expected := "RUN echo 'post-install cleanup'" + expected := "\n\n# Post-install setup for testtool\nRUN echo 'post-install cleanup'" if result != expected { t.Errorf("RenderPost() = %q, want %q", result, expected) } @@ -372,27 +374,30 @@ func TestToolSetup_RenderPre_MultilineTemplate(t *testing.T) { tmpl := template.Must(template.New("multi-pre.tmpl").Parse(content)) setup := &ToolSetup{ + Name: "testtool", PreTemplate: "multi-pre.tmpl", templates: tmpl, } result := setup.RenderPre() - if result != content { - t.Errorf("RenderPre() did not preserve multiline content") + expected := "\n# Pre-install setup for testtool\n" + content + "\n\n" + if result != expected { + t.Errorf("RenderPre() = %q, want %q", result, expected) } } func TestToolSetup_RenderPost_TrimsTrailingNewline(t *testing.T) { content := "RUN echo 'test'\n\n" - expected := "RUN echo 'test'" tmpl := template.Must(template.New("test-post.tmpl").Parse(content)) setup := &ToolSetup{ + Name: "testtool", PostTemplate: "test-post.tmpl", templates: tmpl, } result := setup.RenderPost() + expected := "\n\n# Post-install setup for testtool\nRUN echo 'test'" if result != expected { t.Errorf("RenderPost() = %q, want %q (should trim trailing newlines)", result, expected) } @@ -435,6 +440,7 @@ func TestToolSetup_BothPreAndPost(t *testing.T) { tmpl = template.Must(tmpl.New("tool-post.tmpl").Parse("RUN echo post")) setup := &ToolSetup{ + Name: "testtool", PreTemplate: "tool-pre.tmpl", PostTemplate: "tool-post.tmpl", templates: tmpl, @@ -443,17 +449,20 @@ func TestToolSetup_BothPreAndPost(t *testing.T) { pre := setup.RenderPre() post := setup.RenderPost() - if pre != "RUN echo pre" { - t.Errorf("RenderPre() = %q, want %q", pre, "RUN echo pre") + expectedPre := "\n# Pre-install setup for testtool\nRUN echo pre\n\n" + expectedPost := "\n\n# Post-install setup for testtool\nRUN echo post" + if pre != expectedPre { + t.Errorf("RenderPre() = %q, want %q", pre, expectedPre) } - if post != "RUN echo post" { - t.Errorf("RenderPost() = %q, want %q", post, "RUN echo post") + if post != expectedPost { + t.Errorf("RenderPost() = %q, want %q", post, expectedPost) } } func TestToolSetup_OnlyPre(t *testing.T) { tmpl := template.Must(template.New("tool-pre.tmpl").Parse("RUN echo pre")) setup := &ToolSetup{ + Name: "testtool", PreTemplate: "tool-pre.tmpl", PostTemplate: "", // No post template templates: tmpl, @@ -462,8 +471,9 @@ func TestToolSetup_OnlyPre(t *testing.T) { pre := setup.RenderPre() post := setup.RenderPost() - if pre != "RUN echo pre" { - t.Errorf("RenderPre() = %q, want %q", pre, "RUN echo pre") + expectedPre := "\n# Pre-install setup for testtool\nRUN echo pre\n\n" + if pre != expectedPre { + t.Errorf("RenderPre() = %q, want %q", pre, expectedPre) } if post != "" { t.Errorf("RenderPost() = %q, want empty string", post) @@ -473,6 +483,7 @@ func TestToolSetup_OnlyPre(t *testing.T) { func TestToolSetup_OnlyPost(t *testing.T) { tmpl := template.Must(template.New("tool-post.tmpl").Parse("RUN echo post")) setup := &ToolSetup{ + Name: "testtool", PreTemplate: "", // No pre template PostTemplate: "tool-post.tmpl", templates: tmpl, @@ -484,8 +495,9 @@ func TestToolSetup_OnlyPost(t *testing.T) { if pre != "" { t.Errorf("RenderPre() = %q, want empty string", pre) } - if post != "RUN echo post" { - t.Errorf("RenderPost() = %q, want %q", post, "RUN echo post") + expectedPost := "\n\n# Post-install setup for testtool\nRUN echo post" + if post != expectedPost { + t.Errorf("RenderPost() = %q, want %q", post, expectedPost) } } @@ -511,6 +523,9 @@ func TestBuildToolSetup_OnlyPre(t *testing.T) { if result == nil { t.Fatal("buildToolSetup() returned nil, want ToolSetup") } + if result.Name != "helm" { + t.Errorf("buildToolSetup() Name = %q, want %q", result.Name, "helm") + } if result.PreTemplate != "helm-pre.tmpl" { t.Errorf("buildToolSetup() PreTemplate = %q, want %q", result.PreTemplate, "helm-pre.tmpl") } @@ -531,6 +546,9 @@ func TestBuildToolSetup_OnlyPost(t *testing.T) { if result == nil { t.Fatal("buildToolSetup() returned nil, want ToolSetup") } + if result.Name != "nix" { + t.Errorf("buildToolSetup() Name = %q, want %q", result.Name, "nix") + } if result.PreTemplate != "" { t.Errorf("buildToolSetup() PreTemplate = %q, want empty string", result.PreTemplate) } @@ -552,6 +570,9 @@ func TestBuildToolSetup_BothHooks(t *testing.T) { if result == nil { t.Fatal("buildToolSetup() returned nil, want ToolSetup") } + if result.Name != "terraform" { + t.Errorf("buildToolSetup() Name = %q, want %q", result.Name, "terraform") + } if result.PreTemplate != "terraform-pre.tmpl" { t.Errorf("buildToolSetup() PreTemplate = %q, want %q", result.PreTemplate, "terraform-pre.tmpl") } diff --git a/internal/dockerfile/spec.go b/internal/dockerfile/spec.go index 00669fe..be7b06a 100644 --- a/internal/dockerfile/spec.go +++ b/internal/dockerfile/spec.go @@ -116,12 +116,14 @@ func (g GoInstall) Render() string { return "RUN go install " + g.Package } // ToolSetup describes optional setup steps that run before/after the main install. // If template names are set, those templates are rendered; otherwise the phase is skipped. type ToolSetup struct { + Name string // tool name for comment generation PreTemplate string // optional template name for pre-install steps (e.g., "nix-pre.tmpl") PostTemplate string // optional template name for post-install steps (e.g., "nix-post.tmpl") templates *template.Template // template set with hooks loaded } // RenderPre renders the pre-install template if present. +// Returns the hook content with a blank line, comment header, and trailing blank line. func (s *ToolSetup) RenderPre() string { if s == nil || s.PreTemplate == "" { return "" @@ -130,10 +132,12 @@ func (s *ToolSetup) RenderPre() string { if err := s.templates.ExecuteTemplate(&b, s.PreTemplate, nil); err != nil { panic("dockerfile: executing " + s.PreTemplate + ": " + err.Error()) } - return strings.TrimRight(b.String(), "\n") + content := strings.TrimRight(b.String(), "\n") + return "\n# Pre-install setup for " + s.Name + "\n" + content + "\n\n" } // RenderPost renders the post-install template if present. +// Returns the hook content with leading blank line and comment header. func (s *ToolSetup) RenderPost() string { if s == nil || s.PostTemplate == "" { return "" @@ -142,7 +146,8 @@ func (s *ToolSetup) RenderPost() string { if err := s.templates.ExecuteTemplate(&b, s.PostTemplate, nil); err != nil { panic("dockerfile: executing " + s.PostTemplate + ": " + err.Error()) } - return strings.TrimRight(b.String(), "\n") + content := strings.TrimRight(b.String(), "\n") + return "\n\n# Post-install setup for " + s.Name + "\n" + content } // ToolInstall is one resolved tool entry in a Dockerfile. diff --git a/internal/dockerfile/tmpl/dockerfile.tmpl b/internal/dockerfile/tmpl/dockerfile.tmpl index 7ca1957..89ea5a5 100644 --- a/internal/dockerfile/tmpl/dockerfile.tmpl +++ b/internal/dockerfile/tmpl/dockerfile.tmpl @@ -13,13 +13,7 @@ ENV DO_NOT_TRACK=true {{template "zypper.tmpl" .Packages}} {{range .Tools}}# {{.Name}} {{.Version}} -{{- if .Setup}} -{{.Setup.RenderPre}} -{{end -}} -{{.Install.Render}} -{{- if .Setup}} -{{.Setup.RenderPost}} -{{end}} +{{.Setup.RenderPre}}{{.Install.Render}}{{.Setup.RenderPost}} {{end}}{{if .Selectors}}# Family selectors — copy scripts and set up manifest + active symlinks. # /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update From 49c27f4f94a5bd2590df73cbf6af79742df0c588 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Mon, 11 May 2026 23:43:22 -0400 Subject: [PATCH 07/11] docs: add advanced features section for tool installation hooks --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index 60629ce..7d8e6d0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,54 @@ jobs: Pin to a specific date-stamped tag (e.g. `go1.26:20240419-42`) for fully reproducible workflows. +## Advanced Features + +### Tool Installation Hooks + +You can customize tool installation by adding pre-install or post-install hooks. Hooks are Go templates placed in the `hooks/` directory that inject additional Dockerfile commands before or after a tool is installed. + +**Hook naming convention:** +- `hooks/{toolname}-pre.tmpl` - runs before tool installation +- `hooks/{toolname}-post.tmpl` - runs after tool installation + +**Use cases:** +- Create directories or configuration files before installation +- Run custom install scripts that come with the tool +- Set up environment variables or permissions +- Clean up temporary files after installation + +**Example pre-hook:** +```dockerfile +# hooks/mytool-pre.tmpl +RUN mkdir -p /opt/mytool-config +RUN echo "config_option=value" > /opt/mytool-config/settings.conf +``` + +**Example post-hook:** +```dockerfile +# hooks/mytool-post.tmpl +RUN /var/ci-tools/mytool/install.sh --daemon +RUN chmod +x /usr/local/bin/mytool +``` + +**Accessing tool files:** For tools that include install scripts or other files, set `install_to_path: false` in the `release:` block in `deps.yaml`. This extracts the tool to `/var/ci-tools/{toolname}/` instead of installing directly to `/usr/local/bin`, making all files available to hooks: + +```yaml +tools: + - name: mytool + source: owner/repo + version: v1.0.0 + checksums: + linux/amd64: "checksum-here" + linux/arm64: "checksum-here" + release: + download_template: "mytool-{version}-{os}-{arch}.tar.gz" + extract: "mytool-{version}-{os}-{arch}" + install_to_path: false # Extract to /var/ci-tools/mytool/ for hooks +``` + +Hook changes are automatically tracked in the changelog and `images-lock.yaml` with MD5 checksums, ensuring any modification triggers a rebuild of affected images. + --- For adding tools, new Go versions, or modifying the build system, see [CONTRIBUTING.md](CONTRIBUTING.md). From 0b3ea3aa2606405afca647aa811fdf8072fb5fc7 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Tue, 12 May 2026 00:14:47 -0400 Subject: [PATCH 08/11] fix: update `curl_archive.tmpl` to correct tar extraction and cleanup logic --- internal/dockerfile/tmpl/curl_archive.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/dockerfile/tmpl/curl_archive.tmpl b/internal/dockerfile/tmpl/curl_archive.tmpl index 0aae3ce..4b226f3 100644 --- a/internal/dockerfile/tmpl/curl_archive.tmpl +++ b/internal/dockerfile/tmpl/curl_archive.tmpl @@ -28,6 +28,6 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${INSTALL_DIR}/checksum.sha256" && \ sha256sum -c "${INSTALL_DIR}/checksum.sha256" && \ cd "${INSTALL_DIR}" && \ - {{extractCmd .ArchiveExt}} && \ - rm "${TMP_FILE}" "${INSTALL_DIR}/checksum.sha256" + tar xf "{{.Name}}{{.ArchiveExt}}" && \ + rm "{{.Name}}{{.ArchiveExt}}" checksum.sha256 {{- end}} \ No newline at end of file From 96007fdf75f980c11a3b5d6c3970f64da45646ab Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Tue, 12 May 2026 00:21:55 -0400 Subject: [PATCH 09/11] feat: improve runner setup and tool extraction permissions in Dockerfile templates --- internal/dockerfile/tmpl/curl_archive.tmpl | 1 + internal/dockerfile/tmpl/dockerfile.tmpl | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/dockerfile/tmpl/curl_archive.tmpl b/internal/dockerfile/tmpl/curl_archive.tmpl index 4b226f3..8f0a307 100644 --- a/internal/dockerfile/tmpl/curl_archive.tmpl +++ b/internal/dockerfile/tmpl/curl_archive.tmpl @@ -29,5 +29,6 @@ RUN case "${ARCH}" in \ sha256sum -c "${INSTALL_DIR}/checksum.sha256" && \ cd "${INSTALL_DIR}" && \ tar xf "{{.Name}}{{.ArchiveExt}}" && \ + chmod -R a+rX . && \ rm "{{.Name}}{{.ArchiveExt}}" checksum.sha256 {{- end}} \ No newline at end of file diff --git a/internal/dockerfile/tmpl/dockerfile.tmpl b/internal/dockerfile/tmpl/dockerfile.tmpl index 89ea5a5..4e9d620 100644 --- a/internal/dockerfile/tmpl/dockerfile.tmpl +++ b/internal/dockerfile/tmpl/dockerfile.tmpl @@ -12,6 +12,15 @@ ENV DO_NOT_TRACK=true {{end}} {{template "zypper.tmpl" .Packages}} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + {{range .Tools}}# {{.Name}} {{.Version}} {{.Setup.RenderPre}}{{.Install.Render}}{{.Setup.RenderPost}} @@ -29,14 +38,9 @@ RUN {{range $i, $a := .Aliases}}{{if $i}} && \ {{end}}{{if .HasGoInstall}}# Cleanup Go caches RUN go clean -cache -modcache -{{end}}# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -{{- if .Selectors}} -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && {{.SelectorSetupCmd}} -{{- else}} -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +{{end}}{{- if .Selectors}} +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN {{.SelectorSetupCmd}} {{- end}} {{if .HasAnyOfPackages "git" "git-core"}}# We trust our base image and the repos that are pulled in workflows. Otherwise From 56ff726f056c1f8bc987d7907677df539287190d Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Tue, 12 May 2026 00:23:50 -0400 Subject: [PATCH 10/11] generate --- dockerfiles/Dockerfile.charts | 17 ++++++++++++----- dockerfiles/Dockerfile.go1.25 | 17 ++++++++++++----- dockerfiles/Dockerfile.go1.26 | 17 ++++++++++++----- dockerfiles/Dockerfile.node22 | 17 ++++++++++++----- dockerfiles/Dockerfile.node24 | 17 ++++++++++++----- dockerfiles/Dockerfile.python3.11 | 17 ++++++++++++----- dockerfiles/Dockerfile.python3.13 | 17 ++++++++++++----- 7 files changed, 84 insertions(+), 35 deletions(-) diff --git a/dockerfiles/Dockerfile.charts b/dockerfiles/Dockerfile.charts index 10215ec..4ffb464 100644 --- a/dockerfiles/Dockerfile.charts +++ b/dockerfiles/Dockerfile.charts @@ -30,6 +30,15 @@ RUN zypper -n refresh && \ zypper -n clean -a && \ rm -rf /var/log/{lastlog,tallylog,zypper.log,zypp/history,YaST2} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + # cosign v3.0.6 RUN case "${ARCH}" in \ amd64) CHECKSUM="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" ;; \ @@ -221,11 +230,9 @@ COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select -# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && mkdir -p /var/ci-tools/active \ + +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN mkdir -p /var/ci-tools/active \ && mkdir -p /usr/local/share/ci-tools/families/helm \ && touch /usr/local/share/ci-tools/families/helm/helmv3 \ && touch /usr/local/share/ci-tools/families/helm/helmv4 \ diff --git a/dockerfiles/Dockerfile.go1.25 b/dockerfiles/Dockerfile.go1.25 index 008b5d5..dfd9c77 100644 --- a/dockerfiles/Dockerfile.go1.25 +++ b/dockerfiles/Dockerfile.go1.25 @@ -30,6 +30,15 @@ RUN zypper -n refresh && \ zypper -n clean -a && \ rm -rf /var/log/{lastlog,tallylog,zypper.log,zypp/history,YaST2} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + # cosign v3.0.6 RUN case "${ARCH}" in \ amd64) CHECKSUM="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" ;; \ @@ -196,11 +205,9 @@ RUN ln -sf /usr/local/bin/helmv3 /usr/local/bin/helm_v3 # Cleanup Go caches RUN go clean -cache -modcache -# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && mkdir -p /var/ci-tools/active \ + +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN mkdir -p /var/ci-tools/active \ && mkdir -p /usr/local/share/ci-tools/families/helm \ && touch /usr/local/share/ci-tools/families/helm/helmv3 \ && touch /usr/local/share/ci-tools/families/helm/helmv4 \ diff --git a/dockerfiles/Dockerfile.go1.26 b/dockerfiles/Dockerfile.go1.26 index 1be27fe..246cb83 100644 --- a/dockerfiles/Dockerfile.go1.26 +++ b/dockerfiles/Dockerfile.go1.26 @@ -30,6 +30,15 @@ RUN zypper -n refresh && \ zypper -n clean -a && \ rm -rf /var/log/{lastlog,tallylog,zypper.log,zypp/history,YaST2} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + # cosign v3.0.6 RUN case "${ARCH}" in \ amd64) CHECKSUM="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" ;; \ @@ -196,11 +205,9 @@ RUN ln -sf /usr/local/bin/helmv3 /usr/local/bin/helm_v3 # Cleanup Go caches RUN go clean -cache -modcache -# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && mkdir -p /var/ci-tools/active \ + +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN mkdir -p /var/ci-tools/active \ && mkdir -p /usr/local/share/ci-tools/families/helm \ && touch /usr/local/share/ci-tools/families/helm/helmv3 \ && touch /usr/local/share/ci-tools/families/helm/helmv4 \ diff --git a/dockerfiles/Dockerfile.node22 b/dockerfiles/Dockerfile.node22 index 54501b7..9055365 100644 --- a/dockerfiles/Dockerfile.node22 +++ b/dockerfiles/Dockerfile.node22 @@ -28,6 +28,15 @@ RUN zypper -n refresh && \ zypper -n clean -a && \ rm -rf /var/log/{lastlog,tallylog,zypper.log,zypp/history,YaST2} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + # cosign v3.0.6 RUN case "${ARCH}" in \ amd64) CHECKSUM="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" ;; \ @@ -128,11 +137,9 @@ COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select -# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && mkdir -p /var/ci-tools/active \ + +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN mkdir -p /var/ci-tools/active \ && mkdir -p /usr/local/share/ci-tools/families/helm \ && touch /usr/local/share/ci-tools/families/helm/helmv3 \ && touch /usr/local/share/ci-tools/families/helm/helmv4 \ diff --git a/dockerfiles/Dockerfile.node24 b/dockerfiles/Dockerfile.node24 index baebbb2..78cd1f5 100644 --- a/dockerfiles/Dockerfile.node24 +++ b/dockerfiles/Dockerfile.node24 @@ -28,6 +28,15 @@ RUN zypper -n refresh && \ zypper -n clean -a && \ rm -rf /var/log/{lastlog,tallylog,zypper.log,zypp/history,YaST2} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + # cosign v3.0.6 RUN case "${ARCH}" in \ amd64) CHECKSUM="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" ;; \ @@ -128,11 +137,9 @@ COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select -# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && mkdir -p /var/ci-tools/active \ + +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN mkdir -p /var/ci-tools/active \ && mkdir -p /usr/local/share/ci-tools/families/helm \ && touch /usr/local/share/ci-tools/families/helm/helmv3 \ && touch /usr/local/share/ci-tools/families/helm/helmv4 \ diff --git a/dockerfiles/Dockerfile.python3.11 b/dockerfiles/Dockerfile.python3.11 index 4286fb3..b0ba50e 100644 --- a/dockerfiles/Dockerfile.python3.11 +++ b/dockerfiles/Dockerfile.python3.11 @@ -28,6 +28,15 @@ RUN zypper -n refresh && \ zypper -n clean -a && \ rm -rf /var/log/{lastlog,tallylog,zypper.log,zypp/history,YaST2} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + # cosign v3.0.6 RUN case "${ARCH}" in \ amd64) CHECKSUM="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" ;; \ @@ -128,11 +137,9 @@ COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select -# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && mkdir -p /var/ci-tools/active \ + +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN mkdir -p /var/ci-tools/active \ && mkdir -p /usr/local/share/ci-tools/families/helm \ && touch /usr/local/share/ci-tools/families/helm/helmv3 \ && touch /usr/local/share/ci-tools/families/helm/helmv4 \ diff --git a/dockerfiles/Dockerfile.python3.13 b/dockerfiles/Dockerfile.python3.13 index 26a2d69..d5fa8ad 100644 --- a/dockerfiles/Dockerfile.python3.13 +++ b/dockerfiles/Dockerfile.python3.13 @@ -28,6 +28,15 @@ RUN zypper -n refresh && \ zypper -n clean -a && \ rm -rf /var/log/{lastlog,tallylog,zypper.log,zypp/history,YaST2} +# Create runner group (GID 121) and user (UID 1001) early for use in tool installations. +# /var/ci-tools/ is set up with setgid (2755) so subdirectories inherit the runner group. +# This allows any user added to the runner group to access tools extracted to /var/ci-tools/. +RUN groupadd -g 121 runner && \ + useradd -u 1001 -g 121 -m runner && \ + mkdir -p /var/ci-tools && \ + chown root:runner /var/ci-tools && \ + chmod 2755 /var/ci-tools + # cosign v3.0.6 RUN case "${ARCH}" in \ amd64) CHECKSUM="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" ;; \ @@ -128,11 +137,9 @@ COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select -# Create a new group with GID 121 and a new user with UID 1001, add the user -# to the group, create a home directory for the user. -# Also set up CI tool family infrastructure (requires runner group to exist). -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ - && mkdir -p /var/ci-tools/active \ + +# Set up CI tool family infrastructure (runner user and group created earlier). +RUN mkdir -p /var/ci-tools/active \ && mkdir -p /usr/local/share/ci-tools/families/helm \ && touch /usr/local/share/ci-tools/families/helm/helmv3 \ && touch /usr/local/share/ci-tools/families/helm/helmv4 \ From a04902a25c4dc130d404eb6204e96faee2c08479 Mon Sep 17 00:00:00 2001 From: Dan Pock Date: Tue, 12 May 2026 00:29:44 -0400 Subject: [PATCH 11/11] fix archive bug --- internal/dockerfile/tmpl/curl_archive.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/dockerfile/tmpl/curl_archive.tmpl b/internal/dockerfile/tmpl/curl_archive.tmpl index 8f0a307..518b169 100644 --- a/internal/dockerfile/tmpl/curl_archive.tmpl +++ b/internal/dockerfile/tmpl/curl_archive.tmpl @@ -28,7 +28,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${INSTALL_DIR}/checksum.sha256" && \ sha256sum -c "${INSTALL_DIR}/checksum.sha256" && \ cd "${INSTALL_DIR}" && \ - tar xf "{{.Name}}{{.ArchiveExt}}" && \ + tar xf "${TMP_FILE}" && \ chmod -R a+rX . && \ - rm "{{.Name}}{{.ArchiveExt}}" checksum.sha256 + rm "${TMP_FILE}" "${INSTALL_DIR}/checksum.sha256" {{- end}} \ No newline at end of file