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",
|
||||
"milestone": "v0.9",
|
||||
"milestone_slug": "rearchitecture",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-05T02:45:00Z",
|
||||
"updated_at": "2026-08-05T03:00:00Z",
|
||||
"milestone_complete": false,
|
||||
"gates_cleared": ["C-03", "C-05", "C-06", "C-15", "C-16", "C-17", "C-18"],
|
||||
"verify": {
|
||||
"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)"
|
||||
}
|
||||
"gates_cleared_this_phase": ["C-07"],
|
||||
"verify": { "build": "pass", "go_test": "17/17", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" }
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
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) {
|
||||
|
||||
@@ -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