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
|
|
@ -22,7 +22,6 @@ Always run `make build` before commit.
|
|||
- `./build/bin/banger vm run` is the primary user-facing entry point — auto-pulls the default image + kernel from the catalogs if missing.
|
||||
- `./build/bin/banger image pull <name>` uses the bundle catalog (fast) when `<name>` is a catalog entry, or falls through to the OCI path for arbitrary registry refs. See `docs/image-catalog.md` and `docs/oci-import.md`.
|
||||
- `./build/bin/banger image register ...` registers an unmanaged host-side image stack.
|
||||
- `./build/bin/banger image build --from-image <image>` builds a managed image from an existing one.
|
||||
- `./build/bin/banger image promote <image>` copies an unmanaged image into daemon-owned managed artifacts.
|
||||
- `scripts/make-generic-kernel.sh` builds a Firecracker-optimized vmlinux from upstream sources. `scripts/publish-kernel.sh <name>` publishes it to the kernel catalog.
|
||||
- `scripts/publish-golden-image.sh` rebuilds + publishes the golden image bundle and patches the image catalog.
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -110,15 +110,8 @@ banger image register --name base \
|
|||
--kernel-ref generic-6.12
|
||||
```
|
||||
|
||||
### `image build --from-image` — derived images
|
||||
|
||||
```bash
|
||||
banger image build --name devbox --from-image debian-bookworm --docker
|
||||
```
|
||||
|
||||
Spins up a transient VM from a base image, applies opinionated
|
||||
customisation (mise, claude, pi, tmux plugins), saves a new managed
|
||||
image.
|
||||
For custom images, write a Dockerfile and either publish to the
|
||||
catalog (see `docs/image-catalog.md`) or pull it via the OCI path.
|
||||
|
||||
### Workspace + session primitives
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ banger image pull ghcr.io/myorg/devimg:v2 --kernel-ref generic-6.12
|
|||
`openssh-server` via the guest's package manager on first boot.
|
||||
Dispatches on `/etc/os-release` → `apt-get` / `apk` / `dnf` /
|
||||
`pacman` / `zypper`. Subsequent boots skip the install.
|
||||
- Composition with `image build --from-image`.
|
||||
|
||||
## What doesn't yet work
|
||||
|
||||
|
|
|
|||
|
|
@ -58,33 +58,6 @@ type VMCreateStatusResult struct {
|
|||
Operation VMCreateOperation `json:"operation"`
|
||||
}
|
||||
|
||||
type ImageBuildStatusParams struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type ImageBuildOperation struct {
|
||||
ID string `json:"id"`
|
||||
ImageID string `json:"image_id,omitempty"`
|
||||
ImageName string `json:"image_name,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
BuildLogPath string `json:"build_log_path,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Image *model.Image `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
type ImageBuildBeginResult struct {
|
||||
Operation ImageBuildOperation `json:"operation"`
|
||||
}
|
||||
|
||||
type ImageBuildStatusResult struct {
|
||||
Operation ImageBuildOperation `json:"operation"`
|
||||
}
|
||||
|
||||
type VMRefParams struct {
|
||||
IDOrName string `json:"id_or_name"`
|
||||
}
|
||||
|
|
@ -242,16 +215,6 @@ type VMWorkspacePrepareResult struct {
|
|||
Workspace model.WorkspacePrepareResult `json:"workspace"`
|
||||
}
|
||||
|
||||
type ImageBuildParams struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
FromImage string `json:"from_image,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
KernelPath string `json:"kernel_path,omitempty"`
|
||||
InitrdPath string `json:"initrd_path,omitempty"`
|
||||
ModulesDir string `json:"modules_dir,omitempty"`
|
||||
Docker bool `json:"docker,omitempty"`
|
||||
}
|
||||
|
||||
type ImageRegisterParams struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
RootfsPath string `json:"rootfs_path,omitempty"`
|
||||
|
|
|
|||
|
|
@ -1702,7 +1702,6 @@ func newImageCommand() *cobra.Command {
|
|||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newImageBuildCommand(),
|
||||
newImageRegisterCommand(),
|
||||
newImagePullCommand(),
|
||||
newImagePromoteCommand(),
|
||||
|
|
@ -1713,40 +1712,6 @@ func newImageCommand() *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func newImageBuildCommand() *cobra.Command {
|
||||
var params api.ImageBuildParams
|
||||
cmd := &cobra.Command{
|
||||
Use: "build",
|
||||
Short: "Build an image",
|
||||
Args: noArgsUsage("usage: banger image build"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := absolutizeImageBuildPaths(¶ms); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.build", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printImageSummary(cmd.OutOrStdout(), result.Image)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(¶ms.Name, "name", "", "image name")
|
||||
cmd.Flags().StringVar(¶ms.FromImage, "from-image", "", "registered base image id or name")
|
||||
cmd.Flags().StringVar(¶ms.Size, "size", "", "output image size")
|
||||
cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path")
|
||||
cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path")
|
||||
cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir")
|
||||
cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "install docker")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newImageRegisterCommand() *cobra.Command {
|
||||
var params api.ImageRegisterParams
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -3181,10 +3146,6 @@ func shellQuote(value string) string {
|
|||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
func absolutizeImageBuildPaths(params *api.ImageBuildParams) error {
|
||||
return absolutizePaths(¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir)
|
||||
}
|
||||
|
||||
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
|
||||
return absolutizePaths(
|
||||
¶ms.RootfsPath,
|
||||
|
|
|
|||
|
|
@ -1965,40 +1965,6 @@ func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAbsolutizeImageBuildPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
prev, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(prev)
|
||||
})
|
||||
|
||||
params := api.ImageBuildParams{
|
||||
FromImage: "base-image",
|
||||
KernelPath: "/kernel",
|
||||
InitrdPath: "boot/initrd.img",
|
||||
ModulesDir: "modules",
|
||||
}
|
||||
if err := absolutizeImageBuildPaths(¶ms); err != nil {
|
||||
t.Fatalf("absolutizeImageBuildPaths: %v", err)
|
||||
}
|
||||
|
||||
want := api.ImageBuildParams{
|
||||
FromImage: "base-image",
|
||||
KernelPath: "/kernel",
|
||||
InitrdPath: filepath.Join(dir, "boot/initrd.img"),
|
||||
ModulesDir: filepath.Join(dir, "modules"),
|
||||
}
|
||||
if !reflect.DeepEqual(params, want) {
|
||||
t.Fatalf("params = %+v, want %+v", params, want)
|
||||
}
|
||||
}
|
||||
|
||||
func testCLIResolvedVM(id, name string) model.VMRecord {
|
||||
return model.VMRecord{ID: id, Name: name}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -155,16 +155,6 @@ type VMSetRequest struct {
|
|||
NATEnabled *bool
|
||||
}
|
||||
|
||||
type ImageBuildRequest struct {
|
||||
Name string
|
||||
FromImage string
|
||||
Size string
|
||||
KernelPath string
|
||||
InitrdPath string
|
||||
ModulesDir string
|
||||
Docker bool
|
||||
}
|
||||
|
||||
type GuestSession struct {
|
||||
ID string `json:"id"`
|
||||
VMID string `json:"vm_id"`
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ type Backend interface {
|
|||
PortsVM(context.Context, string) (api.VMPortsResult, error)
|
||||
ListImages(context.Context) ([]model.Image, error)
|
||||
FindImage(context.Context, string) (model.Image, error)
|
||||
BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error)
|
||||
ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error)
|
||||
RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error)
|
||||
PromoteImage(context.Context, string) (model.Image, error)
|
||||
DeleteImage(context.Context, string) (model.Image, error)
|
||||
|
|
@ -84,16 +82,6 @@ type vmSetForm struct {
|
|||
NATEnabled bool
|
||||
}
|
||||
|
||||
type imageBuildForm struct {
|
||||
Name string
|
||||
FromImage string
|
||||
Size string
|
||||
KernelPath string
|
||||
InitrdPath string
|
||||
ModulesDir string
|
||||
Docker bool
|
||||
}
|
||||
|
||||
type imageRegisterForm struct {
|
||||
Name string
|
||||
RootfsPath string
|
||||
|
|
@ -126,11 +114,9 @@ type pageData struct {
|
|||
Images []model.Image
|
||||
Image model.Image
|
||||
ImageUsers int
|
||||
ImageBuildForm imageBuildForm
|
||||
ImageRegisterForm imageRegisterForm
|
||||
LogText string
|
||||
VMCreateOperation *api.VMCreateOperation
|
||||
ImageBuildOperation *api.ImageBuildOperation
|
||||
OperationStatusURL string
|
||||
OperationSuccessURL string
|
||||
OperationLogPath string
|
||||
|
|
@ -197,17 +183,13 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("POST /vms/{id}/delete", s.wrap(s.handleVMDelete))
|
||||
mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet))
|
||||
mux.HandleFunc("GET /images", s.wrap(s.handleImageList))
|
||||
mux.HandleFunc("GET /images/build", s.wrap(s.handleImageBuildForm))
|
||||
mux.HandleFunc("POST /images/build", s.wrap(s.handleImageBuild))
|
||||
mux.HandleFunc("GET /images/register", s.wrap(s.handleImageRegisterForm))
|
||||
mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister))
|
||||
mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow))
|
||||
mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote))
|
||||
mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete))
|
||||
mux.HandleFunc("GET /operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationPage))
|
||||
mux.HandleFunc("GET /operations/image-build/{id}", s.wrap(s.handleImageBuildOperationPage))
|
||||
mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI))
|
||||
mux.HandleFunc("GET /api/operations/image-build/{id}", s.wrap(s.handleImageBuildOperationAPI))
|
||||
mux.HandleFunc("GET /api/fs", s.wrap(s.handleFSAPI))
|
||||
}
|
||||
|
||||
|
|
@ -522,42 +504,6 @@ func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleImageBuildForm(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.renderImageBuildPage(w, r, imageBuildForm{}, "")
|
||||
}
|
||||
|
||||
func (s *Server) renderImageBuildPage(w http.ResponseWriter, r *http.Request, form imageBuildForm, formErr string) error {
|
||||
return s.renderPage(w, r, http.StatusOK, "Build Image", "image_build_content", func(data *pageData) error {
|
||||
data.Section = "images"
|
||||
data.ImageBuildForm = form
|
||||
data.ErrorMessage = formErr
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleImageBuild(w http.ResponseWriter, r *http.Request) error {
|
||||
if err := s.verifyPOST(w, r); err != nil {
|
||||
return err
|
||||
}
|
||||
allowed, err := s.requireMutationAllowed(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
form, params, err := s.parseImageBuildForm(r)
|
||||
if err != nil {
|
||||
return s.renderImageBuildPage(w, r, form, err.Error())
|
||||
}
|
||||
if !allowed {
|
||||
return s.renderImageBuildPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
|
||||
}
|
||||
op, err := s.backend.BeginImageBuild(r.Context(), params)
|
||||
if err != nil {
|
||||
return s.renderImageBuildPage(w, r, form, err.Error())
|
||||
}
|
||||
http.Redirect(w, r, "/operations/image-build/"+url.PathEscape(op.ID), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "")
|
||||
}
|
||||
|
|
@ -683,24 +629,6 @@ func (s *Server) handleVMCreateOperationPage(w http.ResponseWriter, r *http.Requ
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleImageBuildOperationPage(w http.ResponseWriter, r *http.Request) error {
|
||||
op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.renderPage(w, r, http.StatusOK, "Building Image", "operation_content", func(data *pageData) error {
|
||||
data.Section = "images"
|
||||
data.OperationKind = "image"
|
||||
data.ImageBuildOperation = &op
|
||||
data.OperationStatusURL = "/api/operations/image-build/" + url.PathEscape(op.ID)
|
||||
if op.ImageID != "" {
|
||||
data.OperationSuccessURL = "/images/" + url.PathEscape(op.ImageID)
|
||||
}
|
||||
data.OperationLogPath = op.BuildLogPath
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error {
|
||||
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
|
|
@ -709,14 +637,6 @@ func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Reque
|
|||
return writeJSON(w, api.VMCreateStatusResult{Operation: op})
|
||||
}
|
||||
|
||||
func (s *Server) handleImageBuildOperationAPI(w http.ResponseWriter, r *http.Request) error {
|
||||
op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(w, api.ImageBuildStatusResult{Operation: op})
|
||||
}
|
||||
|
||||
func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error {
|
||||
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
if path == "" {
|
||||
|
|
@ -977,31 +897,6 @@ func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetPa
|
|||
return params, nil
|
||||
}
|
||||
|
||||
func (s *Server) parseImageBuildForm(r *http.Request) (imageBuildForm, api.ImageBuildParams, error) {
|
||||
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
||||
return imageBuildForm{}, api.ImageBuildParams{}, err
|
||||
}
|
||||
form := imageBuildForm{
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
FromImage: strings.TrimSpace(r.FormValue("from_image")),
|
||||
Size: strings.TrimSpace(r.FormValue("size")),
|
||||
KernelPath: strings.TrimSpace(r.FormValue("kernel_path")),
|
||||
InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")),
|
||||
ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")),
|
||||
Docker: r.FormValue("docker") == "on",
|
||||
}
|
||||
params := api.ImageBuildParams{
|
||||
Name: form.Name,
|
||||
FromImage: form.FromImage,
|
||||
Size: form.Size,
|
||||
KernelPath: form.KernelPath,
|
||||
InitrdPath: form.InitrdPath,
|
||||
ModulesDir: form.ModulesDir,
|
||||
Docker: form.Docker,
|
||||
}
|
||||
return form, params, nil
|
||||
}
|
||||
|
||||
func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) {
|
||||
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
||||
return imageRegisterForm{}, api.ImageRegisterParams{}, err
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ type fakeBackend struct {
|
|||
image model.Image
|
||||
ports api.VMPortsResult
|
||||
createOp api.VMCreateOperation
|
||||
buildOp api.ImageBuildOperation
|
||||
}
|
||||
|
||||
func (f fakeBackend) Config() model.DaemonConfig { return f.config }
|
||||
|
|
@ -55,12 +54,6 @@ func (f fakeBackend) SetVM(context.Context, api.VMSetParams) (model.VMRecord, er
|
|||
func (f fakeBackend) PortsVM(context.Context, string) (api.VMPortsResult, error) { return f.ports, nil }
|
||||
func (f fakeBackend) ListImages(context.Context) ([]model.Image, error) { return f.images, nil }
|
||||
func (f fakeBackend) FindImage(context.Context, string) (model.Image, error) { return f.image, nil }
|
||||
func (f fakeBackend) BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error) {
|
||||
return f.buildOp, nil
|
||||
}
|
||||
func (f fakeBackend) ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error) {
|
||||
return f.buildOp, nil
|
||||
}
|
||||
func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) {
|
||||
return f.image, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@
|
|||
<h3>Images</h3>
|
||||
<div class="stack-inline">
|
||||
<a class="button secondary" href="/images/register">Register</a>
|
||||
<a class="button" href="/images/build">Build</a>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
<p class="muted">Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.</p>
|
||||
<div class="stack-inline">
|
||||
<a class="button secondary" href="/images/register">Register Image</a>
|
||||
<a class="button" href="/images/build">Build Image</a>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
|
|
@ -32,48 +31,6 @@
|
|||
</table>
|
||||
{{end}}
|
||||
|
||||
{{define "image_build_content"}}
|
||||
<p class="muted">Build a managed image from an existing registered image, then redirect into the async build progress view.</p>
|
||||
{{if .ErrorMessage}}
|
||||
<div class="inline-error">{{.ErrorMessage}}</div>
|
||||
{{end}}
|
||||
<form method="post" action="/images/build" class="form-grid">
|
||||
{{template "csrf_field" .}}
|
||||
<label><span>Name</span><input type="text" name="name" value="{{.ImageBuildForm.Name}}" placeholder="generated when empty"></label>
|
||||
<label><span>From Image</span><input type="text" name="from_image" value="{{.ImageBuildForm.FromImage}}" placeholder="image id or name"></label>
|
||||
<label><span>Size Override</span><input type="text" name="size" value="{{.ImageBuildForm.Size}}" placeholder="optional"></label>
|
||||
<label class="picker-field">
|
||||
<span>Kernel Path</span>
|
||||
<div class="picker-input">
|
||||
<input type="text" name="kernel_path" value="{{.ImageBuildForm.KernelPath}}" data-picker-input>
|
||||
<button type="button" class="button secondary" data-picker-target="kernel_path" data-picker-kind="file">Browse</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="picker-field">
|
||||
<span>Initrd Path</span>
|
||||
<div class="picker-input">
|
||||
<input type="text" name="initrd_path" value="{{.ImageBuildForm.InitrdPath}}" data-picker-input>
|
||||
<button type="button" class="button secondary" data-picker-target="initrd_path" data-picker-kind="file">Browse</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="picker-field">
|
||||
<span>Modules Directory</span>
|
||||
<div class="picker-input">
|
||||
<input type="text" name="modules_dir" value="{{.ImageBuildForm.ModulesDir}}" data-picker-input>
|
||||
<button type="button" class="button secondary" data-picker-target="modules_dir" data-picker-kind="dir">Browse</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="docker" {{if .ImageBuildForm.Docker}}checked{{end}}>
|
||||
<span>Install Docker</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<a class="button secondary" href="/images">Cancel</a>
|
||||
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Build Image</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "image_register_content"}}
|
||||
<p class="muted">Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.</p>
|
||||
{{if .ErrorMessage}}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
{{define "operation_content"}}
|
||||
<section class="operation-card" data-operation-url="{{.OperationStatusURL}}" {{if .OperationSuccessURL}}data-operation-success="{{.OperationSuccessURL}}"{{end}}>
|
||||
<h2>{{if eq .OperationKind "vm"}}VM readiness{{else}}Managed image build{{end}}</h2>
|
||||
<h2>VM readiness</h2>
|
||||
{{if .VMCreateOperation}}
|
||||
<h3 id="operation-stage">{{.VMCreateOperation.Stage}}</h3>
|
||||
<p id="operation-detail">{{.VMCreateOperation.Detail}}</p>
|
||||
<p class="muted" id="operation-error">{{.VMCreateOperation.Error}}</p>
|
||||
{{end}}
|
||||
{{if .ImageBuildOperation}}
|
||||
<h3 id="operation-stage">{{.ImageBuildOperation.Stage}}</h3>
|
||||
<p id="operation-detail">{{.ImageBuildOperation.Detail}}</p>
|
||||
<p class="muted" id="operation-error">{{.ImageBuildOperation.Error}}</p>
|
||||
{{end}}
|
||||
{{if .OperationLogPath}}
|
||||
<p class="muted">Build log: <code id="operation-log">{{.OperationLogPath}}</code></p>
|
||||
{{else}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue