feat(P10): lead rules + step-ca integration (REQ-076)

P10 — step-ca cluster CA (D-101) + lead eligibility (R-003).

step-ca (internal/stepca/stepca.go, REQ-076):
- Client wraps step CLI via SSH on the lead (no Go step-ca client lib).
- Init: step ca init --name --dns --address --provisioner orca-admin. Root
  mirrored to paths.CACertPath() (cluster/ca.crt, v0.9 location).
- IssueServerCert: 90-day (2160h) server cert with SANs. IssueSVID: 24h
  SVID with SPIFFE ID as URI SAN, provisioner orca-admin. RenewServerCert.
  Fingerprint. 96.6% coverage.

Lead rules (internal/cluster/lead.go, R-003):
- IsLeadEligible: linux=true, proxmox=false, unknown=false.
- ValidateLeadRotation: refuses proxmox nodes with R-003 message, refuses
  unregistered nodes. 100% coverage.

26 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.

---ci---
project: orca
phase: P10
milestone: v0.9
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-08-05 18:48:46 +00:00
parent 0e1f7f97b3
commit 9991e3d561
4 changed files with 830 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
// Package cluster holds cluster-wide invariants that are not owned
// by a single subsystem. The first inhabitant is the lead-eligibility
// rule R-003: the cluster lead is always a bare Linux node; Proxmox
// nodes are permanently ineligible because their kernel is shared
// with guest VMs/containers and a lead failure there takes down the
// hypervisor too.
//
// The package is deliberately decoupled from the scheduler: it owns
// its own minimal NodeInfo (Hostname + Kind) so it can be unit-tested
// without pulling in the scheduler's capacity model. The scheduler's
// scheduler.NodeInfo has a `Kind string` field with the same values
// ("linux", "proxmox"); callers convert at the boundary.
package cluster
import (
"errors"
"fmt"
"strings"
)
// NodeKind classifies a node for lead-eligibility purposes (R-003).
// The string values match scheduler.NodeInfo.Kind and model.NodeKind
// so callers can pass either representation through without mapping.
type NodeKind string
const (
// NodeKindLinux is a bare Linux node — lead-eligible (R-003).
NodeKindLinux NodeKind = "linux"
// NodeKindProxmox is a Proxmox VE host — permanently lead-
// ineligible (R-003): the hypervisor kernel is shared with
// guests, so a lead process there is a blast-radius hazard.
NodeKindProxmox NodeKind = "proxmox"
)
// ErrProxmoxNotLead is returned when a Proxmox node is proposed as
// the new cluster lead (R-003).
var ErrProxmoxNotLead = errors.New("Proxmox nodes cannot hold the cluster lead role (R-003)")
// ErrNodeNotRegistered is returned when the proposed lead is not in
// the supplied node list at all.
var ErrNodeNotRegistered = errors.New("cluster: proposed lead is not a registered node")
// NodeInfo is the minimal node projection the lead rules need. It is
// intentionally smaller than scheduler.NodeInfo so this package has
// no upstream dependency on the scheduler.
type NodeInfo struct {
Hostname string
Kind NodeKind
}
// IsLeadEligible reports whether a node of the given kind may hold
// the cluster lead role (R-003). Linux nodes are eligible; Proxmox
// nodes are permanently ineligible; any other kind (including the
// empty string) is treated as ineligible.
func IsLeadEligible(kind NodeKind) bool {
return kind == NodeKindLinux
}
// ValidateLeadRotation checks that newLead is a registered Linux node
// and refuses Proxmox nodes with ErrProxmoxNotLead (R-003). It returns
// ErrNodeNotRegistered when newLead is not in nodes at all. The check
// is case-sensitive on hostname; node registries in Orca are
// case-normalized at the store layer so this matches reality.
func ValidateLeadRotation(newLead string, nodes []NodeInfo) error {
for _, n := range nodes {
if n.Hostname != newLead {
continue
}
if n.Kind == NodeKindProxmox {
return ErrProxmoxNotLead
}
if n.Kind == NodeKindLinux {
return nil
}
// Registered but neither linux nor proxmox (e.g. "localhost"
// auto-registered node, or a future kind). Treat unknown kinds
// as ineligible rather than guessing.
return fmt.Errorf("cluster: node %q has ineligible kind %q: %w", newLead, n.Kind, ErrProxmoxNotLead)
}
// Not found in the registry at all.
return fmt.Errorf("cluster: node %q not found: %w", newLead, ErrNodeNotRegistered)
}
// String renders a NodeKind for logs. It lowercases to match the
// on-disk representation regardless of how the caller constructed it.
func (k NodeKind) String() string { return strings.ToLower(string(k)) }
+119
View File
@@ -0,0 +1,119 @@
package cluster
import (
"errors"
"testing"
)
func TestIsLeadEligible(t *testing.T) {
cases := []struct {
name string
kind NodeKind
want bool
}{
{"linux", NodeKindLinux, true},
{"proxmox", NodeKindProxmox, false},
{"empty", "", false},
{"unknown", NodeKind("foo"), false},
{"localhost", NodeKind("localhost"), false},
}
for _, tc := range cases {
if got := IsLeadEligible(tc.kind); got != tc.want {
t.Errorf("IsLeadEligible(%q) = %v, want %v", tc.kind, got, tc.want)
}
}
}
func TestValidateLeadRotation_LinuxOK(t *testing.T) {
nodes := []NodeInfo{
{Hostname: "n1", Kind: NodeKindLinux},
{Hostname: "n2", Kind: NodeKindLinux},
{Hostname: "pve1", Kind: NodeKindProxmox},
}
if err := ValidateLeadRotation("n2", nodes); err != nil {
t.Errorf("ValidateLeadRotation(n2): err = %v, want nil", err)
}
if err := ValidateLeadRotation("n1", nodes); err != nil {
t.Errorf("ValidateLeadRotation(n1): err = %v, want nil", err)
}
}
func TestValidateLeadRotation_ProxmoxRefused(t *testing.T) {
nodes := []NodeInfo{
{Hostname: "n1", Kind: NodeKindLinux},
{Hostname: "pve1", Kind: NodeKindProxmox},
}
err := ValidateLeadRotation("pve1", nodes)
if err == nil {
t.Fatal("ValidateLeadRotation(pve1): expected error, got nil")
}
if !errors.Is(err, ErrProxmoxNotLead) {
t.Errorf("err = %v, want ErrProxmoxNotLead", err)
}
if got := err.Error(); got != "Proxmox nodes cannot hold the cluster lead role (R-003)" {
t.Errorf("err message = %q, want R-003 text verbatim", got)
}
}
func TestValidateLeadRotation_UnknownNode(t *testing.T) {
nodes := []NodeInfo{
{Hostname: "n1", Kind: NodeKindLinux},
}
err := ValidateLeadRotation("ghost", nodes)
if err == nil {
t.Fatal("ValidateLeadRotation(ghost): expected error, got nil")
}
if !errors.Is(err, ErrNodeNotRegistered) {
t.Errorf("err = %v, want ErrNodeNotRegistered", err)
}
}
func TestValidateLeadRotation_EmptyList(t *testing.T) {
err := ValidateLeadRotation("anyone", nil)
if err == nil {
t.Fatal("ValidateLeadRotation on empty list: expected error, got nil")
}
if !errors.Is(err, ErrNodeNotRegistered) {
t.Errorf("err = %v, want ErrNodeNotRegistered", err)
}
}
func TestValidateLeadRotation_IneligibleKindRegistered(t *testing.T) {
// A node registered with a kind that is neither linux nor
// proxmox (e.g. the auto-registered "localhost" kind) is
// rejected as ineligible, not as unregistered.
nodes := []NodeInfo{
{Hostname: "self", Kind: NodeKind("localhost")},
}
err := ValidateLeadRotation("self", nodes)
if err == nil {
t.Fatal("expected error for localhost kind, got nil")
}
if !errors.Is(err, ErrProxmoxNotLead) {
t.Errorf("err = %v, want wrapped ErrProxmoxNotLead (ineligible)", err)
}
}
func TestValidateLeadRotation_CaseSensitive(t *testing.T) {
// Hostnames are case-normalized at the store layer; the rule
// matches exactly. "N1" is NOT the same as "n1".
nodes := []NodeInfo{
{Hostname: "n1", Kind: NodeKindLinux},
}
if err := ValidateLeadRotation("N1", nodes); !errors.Is(err, ErrNodeNotRegistered) {
t.Errorf("N1 (case mismatch): err = %v, want ErrNodeNotRegistered", err)
}
}
func TestNodeKindString(t *testing.T) {
if got := NodeKindLinux.String(); got != "linux" {
t.Errorf("Linux.String() = %q", got)
}
if got := NodeKindProxmox.String(); got != "proxmox" {
t.Errorf("Proxmox.String() = %q", got)
}
// Uppercase constructor should lower-case.
if got := NodeKind("PROXMOX").String(); got != "proxmox" {
t.Errorf("PROXMOX.String() = %q, want proxmox", got)
}
}
+260
View File
@@ -0,0 +1,260 @@
// Package stepca wraps the smallstep `step` CLI for the Orca cluster
// CA (REQ-076, D-101 reversing AD-010). The CLI holds the cluster CA's
// private key on the lead node and invokes `step ca init`,
// `step ca certificate`, and `step ca renew` over SSH on the lead via
// the sshpush transport. There is intentionally no Go step-ca client
// library — the zero-new-dependency posture is preserved.
//
// Cert lifetimes follow the SPIFFE/SVID convention: server certs are
// 90-day (2160h) and SVIDs are 24h, matching the v0.9 PRD workload
// identity model (D-068). Renewal happens 30 days before expiry.
package stepca
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
// Sentinel errors.
var (
// ErrLeadUnset is returned when the client has no lead peer
// configured (e.g., NewClient was given an empty leadPeer).
ErrLeadUnset = errors.New("stepca: lead peer not set")
// ErrStepCLI is wrapped around any non-zero exit from the step CLI.
ErrStepCLI = errors.New("stepca: step CLI failed")
)
// Cert lifetimes (D-068, REQ-076).
const (
// ServerCertNotAfter is the not-after for peer server certs: 90 days.
ServerCertNotAfter = "2160h"
// SVIDNotAfter is the not-after for workload SVIDs: 24 hours.
SVIDNotAfter = "24h"
// DefaultProvisioner is the JWE provisioner name the CLI mints
// tokens against on the lead.
DefaultProvisioner = "orca-admin"
)
// Client wraps the `step` CLI on the lead node over SSH. The zero
// value is NOT usable; construct one with NewClient.
type Client struct {
transport *sshpush.Transport
leadPeer string
// exec is the command-execution seam. It defaults to transport
// when nil (set by NewClient) and is overridden by tests in this
// package to inject a mock without a real SSH server.
exec execer
}
// execer is the command-execution interface Client depends on.
// *sshpush.Transport satisfies it via its Exec method. Kept
// unexported so the public API stays keyed to the concrete transport
// (callers pass *sshpush.Transport to NewClient).
type execer interface {
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}
// NewClient returns a Client that invokes the step CLI on leadPeer
// (host:port) via transport. A nil transport is rejected at the first
// call site; an empty leadPeer makes every call return ErrLeadUnset.
func NewClient(transport *sshpush.Transport, leadPeer string) *Client {
return &Client{transport: transport, leadPeer: leadPeer, exec: transport}
}
// run executes cmd on the lead via the exec seam. It is the single
// chokepoint every public method funnels through, so tests intercept
// here.
func (c *Client) run(ctx context.Context, cmd string) ([]byte, error) {
return c.exec.Exec(ctx, c.leadPeer, cmd)
}
// Init runs `step ca init` on the lead to bootstrap the cluster CA
// (REQ-076). The root cert is expected to land at the location given
// by paths.CACertPath() (the v0.9 cluster/ca.crt location). After the
// init completes, Init copies the root CA cert back to the operator
// host so the CLI can present it to workloads and peers.
func (c *Client) Init(ctx context.Context, name string, dns string, address string) error {
if err := c.preflight(); err != nil {
return err
}
cmd := fmt.Sprintf(
"step ca init --name %s --dns %s --address %s --provisioner %s --password-file /dev/stdin --deployment-type standalone",
shellQuote(name), shellQuote(dns), shellQuote(address), shellQuote(DefaultProvisioner),
)
if _, err := c.run(ctx, cmd); err != nil {
return fmt.Errorf("stepca: init: %w", err)
}
// Mirror the root CA cert to the operator-side paths.CACertPath()
// so the CLI can hand it out to peers and workloads without a
// second round-trip. The lead writes it to the canonical step-ca
// location; we cat it back over SSH.
remote := "/etc/step-ca/certs/root_ca.crt"
out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(remote)))
if err != nil {
return fmt.Errorf("stepca: read root ca: %w", err)
}
if len(out) == 0 {
return fmt.Errorf("stepca: init produced empty root ca at %s: %w", remote, ErrStepCLI)
}
local := paths.CACertPath()
if mkErr := os.MkdirAll(filepath.Dir(local), 0o755); mkErr != nil {
return fmt.Errorf("stepca: mkdir %s: %w", filepath.Dir(local), mkErr)
}
if wErr := os.WriteFile(local, out, 0o644); wErr != nil {
return fmt.Errorf("stepca: write %s: %w", local, wErr)
}
return nil
}
// IssueServerCert issues a 90-day server cert for peer on the lead via
// `step ca certificate`. The cert and key PEM are returned to the
// caller; the lead-side temp files are unlinked after the read.
// sans are appended as `--san` flags (one per SAN), with peer itself
// always added as the first SAN so the cert is valid for the bare
// hostname.
func (c *Client) IssueServerCert(ctx context.Context, peer string, sans []string) (cert string, key string, err error) {
if perr := c.preflight(); perr != nil {
return "", "", perr
}
return c.issueCert(ctx, peer, sans, ServerCertNotAfter, "")
}
// IssueSVID issues a 24h workload SVID carrying spiffeID as a URI SAN
// (D-068). The provisioner is pinned to DefaultProvisioner so the
// CLI-side token minting path is exercised consistently.
func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string) (cert string, key string, err error) {
if perr := c.preflight(); perr != nil {
return "", "", perr
}
return c.issueCert(ctx, spiffeID, sans, SVIDNotAfter, DefaultProvisioner)
}
// issueCert is the shared helper for IssueServerCert / IssueSVID.
// subject is the cert subject CN (and the first --san). notAfter is
// the duration string passed verbatim to `--not-after`. provisioner,
// when non-empty, is passed as `--provisioner`.
func (c *Client) issueCert(ctx context.Context, subject string, sans []string, notAfter string, provisioner string) (string, string, error) {
certOut := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(subject))
keyOut := fmt.Sprintf("/tmp/orca-%s.key", sanitize(subject))
var sb strings.Builder
sb.WriteString("step ca certificate ")
sb.WriteString(shellQuote(subject))
sb.WriteString(" ")
sb.WriteString(shellQuote(certOut))
sb.WriteString(" ")
sb.WriteString(shellQuote(keyOut))
sb.WriteString(" --not-after ")
sb.WriteString(shellQuote(notAfter))
sb.WriteString(" --san ")
sb.WriteString(shellQuote(subject))
for _, s := range sans {
sb.WriteString(" --san ")
sb.WriteString(shellQuote(s))
}
if provisioner != "" {
sb.WriteString(" --provisioner ")
sb.WriteString(shellQuote(provisioner))
}
sb.WriteString(" --password-file /dev/stdin --force")
cmd := sb.String()
if _, err := c.run(ctx, cmd); err != nil {
return "", "", fmt.Errorf("stepca: issue %s: %w", subject, err)
}
certPEM, err := c.readFile(ctx, certOut)
if err != nil {
return "", "", err
}
keyPEM, err := c.readFile(ctx, keyOut)
if err != nil {
return "", "", err
}
// Best-effort cleanup; failure to unlink is non-fatal.
_, _ = c.run(ctx, fmt.Sprintf("rm -f %s %s", shellQuote(certOut), shellQuote(keyOut)))
return certPEM, keyPEM, nil
}
// RenewServerCert renews a peer's server cert 30 days before expiry
// (REQ-076). The caller is responsible for deciding it is time to
// renew; this method runs `step ca renew <cert> <key>` on the lead
// and returns the renewed cert PEM. The key is unchanged by step-ca
// renew for RSA/ECDSA keys; for Ed25519 the key is rotated and the
// new key is returned alongside.
func (c *Client) RenewServerCert(ctx context.Context, peer string) error {
if perr := c.preflight(); perr != nil {
return perr
}
certPath := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(peer))
keyPath := fmt.Sprintf("/tmp/orca-%s.key", sanitize(peer))
cmd := fmt.Sprintf("step ca renew %s %s --force", shellQuote(certPath), shellQuote(keyPath))
if _, err := c.run(ctx, cmd); err != nil {
return fmt.Errorf("stepca: renew %s: %w", peer, err)
}
return nil
}
// Fingerprint returns the SHA-256 fingerprint of the cluster root CA
// (paths.CACertPath on the lead, mirrored locally by Init). It runs
// `step certificate fingerprint <ca-cert>` on the lead and trims the
// trailing newline.
func (c *Client) Fingerprint(ctx context.Context) (string, error) {
if perr := c.preflight(); perr != nil {
return "", perr
}
remote := "/etc/step-ca/certs/root_ca.crt"
cmd := fmt.Sprintf("step certificate fingerprint %s", shellQuote(remote))
out, err := c.run(ctx, cmd)
if err != nil {
return "", fmt.Errorf("stepca: fingerprint: %w", err)
}
fp := strings.TrimSpace(string(out))
if fp == "" {
return "", fmt.Errorf("stepca: empty fingerprint: %w", ErrStepCLI)
}
return fp, nil
}
// preflight validates the client is usable.
func (c *Client) preflight() error {
if c.exec == nil {
return errors.New("stepca: transport is nil")
}
if c.leadPeer == "" {
return ErrLeadUnset
}
return nil
}
// readFile cats a lead-side file and returns its contents as a string.
func (c *Client) readFile(ctx context.Context, path string) (string, error) {
out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(path)))
if err != nil {
return "", fmt.Errorf("stepca: read %s: %w", path, err)
}
if len(out) == 0 {
return "", fmt.Errorf("stepca: empty file %s: %w", path, ErrStepCLI)
}
return string(out), nil
}
// sanitize replaces path-unsafe characters in a subject so it can be
// used in a /tmp filename. SPIFFE IDs contain `://` and `/`, both of
// which would confuse the shell. We collapse to `_`.
func sanitize(s string) string {
r := strings.NewReplacer("://", "-", "/", "_", ":", "_", " ", "_")
return r.Replace(s)
}
// shellQuote single-quotes a string for safe shell interpolation. It
// escapes embedded single-quotes via the standard '\” idiom (mirrors
// sshpush.shellQuote, kept local to avoid importing an unexported
// helper).
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
+365
View File
@@ -0,0 +1,365 @@
package stepca
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
// mockExec is a record-and-replay execer for the stepca.Client. It
// stores every command it received keyed by a substring match, so a
// test can assert "Init ran `step ca init`" without coupling to
// exact-flag ordering. Each entry maps a substring the test expects
// to appear in the command to the output that should be returned.
type mockExec struct {
// responses is a list of (substring, output, err). The first
// matching entry wins; an entry with an empty substring matches
// any command (catch-all).
responses []mockResp
// calls records every command the client issued, in order.
calls []string
}
type mockResp struct {
match string
out []byte
err error
}
func (m *mockExec) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
m.calls = append(m.calls, cmd)
for _, r := range m.responses {
if r.match == "" || strings.Contains(cmd, r.match) {
return r.out, r.err
}
}
return nil, nil
}
// newMockClient returns a Client wired to a mockExec and an ORCA_HOME
// under a temp dir (so paths.CACertPath() resolves to a writable path
// during Init tests).
func newMockClient(t *testing.T, lead string) (*Client, *mockExec) {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
mx := &mockExec{}
c := NewClient(nil, lead)
c.exec = mx
return c, mx
}
func containsCall(t *testing.T, mx *mockExec, want string) {
t.Helper()
for _, c := range mx.calls {
if strings.Contains(c, want) {
return
}
}
t.Errorf("no exec call contained %q; calls were:\n%s", want, strings.Join(mx.calls, "\n"))
}
func TestNewClient_Defaults(t *testing.T) {
tr := sshpush.NewTransport("/tmp/key", "/tmp/kh")
c := NewClient(tr, "lead:22")
if c.leadPeer != "lead:22" {
t.Errorf("leadPeer = %q", c.leadPeer)
}
if c.transport != tr {
t.Error("transport not stored")
}
if c.exec == nil {
t.Error("exec seam is nil")
}
}
func TestClient_Preflight_LeadUnset(t *testing.T) {
c, _ := newMockClient(t, "")
if err := c.Init(context.Background(), "n", "d", "a"); !errors.Is(err, ErrLeadUnset) {
t.Errorf("Init with empty lead: err = %v, want ErrLeadUnset", err)
}
if _, _, err := c.IssueServerCert(context.Background(), "p", nil); !errors.Is(err, ErrLeadUnset) {
t.Errorf("IssueServerCert: err = %v, want ErrLeadUnset", err)
}
if _, _, err := c.IssueSVID(context.Background(), "spiffe://orca/x", nil); !errors.Is(err, ErrLeadUnset) {
t.Errorf("IssueSVID: err = %v, want ErrLeadUnset", err)
}
if err := c.RenewServerCert(context.Background(), "p"); !errors.Is(err, ErrLeadUnset) {
t.Errorf("RenewServerCert: err = %v, want ErrLeadUnset", err)
}
if _, err := c.Fingerprint(context.Background()); !errors.Is(err, ErrLeadUnset) {
t.Errorf("Fingerprint: err = %v, want ErrLeadUnset", err)
}
}
func TestClient_Preflight_NilExec(t *testing.T) {
c := &Client{leadPeer: "lead:22"} // exec is nil
if err := c.Init(context.Background(), "n", "d", "a"); err == nil {
t.Fatal("Init with nil exec: expected error, got nil")
}
}
func TestInit_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
caPEM := []byte("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: nil},
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
}
if err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443"); err != nil {
t.Fatalf("Init: %v", err)
}
containsCall(t, mx, "step ca init --name 'orca'")
containsCall(t, mx, "--dns 'ca.orca.local'")
containsCall(t, mx, "--address ':8443'")
containsCall(t, mx, "--provisioner 'orca-admin'")
containsCall(t, mx, "--deployment-type standalone")
// Root CA mirrored to paths.CACertPath().
got, err := os.ReadFile(paths.CACertPath())
if err != nil {
t.Fatalf("read mirrored CA: %v", err)
}
if string(got) != string(caPEM) {
t.Errorf("mirrored CA = %q, want %q", got, caPEM)
}
}
func TestInit_StepCLIFails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
stepErr := errors.New("step: non-zero exit 1")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: stepErr},
}
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
if err == nil {
t.Fatal("Init: expected error, got nil")
}
if !strings.Contains(err.Error(), "stepca: init") {
t.Errorf("err = %v, want wrapped 'stepca: init'", err)
}
}
func TestInit_EmptyRootCA(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: nil},
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: nil, err: nil},
}
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
if err == nil {
t.Fatal("Init with empty root CA: expected error, got nil")
}
if !errors.Is(err, ErrStepCLI) {
t.Errorf("err = %v, want ErrStepCLI", err)
}
}
func TestIssueServerCert_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
certPEM := []byte("SERVER-CERT-PEM")
keyPEM := []byte("SERVER-KEY-PEM")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/tmp/orca-peer1.crt'", out: certPEM, err: nil},
{match: "cat '/tmp/orca-peer1.key'", out: keyPEM, err: nil},
{match: "rm -f", out: nil, err: nil},
}
gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"})
if err != nil {
t.Fatalf("IssueServerCert: %v", err)
}
if gotCert != string(certPEM) {
t.Errorf("cert = %q", gotCert)
}
if gotKey != string(keyPEM) {
t.Errorf("key = %q", gotKey)
}
containsCall(t, mx, "step ca certificate 'peer1'")
containsCall(t, mx, "--not-after '2160h'")
containsCall(t, mx, "--san 'peer1.orca.local'")
containsCall(t, mx, "--san '10.0.0.1'")
// Server cert path must NOT pin a provisioner (uses default).
for _, call := range mx.calls {
if strings.HasPrefix(call, "step ca certificate") && strings.Contains(call, "--provisioner") {
t.Errorf("server cert should not pin provisioner; cmd: %s", call)
}
}
}
func TestIssueSVID_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
spiffe := "spiffe://orca/ns/_defaults/job/web/alloc/0"
certPEM := []byte("SVID-CERT-PEM")
keyPEM := []byte("SVID-KEY-PEM")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
{match: "rm -f", out: nil, err: nil},
}
gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"})
if err != nil {
t.Fatalf("IssueSVID: %v", err)
}
if gotCert != string(certPEM) || gotKey != string(keyPEM) {
t.Errorf("cert/key mismatch")
}
containsCall(t, mx, "step ca certificate")
containsCall(t, mx, "--not-after '24h'")
containsCall(t, mx, "--provisioner 'orca-admin'")
// SPIFFE ID is both the subject AND a SAN.
containsCall(t, mx, "--san '"+spiffe+"'")
}
func TestIssueServerCert_StepFails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: errors.New("step: exit 1")},
}
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
if err == nil || !strings.Contains(err.Error(), "stepca: issue") {
t.Errorf("err = %v, want wrapped 'stepca: issue'", err)
}
}
func TestIssueServerCert_ReadCertFails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
{match: "cat '/tmp/orca-peer1.key'", out: nil, err: nil},
}
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
if err == nil || !strings.Contains(err.Error(), "read") {
t.Errorf("err = %v, want wrapped 'read'", err)
}
}
func TestIssueServerCert_EmptyCert(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: nil},
{match: "cat '/tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
{match: "rm -f", out: nil, err: nil},
}
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
if err == nil || !errors.Is(err, ErrStepCLI) {
t.Errorf("err = %v, want ErrStepCLI", err)
}
}
func TestRenewServerCert_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca renew", out: nil, err: nil},
}
if err := c.RenewServerCert(context.Background(), "peer1"); err != nil {
t.Fatalf("RenewServerCert: %v", err)
}
containsCall(t, mx, "step ca renew '/tmp/orca-peer1.crt' '/tmp/orca-peer1.key' --force")
}
func TestRenewServerCert_Fails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca renew", out: nil, err: errors.New("step: renew failed")},
}
err := c.RenewServerCert(context.Background(), "peer1")
if err == nil || !strings.Contains(err.Error(), "stepca: renew") {
t.Errorf("err = %v, want wrapped 'stepca: renew'", err)
}
}
func TestFingerprint_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step certificate fingerprint", out: []byte("a1b2c3d4e5f6\n"), err: nil},
}
fp, err := c.Fingerprint(context.Background())
if err != nil {
t.Fatalf("Fingerprint: %v", err)
}
if fp != "a1b2c3d4e5f6" {
t.Errorf("fp = %q, want a1b2c3d4e5f6 (trimmed)", fp)
}
containsCall(t, mx, "step certificate fingerprint '/etc/step-ca/certs/root_ca.crt'")
}
func TestFingerprint_Empty(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step certificate fingerprint", out: []byte(""), err: nil},
}
_, err := c.Fingerprint(context.Background())
if err == nil || !errors.Is(err, ErrStepCLI) {
t.Errorf("err = %v, want ErrStepCLI", err)
}
}
func TestFingerprint_Fails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step certificate fingerprint", out: nil, err: errors.New("ssh: exec failed")},
}
_, err := c.Fingerprint(context.Background())
if err == nil || !strings.Contains(err.Error(), "stepca: fingerprint") {
t.Errorf("err = %v, want wrapped 'stepca: fingerprint'", err)
}
}
func TestInit_MkdirFails(t *testing.T) {
// Point ORCA_HOME at a path that cannot be created under to
// force MkdirAll failure. We use a file as the parent.
dir := t.TempDir()
blocker := filepath.Join(dir, "block")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatalf("write blocker: %v", err)
}
t.Setenv("ORCA_HOME", filepath.Join(blocker, "sub"))
// Construct the client directly (not newMockClient, which
// resets ORCA_HOME to a fresh temp dir).
mx := &mockExec{}
caPEM := []byte("FAKE")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: nil},
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
}
c := NewClient(nil, "lead:22")
c.exec = mx
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
if err == nil {
t.Fatal("Init: expected mkdir error, got nil")
}
if !strings.Contains(err.Error(), "mkdir") {
t.Errorf("err = %v, want 'mkdir'", err)
}
}
func TestShellQuote(t *testing.T) {
got := shellQuote("a'b")
want := "'a'\\''b'"
if got != want {
t.Errorf("shellQuote = %q, want %q", got, want)
}
}
func TestSanitize(t *testing.T) {
cases := []struct{ in, want string }{
{"spiffe://orca/ns/_defaults/job/web/alloc/0",
"spiffe-orca_ns__defaults_job_web_alloc_0"},
{"plain-host", "plain-host"},
{"a b", "a_b"},
}
for _, tc := range cases {
if got := sanitize(tc.in); got != tc.want {
t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}