Extract imagemgr subpackage with pure image helpers
Moves the stateless helpers of the image subsystem into internal/daemon/imagemgr: paths.go — path validators (ValidateRegisterPaths, ValidatePromotePaths), artifact staging (StageBootArtifacts, StageOptionalArtifactPath), metadata (BuildMetadataPackages, WritePackagesMetadata). build.go — ResizeRootfs, WriteBuildLog, and the full guest provisioning script generator (BuildProvisionScript, BuildModulesCommand and all private script-append helpers) along with the mise/tmux/opencode version constants. The orchestrator methods (BuildImage, RegisterImage, PromoteImage, DeleteImage, runImageBuildNative) stay on *Daemon: they still touch d.store, d.imageOpsMu, d.beginOperation, capability hooks, and fcproc-wrapped Daemon helpers — extracting them needs prerequisite phases (operation protocol, workdisk helpers, tap pool). This commit is strictly the pure-helper extraction that can land cleanly today. imagebuild.go shrinks from 453 -> 225 LOC (half gone). images.go shrinks from 450 -> 374 LOC. imagebuild_test.go updated to call the exported imagemgr.BuildProvisionScript. Zero behavior change; all tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e989914dd
commit
c13c8b11af
5 changed files with 380 additions and 326 deletions
|
|
@ -1,42 +1,22 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"banger/internal/daemon/imagemgr"
|
||||
"banger/internal/firecracker"
|
||||
"banger/internal/guest"
|
||||
"banger/internal/guestnet"
|
||||
"banger/internal/hostnat"
|
||||
"banger/internal/imagepreset"
|
||||
"banger/internal/model"
|
||||
"banger/internal/opencode"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vsockagent"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMiseVersion = "v2025.12.0"
|
||||
defaultMiseInstallPath = "/usr/local/bin/mise"
|
||||
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
|
||||
defaultNodeTool = "node@22"
|
||||
defaultOpenCodeTool = "github:anomalyco/opencode"
|
||||
defaultClaudeCodeTool = "npm:@anthropic-ai/claude-code"
|
||||
defaultPiTool = "npm:@mariozechner/pi-coding-agent"
|
||||
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
|
||||
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
|
||||
defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum"
|
||||
defaultTMUXPluginDir = "/root/.tmux/plugins"
|
||||
defaultTMUXResurrectDir = "/root/.tmux/resurrect"
|
||||
tmuxManagedBlockStart = "# >>> banger tmux plugins >>>"
|
||||
tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type imageBuildSpec struct {
|
||||
|
|
@ -73,7 +53,7 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
|
|||
return err
|
||||
}
|
||||
if spec.Size != "" {
|
||||
if err := resizeRootfs(spec.SourceRootfs, spec.RootfsPath, spec.Size); err != nil {
|
||||
if err := imagemgr.ResizeRootfs(spec.SourceRootfs, spec.RootfsPath, spec.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -117,27 +97,27 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeBuildLog(spec.BuildLog, "installing vsock agent"); err != nil {
|
||||
if err := imagemgr.WriteBuildLog(spec.BuildLog, "installing vsock agent"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.UploadFile(ctx, vsockagent.GuestInstallPath, 0o755, helperBytes, spec.BuildLog); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil {
|
||||
if err := imagemgr.WriteBuildLog(spec.BuildLog, "configuring guest"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), spec.Packages, spec.InstallDocker), spec.BuildLog); err != nil {
|
||||
if err := client.RunScript(ctx, imagemgr.BuildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), spec.Packages, spec.InstallDocker), spec.BuildLog); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(spec.ModulesDir) != "" {
|
||||
if err := writeBuildLog(spec.BuildLog, "copying kernel modules"); err != nil {
|
||||
if err := imagemgr.WriteBuildLog(spec.BuildLog, "copying kernel modules"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.StreamTar(ctx, spec.ModulesDir, buildModulesCommand(filepath.Base(spec.ModulesDir)), spec.BuildLog); err != nil {
|
||||
if err := client.StreamTar(ctx, spec.ModulesDir, imagemgr.BuildModulesCommand(filepath.Base(spec.ModulesDir)), spec.BuildLog); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeBuildLog(spec.BuildLog, "shutting down guest"); err != nil {
|
||||
if err := imagemgr.WriteBuildLog(spec.BuildLog, "shutting down guest"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.RunScript(ctx, "set -e\nsync\n", spec.BuildLog); err != nil {
|
||||
|
|
@ -146,21 +126,6 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
|
|||
return d.shutdownImageBuildVM(ctx, vm)
|
||||
}
|
||||
|
||||
func resizeRootfs(baseRootfs, rootfsPath, sizeSpec string) error {
|
||||
sizeBytes, err := model.ParseSize(sizeSpec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(baseRootfs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sizeBytes < info.Size() {
|
||||
return fmt.Errorf("size must be >= base image size")
|
||||
}
|
||||
return system.ResizeExt4Image(context.Background(), system.NewRunner(), rootfsPath, sizeBytes)
|
||||
}
|
||||
|
||||
func (d *Daemon) startImageBuildVM(ctx context.Context, spec imageBuildSpec) (imageBuildVM, func(context.Context) error, error) {
|
||||
if err := d.ensureBridge(ctx); err != nil {
|
||||
return imageBuildVM{}, nil, err
|
||||
|
|
@ -258,196 +223,3 @@ func (d *Daemon) shutdownImageBuildVM(ctx context.Context, vm imageBuildVM) erro
|
|||
}
|
||||
return d.waitForExit(ctx, vm.PID, vm.APISock, 15*time.Second)
|
||||
}
|
||||
|
||||
func buildProvisionScript(vmName, dnsServer, authorizedKey string, packages []string, installDocker bool) string {
|
||||
var script bytes.Buffer
|
||||
script.WriteString("set -euo pipefail\n")
|
||||
fmt.Fprintf(&script, "printf 'nameserver %%s\\n' %s > /etc/resolv.conf\n", shellQuote(dnsServer))
|
||||
fmt.Fprintf(&script, "printf '%%s\\n' %s > /etc/hostname\n", shellQuote(vmName))
|
||||
fmt.Fprintf(&script, "printf '127.0.0.1 localhost\\n127.0.1.1 %%s\\n' %s > /etc/hosts\n", shellQuote(vmName))
|
||||
script.WriteString("touch /etc/fstab\n")
|
||||
script.WriteString("sed -i '\\|^/dev/vdb[[:space:]]\\+/home[[:space:]]|d; \\|^/dev/vdc[[:space:]]\\+/var[[:space:]]|d' /etc/fstab\n")
|
||||
script.WriteString("if ! grep -q '^tmpfs /run ' /etc/fstab; then echo 'tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0' >> /etc/fstab; fi\n")
|
||||
script.WriteString("if ! grep -q '^tmpfs /tmp ' /etc/fstab; then echo 'tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0' >> /etc/fstab; fi\n")
|
||||
appendAuthorizedKeySetup(&script, authorizedKey)
|
||||
script.WriteString("apt-get update\n")
|
||||
script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y upgrade\n")
|
||||
fmt.Fprintf(&script, "PACKAGES=%s\n", shellArray(packages))
|
||||
script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y install \"${PACKAGES[@]}\"\n")
|
||||
appendGuestNetworkSetup(&script)
|
||||
appendMiseSetup(&script)
|
||||
appendOpenCodeServiceSetup(&script)
|
||||
appendTmuxSetup(&script)
|
||||
appendVSockPingSetup(&script)
|
||||
if installDocker {
|
||||
script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y remove containerd || true\n")
|
||||
script.WriteString("if ! DEBIAN_FRONTEND=noninteractive apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then\n")
|
||||
script.WriteString(" DEBIAN_FRONTEND=noninteractive apt-get -y install docker.io\n")
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker || true; fi\n")
|
||||
}
|
||||
appendGuestCleanup(&script)
|
||||
script.WriteString("git config --system init.defaultBranch main\n")
|
||||
return script.String()
|
||||
}
|
||||
|
||||
func appendAuthorizedKeySetup(script *bytes.Buffer, authorizedKey string) {
|
||||
script.WriteString("mkdir -p /root/.ssh\n")
|
||||
script.WriteString("chmod 700 /root/.ssh\n")
|
||||
script.WriteString("cat > /root/.ssh/authorized_keys <<'EOF'\n")
|
||||
script.WriteString(strings.TrimSpace(authorizedKey))
|
||||
script.WriteString("\nEOF\n")
|
||||
script.WriteString("chmod 600 /root/.ssh/authorized_keys\n")
|
||||
}
|
||||
|
||||
func buildModulesCommand(modulesBase string) string {
|
||||
return fmt.Sprintf("bash -se <<'EOF'\nset -euo pipefail\nmkdir -p /lib/modules\ntar -C /lib/modules -xf -\ndepmod -a %s\nmkdir -p /etc/modules-load.d\nprintf 'nf_tables\\nnft_chain_nat\\nveth\\nbr_netfilter\\noverlay\\n' > /etc/modules-load.d/docker-netfilter.conf\nmkdir -p /etc/sysctl.d\ncat > /etc/sysctl.d/99-docker.conf <<'SYSCTL'\nnet.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\nSYSCTL\nsysctl --system >/dev/null 2>&1 || true\nEOF", shellQuote(modulesBase))
|
||||
}
|
||||
|
||||
func appendMiseSetup(script *bytes.Buffer) {
|
||||
const (
|
||||
nodeShimPath = "/root/.local/share/mise/shims/node"
|
||||
npmShimPath = "/root/.local/share/mise/shims/npm"
|
||||
claudeShimPath = "/root/.local/share/mise/shims/claude"
|
||||
piShimPath = "/root/.local/share/mise/shims/pi"
|
||||
)
|
||||
|
||||
fmt.Fprintf(script, "curl -fsSL https://mise.run | MISE_INSTALL_PATH=%s MISE_VERSION=%s sh\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultMiseVersion))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultNodeTool))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultClaudeCodeTool))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultPiTool))
|
||||
fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi\n", shellQuote(nodeShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi\n", shellQuote(npmShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi\n", shellQuote(opencode.ShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'claude shim not found after mise install' >&2; exit 1; fi\n", shellQuote(claudeShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'pi shim not found after mise install' >&2; exit 1; fi\n", shellQuote(piShimPath))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(nodeShimPath), shellQuote("/usr/local/bin/node"))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(npmShimPath), shellQuote("/usr/local/bin/npm"))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(opencode.ShimPath), shellQuote(opencode.GuestBinaryPath))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(claudeShimPath), shellQuote("/usr/local/bin/claude"))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(piShimPath), shellQuote("/usr/local/bin/pi"))
|
||||
script.WriteString("mkdir -p /etc/profile.d\n")
|
||||
script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n")
|
||||
fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath))
|
||||
fmt.Fprintf(script, " %s\n", defaultMiseActivateLine)
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/profile.d/mise.sh\n")
|
||||
appendLineIfMissing(script, "/etc/bash.bashrc", defaultMiseActivateLine)
|
||||
}
|
||||
|
||||
func appendGuestNetworkSetup(script *bytes.Buffer) {
|
||||
script.WriteString("mkdir -p /usr/local/libexec /etc/systemd/system\n")
|
||||
script.WriteString("cat > " + guestnet.GuestScriptPath + " <<'EOF'\n")
|
||||
script.WriteString(guestnet.BootstrapScript())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0755 " + guestnet.GuestScriptPath + "\n")
|
||||
script.WriteString("cat > /etc/systemd/system/" + guestnet.SystemdServiceName + " <<'EOF'\n")
|
||||
script.WriteString(guestnet.SystemdServiceUnit())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + guestnet.SystemdServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + guestnet.SystemdServiceName + " || true; fi\n")
|
||||
}
|
||||
|
||||
func appendOpenCodeServiceSetup(script *bytes.Buffer) {
|
||||
script.WriteString("mkdir -p /etc/systemd/system\n")
|
||||
script.WriteString("cat > /etc/systemd/system/" + opencode.ServiceName + " <<'EOF'\n")
|
||||
script.WriteString(opencode.ServiceUnit())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + opencode.ServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + opencode.ServiceName + " || true; fi\n")
|
||||
}
|
||||
|
||||
func appendTmuxSetup(script *bytes.Buffer) {
|
||||
fmt.Fprintf(script, "TMUX_PLUGIN_DIR=%s\n", shellQuote(defaultTMUXPluginDir))
|
||||
fmt.Fprintf(script, "TMUX_RESURRECT_DIR=%s\n", shellQuote(defaultTMUXResurrectDir))
|
||||
script.WriteString("mkdir -p \"$TMUX_PLUGIN_DIR\" \"$TMUX_RESURRECT_DIR\"\n")
|
||||
appendGitRepo(script, "$TMUX_PLUGIN_DIR/tpm", defaultTPMRepo)
|
||||
appendGitRepo(script, "$TMUX_PLUGIN_DIR/tmux-resurrect", defaultResurrectRepo)
|
||||
appendGitRepo(script, "$TMUX_PLUGIN_DIR/tmux-continuum", defaultContinuumRepo)
|
||||
script.WriteString("TMUX_CONF=/root/.tmux.conf\n")
|
||||
fmt.Fprintf(script, "TMUX_MANAGED_START=%s\n", shellQuote(tmuxManagedBlockStart))
|
||||
fmt.Fprintf(script, "TMUX_MANAGED_END=%s\n", shellQuote(tmuxManagedBlockEnd))
|
||||
script.WriteString("tmp_tmux_conf=$(mktemp)\n")
|
||||
script.WriteString("if [[ -f \"$TMUX_CONF\" ]]; then\n")
|
||||
script.WriteString(" awk -v begin=\"$TMUX_MANAGED_START\" -v end=\"$TMUX_MANAGED_END\" '$0 == begin { skip = 1; next } $0 == end { skip = 0; next } !skip { print }' \"$TMUX_CONF\" > \"$tmp_tmux_conf\"\n")
|
||||
script.WriteString("else\n")
|
||||
script.WriteString(" : > \"$tmp_tmux_conf\"\n")
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("if [[ -s \"$tmp_tmux_conf\" ]]; then\n")
|
||||
script.WriteString(" printf '\\n' >> \"$tmp_tmux_conf\"\n")
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("cat >> \"$tmp_tmux_conf\" <<'EOF'\n")
|
||||
script.WriteString(tmuxManagedBlockStart + "\n")
|
||||
script.WriteString("set -g @plugin 'tmux-plugins/tpm'\n")
|
||||
script.WriteString("set -g @plugin 'tmux-plugins/tmux-resurrect'\n")
|
||||
script.WriteString("set -g @plugin 'tmux-plugins/tmux-continuum'\n")
|
||||
script.WriteString("set -g @continuum-save-interval '15'\n")
|
||||
script.WriteString("set -g @continuum-restore 'off'\n")
|
||||
script.WriteString("set -g @resurrect-dir '/root/.tmux/resurrect'\n")
|
||||
script.WriteString("run '~/.tmux/plugins/tpm/tpm'\n")
|
||||
script.WriteString(tmuxManagedBlockEnd + "\n")
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("mv \"$tmp_tmux_conf\" \"$TMUX_CONF\"\n")
|
||||
script.WriteString("chmod 0644 \"$TMUX_CONF\"\n")
|
||||
}
|
||||
|
||||
func appendVSockPingSetup(script *bytes.Buffer) {
|
||||
script.WriteString("mkdir -p /etc/modules-load.d /etc/systemd/system\n")
|
||||
script.WriteString("cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'\n")
|
||||
script.WriteString(vsockagent.ModulesLoadConfig())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/modules-load.d/banger-vsock.conf\n")
|
||||
script.WriteString("cat > /etc/systemd/system/" + vsockagent.ServiceName + " <<'EOF'\n")
|
||||
script.WriteString(vsockagent.ServiceUnit())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + vsockagent.ServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + vsockagent.ServiceName + " || true; fi\n")
|
||||
}
|
||||
|
||||
func appendGitRepo(script *bytes.Buffer, dir, repo string) {
|
||||
fmt.Fprintf(script, "if [[ -d \"%s/.git\" ]]; then\n", dir)
|
||||
fmt.Fprintf(script, " git -C \"%s\" fetch --depth 1 origin\n", dir)
|
||||
fmt.Fprintf(script, " git -C \"%s\" reset --hard FETCH_HEAD\n", dir)
|
||||
script.WriteString("else\n")
|
||||
fmt.Fprintf(script, " rm -rf \"%s\"\n", dir)
|
||||
fmt.Fprintf(script, " git clone --depth 1 %s \"%s\"\n", shellQuote(repo), dir)
|
||||
script.WriteString("fi\n")
|
||||
}
|
||||
|
||||
func appendGuestCleanup(script *bytes.Buffer) {
|
||||
script.WriteString("rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh\n")
|
||||
}
|
||||
|
||||
func appendLineIfMissing(script *bytes.Buffer, path, line string) {
|
||||
fmt.Fprintf(script, "touch %s\n", shellQuote(path))
|
||||
fmt.Fprintf(script, "if ! grep -Fqx %s %s; then\n", shellQuote(line), shellQuote(path))
|
||||
fmt.Fprintf(script, " printf '\\n%%s\\n' %s >> %s\n", shellQuote(line), shellQuote(path))
|
||||
script.WriteString("fi\n")
|
||||
}
|
||||
|
||||
func shellArray(values []string) string {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, shellQuote(value))
|
||||
}
|
||||
return "(" + strings.Join(quoted, " ") + ")"
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
func writeBuildLog(w io.Writer, message string) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "[image.build] %s\n", message)
|
||||
return err
|
||||
}
|
||||
|
||||
func packagesHash(lines []string) string {
|
||||
return imagepreset.Hash(lines)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ package daemon
|
|||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"banger/internal/daemon/imagemgr"
|
||||
)
|
||||
|
||||
func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := buildProvisionScript("devbox", "1.1.1.1", "ssh-ed25519 AAAATESTKEY banger", []string{"git", "curl"}, false)
|
||||
script := imagemgr.BuildProvisionScript("devbox", "1.1.1.1", "ssh-ed25519 AAAATESTKEY banger", []string{"git", "curl"}, false)
|
||||
for _, snippet := range []string{
|
||||
"mkdir -p /root/.ssh",
|
||||
"cat > /root/.ssh/authorized_keys <<'EOF'",
|
||||
|
|
@ -59,7 +61,7 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
|
|||
"rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh",
|
||||
} {
|
||||
if !strings.Contains(script, snippet) {
|
||||
t.Fatalf("buildProvisionScript missing snippet %q\nscript:\n%s", snippet, script)
|
||||
t.Fatalf("BuildProvisionScript missing snippet %q\nscript:\n%s", snippet, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
248
internal/daemon/imagemgr/build.go
Normal file
248
internal/daemon/imagemgr/build.go
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
package imagemgr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"banger/internal/guestnet"
|
||||
"banger/internal/model"
|
||||
"banger/internal/opencode"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vsockagent"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMiseVersion = "v2025.12.0"
|
||||
defaultMiseInstallPath = "/usr/local/bin/mise"
|
||||
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
|
||||
defaultNodeTool = "node@22"
|
||||
defaultOpenCodeTool = "github:anomalyco/opencode"
|
||||
defaultClaudeCodeTool = "npm:@anthropic-ai/claude-code"
|
||||
defaultPiTool = "npm:@mariozechner/pi-coding-agent"
|
||||
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
|
||||
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
|
||||
defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum"
|
||||
defaultTMUXPluginDir = "/root/.tmux/plugins"
|
||||
defaultTMUXResurrectDir = "/root/.tmux/resurrect"
|
||||
tmuxManagedBlockStart = "# >>> banger tmux plugins >>>"
|
||||
tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<"
|
||||
)
|
||||
|
||||
// ResizeRootfs grows a rootfs ext4 image to sizeSpec bytes. sizeSpec must
|
||||
// parse via model.ParseSize and must be >= the base image size.
|
||||
func ResizeRootfs(baseRootfs, rootfsPath, sizeSpec string) error {
|
||||
sizeBytes, err := model.ParseSize(sizeSpec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(baseRootfs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sizeBytes < info.Size() {
|
||||
return fmt.Errorf("size must be >= base image size")
|
||||
}
|
||||
return system.ResizeExt4Image(context.Background(), system.NewRunner(), rootfsPath, sizeBytes)
|
||||
}
|
||||
|
||||
// WriteBuildLog emits a prefixed status line to w. Safe on a nil writer.
|
||||
func WriteBuildLog(w io.Writer, message string) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "[image.build] %s\n", message)
|
||||
return err
|
||||
}
|
||||
|
||||
// BuildProvisionScript returns the bash script that configures a freshly
|
||||
// booted build VM: host/dns files, authorized key, apt packages, mise +
|
||||
// language shims, guest network unit, opencode service, tmux plugins,
|
||||
// vsock agent, optional docker, and cleanup.
|
||||
func BuildProvisionScript(vmName, dnsServer, authorizedKey string, packages []string, installDocker bool) string {
|
||||
var script bytes.Buffer
|
||||
script.WriteString("set -euo pipefail\n")
|
||||
fmt.Fprintf(&script, "printf 'nameserver %%s\\n' %s > /etc/resolv.conf\n", shellQuote(dnsServer))
|
||||
fmt.Fprintf(&script, "printf '%%s\\n' %s > /etc/hostname\n", shellQuote(vmName))
|
||||
fmt.Fprintf(&script, "printf '127.0.0.1 localhost\\n127.0.1.1 %%s\\n' %s > /etc/hosts\n", shellQuote(vmName))
|
||||
script.WriteString("touch /etc/fstab\n")
|
||||
script.WriteString("sed -i '\\|^/dev/vdb[[:space:]]\\+/home[[:space:]]|d; \\|^/dev/vdc[[:space:]]\\+/var[[:space:]]|d' /etc/fstab\n")
|
||||
script.WriteString("if ! grep -q '^tmpfs /run ' /etc/fstab; then echo 'tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0' >> /etc/fstab; fi\n")
|
||||
script.WriteString("if ! grep -q '^tmpfs /tmp ' /etc/fstab; then echo 'tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0' >> /etc/fstab; fi\n")
|
||||
appendAuthorizedKeySetup(&script, authorizedKey)
|
||||
script.WriteString("apt-get update\n")
|
||||
script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y upgrade\n")
|
||||
fmt.Fprintf(&script, "PACKAGES=%s\n", shellArray(packages))
|
||||
script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y install \"${PACKAGES[@]}\"\n")
|
||||
appendGuestNetworkSetup(&script)
|
||||
appendMiseSetup(&script)
|
||||
appendOpenCodeServiceSetup(&script)
|
||||
appendTmuxSetup(&script)
|
||||
appendVSockPingSetup(&script)
|
||||
if installDocker {
|
||||
script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y remove containerd || true\n")
|
||||
script.WriteString("if ! DEBIAN_FRONTEND=noninteractive apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then\n")
|
||||
script.WriteString(" DEBIAN_FRONTEND=noninteractive apt-get -y install docker.io\n")
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker || true; fi\n")
|
||||
}
|
||||
appendGuestCleanup(&script)
|
||||
script.WriteString("git config --system init.defaultBranch main\n")
|
||||
return script.String()
|
||||
}
|
||||
|
||||
// BuildModulesCommand returns the guest shell command that receives a tar
|
||||
// stream on stdin, extracts it into /lib/modules/<modulesBase>, runs depmod,
|
||||
// and writes sysctl/modules-load config for docker networking.
|
||||
func BuildModulesCommand(modulesBase string) string {
|
||||
return fmt.Sprintf("bash -se <<'EOF'\nset -euo pipefail\nmkdir -p /lib/modules\ntar -C /lib/modules -xf -\ndepmod -a %s\nmkdir -p /etc/modules-load.d\nprintf 'nf_tables\\nnft_chain_nat\\nveth\\nbr_netfilter\\noverlay\\n' > /etc/modules-load.d/docker-netfilter.conf\nmkdir -p /etc/sysctl.d\ncat > /etc/sysctl.d/99-docker.conf <<'SYSCTL'\nnet.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\nSYSCTL\nsysctl --system >/dev/null 2>&1 || true\nEOF", shellQuote(modulesBase))
|
||||
}
|
||||
|
||||
func appendAuthorizedKeySetup(script *bytes.Buffer, authorizedKey string) {
|
||||
script.WriteString("mkdir -p /root/.ssh\n")
|
||||
script.WriteString("chmod 700 /root/.ssh\n")
|
||||
script.WriteString("cat > /root/.ssh/authorized_keys <<'EOF'\n")
|
||||
script.WriteString(strings.TrimSpace(authorizedKey))
|
||||
script.WriteString("\nEOF\n")
|
||||
script.WriteString("chmod 600 /root/.ssh/authorized_keys\n")
|
||||
}
|
||||
|
||||
func appendMiseSetup(script *bytes.Buffer) {
|
||||
const (
|
||||
nodeShimPath = "/root/.local/share/mise/shims/node"
|
||||
npmShimPath = "/root/.local/share/mise/shims/npm"
|
||||
claudeShimPath = "/root/.local/share/mise/shims/claude"
|
||||
piShimPath = "/root/.local/share/mise/shims/pi"
|
||||
)
|
||||
|
||||
fmt.Fprintf(script, "curl -fsSL https://mise.run | MISE_INSTALL_PATH=%s MISE_VERSION=%s sh\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultMiseVersion))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultNodeTool))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultClaudeCodeTool))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultPiTool))
|
||||
fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi\n", shellQuote(nodeShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi\n", shellQuote(npmShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi\n", shellQuote(opencode.ShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'claude shim not found after mise install' >&2; exit 1; fi\n", shellQuote(claudeShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'pi shim not found after mise install' >&2; exit 1; fi\n", shellQuote(piShimPath))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(nodeShimPath), shellQuote("/usr/local/bin/node"))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(npmShimPath), shellQuote("/usr/local/bin/npm"))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(opencode.ShimPath), shellQuote(opencode.GuestBinaryPath))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(claudeShimPath), shellQuote("/usr/local/bin/claude"))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(piShimPath), shellQuote("/usr/local/bin/pi"))
|
||||
script.WriteString("mkdir -p /etc/profile.d\n")
|
||||
script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n")
|
||||
fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath))
|
||||
fmt.Fprintf(script, " %s\n", defaultMiseActivateLine)
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/profile.d/mise.sh\n")
|
||||
appendLineIfMissing(script, "/etc/bash.bashrc", defaultMiseActivateLine)
|
||||
}
|
||||
|
||||
func appendGuestNetworkSetup(script *bytes.Buffer) {
|
||||
script.WriteString("mkdir -p /usr/local/libexec /etc/systemd/system\n")
|
||||
script.WriteString("cat > " + guestnet.GuestScriptPath + " <<'EOF'\n")
|
||||
script.WriteString(guestnet.BootstrapScript())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0755 " + guestnet.GuestScriptPath + "\n")
|
||||
script.WriteString("cat > /etc/systemd/system/" + guestnet.SystemdServiceName + " <<'EOF'\n")
|
||||
script.WriteString(guestnet.SystemdServiceUnit())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + guestnet.SystemdServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + guestnet.SystemdServiceName + " || true; fi\n")
|
||||
}
|
||||
|
||||
func appendOpenCodeServiceSetup(script *bytes.Buffer) {
|
||||
script.WriteString("mkdir -p /etc/systemd/system\n")
|
||||
script.WriteString("cat > /etc/systemd/system/" + opencode.ServiceName + " <<'EOF'\n")
|
||||
script.WriteString(opencode.ServiceUnit())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + opencode.ServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + opencode.ServiceName + " || true; fi\n")
|
||||
}
|
||||
|
||||
func appendTmuxSetup(script *bytes.Buffer) {
|
||||
fmt.Fprintf(script, "TMUX_PLUGIN_DIR=%s\n", shellQuote(defaultTMUXPluginDir))
|
||||
fmt.Fprintf(script, "TMUX_RESURRECT_DIR=%s\n", shellQuote(defaultTMUXResurrectDir))
|
||||
script.WriteString("mkdir -p \"$TMUX_PLUGIN_DIR\" \"$TMUX_RESURRECT_DIR\"\n")
|
||||
appendGitRepo(script, "$TMUX_PLUGIN_DIR/tpm", defaultTPMRepo)
|
||||
appendGitRepo(script, "$TMUX_PLUGIN_DIR/tmux-resurrect", defaultResurrectRepo)
|
||||
appendGitRepo(script, "$TMUX_PLUGIN_DIR/tmux-continuum", defaultContinuumRepo)
|
||||
script.WriteString("TMUX_CONF=/root/.tmux.conf\n")
|
||||
fmt.Fprintf(script, "TMUX_MANAGED_START=%s\n", shellQuote(tmuxManagedBlockStart))
|
||||
fmt.Fprintf(script, "TMUX_MANAGED_END=%s\n", shellQuote(tmuxManagedBlockEnd))
|
||||
script.WriteString("tmp_tmux_conf=$(mktemp)\n")
|
||||
script.WriteString("if [[ -f \"$TMUX_CONF\" ]]; then\n")
|
||||
script.WriteString(" awk -v begin=\"$TMUX_MANAGED_START\" -v end=\"$TMUX_MANAGED_END\" '$0 == begin { skip = 1; next } $0 == end { skip = 0; next } !skip { print }' \"$TMUX_CONF\" > \"$tmp_tmux_conf\"\n")
|
||||
script.WriteString("else\n")
|
||||
script.WriteString(" : > \"$tmp_tmux_conf\"\n")
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("if [[ -s \"$tmp_tmux_conf\" ]]; then\n")
|
||||
script.WriteString(" printf '\\n' >> \"$tmp_tmux_conf\"\n")
|
||||
script.WriteString("fi\n")
|
||||
script.WriteString("cat >> \"$tmp_tmux_conf\" <<'EOF'\n")
|
||||
script.WriteString(tmuxManagedBlockStart + "\n")
|
||||
script.WriteString("set -g @plugin 'tmux-plugins/tpm'\n")
|
||||
script.WriteString("set -g @plugin 'tmux-plugins/tmux-resurrect'\n")
|
||||
script.WriteString("set -g @plugin 'tmux-plugins/tmux-continuum'\n")
|
||||
script.WriteString("set -g @continuum-save-interval '15'\n")
|
||||
script.WriteString("set -g @continuum-restore 'off'\n")
|
||||
script.WriteString("set -g @resurrect-dir '/root/.tmux/resurrect'\n")
|
||||
script.WriteString("run '~/.tmux/plugins/tpm/tpm'\n")
|
||||
script.WriteString(tmuxManagedBlockEnd + "\n")
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("mv \"$tmp_tmux_conf\" \"$TMUX_CONF\"\n")
|
||||
script.WriteString("chmod 0644 \"$TMUX_CONF\"\n")
|
||||
}
|
||||
|
||||
func appendVSockPingSetup(script *bytes.Buffer) {
|
||||
script.WriteString("mkdir -p /etc/modules-load.d /etc/systemd/system\n")
|
||||
script.WriteString("cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'\n")
|
||||
script.WriteString(vsockagent.ModulesLoadConfig())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/modules-load.d/banger-vsock.conf\n")
|
||||
script.WriteString("cat > /etc/systemd/system/" + vsockagent.ServiceName + " <<'EOF'\n")
|
||||
script.WriteString(vsockagent.ServiceUnit())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + vsockagent.ServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + vsockagent.ServiceName + " || true; fi\n")
|
||||
}
|
||||
|
||||
func appendGitRepo(script *bytes.Buffer, dir, repo string) {
|
||||
fmt.Fprintf(script, "if [[ -d \"%s/.git\" ]]; then\n", dir)
|
||||
fmt.Fprintf(script, " git -C \"%s\" fetch --depth 1 origin\n", dir)
|
||||
fmt.Fprintf(script, " git -C \"%s\" reset --hard FETCH_HEAD\n", dir)
|
||||
script.WriteString("else\n")
|
||||
fmt.Fprintf(script, " rm -rf \"%s\"\n", dir)
|
||||
fmt.Fprintf(script, " git clone --depth 1 %s \"%s\"\n", shellQuote(repo), dir)
|
||||
script.WriteString("fi\n")
|
||||
}
|
||||
|
||||
func appendGuestCleanup(script *bytes.Buffer) {
|
||||
script.WriteString("rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh\n")
|
||||
}
|
||||
|
||||
func appendLineIfMissing(script *bytes.Buffer, path, line string) {
|
||||
fmt.Fprintf(script, "touch %s\n", shellQuote(path))
|
||||
fmt.Fprintf(script, "if ! grep -Fqx %s %s; then\n", shellQuote(line), shellQuote(path))
|
||||
fmt.Fprintf(script, " printf '\\n%%s\\n' %s >> %s\n", shellQuote(line), shellQuote(path))
|
||||
script.WriteString("fi\n")
|
||||
}
|
||||
|
||||
func shellArray(values []string) string {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, shellQuote(value))
|
||||
}
|
||||
return "(" + strings.Join(quoted, " ") + ")"
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
108
internal/daemon/imagemgr/paths.go
Normal file
108
internal/daemon/imagemgr/paths.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Package imagemgr contains the pure helpers of the banger image subsystem:
|
||||
// path validators, artifact staging, managed-image metadata, and the guest
|
||||
// provisioning script generator used by image build.
|
||||
//
|
||||
// The orchestrator methods (BuildImage, RegisterImage, PromoteImage,
|
||||
// DeleteImage) still live in the daemon package and compose these helpers.
|
||||
package imagemgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"banger/internal/imagepreset"
|
||||
"banger/internal/system"
|
||||
)
|
||||
|
||||
// ValidateRegisterPaths checks that rootfs + kernel exist and that optional
|
||||
// artifacts, when provided, also exist.
|
||||
func ValidateRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir string) error {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs <path>`)
|
||||
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path>`)
|
||||
if workSeedPath != "" {
|
||||
checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed <path> or rebuild the image with a work seed`)
|
||||
}
|
||||
if initrdPath != "" {
|
||||
checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path>`)
|
||||
}
|
||||
if modulesDir != "" {
|
||||
checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir>`)
|
||||
}
|
||||
return checks.Err("image register failed")
|
||||
}
|
||||
|
||||
// ValidatePromotePaths checks that an existing registered image's artifacts
|
||||
// are still present before promoting it to daemon-owned storage.
|
||||
func ValidatePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir string) error {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireFile(rootfsPath, "rootfs image", `re-register the image with a valid rootfs`)
|
||||
checks.RequireFile(kernelPath, "kernel image", `re-register the image with a valid kernel`)
|
||||
if initrdPath != "" {
|
||||
checks.RequireFile(initrdPath, "initrd image", `re-register the image with a valid initrd`)
|
||||
}
|
||||
if modulesDir != "" {
|
||||
checks.RequireDir(modulesDir, "kernel modules dir", `re-register the image with a valid modules dir`)
|
||||
}
|
||||
return checks.Err("image promote failed")
|
||||
}
|
||||
|
||||
// StageBootArtifacts copies kernel/initrd/modules into artifactDir and
|
||||
// returns the staged paths. initrd and modules are optional; an empty source
|
||||
// returns an empty staged path.
|
||||
func StageBootArtifacts(ctx context.Context, runner system.CommandRunner, artifactDir, kernelSource, initrdSource, modulesSource string) (string, string, string, error) {
|
||||
kernelPath := filepath.Join(artifactDir, "kernel")
|
||||
if err := system.CopyFilePreferClone(kernelSource, kernelPath); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
initrdPath := ""
|
||||
if strings.TrimSpace(initrdSource) != "" {
|
||||
initrdPath = filepath.Join(artifactDir, "initrd.img")
|
||||
if err := system.CopyFilePreferClone(initrdSource, initrdPath); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
}
|
||||
modulesDir := ""
|
||||
if strings.TrimSpace(modulesSource) != "" {
|
||||
modulesDir = filepath.Join(artifactDir, "modules")
|
||||
if err := os.MkdirAll(modulesDir, 0o755); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := system.CopyDirContents(ctx, runner, modulesSource, modulesDir, false); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
}
|
||||
return kernelPath, initrdPath, modulesDir, nil
|
||||
}
|
||||
|
||||
// StageOptionalArtifactPath returns the destination path for an optional
|
||||
// artifact in artifactDir, or "" when stagedPath is empty (artifact absent).
|
||||
func StageOptionalArtifactPath(artifactDir, stagedPath, name string) string {
|
||||
if strings.TrimSpace(stagedPath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(artifactDir, name)
|
||||
}
|
||||
|
||||
// BuildMetadataPackages returns the canonical package set recorded for a
|
||||
// managed image build. The #feature:docker sentinel is appended when
|
||||
// docker is requested.
|
||||
func BuildMetadataPackages(docker bool) []string {
|
||||
packages := imagepreset.DebianBasePackages()
|
||||
if docker {
|
||||
packages = append(packages, "#feature:docker")
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
// WritePackagesMetadata writes the hash of packages next to rootfsPath so
|
||||
// future builds can detect drift. Empty packages or rootfsPath is a no-op.
|
||||
func WritePackagesMetadata(rootfsPath string, packages []string) error {
|
||||
if rootfsPath == "" || len(packages) == 0 {
|
||||
return nil
|
||||
}
|
||||
metadataPath := rootfsPath + ".packages.sha256"
|
||||
return os.WriteFile(metadataPath, []byte(imagepreset.Hash(packages)+"\n"), 0o644)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/daemon/imagemgr"
|
||||
"banger/internal/imagepreset"
|
||||
"banger/internal/model"
|
||||
"banger/internal/system"
|
||||
|
|
@ -80,12 +81,12 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
|
|||
if err := d.validateImageBuildPrereqs(ctx, baseImage.RootfsPath, kernelSource, initrdSource, modulesSource, params.Size); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
kernelPath, initrdPath, modulesDir, err := stageManagedBootArtifacts(ctx, d.runner, stageDir, kernelSource, initrdSource, modulesSource)
|
||||
kernelPath, initrdPath, modulesDir, err := imagemgr.StageBootArtifacts(ctx, d.runner, stageDir, kernelSource, initrdSource, modulesSource)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
packages := imagepreset.DebianBasePackages()
|
||||
metadataPackages := imageBuildMetadataPackages(params.Docker)
|
||||
metadataPackages := imagemgr.BuildMetadataPackages(params.Docker)
|
||||
spec := imageBuildSpec{
|
||||
ID: id,
|
||||
Name: name,
|
||||
|
|
@ -117,7 +118,7 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
|
|||
return model.Image{}, err
|
||||
}
|
||||
imageBuildStage(ctx, "write_metadata", "writing image metadata")
|
||||
if err := writePackagesMetadata(rootfsPath, metadataPackages); err != nil {
|
||||
if err := imagemgr.WritePackagesMetadata(rootfsPath, metadataPackages); err != nil {
|
||||
_ = logFile.Sync()
|
||||
return model.Image{}, err
|
||||
}
|
||||
|
|
@ -134,8 +135,8 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
|
|||
RootfsPath: filepath.Join(artifactDir, "rootfs.ext4"),
|
||||
WorkSeedPath: filepath.Join(artifactDir, "work-seed.ext4"),
|
||||
KernelPath: filepath.Join(artifactDir, "kernel"),
|
||||
InitrdPath: stageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img"),
|
||||
ModulesDir: stageOptionalArtifactPath(artifactDir, modulesDir, "modules"),
|
||||
InitrdPath: imagemgr.StageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img"),
|
||||
ModulesDir: imagemgr.StageOptionalArtifactPath(artifactDir, modulesDir, "modules"),
|
||||
BuildSize: params.Size,
|
||||
SeededSSHPublicKeyFingerprint: seededSSHPublicKeyFingerprint,
|
||||
Docker: params.Docker,
|
||||
|
|
@ -184,7 +185,7 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
|
|||
initrdPath := strings.TrimSpace(params.InitrdPath)
|
||||
modulesDir := strings.TrimSpace(params.ModulesDir)
|
||||
|
||||
if err := validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir); err != nil {
|
||||
if err := imagemgr.ValidateRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
|
||||
|
|
@ -251,7 +252,7 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
|
|||
if image.Managed {
|
||||
return model.Image{}, fmt.Errorf("image %s is already managed", image.Name)
|
||||
}
|
||||
if err := validateImagePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir); err != nil {
|
||||
if err := imagemgr.ValidatePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
if strings.TrimSpace(d.layout.ImagesDir) == "" {
|
||||
|
|
@ -309,7 +310,7 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
|
|||
} else {
|
||||
image.SeededSSHPublicKeyFingerprint = ""
|
||||
}
|
||||
_, initrdPath, modulesDir, err := stageManagedBootArtifacts(ctx, d.runner, stageDir, image.KernelPath, image.InitrdPath, image.ModulesDir)
|
||||
_, initrdPath, modulesDir, err := imagemgr.StageBootArtifacts(ctx, d.runner, stageDir, image.KernelPath, image.InitrdPath, image.ModulesDir)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
|
|
@ -327,8 +328,8 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
|
|||
image.WorkSeedPath = filepath.Join(artifactDir, "work-seed.ext4")
|
||||
}
|
||||
image.KernelPath = filepath.Join(artifactDir, "kernel")
|
||||
image.InitrdPath = stageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img")
|
||||
image.ModulesDir = stageOptionalArtifactPath(artifactDir, modulesDir, "modules")
|
||||
image.InitrdPath = imagemgr.StageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img")
|
||||
image.ModulesDir = imagemgr.StageOptionalArtifactPath(artifactDir, modulesDir, "modules")
|
||||
image.UpdatedAt = model.Now()
|
||||
if err := d.store.UpsertImage(ctx, image); err != nil {
|
||||
_ = os.RemoveAll(artifactDir)
|
||||
|
|
@ -337,43 +338,6 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
|
|||
return image, nil
|
||||
}
|
||||
|
||||
func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir string) error {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs <path>`)
|
||||
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path>`)
|
||||
if workSeedPath != "" {
|
||||
checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed <path> or rebuild the image with a work seed`)
|
||||
}
|
||||
if initrdPath != "" {
|
||||
checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path>`)
|
||||
}
|
||||
if modulesDir != "" {
|
||||
checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir>`)
|
||||
}
|
||||
return checks.Err("image register failed")
|
||||
}
|
||||
|
||||
func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir string) error {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireFile(rootfsPath, "rootfs image", `re-register the image with a valid rootfs`)
|
||||
checks.RequireFile(kernelPath, "kernel image", `re-register the image with a valid kernel`)
|
||||
if initrdPath != "" {
|
||||
checks.RequireFile(initrdPath, "initrd image", `re-register the image with a valid initrd`)
|
||||
}
|
||||
if modulesDir != "" {
|
||||
checks.RequireDir(modulesDir, "kernel modules dir", `re-register the image with a valid modules dir`)
|
||||
}
|
||||
return checks.Err("image promote failed")
|
||||
}
|
||||
|
||||
func writePackagesMetadata(rootfsPath string, packages []string) error {
|
||||
if rootfsPath == "" || len(packages) == 0 {
|
||||
return nil
|
||||
}
|
||||
metadataPath := rootfsPath + ".packages.sha256"
|
||||
return os.WriteFile(metadataPath, []byte(packagesHash(packages)+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image, error) {
|
||||
d.imageOpsMu.Lock()
|
||||
defer d.imageOpsMu.Unlock()
|
||||
|
|
@ -400,46 +364,6 @@ func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image,
|
|||
return image, nil
|
||||
}
|
||||
|
||||
func stageManagedBootArtifacts(ctx context.Context, runner system.CommandRunner, artifactDir, kernelSource, initrdSource, modulesSource string) (string, string, string, error) {
|
||||
kernelPath := filepath.Join(artifactDir, "kernel")
|
||||
if err := system.CopyFilePreferClone(kernelSource, kernelPath); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
initrdPath := ""
|
||||
if strings.TrimSpace(initrdSource) != "" {
|
||||
initrdPath = filepath.Join(artifactDir, "initrd.img")
|
||||
if err := system.CopyFilePreferClone(initrdSource, initrdPath); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
}
|
||||
modulesDir := ""
|
||||
if strings.TrimSpace(modulesSource) != "" {
|
||||
modulesDir = filepath.Join(artifactDir, "modules")
|
||||
if err := os.MkdirAll(modulesDir, 0o755); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := system.CopyDirContents(ctx, runner, modulesSource, modulesDir, false); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
}
|
||||
return kernelPath, initrdPath, modulesDir, nil
|
||||
}
|
||||
|
||||
func imageBuildMetadataPackages(docker bool) []string {
|
||||
packages := imagepreset.DebianBasePackages()
|
||||
if docker {
|
||||
packages = append(packages, "#feature:docker")
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
func stageOptionalArtifactPath(artifactDir, stagedPath, name string) string {
|
||||
if strings.TrimSpace(stagedPath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(artifactDir, name)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue