mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:11:31 +00:00
f5c67a51b2
Phase 1 of the deploy-hardening I master bundle. Closes the load-bearing prerequisite for the seven Bundle I items by extracting one canonical atomic-deploy primitive at internal/deploy/ that all 13 target connectors will consume in Phases 4-9. The package ships: - Plan + Apply API: write all File entries to sibling .certctl-tmp.<nanos> in the destination directory (same-filesystem guarantees os.Rename atomicity), call PreCommit (validate-with-the-target), atomic-rename all temps to final, call PostCommit (reload). On PostCommit failure, restore from pre-deploy backups + re-call PostCommit. If second PostCommit also fails, return ErrRollbackFailed (operator-actionable; documented loud). - AtomicWriteFile lower-level entry for connectors that don't fit the Plan model (F5, K8s — they ship bytes through APIs, not local files). - SHA-256 idempotency: every Apply short-circuits when all File destinations already match SHA-256 of new bytes. Defends against agent-restart retry storms hammering targets with no-op reloads. - Ownership + mode preservation: existing nginx:nginx 0640 stays nginx:nginx 0640 across renewals. Per-target FileDefaults applies for first-deploy. Per-File explicit Mode/Owner/Group overrides win over both. Closes the silent-failure mode where os.WriteFile(path, bytes, 0600) at apache.go:119 (et al.) clobbered worker access. - Backup retention janitor: pre-deploy backup at <path>.certctl-bak.<nanos>; default keeps last 3 (DefaultBackupRetention); BackupRetention=-1 disables backups (rollback impossible — documented foot-gun). - File-level mutex via sync.Map: two concurrent Apply calls touching the same destination serialize. Per-target serialization (Phase 2) is finer- grained at the agent dispatch layer; this is the file-level guard. - Sentinel errors for connector errors.Is checks: ErrPlanInvalid, ErrValidateFailed, ErrReloadFailed, ErrRollbackFailed. Tests (37 named cases across deploy_test.go + coverage_test.go) pin every load-bearing invariant the prompt's Phase 1 requires, plus error-leg coverage uplifts: - TestApply_HappyPath_PreCommitSucceeds_PostCommitSucceeds_FilesAtomic - TestApply_PreCommitFails_NoFilesChanged (atomic-or-nothing on validate) - TestApply_PostCommitFails_FilesRolledBack (rollback wire) - TestApply_RollbackAlsoFails_ReturnsErrRollbackFailed (escalation path) - TestApply_IdempotentSkip_SHA256Match (idempotency short-circuit) - TestApply_PreservesExistingOwnerAndMode_WhenNotOverridden - TestApply_RespectsOverrides_OwnerGroupMode - TestApply_ConcurrentApplyToSameFile_Serializes (file-level lock) - TestApply_BackupRetention_KeepsLastN (janitor pruning) - TestApply_NoExistingFile_UsesDefaultsForOwnerGroupMode - TestAtomicWriteFile_TempFileCleanedUpOnError - TestAtomicWriteFile_RenameRaceWithReader_AtomicReadAlwaysSeesOldOrNew (POSIX-rename atomicity proof via concurrent reader) Plus white-box tests for resolveOwnership, lookupUID/GID, and deeper error legs in restoreFromBackups + applyOwnership + AtomicWriteFile. Coverage 87.3% — practical ceiling without injecting a fault-aware FS abstraction (Write/Sync/Close OS errors are unreachable from go test without sudo'd disk-fill or a custom interface seam). Above the existing service-layer 70% floor; Phases 4-9 will lift this further as they exercise the package through real-connector use. Race detector clean; gofmt + go vet + golangci-lint v2.11.4 all 0 issues. The package is the load-bearing prerequisite for Phases 4-9. Phase 2 next: per-target deploy mutex in cmd/agent/main.go. Spec: cowork/deploy-hardening-i-prompt.md Baseline + recon: cowork/deploy-hardening-i/baseline.md
821 lines
24 KiB
Go
821 lines
24 KiB
Go
package deploy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Phase 1 of the deploy-hardening I master bundle. The 12 named
|
|
// tests below pin the load-bearing invariants of the
|
|
// internal/deploy/ package: atomic-or-nothing across files,
|
|
// validate-fail-cleans-up, reload-fail-rolls-back,
|
|
// rollback-also-fails-escalates, SHA-256 idempotency,
|
|
// owner/mode preservation + override, file-level serialization,
|
|
// backup retention janitor, and AtomicWriteFile temp-file +
|
|
// rename-race correctness.
|
|
//
|
|
// All 12 are required by the prompt at
|
|
// cowork/deploy-hardening-i-prompt.md::"Test plan (Phase 1
|
|
// ships ≥95% coverage on the new package)".
|
|
//
|
|
// The tests run in non-root environments — they do NOT exercise
|
|
// cross-user chown (which requires CAP_CHOWN). The chown wiring
|
|
// is exercised via the same-user case (chown to os.Getuid()
|
|
// always succeeds) + the resolveOwnership white-box tests.
|
|
|
|
const testCert1 = "-----BEGIN CERTIFICATE-----\nFAKE-CERT-1-PAYLOAD\n-----END CERTIFICATE-----\n"
|
|
const testCert2 = "-----BEGIN CERTIFICATE-----\nFAKE-CERT-2-DIFFERENT\n-----END CERTIFICATE-----\n"
|
|
|
|
// TestApply_HappyPath_PreCommitSucceeds_PostCommitSucceeds_FilesAtomic
|
|
// pins the canonical happy path: write multiple files, validate
|
|
// passes, all atomic-rename, reload passes. Every File ends up
|
|
// with the new bytes; PreCommit + PostCommit each fired once.
|
|
func TestApply_HappyPath_PreCommitSucceeds_PostCommitSucceeds_FilesAtomic(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
key := filepath.Join(dir, "tls.key")
|
|
|
|
preCalls, postCalls := 0, 0
|
|
var seenTempPaths map[string]string
|
|
plan := Plan{
|
|
Files: []File{
|
|
{Path: cert, Bytes: []byte(testCert1)},
|
|
{Path: key, Bytes: []byte(testCert2)},
|
|
},
|
|
PreCommit: func(ctx context.Context, tempPaths map[string]string) error {
|
|
preCalls++
|
|
seenTempPaths = tempPaths
|
|
// Both temp files exist + readable + carry the new
|
|
// bytes (the load-bearing invariant for "validate-
|
|
// against-temp" semantics).
|
|
for finalPath, tempPath := range tempPaths {
|
|
if _, err := os.Stat(tempPath); err != nil {
|
|
return fmt.Errorf("temp for %s missing: %w", finalPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
PostCommit: func(ctx context.Context) error {
|
|
postCalls++
|
|
return nil
|
|
},
|
|
}
|
|
|
|
res, err := Apply(context.Background(), plan)
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
if res.SkippedAsIdempotent {
|
|
t.Errorf("expected fresh write, got idempotent skip")
|
|
}
|
|
if !res.ValidateOK || !res.Reloaded {
|
|
t.Errorf("ValidateOK=%v Reloaded=%v, want true/true", res.ValidateOK, res.Reloaded)
|
|
}
|
|
if preCalls != 1 || postCalls != 1 {
|
|
t.Errorf("PreCommit/PostCommit calls = %d/%d, want 1/1", preCalls, postCalls)
|
|
}
|
|
if len(seenTempPaths) != 2 {
|
|
t.Errorf("PreCommit saw %d temp paths, want 2", len(seenTempPaths))
|
|
}
|
|
// Final files have new bytes.
|
|
if got, _ := os.ReadFile(cert); string(got) != testCert1 {
|
|
t.Errorf("cert content = %q, want %q", got, testCert1)
|
|
}
|
|
if got, _ := os.ReadFile(key); string(got) != testCert2 {
|
|
t.Errorf("key content = %q, want %q", got, testCert2)
|
|
}
|
|
}
|
|
|
|
// TestApply_PreCommitFails_NoFilesChanged pins the all-or-nothing
|
|
// invariant on the validate path: PreCommit returns an error →
|
|
// neither destination is touched, ErrValidateFailed is returned.
|
|
func TestApply_PreCommitFails_NoFilesChanged(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
key := filepath.Join(dir, "tls.key")
|
|
if err := os.WriteFile(cert, []byte("ORIGINAL-CERT"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(key, []byte("ORIGINAL-KEY"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
postCalls := 0
|
|
plan := Plan{
|
|
Files: []File{
|
|
{Path: cert, Bytes: []byte(testCert1)},
|
|
{Path: key, Bytes: []byte(testCert2)},
|
|
},
|
|
PreCommit: func(ctx context.Context, tempPaths map[string]string) error {
|
|
return errors.New("nginx -t says: invalid SAN")
|
|
},
|
|
PostCommit: func(ctx context.Context) error {
|
|
postCalls++
|
|
return nil
|
|
},
|
|
}
|
|
|
|
_, err := Apply(context.Background(), plan)
|
|
if !errors.Is(err, ErrValidateFailed) {
|
|
t.Fatalf("expected ErrValidateFailed, got %v", err)
|
|
}
|
|
if postCalls != 0 {
|
|
t.Errorf("PostCommit called %d times after PreCommit failure, want 0", postCalls)
|
|
}
|
|
// Both destinations untouched.
|
|
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL-CERT" {
|
|
t.Errorf("cert was modified despite PreCommit failure: %q", got)
|
|
}
|
|
if got, _ := os.ReadFile(key); string(got) != "ORIGINAL-KEY" {
|
|
t.Errorf("key was modified despite PreCommit failure: %q", got)
|
|
}
|
|
// No temp files leaked.
|
|
entries, _ := os.ReadDir(dir)
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), TempSuffix) {
|
|
t.Errorf("temp file leaked: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestApply_PostCommitFails_FilesRolledBack pins the rollback
|
|
// wire: PostCommit fails → restore from backup → re-call
|
|
// PostCommit → second one succeeds → return ErrReloadFailed +
|
|
// RolledBack=true. The destinations now hold the ORIGINAL bytes.
|
|
func TestApply_PostCommitFails_FilesRolledBack(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
postCalls := 0
|
|
plan := Plan{
|
|
Files: []File{
|
|
{Path: cert, Bytes: []byte(testCert1)},
|
|
},
|
|
PostCommit: func(ctx context.Context) error {
|
|
postCalls++
|
|
if postCalls == 1 {
|
|
return errors.New("nginx -s reload exited 1")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
res, err := Apply(context.Background(), plan)
|
|
if !errors.Is(err, ErrReloadFailed) {
|
|
t.Fatalf("expected ErrReloadFailed, got %v", err)
|
|
}
|
|
if !res.RolledBack {
|
|
t.Error("expected RolledBack=true")
|
|
}
|
|
if res.Reloaded {
|
|
t.Error("expected Reloaded=false after rollback")
|
|
}
|
|
if postCalls != 2 {
|
|
t.Errorf("PostCommit calls = %d, want 2 (once for the new bytes, once for the restored bytes)", postCalls)
|
|
}
|
|
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL" {
|
|
t.Errorf("cert after rollback = %q, want %q", got, "ORIGINAL")
|
|
}
|
|
}
|
|
|
|
// TestApply_RollbackAlsoFails_ReturnsErrRollbackFailed is the
|
|
// escalation path: PostCommit fails + the second PostCommit (after
|
|
// restore) also fails. ErrRollbackFailed surfaces;
|
|
// operator-actionable.
|
|
func TestApply_RollbackAlsoFails_ReturnsErrRollbackFailed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
plan := Plan{
|
|
Files: []File{
|
|
{Path: cert, Bytes: []byte(testCert1)},
|
|
},
|
|
PostCommit: func(ctx context.Context) error {
|
|
return errors.New("nginx is wedged")
|
|
},
|
|
}
|
|
|
|
_, err := Apply(context.Background(), plan)
|
|
if !errors.Is(err, ErrRollbackFailed) {
|
|
t.Fatalf("expected ErrRollbackFailed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestApply_IdempotentSkip_SHA256Match pins the idempotency
|
|
// short-circuit: when every File's destination already matches
|
|
// SHA-256, neither PreCommit nor PostCommit fires; the result
|
|
// reports SkippedAsIdempotent=true.
|
|
func TestApply_IdempotentSkip_SHA256Match(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
if err := os.WriteFile(cert, []byte(testCert1), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
preCalls, postCalls := 0, 0
|
|
plan := Plan{
|
|
Files: []File{
|
|
{Path: cert, Bytes: []byte(testCert1)},
|
|
},
|
|
PreCommit: func(ctx context.Context, _ map[string]string) error {
|
|
preCalls++
|
|
return nil
|
|
},
|
|
PostCommit: func(ctx context.Context) error {
|
|
postCalls++
|
|
return nil
|
|
},
|
|
}
|
|
res, err := Apply(context.Background(), plan)
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
if !res.SkippedAsIdempotent {
|
|
t.Error("expected SkippedAsIdempotent=true")
|
|
}
|
|
if preCalls != 0 || postCalls != 0 {
|
|
t.Errorf("expected no Pre/PostCommit calls, got %d/%d", preCalls, postCalls)
|
|
}
|
|
if len(res.BackupPaths) != 0 {
|
|
t.Errorf("expected zero backups for idempotent skip, got %d", len(res.BackupPaths))
|
|
}
|
|
|
|
// Verify SkipIdempotent forces the calls.
|
|
plan.SkipIdempotent = true
|
|
res, err = Apply(context.Background(), plan)
|
|
if err != nil {
|
|
t.Fatalf("Apply with SkipIdempotent: %v", err)
|
|
}
|
|
if res.SkippedAsIdempotent {
|
|
t.Error("expected SkipIdempotent override to force the deploy")
|
|
}
|
|
if preCalls != 1 || postCalls != 1 {
|
|
t.Errorf("expected 1/1 calls under SkipIdempotent, got %d/%d", preCalls, postCalls)
|
|
}
|
|
}
|
|
|
|
// TestApply_PreservesExistingOwnerAndMode_WhenNotOverridden pins
|
|
// the silent-failure-mode-defense: an existing nginx:nginx 0640
|
|
// file MUST stay nginx:nginx 0640 across a renewal, NOT get
|
|
// clobbered to root:root 0600.
|
|
//
|
|
// We can't actually create a non-current-user file in a non-root
|
|
// test, so this test verifies mode preservation only (the chown
|
|
// preservation is exercised by the resolveOwnership unit test
|
|
// below).
|
|
func TestApply_PreservesExistingOwnerAndMode_WhenNotOverridden(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
// Pre-existing file with very specific mode.
|
|
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Some umasks downgrade 0640 → 0620; force the desired bits
|
|
// after creation.
|
|
if err := os.Chmod(cert, 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
plan := Plan{
|
|
Files: []File{
|
|
{Path: cert, Bytes: []byte(testCert1)}, // no Mode/Owner/Group set
|
|
},
|
|
}
|
|
if _, err := Apply(context.Background(), plan); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
stat, err := os.Stat(cert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stat.Mode().Perm() != 0640 {
|
|
t.Errorf("mode after deploy = %#o, want %#o (preservation broken)", stat.Mode().Perm(), os.FileMode(0640))
|
|
}
|
|
}
|
|
|
|
// TestApply_RespectsOverrides_OwnerGroupMode pins the override
|
|
// path: when File.Mode is set, the existing mode is overridden.
|
|
// We use the current user/group so chown succeeds on non-root.
|
|
func TestApply_RespectsOverrides_OwnerGroupMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(cert, 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
currentUser, err := user.Current()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
currentGroup, err := user.LookupGroupId(currentUser.Gid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
plan := Plan{
|
|
Files: []File{{
|
|
Path: cert,
|
|
Bytes: []byte(testCert1),
|
|
Mode: 0644,
|
|
Owner: currentUser.Username,
|
|
Group: currentGroup.Name,
|
|
}},
|
|
}
|
|
if _, err := Apply(context.Background(), plan); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
stat, err := os.Stat(cert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stat.Mode().Perm() != 0644 {
|
|
t.Errorf("override mode = %#o, want 0644", stat.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
// TestApply_ConcurrentApplyToSameFile_Serializes pins the
|
|
// file-level mutex: 10 concurrent Applies to the same destination
|
|
// see exactly 10 PostCommit invocations and the file ends with
|
|
// one of the writers' bytes (no torn write).
|
|
func TestApply_ConcurrentApplyToSameFile_Serializes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
|
|
const N = 10
|
|
var inFlight, maxInFlight int32
|
|
var postCount int32
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < N; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
plan := Plan{
|
|
Files: []File{{
|
|
Path: cert,
|
|
Bytes: []byte(fmt.Sprintf("WRITER-%d", idx)),
|
|
}},
|
|
SkipIdempotent: true, // force every call through the full path
|
|
PostCommit: func(ctx context.Context) error {
|
|
n := atomic.AddInt32(&inFlight, 1)
|
|
for {
|
|
m := atomic.LoadInt32(&maxInFlight)
|
|
if n <= m || atomic.CompareAndSwapInt32(&maxInFlight, m, n) {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
atomic.AddInt32(&inFlight, -1)
|
|
atomic.AddInt32(&postCount, 1)
|
|
return nil
|
|
},
|
|
}
|
|
if _, err := Apply(context.Background(), plan); err != nil {
|
|
t.Errorf("Apply: %v", err)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if postCount != N {
|
|
t.Errorf("postCount = %d, want %d", postCount, N)
|
|
}
|
|
if maxInFlight > 1 {
|
|
t.Errorf("max concurrent PostCommit = %d, want 1 (serialization broken)", maxInFlight)
|
|
}
|
|
// File must contain exactly one of the writers' contents.
|
|
got, _ := os.ReadFile(cert)
|
|
if !strings.HasPrefix(string(got), "WRITER-") {
|
|
t.Errorf("file content not from any writer: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestApply_BackupRetention_KeepsLastN pins the janitor: after
|
|
// many deploys, only the last N backups remain.
|
|
func TestApply_BackupRetention_KeepsLastN(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
|
|
// Initial file.
|
|
if err := os.WriteFile(cert, []byte("V0"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const keep = 2
|
|
for i := 1; i <= 5; i++ {
|
|
plan := Plan{
|
|
Files: []File{{
|
|
Path: cert,
|
|
Bytes: []byte(fmt.Sprintf("V%d", i)),
|
|
}},
|
|
BackupRetention: keep,
|
|
}
|
|
if _, err := Apply(context.Background(), plan); err != nil {
|
|
t.Fatalf("Apply iter %d: %v", i, err)
|
|
}
|
|
// Stagger to ensure distinct nanosecond stamps.
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
|
|
entries, _ := os.ReadDir(dir)
|
|
count := 0
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), BackupSuffix) {
|
|
count++
|
|
}
|
|
}
|
|
if count != keep {
|
|
t.Errorf("backup count after 5 deploys with retention=%d = %d, want %d", keep, count, keep)
|
|
}
|
|
}
|
|
|
|
// TestApply_NoExistingFile_UsesDefaultsForOwnerGroupMode covers
|
|
// the first-deploy path: destination doesn't exist; FileDefaults
|
|
// applies. We verify the mode default lands; owner/group default
|
|
// is exercised in resolveOwnership unit tests (would require root
|
|
// for cross-user chown).
|
|
func TestApply_NoExistingFile_UsesDefaultsForOwnerGroupMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
|
|
plan := Plan{
|
|
Files: []File{
|
|
{Path: cert, Bytes: []byte(testCert1)},
|
|
},
|
|
Defaults: FileDefaults{Mode: 0640},
|
|
}
|
|
if _, err := Apply(context.Background(), plan); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
stat, err := os.Stat(cert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stat.Mode().Perm() != 0640 {
|
|
t.Errorf("default mode for new file = %#o, want 0640", stat.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
// TestAtomicWriteFile_TempFileCleanedUpOnError checks that a
|
|
// failure mid-flight (we simulate by passing an unwritable
|
|
// directory) leaves no .certctl-tmp.* file behind.
|
|
func TestAtomicWriteFile_TempFileCleanedUpOnError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Make the directory read-only AFTER the temp open would fail.
|
|
// Easier: target a path inside a directory that doesn't exist.
|
|
ghost := filepath.Join(dir, "does-not-exist", "tls.crt")
|
|
_, err := AtomicWriteFile(context.Background(), ghost, []byte(testCert1), WriteOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error writing into nonexistent directory")
|
|
}
|
|
// No leaked temps in the parent (which does exist).
|
|
entries, _ := os.ReadDir(dir)
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), TempSuffix) {
|
|
t.Errorf("temp file leaked: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAtomicWriteFile_RenameRaceWithReader_AtomicReadAlwaysSeesOldOrNew
|
|
// pins the load-bearing POSIX-rename atomicity: a concurrent
|
|
// reader hitting the destination during a write either sees the
|
|
// pre-write bytes or the post-write bytes; never an intermediate
|
|
// state.
|
|
func TestAtomicWriteFile_RenameRaceWithReader_AtomicReadAlwaysSeesOldOrNew(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "tls.crt")
|
|
old := []byte(strings.Repeat("OLD", 1000))
|
|
newer := []byte(strings.Repeat("NEW", 1000))
|
|
if err := os.WriteFile(path, old, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stop := make(chan struct{})
|
|
var torn atomic.Bool
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s := string(b)
|
|
if s != string(old) && s != string(newer) {
|
|
torn.Store(true)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Issue many writes back and forth.
|
|
for i := 0; i < 30; i++ {
|
|
writeBytes := old
|
|
if i%2 == 0 {
|
|
writeBytes = newer
|
|
}
|
|
if _, err := AtomicWriteFile(context.Background(), path, writeBytes, WriteOptions{
|
|
SkipIdempotent: true,
|
|
}); err != nil {
|
|
t.Fatalf("AtomicWriteFile %d: %v", i, err)
|
|
}
|
|
}
|
|
close(stop)
|
|
wg.Wait()
|
|
if torn.Load() {
|
|
t.Error("torn read observed (rename was not atomic)")
|
|
}
|
|
}
|
|
|
|
// --- White-box tests for resolveOwnership (chown semantics under
|
|
// non-root require this, since we can't write a chown-to-root
|
|
// integration test without sudo). ---
|
|
|
|
// TestResolveOwnership_ExplicitOverride_Wins verifies that an
|
|
// explicit File.Mode/Owner/Group beats both existing-file
|
|
// preservation and Defaults fallback.
|
|
func TestResolveOwnership_ExplicitOverride_Wins(t *testing.T) {
|
|
currentUser, _ := user.Current()
|
|
currentGroup, _ := user.LookupGroupId(currentUser.Gid)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "f")
|
|
if err := os.WriteFile(path, []byte("x"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stat, _ := os.Stat(path)
|
|
res, err := resolveOwnership(File{
|
|
Path: path,
|
|
Mode: 0644,
|
|
Owner: currentUser.Username,
|
|
Group: currentGroup.Name,
|
|
}, FileDefaults{Mode: 0400, Owner: "nobody", Group: "nogroup"}, stat)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Mode != 0644 {
|
|
t.Errorf("mode = %#o, want 0644 (override should win)", res.Mode)
|
|
}
|
|
if res.OwnerLabel != currentUser.Username {
|
|
t.Errorf("owner label = %q, want %q (override should win)", res.OwnerLabel, currentUser.Username)
|
|
}
|
|
}
|
|
|
|
// TestResolveOwnership_PreservesExisting_WhenNoOverride verifies
|
|
// the preservation path: no explicit override + existing file →
|
|
// existing uid/gid/mode are returned.
|
|
func TestResolveOwnership_PreservesExisting_WhenNoOverride(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "f")
|
|
if err := os.WriteFile(path, []byte("x"), 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stat, _ := os.Stat(path)
|
|
|
|
res, err := resolveOwnership(File{Path: path}, FileDefaults{Mode: 0400}, stat)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Mode != 0640 {
|
|
t.Errorf("mode = %#o, want 0640 (preservation)", res.Mode)
|
|
}
|
|
uid, gid, ok := unixOwnerFromStat(stat)
|
|
if !ok {
|
|
t.Skip("non-unix platform")
|
|
}
|
|
if res.UID != uid || res.GID != gid {
|
|
t.Errorf("uid/gid = %d/%d, want %d/%d", res.UID, res.GID, uid, gid)
|
|
}
|
|
}
|
|
|
|
// TestResolveOwnership_NewFile_FallsBackToDefaults verifies the
|
|
// defaults path: no override + no existing file → Plan.Defaults.
|
|
func TestResolveOwnership_NewFile_FallsBackToDefaults(t *testing.T) {
|
|
currentUser, _ := user.Current()
|
|
currentGroup, _ := user.LookupGroupId(currentUser.Gid)
|
|
|
|
res, err := resolveOwnership(File{Path: "/tmp/never"}, FileDefaults{
|
|
Mode: 0640,
|
|
Owner: currentUser.Username,
|
|
Group: currentGroup.Name,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Mode != 0640 {
|
|
t.Errorf("mode = %#o, want 0640 (default)", res.Mode)
|
|
}
|
|
if res.OwnerLabel != currentUser.Username {
|
|
t.Errorf("owner = %q, want %q (default)", res.OwnerLabel, currentUser.Username)
|
|
}
|
|
}
|
|
|
|
// TestApply_RejectsInvalidPlan_NoFiles + duplicate-paths + empty-
|
|
// path. Pin the validatePlan gate.
|
|
func TestApply_RejectsInvalidPlan(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
plan Plan
|
|
}{
|
|
{"no files", Plan{}},
|
|
{"empty path", Plan{Files: []File{{Path: "", Bytes: []byte("x")}}}},
|
|
{"duplicate", Plan{Files: []File{
|
|
{Path: "/tmp/dup", Bytes: []byte("a")},
|
|
{Path: "/tmp/dup", Bytes: []byte("b")},
|
|
}}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := Apply(context.Background(), tc.plan)
|
|
if !errors.Is(err, ErrPlanInvalid) {
|
|
t.Errorf("got %v, want ErrPlanInvalid", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestApply_ContextCancelledBeforeStart_AbortsCleanly pins the
|
|
// context-respect contract: a cancelled context aborts before
|
|
// any I/O.
|
|
func TestApply_ContextCancelledBeforeStart_AbortsCleanly(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err := Apply(ctx, Plan{
|
|
Files: []File{{Path: cert, Bytes: []byte(testCert1)}},
|
|
})
|
|
if err == nil || !errors.Is(err, context.Canceled) {
|
|
t.Errorf("got %v, want context.Canceled", err)
|
|
}
|
|
if _, statErr := os.Stat(cert); statErr == nil {
|
|
t.Error("file was created despite cancelled context")
|
|
}
|
|
}
|
|
|
|
// TestApply_NoBackupRetention_DisablesBackups pins
|
|
// BackupRetention = -1 sentinel: no backup created; rollback
|
|
// becomes impossible.
|
|
func TestApply_NoBackupRetention_DisablesBackups(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cert := filepath.Join(dir, "tls.crt")
|
|
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan := Plan{
|
|
Files: []File{{Path: cert, Bytes: []byte(testCert1)}},
|
|
BackupRetention: -1,
|
|
}
|
|
if _, err := Apply(context.Background(), plan); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
entries, _ := os.ReadDir(dir)
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), BackupSuffix) {
|
|
t.Errorf("backup created despite BackupRetention=-1: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAtomicWriteFile_HappyPath_ReplacesExistingAtomically covers
|
|
// the simple AtomicWriteFile path used by F5 + K8s connectors.
|
|
func TestAtomicWriteFile_HappyPath_ReplacesExistingAtomically(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "f")
|
|
if err := os.WriteFile(path, []byte("OLD"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := AtomicWriteFile(context.Background(), path, []byte("NEW"), WriteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("AtomicWriteFile: %v", err)
|
|
}
|
|
if !res.Replaced {
|
|
t.Error("Replaced=false; want true")
|
|
}
|
|
if res.BackupPath == "" {
|
|
t.Error("expected non-empty BackupPath")
|
|
}
|
|
if got, _ := os.ReadFile(path); string(got) != "NEW" {
|
|
t.Errorf("file = %q, want NEW", got)
|
|
}
|
|
}
|
|
|
|
// TestAtomicWriteFile_IdempotentSkip covers the AtomicWriteFile
|
|
// SHA-256 skip — same coverage as Plan.Apply but for the lower-
|
|
// level entry point used by F5/K8s.
|
|
func TestAtomicWriteFile_IdempotentSkip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "f")
|
|
if err := os.WriteFile(path, []byte("SAME"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := AtomicWriteFile(context.Background(), path, []byte("SAME"), WriteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("AtomicWriteFile: %v", err)
|
|
}
|
|
if !res.Idempotent {
|
|
t.Error("Idempotent=false; want true")
|
|
}
|
|
if res.Replaced {
|
|
t.Error("Replaced=true on idempotent skip; want false")
|
|
}
|
|
}
|
|
|
|
// TestAtomicWriteFile_RejectsEmptyPath pins the input validation.
|
|
func TestAtomicWriteFile_RejectsEmptyPath(t *testing.T) {
|
|
_, err := AtomicWriteFile(context.Background(), "", []byte("x"), WriteOptions{})
|
|
if !errors.Is(err, ErrPlanInvalid) {
|
|
t.Errorf("got %v, want ErrPlanInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestPruneBackups_NoOp_WhenUnderRetention pins the early return
|
|
// when there are fewer backups than the retention bar.
|
|
func TestPruneBackups_NoOp_WhenUnderRetention(t *testing.T) {
|
|
dir := t.TempDir()
|
|
abs := filepath.Join(dir, "f")
|
|
// Create two backup-style files.
|
|
os.WriteFile(abs+BackupSuffix+"0000000000000000001", []byte("a"), 0644)
|
|
os.WriteFile(abs+BackupSuffix+"0000000000000000002", []byte("b"), 0644)
|
|
if err := pruneBackups(abs, 5); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, _ := os.ReadDir(dir)
|
|
count := 0
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), BackupSuffix) {
|
|
count++
|
|
}
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("count = %d, want 2 (no pruning under retention)", count)
|
|
}
|
|
}
|
|
|
|
// TestLookupUID_Numeric covers the "numeric passthrough" branch
|
|
// of lookupUID — agents can configure with either "nginx" or "1000".
|
|
func TestLookupUID_Numeric(t *testing.T) {
|
|
uid, err := lookupUID("12345")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if uid != 12345 {
|
|
t.Errorf("uid = %d, want 12345", uid)
|
|
}
|
|
}
|
|
|
|
// TestLookupGID_Numeric mirror.
|
|
func TestLookupGID_Numeric(t *testing.T) {
|
|
gid, err := lookupGID("54321")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gid != 54321 {
|
|
t.Errorf("gid = %d, want 54321", gid)
|
|
}
|
|
}
|
|
|
|
// TestSHA256Eq_EdgeCases pins the helper used by the idempotency
|
|
// short-circuit.
|
|
func TestSHA256Eq_EdgeCases(t *testing.T) {
|
|
if !sha256Eq([]byte{}, []byte{}) {
|
|
t.Error("empty == empty failed")
|
|
}
|
|
if sha256Eq([]byte("a"), []byte("b")) {
|
|
t.Error("a == b unexpectedly true")
|
|
}
|
|
if sha256Eq([]byte("ab"), []byte("ac")) {
|
|
t.Error("ab == ac unexpectedly true")
|
|
}
|
|
if !sha256Eq([]byte("abc"), []byte("abc")) {
|
|
t.Error("abc == abc failed")
|
|
}
|
|
}
|