internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into 15 per-resource files: - actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget, executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate) - checks.go, classifications.go, executions.go, approvals.go, patterns.go, skills.go, approval_rules.go, autonomy.go, risk_classes.go, relationships.go, entity_types.go, metrics.go, agent_activity.go, helpers.go — one file per resource domain, each with its own imports. internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations) refactored to a registry pattern: - internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33 tool definitions. Handler logic moved verbatim — no changes to tool names, descriptions, schemas, or behavior. - server.go: newServer is now 9 lines (iterate registry, AddTool each). -699 lines. No function logic, names, or signatures changed. go vet, build, and all tests pass (httpapi, mcp, db, policy).
677 lines
27 KiB
Go
677 lines
27 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/db"
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
var (
|
|
_sshUser string
|
|
_sshKey []byte
|
|
)
|
|
|
|
// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes").
|
|
// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool`
|
|
// field made the approved pct_create execution fail to parse *after* the
|
|
// operator had already approved it — the container was never created and the
|
|
// operator saw "queued" with no result. This type tolerates the common shapes.
|
|
type flexBool bool
|
|
|
|
func (b *flexBool) UnmarshalJSON(data []byte) error {
|
|
s := strings.TrimSpace(strings.Trim(string(data), `"`))
|
|
switch strings.ToLower(s) {
|
|
case "true", "1", "yes", "on":
|
|
*b = true
|
|
case "false", "0", "no", "off", "", "null":
|
|
*b = false
|
|
default:
|
|
return fmt.Errorf("cannot parse %q as bool", s)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func initSSH() {
|
|
if _sshUser == "" {
|
|
_sshUser = os.Getenv("OIKOS_SSH_USER")
|
|
if _sshUser == "" {
|
|
_sshUser = "root"
|
|
}
|
|
}
|
|
if len(_sshKey) == 0 {
|
|
keyPath := os.Getenv("OIKOS_SSH_KEY_PATH")
|
|
if keyPath == "" {
|
|
keyPath = "/etc/oikos/ssh_key"
|
|
}
|
|
var err error
|
|
_sshKey, err = os.ReadFile(keyPath)
|
|
if err != nil {
|
|
slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sshExecTimeout bounds how long a single remote command may run. Without
|
|
// this, a hung remote command (e.g. a piped install script stuck retrying
|
|
// DNS against a misconfigured gateway) blocks the executing goroutine
|
|
// forever: the execution never leaves 'approved'/'running', the operator
|
|
// sees an unkillable spinner, and get_execution_status has nothing new to
|
|
// report. Generous enough for a real apt/docker install; not infinite.
|
|
const sshExecTimeout = 10 * time.Minute
|
|
|
|
func sshExec(ctx context.Context, host, user, command string) (string, error) {
|
|
initSSH()
|
|
if len(_sshKey) == 0 {
|
|
return "", fmt.Errorf("no SSH key available")
|
|
}
|
|
if user == "" {
|
|
user = _sshUser
|
|
}
|
|
|
|
addr := host + ":22"
|
|
signer, err := ssh.ParsePrivateKey(_sshKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse key: %w", err)
|
|
}
|
|
|
|
cfg := &ssh.ClientConfig{
|
|
User: user,
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
client, err := ssh.Dial("tcp", addr, cfg)
|
|
if err != nil {
|
|
return "", fmt.Errorf("dial %s: %w", host, err)
|
|
}
|
|
defer client.Close()
|
|
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return "", fmt.Errorf("session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
type result struct {
|
|
out []byte
|
|
err error
|
|
}
|
|
done := make(chan result, 1)
|
|
go func() {
|
|
// See internal/mcp/server.go's sshExec for why this recovers rather
|
|
// than letting a rare SSH-library panic crash the whole api process.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
|
|
}
|
|
}()
|
|
out, err := session.CombinedOutput(command)
|
|
done <- result{out, err}
|
|
}()
|
|
|
|
select {
|
|
case r := <-done:
|
|
text := strings.TrimSpace(string(r.out))
|
|
// A non-zero exit MUST surface as an error. The previous guard only
|
|
// errored when there was no output, so a `pct create` that printed
|
|
// "CT 132 already exists" and exited non-zero was reported as
|
|
// success — the execution was marked completed though nothing was
|
|
// provisioned.
|
|
if r.err != nil {
|
|
if text != "" {
|
|
return text, fmt.Errorf("%w: %s", r.err, text)
|
|
}
|
|
return text, fmt.Errorf("exec: %w", r.err)
|
|
}
|
|
return text, nil
|
|
case <-time.After(sshExecTimeout):
|
|
// Close the session/client to hang up the remote side; the
|
|
// goroutine above will eventually exit once that unblocks
|
|
// CombinedOutput, but we don't wait for it — the caller needs an
|
|
// answer now, not an indefinite hang.
|
|
session.Close()
|
|
client.Close()
|
|
return "", fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
|
|
case <-ctx.Done():
|
|
session.Close()
|
|
client.Close()
|
|
return "", ctx.Err()
|
|
}
|
|
}
|
|
|
|
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {
|
|
var attrs string
|
|
err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("entity not found: %s", entitySlug)
|
|
}
|
|
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal([]byte(attrs), &m); err != nil {
|
|
return "", "", fmt.Errorf("parse attributes: %w", err)
|
|
}
|
|
|
|
sshUser := _sshUser
|
|
if sshUser == "" {
|
|
sshUser = "root"
|
|
}
|
|
|
|
if ip, ok := m["lan_ip"].(string); ok && ip != "" {
|
|
return ip, sshUser, nil
|
|
}
|
|
if mesh, ok := m["mesh"].(map[string]interface{}); ok {
|
|
for _, proto := range []string{"netbird", "tailscale"} {
|
|
if p, ok := mesh[proto].(map[string]interface{}); ok {
|
|
if ip, ok := p["ip"].(string); ok && ip != "" {
|
|
return ip, sshUser, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
|
|
}
|
|
|
|
// resolveRunTarget mirrors internal/mcp.resolveExecTarget for the approved-
|
|
// execution side: any target slug (host: or lxc:) resolves to the SSH
|
|
// endpoint that runs the command plus a wrap function that turns a plain
|
|
// shell command into what actually needs to be sent — identity for a host,
|
|
// `pct exec <pve_id>` for an LXC. Kept as a small duplicate rather than a
|
|
// cross-package import to avoid coupling httpapi to mcp for one helper.
|
|
func resolveRunTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(string) string, err error) {
|
|
if strings.HasPrefix(targetSlug, "host:") {
|
|
host, user, err = resolveHostSSH(ctx, pool, targetSlug)
|
|
return host, user, func(cmd string) string { return cmd }, err
|
|
}
|
|
if strings.HasPrefix(targetSlug, "lxc:") {
|
|
var pveID, hostAttr string
|
|
// COALESCE the host column: many older LXC entities (seeded from
|
|
// inventory, not provisioned by pct_create) have pve_id but no host
|
|
// attribute at all. Scanning a SQL NULL into a plain string errors
|
|
// the whole row, wrongly reporting "missing pve_id" even when it was
|
|
// present — COALESCE avoids the NULL, "" is handled below.
|
|
if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
|
|
return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug)
|
|
}
|
|
hostSlug := hostAttr
|
|
if hostSlug == "" {
|
|
hostSlug = "hubris"
|
|
}
|
|
if !strings.HasPrefix(hostSlug, "host:") {
|
|
hostSlug = "host:" + hostSlug
|
|
}
|
|
host, user, err = resolveHostSSH(ctx, pool, hostSlug)
|
|
id := pveID
|
|
return host, user, func(cmd string) string {
|
|
b64 := base64.StdEncoding.EncodeToString([]byte(cmd))
|
|
return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64)
|
|
}, err
|
|
}
|
|
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
|
|
}
|
|
|
|
// executeApprovedAction runs a gated action after operator approval.
|
|
// Runs in a background goroutine to not block the HTTP response.
|
|
// emitExecutionEvent records an execution lifecycle event for SSE fan-out so
|
|
// the control room can watch approved actions run to completion live.
|
|
func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) {
|
|
severity := "info"
|
|
if status == "failed" {
|
|
severity = "warning"
|
|
}
|
|
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
|
|
if status == "completed" || status == "failed" || status == "cancelled" {
|
|
closePlanStepForExecution(ctx, pool, execID, status)
|
|
}
|
|
}
|
|
|
|
// closePlanStepForExecution auto-closes a task plan step whose linked execution
|
|
// just reached a terminal state, so the task board advances even if the agent
|
|
// doesn't call update_plan_step itself (belt and suspenders — the agent links
|
|
// the step to the execution when it starts it; the api finishes it here). Emits
|
|
// plan.step.finished correlated to the step's session. No-op for the vast
|
|
// majority of executions, which aren't plan steps.
|
|
func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) {
|
|
stepStatus := "done"
|
|
if execStatus == "failed" || execStatus == "cancelled" {
|
|
stepStatus = "failed"
|
|
}
|
|
var stepID, sessionID string
|
|
var seq int
|
|
if err := pool.QueryRow(ctx, `
|
|
UPDATE session_plan_steps SET status = $2, finished_at = now()
|
|
WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped')
|
|
RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil {
|
|
return // no matching open step
|
|
}
|
|
_ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID,
|
|
map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()})
|
|
}
|
|
|
|
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
|
|
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
|
|
|
|
host, user, wrap, err := resolveRunTarget(ctx, pool, targetSlug)
|
|
if err != nil {
|
|
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("%s", err.Error()))
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
|
|
return
|
|
}
|
|
|
|
idx := strings.Index(actionStr, ":")
|
|
if idx < 0 {
|
|
slog.Error("httpapi: malformed action string (no colon)", "action", actionStr)
|
|
return
|
|
}
|
|
action, params := actionStr[:idx], actionStr[idx+1:]
|
|
|
|
startedAt := time.Now()
|
|
var output, cmd string
|
|
|
|
switch action {
|
|
case "systemctl":
|
|
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
|
switch {
|
|
case strings.HasPrefix(params, "enable:"):
|
|
svc = strings.TrimPrefix(params, "enable:")
|
|
cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
|
|
case strings.HasPrefix(params, "disable:"):
|
|
svc = strings.TrimPrefix(params, "disable:")
|
|
cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
|
|
default:
|
|
cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc)
|
|
}
|
|
output, err = sshExec(ctx, host, user, cmd)
|
|
|
|
case "apt_upgrade":
|
|
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
|
cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc)
|
|
output, err = sshExec(ctx, host, user, cmd)
|
|
|
|
case "pct_create":
|
|
var cfg struct {
|
|
VMID int `json:"vmid"`
|
|
Hostname string `json:"hostname"`
|
|
Cores int `json:"cores"`
|
|
Memory int `json:"memory"`
|
|
DiskGB int `json:"disk_gb"`
|
|
IP string `json:"ip"`
|
|
GW string `json:"gw"`
|
|
Bridge string `json:"bridge"` // e.g. vmbr0/vmbr1 — which bridge actually reaches the target subnet on this host varies per host, don't assume vmbr0
|
|
Storage string `json:"storage"`
|
|
Template string `json:"template"`
|
|
Privileged flexBool `json:"privileged"`
|
|
Nesting flexBool `json:"nesting"`
|
|
Mounts []string `json:"mounts"`
|
|
Nameserver string `json:"nameserver"`
|
|
Searchdomain string `json:"searchdomain"`
|
|
// No services/post_install here anymore — pct_create is atomic
|
|
// (create + start + register only). Installing packages and
|
|
// running setup scripts is the agent's job via follow-up `run`
|
|
// calls against lxc:<hostname>, so each step is individually
|
|
// observable and recoverable instead of one opaque multi-minute
|
|
// black box. See the comment above the removed post-create block.
|
|
}
|
|
if err := json.Unmarshal([]byte(params), &cfg); err != nil {
|
|
slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("invalid pct_create params: %v", err))
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
|
|
return
|
|
}
|
|
// 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: hostname is required"}`)
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"})
|
|
return
|
|
}
|
|
if cfg.Cores == 0 {
|
|
cfg.Cores = 1
|
|
}
|
|
if cfg.Memory == 0 {
|
|
cfg.Memory = 512
|
|
}
|
|
if cfg.DiskGB == 0 {
|
|
cfg.DiskGB = 8
|
|
}
|
|
if cfg.Storage == "" {
|
|
cfg.Storage = "local-lvm"
|
|
}
|
|
if cfg.GW == "" {
|
|
cfg.GW = "192.168.8.2"
|
|
}
|
|
if cfg.Nameserver == "" {
|
|
cfg.Nameserver = "192.168.8.2"
|
|
}
|
|
if cfg.Searchdomain == "" {
|
|
cfg.Searchdomain = "hubris.network"
|
|
}
|
|
// Template pre-flight: resolve against what the host actually has
|
|
// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
|
|
// `pct` error when that exact file isn't present. List the cache, then
|
|
// either validate the requested template or auto-pick the newest
|
|
// debian one; on miss, fail early with the available list so the
|
|
// operator/agent can retry with a real name.
|
|
cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true")
|
|
available := []string{}
|
|
for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
available = append(available, l)
|
|
}
|
|
}
|
|
if tplErr != nil {
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("list templates on %s: %s", targetSlug, tplErr.Error()))
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()})
|
|
return
|
|
}
|
|
cfg.Template = resolveTemplate(cfg.Template, available)
|
|
if cfg.Template == "" {
|
|
msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available)
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("%s", msg))
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
|
|
return
|
|
}
|
|
|
|
// VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's
|
|
// guess (e.g. 132) can collide with a container on another node — pct
|
|
// create then fails with "CT N already exists on node X". Fetch the set
|
|
// of in-use VMIDs across the cluster; if the requested id is taken (or
|
|
// absent), fall back to the cluster's next free id so provisioning
|
|
// still succeeds instead of dead-ending on the operator's approval.
|
|
usedRaw, _ := sshExec(ctx, host, user, `pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`)
|
|
used := map[int]bool{}
|
|
for _, l := range strings.Fields(usedRaw) {
|
|
if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
|
|
used[n] = true
|
|
}
|
|
}
|
|
if cfg.VMID == 0 || used[cfg.VMID] {
|
|
nextRaw, nerr := sshExec(ctx, host, user, `pvesh get /cluster/nextid 2>/dev/null`)
|
|
nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw))
|
|
if nerr != nil || cerr != nil || nextID == 0 {
|
|
msg := fmt.Sprintf("VMID %d is already in use on the cluster and could not resolve a free id", cfg.VMID)
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("%s", msg))
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
|
|
return
|
|
}
|
|
slog.Info("httpapi: pct_create VMID reassigned", "requested", cfg.VMID, "assigned", nextID)
|
|
cfg.VMID = nextID
|
|
}
|
|
|
|
privFlag := "--unprivileged 1"
|
|
if cfg.Privileged {
|
|
privFlag = "--unprivileged 0"
|
|
}
|
|
|
|
nestingFlag := ""
|
|
features := []string{}
|
|
if cfg.Nesting {
|
|
features = append(features, "nesting=1")
|
|
}
|
|
if cfg.Privileged {
|
|
features = append(features, "keyctl=1")
|
|
}
|
|
if len(features) > 0 {
|
|
nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ","))
|
|
}
|
|
|
|
if cfg.Bridge == "" {
|
|
cfg.Bridge = "vmbr0"
|
|
}
|
|
|
|
// 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=" + cfg.Bridge + ","
|
|
isStatic := cfg.IP != "" && !strings.EqualFold(cfg.IP, "dhcp")
|
|
if !isStatic {
|
|
net0 += "ip=dhcp"
|
|
} else {
|
|
net0 += "ip=" + cfg.IP
|
|
if cfg.GW != "" {
|
|
net0 += ",gw=" + cfg.GW
|
|
}
|
|
}
|
|
|
|
// Pre-flight: for a static config, ping the gateway from the target
|
|
// HOST, on the SPECIFIC BRIDGE being requested, before spending 5+
|
|
// minutes creating the container. This is the check that would have
|
|
// caught the real TypeType failure immediately instead of after a
|
|
// full provision attempt.
|
|
//
|
|
// Binding to the bridge (`ping -I <bridge>`) matters and was found
|
|
// live: a plain unqualified `ping <gw>` from the host can succeed via
|
|
// the host's own routing table (multiple routes, possibly through an
|
|
// upstream router) even when the *container* — which only gets a
|
|
// naive on-link default route via its bridge's veth — can never ARP
|
|
// that gateway at all. Confirmed on `strong`: bare `ping 192.168.8.2`
|
|
// succeeded (via the host's default route), but a container actually
|
|
// attached to vmbr0 showed 100% packet loss trying to reach the same
|
|
// address, because vmbr0 doesn't carry that subnet's L2 segment.
|
|
// Binding to the bridge interface reproduces what the container will
|
|
// actually experience, not what the host's broader routing table can
|
|
// reach.
|
|
if isStatic && cfg.GW != "" {
|
|
pingOut, pingErr := sshExec(ctx, host, user, fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", cfg.Bridge, cfg.GW))
|
|
if pingErr != nil || !gatewayPreflightPassed(pingOut) {
|
|
msg := fmt.Sprintf(
|
|
"gateway %s is not reachable from %s on bridge %s — this almost always means the bridge doesn't carry that subnet on this host (each bridge only reaches the network it's physically wired to). "+
|
|
"Do not retry with a different gateway guess in the same subnet: find an existing LXC on this host with an IP in the same /28 and copy its exact bridge+gateway, or use DHCP instead.",
|
|
cfg.GW, targetSlug, cfg.Bridge)
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("%s", msg))
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
|
|
return
|
|
}
|
|
}
|
|
|
|
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 %s%s --start 1",
|
|
cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory,
|
|
cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag)
|
|
|
|
if cfg.Nameserver != "" {
|
|
createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver)
|
|
}
|
|
if cfg.Searchdomain != "" {
|
|
createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain)
|
|
}
|
|
|
|
// Add mount points
|
|
for i, mp := range cfg.Mounts {
|
|
if i < 10 { // pct supports up to mp9
|
|
createCmd += fmt.Sprintf(" --mp%d %s", i, mp)
|
|
}
|
|
}
|
|
|
|
slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
|
|
output, err = sshExec(ctx, host, user, createCmd)
|
|
|
|
// pct_create is now DELIBERATELY ATOMIC: create + start + register,
|
|
// nothing else. It used to also run apt installs and a post_install
|
|
// script inline as one black-box multi-minute SSH call — the agent
|
|
// got back a single opaque success/fail for the whole thing with no
|
|
// way to see (or fix) which step actually broke. That's the opposite
|
|
// of what makes an agent able to recover from errors.
|
|
//
|
|
// Installing packages, running post_install, and verifying the
|
|
// service now happen as the agent's OWN follow-up `run` calls against
|
|
// the new lxc:<hostname> target — each one is synchronous (in an
|
|
// active assent window) or individually gated, so the agent observes
|
|
// every step's real output and can diagnose + retry the exact thing
|
|
// that failed instead of re-doing the whole container. See SOUL.md
|
|
// "After pct_create: you drive the install" and provisionScript's
|
|
// surviving role (DNS self-heal) is now something the agent invokes
|
|
// itself via `run`, not something baked into this handler.
|
|
//
|
|
// cfg.Services/cfg.PostInstall are intentionally no longer read here.
|
|
|
|
// On success, register the entity in the DB with proper relationships
|
|
if err == nil {
|
|
slug := "lxc:" + cfg.Hostname
|
|
var lxcID uuid.UUID
|
|
lxcID, _ = uuid.NewV7()
|
|
attrs := map[string]any{
|
|
"pve_id": fmt.Sprintf("%d", cfg.VMID),
|
|
"host": strings.TrimPrefix(targetSlug, "host:"),
|
|
"ip": cfg.IP,
|
|
}
|
|
attrsJSON, _ := json.Marshal(attrs)
|
|
_, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at)
|
|
VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON)
|
|
if insErr != nil {
|
|
slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug)
|
|
}
|
|
|
|
// Create hosts relationship: Proxmox host → LXC
|
|
var hostID uuid.UUID
|
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil {
|
|
_, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID)
|
|
if relErr != nil {
|
|
slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug)
|
|
}
|
|
}
|
|
|
|
// Create entity_status row for health tracking
|
|
pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at)
|
|
VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID)
|
|
|
|
emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{
|
|
"lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug,
|
|
})
|
|
|
|
slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug)
|
|
}
|
|
|
|
case "run":
|
|
// The general gated primitive: arbitrary shell against any host or
|
|
// LXC, approved and classified by internal/policy.ClassifyCommand at
|
|
// request time (see mcp/server.go's "run" tool). No fixed action
|
|
// enum — new capability doesn't require new Go code here.
|
|
var cfg struct {
|
|
Command string `json:"command"`
|
|
Purpose string `json:"purpose"`
|
|
}
|
|
if perr := json.Unmarshal([]byte(params), &cfg); perr != nil {
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("invalid run params: %v", perr))
|
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": perr.Error()})
|
|
return
|
|
}
|
|
cmd = wrap(cfg.Command)
|
|
output, err = sshExec(ctx, host, user, cmd)
|
|
|
|
default:
|
|
slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID)
|
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
|
execID, jsonErr("unknown action: %s", action))
|
|
return
|
|
}
|
|
|
|
durationMs := int(time.Since(startedAt).Milliseconds())
|
|
status := "completed"
|
|
verified := true
|
|
// Build result via json.Marshal, not string interpolation. Command output
|
|
// (apt/pct) contains quotes, backslashes and control chars; the old
|
|
// fmt.Sprintf only escaped "\n", producing invalid JSON that failed the
|
|
// ::jsonb cast — so this UPDATE was silently discarded and the execution
|
|
// was stuck at "approved" forever even though provisioning succeeded.
|
|
resMap := map[string]any{"output": output}
|
|
if err != nil {
|
|
resMap["error"] = err.Error()
|
|
status = "failed"
|
|
verified = false
|
|
}
|
|
resultJSON, _ := json.Marshal(resMap)
|
|
|
|
if _, uerr := pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
|
|
execID, status, resultJSON, durationMs, verified, startedAt, time.Now()); uerr != nil {
|
|
slog.Error("httpapi: finalize execution status", "error", uerr, "execution_id", execID, "intended_status", status)
|
|
}
|
|
|
|
emitExecutionEvent(ctx, pool, execID, status, map[string]any{
|
|
"action": action, "target": targetSlug, "duration_ms": durationMs,
|
|
})
|
|
|
|
slog.Info("httpapi: approved action executed",
|
|
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
|
|
}
|
|
|
|
// jsonErr builds a valid {"error": "..."} JSON payload for an execution's
|
|
// result column. Always use this instead of fmt.Sprintf'ing JSON by hand —
|
|
// error text and command output routinely contain quotes/backslashes that
|
|
// break a hand-built string and fail the ::jsonb cast.
|
|
func jsonErr(format string, args ...any) []byte {
|
|
b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
|
|
return b
|
|
}
|
|
|
|
// resolveTemplate maps a requested template name to one actually present in
|
|
// the host's template cache. Exact match wins; a bare distro hint (e.g.
|
|
// "debian-13" or "debian") matches by prefix; empty picks the newest debian
|
|
// (falling back to any) template available. Returns "" when nothing fits.
|
|
// gatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL markers
|
|
// from the pct_create gateway pre-flight check. Pulled out as its own
|
|
// function (rather than an inline strings.Contains at the call site) so it's
|
|
// unit-testable: a prior version checked for "REACHABLE", which is a
|
|
// substring of "UNREACHABLE" — the check could never actually fail, and it
|
|
// took a live deployment to notice. Exact-match markers plus a test make
|
|
// that specific bug class structurally unable to recur silently.
|
|
func gatewayPreflightPassed(out string) bool {
|
|
return strings.TrimSpace(out) == "PREFLIGHT_OK"
|
|
}
|
|
|
|
func resolveTemplate(requested string, available []string) string {
|
|
if len(available) == 0 {
|
|
return ""
|
|
}
|
|
if requested != "" {
|
|
for _, a := range available {
|
|
if a == requested {
|
|
return a
|
|
}
|
|
}
|
|
for _, a := range available {
|
|
if strings.HasPrefix(a, requested) {
|
|
return a
|
|
}
|
|
}
|
|
}
|
|
// Auto-pick: prefer debian, then the lexically-greatest (newest version).
|
|
best := ""
|
|
for _, a := range available {
|
|
if strings.Contains(a, "debian") && a > best {
|
|
best = a
|
|
}
|
|
}
|
|
if best != "" {
|
|
return best
|
|
}
|
|
for _, a := range available {
|
|
if a > best {
|
|
best = a
|
|
}
|
|
}
|
|
return best
|
|
}
|