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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue