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:
@@ -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)) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user