Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md) must give the use-cases-to-be their contract surface: driven-port interfaces, test fakes, the secrets interface moved into core, and the postgres package inside the adapters tree — before the first vertical slice (Phase 3) can wire a composition root. Change: - internal/core/ports: full driven-port catalog per plan §3.3 — repositories as transaction-scoped aggregates whose inputs carry derived checks, audit, and events (§3.6), plus CommandExecutor, TargetResolver, Checker, Secrets, EventPublisher, Provisioner. Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry, ExecResult) keep signatures off infrastructure; TypeTree aliases internal/ontology (pure over domain) until checkdefaults is absorbed. ReadModels intentionally not declared yet — it materializes with the Phase 3 slice and grows as report handlers rewire. - secrets.Backend is now an alias of ports.Secrets; implementations (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend subset is deleted; tool constructors take ports.Secrets. - internal/db → internal/adapters/postgres (mechanical import rewrite; package identifier stays db until the Phase 3 repository split). sqlc.yaml, Makefile, golangci exclusions, and docs follow the move; make generate-check verified. - internal/adapters/ssh: Executor implements ports.CommandExecutor over the actuator dial pool + RunStreaming (10-min default timeout carried over from the httpapi path). - internal/adapters/remote: Resolver implements ports.TargetResolver delegating to internal/remote (still pool-based; drops onto ports.EntityRepository when repositories land in Phase 3 — documented transitional import). - internal/core/ports/portstest: importable fakes — in-memory EntityRepo (with check-then-act SetState, side-effect recording), RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction guards; tests. Risk: ports are declared ahead of implementations — signatures firm up per phase as slices land (documented in the package doc); the remote→postgres transitional import is explicit and dissolves in Phase 3. Verification: go vet, make test (race, 19 packages), generate-check, golangci on core+adapters — 0 issues; full-repo baseline down 365→344.
102 lines
3.3 KiB
Go
102 lines
3.3 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
|
)
|
|
|
|
func backupCheckDef(t *testing.T, config string) sqlcgen.ListEnabledCheckDefsRow {
|
|
t.Helper()
|
|
return sqlcgen.ListEnabledCheckDefsRow{
|
|
Kind: "backup-freshness",
|
|
Config: []byte(config),
|
|
TimeoutS: 15,
|
|
}
|
|
}
|
|
|
|
// A misconfigured check must say so rather than quietly reporting healthy —
|
|
// "no path configured" and "backup ran fine" must never look the same.
|
|
func TestBackupFreshnessRejectsIncompleteConfig(t *testing.T) {
|
|
for _, c := range []struct{ desc, config string }{
|
|
{"no path", `{"host":"localhost"}`},
|
|
{"no host", `{"path":"/tmp"}`},
|
|
{"empty", `{}`},
|
|
} {
|
|
got := checkBackupFreshness(context.Background(), backupCheckDef(t, c.config))
|
|
if got.health != "unknown" || got.signalKind != "backup-misconfigured" {
|
|
t.Errorf("%s: got health=%q kind=%q, want unknown/backup-misconfigured",
|
|
c.desc, got.health, got.signalKind)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Live probe against a real SSH endpoint. Guarded by OIKOS_SSH_TEST_HOST:
|
|
//
|
|
// OIKOS_SSH_TEST_HOST=localhost OIKOS_SSH_USER=$USER \
|
|
// OIKOS_SSH_KEY_PATH=~/.ssh/id_ed25519 go test ./internal/scheduler/ -run TestBackupFreshnessLive
|
|
//
|
|
// The probe shell has to work on both GNU and BSD find — the first real target
|
|
// is the pre-deploy pg_dump on the macOS mac-mini, so a GNU-only construct
|
|
// would fail exactly where it matters.
|
|
func TestBackupFreshnessLiveDistinguishesTheFourStates(t *testing.T) {
|
|
host := os.Getenv("OIKOS_SSH_TEST_HOST")
|
|
if host == "" {
|
|
t.Skip("OIKOS_SSH_TEST_HOST not set — skipping live backup probe")
|
|
}
|
|
sshKeyPath = os.Getenv("OIKOS_SSH_KEY_PATH")
|
|
sshUser = os.Getenv("OIKOS_SSH_USER")
|
|
|
|
dir := t.TempDir()
|
|
fresh := filepath.Join(dir, "fresh")
|
|
stale := filepath.Join(dir, "stale")
|
|
empty := filepath.Join(dir, "empty")
|
|
for _, d := range []string{fresh, stale, empty} {
|
|
if err := os.Mkdir(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(fresh, "dump.sql"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldFile := filepath.Join(stale, "dump.sql")
|
|
if err := os.WriteFile(oldFile, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Now().Add(-72 * time.Hour)
|
|
if err := os.Chtimes(oldFile, old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := []struct {
|
|
desc, path, wantHealth, wantKind string
|
|
}{
|
|
{"recent artifact", fresh, "healthy", ""},
|
|
{"artifact older than max_age", stale, "degraded", "backup-stale"},
|
|
{"directory exists but is empty", empty, "down", "backup-missing"},
|
|
{"directory does not exist", filepath.Join(dir, "nope"), "down", "backup-missing"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
cfg := `{"host":"` + host + `","path":"` + c.path + `","max_age_s":86400}`
|
|
got := checkBackupFreshness(context.Background(), backupCheckDef(t, cfg))
|
|
if got.health != c.wantHealth || got.signalKind != c.wantKind {
|
|
t.Errorf("%s: got health=%q kind=%q evidence=%q, want %q/%q",
|
|
c.desc, got.health, got.signalKind, got.evidence, c.wantHealth, c.wantKind)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A path with a quote in it must not break out of the remote sh command.
|
|
func TestShellSingleQuoteEscapes(t *testing.T) {
|
|
got := shellSingleQuote(`/tmp/it's; rm -rf /`)
|
|
want := `'/tmp/it'\''s; rm -rf /'`
|
|
if got != want {
|
|
t.Errorf("shellSingleQuote = %s, want %s", got, want)
|
|
}
|
|
}
|