feat: Phase 7 — SeedService, SecretsService, ProvisioningService + ssh Provisioner

Seed ingest/export moves behind ports.SeedRepository (SeedRepo in the
postgres adapter; knowledge ingest absorbed from internal/knowledge,
package deleted). pct_create flow (defaults, template/VMID/gateway
pre-flights, pct create, graph registration) moves from httpapi's
approved-execution path into app.ProvisioningService + the ssh
provisioner adapter; CLI seed/export/secret become adapters over the
services. EntityCreateInput gains EnrolledAt. Plan status corrected:
phases 0-7 shipped, 8 + 9 gates open. VERSION 0.34.1.
This commit is contained in:
2026-08-16 09:11:26 +02:00
parent cd44501fa9
commit 60c0432d8b
26 changed files with 1565 additions and 689 deletions

View File

@@ -259,10 +259,10 @@ func (r *EntityRepo) Create(ctx context.Context, in ports.EntityCreateInput) (do
}
created, err := scanDomainEntity(tx.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+entityFullCols,
mustUUID(e.ID), e.Slug, e.Type, e.Name, state, attrsJSON))
mustUUID(e.ID), e.Slug, e.Type, e.Name, state, attrsJSON, in.EnrolledAt))
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return domain.Entity{}, errors.Join(domain.ErrAlreadyExists, err)

View File

@@ -0,0 +1,380 @@
package db
import (
"context"
"encoding/json"
"fmt"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// SeedRepo implements ports.SeedRepository over the postgres pool. Each
// Ingest method wraps Pool.SeedIngest (hash no-op + one transaction +
// seed_versions recording) around the file's ingest functions; the
// knowledge ingest moved here from internal/knowledge when SeedService
// absorbed seeding (Phase 7) — raw SQL over the open transaction belongs
// in the adapter.
type SeedRepo struct {
pool *Pool
}
var _ ports.SeedRepository = (*SeedRepo)(nil)
// NewSeedRepo builds the seed repository.
func NewSeedRepo(pool *Pool) *SeedRepo { return &SeedRepo{pool: pool} }
// IngestOntology ingests seeds/ontology.yaml (lifecycles, entity types,
// relationship types) — one transaction, no-op on unchanged hash.
func (r *SeedRepo) IngestOntology(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestOntologySeed(ctx, tx, data)
if err != nil {
return err
}
counts.Lifecycles = res.Lifecycles
counts.EntityTypes = res.EntityTypes
counts.RelationshipTypes = res.RelationshipTypes
applied = true
return nil
})
return counts, applied, err
}
// IngestInventory ingests seeds/inventory.yaml (entities, relationships,
// derived default checks) with ontology validation — a violating seed
// rolls back atomically.
func (r *SeedRepo) IngestInventory(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestInventorySeed(ctx, tx, data)
if err != nil {
return err
}
counts.Entities = res.Entities
counts.Relationships = res.Relationships
counts.Checks = res.Checks
applied = true
return nil
})
return counts, applied, err
}
// IngestPolicy ingests seeds/policy.yaml (risk classes, approval rules,
// autonomy settings).
func (r *SeedRepo) IngestPolicy(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestPolicySeed(ctx, tx, data)
if err != nil {
return err
}
counts.RiskClasses = res.RiskClasses
counts.ApprovalRules = res.ApprovalRules
counts.AutonomySettings = res.AutonomySettings
applied = true
return nil
})
return counts, applied, err
}
// IngestKnowledge ingests seeds/knowledge.yaml (documents,
// investigations, runbooks) as knowledge entities linked into the graph.
func (r *SeedRepo) IngestKnowledge(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestKnowledgeSeed(ctx, tx, data)
if err != nil {
return err
}
counts.Documents = res.Documents
counts.Investigations = res.Investigations
counts.Runbooks = res.Runbooks
applied = true
return nil
})
return counts, applied, err
}
// Export regenerates the three structural seed YAMLs from the DB.
func (r *SeedRepo) Export(ctx context.Context) (map[string][]byte, error) {
return ExportToYAML(ctx, r.pool)
}
// KnowledgeSeedResult holds counts from the knowledge seed ingest.
type KnowledgeSeedResult struct {
Documents int
Investigations int
Runbooks int
}
// IngestKnowledgeSeed ingests the knowledge seed (documents,
// investigations, runbooks) into knowledge entities plus their graph
// edges, inside the caller's ingest transaction. Absorbed from
// internal/knowledge (Phase 7): the logic is seed ingest, its home is the
// seed repository.
func IngestKnowledgeSeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*KnowledgeSeedResult, error) {
r := &KnowledgeSeedResult{}
docs, _ := data["documents"].([]any)
for _, raw := range docs {
d, _ := raw.(map[string]any)
if err := ingestDocument(ctx, tx, d); err != nil {
return nil, fmt.Errorf("document %v: %w", seedStr(d, "slug"), err)
}
r.Documents++
}
invs, _ := data["investigations"].([]any)
for _, raw := range invs {
m, _ := raw.(map[string]any)
if err := ingestInvestigation(ctx, tx, m); err != nil {
return nil, fmt.Errorf("investigation %v: %w", seedStr(m, "slug"), err)
}
r.Investigations++
}
rbs, _ := data["runbooks"].([]any)
for _, raw := range rbs {
m, _ := raw.(map[string]any)
if err := ingestRunbook(ctx, tx, m); err != nil {
return nil, fmt.Errorf("runbook %v: %w", seedStr(m, "slug"), err)
}
r.Runbooks++
}
return r, nil
}
func seedStr(m map[string]any, key string) string {
s, _ := m[key].(string)
return s
}
func seedStrSlice(m map[string]any, key string) []string {
raw, _ := m[key].([]any)
var out []string
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func ingestDocument(ctx context.Context, tx pgx.Tx, m map[string]any) error {
slug := seedStr(m, "slug")
title := seedStr(m, "title")
content := seedStr(m, "content")
entitySlug := seedStr(m, "entity_slug")
tags := seedStrSlice(m, "tags")
atGlance, _ := m["at_glance"].(map[string]any)
clRaw, _ := m["changelog"].([]any)
entityDocSlug := "document:" + slug
if err := upsertKnowledgeEntity(ctx, tx, entityDocSlug, "document", title, content, slug, tags); err != nil {
return err
}
attrs := map[string]any{}
if len(atGlance) > 0 {
attrs["at_glance"] = atGlance
}
if len(clRaw) > 0 {
attrs["changelog"] = clRaw
}
if len(attrs) > 0 {
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = attributes || $1, updated_at = now()
WHERE slug = $2`, string(attrsBytes), entityDocSlug)
if err != nil {
return fmt.Errorf("update document attrs: %w", err)
}
}
if entitySlug != "" {
if err := createSeedEdge(ctx, tx, entityDocSlug, entitySlug, "documents", nil); err != nil {
return fmt.Errorf("link document: %w", err)
}
}
if len(atGlance) > 0 && entitySlug != "" {
backfillAttrs, _ := json.Marshal(atGlance)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = $1 || attributes, updated_at = now()
WHERE slug = $2`, string(backfillAttrs), entitySlug)
if err != nil {
return fmt.Errorf("backfill entity attrs: %w", err)
}
}
return nil
}
func ingestInvestigation(ctx context.Context, tx pgx.Tx, m map[string]any) error {
slug := seedStr(m, "slug")
title := seedStr(m, "title")
content := seedStr(m, "content")
date := seedStr(m, "date")
status := seedStr(m, "status")
duration := seedStr(m, "duration")
aboutSlugs := seedStrSlice(m, "about_slugs")
tags := seedStrSlice(m, "tags")
entitySlug := "investigation:" + slug
if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "investigation", title, content, slug, tags); err != nil {
return err
}
attrs := map[string]any{}
if date != "" {
attrs["date"] = date
}
if status != "" {
attrs["status"] = status
}
if duration != "" {
attrs["duration"] = duration
}
if len(attrs) > 0 {
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = attributes || $1, updated_at = now()
WHERE slug = $2`, string(attrsBytes), entitySlug)
if err != nil {
return fmt.Errorf("update investigation attrs: %w", err)
}
}
for _, aboutSlug := range aboutSlugs {
if err := createSeedEdge(ctx, tx, entitySlug, aboutSlug, "about", nil); err != nil {
return fmt.Errorf("link investigation about %s: %w", aboutSlug, err)
}
}
return nil
}
func ingestRunbook(ctx context.Context, tx pgx.Tx, m map[string]any) error {
slug := seedStr(m, "slug")
name := seedStr(m, "name")
riskClass := seedStr(m, "risk_class")
entityType := seedStr(m, "entity_type")
content := seedStr(m, "content")
tags := seedStrSlice(m, "tags")
procedure, _ := m["procedure"].(map[string]any)
entitySlug := "runbook:" + slug
if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "runbook", name, content, slug, tags); err != nil {
return err
}
attrs := map[string]any{}
if riskClass != "" {
attrs["risk_class"] = riskClass
}
if entityType != "" {
attrs["applies_to_type"] = entityType
}
if len(procedure) > 0 {
attrs["procedure"] = procedure
}
if len(attrs) > 0 {
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = attributes || $1, updated_at = now()
WHERE slug = $2`, string(attrsBytes), entitySlug)
if err != nil {
return fmt.Errorf("update runbook attrs: %w", err)
}
}
return nil
}
func upsertKnowledgeEntity(ctx context.Context, tx pgx.Tx, slug, entityType, title, content, source string, tags []string) error {
id, err := getOrCreateKnowledgeEntity(ctx, tx, slug, entityType, title)
if err != nil {
return err
}
hash := contentHash([]byte(content))
tagArray := toPGArray(tags)
_, err = tx.Exec(ctx,
`INSERT INTO knowledge_entities (entity_id, title, content, source, tags, content_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now(), now())
ON CONFLICT (entity_id) DO UPDATE SET
title = $2, content = $3, source = $4, tags = $5,
content_hash = $6, updated_at = now()`,
id, title, content, source, tagArray, hash)
return err
}
func getOrCreateKnowledgeEntity(ctx context.Context, tx pgx.Tx, slug, entityType, name string) (uuid.UUID, error) {
var id uuid.UUID
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
if err == nil {
return id, nil
}
if err != pgx.ErrNoRows {
return uuid.Nil, fmt.Errorf("lookup entity %s: %w", slug, err)
}
id, err = uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("generate uuid: %w", err)
}
_, err = tx.Exec(ctx,
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, '{}', 1, now(), now())`,
id, slug, entityType, name)
if err != nil {
return uuid.Nil, fmt.Errorf("create entity %s: %w", slug, err)
}
return id, nil
}
func createSeedEdge(ctx context.Context, tx pgx.Tx, sourceSlug, targetSlug, relType string, attrs map[string]any) error {
var sourceID, targetID uuid.UUID
if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", sourceSlug).Scan(&sourceID); err != nil {
return fmt.Errorf("source %s: %w", sourceSlug, err)
}
if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
return fmt.Errorf("target %s: %w", targetSlug, err)
}
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
VALUES ($1, $2, $3, $4, now(), NULL)
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
DO UPDATE SET attributes = EXCLUDED.attributes`,
sourceID, targetID, relType, string(attrsBytes))
return err
}
func toPGArray(tags []string) string {
if len(tags) == 0 {
return "{}"
}
out := "{"
for i, t := range tags {
if i > 0 {
out += ","
}
out += `"` + t + `"`
}
out += "}"
return out
}

View File

@@ -0,0 +1,65 @@
package db
import (
"reflect"
"testing"
)
// Tests for the seed-helper functions absorbed from internal/knowledge
// (Phase 7).
func TestSeedStr(t *testing.T) {
cases := []struct {
name string
m map[string]any
key string
want string
}{
{"missing key", map[string]any{}, "nope", ""},
{"string value", map[string]any{"k": "v"}, "k", "v"},
{"int value", map[string]any{"k": 42}, "k", ""},
{"nil value", map[string]any{"k": nil}, "k", ""},
{"empty string", map[string]any{"k": ""}, "k", ""},
}
for _, c := range cases {
if got := seedStr(c.m, c.key); got != c.want {
t.Errorf("%s: seedStr(%v, %q) = %q, want %q", c.name, c.m, c.key, got, c.want)
}
}
}
func TestSeedStrSlice(t *testing.T) {
cases := []struct {
name string
m map[string]any
key string
want []string
}{
{"missing key", map[string]any{}, "tags", nil},
{"string list", map[string]any{"tags": []any{"a", "b"}}, "tags", []string{"a", "b"}},
{"mixed list drops non-strings", map[string]any{"tags": []any{"a", 1, "b"}}, "tags", []string{"a", "b"}},
{"empty list", map[string]any{"tags": []any{}}, "tags", nil},
}
for _, c := range cases {
if got := seedStrSlice(c.m, c.key); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: seedStrSlice(%v, %q) = %v, want %v", c.name, c.m, c.key, got, c.want)
}
}
}
func TestToPGArray(t *testing.T) {
cases := []struct {
in []string
want string
}{
{nil, "{}"},
{[]string{}, "{}"},
{[]string{"ops"}, `{"ops"}`},
{[]string{"ops", "network"}, `{"ops","network"}`},
}
for _, c := range cases {
if got := toPGArray(c.in); got != c.want {
t.Errorf("toPGArray(%v) = %q, want %q", c.in, got, c.want)
}
}
}

View File

@@ -0,0 +1,237 @@
package ssh
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/dtoro/oikos/internal/core/ports"
)
// Provisioner implements ports.Provisioner over CommandExecutor: the
// pct create flow absorbed from httpapi's approved-execution path
// (Phase 7). It owns the SSH-side pre-flights — template-cache
// resolution, cluster-wide VMID collision guard, gateway reachability
// on the target bridge — and the pct create itself. Guest registration
// in the entity graph is ProvisioningService's job, not this adapter's.
type Provisioner struct {
exec ports.CommandExecutor
}
var _ ports.Provisioner = (*Provisioner)(nil)
// NewProvisioner builds the provisioner over a command executor (the
// dial-pool executor; the target is the Proxmox host, reached directly).
func NewProvisioner(exec ports.CommandExecutor) *Provisioner {
return &Provisioner{exec: exec}
}
func (p *Provisioner) target(in ports.LXCInput) ports.Target {
return ports.Target{Host: in.HostAddr, User: in.HostUser}
}
// CreateLXC runs the full pct create: template pre-flight, VMID guard,
// gateway pre-flight, then create + start. Output streams to in.Sink.
func (p *Provisioner) CreateLXC(ctx context.Context, in ports.LXCInput) (ports.ProvisionResult, error) {
// 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.
res := p.exec.Run(ctx, p.target(in),
"ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true",
ports.ExecOpts{})
if res.Err != nil {
return ports.ProvisionResult{}, fmt.Errorf("list templates on %s: %w", in.HostSlug, res.Err)
}
available := []string{}
for _, l := range strings.Split(strings.TrimSpace(res.Output), "\n") {
if l = strings.TrimSpace(l); l != "" {
available = append(available, l)
}
}
in.Template = ResolveTemplate(in.Template, available)
if in.Template == "" {
return ports.ProvisionResult{}, fmt.Errorf(
"no usable LXC template on %s; available: %v", in.HostSlug, available)
}
// 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.
var vmidErr error
in.VMID, vmidErr = p.resolveVMID(ctx, in)
if vmidErr != nil {
return ports.ProvisionResult{}, vmidErr
}
// Gateway 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. Binding to the bridge
// (`ping -I <bridge>`) matters: a bare `ping <gw>` from the host can
// succeed via the host's own routing table 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. Binding reproduces what the
// container will actually experience.
isStatic := in.IP != "" && !strings.EqualFold(in.IP, "dhcp")
if isStatic && in.GW != "" {
ping := p.exec.Run(ctx, p.target(in),
fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", in.Bridge, in.GW),
ports.ExecOpts{})
if ping.Err != nil || !GatewayPreflightPassed(ping.Output) {
return ports.ProvisionResult{}, fmt.Errorf(
"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",
in.GW, in.HostSlug, in.Bridge)
}
}
createCmd := BuildPctCreateCmd(in)
slog.Info("provisioner: pct create running",
"vmid", in.VMID, "hostname", in.Hostname, "cmd", createCmd)
run := p.exec.Run(ctx, p.target(in), createCmd, ports.ExecOpts{Sink: in.Sink})
if run.Err != nil {
return ports.ProvisionResult{VMID: in.VMID, Template: in.Template, Output: run.Output}, run.Err
}
return ports.ProvisionResult{VMID: in.VMID, Template: in.Template, Output: run.Output}, nil
}
// resolveVMID returns the VMID to use, reassigning via the cluster's
// next free id when the request is absent or already taken.
func (p *Provisioner) resolveVMID(ctx context.Context, in ports.LXCInput) (int, error) {
usedRaw := p.exec.Run(ctx, p.target(in),
`pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`,
ports.ExecOpts{})
used := map[int]bool{}
for _, l := range strings.Fields(usedRaw.Output) {
if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
used[n] = true
}
}
if in.VMID != 0 && !used[in.VMID] {
return in.VMID, nil
}
if in.VMID != 0 {
slog.Info("provisioner: pct_create VMID reassigned", "requested", in.VMID)
}
nextRaw := p.exec.Run(ctx, p.target(in), `pvesh get /cluster/nextid 2>/dev/null`, ports.ExecOpts{})
nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw.Output))
if nextRaw.Err != nil || cerr != nil || nextID == 0 {
return 0, fmt.Errorf("VMID %d is already in use on the cluster and could not resolve a free id", in.VMID)
}
return nextID, nil
}
// CreateVM is reserved for the qm flow; no consumer exists yet.
func (p *Provisioner) CreateVM(_ context.Context, _ ports.VMInput) (ports.ProvisionResult, error) {
return ports.ProvisionResult{}, errors.New("provisioner: VM creation via qm not implemented yet")
}
// BuildPctCreateCmd renders the pct create command from a
// fully-defaulted input. Pure; split out for tests.
func BuildPctCreateCmd(in ports.LXCInput) string {
privFlag := "--unprivileged 1"
if in.Privileged {
privFlag = "--unprivileged 0"
}
features := []string{}
if in.Nesting {
features = append(features, "nesting=1")
}
if in.Privileged {
features = append(features, "keyctl=1")
}
nestingFlag := ""
if len(features) > 0 {
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=" + in.Bridge + ","
if in.IP == "" || strings.EqualFold(in.IP, "dhcp") {
net0 += "ip=dhcp"
} else {
net0 += "ip=" + in.IP
if in.GW != "" {
net0 += ",gw=" + in.GW
}
}
templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", in.Template)
cmd := fmt.Sprintf(
"pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1",
in.VMID, templatePath, in.Hostname, in.Cores, in.MemoryMB,
in.Storage, in.DiskGB, privFlag, net0, nestingFlag)
if in.Nameserver != "" {
cmd += fmt.Sprintf(" --nameserver %s", in.Nameserver)
}
if in.Searchdomain != "" {
cmd += fmt.Sprintf(" --searchdomain %s", in.Searchdomain)
}
for i, mp := range in.Mounts {
if i < 10 { // pct supports up to mp9
cmd += fmt.Sprintf(" --mp%d %s", i, mp)
}
}
return cmd
}
// 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.
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
}
// GatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL
// markers from the gateway pre-flight check. Exact-match markers — a
// prior version checked for "REACHABLE", a substring of "UNREACHABLE",
// so the check could never actually fail. Exact match plus a test make
// that bug class structurally unable to recur silently.
func GatewayPreflightPassed(out string) bool {
return strings.TrimSpace(out) == "PREFLIGHT_OK"
}

View File

@@ -0,0 +1,100 @@
package ssh
import (
"strings"
"testing"
"github.com/dtoro/oikos/internal/core/ports"
)
// TestResolveTemplate — moved from httpapi/pct_create_test.go with the
// pct flow it belongs to.
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
{"ubuntu-24", "ubuntu-24.04-standard_24.04-2_amd64.tar.zst"},
{"alpine", "debian-13-standard_13.0-1_amd64.tar.zst"}, // miss → auto debian
}
for _, c := range cases {
if got := ResolveTemplate(c.requested, avail); got != c.want {
t.Errorf("ResolveTemplate(%q) = %q, want %q", c.requested, got, c.want)
}
}
if got := ResolveTemplate("debian", nil); got != "" {
t.Errorf("ResolveTemplate with no cache = %q, want empty", got)
}
}
func TestGatewayPreflightPassed(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"PREFLIGHT_OK", true},
{"PREFLIGHT_OK\n", true},
{" PREFLIGHT_OK ", true},
{"PREFLIGHT_FAIL", false},
{"UNREACHABLE", false}, // the original substring bug
{"", false},
}
for _, c := range cases {
if got := GatewayPreflightPassed(c.in); got != c.want {
t.Errorf("GatewayPreflightPassed(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestBuildPctCreateCmd(t *testing.T) {
base := ports.LXCInput{
VMID: 137, Hostname: "grafana", Cores: 2, MemoryMB: 1024, DiskGB: 12,
IP: "192.168.8.55", GW: "192.168.8.2", Bridge: "vmbr1",
Storage: "local-lvm", Template: "debian-13-standard_13.0-1_amd64.tar.zst",
Nameserver: "192.168.8.2", Searchdomain: "hubris.network",
}
got := BuildPctCreateCmd(base)
for _, want := range []string{
"pct create 137 /var/lib/vz/template/cache/debian-13-standard_13.0-1_amd64.tar.zst",
"--hostname grafana",
"--cores 2",
"--memory 1024",
"--rootfs local-lvm:12",
"--unprivileged 1",
"--net0 name=eth0,bridge=vmbr1,ip=192.168.8.55,gw=192.168.8.2",
"--start 1",
"--nameserver 192.168.8.2",
"--searchdomain hubris.network",
} {
if !strings.Contains(got, want) {
t.Errorf("cmd missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "--features") {
t.Errorf("unprivileged non-nested should have no features flag:\n%s", got)
}
nested := base
nested.Nesting = true
nested.Privileged = true
nested.IP = "dhcp"
got = BuildPctCreateCmd(nested)
if !strings.Contains(got, "--features nesting=1,keyctl=1") {
t.Errorf("privileged+nesting features missing:\n%s", got)
}
if !strings.Contains(got, "--unprivileged 0") {
t.Errorf("privileged should pass --unprivileged 0:\n%s", got)
}
if !strings.Contains(got, "ip=dhcp") || strings.Contains(got, "gw=") {
t.Errorf("dhcp must omit gateway:\n%s", got)
}
}