package cli import ( "context" "crypto/x509" "encoding/pem" "fmt" "os" "time" "github.com/google/uuid" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/acl" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/identity" "git.cloudinit.dev/coreci/orca/internal/model" "git.cloudinit.dev/coreci/orca/internal/paths" "git.cloudinit.dev/coreci/orca/internal/security" "git.cloudinit.dev/coreci/orca/internal/store" ) const ( initCAN = "orca-internal-ca" localhostName = "localhost" localhostAddr = "localhost:8443" ) var initCmd = &cobra.Command{ Use: "init", Short: "Initialize local orca state with full bootstrap", Long: `Initialize the local orca state directory and provision all dependencies required for ` + "`orca doctor`" + ` to pass: 1. Create the namespace directory (honors $ORCA_HOME; defaults to ~/.orca) 2. Open and migrate the SQLite database (migrations 0001..0006) 3. Bootstrap the internal CA (ca.crt + ca.key) if not already present 4. Generate the server cert (server.crt + server.key) if not already present 5. Auto-detect the local OS via /etc/os-release 6. Register a localhost node (kind=localhost, os=) Idempotent: re-running is safe and will refresh last_seen + os on the localhost node without regenerating certs or changing the node ID.`, RunE: func(cmd *cobra.Command, args []string) error { return runInit(cmd.OutOrStdout()) }, } func runInit(out interface{ Write([]byte) (int, error) }) error { dir := certpaths.Dir() type stepResult struct { Label string `json:"label"` Status string `json:"status"` Detail string `json:"detail,omitempty"` } type initSummary struct { Namespace string `json:"namespace"` Database string `json:"database"` CAFingerprint string `json:"ca_fingerprint,omitempty"` CertFingerprint string `json:"cert_fingerprint,omitempty"` OS string `json:"os"` NodeID string `json:"node_id"` NodeName string `json:"node_name"` Steps []stepResult `json:"steps"` } summary := initSummary{Namespace: dir} // Step 1: namespace dir. if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create orca dir: %w", err) } summary.Steps = append(summary.Steps, stepResult{Label: "namespace", Status: "ok", Detail: dir}) if !jsonOutput { fmt.Fprintf(out, "✓ Namespace dir: %s\n", dir) } // Step 2: database + migrations. dbPath := certpaths.DBPath() db, err := store.Open(dbPath) if err != nil { return fmt.Errorf("open database: %w", err) } defer db.Close() summary.Database = dbPath summary.Steps = append(summary.Steps, stepResult{Label: "database", Status: "ok", Detail: dbPath}) if !jsonOutput { fmt.Fprintf(out, "✓ Database initialized: %s\n", dbPath) } // Step 3: CA bootstrap (idempotent — CAInit has a fast-path). ca, err := security.CAInit(dir, initCAN) if err != nil { return fmt.Errorf("bootstrap CA: %w", err) } caFp := ca.Fingerprint() summary.CAFingerprint = caFp summary.Steps = append(summary.Steps, stepResult{Label: "ca", Status: "ok", Detail: caFp[:16] + "..."}) if !jsonOutput { fmt.Fprintf(out, "✓ CA provisioned: fp=%s\n", caFp[:16]+"...") } // Step 4: server cert (only if absent — D-036 idempotency). certPath := certpaths.ServerCertPath() certFp := "" if _, err := os.Stat(certPath); err == nil { // Already exists — load fingerprint for the summary. if fp, err := security.Fingerprint(certPath); err == nil { certFp = fp } summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "skipped", Detail: "already present"}) } else if os.IsNotExist(err) { keyPEM, csrPEM, err := security.GenerateCSR("localhost", []string{"localhost", "127.0.0.1"}) if err != nil { return fmt.Errorf("generate server CSR: %w", err) } certPEM, err := ca.SignCSR(csrPEM) if err != nil { return fmt.Errorf("sign server CSR: %w", err) } if err := security.WriteCert(certPath, certPEM); err != nil { return fmt.Errorf("write server cert: %w", err) } if err := security.WriteKey(certpaths.ServerKeyPath(), keyPEM); err != nil { return fmt.Errorf("write server key: %w", err) } certFp = security.FingerprintOf(parseFirstCertDER(certPEM)) summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "ok", Detail: certFp[:16] + "..."}) } else { return fmt.Errorf("stat server cert: %w", err) } summary.CertFingerprint = certFp if !jsonOutput { if certFp != "" { fmt.Fprintf(out, "✓ Server cert provisioned: fp=%s\n", certFp[:16]+"...") } else { fmt.Fprintf(out, "✓ Server cert: already present\n") } } // Step 5: OS detection. osDetected := detectOS() summary.OS = osDetected summary.Steps = append(summary.Steps, stepResult{Label: "os", Status: "ok", Detail: osDetected}) if !jsonOutput { fmt.Fprintf(out, "✓ OS detected: %s\n", osDetected) } // Step 6: localhost node upsert (idempotent per D-036). ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() repo := store.NewNodeRepo(db) existing, err := repo.GetByName(ctx, localhostName) if err == nil { // Refresh last_seen + os; keep id and joined_at. if err := repo.UpdateLastSeenAndOS(ctx, existing.ID, osDetected); err != nil { return fmt.Errorf("refresh localhost node: %w", err) } summary.NodeID = existing.ID summary.NodeName = existing.Name summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "refreshed", Detail: existing.ID}) if !jsonOutput { fmt.Fprintf(out, "✓ Localhost node refreshed: %s (os=%s)\n", existing.ID, osDetected) } } else if err == store.ErrNotFound { node := &model.Node{ ID: uuid.NewString(), Name: localhostName, Address: localhostAddr, State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), Kind: string(model.NodeKindLocalhost), OS: osDetected, } if err := repo.Insert(ctx, node); err != nil { return fmt.Errorf("insert localhost node: %w", err) } summary.NodeID = node.ID summary.NodeName = node.Name summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "ok", Detail: node.ID}) if !jsonOutput { fmt.Fprintf(out, "✓ Localhost node registered: %s (os=%s)\n", node.ID, osDetected) } } else { return fmt.Errorf("lookup localhost node: %w", err) } // Step 7: bootstrap ACL (P04, T8; C-40). Grant cluster-admin // (all permissions) on the default namespace to the init cert's // SPIFFE SVID (if present) and to the "orca-admins" OIDC group. // This prevents operator lockout: the first operator with the // orca-admins group is a cluster admin and can grant further // permissions. Idempotent — re-running init refreshes the grant. if err := bootstrapACL(certPath); err != nil { // Non-fatal: log and continue. The operator can run `orca acl // grant` manually. Failing init here would block bootstrap. if !jsonOutput { fmt.Fprintf(out, "⚠ ACL bootstrap skipped: %v\n", err) } summary.Steps = append(summary.Steps, stepResult{Label: "acl-bootstrap", Status: "skipped", Detail: err.Error()}) } else { summary.Steps = append(summary.Steps, stepResult{Label: "acl-bootstrap", Status: "ok", Detail: "cluster-admin on _defaults"}) if !jsonOutput { fmt.Fprintf(out, "✓ ACL bootstrapped: cluster-admin on _defaults (orca-admins group + init SVID)\n") } } if jsonOutput { return printJSON(summary) } fmt.Fprintf(out, "\n✓ orca init complete — run `orca doctor` to verify.\n") return nil } // bootstrapACL grants cluster-admin (all permissions) on the default // namespace to the init cert's SPIFFE SVID and to the "orca-admins" // OIDC group. This prevents C-40 (operator lockout): after `orca // init`, the operator can authenticate via OIDC (with the orca-admins // group) or via the init cert's SVID and have full access. Idempotent // — re-running init refreshes the grants. // // The default namespace is paths.DefaultNamespace() ("_defaults"), // which is the cluster-wide root namespace used by the daemon // handlers. Future phases can grant on additional namespaces. func bootstrapACL(certPath string) error { a, err := loadACL() if err != nil { return fmt.Errorf("load acl: %w", err) } ns := paths.DefaultNamespace() // Grant cluster-admin to the orca-admins OIDC group. The first // operator with this group (set in the IdP) becomes cluster admin. a.Grant(acl.OidcGroupIdentity("orca-admins"), ns, acl.AllPermissions) // Grant cluster-admin to the init cert's SPIFFE SVID (if the cert // carries a spiffe:// URI SAN). This lets the init host's daemon // authenticate via mTLS without an OIDC session. if svid, err := svidFromCert(certPath); err == nil && svid != "" { id := acl.Identity{Kind: acl.KindSpiffe, ID: svid} if nsFromURI, err := acl.SpiffeNamespace(svid); err == nil { id.Namespace = nsFromURI a.Grant(id, nsFromURI, acl.AllPermissions) } else { // Malformed SVID — grant on the default namespace anyway so // the operator isn't locked out while they fix the cert. a.Grant(id, ns, acl.AllPermissions) } } release, err := lockACL() if err != nil { return fmt.Errorf("acquire acl lock: %w", err) } defer release() if err := saveACL(a); err != nil { return fmt.Errorf("save acl: %w", err) } return nil } // svidFromCert reads the PEM cert at certPath and returns the first // spiffe:// URI SAN, or ("", nil) if the cert has no SPIFFE URI. func svidFromCert(certPath string) (string, error) { data, err := os.ReadFile(certPath) if err != nil { return "", fmt.Errorf("read cert: %w", err) } block, _ := pem.Decode(data) if block == nil { return "", fmt.Errorf("decode cert pem: no block") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return "", fmt.Errorf("parse cert: %w", err) } for _, u := range cert.URIs { if u != nil && u.Scheme == "spiffe" { return u.String(), nil } } return "", nil } // compile-time guard: identity import is used by the doc comment // reference; keep the import so future SVID minting hooks land here. var _ = identity.SpiffeTrustDomain func init() { rootCmd.AddCommand(initCmd) }