3a3ea74d76
- transport.IsTransient: typed sentinels (ErrTransient/ErrPermanent) + standard net.Error/io errors.Is; substring matching removed - sshpush.isTransient: same typed-error classification - rotateSSHKeys: 2-phase atomic swap (stage peers -> swap local -> verify -> cleanup old); no more partial-result window - known_hosts: dial() reads stored field (was reading v0.8 path directly) - IPv6: net.JoinHostPort in proxmox SSH dial + drain splitHostPort - SSH timeouts: context.WithTimeout on peer-setup, drift, txn rollback, job restart (default 2m) - verifyCutover: orca CA pool TLS config (was default http.Client) - OIDC callback: ReadHeaderTimeout 5s (slowloris defense) - root Execute: signal.NotifyContext for SIGINT/SIGTERM (clean exit for non-watch commands) Tests: typed-error classification table, IPv6 JoinHostPort, signal handler context cancellation. ---ci--- project: orca phase: 8 milestone: v0.13 status: complete requirements: covered: [157] ---/ci---
151 lines
5.6 KiB
Go
151 lines
5.6 KiB
Go
// Package cli: peer_setup.go implements the orca system-user setup and
|
|
// NFS detection on peers (P10b-T8/T9, v0.11, REQ-111, REQ-112/D-233).
|
|
//
|
|
// `orca node join` now also creates the `orca` system user on the peer
|
|
// (so the systemd Path-unit services, which run as User=orca, have a
|
|
// uid to run as). It also detects whether /etc/orca is on an NFS mount
|
|
// and, when it is, skips emitting Path units for paths under /etc/orca
|
|
// (falling back to polling for those paths — systemd Path units on NFS
|
|
// are unreliable because inotify does not fire reliably over NFS).
|
|
//
|
|
// The setup is idempotent: re-running on an already-configured peer is
|
|
// a no-op. A --no-orca-user flag skips user creation (for environments
|
|
// with existing service accounts).
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// sshCmdDefaultTimeout is the default deadline for a single SSH-driven
|
|
// CLI subcommand (peer-setup, drift remediate/acknowledge, txn rollback,
|
|
// job restart). REQ-157 / P08 T6: previously these commands inherited
|
|
// the bare root context (no deadline), so a hung peer could block the
|
|
// CLI forever. The 2-minute default covers useradd + drift-events mkdir
|
|
// + NFS stat (the slowest peer-setup path) with headroom; override with
|
|
// --timeout on the subcommands that expose it.
|
|
const sshCmdDefaultTimeout = 2 * time.Minute
|
|
|
|
var peerSetupNoOrcaUser bool
|
|
var peerSetupTimeout time.Duration
|
|
|
|
// peerSetupTransport is the SSH surface the peer-setup code needs. It
|
|
// mirrors driftTransport; tests substitute a mock.
|
|
type peerSetupTransport interface {
|
|
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
|
|
}
|
|
|
|
// peerSetupTransportOverride is the test seam.
|
|
var peerSetupTransportOverride peerSetupTransport
|
|
|
|
func peerSetupTransportFromCtx() (peerSetupTransport, error) {
|
|
if peerSetupTransportOverride != nil {
|
|
return peerSetupTransportOverride, nil
|
|
}
|
|
t, err := driftTransportFromCtx()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// PeerSetupResult records what the peer setup did.
|
|
type PeerSetupResult struct {
|
|
UserCreated bool `json:"user_created"`
|
|
EventsDir string `json:"events_dir"`
|
|
NFSOnOrca bool `json:"nfs_on_orca"`
|
|
NFSMsg string `json:"nfs_msg,omitempty"`
|
|
}
|
|
|
|
// setupOrcaUser runs the idempotent `useradd -r orca` and creates the
|
|
// drift-events directory owned by orca:orca on the peer. Returns the
|
|
// result; a transient SSH failure returns the error (no partial state).
|
|
func setupOrcaUser(ctx context.Context, transport peerSetupTransport, peer string) (*PeerSetupResult, error) {
|
|
if peer == "" {
|
|
return nil, fmt.Errorf("peer setup: peer is empty")
|
|
}
|
|
res := &PeerSetupResult{EventsDir: "/etc/orca/state/drift-events"}
|
|
|
|
if !peerSetupNoOrcaUser {
|
|
useraddCmd := "useradd -r orca -s /usr/sbin/nologin 2>/dev/null || true"
|
|
if _, err := transport.Exec(ctx, peer, useraddCmd); err != nil {
|
|
return nil, fmt.Errorf("peer setup: useradd: %w", err)
|
|
}
|
|
res.UserCreated = true
|
|
}
|
|
|
|
mkdirCmd := fmt.Sprintf("mkdir -p %s && %s", res.EventsDir, chownDriftEvents(res.EventsDir))
|
|
if _, err := transport.Exec(ctx, peer, mkdirCmd); err != nil {
|
|
return nil, fmt.Errorf("peer setup: mkdir drift-events: %w", err)
|
|
}
|
|
|
|
nfs, msg := detectNFS(ctx, transport, peer, "/etc/orca")
|
|
res.NFSOnOrca = nfs
|
|
res.NFSMsg = msg
|
|
return res, nil
|
|
}
|
|
|
|
// chownDriftEvents returns the chown command for the drift-events dir.
|
|
// When --no-orca-user is set the orca user may not exist; chown only
|
|
// when the user was created.
|
|
func chownDriftEvents(dir string) string {
|
|
if peerSetupNoOrcaUser {
|
|
return "true"
|
|
}
|
|
return fmt.Sprintf("chown orca:orca %s 2>/dev/null || true", dir)
|
|
}
|
|
|
|
// detectNFS checks whether the given path is on an NFS mount by running
|
|
// `stat -f -c %T <path>` on the peer. When the fs type contains "nfs"
|
|
// it returns (true, msg). Best-effort: a stat failure returns
|
|
// (false, "stat unavailable").
|
|
func detectNFS(ctx context.Context, transport peerSetupTransport, peer, path string) (bool, string) {
|
|
out, err := transport.Exec(ctx, peer, fmt.Sprintf("stat -f -c %%T %s 2>/dev/null || echo unknown", shellQuoteDrift(path)))
|
|
if err != nil {
|
|
return false, "stat unavailable: " + err.Error()
|
|
}
|
|
fsType := strings.TrimSpace(string(out))
|
|
if strings.Contains(fsType, "nfs") {
|
|
return true, fmt.Sprintf("%s is on NFS (%s); skipping Path units for /etc/orca paths", path, fsType)
|
|
}
|
|
return false, fsType
|
|
}
|
|
|
|
var peerSetupCmd = &cobra.Command{
|
|
Use: "peer-setup <peer>",
|
|
Short: "Create the orca system user + drift-events dir on a peer (REQ-111)",
|
|
Long: `SSH to <peer> and idempotently create the orca system user
|
|
(useradd -r orca -s /usr/sbin/nologin) and /etc/orca/state/drift-events/
|
|
owned by orca:orca. Also detects NFS on /etc/orca (REQ-112/D-233) and
|
|
logs a warning when /etc/orca is on NFS (Path units are skipped for
|
|
those paths in that case). Use --no-orca-user to skip user creation
|
|
(for environments with an existing service account).`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
peer := args[0]
|
|
transport, err := peerSetupTransportFromCtx()
|
|
if err != nil {
|
|
return fmt.Errorf("ssh transport: %w", err)
|
|
}
|
|
ctx, cancel := sshCmdCtx(cmd.Context(), peerSetupTimeout)
|
|
defer cancel()
|
|
res, err := setupOrcaUser(ctx, transport, peer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printResult(fmt.Sprintf("✓ Peer %s set up (user=%t, nfs=%t)", peer, res.UserCreated, res.NFSOnOrca), res)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
peerSetupCmd.Flags().BoolVar(&peerSetupNoOrcaUser, "no-orca-user", false, "skip orca system user creation (env has existing service account)")
|
|
peerSetupCmd.Flags().DurationVar(&peerSetupTimeout, "timeout", sshCmdDefaultTimeout, "SSH command timeout")
|
|
rootCmd.AddCommand(peerSetupCmd)
|
|
}
|