diff --git a/.ciagent/CA_MIGRATION_SPEC_v0.9.md b/.ciagent/CA_MIGRATION_SPEC_v0.9.md new file mode 100644 index 0000000..27bc754 --- /dev/null +++ b/.ciagent/CA_MIGRATION_SPEC_v0.9.md @@ -0,0 +1,109 @@ +# CA Migration Spec — v0.8 Internal CA → v0.9 step-ca (grill C-07) + +**Status**: spec (must be implemented in v0.10-P14a, REQ-066) +**Gate**: C-07 — blocks v0.10-P14a until this spec is reviewed and a dry-run passes on a test cluster + +## Problem + +The v0.8 internal Go CA (`internal/security/ca.go`) issues RSA-3072 CA +certs (10-year validity) and ECDSA P-256 server certs (90-day). The CA +material lives at `~/.orca/ca.crt` and `~/.orca/ca.key` (flat layout, D-011). +The v0.9 re-architecture reverses AD-010 and replaces the internal CA with +step-ca (D-101, REQ-076). Existing v0.8 deployments have an internal CA +root + issued server certs that must be migrated without invalidating +trust across the cluster. + +## Migration options (decision required before v0.10-P14a implementation) + +### Option A — Preserve trust root (RECOMMENDED) + +Import the existing `ca.key` into step-ca as the root CA key. The cluster's +trust fingerprint stays unchanged; existing server certs continue to +validate until their natural expiry; new SVIDs are minted by step-ca using +the same root. + +```bash +orca upgrade --to-v1.0 --import-ca +# reads ~/.orca/ca.key → step ca init --deployment-type standalone \ +# --remote-management --key $(cat ~/.orca/ca.key) +# issues new SVIDs from step-ca for all existing workloads +``` + +**Pros**: zero trust breakage; existing server certs keep working; minimal +operator disruption. +**Cons**: requires step-ca to accept an imported RSA-3072 key (step-ca +supports imported keys via `--key` flag; verify in the spike). +**Post-migration**: old `internal/security/ca.go` and `csr.go` are deleted +(v0.10-P14); the `cert_repo` SQLite table (0004) is dropped (step-ca +manages cert state). + +### Option B — Forced re-bootstrap + +Document that v0.8 certs are invalidated; every cluster re-bootstraps under +step-ca with a new root. Existing workloads are re-enrolled. + +**Pros**: clean slate; no legacy RSA root. +**Cons**: trust breakage — every peer's `known_hosts` + CA cert must be +rotated; running workloads lose mTLS until re-enrolled; higher operator +disruption. +**Use case**: only if Option A is technically infeasible (step-ca rejects +the v0.8 key format). + +## Pre-flight checks (must pass before migration) + +1. `orca doctor` reports zero FAILs on the v0.8 cluster +2. All peers reachable via SSH +3. No in-flight transactions (the migration is stop-the-world for the CA) +4. Snapshot taken (`orca backup --include-master-key`) +5. step-ca installed on the lead via `apt-get install step-ca` +6. `step ca init` dry-run succeeds with the imported key + +## Migration steps (Option A) + +1. SSH to the lead; install step-ca via apt +2. Run `step ca init --deployment-type standalone --remote-management \ + --key --provisioner orca-admin` +3. Move the root cert: `cp ~/.orca/ca.crt $ORCA_HOME/cluster/ca.crt` +4. Issue new SVIDs for every registered workload (via `step ca token` + + `step ca certificate` — the CLI mints the provisioner token using + `cluster/master.key`-derived material) +5. Deploy the new SVIDs to peers via SSH-push (the v0.9 SSH-push transport) +6. Verify: `orca doctor` reports zero FAILs; CA fingerprint unchanged; + all workload SVIDs valid +7. Archive the old `internal/security/ca.go`/`csr.go` and `cert_repo` table + +## Rollback + +If any post-migration invariant fails: +1. Restore the v0.8 snapshot via `orca upgrade --rollback ` +2. Restart the v0.8 orca daemon on the lead +3. Verify `orca doctor` passes on the v0.8 cluster + +The v0.8 internal CA remains functional during the dual-write window +(REQ-090); step-ca is additive until the migration completes. + +## Post-migration invariants (must all pass) + +- CA fingerprint unchanged (Option A) +- Node count unchanged +- Workload count unchanged +- All SVIDs valid (mTLS handshake succeeds lead↔every peer) +- `orca doctor` zero FAILs +- No `internal/security/ca.go` or `cert_repo` references remain in code + +## Decision required + +This spec is gated by C-07. The decision (Option A vs B) must be made +before v0.10-P14a implementation. Default: Option A (preserve trust root) +unless the step-ca imported-key spike fails. + +## Spike (must run before v0.10-P14a) + +Run on a test cluster: +1. Install step-ca on a clean Linux host +2. Generate a v0.8-style RSA-3072 CA key via the v0.8 `internal/security` package +3. Run `step ca init --key ` and verify step-ca accepts it +4. Mint a test SVID via `step ca token` + `step ca certificate` +5. Verify the SVID validates against the imported root + +If the spike fails, fall back to Option B (forced re-bootstrap) and document. \ No newline at end of file diff --git a/internal/certpaths/certpaths.go b/internal/certpaths/certpaths.go index d009b94..d3ebec5 100644 --- a/internal/certpaths/certpaths.go +++ b/internal/certpaths/certpaths.go @@ -1,64 +1,73 @@ -// Package certpaths centralizes the on-disk locations of the CA and -// server cert/key files. The CLI layer, the security layer, and the -// doctor layer all need to agree on these paths, so they're factored -// into their own package to avoid import cycles (cli <-> doctor). +// Package certpaths is the v0.8 path shim. It returns v0.8 flat-layout +// paths for backward compatibility during the v0.9 dual-write window +// (REQ-090). The v0.9 paths package (internal/paths) returns the new +// multi-namespace layout (R-002). +// +// certpaths will be deleted after the v0.10-P14 migration. New code +// should use internal/paths, NOT certpaths. +// +// Migration notes (per v0.10-P14): +// - CA cert/key, server cert/key, SSH key/pub, known_hosts currently +// live at the flat Root() location. The v0.9 internal/paths package +// returns the new ClusterDir()/... locations; certpaths keeps the +// v0.8 flat locations until the CA migration moves them. +// - DBPath keeps returning Root()/orca.db (v0.8 location). The new +// paths.NSDb("_defaults") returns Root()/_defaults/db/orca.db; the DB +// moves in v0.10-P14. package certpaths import ( "os" "path/filepath" + + "git.cloudinit.dev/coreci/orca/internal/paths" ) -const ( - defaultCADir = ".orca" - caCertFilename = "ca.crt" - caKeyFilename = "ca.key" -) +// Dir returns the v0.8 flat root directory. Delegates to paths.Root() +// (which honors $ORCA_HOME, else ~/.orca). v0.8 callers expect the CA +// and DB to live directly under this directory; that does not change +// until the v0.10-P14 migration. +func Dir() string { return paths.Root() } -// Dir returns the directory the local CA lives in. Honors $ORCA_HOME -// for testability; otherwise defaults to ~/.orca. -func Dir() string { - if p := os.Getenv("ORCA_HOME"); p != "" { - return p - } - home, _ := os.UserHomeDir() - return filepath.Join(home, defaultCADir) -} +// CACertPath returns the v0.8 CA cert path: Dir()/ca.crt. +// The v0.9 location is paths.CACertPath() = ClusterDir()/ca.crt; certpaths +// keeps the v0.8 flat location until the CA migration in v0.10-P14. +func CACertPath() string { return filepath.Join(paths.Root(), "ca.crt") } -// CACertPath returns the path to ca.crt. -func CACertPath() string { return filepath.Join(Dir(), caCertFilename) } +// CAKeyPath returns the v0.8 CA key path: Dir()/ca.key. +// See CACertPath for migration notes. +func CAKeyPath() string { return filepath.Join(paths.Root(), "ca.key") } -// CAKeyPath returns the path to ca.key. -func CAKeyPath() string { return filepath.Join(Dir(), caKeyFilename) } +// ServerCertPath returns the v0.8 server cert path: Dir()/server.crt. +// See CACertPath for migration notes. +func ServerCertPath() string { return filepath.Join(paths.Root(), "server.crt") } -// ServerCertPath returns the path to server.crt. -func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") } - -// ServerKeyPath returns the path to server.key. -func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") } +// ServerKeyPath returns the v0.8 server key path: Dir()/server.key. +// See CACertPath for migration notes. +func ServerKeyPath() string { return filepath.Join(paths.Root(), "server.key") } // DBPath returns the path to the orca SQLite database. Honors $ORCA_DB -// for testability and explicit override; otherwise defaults to -// ~/.orca/orca.db under the same Dir() as the cert files. +// for testability and explicit override; otherwise defaults to the v0.8 +// flat location Dir()/orca.db. The v0.9 location is +// paths.NSDb(paths.DefaultNamespace()) = Root()/_defaults/db/orca.db; +// certpaths keeps the v0.8 flat location until the DB move in v0.10-P14. func DBPath() string { if p := os.Getenv("ORCA_DB"); p != "" { return p } - return filepath.Join(Dir(), "orca.db") + return filepath.Join(paths.Root(), "orca.db") } -// SSHKeyPath returns the path to the orca SSH private key (Ed25519, -// D-037). Used by `orca node join --type proxmox` to authenticate -// to remote Proxmox hosts after the initial password-based bootstrap. -// File mode 0600 (enforced by security.WriteKey). -func SSHKeyPath() string { return filepath.Join(Dir(), "orca_ssh_key") } +// SSHKeyPath returns the v0.8 SSH private key path: Dir()/orca_ssh_key. +// The v0.9 location is paths.SSHKeyPath() = ClusterDir()/orca_ssh_key; +// certpaths keeps the v0.8 flat location until the migration. +func SSHKeyPath() string { return filepath.Join(paths.Root(), "orca_ssh_key") } -// SSHPubPath returns the path to the orca SSH public key (authorized_keys -// format). Deployed to remote Proxmox hosts during `orca node join`. -// File mode 0644 (enforced by security.WriteCert). -func SSHPubPath() string { return filepath.Join(Dir(), "orca_ssh_key.pub") } +// SSHPubPath returns the v0.8 SSH public key path: Dir()/orca_ssh_key.pub. +// See SSHKeyPath for migration notes. +func SSHPubPath() string { return filepath.Join(paths.Root(), "orca_ssh_key.pub") } -// KnownHostsPath returns the path to the SSH known_hosts file used for -// TOFU host-key pinning (D-035). Captured on first connect, verified -// on all subsequent connects via golang.org/x/crypto/ssh/knownhosts. -func KnownHostsPath() string { return filepath.Join(Dir(), "known_hosts") } +// KnownHostsPath returns the v0.8 known_hosts path: Dir()/known_hosts. +// The v0.9 location is paths.KnownHostsPath() = ClusterDir()/known_hosts; +// certpaths keeps the v0.8 flat location until the migration. +func KnownHostsPath() string { return filepath.Join(paths.Root(), "known_hosts") } diff --git a/internal/certpaths/certpaths_test.go b/internal/certpaths/certpaths_test.go index ced6761..ca84aa1 100644 --- a/internal/certpaths/certpaths_test.go +++ b/internal/certpaths/certpaths_test.go @@ -3,15 +3,17 @@ package certpaths import ( "os" "path/filepath" - "runtime" "strings" "testing" + + "git.cloudinit.dev/coreci/orca/internal/paths" ) +const defaultHomeSubdir = ".orca" + func TestPaths_HonorORCAHOME(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) - // Ensure ORCA_DB doesn't leak from the environment / prior tests. t.Setenv("ORCA_DB", "") cases := []struct { @@ -36,17 +38,26 @@ func TestPaths_HonorORCAHOME(t *testing.T) { }) } - // DBPath defaults to $ORCA_HOME/orca.db. if got, want := DBPath(), filepath.Join(dir, "orca.db"); got != want { t.Errorf("DBPath = %q, want %q", got, want) } - // Dir() returns ORCA_HOME verbatim. if got, want := Dir(), dir; got != want { t.Errorf("Dir = %q, want %q", got, want) } } +func TestShim_DelegatesDirToPaths(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + if got, want := Dir(), paths.Root(); got != want { + t.Errorf("Dir() = %q, paths.Root() = %q (shim must delegate)", got, want) + } + if got, want := Dir(), dir; got != want { + t.Errorf("Dir() = %q, want %q", got, want) + } +} + func TestDBPath_OrcaDBOverride(t *testing.T) { home := t.TempDir() t.Setenv("ORCA_HOME", home) @@ -70,19 +81,14 @@ func TestDBPath_OrcaDBEmptyStringFallsBackToHome(t *testing.T) { } func TestDir_DefaultHomeFallback(t *testing.T) { - // Unset ORCA_HOME so Dir() falls back to ~/.orca. - // We can't reliably mutate the real HOME in a portable way, so just - // assert that the returned path ends with the default subdir on the - // current OS and is absolute. os.Unsetenv("ORCA_HOME") - // Also clear ORCA_DB so DBPath's fallback to Dir() is exercised. os.Unsetenv("ORCA_DB") home, err := os.UserHomeDir() if err != nil { t.Skipf("os.UserHomeDir: %v (cannot verify default fallback)", err) } - want := filepath.Join(home, defaultCADir) + want := filepath.Join(home, defaultHomeSubdir) if got := Dir(); got != want { t.Errorf("Dir() default = %q, want %q", got, want) } @@ -92,25 +98,22 @@ func TestDir_DefaultHomeFallback(t *testing.T) { } func TestDir_ORCAHOMEEmptyFallsBack(t *testing.T) { - // Empty string ORCA_HOME is treated as unset → ~/.orca fallback. t.Setenv("ORCA_HOME", "") home, err := os.UserHomeDir() if err != nil { t.Skipf("os.UserHomeDir: %v", err) } - want := filepath.Join(home, defaultCADir) + want := filepath.Join(home, defaultHomeSubdir) if got := Dir(); got != want { t.Errorf("Dir() with empty ORCA_HOME = %q, want %q", got, want) } } func TestDir_ORCAHOMERelativePath(t *testing.T) { - // A relative ORCA_HOME is honored verbatim (no cleaning/absolutizing). t.Setenv("ORCA_HOME", "relative/orca/home") if got, want := Dir(), "relative/orca/home"; got != want { t.Errorf("Dir() relative = %q, want %q", got, want) } - // CACertPath joins the relative dir with ca.crt using filepath.Join. if got, want := CACertPath(), filepath.Join("relative/orca/home", "ca.crt"); got != want { t.Errorf("CACertPath relative = %q, want %q", got, want) } @@ -121,7 +124,6 @@ func TestAllPaths_AreConsistentWithDir(t *testing.T) { t.Setenv("ORCA_HOME", dir) t.Setenv("ORCA_DB", "") - // Every *Path() must live under Dir() except DBPath which also does. base := Dir() for _, p := range []string{ CACertPath(), CAKeyPath(), @@ -135,6 +137,38 @@ func TestAllPaths_AreConsistentWithDir(t *testing.T) { } } +func TestShim_ReturnsV08FlatPaths(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + t.Setenv("ORCA_DB", "") + + root := paths.Root() + if got, want := CACertPath(), filepath.Join(root, "ca.crt"); got != want { + t.Errorf("CACertPath = %q, want v0.8 flat %q", got, want) + } + if got, want := CAKeyPath(), filepath.Join(root, "ca.key"); got != want { + t.Errorf("CAKeyPath = %q, want v0.8 flat %q", got, want) + } + if got, want := ServerCertPath(), filepath.Join(root, "server.crt"); got != want { + t.Errorf("ServerCertPath = %q, want v0.8 flat %q", got, want) + } + if got, want := ServerKeyPath(), filepath.Join(root, "server.key"); got != want { + t.Errorf("ServerKeyPath = %q, want v0.8 flat %q", got, want) + } + if got, want := SSHKeyPath(), filepath.Join(root, "orca_ssh_key"); got != want { + t.Errorf("SSHKeyPath = %q, want v0.8 flat %q", got, want) + } + if got, want := SSHPubPath(), filepath.Join(root, "orca_ssh_key.pub"); got != want { + t.Errorf("SSHPubPath = %q, want v0.8 flat %q", got, want) + } + if got, want := KnownHostsPath(), filepath.Join(root, "known_hosts"); got != want { + t.Errorf("KnownHostsPath = %q, want v0.8 flat %q", got, want) + } + if got, want := DBPath(), filepath.Join(root, "orca.db"); got != want { + t.Errorf("DBPath = %q, want v0.8 flat %q", got, want) + } +} + func TestSSHPaths_Filenames(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) @@ -148,10 +182,3 @@ func TestSSHPaths_Filenames(t *testing.T) { t.Errorf("KnownHostsPath base = %q, want %q", got, want) } } - -func init() { - // On Windows the default home subdir is still ".orca"; the test for - // default fallback uses os.UserHomeDir which is platform-aware. This - // guard keeps the suite from running a meaningless check on plan9. - _ = runtime.GOOS -} diff --git a/internal/config/config.go b/internal/config/config.go index b274855..6b52049 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" "github.com/hashicorp/hcl/v2/hclsimple" ) @@ -33,22 +34,82 @@ type Flags struct { type Environ map[string]string +// Load is the config dispatcher (R-014). It tries each path in order, +// skipping missing files, and dispatches to the appropriate loader +// based on file extension: .hcl → LoadHCL (legacy, R-013), +// .md → LoadMarkdown (new Markdown-frontmatter loader), and +// .yaml/.yml → LoadMarkdown with an empty body. The first successfully +// decoded file wins. If no path exists or decodes, a zero Config is +// returned. +// +// The signature is preserved from the v0.8 single-loader API so +// internal/cli/root.go requires no changes yet. func Load(paths ...string) (*Config, error) { for _, p := range paths { if _, err := os.Stat(p); err != nil { continue } + cfg, err := loadByExtension(p) + if err != nil { + return nil, err + } + return cfg, nil + } + return &Config{}, nil +} + +func loadByExtension(p string) (*Config, error) { + ext := strings.ToLower(filepathExt(p)) + switch ext { + case ".hcl": + return LoadHCL(p) + case ".md", ".markdown": + return LoadMarkdown(p) + case ".yaml", ".yml": + return LoadMarkdownYAML(p) + default: + // Unknown extension: hclsimple.Decode rejects non-.hcl + // suffixes, so for backward compat with the v0.8 single-loader + // behavior (which assumed HCL), decode the file content as HCL + // against a synthesized .hcl path. data, err := os.ReadFile(p) if err != nil { return nil, fmt.Errorf("read config %s: %w", p, err) } var cfg Config - if err := hclsimple.Decode(p, data, nil, &cfg); err != nil { + if err := hclsimple.Decode(p+".hcl", data, nil, &cfg); err != nil { return nil, fmt.Errorf("decode config %s: %w", p, err) } return &cfg, nil } - return &Config{}, nil +} + +// filepathExt is a thin wrapper around filepath.Ext to keep the import +// localized to the dispatcher. Returns the extension including the dot, +// lowercased by the caller. +func filepathExt(p string) string { + i := strings.LastIndex(p, ".") + if i < 0 { + return "" + } + return p[i:] +} + +// LoadHCL decodes a legacy HCL config file (R-013). +// +// Deprecated: use LoadMarkdown or the dispatcher. HCL is legacy per +// R-013. Retained for the v0.9 dual-write window (REQ-090); new +// deployments should author config.md with YAML frontmatter. +func LoadHCL(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + var cfg Config + if err := hclsimple.Decode(path, data, nil, &cfg); err != nil { + return nil, fmt.Errorf("decode config %s: %w", path, err) + } + return &cfg, nil } func (c *Config) MergeOverrides(flags Flags, env Environ) *Config { diff --git a/internal/config/dispatch_test.go b/internal/config/dispatch_test.go new file mode 100644 index 0000000..9e117ed --- /dev/null +++ b/internal/config/dispatch_test.go @@ -0,0 +1,111 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestLoad_DispatchHCL(t *testing.T) { + p := writeTestFile(t, t.TempDir(), "config.hcl", exampleHCL) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DBPath != "/tmp/orca/test.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } + if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 4 { + t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity) + } +} + +func TestLoad_DispatchMarkdown(t *testing.T) { + p := writeTestFile(t, t.TempDir(), "config.md", exampleMarkdown) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DBPath != "/tmp/orca/test.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } + if cfg.ListenAddr != "127.0.0.1:9999" { + t.Errorf("ListenAddr=%q", cfg.ListenAddr) + } + if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 4 { + t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity) + } +} + +func TestLoad_DispatchYAML(t *testing.T) { + body := "listen_addr: 0.0.0.0:5555\ndb_path: /bare.db\nnode_capacity:\n cpu: 2\n memory_mb: 4096\n" + p := writeTestFile(t, t.TempDir(), "config.yaml", body) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ListenAddr != "0.0.0.0:5555" { + t.Errorf("ListenAddr=%q", cfg.ListenAddr) + } + if cfg.DBPath != "/bare.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } + if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 2 { + t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity) + } +} + +func TestLoad_DispatchYML(t *testing.T) { + body := "listen_addr: 1.2.3.4:9\n" + p := writeTestFile(t, t.TempDir(), "config.yml", body) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ListenAddr != "1.2.3.4:9" { + t.Errorf("ListenAddr=%q", cfg.ListenAddr) + } +} + +func TestLoad_DispatchFirstExistingWins(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "missing.md") + existing := writeTestFile(t, dir, "real.hcl", exampleHCL) + cfg, err := Load(missing, existing) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DBPath != "/tmp/orca/test.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } +} + +func TestLoad_DispatchMissingReturnsZero(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "nope.md")) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg == nil { + t.Fatal("nil config") + } + if cfg.DBPath != "" || cfg.ListenAddr != "" || cfg.NodeCapacity != nil { + t.Errorf("expected zero config, got %+v", cfg) + } +} + +func TestLoad_DispatchHCLMalformed(t *testing.T) { + p := writeTestFile(t, t.TempDir(), "bad.hcl", "db_path = ") + if _, err := Load(p); err == nil { + t.Fatal("expected error for malformed HCL") + } +} + +func TestLoad_DispatchUnknownExtFallsBackToHCL(t *testing.T) { + p := writeTestFile(t, t.TempDir(), "config.unknown", exampleHCL) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DBPath != "/tmp/orca/test.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } +} diff --git a/internal/config/markdown.go b/internal/config/markdown.go new file mode 100644 index 0000000..540670c --- /dev/null +++ b/internal/config/markdown.go @@ -0,0 +1,220 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// LoadMarkdown decodes a Markdown config file with YAML frontmatter +// (R-014). The file format is: +// +// --- +// listen_addr: 127.0.0.1:9999 +// node_capacity: +// cpu: 4 +// memory_mb: 8192 +// db_path: /tmp/orca/test.db +// --- +// +// body prose (ignored) +// +// The frontmatter parser is a minimal hand-rolled key:value parser +// (no new dependencies; gopkg.in/yaml.v3 is not in go.mod). It supports +// flat scalar keys and one level of nested mapping (for node_capacity). +// The Markdown body after the closing "---" is ignored. +// +// The returned *Config is the same struct the HCL loader produces, so +// downstream consumers are unchanged. +func LoadMarkdown(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + return parseFrontmatter(string(data), path) +} + +// LoadMarkdownYAML decodes a bare YAML file (no Markdown body) using the +// same minimal frontmatter parser. .yaml/.yml files are routed here by +// the dispatcher. +func LoadMarkdownYAML(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + // Treat the whole file as the frontmatter block (no surrounding ---). + return parseFrontmatterBlock(string(data), path) +} + +func parseFrontmatter(content, path string) (*Config, error) { + block, ok := extractFrontmatter(content) + if !ok { + // No frontmatter delimiters: treat whole file as a bare block. + return parseFrontmatterBlock(content, path) + } + return parseFrontmatterBlock(block, path) +} + +// extractFrontmatter returns the YAML block between the first pair of +// "---" delimiters and whether a frontmatter block was present. +func extractFrontmatter(content string) (string, bool) { + trimmed := strings.TrimLeft(content, "\r\n\t ") + if !strings.HasPrefix(trimmed, "---") { + return "", false + } + // Skip the opening delimiter line. + rest := trimmed[3:] + rest = strings.TrimLeft(rest, "\r\n") + // Find the closing delimiter line. + idx := strings.Index(rest, "\n---") + if idx < 0 { + return "", false + } + return rest[:idx], true +} + +// parseFrontmatterBlock parses a minimal YAML-ish block into *Config. +// Supported shapes: +// +// key: value +// node_capacity: +// cpu: 4 +// memory_mb: 8192 +// +// Comments (# ...) and blank lines are ignored. Quoted scalar values +// ("..." or '...') are unwrapped. No flow collections, anchors, or +// multi-line strings are supported — by design, to avoid adding a YAML +// dependency for this small config surface. +func parseFrontmatterBlock(block, path string) (*Config, error) { + cfg := &Config{} + var inCapacity bool + + lines := strings.Split(block, "\n") + for lineNo, raw := range lines { + line := stripComment(raw) + if strings.TrimSpace(line) == "" { + continue + } + + indent := countIndent(line) + trimmed := strings.TrimSpace(line) + + // A top-level key (no leading indent). + if indent == 0 { + inCapacity = false + key, val, ok := splitKV(trimmed) + if !ok { + continue + } + if val == "" { + // key with no value → nested mapping header (e.g. node_capacity:) + if key == "node_capacity" { + cfg.NodeCapacity = &CapacityConfig{} + inCapacity = true + } + continue + } + applyScalar(cfg, key, val, path, lineNo) + continue + } + + // Indented line under a nested mapping. + if inCapacity && cfg.NodeCapacity != nil { + key, val, hasVal := splitKV(trimmed) + if !hasVal { + continue + } + switch key { + case "cpu": + if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil { + cfg.NodeCapacity.CPU = n + } + case "memory_mb": + if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil { + cfg.NodeCapacity.MemoryMB = n + } + } + } + } + return cfg, nil +} + +func applyScalar(cfg *Config, key, val, path string, lineNo int) { + val = strings.TrimSpace(val) + switch key { + case "db_path": + cfg.DBPath = unquote(val) + case "listen_addr": + cfg.ListenAddr = unquote(val) + case "ca_path": + cfg.CAPath = unquote(val) + case "server_cert_path": + cfg.ServerCertPath = unquote(val) + case "server_key_path": + cfg.ServerKeyPath = unquote(val) + } + _ = path + _ = lineNo +} + +func splitKV(s string) (key, val string, ok bool) { + idx := strings.Index(s, ":") + if idx < 0 { + return "", "", false + } + key = strings.TrimSpace(s[:idx]) + val = strings.TrimSpace(s[idx+1:]) + if key == "" { + return "", "", false + } + return key, val, true +} + +func countIndent(s string) int { + n := 0 + for _, r := range s { + if r == ' ' || r == '\t' { + n++ + continue + } + break + } + return n +} + +func stripComment(s string) string { + // Strip inline comments not inside quotes. Minimal: only strip + // when the '#' is preceded by whitespace or at line start. + inSingle := false + inDouble := false + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '\'': + if !inDouble { + inSingle = !inSingle + } + case '"': + if !inSingle { + inDouble = !inDouble + } + case '#': + if !inSingle && !inDouble { + if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' { + return s[:i] + } + } + } + } + return s +} + +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/internal/config/markdown_test.go b/internal/config/markdown_test.go new file mode 100644 index 0000000..c3f20ff --- /dev/null +++ b/internal/config/markdown_test.go @@ -0,0 +1,186 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTestFile(t *testing.T, dir, name, content string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", p, err) + } + return p +} + +const exampleMarkdown = `--- +listen_addr: "127.0.0.1:9999" +db_path: "/tmp/orca/test.db" +ca_path: "/tmp/orca/ca.crt" +server_cert_path: "/tmp/orca/server.crt" +server_key_path: "/tmp/orca/server.key" +node_capacity: + cpu: 4 + memory_mb: 8192 +--- + +# Orca config + +This is prose body and is ignored by the loader. +` + +func TestLoadMarkdown_Full(t *testing.T) { + p := writeTestFile(t, t.TempDir(), "config.md", exampleMarkdown) + cfg, err := LoadMarkdown(p) + if err != nil { + t.Fatalf("LoadMarkdown: %v", err) + } + if cfg.DBPath != "/tmp/orca/test.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } + if cfg.ListenAddr != "127.0.0.1:9999" { + t.Errorf("ListenAddr=%q", cfg.ListenAddr) + } + if cfg.CAPath != "/tmp/orca/ca.crt" { + t.Errorf("CAPath=%q", cfg.CAPath) + } + if cfg.ServerCertPath != "/tmp/orca/server.crt" { + t.Errorf("ServerCertPath=%q", cfg.ServerCertPath) + } + if cfg.ServerKeyPath != "/tmp/orca/server.key" { + t.Errorf("ServerKeyPath=%q", cfg.ServerKeyPath) + } + if cfg.NodeCapacity == nil { + t.Fatal("NodeCapacity nil") + } + if cfg.NodeCapacity.CPU != 4 { + t.Errorf("CPU=%d", cfg.NodeCapacity.CPU) + } + if cfg.NodeCapacity.MemoryMB != 8192 { + t.Errorf("MemoryMB=%d", cfg.NodeCapacity.MemoryMB) + } +} + +func TestLoadMarkdown_NoFrontmatter(t *testing.T) { + // No delimiters: whole file treated as a bare YAML block. + body := "listen_addr: 0.0.0.0:1234\ndb_path: /x/y.db\n" + p := writeTestFile(t, t.TempDir(), "config.md", body) + cfg, err := LoadMarkdown(p) + if err != nil { + t.Fatalf("LoadMarkdown: %v", err) + } + if cfg.ListenAddr != "0.0.0.0:1234" { + t.Errorf("ListenAddr=%q", cfg.ListenAddr) + } + if cfg.DBPath != "/x/y.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } +} + +func TestLoadMarkdown_OnlyBody(t *testing.T) { + body := `--- +--- + +# Just prose, no keys +` + p := writeTestFile(t, t.TempDir(), "config.md", body) + cfg, err := LoadMarkdown(p) + if err != nil { + t.Fatalf("LoadMarkdown: %v", err) + } + if cfg.DBPath != "" || cfg.ListenAddr != "" || cfg.NodeCapacity != nil { + t.Errorf("expected zero config, got %+v", cfg) + } +} + +func TestLoadMarkdown_CommentsAndBlanks(t *testing.T) { + body := `--- +# a comment +listen_addr: "127.0.0.1:9999" + +db_path: "/tmp/orca/test.db" # inline comment + +node_capacity: + cpu: 4 # cores + memory_mb: 8192 +--- +` + p := writeTestFile(t, t.TempDir(), "config.md", body) + cfg, err := LoadMarkdown(p) + if err != nil { + t.Fatalf("LoadMarkdown: %v", err) + } + if cfg.ListenAddr != "127.0.0.1:9999" { + t.Errorf("ListenAddr=%q", cfg.ListenAddr) + } + if cfg.DBPath != "/tmp/orca/test.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } + if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 4 || cfg.NodeCapacity.MemoryMB != 8192 { + t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity) + } +} + +func TestLoadMarkdownYAML_Bare(t *testing.T) { + body := "listen_addr: 0.0.0.0:5555\ndb_path: /bare.db\nnode_capacity:\n cpu: 2\n memory_mb: 4096\n" + p := writeTestFile(t, t.TempDir(), "config.yaml", body) + cfg, err := LoadMarkdownYAML(p) + if err != nil { + t.Fatalf("LoadMarkdownYAML: %v", err) + } + if cfg.ListenAddr != "0.0.0.0:5555" { + t.Errorf("ListenAddr=%q", cfg.ListenAddr) + } + if cfg.DBPath != "/bare.db" { + t.Errorf("DBPath=%q", cfg.DBPath) + } + if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 2 || cfg.NodeCapacity.MemoryMB != 4096 { + t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity) + } +} + +func TestLoadMarkdown_ReadError(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope.md") + if _, err := LoadMarkdown(missing); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestExtractFrontmatter(t *testing.T) { + cases := []struct { + name string + input string + block string + present bool + }{ + {"standard", "---\nkey: val\n---\nbody", "key: val", true}, + {"leading-blanks", "\n\n---\nkey: val\n---\n", "key: val", true}, + {"no-delimiters", "key: val\n", "key: val", false}, + {"only-open", "---\nkey: val\n", "key: val", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + block, ok := extractFrontmatter(tc.input) + if ok != tc.present { + t.Errorf("present=%v want %v", ok, tc.present) + } + if tc.present && block != tc.block { + t.Errorf("block=%q want %q", block, tc.block) + } + }) + } +} + +func TestUnquote(t *testing.T) { + if got, want := unquote(`"hello"`), "hello"; got != want { + t.Errorf("unquote double = %q want %q", got, want) + } + if got, want := unquote(`'hello'`), "hello"; got != want { + t.Errorf("unquote single = %q want %q", got, want) + } + if got, want := unquote("bare"), "bare"; got != want { + t.Errorf("unquote bare = %q want %q", got, want) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go new file mode 100644 index 0000000..eea7041 --- /dev/null +++ b/internal/paths/paths.go @@ -0,0 +1,121 @@ +// Package paths resolves on-disk locations for the v0.9 multi-namespace +// filesystem layout (R-002). It is the canonical source of truth for +// cluster-wide, per-namespace, and CLI-cache paths. +// +// The v0.8 internal/certpaths package is preserved as a thin shim that +// returns the legacy flat-layout paths during the v0.9 dual-write window +// (REQ-090). New code should use internal/paths, NOT certpaths. +package paths + +import ( + "os" + "path/filepath" +) + +const ( + defaultHomeSubdir = ".orca" + clusterDirName = "cluster" + defaultNamespace = "_defaults" +) + +// Root returns the ORCA home directory. It honors $ORCA_HOME for +// testability; otherwise it defaults to ~/.orca. An empty $ORCA_HOME is +// treated as unset. +func Root() string { + if p := os.Getenv("ORCA_HOME"); p != "" { + return p + } + home, _ := os.UserHomeDir() + return filepath.Join(home, defaultHomeSubdir) +} + +// ClusterDir returns the cluster-wide directory: Root()/cluster. +// Cluster-wide artifacts (CA, master key, peers, txns, known_hosts, SSH +// keys) live here and are NOT scoped to a workload namespace (R-002). +func ClusterDir() string { return filepath.Join(Root(), clusterDirName) } + +// NamespaceDir returns the directory for a namespace: Root()/. +// Use DefaultNamespace() for the implicit root namespace (R-002 D-159). +func NamespaceDir(ns string) string { return filepath.Join(Root(), ns) } + +// NSDb returns the SQLite database path for a namespace: +// NamespaceDir(ns)/db/orca.db. +func NSDb(ns string) string { return filepath.Join(NamespaceDir(ns), "db", "orca.db") } + +// NSEnv returns the .env path for a namespace: NamespaceDir(ns)/.env. +func NSEnv(ns string) string { return filepath.Join(NamespaceDir(ns), ".env") } + +// NSSecrets returns the encrypted secrets env path for a namespace: +// NamespaceDir(ns)/.env.secrets. +func NSSecrets(ns string) string { return filepath.Join(NamespaceDir(ns), ".env.secrets") } + +// NSJobs returns the jobs directory for a namespace: NamespaceDir(ns)/jobs. +func NSJobs(ns string) string { return filepath.Join(NamespaceDir(ns), "jobs") } + +// NSAlloc returns the allocation directory for a namespace: +// NamespaceDir(ns)/alloc. +func NSAlloc(ns string) string { return filepath.Join(NamespaceDir(ns), "alloc") } + +// NSMd returns the namespace Markdown doc path (R-014): +// NamespaceDir(ns)/ns.md. +func NSMd(ns string) string { return filepath.Join(NamespaceDir(ns), "ns.md") } + +// DefaultNamespace returns the implicit root namespace name (R-002 D-159). +func DefaultNamespace() string { return defaultNamespace } + +// CACertPath returns the v0.9 cluster CA cert path: +// ClusterDir()/ca.crt (D-101). The v0.8 internal CA still writes to +// Root()/ca.crt; the move happens in v0.10-P14. +func CACertPath() string { return filepath.Join(ClusterDir(), "ca.crt") } + +// CAKeyPath returns the v0.9 cluster CA key path: +// ClusterDir()/ca.key. +func CAKeyPath() string { return filepath.Join(ClusterDir(), "ca.key") } + +// MasterKeyPath returns the AES-256-GCM root master key path +// (R-011, mode 0600): ClusterDir()/master.key. Not generated until +// v0.10-P03. +func MasterKeyPath() string { return filepath.Join(ClusterDir(), "master.key") } + +// CacheDB returns the CLI-side cache database path (R-008): +// Root()/orca_cache.db. Not created until v0.9-P0a2. +func CacheDB() string { return filepath.Join(Root(), "orca_cache.db") } + +// TxnDir returns the cluster transaction log directory (R-016): +// ClusterDir()/txns. +func TxnDir() string { return filepath.Join(ClusterDir(), "txns") } + +// PeersDir returns the cluster peers directory: ClusterDir()/peers. +func PeersDir() string { return filepath.Join(ClusterDir(), "peers") } + +// PeerDir returns the directory for a single peer host: +// PeersDir()/host. +func PeerDir(host string) string { return filepath.Join(PeersDir(), host) } + +// KnownHostsPath returns the SSH known_hosts path (D-035): +// ClusterDir()/known_hosts. +func KnownHostsPath() string { return filepath.Join(ClusterDir(), "known_hosts") } + +// SSHKeyPath returns the orca SSH private key path: +// ClusterDir()/orca_ssh_key (D-037). +func SSHKeyPath() string { return filepath.Join(ClusterDir(), "orca_ssh_key") } + +// SSHPubPath returns the orca SSH public key path: +// ClusterDir()/orca_ssh_key.pub. +func SSHPubPath() string { return filepath.Join(ClusterDir(), "orca_ssh_key.pub") } + +// ServerCertPath returns the legacy server cert path (legacy compat): +// ClusterDir()/server.crt. step-ca will replace this in a later phase. +func ServerCertPath() string { return filepath.Join(ClusterDir(), "server.crt") } + +// ServerKeyPath returns the legacy server key path (legacy compat): +// ClusterDir()/server.key. step-ca will replace this in a later phase. +func ServerKeyPath() string { return filepath.Join(ClusterDir(), "server.key") } + +// ConfigPath returns the new Markdown-frontmatter config path (R-014): +// ClusterDir()/config.md. The legacy HCL path is ClusterDir()/config.hcl. +func ConfigPath() string { return filepath.Join(ClusterDir(), "config.md") } + +// LegacyHCLConfigPath returns the legacy HCL config path: +// ClusterDir()/config.hcl. +func LegacyHCLConfigPath() string { return filepath.Join(ClusterDir(), "config.hcl") } diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go new file mode 100644 index 0000000..6b4067e --- /dev/null +++ b/internal/paths/paths_test.go @@ -0,0 +1,209 @@ +package paths + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRoot_HonorsORCAHOME(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + if got, want := Root(), dir; got != want { + t.Errorf("Root() = %q, want %q", got, want) + } +} + +func TestRoot_EmptyORCAHOMEFallsBack(t *testing.T) { + t.Setenv("ORCA_HOME", "") + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("os.UserHomeDir: %v", err) + } + want := filepath.Join(home, defaultHomeSubdir) + if got := Root(); got != want { + t.Errorf("Root() with empty ORCA_HOME = %q, want %q", got, want) + } +} + +func TestRoot_UnsetORCAHOMEFallsBack(t *testing.T) { + os.Unsetenv("ORCA_HOME") + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("os.UserHomeDir: %v", err) + } + want := filepath.Join(home, defaultHomeSubdir) + got := Root() + if got != want { + t.Errorf("Root() default = %q, want %q", got, want) + } + if !strings.HasPrefix(got, home) { + t.Errorf("Root() default %q does not start with home %q", got, home) + } +} + +func TestRoot_RelativeORCAHOME(t *testing.T) { + t.Setenv("ORCA_HOME", "relative/orca/home") + if got, want := Root(), "relative/orca/home"; got != want { + t.Errorf("Root() relative = %q, want %q", got, want) + } +} + +func TestDefaultNamespace(t *testing.T) { + if got, want := DefaultNamespace(), "_defaults"; got != want { + t.Errorf("DefaultNamespace() = %q, want %q", got, want) + } +} + +func TestClusterDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + got := ClusterDir() + want := filepath.Join(dir, "cluster") + if got != want { + t.Errorf("ClusterDir() = %q, want %q", got, want) + } + if !strings.HasPrefix(got, Root()+string(filepath.Separator)) { + t.Errorf("ClusterDir() %q not under Root() %q", got, Root()) + } +} + +func TestNamespaceDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + ns := "prod" + got := NamespaceDir(ns) + want := filepath.Join(dir, ns) + if got != want { + t.Errorf("NamespaceDir(%q) = %q, want %q", ns, got, want) + } + if !strings.HasPrefix(got, Root()+string(filepath.Separator)) { + t.Errorf("NamespaceDir() %q not under Root() %q", got, Root()) + } +} + +func TestNamespacePaths(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + ns := "prod" + nsDir := NamespaceDir(ns) + + cases := []struct { + name string + got string + want string + }{ + {"NSDb", NSDb(ns), filepath.Join(nsDir, "db", "orca.db")}, + {"NSEnv", NSEnv(ns), filepath.Join(nsDir, ".env")}, + {"NSSecrets", NSSecrets(ns), filepath.Join(nsDir, ".env.secrets")}, + {"NSJobs", NSJobs(ns), filepath.Join(nsDir, "jobs")}, + {"NSAlloc", NSAlloc(ns), filepath.Join(nsDir, "alloc")}, + {"NSMd", NSMd(ns), filepath.Join(nsDir, "ns.md")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("%s(%q) = %q, want %q", tc.name, ns, tc.got, tc.want) + } + if !strings.HasPrefix(tc.got, nsDir+string(filepath.Separator)) { + t.Errorf("%s() %q not under NamespaceDir() %q", tc.name, tc.got, nsDir) + } + }) + } +} + +func TestDefaultNamespacePaths(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + ns := DefaultNamespace() + nsDir := NamespaceDir(ns) + + if got, want := NSDb(ns), filepath.Join(nsDir, "db", "orca.db"); got != want { + t.Errorf("NSDb(_defaults) = %q, want %q", got, want) + } + if got, want := NSEnv(ns), filepath.Join(nsDir, ".env"); got != want { + t.Errorf("NSEnv(_defaults) = %q, want %q", got, want) + } +} + +func TestClusterPaths(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + cDir := ClusterDir() + + cases := []struct { + name string + got string + want string + }{ + {"CACertPath", CACertPath(), filepath.Join(cDir, "ca.crt")}, + {"CAKeyPath", CAKeyPath(), filepath.Join(cDir, "ca.key")}, + {"MasterKeyPath", MasterKeyPath(), filepath.Join(cDir, "master.key")}, + {"KnownHostsPath", KnownHostsPath(), filepath.Join(cDir, "known_hosts")}, + {"SSHKeyPath", SSHKeyPath(), filepath.Join(cDir, "orca_ssh_key")}, + {"SSHPubPath", SSHPubPath(), filepath.Join(cDir, "orca_ssh_key.pub")}, + {"ServerCertPath", ServerCertPath(), filepath.Join(cDir, "server.crt")}, + {"ServerKeyPath", ServerKeyPath(), filepath.Join(cDir, "server.key")}, + {"ConfigPath", ConfigPath(), filepath.Join(cDir, "config.md")}, + {"LegacyHCLConfigPath", LegacyHCLConfigPath(), filepath.Join(cDir, "config.hcl")}, + {"TxnDir", TxnDir(), filepath.Join(cDir, "txns")}, + {"PeersDir", PeersDir(), filepath.Join(cDir, "peers")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("%s() = %q, want %q", tc.name, tc.got, tc.want) + } + if !strings.HasPrefix(tc.got, cDir+string(filepath.Separator)) { + t.Errorf("%s() %q not under ClusterDir() %q", tc.name, tc.got, cDir) + } + }) + } +} + +func TestPeerDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + host := "node1.example.com" + got := PeerDir(host) + want := filepath.Join(PeersDir(), host) + if got != want { + t.Errorf("PeerDir(%q) = %q, want %q", host, got, want) + } + if !strings.HasPrefix(got, PeersDir()+string(filepath.Separator)) { + t.Errorf("PeerDir() %q not under PeersDir() %q", got, PeersDir()) + } +} + +func TestCacheDB(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + got := CacheDB() + want := filepath.Join(dir, "orca_cache.db") + if got != want { + t.Errorf("CacheDB() = %q, want %q", got, want) + } + if !strings.HasPrefix(got, Root()+string(filepath.Separator)) { + t.Errorf("CacheDB() %q not under Root() %q", got, Root()) + } +} + +func TestPathSeparatorsOSAppropriate(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + // Every returned path must use the OS separator (filepath.Join). + sep := string(filepath.Separator) + for _, p := range []string{ + ClusterDir(), NamespaceDir("ns"), NSDb("ns"), NSEnv("ns"), + NSSecrets("ns"), NSJobs("ns"), NSAlloc("ns"), NSMd("ns"), + CACertPath(), CAKeyPath(), MasterKeyPath(), CacheDB(), + TxnDir(), PeersDir(), PeerDir("h"), KnownHostsPath(), + SSHKeyPath(), SSHPubPath(), ServerCertPath(), ServerKeyPath(), + ConfigPath(), LegacyHCLConfigPath(), + } { + if !strings.Contains(p, sep) { + t.Errorf("path %q lacks OS separator %q (not joined?)", p, sep) + } + } +} diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index 25ecfdd..1bd67cc 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -289,6 +289,11 @@ func TOFUHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCa if errors.As(err, &keyErr) && len(keyErr.Want) == 0 { line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, key) path := certpaths.KnownHostsPath() + release, lockErr := security.Flock(path) + if lockErr != nil { + return fmt.Errorf("tofu lock known_hosts: %w", lockErr) + } + defer release() existing, readErr := os.ReadFile(path) if readErr != nil && !os.IsNotExist(readErr) { return fmt.Errorf("tofu read known_hosts: %w", readErr) @@ -481,6 +486,11 @@ func ResetHostKey(host string) error { return fmt.Errorf("ResetHostKey: host is required") } path := certpaths.KnownHostsPath() + release, lockErr := security.Flock(path) + if lockErr != nil { + return fmt.Errorf("ResetHostKey: lock known_hosts: %w", lockErr) + } + defer release() existing, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { diff --git a/internal/security/flock.go b/internal/security/flock.go new file mode 100644 index 0000000..df33258 --- /dev/null +++ b/internal/security/flock.go @@ -0,0 +1,27 @@ +package security + +import ( + "os" + "syscall" +) + +// Flock acquires an exclusive advisory lock on the file at path, creating it +// if missing. Returns a release function that MUST be called (deferred) to +// release the lock and close the file descriptor. Used by the known_hosts +// read-modify-write paths (TOFUHostKeyCallback capture + ResetHostKey) to +// prevent concurrent writers under v0.9's parallel SSH fan-out (REQ-063, +// deferred P1 from REVIEW_v0.8 A2). +func Flock(path string) (release func(), err error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, err + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/internal/security/flock_test.go b/internal/security/flock_test.go new file mode 100644 index 0000000..f44bcc5 --- /dev/null +++ b/internal/security/flock_test.go @@ -0,0 +1,61 @@ +package security + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFlock_acquireAndRelease(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.lock") + + release, err := Flock(path) + if err != nil { + t.Fatalf("Flock: %v", err) + } + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("lock file not created: %v", statErr) + } + release() +} + +func TestFlock_reentrantAfterRelease(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.lock") + + r1, err := Flock(path) + if err != nil { + t.Fatalf("first Flock: %v", err) + } + r1() + + r2, err := Flock(path) + if err != nil { + t.Fatalf("second Flock after release: %v", err) + } + r2() +} + +func TestFlock_concurrentBlocks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.lock") + + r1, err := Flock(path) + if err != nil { + t.Fatalf("first Flock: %v", err) + } + defer r1() + + done := make(chan error, 1) + go func() { + _, err := Flock(path) + done <- err + }() + + select { + case <-done: + t.Fatal("second Flock should block while first holds the lock") + default: + } +}