// Package txn implements orca's transactional control-plane update // mechanism (P10a, v0.11 milestone; REQ-075, REQ-079; gates C-09, C-23). // // The model is ArgoCD-style desired-state + lead-applier: // // - RenderBundle marshals a desired-state object to JSON, computes a // content-addressed TxnID (T- + first 16 hex chars of SHA-256 of // the JSON), and generates apply.sh / verify.sh / rollback.sh // scripts plus a signed manifest (HMAC-SHA256 under the cluster // master key). The bundle is self-contained and reproducible: the // same desired-state + key always yields the same TxnID and the // same scripts. // - Stage SCPs the bundle to the lead peer's /run/orca/txns// // directory using the sshpush transport (idempotent writes). // - Apply runs apply.sh on the lead (idempotent: re-running an // already-applied txn is a no-op), then verify.sh. On verify // failure it runs rollback.sh and returns an error. The // namespace-scoped vs cluster-wide distinction (C-23) is enforced // by the caller via ApplyOptions.Namespace and the lead-side // orca-pull.sh script. // // The package never logs key material. slog calls carry only metadata. package txn import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "os" "strings" "time" "git.cloudinit.dev/coreci/orca/internal/sshpush" ) const ( txnIDPrefix = "T-" txnIDHexLen = 16 remoteTxnRoot = "/run/orca/txns" ) const ( fileDesiredState = "desired-state.json" fileApply = "apply.sh" fileVerify = "verify.sh" fileRollback = "rollback.sh" fileManifest = "manifest.json" fileManifestSig = "manifest.sig" fileApplied = ".applied" ) type TxnID string func (id TxnID) String() string { return string(id) } type ManifestEntry struct { Name string `json:"name"` SHA256 string `json:"sha256"` } type Manifest struct { TxnID TxnID `json:"txn_id"` Timestamp string `json:"timestamp"` Files []ManifestEntry `json:"files"` } type Bundle struct { ID TxnID DesiredState json.RawMessage ApplyScript []byte VerifyScript []byte RollbackScript []byte Manifest []byte ManifestSig []byte } type ApplyOptions struct { Force bool AcknowledgeRisk bool Yes bool Namespace string Timeout time.Duration // Authorize is called before the apply runs (P04, C-44). It must // return the verified operator identity (e.g. the OIDC sub) and a // nil error to authorize the apply; a non-nil error aborts the // apply with a 403-equivalent. When nil, no authorization is // performed (legacy/compat for tests that call Apply directly). // The CLI wires this to identity.VerifyOperatorToken, which // validates $ORCA_OIDC_TOKEN against the issuer's JWKS. Authorize func(ctx context.Context) (actor string, err error) } // Transport is the SSH-push surface the txn package needs: writing // files idempotently and running remote commands. *sshpush.Transport // satisfies it; tests substitute a mock to assert staging and apply // behavior without a real SSH server (same pattern as // internal/emitter.AtomicWriter). type Transport interface { WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) } var ( ErrSignatureMismatch = errors.New("txn: manifest signature mismatch") ErrNotApplied = errors.New("txn: not applied") ErrAlreadyApplied = errors.New("txn: already applied") ErrClusterWideRequiresForce = errors.New("cluster-wide txn requires --force") ErrClusterWideRequiresAck = errors.New("cluster-wide --force requires --i-understand-the-risk (or --yes)") ) func RenderBundle(desiredState any, masterKey []byte) (*Bundle, error) { if len(masterKey) == 0 { return nil, fmt.Errorf("txn: master key is empty") } data, err := json.MarshalIndent(desiredState, "", " ") if err != nil { return nil, fmt.Errorf("txn: marshal desired state: %w", err) } var norm any if err := json.Unmarshal(data, &norm); err != nil { return nil, fmt.Errorf("txn: normalize desired state: %w", err) } canonical, err := json.Marshal(norm) if err != nil { return nil, fmt.Errorf("txn: re-marshal desired state: %w", err) } id, err := computeTxnID(canonical) if err != nil { return nil, err } ts := time.Now().UTC().Format(time.RFC3339Nano) apply := renderApplyScript(id) verify := renderVerifyScript(id) rollback := renderRollbackScript(id) manifest, err := buildManifest(id, ts, canonical, apply, verify, rollback) if err != nil { return nil, err } sig := signManifest(manifest, masterKey) slog.Info("txn bundle rendered", "txn_id", id, "desired_bytes", len(canonical)) return &Bundle{ ID: id, DesiredState: canonical, ApplyScript: apply, VerifyScript: verify, RollbackScript: rollback, Manifest: manifest, ManifestSig: sig, }, nil } func computeTxnID(data []byte) (TxnID, error) { sum := sha256.Sum256(data) h := hex.EncodeToString(sum[:]) if len(h) < txnIDHexLen { return "", fmt.Errorf("txn: sha256 hex too short") } return TxnID(txnIDPrefix + h[:txnIDHexLen]), nil } func sha256Hex(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } func buildManifest(id TxnID, ts string, desired, apply, verify, rollback []byte) ([]byte, error) { m := Manifest{ TxnID: id, Timestamp: ts, Files: []ManifestEntry{ {Name: fileDesiredState, SHA256: sha256Hex(desired)}, {Name: fileApply, SHA256: sha256Hex(apply)}, {Name: fileVerify, SHA256: sha256Hex(verify)}, {Name: fileRollback, SHA256: sha256Hex(rollback)}, }, } out, err := json.MarshalIndent(m, "", " ") if err != nil { return nil, fmt.Errorf("txn: marshal manifest: %w", err) } return out, nil } func signManifest(manifest, masterKey []byte) []byte { mac := hmac.New(sha256.New, masterKey) mac.Write(manifest) return []byte(hex.EncodeToString(mac.Sum(nil))) } func VerifyManifestSignature(manifest, sigHex, masterKey []byte) error { if len(masterKey) == 0 { return fmt.Errorf("txn: master key is empty") } mac := hmac.New(sha256.New, masterKey) mac.Write(manifest) got := mac.Sum(nil) want, err := hex.DecodeString(string(sigHex)) if err != nil { return fmt.Errorf("txn: decode manifest signature: %w", err) } if !hmac.Equal(got, want) { return ErrSignatureMismatch } return nil } func remoteTxnDir(id TxnID) string { return remoteTxnRoot + "/" + string(id) } func Stage(bundle *Bundle, leadPeer string, transport Transport) error { if bundle == nil { return fmt.Errorf("txn: nil bundle") } if leadPeer == "" { return fmt.Errorf("txn: lead peer is empty") } if transport == nil { return fmt.Errorf("txn: nil transport") } dir := remoteTxnDir(bundle.ID) ctx := context.Background() files := []struct { name string content []byte mode os.FileMode }{ {fileDesiredState, bundle.DesiredState, 0o644}, {fileApply, bundle.ApplyScript, 0o755}, {fileVerify, bundle.VerifyScript, 0o755}, {fileRollback, bundle.RollbackScript, 0o755}, {fileManifest, bundle.Manifest, 0o644}, {fileManifestSig, bundle.ManifestSig, 0o644}, } for _, f := range files { path := dir + "/" + f.name if _, err := transport.WriteFileIdempotent(ctx, leadPeer, path, f.content, f.mode); err != nil { return fmt.Errorf("txn: stage %s: %w", f.name, err) } } slog.Info("txn staged", "txn_id", bundle.ID, "peer", leadPeer, "dir", dir) return nil } // ErrUnauthorized is returned when the operator OIDC token is // missing or invalid (P04, C-44). The apply is refused. var ErrUnauthorized = errors.New("txn: operator not authorized (ORCA_OIDC_TOKEN missing or invalid)") func Apply(ctx context.Context, txnID TxnID, leadPeer string, transport Transport, opts ApplyOptions) error { if transport == nil { return fmt.Errorf("txn: nil transport") } if leadPeer == "" { return fmt.Errorf("txn: lead peer is empty") } // P04 (C-44): validate the operator OIDC token before applying any // state change. The CLI wires opts.Authorize to // identity.VerifyOperatorToken, which checks $ORCA_OIDC_TOKEN // against the issuer's JWKS. When opts.Authorize is nil (legacy // test path), this check is skipped. if opts.Authorize != nil { actor, err := opts.Authorize(ctx) if err != nil { slog.Warn("txn apply refused (unauthorized)", slog.String("txn_id", string(txnID)), slog.String("peer", leadPeer), slog.String("error", err.Error())) return fmt.Errorf("txn: apply %s: %w: %v", txnID, ErrUnauthorized, err) } slog.Info("txn apply authorized", slog.String("txn_id", string(txnID)), slog.String("actor", actor)) } dir := remoteTxnDir(txnID) pull := dir + "/orca-pull.sh" if opts.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, opts.Timeout) defer cancel() } cmd := fmt.Sprintf("bash %s --txn-dir %s", pull, shellQuote(dir)) if opts.Namespace != "" { cmd += fmt.Sprintf(" --namespace %s", shellQuote(opts.Namespace)) } else { if !opts.Force { return fmt.Errorf("txn: cluster-wide txn requires --force (C-23): %w", ErrClusterWideRequiresForce) } if !opts.AcknowledgeRisk && !opts.Yes { return fmt.Errorf("txn: cluster-wide --force requires --i-understand-the-risk (or --yes): %w", ErrClusterWideRequiresAck) } cmd += " --force" if opts.AcknowledgeRisk { cmd += " --i-understand-the-risk" } if opts.Yes { cmd += " --yes" } } out, err := transport.Exec(ctx, leadPeer, cmd) if err != nil { if isExitCode(err, 5) { slog.Info("txn already applied (no-op)", "txn_id", txnID, "peer", leadPeer) return ErrAlreadyApplied } return fmt.Errorf("txn: apply %s on %s: %w (output: %s)", txnID, leadPeer, err, string(out)) } slog.Info("txn applied", "txn_id", txnID, "peer", leadPeer, "output", string(out)) return nil } func isExitCode(err error, code int) bool { if err == nil { return false } return errors.Is(err, sshpush.ErrPermanent) && strings.Contains(err.Error(), fmt.Sprintf("exit %d", code)) } func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } func renderApplyScript(id TxnID) []byte { return []byte(`#!/usr/bin/env bash # apply.sh — orca txn ` + string(id) + ` (auto-generated; do not edit). # Idempotently writes the desired state to disk. Re-running after a # successful apply is a no-op (checks .applied marker). set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STATE="$DIR/` + fileDesiredState + `" MARKER="$DIR/` + fileApplied + `" if [ -f "$MARKER" ]; then echo "already-applied" exit 0 fi if command -v python3 >/dev/null 2>&1; then python3 - "$STATE" <<'PYEOF' import json, os, sys state_path = sys.argv[1] with open(state_path) as f: artifacts = json.load(f) if isinstance(artifacts, dict): artifacts = [artifacts] # REQ-121/F5: path allowlist. Only orca-managed paths may be written. # This prevents a compromised manifest from overwriting arbitrary # system files (e.g. /etc/shadow, /root/.ssh/authorized_keys). ALLOWED_PREFIXES = ( "/etc/orca/", "/etc/traefik/orca", "/etc/traefik/dynamic/orca", "/etc/systemd/system/orca-", "/etc/nftables.d/orca", "/etc/syncthing/orca", ) # Resolve symlinks + normalize to catch ../ traversal attempts. def path_allowed(p): if not p: return False # Reject any path containing .. (path traversal). if ".." in p.split("/"): return False # Reject paths that are not absolute (relative could land anywhere). if not p.startswith("/"): return False norm = os.path.normpath(p) for prefix in ALLOWED_PREFIXES: if norm == prefix or norm.startswith(prefix): return True return False for a in artifacts: path = a.get("path") if not path: continue if not path_allowed(path): sys.stderr.write("apply: refusing to write disallowed path: %s\n" % path) sys.exit(7) content = a.get("content", "") mode = a.get("mode", "0644") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as fh: fh.write(content) try: m = int(mode, 8) os.chmod(path, m) except (ValueError, TypeError): pass PYEOF fi touch "$MARKER" echo "applied" `) } func renderVerifyScript(id TxnID) []byte { return []byte(`#!/usr/bin/env bash # verify.sh — orca txn ` + string(id) + ` (auto-generated; do not edit). # Verifies the applied state matches desired-state.json. set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STATE="$DIR/` + fileDesiredState + `" if [ ! -f "$STATE" ]; then echo "verify: missing desired-state" >&2 exit 1 fi if command -v python3 >/dev/null 2>&1; then python3 - "$STATE" <<'PYEOF' import json, os, sys state_path = sys.argv[1] with open(state_path) as f: artifacts = json.load(f) if isinstance(artifacts, dict): artifacts = [artifacts] ok = True for a in artifacts: path = a.get("path") if not path: continue want = a.get("content", "") if not os.path.exists(path): print("verify: missing %s" % path, file=sys.stderr) ok = False continue with open(path) as fh: got = fh.read() if got != want: print("verify: mismatch %s" % path, file=sys.stderr) ok = False sys.exit(0 if ok else 1) PYEOF else echo "verify: python3 missing, cannot verify" >&2 exit 1 fi echo "verified" `) } func renderRollbackScript(id TxnID) []byte { return []byte(`#!/usr/bin/env bash # rollback.sh — orca txn ` + string(id) + ` (auto-generated; do not edit). # Reverts the apply by removing the .applied marker and the written files. set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STATE="$DIR/` + fileDesiredState + `" MARKER="$DIR/` + fileApplied + `" rm -f "$MARKER" if command -v python3 >/dev/null 2>&1 && [ -f "$STATE" ]; then python3 - "$STATE" <<'PYEOF' import json, os, sys state_path = sys.argv[1] with open(state_path) as f: artifacts = json.load(f) if isinstance(artifacts, dict): artifacts = [artifacts] for a in artifacts: path = a.get("path") if not path: continue if os.path.exists(path): try: os.remove(path) except OSError: pass PYEOF fi echo "rolled-back" `) } var _ Transport = (*sshpush.Transport)(nil)