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>
132 lines
4.5 KiB
Go
132 lines
4.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestFlexBoolUnmarshal covers the exact production failure: the LLM emitted
|
|
// `"privileged":0` / `"nesting":1` (numbers) and the strict bool field made the
|
|
// already-approved pct_create execution fail to parse, so the LXC was never
|
|
// created.
|
|
func TestFlexBoolUnmarshal(t *testing.T) {
|
|
type cfg struct {
|
|
Privileged flexBool `json:"privileged"`
|
|
Nesting flexBool `json:"nesting"`
|
|
}
|
|
cases := []struct {
|
|
in string
|
|
privileged bool
|
|
nesting bool
|
|
wantErr bool
|
|
}{
|
|
{`{"privileged":0,"nesting":1}`, false, true, false}, // the prod payload
|
|
{`{"privileged":false,"nesting":true}`, false, true, false}, // canonical
|
|
{`{"privileged":"1","nesting":"0"}`, true, false, false}, // stringified
|
|
{`{"privileged":"true","nesting":"no"}`, true, false, false},
|
|
{`{}`, false, false, false}, // absent → zero
|
|
{`{"privileged":"maybe"}`, false, false, true},
|
|
}
|
|
for _, c := range cases {
|
|
var out cfg
|
|
err := json.Unmarshal([]byte(c.in), &out)
|
|
if (err != nil) != c.wantErr {
|
|
t.Fatalf("%s: err=%v wantErr=%v", c.in, err, c.wantErr)
|
|
}
|
|
if c.wantErr {
|
|
continue
|
|
}
|
|
if bool(out.Privileged) != c.privileged || bool(out.Nesting) != c.nesting {
|
|
t.Errorf("%s: got priv=%v nest=%v want priv=%v nest=%v",
|
|
c.in, bool(out.Privileged), bool(out.Nesting), c.privileged, c.nesting)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveTemplate(t *testing.T) {
|
|
avail := []string{
|
|
"debian-12-standard_12.7-1_amd64.tar.zst",
|
|
"debian-13-standard_13.0-1_amd64.tar.zst",
|
|
"ubuntu-24.04-standard_24.04-2_amd64.tar.zst",
|
|
}
|
|
cases := []struct {
|
|
requested string
|
|
want string
|
|
}{
|
|
{"debian-13-standard_13.0-1_amd64.tar.zst", "debian-13-standard_13.0-1_amd64.tar.zst"}, // exact
|
|
{"debian-13", "debian-13-standard_13.0-1_amd64.tar.zst"}, // prefix
|
|
{"", "debian-13-standard_13.0-1_amd64.tar.zst"}, // auto newest debian
|
|
{"debian-99", "debian-13-standard_13.0-1_amd64.tar.zst"}, // miss prefix → auto debian
|
|
}
|
|
for _, c := range cases {
|
|
if got := resolveTemplate(c.requested, avail); got != c.want {
|
|
t.Errorf("resolveTemplate(%q): got %q want %q", c.requested, got, c.want)
|
|
}
|
|
}
|
|
if got := resolveTemplate("debian-13", nil); got != "" {
|
|
t.Errorf("empty cache should yield empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestJSONErrValidForNastyOutput guards the bug where command output with
|
|
// quotes/backslashes/newlines produced invalid JSON, failing the ::jsonb cast
|
|
// and silently dropping the execution's final status update.
|
|
func TestJSONErrValidForNastyOutput(t *testing.T) {
|
|
nasty := "CT 132 already exists on node \"hubris\"\n\tpath C:\\x\r\n\x00 100%"
|
|
for _, payload := range [][]byte{
|
|
jsonErr("%s", nasty),
|
|
jsonErr("list templates on %s: %s", "host:strong", nasty),
|
|
} {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(payload, &m); err != nil {
|
|
t.Fatalf("jsonErr produced invalid JSON: %v\npayload=%s", err, payload)
|
|
}
|
|
if _, ok := m["error"]; !ok {
|
|
t.Errorf("missing error key: %s", payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
want := map[string]bool{"docker.io": true, "git": true, "python3-pip": true}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("got %v want keys %v", got, want)
|
|
}
|
|
for _, g := range got {
|
|
if !want[g] {
|
|
t.Errorf("unexpected package survived sanitize: %q", g)
|
|
}
|
|
}
|
|
}
|