Manage image artifacts and show VM create progress
Stop relying on ad hoc rootfs handling by adding image promotion, managed work-seed fingerprint metadata, and lazy self-healing for older managed images after the first create. Rebuild guest images with baked SSH access, a guest NIC bootstrap, and default opencode services, and add the staged Void kernel/initramfs/modules workflow so void-exp uses a matching Void boot stack. Replace the opaque blocking vm.create RPC with a begin/status flow that prints live stages in the CLI while still waiting for vsock health and opencode on guest port 4096. Validate with GOCACHE=/tmp/banger-gocache go test ./... and live void-exp create/delete smoke runs.
This commit is contained in:
parent
9f09b0d25c
commit
30f0c0b54a
37 changed files with 2334 additions and 99 deletions
|
|
@ -46,6 +46,16 @@ var (
|
|||
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
||||
return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName})
|
||||
}
|
||||
vmCreateBeginFunc = func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
||||
return rpc.Call[api.VMCreateBeginResult](ctx, socketPath, "vm.create.begin", params)
|
||||
}
|
||||
vmCreateStatusFunc = func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error) {
|
||||
return rpc.Call[api.VMCreateStatusResult](ctx, socketPath, "vm.create.status", api.VMCreateStatusParams{ID: operationID})
|
||||
}
|
||||
vmCreateCancelFunc = func(ctx context.Context, socketPath, operationID string) error {
|
||||
_, err := rpc.Call[api.Empty](ctx, socketPath, "vm.create.cancel", api.VMCreateStatusParams{ID: operationID})
|
||||
return err
|
||||
}
|
||||
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
|
||||
return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName})
|
||||
}
|
||||
|
|
@ -323,11 +333,11 @@ func newVMCreateCommand() *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.create", params)
|
||||
vm, err := runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printVMSummary(cmd.OutOrStdout(), result.VM)
|
||||
return printVMSummary(cmd.OutOrStdout(), vm)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "vm name")
|
||||
|
|
@ -575,6 +585,7 @@ func newImageCommand() *cobra.Command {
|
|||
cmd.AddCommand(
|
||||
newImageBuildCommand(),
|
||||
newImageRegisterCommand(),
|
||||
newImagePromoteCommand(),
|
||||
newImageListCommand(),
|
||||
newImageShowCommand(),
|
||||
newImageDeleteCommand(),
|
||||
|
|
@ -651,6 +662,28 @@ func newImageRegisterCommand() *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func newImagePromoteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "promote <id-or-name>",
|
||||
Short: "Promote an unmanaged image to a managed artifact",
|
||||
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
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.promote", api.ImageRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printImageSummary(cmd.OutOrStdout(), result.Image)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newImageListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
|
|
@ -1255,6 +1288,141 @@ type anyWriter interface {
|
|||
Write(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
func runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams) (model.VMRecord, error) {
|
||||
begin, err := vmCreateBeginFunc(ctx, socketPath, params)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
renderer := newVMCreateProgressRenderer(stderr)
|
||||
renderer.render(begin.Operation)
|
||||
|
||||
op := begin.Operation
|
||||
for {
|
||||
if op.Done {
|
||||
renderer.render(op)
|
||||
if op.Success && op.VM != nil {
|
||||
return *op.VM, nil
|
||||
}
|
||||
if strings.TrimSpace(op.Error) == "" {
|
||||
return model.VMRecord{}, errors.New("vm create failed")
|
||||
}
|
||||
return model.VMRecord{}, errors.New(op.Error)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID)
|
||||
return model.VMRecord{}, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
status, err := vmCreateStatusFunc(ctx, socketPath, op.ID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID)
|
||||
return model.VMRecord{}, ctx.Err()
|
||||
}
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
op = status.Operation
|
||||
renderer.render(op)
|
||||
}
|
||||
}
|
||||
|
||||
type vmCreateProgressRenderer struct {
|
||||
out io.Writer
|
||||
enabled bool
|
||||
lastLine string
|
||||
}
|
||||
|
||||
func newVMCreateProgressRenderer(out io.Writer) *vmCreateProgressRenderer {
|
||||
return &vmCreateProgressRenderer{
|
||||
out: out,
|
||||
enabled: writerSupportsProgress(out),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *vmCreateProgressRenderer) render(op api.VMCreateOperation) {
|
||||
if r == nil || !r.enabled {
|
||||
return
|
||||
}
|
||||
line := formatVMCreateProgress(op)
|
||||
if line == "" || line == r.lastLine {
|
||||
return
|
||||
}
|
||||
r.lastLine = line
|
||||
_, _ = fmt.Fprintln(r.out, line)
|
||||
}
|
||||
|
||||
func writerSupportsProgress(out io.Writer) bool {
|
||||
file, ok := out.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func formatVMCreateProgress(op api.VMCreateOperation) string {
|
||||
stage := strings.TrimSpace(op.Stage)
|
||||
detail := strings.TrimSpace(op.Detail)
|
||||
label := vmCreateStageLabel(stage)
|
||||
if label == "" && detail == "" {
|
||||
return ""
|
||||
}
|
||||
if label == "" {
|
||||
return "[vm create] " + detail
|
||||
}
|
||||
if detail == "" {
|
||||
return "[vm create] " + label
|
||||
}
|
||||
return "[vm create] " + label + ": " + detail
|
||||
}
|
||||
|
||||
func vmCreateStageLabel(stage string) string {
|
||||
switch strings.TrimSpace(stage) {
|
||||
case "queued":
|
||||
return "queued"
|
||||
case "resolve_image":
|
||||
return "resolving image"
|
||||
case "reserve_vm":
|
||||
return "allocating vm"
|
||||
case "preflight":
|
||||
return "checking host prerequisites"
|
||||
case "prepare_rootfs":
|
||||
return "preparing root filesystem"
|
||||
case "prepare_host_features":
|
||||
return "preparing host features"
|
||||
case "prepare_work_disk":
|
||||
return "preparing work disk"
|
||||
case "boot_firecracker":
|
||||
return "starting firecracker"
|
||||
case "wait_vsock_agent":
|
||||
return "waiting for vsock agent"
|
||||
case "wait_guest_ready":
|
||||
return "waiting for guest services"
|
||||
case "wait_opencode":
|
||||
return "waiting for opencode"
|
||||
case "apply_dns":
|
||||
return "publishing dns"
|
||||
case "apply_nat":
|
||||
return "configuring nat"
|
||||
case "finalize":
|
||||
return "finalizing"
|
||||
case "ready":
|
||||
return "ready"
|
||||
default:
|
||||
return strings.ReplaceAll(stage, "_", " ")
|
||||
}
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
|
|
|
|||
|
|
@ -170,6 +170,17 @@ func TestImageRegisterFlagsExist(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImagePromoteCommandExists(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
image, _, err := root.Find([]string{"image"})
|
||||
if err != nil {
|
||||
t.Fatalf("find image: %v", err)
|
||||
}
|
||||
if _, _, err := image.Find([]string{"promote"}); err != nil {
|
||||
t.Fatalf("find promote: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMKillFlagsExist(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
|
|
@ -304,6 +315,95 @@ func TestVMCreateParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunVMCreatePollsUntilDone(t *testing.T) {
|
||||
origBegin := vmCreateBeginFunc
|
||||
origStatus := vmCreateStatusFunc
|
||||
origCancel := vmCreateCancelFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
vmCreateCancelFunc = origCancel
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
ID: "vm-id",
|
||||
Name: "devbox",
|
||||
Spec: model.VMSpec{WorkDiskSizeBytes: model.DefaultWorkDiskSize},
|
||||
Runtime: model.VMRuntime{
|
||||
State: model.VMStateRunning,
|
||||
GuestIP: "172.16.0.2",
|
||||
DNSName: "devbox.vm",
|
||||
},
|
||||
}
|
||||
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
||||
return api.VMCreateBeginResult{
|
||||
Operation: api.VMCreateOperation{
|
||||
ID: "op-1",
|
||||
Stage: "prepare_work_disk",
|
||||
Detail: "cloning work seed",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
statusCalls := 0
|
||||
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
||||
statusCalls++
|
||||
if statusCalls == 1 {
|
||||
return api.VMCreateStatusResult{
|
||||
Operation: api.VMCreateOperation{
|
||||
ID: "op-1",
|
||||
Stage: "wait_opencode",
|
||||
Detail: "waiting for opencode on guest port 4096",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return api.VMCreateStatusResult{
|
||||
Operation: api.VMCreateOperation{
|
||||
ID: "op-1",
|
||||
Stage: "ready",
|
||||
Detail: "vm is ready",
|
||||
Done: true,
|
||||
Success: true,
|
||||
VM: &vm,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
vmCreateCancelFunc = func(context.Context, string, string) error {
|
||||
t.Fatal("cancel should not be called")
|
||||
return nil
|
||||
}
|
||||
|
||||
got, err := runVMCreate(context.Background(), "/tmp/bangerd.sock", &bytes.Buffer{}, api.VMCreateParams{Name: "devbox"})
|
||||
if err != nil {
|
||||
t.Fatalf("runVMCreate: %v", err)
|
||||
}
|
||||
if got.Name != vm.Name || got.Runtime.GuestIP != vm.Runtime.GuestIP {
|
||||
t.Fatalf("vm = %+v, want %+v", got, vm)
|
||||
}
|
||||
if statusCalls != 2 {
|
||||
t.Fatalf("statusCalls = %d, want 2", statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
renderer := &vmCreateProgressRenderer{out: &stderr, enabled: true}
|
||||
|
||||
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
||||
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
||||
renderer.render(api.VMCreateOperation{Stage: "wait_opencode", Detail: "waiting for opencode on guest port 4096"})
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("rendered lines = %q, want 2 lines", stderr.String())
|
||||
}
|
||||
if lines[0] != "[vm create] preparing work disk: cloning work seed" {
|
||||
t.Fatalf("first line = %q", lines[0])
|
||||
}
|
||||
if lines[1] != "[vm create] waiting for opencode: waiting for opencode on guest port 4096" {
|
||||
t.Fatalf("second line = %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
|
||||
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
|
||||
t.Fatal("expected nat conflict error")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue