e92b18197c
P01 — Load-bearing replacement for v0.8 mTLS transport (R-001). Transport (internal/sshpush/transport.go, REQ-073): - Transport struct with sync.Map connection pool (reuse *ssh.Client per peer). - Exec with context timeout (10s default) + retry (100ms x2 cap 5s max 5 attempts, +/-25% jitter — same backoff as v0.8 transport/retry.go). - ReadFile, WriteFile (atomic heredoc + mv), Close. - sshDialer + sshSession seams for testability. TOFU host-key verification reuses proxmox.TOFUHostKeyCallback. security.Flock for known_hosts. Fan-out (internal/sshpush/fanout.go): - ExecAll, WriteAll with errgroup + SetLimit semaphore (default 8 per I-B-001). Per-peer errors collected, don't cancel the group. Idempotency (internal/sshpush/idempotency.go, C-18): - WriteFileIdempotent: SHA-256 compare via ssh sha256sum; skip if content matches (written=false). Content-addressed idempotency replaces the v0.8 X-Orca-Idempotency-Key header (C-18 capability map). Tests: in-process fake SSH server (ssh.NewServerConn NoClientAuth ed25519) for e2e + interface seams for pure-logic. 93.0% coverage. 20 packages pass. ---ci--- project: orca phase: P01 milestone: v0.9 status: execute ---/ci---
102 lines
3.3 KiB
Go
102 lines
3.3 KiB
Go
package sshpush
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// WriteFileIdempotent writes content to peer:path atomically (write-to-tmp
|
|
// + mv, REQ-074) with mode, but only if the remote file's SHA-256 differs
|
|
// from the local content's SHA-256 (C-18 content-addressed idempotency —
|
|
// the SSH-push equivalent of the v0.8 X-Orca-Idempotency-Key).
|
|
//
|
|
// Returns written=true if the file was written, written=false if the
|
|
// content already matched (skip). The default per-SCP timeout is
|
|
// SCPTimeout (I-B-001).
|
|
//
|
|
// Atomicity: the content is written to a temp file in the same directory
|
|
// as the target, then `mv`'d into place. The temp file is mode-appended
|
|
// (e.g. `/etc/orca/foo.conf.orca-tmp-<rand>`) so the rename is atomic on
|
|
// POSIX filesystems.
|
|
func (t *Transport) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
|
|
localHash := sha256Hex(content)
|
|
remoteHash, err := t.remoteSHA256(ctx, peer, path)
|
|
if err == nil && remoteHash != "" && strings.EqualFold(remoteHash, localHash) {
|
|
return false, nil
|
|
}
|
|
if err := t.writeFile(ctx, peer, path, content, mode); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// writeFile writes content to peer:path atomically (write-to-tmp + mv).
|
|
// It writes the content via a single SSH exec (cat heredoc + chmod + mv),
|
|
// keeping the transfer in one round-trip. The temp file lives next to the
|
|
// target so the rename is atomic.
|
|
func (t *Transport) writeFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error {
|
|
dir, base := splitDir(path)
|
|
tmpName := fmt.Sprintf(".orca-tmp-%s", randomToken(8))
|
|
tmpPath := base + "/" + tmpName
|
|
if dir == "" {
|
|
tmpPath = tmpName
|
|
}
|
|
// Build the remote command: mkdir -p <dir> && cat > <tmp> <<'EOF'
|
|
// ... EOF && chmod <mode> <tmp> && mv <tmp> <path>. The heredoc
|
|
// delimiter is chosen to not appear in the content (we use a fixed
|
|
// marker; content with the marker would break, but the marker is
|
|
// sufficiently unusual).
|
|
const eof = "ORCA_PUSH_EOF_a1b2c3"
|
|
modeStr := fmt.Sprintf("%04o", uint32(mode.Perm()))
|
|
cmd := fmt.Sprintf(
|
|
"mkdir -p %s && cat > %s <<'%s'\n%s\n%s\nchmod %s %s && mv -f %s %s",
|
|
shellQuote(base),
|
|
shellQuote(tmpPath),
|
|
eof,
|
|
string(content),
|
|
eof,
|
|
modeStr,
|
|
shellQuote(tmpPath),
|
|
shellQuote(tmpPath),
|
|
shellQuote(path),
|
|
)
|
|
execCtx, cancel := context.WithTimeout(ctx, SCPTimeout)
|
|
defer cancel()
|
|
if _, err := t.execWithRetry(execCtx, peer, cmd, true); err != nil {
|
|
return fmt.Errorf("sshpush: write %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sha256Hex returns the lowercase hex SHA-256 digest of b.
|
|
func sha256Hex(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// splitDir returns the directory and the directory itself (for mkdir).
|
|
// For "/etc/orca/foo.conf" it returns ("/etc/orca", "/etc/orca"). For
|
|
// "foo.conf" it returns ("", ".").
|
|
func splitDir(path string) (dir, base string) {
|
|
idx := strings.LastIndex(path, "/")
|
|
if idx < 0 {
|
|
return "", "."
|
|
}
|
|
return path[:idx], path[:idx]
|
|
}
|
|
|
|
// randomToken returns a random hex token of the given byte length. Used
|
|
// for temp-file naming to avoid collisions under parallel fan-out.
|
|
func randomToken(n int) string {
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = byte(rand.Intn(256))
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|