diff --git a/internal/httpapi/phase3.go b/internal/httpapi/phase3.go index ccbdca8..011386c 100644 --- a/internal/httpapi/phase3.go +++ b/internal/httpapi/phase3.go @@ -230,10 +230,12 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) return } - if cfg.VMID == 0 || cfg.Hostname == "" { + // Only hostname is required. vmid is optional — when 0 (or later found + // to collide) the VMID guard below assigns a free cluster id. + if cfg.Hostname == "" { pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, `{"error":"pct_create: vmid and hostname are required"}`) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing vmid or hostname"}) + execID, `{"error":"pct_create: hostname is required"}`) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"}) return } if cfg.Cores == 0 { @@ -329,11 +331,23 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ",")) } + // net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox + // rejects a gateway alongside ip=dhcp, so only add gw for a static IP. + net0 := "name=eth0,bridge=vmbr0," + if cfg.IP == "" || strings.EqualFold(cfg.IP, "dhcp") { + net0 += "ip=dhcp" + } else { + net0 += "ip=" + cfg.IP + if cfg.GW != "" { + net0 += ",gw=" + cfg.GW + } + } + templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template) createCmd := fmt.Sprintf( - "pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 name=eth0,bridge=vmbr0,ip=%s,gw=%s%s --start 1", + "pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1", cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory, - cfg.Storage, cfg.DiskGB, privFlag, cfg.IP, cfg.GW, nestingFlag) + cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag) if cfg.Nameserver != "" { createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver) @@ -358,8 +372,9 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, // with a boot settle; failures are appended to output and mark the // execution failed so the operator sees exactly which step broke. if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") { - // Give the container a moment to finish booting before exec. - steps := []string{fmt.Sprintf("sleep 5")} + // Give the container time to boot and (for DHCP) acquire a lease + // before apt needs the network. + steps := []string{"sleep 10"} if len(cfg.Services) > 0 { pkgs := strings.Join(sanitizePkgs(cfg.Services), " ") steps = append(steps, fmt.Sprintf("pct exec %d -- bash -lc 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s'", cfg.VMID, pkgs))