Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6de82d712 | |||
| 437aab39b4 | |||
| e5d2711d71 |
@@ -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 <v0.8-ca-key-path> --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 <tarball>`
|
||||||
|
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 <v8-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.
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
{
|
{
|
||||||
"phase": "P00",
|
"phase": "P0a1",
|
||||||
"stage": "verify",
|
"stage": "verify",
|
||||||
"milestone": "v0.9",
|
"milestone": "v0.9",
|
||||||
"milestone_slug": "rearchitecture",
|
|
||||||
"phase_role": "execution",
|
"phase_role": "execution",
|
||||||
"attempts": 0,
|
"updated_at": "2026-08-05T03:00:00Z",
|
||||||
"updated_at": "2026-08-05T02:45:00Z",
|
|
||||||
"milestone_complete": false,
|
"milestone_complete": false,
|
||||||
"gates_cleared": ["C-03", "C-05", "C-06", "C-15", "C-16", "C-17", "C-18"],
|
"gates_cleared_this_phase": ["C-07"],
|
||||||
"verify": {
|
"verify": { "build": "pass", "go_test": "17/17", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" }
|
||||||
"build": "pass",
|
|
||||||
"go_test": "16/16 packages pass",
|
|
||||||
"bats": "20/20 tests pass",
|
|
||||||
"gofmt": "clean",
|
|
||||||
"go_vet": "clean",
|
|
||||||
"verify_reqs": "90 requirements consistent",
|
|
||||||
"shellcheck": "info-level only (no errors)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,73 @@
|
|||||||
// Package certpaths centralizes the on-disk locations of the CA and
|
// Package certpaths is the v0.8 path shim. It returns v0.8 flat-layout
|
||||||
// server cert/key files. The CLI layer, the security layer, and the
|
// paths for backward compatibility during the v0.9 dual-write window
|
||||||
// doctor layer all need to agree on these paths, so they're factored
|
// (REQ-090). The v0.9 paths package (internal/paths) returns the new
|
||||||
// into their own package to avoid import cycles (cli <-> doctor).
|
// 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
|
package certpaths
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// Dir returns the v0.8 flat root directory. Delegates to paths.Root()
|
||||||
defaultCADir = ".orca"
|
// (which honors $ORCA_HOME, else ~/.orca). v0.8 callers expect the CA
|
||||||
caCertFilename = "ca.crt"
|
// and DB to live directly under this directory; that does not change
|
||||||
caKeyFilename = "ca.key"
|
// until the v0.10-P14 migration.
|
||||||
)
|
func Dir() string { return paths.Root() }
|
||||||
|
|
||||||
// Dir returns the directory the local CA lives in. Honors $ORCA_HOME
|
// CACertPath returns the v0.8 CA cert path: Dir()/ca.crt.
|
||||||
// for testability; otherwise defaults to ~/.orca.
|
// The v0.9 location is paths.CACertPath() = ClusterDir()/ca.crt; certpaths
|
||||||
func Dir() string {
|
// keeps the v0.8 flat location until the CA migration in v0.10-P14.
|
||||||
if p := os.Getenv("ORCA_HOME"); p != "" {
|
func CACertPath() string { return filepath.Join(paths.Root(), "ca.crt") }
|
||||||
return p
|
|
||||||
}
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return filepath.Join(home, defaultCADir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CACertPath returns the path to ca.crt.
|
// CAKeyPath returns the v0.8 CA key path: Dir()/ca.key.
|
||||||
func CACertPath() string { return filepath.Join(Dir(), caCertFilename) }
|
// See CACertPath for migration notes.
|
||||||
|
func CAKeyPath() string { return filepath.Join(paths.Root(), "ca.key") }
|
||||||
|
|
||||||
// CAKeyPath returns the path to ca.key.
|
// ServerCertPath returns the v0.8 server cert path: Dir()/server.crt.
|
||||||
func CAKeyPath() string { return filepath.Join(Dir(), caKeyFilename) }
|
// See CACertPath for migration notes.
|
||||||
|
func ServerCertPath() string { return filepath.Join(paths.Root(), "server.crt") }
|
||||||
|
|
||||||
// ServerCertPath returns the path to server.crt.
|
// ServerKeyPath returns the v0.8 server key path: Dir()/server.key.
|
||||||
func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") }
|
// See CACertPath for migration notes.
|
||||||
|
func ServerKeyPath() string { return filepath.Join(paths.Root(), "server.key") }
|
||||||
// ServerKeyPath returns the path to server.key.
|
|
||||||
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
|
|
||||||
|
|
||||||
// DBPath returns the path to the orca SQLite database. Honors $ORCA_DB
|
// DBPath returns the path to the orca SQLite database. Honors $ORCA_DB
|
||||||
// for testability and explicit override; otherwise defaults to
|
// for testability and explicit override; otherwise defaults to the v0.8
|
||||||
// ~/.orca/orca.db under the same Dir() as the cert files.
|
// 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 {
|
func DBPath() string {
|
||||||
if p := os.Getenv("ORCA_DB"); p != "" {
|
if p := os.Getenv("ORCA_DB"); p != "" {
|
||||||
return 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,
|
// SSHKeyPath returns the v0.8 SSH private key path: Dir()/orca_ssh_key.
|
||||||
// D-037). Used by `orca node join --type proxmox` to authenticate
|
// The v0.9 location is paths.SSHKeyPath() = ClusterDir()/orca_ssh_key;
|
||||||
// to remote Proxmox hosts after the initial password-based bootstrap.
|
// certpaths keeps the v0.8 flat location until the migration.
|
||||||
// File mode 0600 (enforced by security.WriteKey).
|
func SSHKeyPath() string { return filepath.Join(paths.Root(), "orca_ssh_key") }
|
||||||
func SSHKeyPath() string { return filepath.Join(Dir(), "orca_ssh_key") }
|
|
||||||
|
|
||||||
// SSHPubPath returns the path to the orca SSH public key (authorized_keys
|
// SSHPubPath returns the v0.8 SSH public key path: Dir()/orca_ssh_key.pub.
|
||||||
// format). Deployed to remote Proxmox hosts during `orca node join`.
|
// See SSHKeyPath for migration notes.
|
||||||
// File mode 0644 (enforced by security.WriteCert).
|
func SSHPubPath() string { return filepath.Join(paths.Root(), "orca_ssh_key.pub") }
|
||||||
func SSHPubPath() string { return filepath.Join(Dir(), "orca_ssh_key.pub") }
|
|
||||||
|
|
||||||
// KnownHostsPath returns the path to the SSH known_hosts file used for
|
// KnownHostsPath returns the v0.8 known_hosts path: Dir()/known_hosts.
|
||||||
// TOFU host-key pinning (D-035). Captured on first connect, verified
|
// The v0.9 location is paths.KnownHostsPath() = ClusterDir()/known_hosts;
|
||||||
// on all subsequent connects via golang.org/x/crypto/ssh/knownhosts.
|
// certpaths keeps the v0.8 flat location until the migration.
|
||||||
func KnownHostsPath() string { return filepath.Join(Dir(), "known_hosts") }
|
func KnownHostsPath() string { return filepath.Join(paths.Root(), "known_hosts") }
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ package certpaths
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultHomeSubdir = ".orca"
|
||||||
|
|
||||||
func TestPaths_HonorORCAHOME(t *testing.T) {
|
func TestPaths_HonorORCAHOME(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", dir)
|
||||||
// Ensure ORCA_DB doesn't leak from the environment / prior tests.
|
|
||||||
t.Setenv("ORCA_DB", "")
|
t.Setenv("ORCA_DB", "")
|
||||||
|
|
||||||
cases := []struct {
|
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 {
|
if got, want := DBPath(), filepath.Join(dir, "orca.db"); got != want {
|
||||||
t.Errorf("DBPath = %q, want %q", got, want)
|
t.Errorf("DBPath = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dir() returns ORCA_HOME verbatim.
|
|
||||||
if got, want := Dir(), dir; got != want {
|
if got, want := Dir(), dir; got != want {
|
||||||
t.Errorf("Dir = %q, want %q", 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) {
|
func TestDBPath_OrcaDBOverride(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("ORCA_HOME", home)
|
t.Setenv("ORCA_HOME", home)
|
||||||
@@ -70,19 +81,14 @@ func TestDBPath_OrcaDBEmptyStringFallsBackToHome(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDir_DefaultHomeFallback(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")
|
os.Unsetenv("ORCA_HOME")
|
||||||
// Also clear ORCA_DB so DBPath's fallback to Dir() is exercised.
|
|
||||||
os.Unsetenv("ORCA_DB")
|
os.Unsetenv("ORCA_DB")
|
||||||
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("os.UserHomeDir: %v (cannot verify default fallback)", err)
|
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 {
|
if got := Dir(); got != want {
|
||||||
t.Errorf("Dir() default = %q, want %q", 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) {
|
func TestDir_ORCAHOMEEmptyFallsBack(t *testing.T) {
|
||||||
// Empty string ORCA_HOME is treated as unset → ~/.orca fallback.
|
|
||||||
t.Setenv("ORCA_HOME", "")
|
t.Setenv("ORCA_HOME", "")
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("os.UserHomeDir: %v", err)
|
t.Skipf("os.UserHomeDir: %v", err)
|
||||||
}
|
}
|
||||||
want := filepath.Join(home, defaultCADir)
|
want := filepath.Join(home, defaultHomeSubdir)
|
||||||
if got := Dir(); got != want {
|
if got := Dir(); got != want {
|
||||||
t.Errorf("Dir() with empty ORCA_HOME = %q, want %q", got, want)
|
t.Errorf("Dir() with empty ORCA_HOME = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDir_ORCAHOMERelativePath(t *testing.T) {
|
func TestDir_ORCAHOMERelativePath(t *testing.T) {
|
||||||
// A relative ORCA_HOME is honored verbatim (no cleaning/absolutizing).
|
|
||||||
t.Setenv("ORCA_HOME", "relative/orca/home")
|
t.Setenv("ORCA_HOME", "relative/orca/home")
|
||||||
if got, want := Dir(), "relative/orca/home"; got != want {
|
if got, want := Dir(), "relative/orca/home"; got != want {
|
||||||
t.Errorf("Dir() relative = %q, want %q", 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 {
|
if got, want := CACertPath(), filepath.Join("relative/orca/home", "ca.crt"); got != want {
|
||||||
t.Errorf("CACertPath relative = %q, want %q", 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_HOME", dir)
|
||||||
t.Setenv("ORCA_DB", "")
|
t.Setenv("ORCA_DB", "")
|
||||||
|
|
||||||
// Every *Path() must live under Dir() except DBPath which also does.
|
|
||||||
base := Dir()
|
base := Dir()
|
||||||
for _, p := range []string{
|
for _, p := range []string{
|
||||||
CACertPath(), CAKeyPath(),
|
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) {
|
func TestSSHPaths_Filenames(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", dir)
|
||||||
@@ -148,10 +182,3 @@ func TestSSHPaths_Filenames(t *testing.T) {
|
|||||||
t.Errorf("KnownHostsPath base = %q, want %q", got, want)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/hcl/v2/hclsimple"
|
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||||
)
|
)
|
||||||
@@ -33,22 +34,82 @@ type Flags struct {
|
|||||||
|
|
||||||
type Environ map[string]string
|
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) {
|
func Load(paths ...string) (*Config, error) {
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
if _, err := os.Stat(p); err != nil {
|
if _, err := os.Stat(p); err != nil {
|
||||||
continue
|
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)
|
data, err := os.ReadFile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read config %s: %w", p, err)
|
return nil, fmt.Errorf("read config %s: %w", p, err)
|
||||||
}
|
}
|
||||||
var cfg Config
|
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 nil, fmt.Errorf("decode config %s: %w", p, err)
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
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 {
|
func (c *Config) MergeOverrides(flags Flags, env Environ) *Config {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()/<ns>.
|
||||||
|
// 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") }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -289,6 +289,11 @@ func TOFUHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCa
|
|||||||
if errors.As(err, &keyErr) && len(keyErr.Want) == 0 {
|
if errors.As(err, &keyErr) && len(keyErr.Want) == 0 {
|
||||||
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, key)
|
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, key)
|
||||||
path := certpaths.KnownHostsPath()
|
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)
|
existing, readErr := os.ReadFile(path)
|
||||||
if readErr != nil && !os.IsNotExist(readErr) {
|
if readErr != nil && !os.IsNotExist(readErr) {
|
||||||
return fmt.Errorf("tofu read known_hosts: %w", 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")
|
return fmt.Errorf("ResetHostKey: host is required")
|
||||||
}
|
}
|
||||||
path := certpaths.KnownHostsPath()
|
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)
|
existing, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user