feat: robust provisioning (DNS self-heal) + live execution feedback in chat
Production session provisioned the container but the service never installed:
apt failed with "Temporary failure resolving deb.debian.org" — a static-IP LXC
whose assigned nameserver couldn't resolve. The operator also got zero feedback:
the approval banner just sat there with no running/complete/failed status.
Backend robustness (provisionScript):
- Wait for real DNS/connectivity inside the container before apt, and self-heal
/etc/resolv.conf to a public resolver (1.1.1.1/8.8.8.8) if the assigned one
is dead. `set -e` after the gate so apt/post_install failures surface.
- apt-get update/install with Acquire::Retries=3.
Frontend feedback (InlineApproval):
- After approve, poll GET /executions/{id} and show live phase: submitting →
provisioning… → provisioned successfully / execution failed (with the error).
- add getExecution() to api.ts.
Agent guidance (SOUL.md):
- omit vmid (auto-assigned), prefer dhcp, docker-compose-plugin is not in Debian
(use docker.io + get.docker.com), end post_install with a health check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -87,6 +88,34 @@ func TestJSONErrValidForNastyOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionScript(t *testing.T) {
|
||||
s := provisionScript([]string{"docker.io", "git"}, "echo hi > /root/x")
|
||||
// Network/DNS gate must come before apt.
|
||||
gate := strings.Index(s, "getent hosts")
|
||||
apt := strings.Index(s, "apt-get update")
|
||||
post := strings.Index(s, "echo hi > /root/x")
|
||||
if gate < 0 || apt < 0 || post < 0 {
|
||||
t.Fatalf("missing sections: gate=%d apt=%d post=%d\n%s", gate, apt, post, s)
|
||||
}
|
||||
if !(gate < apt && apt < post) {
|
||||
t.Errorf("wrong ordering: gate=%d apt=%d post=%d", gate, apt, post)
|
||||
}
|
||||
if !strings.Contains(s, "nameserver 1.1.1.1") {
|
||||
t.Error("missing DNS self-heal fallback")
|
||||
}
|
||||
if !strings.Contains(s, "docker.io git") {
|
||||
t.Error("packages not joined into install line")
|
||||
}
|
||||
// No packages: no apt lines, but post_install and gate still present.
|
||||
s2 := provisionScript(nil, "systemctl status foo")
|
||||
if strings.Contains(s2, "apt-get install") {
|
||||
t.Error("apt install should be absent when no packages requested")
|
||||
}
|
||||
if !strings.Contains(s2, "systemctl status foo") || !strings.Contains(s2, "getent hosts") {
|
||||
t.Error("post_install or gate missing in no-package case")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePkgs(t *testing.T) {
|
||||
in := []string{"docker.io", "git", "rm -rf /", "curl;wget", "python3-pip", ""}
|
||||
got := sanitizePkgs(in)
|
||||
|
||||
@@ -368,23 +368,17 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
|
||||
// Post-create provisioning: install apt packages and run a post_install
|
||||
// script inside the fresh container, so a single approved pct_create
|
||||
// yields a *working service*, not just an empty container. Best-effort
|
||||
// with a boot settle; failures are appended to output and mark the
|
||||
// execution failed so the operator sees exactly which step broke.
|
||||
// yields a *working service*, not just an empty container. The script
|
||||
// waits for real DNS/connectivity and self-heals the resolver first —
|
||||
// a static-IP container with a dead nameserver otherwise fails apt with
|
||||
// "Temporary failure resolving deb.debian.org" and installs nothing.
|
||||
if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") {
|
||||
// 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))
|
||||
}
|
||||
if cfg.PostInstall != "" {
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(cfg.PostInstall))
|
||||
steps = append(steps, fmt.Sprintf("pct exec %d -- bash -lc 'echo %s | base64 -d | bash'", cfg.VMID, b64))
|
||||
}
|
||||
script := provisionScript(sanitizePkgs(cfg.Services), cfg.PostInstall)
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(script))
|
||||
// sleep on the host so the container is up enough to accept pct exec.
|
||||
cmd := fmt.Sprintf("sleep 4; pct exec %d -- bash -c 'echo %s | base64 -d | bash'", cfg.VMID, b64)
|
||||
var provOut string
|
||||
provOut, err = sshExec(ctx, host, user, strings.Join(steps, " && "))
|
||||
provOut, err = sshExec(ctx, host, user, cmd)
|
||||
output = output + "\n--- post-install ---\n" + provOut
|
||||
}
|
||||
|
||||
@@ -462,6 +456,36 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
|
||||
}
|
||||
|
||||
// provisionScript builds the in-container bootstrap run after pct create. It
|
||||
// (1) waits for DNS/connectivity and self-heals /etc/resolv.conf with a public
|
||||
// resolver if the configured nameserver is dead, (2) installs apt packages with
|
||||
// retries, (3) runs the operator's post_install. `set -e` after the network
|
||||
// gate means any apt or post_install failure exits non-zero, so sshExec surfaces
|
||||
// it and the execution is marked failed with the exact broken step in output.
|
||||
func provisionScript(pkgs []string, postInstall string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("set -o pipefail\n")
|
||||
b.WriteString("probe=deb.debian.org\n")
|
||||
b.WriteString("ok=0\n")
|
||||
b.WriteString("for i in $(seq 1 30); do if getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done\n")
|
||||
// Self-heal: if the assigned resolver can't resolve, fall back to public DNS.
|
||||
b.WriteString("if [ \"$ok\" != 1 ]; then printf 'nameserver 1.1.1.1\\nnameserver 8.8.8.8\\n' > /etc/resolv.conf; ")
|
||||
b.WriteString("for i in $(seq 1 15); do if getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done; fi\n")
|
||||
b.WriteString("if [ \"$ok\" != 1 ]; then echo 'ERROR: container has no DNS/connectivity after ~90s'; exit 1; fi\n")
|
||||
b.WriteString("set -e\n")
|
||||
if len(pkgs) > 0 {
|
||||
b.WriteString("export DEBIAN_FRONTEND=noninteractive\n")
|
||||
b.WriteString("apt-get update -o Acquire::Retries=3 -qq\n")
|
||||
b.WriteString("apt-get install -y -o Acquire::Retries=3 --no-install-recommends -qq " + strings.Join(pkgs, " ") + "\n")
|
||||
}
|
||||
if strings.TrimSpace(postInstall) != "" {
|
||||
b.WriteString("# --- operator post_install ---\n")
|
||||
b.WriteString(postInstall)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user