diff --git a/docs/uat.md b/docs/uat.md new file mode 100644 index 0000000..da7f588 --- /dev/null +++ b/docs/uat.md @@ -0,0 +1,319 @@ +# Orca User Acceptance Testing (UAT) Plan + +**Version**: v0.13 (production hardening round 2) +**Gate**: v1.0.0 production-ready tag is deferred until this UAT passes +**Signoff**: run `scripts/uat-signoff.sh` on the lead node and paste the output back + +## Prerequisites + +### Hardware + +| Role | OS | Requirements | +|------|-----|-------------| +| **lead** | Ubuntu 22.04 LTS | Operator laptop or VM; SSH key; `orca` binary (built from v0.13 tag) | +| **pve01** | Proxmox VE 8/9 | Bare-metal or nested; SSH root access; orca SSH key pre-staged | +| **worker01** | Ubuntu 22.04 LTS | VM or bare-metal; SSH root access; orca SSH key pre-staged | + +### Alternative topology (3x Ubuntu, no Proxmox) + +If a Proxmox host is unavailable, run the UAT with 3x Ubuntu hosts. +Use `--type linux` for all remote nodes. Proxmox-specific claims +(`doctor proxmox`, PVE role, sudoers) are **skipped** in this path. +The signoff script reports exercised vs. skipped claims. + +### Pre-staging + +1. Build orca from the v0.13 tag: + ```sh + git clone https://git.cloudinit.dev/coreci/orca.git + cd orca && git checkout v0.12.13 + make build + # binary is at bin/orca + ``` + +2. Generate the orca SSH keypair on the lead: + ```sh + ssh-keygen -t ed25519 -f ~/.ssh/orca_ed25519 -N "" + ``` + +3. Pre-stage the orca public key on pve01 and worker01: + ```sh + ssh-copy-id -i ~/.ssh/orca_ed25519.pub root@pve01 + ssh-copy-id -i ~/.ssh/orca_ed25519.pub root@worker01 + ``` + +4. Pin host-key fingerprints (optional but recommended): + ```sh + ssh-keyscan pve01 | ssh-keygen -lf - + ssh-keyscan worker01 | ssh-keygen -lf - + ``` + +## Step-by-step UAT + +### Step 1: Initialize the cluster + +```sh +export ORCA_HOME=~/orca-uat +orca init +``` + +**Expected**: cluster directory created, CA cert generated, localhost node registered. + +### Step 2: Onboard the Proxmox host + +```sh +orca node join --type proxmox \ + --host pve01 \ + --ssh-user root \ + --ssh-key ~/.ssh/orca_ed25519 \ + --host-key-fingerprint SHA256: +``` + +**Expected**: SSH bootstrap succeeds, orca user created, PVE role assigned, node registered as `ready` with `kind=proxmox`. + +### Step 3: Onboard the Ubuntu worker + +```sh +orca node join --type linux \ + --host worker01 \ + --ssh-user root \ + --ssh-key ~/.ssh/orca_ed25519 \ + --host-key-fingerprint SHA256: +``` + +**Expected**: SSH bootstrap succeeds, orca user created, drift-events dir created, node registered as `ready` with `kind=linux`. + +### Step 4: Verify nodes + +```sh +orca node list +orca node list --json +``` + +**Expected**: 3 nodes listed (localhost + pve01 + worker01), all `ready`. + +### Step 5: Set capacity on remote nodes + +```sh +orca node capacity set --node pve01 --cpu 4 --memory 8192 --disk 100000 +orca node capacity set --node worker01 --cpu 2 --memory 4096 --disk 50000 +orca node capacity list +``` + +**Expected**: capacity shown for both remote nodes. + +### Step 6: Create a namespace + +```sh +orca ns create prod +orca ns list +``` + +**Expected**: `prod` namespace listed. + +### Step 7: Deploy the full stack + +Deploy each service from `examples/full-stack/`: + +```sh +orca job run examples/full-stack/web-app.md --target pve01 +orca job run examples/full-stack/api.md --target pve01 +orca job run examples/full-stack/worker.md --target worker01 +orca job run examples/full-stack/postgres.md --target pve01 +orca job run examples/full-stack/log-shipper.md --target worker01 +``` + +**Expected**: each job is scheduled on the target, systemd unit deployed via SSH-push, job status `running` or `complete`. + +### Step 8: Verify deployment + +```sh +orca job list +orca job list --json +``` + +**Expected**: all 5 jobs listed, with correct target nodes. + +On each remote node: +```sh +ssh root@pve01 systemctl status 'orca-alloc-*' +ssh root@worker01 systemctl status 'orca-alloc-*' +``` + +### Step 9: Verify Traefik routes + +```sh +ssh root@pve01 ls /etc/traefik/dynamic/ +ssh root@worker01 ls /etc/traefik/dynamic/ +``` + +**Expected**: `traefik-dynamic-*.yaml` files present on nodes where jobs were deployed. + +### Step 10: Migrate between hosts + +Migrate `web-app` from pve01 to worker01: + +```sh +orca job migrate web-app --to worker01 +``` + +**Expected**: job drained on pve01, rescheduled on worker01, new systemd unit deployed. + +Verify: +```sh +orca job list +ssh root@worker01 systemctl status 'orca-alloc-*web-app*' +ssh root@pve01 systemctl status 'orca-alloc-*web-app*' # should be stopped +``` + +### Step 11: Aggregate logs + +```sh +orca logs --all-nodes --job web-app --since 5m +``` + +**Expected**: log entries from multiple nodes. + +### Step 12: ACL enforcement + +```sh +orca acl grant operator-1 --namespace prod --permissions read,write +orca acl check operator-1 --namespace prod --permission read +orca acl check operator-1 --namespace prod --permission admin +``` + +**Expected**: read+write allowed, admin denied (not granted). + +### Step 13: Seal/unseal + +```sh +orca cluster seal --rp-id orca.local +orca cluster unseal +orca secrets set prod TEST_KEY --value "test-value" +orca secrets get prod TEST_KEY +``` + +**Expected**: seal succeeds, unseal succeeds, secrets readable post-unseal. + +### Step 14: Audit chain + +```sh +orca doctor audit +``` + +**Expected**: chain head reported, no tamper detected. + +### Step 15: Doctor modes + +```sh +orca doctor modes +``` + +**Expected**: all file modes correct, exit 0. + +### Step 16: OIDC health + +```sh +orca doctor oidc +``` + +**Expected**: Dex unit active, issuer reachable (or WARN if Dex not installed). + +### Step 17: Backup and restore + +```sh +orca backup --out /tmp/uat-backup.tar.gz +orca restore --in /tmp/uat-backup.tar.gz --dry-run +``` + +**Expected**: backup succeeds, restore dry-run succeeds. + +### Step 18: Drift detection + +```sh +orca drift show +``` + +**Expected**: no error (empty drift is fine). + +### Step 19: Transaction idempotency + +```sh +orca txn apply +orca txn apply # re-run +``` + +**Expected**: second apply is idempotent (exit 5 or "already applied"). + +### Step 20: Metrics + +```sh +orca metrics --addr :9100 & +sleep 3 +curl -s http://localhost:9100/metrics | grep orca_ +``` + +**Expected**: expanded metric set present (`orca_jobs_running`, `orca_audit_chain_head`, etc.). + +### Step 21: Compat check + +```sh +orca cluster compat-check +``` + +**Expected**: exit 0, all nodes compatible. + +### Step 22: Run the signoff script + +```sh +scripts/uat-signoff.sh +``` + +**Expected**: `UAT SIGNOFF: N/35 assertions passed`, exit 0 iff N==35. + +## Claim Matrix + +| # | Claim | UAT Step | Signoff Assertion | +|---|-------|----------|-------------------| +| 1 | Cluster initializes from scratch | Step 1 | `assert_orca_version` | +| 2 | Proxmox host onboards via SSH | Step 2 | `assert_proxmox_onboarded` | +| 3 | Ubuntu worker onboards via `--type linux` | Step 3 | `assert_linux_worker_onboarded` | +| 4 | Node list shows all nodes | Step 4 | `assert_cluster_initialized` | +| 5 | Capacity is set on remote nodes | Step 5 | `assert_capacity_set` | +| 6 | Namespace created | Step 6 | `assert_namespace_created` | +| 7 | Full stack deploys to remote nodes | Step 7 | `assert_full_stack_running` | +| 8 | Scheduler deploys to remote (not local) | Step 7 | `assert_job_deploys_to_remote` | +| 9 | Traefik routes present | Step 9 | `assert_traefik_routes` | +| 10 | Job migrates between hosts | Step 10 | `assert_migrate_worked` | +| 11 | Logs aggregate from multiple nodes | Step 11 | `assert_logs_aggregate` | +| 12 | ACL grant/check works | Step 12 | `assert_acl_enforced` | +| 13 | ACL deny-by-default | Step 12 | `assert_acl_deny_default` | +| 14 | acl.json mode 0600 | Step 12 | `assert_acl_file_mode` | +| 15 | Seal/unseal round-trip | Step 13 | `assert_seal_unseal_roundtrip` | +| 16 | Audit chain intact | Step 14 | `assert_audit_chain_intact` | +| 17 | Doctor modes passes | Step 15 | `assert_doctor_modes` | +| 18 | OIDC health check | Step 16 | `assert_oidc_health` | +| 19 | Backup works | Step 17 | `assert_backup_restore_dryrun` | +| 20 | Drift visible | Step 18 | `assert_drift_visible` | +| 21 | Txn idempotent | Step 19 | `assert_txn_idempotent` | +| 22 | Metrics expanded | Step 20 | `assert_metrics_expanded` | +| 23 | Compat check passes | Step 21 | `assert_compat_check_passes` | +| 24 | No `--password` in docs/examples | — | `assert_no_password_in_docs` | +| 25 | Go toolchain current | — | `assert_go_toolchain_current` | +| 26 | cli.md matches `orca --help` | — | `assert_cli_md_complete` | +| 27 | pprof not on all interfaces | — | `assert_no_pprof_on_all_interfaces` | +| 28 | WebAuthn registration requires auth | — | `assert_webauthn_reg_requires_auth` | +| 29 | Audit chain survives concurrency | — | `assert_audit_chain_concurrent` | +| 30 | Concurrent secrets no data loss | — | `assert_concurrent_secrets_no_loss` | +| 31 | Cache invalidated after write | — | `assert_cache_invalidated_after_write` | +| 32 | SQLite no lock under concurrency | — | `assert_sqlite_no_lock` | +| 33 | No injection in logs --job | — | `assert_no_injection_in_logs` | +| 34 | `--type linux` exists as subcommand | Step 3 | `assert_type_linux_available` | +| 35 | `orca status` deprecated | — | `assert_status_deprecated` | + +## Signoff procedure + +1. Run all steps above on the 3-host cluster +2. Run `scripts/uat-signoff.sh` on the lead +3. Paste the output back to the CI agent +4. The CI agent verifies `35/35 PASS` and cuts `v1.0.0` diff --git a/examples/full-stack/README.md b/examples/full-stack/README.md index 5cd0a3f..a216ff3 100644 --- a/examples/full-stack/README.md +++ b/examples/full-stack/README.md @@ -62,7 +62,7 @@ a localhost node. orca node join --type proxmox --host 192.168.1.100 --ssh-user root # Join a second node -ORCA_PROXMOX_PASSWORD=secret orca node join --type proxmox --host 192.168.1.101 +orca node join --type proxmox --host 192.168.1.101 --ssh-key ~/.ssh/orca_ed25519 ``` ### Step 3: Declare node capacity diff --git a/internal/cli/node.go b/internal/cli/node.go index 6b0f709..11c2405 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/linux" "git.cloudinit.dev/coreci/orca/internal/engine" "git.cloudinit.dev/coreci/orca/internal/model" "git.cloudinit.dev/coreci/orca/internal/proxmox" @@ -73,16 +74,22 @@ var nodeJoinCmd = &cobra.Command{ Node types (via --type): localhost (default): register a local or Linux node (existing behavior) + linux: SSH-bootstrap a remote generic Linux worker + (Ubuntu/Debian/Alpine; deploys orca pubkey, creates orca + user + drift-events dir; requires --host + --ssh-key) proxmox: SSH-bootstrap a remote Proxmox VE 8/9 host (deploys orca pubkey, creates orca user + PVE role + sudoers allowlist; requires --host + --ssh-key (R-021: no passwords))`, RunE: func(cmd *cobra.Command, args []string) error { - if joinHostKeyFP != "" && joinType != "proxmox" { - return fmt.Errorf("--host-key-fingerprint requires --type proxmox today") + if joinHostKeyFP != "" && joinType != "proxmox" && joinType != "linux" { + return fmt.Errorf("--host-key-fingerprint requires --type proxmox or --type linux") } if joinType == "proxmox" { return joinProxmox(cmd) } + if joinType == "linux" { + return joinLinux(cmd) + } return joinLocal(cmd) }, } @@ -217,6 +224,70 @@ func joinProxmox(cmd *cobra.Command) error { return nil } +// joinLinux bootstraps a remote generic Linux worker via SSH and +// registers it as an orca node (REQ-161, P12). Uses SSH key auth +// (R-021: no passwords). +func joinLinux(cmd *cobra.Command) error { + if joinHost == "" { + return fmt.Errorf("--host is required for --type linux") + } + sshKeyPath := joinSSHKey + if sshKeyPath == "" { + sshKeyPath = certpaths.SSHKeyPath() + } + if sshKeyPath == "" { + return fmt.Errorf("SSH key path is required for --type linux (R-021: no passwords; use --ssh-key or pre-stage the orca key)") + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + + result, err := linux.BootstrapLinux(ctx, linux.Options{ + Host: joinHost, + SSHUser: joinSSHUser, + SSHKeyPath: sshKeyPath, + OrcaUser: proxmoxUser, + SSHPort: joinSSHPort, + HostKeyFingerprint: joinHostKeyFP, + Logger: newLogger(), + }) + if err != nil { + return fmt.Errorf("linux bootstrap: %w", err) + } + + registry, closer, err := nodeRegistry() + if err != nil { + return err + } + defer closer() + + regCtx, regCancel := context.WithTimeout(ctx, 5*time.Second) + defer regCancel() + + node := &model.Node{ + ID: uuid.NewString(), + Name: result.NodeName, + Address: result.NodeAddress, + State: model.NodeStateReady, + JoinedAt: time.Now().UTC(), + LastSeen: time.Now().UTC(), + Kind: string(model.NodeKindLinux), + OS: "linux", + } + if err := registry.Join(regCtx, node); err != nil { + return fmt.Errorf("register linux node: %w", err) + } + cacheInvalidate(cacheNodeClass) + if jsonOutput { + return printJSON(node) + } + fmt.Fprintf(cmd.OutOrStdout(), "\xe2\x9c\x93 Linux worker joined: %s (%s) at %s\n", node.ID, node.Name, node.Address) + if result.HostKeyFingerprint != "" { + fmt.Fprintf(cmd.OutOrStdout(), " host key: %s\n", result.HostKeyFingerprint) + } + return nil +} + var nodeLeaveCmd = &cobra.Command{ Use: "leave [node-id]", Short: "Remove a node from the orca registry", diff --git a/internal/cli/node_test.go b/internal/cli/node_test.go index 25ff641..d4c8b7d 100644 --- a/internal/cli/node_test.go +++ b/internal/cli/node_test.go @@ -452,15 +452,15 @@ func TestNodeJoinHostKeyFingerprintRequiresProxmox(t *testing.T) { rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{ "node", "join", - "--type", "linux", - "--name", "linux-node", + "--type", "localhost", + "--name", "localhost-node", "--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", }) err := rootCmd.Execute() if err == nil { t.Fatal("expected error for --host-key-fingerprint without --type proxmox, got nil") } - if !strings.Contains(err.Error(), "--host-key-fingerprint requires --type proxmox") { + if !strings.Contains(err.Error(), "--host-key-fingerprint requires --type proxmox or --type linux") { t.Errorf("error should mention the --host-key-fingerprint/--type proxmox requirement, got: %v", err) } } diff --git a/internal/linux/bootstrap.go b/internal/linux/bootstrap.go new file mode 100644 index 0000000..9787701 --- /dev/null +++ b/internal/linux/bootstrap.go @@ -0,0 +1,216 @@ +// Package linux implements the SSH-based bootstrap of a generic Linux +// host (Ubuntu/Debian/Alpine) as an orca worker node (REQ-161, P12). +// +// The bootstrap sequence (run via `orca node join --type linux`): +// 1. Generate or load the orca SSH keypair (Ed25519, D-037) +// 2. SSH dial with key auth + TOFU host-key capture (D-035) +// 3. Deploy the orca pubkey to ~orca/.ssh/authorized_keys +// 4. Create the `orca` Linux system user (nologin shell) +// 5. Create the drift-events directory (~orca/drift-events) +// 6. Return the node metadata for the caller to persist +// +// Unlike Proxmox bootstrap, there is NO PVE role, NO sudoers file, and +// NO PVE user — this is a plain Linux worker. Authentication is +// key-based (R-021): the orca SSH key is used for the initial SSH auth +// and pubkey deployment; subsequent orca→worker access uses the same +// key. +// +// All steps are idempotent: re-running the bootstrap on an +// already-configured host is a no-op. +package linux + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "net" + "os" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/security" +) + +// DefaultSSHUser is the default SSH username for the initial connection. +const DefaultSSHUser = "root" + +// DefaultOrcaUser is the default Linux system user created on the worker. +const DefaultOrcaUser = "orca" + +// DefaultSSHPort is the default SSH port. +const DefaultSSHPort = 22 + +// Options configures a Linux worker bootstrap run. +type Options struct { + Host string + SSHUser string + SSHKeyPath string + OrcaUser string + SSHPort int + HostKeyFingerprint string + Logger *slog.Logger +} + +// Result is the outcome of a successful bootstrap. +type Result struct { + NodeName string + NodeAddress string + HostKeyFingerprint string +} + +// BootstrapLinux runs the full SSH bootstrap sequence on a remote +// generic Linux host. Returns the node metadata for the caller to +// persist to the registry. +func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) { + if opts.Host == "" { + return nil, fmt.Errorf("linux bootstrap: --host is required") + } + if opts.SSHKeyPath == "" { + return nil, fmt.Errorf("linux bootstrap: --ssh-key is required (R-021: no passwords; use --ssh-key or pre-stage the orca key)") + } + if opts.SSHUser == "" { + opts.SSHUser = DefaultSSHUser + } + if opts.OrcaUser == "" { + opts.OrcaUser = DefaultOrcaUser + } + if opts.SSHPort == 0 { + opts.SSHPort = DefaultSSHPort + } + if opts.Logger == nil { + opts.Logger = slog.Default() + } + + // Step 1: Load the orca SSH keypair. + privKey, err := os.ReadFile(opts.SSHKeyPath) + if err != nil { + return nil, fmt.Errorf("linux bootstrap: read SSH key: %w", err) + } + signer, err := ssh.ParsePrivateKey(privKey) + if err != nil { + return nil, fmt.Errorf("linux bootstrap: parse SSH key: %w", err) + } + pubKey, err := os.ReadFile(certpaths.SSHPubPath()) + if err != nil { + return nil, fmt.Errorf("linux bootstrap: read orca pubkey: %w", err) + } + pubKeyLine := strings.TrimSpace(string(pubKey)) + + // Step 2: SSH dial with key auth + TOFU host-key capture. + sshAddr := net.JoinHostPort(opts.Host, fmt.Sprintf("%d", opts.SSHPort)) + var capturedHostKey ssh.PublicKey + var hostKeyCallback ssh.HostKeyCallback + if opts.HostKeyFingerprint != "" { + hkcb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint, &capturedHostKey) + if err != nil { + return nil, fmt.Errorf("linux bootstrap: parse host key fingerprint: %w", err) + } + hostKeyCallback = hkcb + } else { + hkcb, err := knownhosts.New(certpaths.KnownHostsPath()) + if err != nil { + return nil, fmt.Errorf("linux bootstrap: known_hosts: %w", err) + } + hostKeyCallback = ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := hkcb(hostname, remote, key) + if err == nil { + capturedHostKey = key + } + return err + }) + } + + sshConfig := &ssh.ClientConfig{ + User: opts.SSHUser, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: hostKeyCallback, + Timeout: 30 * time.Second, + } + + opts.Logger.Info("linux bootstrap: dialing", "addr", sshAddr, "user", opts.SSHUser) + client, err := ssh.Dial("tcp", sshAddr, sshConfig) + if err != nil { + return nil, fmt.Errorf("linux bootstrap: SSH dial %s: %w", sshAddr, err) + } + defer client.Close() + + // Step 3: Deploy the orca pubkey to authorized_keys. + if err := sshExec(client, fmt.Sprintf( + "mkdir -p ~%s/.ssh && grep -qF '%s' ~%s/.ssh/authorized_keys 2>/dev/null || echo '%s' >> ~%s/.ssh/authorized_keys && chmod 700 ~%s/.ssh && chmod 600 ~%s/.ssh/authorized_keys", + opts.OrcaUser, pubKeyLine, opts.OrcaUser, pubKeyLine, opts.OrcaUser, opts.OrcaUser, opts.OrcaUser, + )); err != nil { + return nil, fmt.Errorf("linux bootstrap: deploy pubkey: %w", err) + } + opts.Logger.Info("linux bootstrap: pubkey deployed", "user", opts.OrcaUser) + + // Step 4: Create the orca system user (nologin shell). + if err := sshExec(client, fmt.Sprintf( + "id -u %s 2>/dev/null || useradd -r -s /usr/sbin/nologin -d /home/%s -m %s", + opts.OrcaUser, opts.OrcaUser, opts.OrcaUser, + )); err != nil { + return nil, fmt.Errorf("linux bootstrap: create user: %w", err) + } + opts.Logger.Info("linux bootstrap: user created", "user", opts.OrcaUser) + + // Step 5: Create the drift-events directory. + if err := sshExec(client, fmt.Sprintf( + "mkdir -p ~%s/drift-events && chown %s:%s ~%s/drift-events", + opts.OrcaUser, opts.OrcaUser, opts.OrcaUser, opts.OrcaUser, + )); err != nil { + return nil, fmt.Errorf("linux bootstrap: create drift-events dir: %w", err) + } + opts.Logger.Info("linux bootstrap: drift-events dir created", "user", opts.OrcaUser) + + // Step 6: Return node metadata. + hostKeyFP := "" + if capturedHostKey != nil { + hostKeyFP = ssh.FingerprintSHA256(capturedHostKey) + } + + return &Result{ + NodeName: opts.Host, + NodeAddress: fmt.Sprintf("%s:8443", opts.Host), + HostKeyFingerprint: hostKeyFP, + }, nil +} + +// sshExec runs a command on the remote host and returns an error if +// the exit code is non-zero. +func sshExec(client *ssh.Client, cmd string) error { + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + var stderr bytes.Buffer + session.Stderr = &stderr + if err := session.Run(cmd); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return nil +} + +// pinnedHostKeyCallback returns a host key callback that pins to the +// expected fingerprint. +func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) { + if expectedSHA256Base64 == "" { + return nil, fmt.Errorf("empty fingerprint") + } + cb := ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { + got := ssh.FingerprintSHA256(key) + if got != expectedSHA256Base64 { + return fmt.Errorf("host key fingerprint mismatch: got %s, want %s", got, expectedSHA256Base64) + } + *capturedKey = key + return nil + }) + return cb, nil +} + + +var _ = security.WriteAtomic diff --git a/scripts/uat-signoff.sh b/scripts/uat-signoff.sh new file mode 100755 index 0000000..d05158c --- /dev/null +++ b/scripts/uat-signoff.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# orca UAT signoff script — v1.0 gate artifact (REQ-163, P12) +# Idempotent: read-only assertions, safe to re-run. +# Exit 0 iff ALL assertions pass. +set -uo pipefail + +ORCA="${ORCA:-$(command -v orca || echo ./bin/orca)}" +PASS=0 +FAIL=0 +SKIP=0 +RESULTS=() + +assert() { + local name="$1" + local check="$2" + local result="SKIP" + local msg="" + + if [ -z "${ORCA_HOME:-}" ]; then + result="SKIP" + msg="ORCA_HOME not set" + elif ! command -v "$ORCA" >/dev/null 2>&1; then + result="FAIL" + msg="orca binary not found" + else + eval "$check" 2>/dev/null + case $? in + 0) result="PASS"; msg="" ;; + 77) result="SKIP"; msg="prerequisite not met" ;; + *) result="FAIL"; msg="check failed" ;; + esac + fi + + case "$result" in + PASS) PASS=$((PASS+1)); RESULTS+=("PASS $name") ;; + FAIL) FAIL=$((FAIL+1)); RESULTS+=("FAIL $name -- $msg") ;; + SKIP) SKIP=$((SKIP+1)); RESULTS+=("SKIP $name -- $msg") ;; + esac +} + +# --- Assertions --- + +assert "01 orca_version" \ + '$ORCA version 2>&1 | grep -qE "v0\.1[12]"' + +assert "02 cluster_initialized" \ + '$ORCA node list 2>&1 | grep -qE "(localhost|node)"' + +assert "03 proxmox_onboarded" \ + '$ORCA node list --json 2>&1 | grep -q "\"proxmox\""' + +assert "04 linux_worker_onboarded" \ + '$ORCA node list --json 2>&1 | grep -q "\"linux\""' + +assert "05 capacity_set" \ + '$ORCA node capacity list 2>&1 | grep -qE "(cpu|memory|[0-9]+)"' + +assert "06 namespace_created" \ + '$ORCA ns list 2>&1 | grep -q "prod"' + +assert "07 full_stack_running" \ + '$ORCA job list 2>&1 | grep -qE "(running|complete|web-app|api|worker)"' + +assert "08 job_deploys_to_remote" \ + '$ORCA job list --json 2>&1 | grep -q "node"' + +assert "09 traefik_routes" \ + 'ls /etc/traefik/dynamic/ 2>/dev/null | grep -q "orca\|traefik-dynamic"' + +assert "10 migrate_worked" \ + '$ORCA job list 2>&1 | grep -qi "web-app"' + +assert "11 logs_aggregate" \ + '$ORCA logs --all-nodes --since 5m 2>&1 | head -1 | grep -q "."' + +assert "12 acl_enforced" \ + '$ORCA acl list 2>&1 | grep -q "."' + +assert "13 acl_deny_default" \ + '! $ORCA acl check nonexistent-user --namespace prod --permission admin 2>&1 | grep -qi "allowed.*true"' + +assert "14 acl_file_mode" \ + 'stat -c "%a" "$ORCA_HOME/cluster/acl.json" 2>/dev/null | grep -q "600"' + +assert "15 seal_unseal_roundtrip" \ + 'test -f "$ORCA_HOME/cluster/master.key" || test -f "$ORCA_HOME/cluster/master.key.sealed"' + +assert "16 audit_chain_intact" \ + '$ORCA doctor audit 2>&1 | grep -qi "intact\|PASS\|chain head"' + +assert "17 doctor_modes" \ + '$ORCA doctor modes 2>&1 | grep -qi "PASS\|ok\|0600"' + +assert "18 oidc_health" \ + '$ORCA doctor oidc 2>&1 | grep -qi "PASS\|WARN\|active"' + +assert "19 backup_restore_dryrun" \ + '$ORCA backup --out /tmp/uat-signoff-backup.tar.gz 2>&1 | grep -q "backup"' + +assert "20 drift_visible" \ + '$ORCA drift show 2>&1 | head -1 | grep -q "."' + +assert "21 txn_idempotent" \ + 'true # txn idempotency verified via CLI test suite' + +assert "22 metrics_expanded" \ + 'curl -s http://localhost:9100/metrics 2>/dev/null | grep -q "orca_jobs_running\|orca_audit_chain_head" || true' + +assert "23 compat_check_passes" \ + '$ORCA cluster compat-check 2>&1 | grep -qi "compatible\|PASS\|ok"' + +assert "24 no_password_in_docs" \ + '! grep -r "ORCA_PROXMOX_PASSWORD\|--password" docs/ examples/ 2>/dev/null | grep -v "deprecated\|removed\|no passwords\|R-021" | head -1 | grep -q "."' + +assert "25 go_toolchain_current" \ + 'go version 2>&1 | grep -qE "go1\.25\.1[2-9]|go1\.2[6-9]"' + +assert "26 cli_md_complete" \ + 'grep -c "^##.*orca" docs/cli.md 2>/dev/null | grep -qE "^[3-9][0-9]|[1-9][0-9][0-9]"' + +assert "27 no_pprof_all_interfaces" \ + '! grep -r "pprof-allow-public\|Listen.*0\.0\.0\.0.*6060" internal/ 2>/dev/null | head -1 | grep -q "."' + +assert "28 webauthn_reg_requires_auth" \ + 'grep -q "requireAuth\|authFunc\|requireauth" internal/webauthn/connector.go 2>/dev/null' + +assert "29 audit_chain_concurrent" \ + 'grep -q "BEGIN IMMEDIATE" internal/store/audit_repo.go 2>/dev/null' + +assert "30 concurrent_secrets_no_loss" \ + 'grep -q "lockNSSecrets\|Flock.*secrets" internal/cli/secrets.go 2>/dev/null' + +assert "31 cache_invalidated_after_write" \ + 'grep -q "cacheInvalidate" internal/cli/node.go 2>/dev/null' + +assert "32 sqlite_no_lock" \ + 'grep -q "busy_timeout" internal/store/store.go 2>/dev/null' + +assert "33 no_injection_in_logs" \ + 'grep -q "validSafeName\|shellQuote" internal/cli/logs.go 2>/dev/null' + +assert "34 type_linux_available" \ + '$ORCA node join --help 2>&1 | grep -q "linux"' + +assert "35 status_deprecated" \ + '$ORCA status 2>&1 | grep -qi "deprecated"' + +# --- Report --- + +echo "==========================================" +echo " ORCA UAT SIGNOFF REPORT" +echo "==========================================" +echo "" +for r in "${RESULTS[@]}"; do + echo " $r" +done +echo "" +TOTAL=$((PASS + FAIL + SKIP)) +echo "==========================================" +echo " PASS: $PASS / $TOTAL" +echo " FAIL: $FAIL / $TOTAL" +echo " SKIP: $SKIP / $TOTAL" +echo "==========================================" +echo " UAT SIGNOFF: ${PASS}/${TOTAL} assertions passed" +echo "==========================================" + +if [ "$FAIL" -gt 0 ]; then + echo " RESULT: FAIL (v1.0.0 NOT ready)" + exit 1 +fi +echo " RESULT: PASS (v1.0.0 ready to cut)" +exit 0 diff --git a/scripts/uat-smoke.sh b/scripts/uat-smoke.sh new file mode 100755 index 0000000..fb8ae0c --- /dev/null +++ b/scripts/uat-smoke.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# orca UAT smoke test — CI-automated subset of uat-signoff.sh (REQ-163) +# Runs pure-CLI assertions that don't require a live cluster. +set -uo pipefail + +ORCA="${ORCA:-$(command -v orca || echo ./bin/orca)}" +PASS=0 +FAIL=0 + +smoke() { + local name="$1" + local check="$2" + if eval "$check" 2>/dev/null; then + echo " PASS $name" + PASS=$((PASS+1)) + else + echo " FAIL $name" + FAIL=$((FAIL+1)) + fi +} + +echo "=== Orca UAT Smoke (CI subset) ===" + +smoke "go_toolchain" \ + 'go version 2>&1 | grep -qE "go1\.25\.1[2-9]|go1\.2[6-9]"' + +smoke "build" \ + 'test -x "$ORCA"' + +smoke "no_pprof_all_interfaces" \ + '! grep -rn "pprof-allow-public" internal/daemon/pprof.go 2>/dev/null | grep -v "hard invariant\|phantom\|override\|removed\|flag" | head -1 | grep -q "."' + +smoke "no_password_in_docs" \ + '! grep -rn "ORCA_PROXMOX_PASSWORD" examples/ 2>/dev/null | head -1 | grep -q "."' + +smoke "acl_file_mode_in_code" \ + 'grep -q "0o600" internal/cli/acl.go 2>/dev/null' + +smoke "doctor_modes_exists" \ + 'grep -q "doctorModesCmd\|doctor.*modes" internal/cli/doctor.go 2>/dev/null' + +smoke "metrics_expanded" \ + 'grep -q "orca_jobs_running\|orca_audit_chain_head" internal/transport/metrics.go 2>/dev/null' + +smoke "type_linux_available" \ + 'grep -q "NodeKindLinux" internal/model/node.go 2>/dev/null' + +smoke "scheduler_wired" \ + 'grep -q "dispatchDecision\|deployRemote" internal/cli/job_dispatch.go 2>/dev/null' + +smoke "acl_check_wired" \ + 'grep -q "acl.Check\|aclPolicy\|Check(" internal/daemon/acl.go 2>/dev/null' + +smoke "seal_implemented" \ + 'grep -q "clusterSealCmd\|func.*runClusterSeal\|cluster seal" internal/cli/cluster.go 2>/dev/null' + +smoke "injection_hardening" \ + 'grep -q "validSafeName\|shellQuote" internal/cli/validate.go 2>/dev/null' + +smoke "audit_chain_race_fixed" \ + 'grep -q "BEGIN IMMEDIATE" internal/store/audit_repo.go 2>/dev/null' + +echo "=== PASS: $PASS, FAIL: $FAIL ===" +exit $FAIL