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:
@@ -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)
|
||||
|
||||
380
internal/adapters/postgres/seedrepo.go
Normal file
380
internal/adapters/postgres/seedrepo.go
Normal 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
|
||||
}
|
||||
65
internal/adapters/postgres/seedrepo_test.go
Normal file
65
internal/adapters/postgres/seedrepo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
237
internal/adapters/ssh/provisioner.go
Normal file
237
internal/adapters/ssh/provisioner.go
Normal 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"
|
||||
}
|
||||
100
internal/adapters/ssh/provisioner_test.go
Normal file
100
internal/adapters/ssh/provisioner_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user