Remove image build --from-image; doctor treats catalog images as OK
The `image build` flow spun up a transient Firecracker VM, SSHed in, and ran a large bash provisioning script to derive a new managed image from an existing one. It overlapped heavily with the golden- image Dockerfile flow (same mise/docker/tmux/opencode install logic duplicated in Go as `imagemgr.BuildProvisionScript`) and had far more machinery: async op state, RPC begin/status/cancel, webui form + operation page, preflight checks, API types, tests. For custom images, writing a Dockerfile is simpler and more reproducible. Removed end-to-end: - CLI `image build` subcommand + `absolutizeImageBuildPaths`. - Daemon: BuildImage method, imagebuild.go (transient-VM orchestration), image_build_ops.go (async begin/status/cancel), imagemgr/build.go (the 247-line provisioning script generator and all its append* helpers), validateImageBuildPrereqs + addImageBuildPrereqs. - RPC dispatches for image.build / .begin / .status / .cancel. - opstate registry `imageBuildOps`, daemon seam `imageBuild`, background pruner call. - API types: ImageBuildParams, ImageBuildOperation, ImageBuildBeginResult, ImageBuildStatusParams, ImageBuildStatusResult; model type ImageBuildRequest. - Web UI: Backend interface methods, handlers, form, routes, template branches (images.html build form, operation.html build branch, dashboard.html Build button). - Tests that directly exercised BuildImage. Doctor polish (task C): - Drop the "image build" preflight section entirely (its raison d'être is gone). - Default-image check now accepts "not local but in imagecat" as OK: vm create auto-pulls on first use. Only flag when the image is neither locally registered nor in the catalog. Net: 24 files touched, 1,373 lines deleted, 25 added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ace4782fce
commit
ac7974f5b9
24 changed files with 25 additions and 1398 deletions
|
|
@ -14,17 +14,16 @@ owning types:
|
|||
- `createVMMu sync.Mutex` — serialises `CreateVM` (guards name uniqueness
|
||||
+ guest IP allocation window).
|
||||
- `imageOpsMu sync.Mutex` — serialises image-registry mutations
|
||||
(`BuildImage`, `RegisterImage`, `PromoteImage`, `DeleteImage`).
|
||||
(`PullImage`, `RegisterImage`, `PromoteImage`, `DeleteImage`).
|
||||
- `createOps opstate.Registry[*vmCreateOperationState]` — in-flight VM
|
||||
create operations; owns its own lock.
|
||||
- `imageBuildOps opstate.Registry[*imageBuildOperationState]` — in-flight
|
||||
image build operations; owns its own lock.
|
||||
- `tapPool tapPool` — TAP interface pool; owns its own lock.
|
||||
- `sessions sessionRegistry` — active guest session controllers; owns
|
||||
its own lock.
|
||||
- `listener`, `webListener`, `webServer`, `webURL`, `vmDNS` — networking.
|
||||
- `vmCaps` — registered VM capability hooks.
|
||||
- `imageBuild`, `requestHandler`, `guestWaitForSSH`, `guestDial`,
|
||||
- `pullAndFlatten`, `finalizePulledRootfs`, `bundleFetch`,
|
||||
`requestHandler`, `guestWaitForSSH`, `guestDial`,
|
||||
`waitForGuestSessionReady` — injectable seams used by tests.
|
||||
|
||||
## Subpackages
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ type Daemon struct {
|
|||
imageOpsMu sync.Mutex
|
||||
createVMMu sync.Mutex
|
||||
createOps opstate.Registry[*vmCreateOperationState]
|
||||
imageBuildOps opstate.Registry[*imageBuildOperationState]
|
||||
vmLocks vmLockSet
|
||||
sessions sessionRegistry
|
||||
tapPool tapPool
|
||||
|
|
@ -51,7 +50,6 @@ type Daemon struct {
|
|||
webURL string
|
||||
vmDNS *vmdns.Server
|
||||
vmCaps []vmCapability
|
||||
imageBuild func(context.Context, imageBuildSpec) error
|
||||
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error)
|
||||
finalizePulledRootfs func(ctx context.Context, ext4File string, meta imagepull.Metadata) error
|
||||
bundleFetch func(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error)
|
||||
|
|
@ -483,34 +481,6 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
}
|
||||
image, err := d.FindImage(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.build":
|
||||
params, err := rpc.DecodeParams[api.ImageBuildParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.BuildImage(ctx, params)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.build.begin":
|
||||
params, err := rpc.DecodeParams[api.ImageBuildParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
op, err := d.BeginImageBuild(ctx, params)
|
||||
return marshalResultOrError(api.ImageBuildBeginResult{Operation: op}, err)
|
||||
case "image.build.status":
|
||||
params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
op, err := d.ImageBuildStatus(ctx, params.ID)
|
||||
return marshalResultOrError(api.ImageBuildStatusResult{Operation: op}, err)
|
||||
case "image.build.cancel":
|
||||
params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
err = d.CancelImageBuild(ctx, params.ID)
|
||||
return marshalResultOrError(api.Empty{}, err)
|
||||
case "image.register":
|
||||
params, err := rpc.DecodeParams[api.ImageRegisterParams](req)
|
||||
if err != nil {
|
||||
|
|
@ -594,7 +564,6 @@ func (d *Daemon) backgroundLoop() {
|
|||
d.logger.Error("background stale sweep failed", "error", err.Error())
|
||||
}
|
||||
d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute))
|
||||
d.pruneImageBuildOperations(time.Now().Add(-10 * time.Minute))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,19 +16,6 @@ import (
|
|||
"banger/internal/system"
|
||||
)
|
||||
|
||||
func TestBuildImageRequiresFromImage(t *testing.T) {
|
||||
d := &Daemon{
|
||||
layout: paths.Layout{ImagesDir: t.TempDir(), StateDir: t.TempDir()},
|
||||
store: openDaemonStore(t),
|
||||
runner: system.NewRunner(),
|
||||
}
|
||||
|
||||
_, err := d.BuildImage(context.Background(), api.ImageBuildParams{Name: "missing-base"})
|
||||
if err == nil || !strings.Contains(err.Error(), "from-image is required") {
|
||||
t.Fatalf("BuildImage() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterImageRequiresKernel(t *testing.T) {
|
||||
rootfs := filepath.Join(t.TempDir(), "rootfs.ext4")
|
||||
if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
// Subpackages:
|
||||
//
|
||||
// internal/daemon/opstate Generic Registry[T AsyncOp] for async
|
||||
// operations (VM create, image build).
|
||||
// operations (VM create).
|
||||
// internal/daemon/dmsnap Device-mapper COW snapshot lifecycle.
|
||||
// internal/daemon/fcproc Firecracker process helpers: bridge/tap,
|
||||
// binary resolution, PID lookup, wait/kill.
|
||||
|
|
@ -46,8 +46,7 @@
|
|||
// Image management (in this package):
|
||||
//
|
||||
// images.go register, promote, delete, find, list
|
||||
// imagebuild.go orchestrates the transient firecracker build VM
|
||||
// image_build_ops.go async begin/status/cancel (uses opstate.Registry)
|
||||
// images_pull.go image pull: catalog (bundle) + OCI paths
|
||||
// image_seed.go managed work-seed SSH fingerprint refresh
|
||||
//
|
||||
// Guest interaction (in this package):
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package daemon
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"banger/internal/config"
|
||||
"banger/internal/imagecat"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/store"
|
||||
|
|
@ -41,7 +41,6 @@ func (d *Daemon) doctorReport(ctx context.Context) system.Report {
|
|||
report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
|
||||
report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available")
|
||||
d.addCapabilityDoctorChecks(ctx, &report)
|
||||
report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available")
|
||||
|
||||
return report
|
||||
}
|
||||
|
|
@ -56,44 +55,38 @@ func (d *Daemon) runtimeChecks() *system.Preflight {
|
|||
checks.Addf("%v", err)
|
||||
}
|
||||
if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" {
|
||||
image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName)
|
||||
switch {
|
||||
case err == nil:
|
||||
name := d.config.DefaultImageName
|
||||
image, err := d.store.GetImageByName(context.Background(), name)
|
||||
if err == nil {
|
||||
checks.RequireFile(image.RootfsPath, "default image rootfs", `re-register or rebuild the default image`)
|
||||
checks.RequireFile(image.KernelPath, "default image kernel", `re-register or rebuild the default image`)
|
||||
if strings.TrimSpace(image.InitrdPath) != "" {
|
||||
checks.RequireFile(image.InitrdPath, "default image initrd", `re-register or rebuild the default image`)
|
||||
}
|
||||
case err != nil && err != sql.ErrNoRows:
|
||||
checks.Addf("failed to inspect default image %q: %v", d.config.DefaultImageName, err)
|
||||
default:
|
||||
checks.Addf("default image %q is not registered", d.config.DefaultImageName)
|
||||
} else if !defaultImageInCatalog(name) {
|
||||
checks.Addf("default image %q is not registered and not in the imagecat catalog", name)
|
||||
}
|
||||
// If the default image isn't local but is cataloged, vm create
|
||||
// will auto-pull it on first use — no error to surface.
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
func defaultImageInCatalog(name string) bool {
|
||||
catalog, err := imagecat.LoadEmbedded()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = catalog.Lookup(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (d *Daemon) coreVMLifecycleChecks() *system.Preflight {
|
||||
checks := system.NewPreflight()
|
||||
d.addBaseStartCommandPrereqs(checks)
|
||||
return checks
|
||||
}
|
||||
|
||||
func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight {
|
||||
checks := system.NewPreflight()
|
||||
if d.store == nil || strings.TrimSpace(d.config.DefaultImageName) == "" {
|
||||
checks.Addf("default image is not available for build inheritance")
|
||||
return checks
|
||||
}
|
||||
image, err := d.store.GetImageByName(ctx, d.config.DefaultImageName)
|
||||
if err != nil {
|
||||
checks.Addf("default image %q is not registered", d.config.DefaultImageName)
|
||||
return checks
|
||||
}
|
||||
d.addImageBuildPrereqs(ctx, checks, image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, "")
|
||||
return checks
|
||||
}
|
||||
|
||||
func (d *Daemon) vsockChecks() *system.Preflight {
|
||||
checks := system.NewPreflight()
|
||||
if helper, err := d.vsockAgentBinary(); err == nil {
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
func (op *imageBuildOperationState) ID() string { return op.snapshot().ID }
|
||||
func (op *imageBuildOperationState) IsDone() bool { return op.snapshot().Done }
|
||||
func (op *imageBuildOperationState) UpdatedAt() time.Time { return op.snapshot().UpdatedAt }
|
||||
func (op *imageBuildOperationState) Cancel() { op.cancelOperation() }
|
||||
|
||||
type imageBuildProgressKey struct{}
|
||||
|
||||
type imageBuildOperationState struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
op api.ImageBuildOperation
|
||||
}
|
||||
|
||||
func newImageBuildOperationState() (*imageBuildOperationState, error) {
|
||||
id, err := model.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := model.Now()
|
||||
return &imageBuildOperationState{
|
||||
op: api.ImageBuildOperation{
|
||||
ID: id,
|
||||
Stage: "queued",
|
||||
Detail: "waiting to start",
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func withImageBuildProgress(ctx context.Context, op *imageBuildOperationState) context.Context {
|
||||
if op == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, imageBuildProgressKey{}, op)
|
||||
}
|
||||
|
||||
func imageBuildProgressFromContext(ctx context.Context) *imageBuildOperationState {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
op, _ := ctx.Value(imageBuildProgressKey{}).(*imageBuildOperationState)
|
||||
return op
|
||||
}
|
||||
|
||||
func imageBuildStage(ctx context.Context, stage, detail string) {
|
||||
if op := imageBuildProgressFromContext(ctx); op != nil {
|
||||
op.stage(stage, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func imageBuildBindImage(ctx context.Context, image model.Image) {
|
||||
if op := imageBuildProgressFromContext(ctx); op != nil {
|
||||
op.bindImage(image)
|
||||
}
|
||||
}
|
||||
|
||||
func imageBuildSetLogPath(ctx context.Context, path string) {
|
||||
if op := imageBuildProgressFromContext(ctx); op != nil {
|
||||
op.setLogPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) setCancel(cancel context.CancelFunc) {
|
||||
op.mu.Lock()
|
||||
defer op.mu.Unlock()
|
||||
op.cancel = cancel
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) setLogPath(path string) {
|
||||
op.mu.Lock()
|
||||
defer op.mu.Unlock()
|
||||
op.op.BuildLogPath = strings.TrimSpace(path)
|
||||
op.op.UpdatedAt = model.Now()
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) bindImage(image model.Image) {
|
||||
op.mu.Lock()
|
||||
defer op.mu.Unlock()
|
||||
op.op.ImageID = image.ID
|
||||
op.op.ImageName = image.Name
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) stage(stage, detail string) {
|
||||
op.mu.Lock()
|
||||
defer op.mu.Unlock()
|
||||
stage = strings.TrimSpace(stage)
|
||||
detail = strings.TrimSpace(detail)
|
||||
if stage == "" {
|
||||
stage = op.op.Stage
|
||||
}
|
||||
if stage == op.op.Stage && detail == op.op.Detail {
|
||||
return
|
||||
}
|
||||
op.op.Stage = stage
|
||||
op.op.Detail = detail
|
||||
op.op.UpdatedAt = model.Now()
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) done(image model.Image) {
|
||||
op.mu.Lock()
|
||||
defer op.mu.Unlock()
|
||||
imageCopy := image
|
||||
op.op.ImageID = image.ID
|
||||
op.op.ImageName = image.Name
|
||||
op.op.Stage = "ready"
|
||||
op.op.Detail = "image is ready"
|
||||
op.op.Done = true
|
||||
op.op.Success = true
|
||||
op.op.Error = ""
|
||||
op.op.Image = &imageCopy
|
||||
op.op.UpdatedAt = model.Now()
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) fail(err error) {
|
||||
op.mu.Lock()
|
||||
defer op.mu.Unlock()
|
||||
op.op.Done = true
|
||||
op.op.Success = false
|
||||
if err != nil {
|
||||
op.op.Error = err.Error()
|
||||
}
|
||||
if strings.TrimSpace(op.op.Detail) == "" {
|
||||
op.op.Detail = "image build failed"
|
||||
}
|
||||
op.op.UpdatedAt = model.Now()
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) snapshot() api.ImageBuildOperation {
|
||||
op.mu.Lock()
|
||||
defer op.mu.Unlock()
|
||||
snapshot := op.op
|
||||
if snapshot.Image != nil {
|
||||
imageCopy := *snapshot.Image
|
||||
snapshot.Image = &imageCopy
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (op *imageBuildOperationState) cancelOperation() {
|
||||
op.mu.Lock()
|
||||
cancel := op.cancel
|
||||
op.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) BeginImageBuild(_ context.Context, params api.ImageBuildParams) (api.ImageBuildOperation, error) {
|
||||
op, err := newImageBuildOperationState()
|
||||
if err != nil {
|
||||
return api.ImageBuildOperation{}, err
|
||||
}
|
||||
buildCtx, cancel := context.WithCancel(context.Background())
|
||||
op.setCancel(cancel)
|
||||
d.imageBuildOps.Insert(op)
|
||||
go d.runImageBuildOperation(withImageBuildProgress(buildCtx, op), op, params)
|
||||
return op.snapshot(), nil
|
||||
}
|
||||
|
||||
func (d *Daemon) runImageBuildOperation(ctx context.Context, op *imageBuildOperationState, params api.ImageBuildParams) {
|
||||
image, err := d.BuildImage(ctx, params)
|
||||
if err != nil {
|
||||
op.fail(err)
|
||||
return
|
||||
}
|
||||
op.done(image)
|
||||
}
|
||||
|
||||
func (d *Daemon) ImageBuildStatus(_ context.Context, id string) (api.ImageBuildOperation, error) {
|
||||
op, ok := d.imageBuildOps.Get(strings.TrimSpace(id))
|
||||
if !ok {
|
||||
return api.ImageBuildOperation{}, fmt.Errorf("image build operation not found: %s", id)
|
||||
}
|
||||
return op.snapshot(), nil
|
||||
}
|
||||
|
||||
func (d *Daemon) CancelImageBuild(_ context.Context, id string) error {
|
||||
op, ok := d.imageBuildOps.Get(strings.TrimSpace(id))
|
||||
if !ok {
|
||||
return fmt.Errorf("image build operation not found: %s", id)
|
||||
}
|
||||
op.cancelOperation()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) pruneImageBuildOperations(olderThan time.Time) {
|
||||
d.imageBuildOps.Prune(olderThan)
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"banger/internal/daemon/imagemgr"
|
||||
"banger/internal/firecracker"
|
||||
"banger/internal/guest"
|
||||
"banger/internal/hostnat"
|
||||
"banger/internal/model"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vsockagent"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type imageBuildSpec struct {
|
||||
ID string
|
||||
Name string
|
||||
SourceRootfs string
|
||||
RootfsPath string
|
||||
BuildLog io.Writer
|
||||
KernelPath string
|
||||
InitrdPath string
|
||||
ModulesDir string
|
||||
Packages []string
|
||||
InstallDocker bool
|
||||
Size string
|
||||
}
|
||||
|
||||
type imageBuildVM struct {
|
||||
Name string
|
||||
GuestIP string
|
||||
TapDevice string
|
||||
APISock string
|
||||
PID int
|
||||
}
|
||||
|
||||
func (d *Daemon) runImageBuild(ctx context.Context, spec imageBuildSpec) error {
|
||||
if d.imageBuild != nil {
|
||||
return d.imageBuild(ctx, spec)
|
||||
}
|
||||
return d.runImageBuildNative(ctx, spec)
|
||||
}
|
||||
|
||||
func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (err error) {
|
||||
if err := system.CopyFilePreferClone(spec.SourceRootfs, spec.RootfsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Size != "" {
|
||||
if err := imagemgr.ResizeRootfs(spec.SourceRootfs, spec.RootfsPath, spec.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
vm, cleanup, err := d.startImageBuildVM(ctx, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
cleanupErr := cleanup(context.Background())
|
||||
if cleanupErr != nil {
|
||||
err = errors.Join(err, cleanupErr)
|
||||
}
|
||||
}()
|
||||
|
||||
sshAddress := vm.GuestIP + ":22"
|
||||
if _, err := fmt.Fprintf(spec.BuildLog, "[image.build] waiting for ssh on %s\n", sshAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
if err := guest.WaitForSSH(waitCtx, sshAddress, d.config.SSHKeyPath, time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := guest.Dial(ctx, sshAddress, d.config.SSHKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
authorizedKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vsockAgentPath, err := d.vsockAgentBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helperBytes, err := os.ReadFile(vsockAgentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 := imagemgr.WriteBuildLog(spec.BuildLog, "configuring guest"); err != nil {
|
||||
return err
|
||||
}
|
||||
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 := imagemgr.WriteBuildLog(spec.BuildLog, "copying kernel modules"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.StreamTar(ctx, spec.ModulesDir, imagemgr.BuildModulesCommand(filepath.Base(spec.ModulesDir)), spec.BuildLog); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return d.shutdownImageBuildVM(ctx, vm)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err := d.ensureSocketDir(); err != nil {
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
fcPath, err := d.firecrackerBinary()
|
||||
if err != nil {
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
|
||||
shortID := system.ShortID(spec.ID)
|
||||
guestIP, err := d.store.NextGuestIP(ctx, bridgePrefix(d.config.BridgeIP))
|
||||
if err != nil {
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
vm := imageBuildVM{
|
||||
Name: "image-build-" + shortID,
|
||||
GuestIP: guestIP,
|
||||
TapDevice: "tap-img-" + shortID,
|
||||
APISock: filepath.Join(d.layout.RuntimeDir, "img-"+shortID+".sock"),
|
||||
}
|
||||
if err := os.RemoveAll(vm.APISock); err != nil && !os.IsNotExist(err) {
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
if err := d.createTap(ctx, vm.TapDevice); err != nil {
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
if err := hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, true); err != nil {
|
||||
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice)
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
|
||||
firecrackerCtx := context.Background()
|
||||
machine, err := firecracker.NewMachine(firecrackerCtx, firecracker.MachineConfig{
|
||||
BinaryPath: fcPath,
|
||||
VMID: spec.ID,
|
||||
SocketPath: vm.APISock,
|
||||
LogPath: spec.RootfsPath + ".firecracker.log",
|
||||
MetricsPath: filepath.Join(filepath.Dir(spec.RootfsPath), "metrics.json"),
|
||||
KernelImagePath: spec.KernelPath,
|
||||
InitrdPath: spec.InitrdPath,
|
||||
KernelArgs: system.BuildBootArgsWithKernelIP(vm.Name, vm.GuestIP, d.config.BridgeIP, d.config.DefaultDNS),
|
||||
Drives: []firecracker.DriveConfig{{
|
||||
ID: "rootfs",
|
||||
Path: spec.RootfsPath,
|
||||
ReadOnly: false,
|
||||
IsRoot: true,
|
||||
}},
|
||||
TapDevice: vm.TapDevice,
|
||||
VCPUCount: model.DefaultVCPUCount,
|
||||
MemoryMiB: model.DefaultMemoryMiB,
|
||||
Logger: d.logger,
|
||||
})
|
||||
if err != nil {
|
||||
_ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false)
|
||||
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice)
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
if err := machine.Start(firecrackerCtx); err != nil {
|
||||
_ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false)
|
||||
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice)
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
vm.PID = d.resolveFirecrackerPID(firecrackerCtx, machine, vm.APISock)
|
||||
if err := d.ensureSocketAccess(ctx, vm.APISock, "firecracker api socket"); err != nil {
|
||||
_ = d.killVMProcess(context.Background(), vm.PID)
|
||||
_ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false)
|
||||
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice)
|
||||
return imageBuildVM{}, nil, err
|
||||
}
|
||||
|
||||
cleanup := func(cleanupCtx context.Context) error {
|
||||
if vm.PID > 0 && system.ProcessRunning(vm.PID, vm.APISock) {
|
||||
_ = d.killVMProcess(cleanupCtx, vm.PID)
|
||||
_ = d.waitForExit(cleanupCtx, vm.PID, vm.APISock, 10*time.Second)
|
||||
}
|
||||
_ = hostnat.Ensure(cleanupCtx, d.runner, vm.GuestIP, vm.TapDevice, false)
|
||||
if vm.TapDevice != "" {
|
||||
_, _ = d.runner.RunSudo(cleanupCtx, "ip", "link", "del", vm.TapDevice)
|
||||
}
|
||||
if vm.APISock != "" {
|
||||
_ = os.Remove(vm.APISock)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return vm, cleanup, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) shutdownImageBuildVM(ctx context.Context, vm imageBuildVM) error {
|
||||
buildVM := model.VMRecord{Runtime: model.VMRuntime{APISockPath: vm.APISock}}
|
||||
if err := d.sendCtrlAltDel(ctx, buildVM); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.waitForExit(ctx, vm.PID, vm.APISock, 15*time.Second)
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"banger/internal/daemon/imagemgr"
|
||||
)
|
||||
|
||||
func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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'",
|
||||
"ssh-ed25519 AAAATESTKEY banger",
|
||||
"cat > /usr/local/libexec/banger-network-bootstrap <<'EOF'",
|
||||
"ip addr replace \"$guest_ip/$prefix\" dev \"$iface\"",
|
||||
"cat > /etc/systemd/system/banger-network.service <<'EOF'",
|
||||
"systemctl enable --now banger-network.service || true",
|
||||
"curl -fsSL https://mise.run | MISE_INSTALL_PATH='/usr/local/bin/mise' MISE_VERSION='v2025.12.0' sh",
|
||||
"'/usr/local/bin/mise' use -g 'node@22'",
|
||||
"'/usr/local/bin/mise' use -g 'github:anomalyco/opencode'",
|
||||
"'/usr/local/bin/mise' use -g 'npm:@anthropic-ai/claude-code'",
|
||||
"'/usr/local/bin/mise' use -g 'npm:@mariozechner/pi-coding-agent'",
|
||||
"'/usr/local/bin/mise' reshim",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/node' ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/npm' ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/opencode' ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/claude' ]]; then echo 'claude shim not found after mise install' >&2; exit 1; fi",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/pi' ]]; then echo 'pi shim not found after mise install' >&2; exit 1; fi",
|
||||
"ln -snf '/root/.local/share/mise/shims/node' '/usr/local/bin/node'",
|
||||
"ln -snf '/root/.local/share/mise/shims/npm' '/usr/local/bin/npm'",
|
||||
"ln -snf '/root/.local/share/mise/shims/opencode' '/usr/local/bin/opencode'",
|
||||
"ln -snf '/root/.local/share/mise/shims/claude' '/usr/local/bin/claude'",
|
||||
"ln -snf '/root/.local/share/mise/shims/pi' '/usr/local/bin/pi'",
|
||||
"cat > /etc/profile.d/mise.sh <<'EOF'",
|
||||
"if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then",
|
||||
`eval "$(/usr/local/bin/mise activate bash)"`,
|
||||
`if ! grep -Fqx 'eval "$(/usr/local/bin/mise activate bash)"' '/etc/bash.bashrc'; then`,
|
||||
"cat > /etc/systemd/system/banger-opencode.service <<'EOF'",
|
||||
"RequiresMountsFor=/root",
|
||||
"ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096",
|
||||
"systemctl enable --now banger-opencode.service || true",
|
||||
`git clone --depth 1 'https://github.com/tmux-plugins/tpm' "$TMUX_PLUGIN_DIR/tpm"`,
|
||||
`git clone --depth 1 'https://github.com/tmux-plugins/tmux-resurrect' "$TMUX_PLUGIN_DIR/tmux-resurrect"`,
|
||||
`git clone --depth 1 'https://github.com/tmux-plugins/tmux-continuum' "$TMUX_PLUGIN_DIR/tmux-continuum"`,
|
||||
"# >>> banger tmux plugins >>>",
|
||||
"set -g @plugin 'tmux-plugins/tmux-resurrect'",
|
||||
"set -g @plugin 'tmux-plugins/tmux-continuum'",
|
||||
"set -g @continuum-save-interval '15'",
|
||||
"set -g @continuum-restore 'off'",
|
||||
"set -g @resurrect-dir '/root/.tmux/resurrect'",
|
||||
"run '~/.tmux/plugins/tpm/tpm'",
|
||||
"cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'",
|
||||
"vmw_vsock_virtio_transport",
|
||||
"cat > /etc/systemd/system/banger-vsock-agent.service <<'EOF'",
|
||||
"ExecStart=/usr/local/bin/banger-vsock-agent",
|
||||
"systemctl enable --now banger-vsock-agent.service || true",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
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, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
|
@ -16,146 +16,6 @@ import (
|
|||
"banger/internal/system"
|
||||
)
|
||||
|
||||
func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (image model.Image, err error) {
|
||||
d.imageOpsMu.Lock()
|
||||
defer d.imageOpsMu.Unlock()
|
||||
op := d.beginOperation("image.build")
|
||||
buildLogPath := ""
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = annotateLogPath(err, buildLogPath)
|
||||
op.fail(err, imageLogAttrs(image)...)
|
||||
return
|
||||
}
|
||||
op.done(imageLogAttrs(image)...)
|
||||
}()
|
||||
|
||||
name := params.Name
|
||||
imageBuildStage(ctx, "resolve_image", "resolving image build inputs")
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("image-%d", model.Now().Unix())
|
||||
}
|
||||
if _, err := d.FindImage(ctx, name); err == nil {
|
||||
return model.Image{}, fmt.Errorf("image name already exists: %s", name)
|
||||
}
|
||||
fromImage := strings.TrimSpace(params.FromImage)
|
||||
if fromImage == "" {
|
||||
return model.Image{}, fmt.Errorf("from-image is required")
|
||||
}
|
||||
baseImage, err := d.FindImage(ctx, fromImage)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
id, err := model.NewID()
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
now := model.Now()
|
||||
artifactDir := filepath.Join(d.layout.ImagesDir, id)
|
||||
buildLogDir := filepath.Join(d.layout.StateDir, "image-build")
|
||||
if err := os.MkdirAll(buildLogDir, 0o755); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
buildLogPath = filepath.Join(buildLogDir, id+".log")
|
||||
imageBuildSetLogPath(ctx, buildLogPath)
|
||||
logFile, err := os.OpenFile(buildLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
defer logFile.Close()
|
||||
stageDir, err := os.MkdirTemp(d.layout.ImagesDir, id+".build-")
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
cleanupStage := true
|
||||
defer func() {
|
||||
if cleanupStage {
|
||||
_ = os.RemoveAll(stageDir)
|
||||
}
|
||||
}()
|
||||
rootfsPath := filepath.Join(stageDir, "rootfs.ext4")
|
||||
workSeedPath := filepath.Join(stageDir, "work-seed.ext4")
|
||||
kernelSource := firstNonEmpty(params.KernelPath, baseImage.KernelPath)
|
||||
initrdSource := firstNonEmpty(params.InitrdPath, baseImage.InitrdPath)
|
||||
modulesSource := firstNonEmpty(params.ModulesDir, baseImage.ModulesDir)
|
||||
if err := d.validateImageBuildPrereqs(ctx, baseImage.RootfsPath, kernelSource, initrdSource, modulesSource, params.Size); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
kernelPath, initrdPath, modulesDir, err := imagemgr.StageBootArtifacts(ctx, d.runner, stageDir, kernelSource, initrdSource, modulesSource)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
packages := imagemgr.DebianBasePackages()
|
||||
metadataPackages := imagemgr.BuildMetadataPackages(params.Docker)
|
||||
spec := imageBuildSpec{
|
||||
ID: id,
|
||||
Name: name,
|
||||
SourceRootfs: baseImage.RootfsPath,
|
||||
RootfsPath: rootfsPath,
|
||||
BuildLog: logFile,
|
||||
KernelPath: kernelPath,
|
||||
InitrdPath: initrdPath,
|
||||
ModulesDir: modulesDir,
|
||||
Packages: packages,
|
||||
InstallDocker: params.Docker,
|
||||
Size: params.Size,
|
||||
}
|
||||
op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir, "from_image", baseImage.Name)
|
||||
imageBuildStage(ctx, "launch_builder", "building rootfs from base image")
|
||||
if err := d.runImageBuild(ctx, spec); err != nil {
|
||||
_ = logFile.Sync()
|
||||
return model.Image{}, err
|
||||
}
|
||||
imageBuildStage(ctx, "prepare_work_seed", "building reusable work seed")
|
||||
if err := system.BuildWorkSeedImage(ctx, d.runner, rootfsPath, workSeedPath); err != nil {
|
||||
_ = logFile.Sync()
|
||||
return model.Image{}, err
|
||||
}
|
||||
imageBuildStage(ctx, "seed_ssh", "seeding runtime SSH access")
|
||||
seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath)
|
||||
if err != nil {
|
||||
_ = logFile.Sync()
|
||||
return model.Image{}, err
|
||||
}
|
||||
imageBuildStage(ctx, "write_metadata", "writing image metadata")
|
||||
if err := imagemgr.WritePackagesMetadata(rootfsPath, metadataPackages); err != nil {
|
||||
_ = logFile.Sync()
|
||||
return model.Image{}, err
|
||||
}
|
||||
op.stage("activate_artifacts", "artifact_dir", artifactDir)
|
||||
if err := os.Rename(stageDir, artifactDir); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
cleanupStage = false
|
||||
image = model.Image{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Managed: true,
|
||||
ArtifactDir: artifactDir,
|
||||
RootfsPath: filepath.Join(artifactDir, "rootfs.ext4"),
|
||||
WorkSeedPath: filepath.Join(artifactDir, "work-seed.ext4"),
|
||||
KernelPath: filepath.Join(artifactDir, "kernel"),
|
||||
InitrdPath: imagemgr.StageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img"),
|
||||
ModulesDir: imagemgr.StageOptionalArtifactPath(artifactDir, modulesDir, "modules"),
|
||||
BuildSize: params.Size,
|
||||
SeededSSHPublicKeyFingerprint: seededSSHPublicKeyFingerprint,
|
||||
Docker: params.Docker,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
imageBuildBindImage(ctx, image)
|
||||
if err := d.store.UpsertImage(ctx, image); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
op.stage("persisted", "build_log_path", buildLogPath)
|
||||
imageBuildStage(ctx, "persisted", "image metadata saved")
|
||||
if d.logger != nil {
|
||||
d.logger.Info("image build log preserved", append(imageLogAttrs(image), "build_log_path", buildLogPath)...)
|
||||
}
|
||||
_ = logFile.Sync()
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterParams) (image model.Image, err error) {
|
||||
d.imageOpsMu.Lock()
|
||||
defer d.imageOpsMu.Unlock()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
)
|
||||
|
|
@ -131,119 +130,6 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openDaemonStore(t)
|
||||
stateDir := filepath.Join(t.TempDir(), "state")
|
||||
imagesDir := filepath.Join(stateDir, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir images dir: %v", err)
|
||||
}
|
||||
|
||||
binDir := t.TempDir()
|
||||
for _, name := range []string{"sudo", "ip", "pgrep", "chown", "chmod", "kill", "iptables", "sysctl", "e2fsck", "resize2fs", "mkfs.ext4", "mount", "umount", "cp"} {
|
||||
writeFakeExecutable(t, filepath.Join(binDir, name))
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
baseRootfs := filepath.Join(t.TempDir(), "base.ext4")
|
||||
kernelPath := filepath.Join(t.TempDir(), "vmlinux")
|
||||
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
|
||||
firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
|
||||
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent")
|
||||
for _, path := range []string{baseRootfs, kernelPath, sshKeyPath} {
|
||||
if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write %s: %v", vsockHelper, err)
|
||||
}
|
||||
t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper)
|
||||
if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write %s: %v", firecrackerBin, err)
|
||||
}
|
||||
runner := &scriptedRunner{
|
||||
t: t,
|
||||
steps: []runnerStep{
|
||||
{call: runnerCall{name: "ip", args: []string{"route", "show", "default"}}, out: []byte("default via 192.0.2.1 dev eth0\n")},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger, _, err := newDaemonLogger(&buf, "info")
|
||||
if err != nil {
|
||||
t.Fatalf("newDaemonLogger: %v", err)
|
||||
}
|
||||
baseImage := model.Image{
|
||||
ID: "base-image",
|
||||
Name: "base-image",
|
||||
RootfsPath: baseRootfs,
|
||||
KernelPath: kernelPath,
|
||||
CreatedAt: model.Now(),
|
||||
UpdatedAt: model.Now(),
|
||||
}
|
||||
if err := store.UpsertImage(ctx, baseImage); err != nil {
|
||||
t.Fatalf("UpsertImage(base): %v", err)
|
||||
}
|
||||
d := &Daemon{
|
||||
layout: paths.Layout{
|
||||
StateDir: stateDir,
|
||||
ImagesDir: imagesDir,
|
||||
},
|
||||
config: model.DaemonConfig{
|
||||
DefaultImageName: "base-image",
|
||||
SSHKeyPath: sshKeyPath,
|
||||
FirecrackerBin: firecrackerBin,
|
||||
},
|
||||
store: store,
|
||||
runner: runner,
|
||||
logger: logger,
|
||||
imageBuild: func(ctx context.Context, spec imageBuildSpec) error {
|
||||
if _, err := fmt.Fprintln(spec.BuildLog, "builder-stdout"); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.SourceRootfs != baseRootfs || spec.KernelPath == kernelPath || len(spec.Packages) == 0 {
|
||||
t.Fatalf("unexpected image build spec: %+v", spec)
|
||||
}
|
||||
return errors.New("builder failed")
|
||||
},
|
||||
}
|
||||
|
||||
_, err = d.BuildImage(ctx, api.ImageBuildParams{
|
||||
Name: "broken-image",
|
||||
FromImage: baseImage.Name,
|
||||
KernelPath: kernelPath,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "inspect ") {
|
||||
t.Fatalf("BuildImage() error = %v, want build log hint", err)
|
||||
}
|
||||
|
||||
buildLogs, globErr := filepath.Glob(filepath.Join(stateDir, "image-build", "*.log"))
|
||||
if globErr != nil {
|
||||
t.Fatalf("glob build logs: %v", globErr)
|
||||
}
|
||||
if len(buildLogs) != 1 {
|
||||
t.Fatalf("build log count = %d, want 1", len(buildLogs))
|
||||
}
|
||||
logData, readErr := os.ReadFile(buildLogs[0])
|
||||
if readErr != nil {
|
||||
t.Fatalf("read build log: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(logData), "builder-stdout") {
|
||||
t.Fatalf("build log = %q, want builder output", string(logData))
|
||||
}
|
||||
runner.assertExhausted()
|
||||
|
||||
entries := parseLogEntries(t, buf.Bytes())
|
||||
if !hasLogEntry(entries, map[string]string{"msg": "operation stage", "operation": "image.build", "stage": "launch_builder"}) {
|
||||
t.Fatalf("expected launch_builder log, got %v", entries)
|
||||
}
|
||||
if !strings.Contains(buf.String(), buildLogs[0]) {
|
||||
t.Fatalf("daemon logs = %q, want build log path %s", buf.String(), buildLogs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func parseLogEntries(t *testing.T, data []byte) []map[string]any {
|
||||
t.Helper()
|
||||
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
|
||||
|
|
|
|||
|
|
@ -17,12 +17,6 @@ func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, im
|
|||
return checks.Err("vm start preflight failed")
|
||||
}
|
||||
|
||||
func (d *Daemon) validateImageBuildPrereqs(ctx context.Context, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec string) error {
|
||||
checks := system.NewPreflight()
|
||||
d.addImageBuildPrereqs(ctx, checks, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec)
|
||||
return checks.Err("image build preflight failed")
|
||||
}
|
||||
|
||||
func (d *Daemon) validateWorkDiskResizePrereqs() error {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireCommand("truncate", toolHint("truncate"))
|
||||
|
|
@ -70,35 +64,6 @@ func (d *Daemon) addBaseStartCommandPrereqs(checks *system.Preflight) {
|
|||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Preflight, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec string) {
|
||||
for _, command := range []string{"sudo", "ip", "pgrep", "chown", "chmod", "kill"} {
|
||||
checks.RequireCommand(command, toolHint(command))
|
||||
}
|
||||
for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} {
|
||||
checks.RequireCommand(command, toolHint(command))
|
||||
}
|
||||
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`)
|
||||
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`)
|
||||
if helper, err := d.vsockAgentBinary(); err == nil {
|
||||
checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`)
|
||||
} else {
|
||||
checks.Addf("%v", err)
|
||||
}
|
||||
checks.RequireFile(baseRootfs, "base image rootfs", `pass --from-image with a valid registered image`)
|
||||
checks.RequireFile(kernelPath, "kernel image", `pass --kernel or build from an image with a valid kernel`)
|
||||
if strings.TrimSpace(initrdPath) != "" {
|
||||
checks.RequireFile(initrdPath, "initrd image", `pass --initrd or build from an image with a valid initrd`)
|
||||
}
|
||||
if strings.TrimSpace(modulesDir) != "" {
|
||||
checks.RequireDir(modulesDir, "modules directory", `pass --modules or build from an image with a valid modules dir`)
|
||||
}
|
||||
if strings.TrimSpace(sizeSpec) != "" {
|
||||
checks.RequireCommand("e2fsck", toolHint("e2fsck"))
|
||||
checks.RequireCommand("resize2fs", toolHint("resize2fs"))
|
||||
}
|
||||
d.addNATPrereqs(ctx, checks)
|
||||
}
|
||||
|
||||
func toolHint(command string) string {
|
||||
switch command {
|
||||
case "ip":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue