diff --git a/internal/application/apply/evaluate.go b/internal/application/apply/evaluate.go index 928ab88..695419e 100644 --- a/internal/application/apply/evaluate.go +++ b/internal/application/apply/evaluate.go @@ -80,7 +80,6 @@ func (service *Service) evaluate(ctx context.Context, request Request) (Candidat return service.evaluateRows(ctx, scopeInput{identity: identity, rows: rows, groups: request.Groups}) } -// scopeInput bundles the repository pair, its rows, and the requested groups of one evaluation. type scopeInput struct { identity RepositoryIdentity rows stateRows @@ -105,10 +104,9 @@ func (service *Service) evaluateRows(ctx context.Context, input scopeInput) (Can if err != nil { return Candidates{}, err } - return Candidates{home: input.identity.Home, records: candidates}, nil + return Candidates{root: input.identity.Root, home: input.identity.Home, records: candidates}, nil } -// chosen compiles the full plan and validates the group selection. func (service *Service) chosen(input scopeInput) (deployment.Plan, selection.Selection, error) { full, err := service.compile(input.identity, nil) if err != nil { @@ -121,7 +119,6 @@ func (service *Service) chosen(input scopeInput) (deployment.Plan, selection.Sel return full, chosen, nil } -// selected restricts the full plan and the rows to the selection and converts them into an immutable state snapshot. func (service *Service) selected(input scopeInput, full deployment.Plan, chosen selection.Selection) (deployment.Plan, reconcile.StateSnapshot, error) { plan, err := service.selectedPlan(input.identity, full, chosen) if err != nil { @@ -277,7 +274,6 @@ func keepAliasRows(rows []state.AliasBaseline, chosen selection.Selection) []sta return slices.DeleteFunc(kept, func(row state.AliasBaseline) bool { return !rowKept(row.GroupName, chosen) }) } -// rowKept reports whether a row group belongs to the selection. func rowKept(group string, chosen selection.Selection) bool { if group == "" { return chosen.Root @@ -296,10 +292,14 @@ type Candidate struct { // Candidates freezes the evaluated records of one apply in deterministic target-path order. type Candidates struct { + root string home string records []Candidate } +// Root returns the canonical repository root of the evaluated pair. +func (c Candidates) Root() string { return c.root } + func (c Candidates) Home() string { return c.home } func (c Candidates) All() []Candidate { diff --git a/internal/application/apply/evaluate_test.go b/internal/application/apply/evaluate_test.go index b40aa30..9341ef9 100644 --- a/internal/application/apply/evaluate_test.go +++ b/internal/application/apply/evaluate_test.go @@ -45,11 +45,15 @@ func (c planCompiler) Compile(repository.CompileInput) (deployment.Plan, error) // evalInput bundles the isolated directories, plan, rows, and key behavior // of one evaluation fixture. type evalInput struct { - repo string - home string - plan deployment.Plan - rows stateRows - keyErr error + repo string + home string + plan deployment.Plan + rows stateRows + keyErr error + probe DependencyProbe + client *secrets.Client + replacer AtomicReplacer + baselines BaselineStore } // evalFixture builds an evaluation service over the frozen input. @@ -59,7 +63,11 @@ func evalFixture(t *testing.T, input evalInput) *Service { RepositorySource: fakeRepositorySource{identity: RepositoryIdentity{Root: input.repo, Home: input.home}}, Compiler: planCompiler{plan: input.plan}, State: fakeStateReader{files: input.rows.files, aliases: input.rows.aliases, key: [32]byte{7}, keyErr: input.keyErr}, - Secrets: &secrets.Client{}, + Baselines: input.baselines, + Secrets: input.client, + Replacer: input.replacer, + Hooks: fakeHookExecutor{}, + Probe: input.probe, ProtectedTrees: []string{filepath.Join(input.repo, "state")}, Platform: "linux", }) diff --git a/internal/application/apply/execute_files.go b/internal/application/apply/execute_files.go new file mode 100644 index 0000000..d947840 --- /dev/null +++ b/internal/application/apply/execute_files.go @@ -0,0 +1,190 @@ +package apply + +import ( + "context" + "io/fs" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" + "github.com/alyraffauf/cattery/internal/state" +) + +// ExecuteFiles runs the regular-file actions of one apply sequentially: +// each target is re-frozen, written durably with its mode policy, and +// baselined in a short state commit. A later failure preserves accurate +// earlier state and never imports target bytes (PLAN.md Section 11.5). +func (service *Service) ExecuteFiles(ctx context.Context, plan PreparedPlan, candidates Candidates) ([]ItemResult, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + byPath := candidatesByPath(candidates) + results := make([]ItemResult, 0) + for _, action := range plan.Actions().Actions() { + if action.Kind != ActionKindWriteSource && action.Kind != ActionKindReplaceFile { + continue + } + job := fileJob{action: action, candidate: byPath[action.TargetPath], root: candidates.Root(), home: candidates.Home()} + record, err := service.executeFile(ctx, job) + results = append(results, record) + if err != nil { + return results, err + } + } + return results, nil +} + +// fileJob bundles one file action with its candidate and the repository +// pair locations. +type fileJob struct { + action PlanAction + candidate Candidate + root string + home string +} + +// candidatesByPath indexes the candidates by target path. +func candidatesByPath(candidates Candidates) map[string]Candidate { + index := make(map[string]Candidate, len(candidates.All())) + for _, candidate := range candidates.All() { + index[candidate.record.TargetPath] = candidate + } + return index +} + +// executeFile re-freezes one target, writes the exact source bytes with the +// managed mode, and establishes the baseline. A target that already carries +// the exact bytes recovers by equality without a write. +func (service *Service) executeFile(ctx context.Context, job fileJob) (ItemResult, error) { + precondition, err := filesystem.Freeze(filesystem.Destination{Root: job.home, Relative: job.action.TargetPath}) + if err != nil { + return partialRecord(job), failure.New(failure.Operational, "apply: freeze target "+job.action.TargetPath, err) + } + content, contentHash, clear, err := service.sourceContent(ctx, job.candidate) + if err != nil { + return partialRecord(job), err + } + if clear != nil { + defer clear() + } + durable, err := service.writeTarget(ctx, writeSpec{precondition: precondition, content: content, candidate: job.candidate}) + if err != nil { + return partialRecord(job), err + } + baseline, err := service.baselines.UpsertFileBaseline(job.root, job.home, baselineRow(job.candidate, contentHash)) + if err != nil { + return partialRecord(job), failure.New(failure.Operational, "apply: baseline "+job.action.TargetPath, err) + } + _ = baseline + if !durable { + return partialRecord(job), nil + } + return completedRecord(job), nil +} + +// writeSpec bundles the frozen precondition, exact bytes, and candidate of +// one target write. +type writeSpec struct { + precondition filesystem.Precondition + content []byte + candidate Candidate +} + +// sourceContent returns the exact source bytes of one file action: the +// retained ordinary bytes, or the decrypted plaintext for secrets. The +// content hash is the keyed secret fingerprint or the ordinary digest. +func (service *Service) sourceContent(ctx context.Context, candidate Candidate) ([]byte, deployment.Digest, func(), error) { + snapshot := candidate.record.Source.Snapshot() + if candidate.record.File.Kind != deployment.FileSecret { + return candidate.record.Source.Bytes(), snapshot.Semantic(), nil, nil + } + plaintext, err := service.secrets.Decrypt(ctx, candidate.record.Source.Bytes(), candidate.record.File.SourceRepositoryPath) + if err != nil { + return nil, deployment.Digest{}, nil, failure.New(failure.Operational, "apply: decrypt source "+candidate.record.File.SourceRepositoryPath, err) + } + key, err := service.state.RecoverHashKey() + if err != nil { + return nil, deployment.Digest{}, nil, failure.New(failure.Operational, "apply: recover hash key", err) + } + return plaintext, deployment.SecretSemantic(plaintext, key), clearBytes(plaintext), nil +} + +// clearBytes returns a function that zeroes the plaintext buffer. +func clearBytes(plaintext []byte) func() { + return func() { + for index := range plaintext { + plaintext[index] = 0 + } + } +} + +// writeTarget writes the exact bytes with the managed mode unless the +// target already carries them, and reports whether the result is durable. +func (service *Service) writeTarget(ctx context.Context, spec writeSpec) (bool, error) { + mode := service.targetMode(spec.precondition, spec.candidate) + if spec.precondition.Target().Token() == filesystem.TokenOfContent(spec.content) && spec.precondition.Target().Mode().Perm() == mode.Perm() { + return true, nil + } + result, err := service.replacer.ReplaceResult(ctx, spec.precondition, filesystem.ReplacementSpec{Content: spec.content, Mode: mode}) + if err != nil { + return false, failure.New(failure.Operational, "apply: replace target "+spec.candidate.record.TargetPath, err) + } + return result.DirectorySynced, nil +} + +// targetMode derives the managed mode of one file action from the mode +// policy and the current target facts. +func (service *Service) targetMode(precondition filesystem.Precondition, candidate Candidate) (mode fs.FileMode) { + executable := candidate.record.File.SourceExecutableBits + if candidate.record.File.Kind == deployment.FileSecret { + return filesystem.SecretTargetMode(executable) + } + return filesystem.OrdinaryTargetMode(precondition.Target().Mode(), executable, precondition.Target().Kind() == filesystem.KindAbsent) +} + +// baselineRow freezes the active baseline of one file action from the +// candidate facts and the content fingerprint. +func baselineRow(candidate Candidate, contentHash deployment.Digest) state.FileBaseline { + file := candidate.record.File + return state.FileBaseline{ + TargetPath: file.TargetRelativePath, + GroupName: file.Scope.Group, + SourcePath: file.SourceRepositoryPath, + SourceKind: file.Kind, + Layer: file.Layer, + BaselineContentHash: contentHash, + BaselineSourceHash: sourceHash(candidate), + ExecutableBits: uint32(file.SourceExecutableBits), + Status: state.StatusActive, + } +} + +// sourceHash is the retained storage fingerprint of the source: the raw +// ciphertext digest for secrets and the ordinary content digest otherwise. +func sourceHash(candidate Candidate) deployment.Digest { + snapshot := candidate.record.Source.Snapshot() + if candidate.record.File.Kind == deployment.FileSecret { + return snapshot.Storage() + } + return snapshot.Semantic() +} + +// completedRecord marks one durable, baselined target. +func completedRecord(job fileJob) ItemResult { + return ItemResult{ + TargetPath: job.action.TargetPath, + Status: StatusCompleted, + Secret: job.candidate.record.File.Kind == deployment.FileSecret, + Kind: job.action.Kind, + } +} + +// partialRecord marks one durable target without an equal baseline. +func partialRecord(job fileJob) ItemResult { + return ItemResult{ + TargetPath: job.action.TargetPath, + Status: StatusPartial, + Secret: job.candidate.record.File.Kind == deployment.FileSecret, + Kind: job.action.Kind, + } +} diff --git a/internal/application/apply/execute_files_test.go b/internal/application/apply/execute_files_test.go new file mode 100644 index 0000000..118ca67 --- /dev/null +++ b/internal/application/apply/execute_files_test.go @@ -0,0 +1,317 @@ +package apply + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" + "github.com/alyraffauf/cattery/internal/state" +) + +func TestApplyFileExecution(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"create new target", testExecuteCreate}, + {"update existing", testExecuteUpdate}, + {"mode correction", testExecuteModeFix}, + {"baseline failure is partial", testExecuteBaselineFailure}, + {"replace failure", testExecuteReplaceFailure}, + {"equality recovery", testExecuteRecovery}, + {"secret write clears", testExecuteSecret}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// baselineFake records upserts and can fail from a given call onward. +type baselineFake struct { + baseline state.FileBaseline + calls int + failCall int +} + +func (b *baselineFake) UpsertFileBaseline(root, home string, baseline state.FileBaseline) (state.FileBaseline, error) { + b.calls++ + if b.failCall > 0 && b.calls >= b.failCall { + return state.FileBaseline{}, os.ErrNotExist + } + b.baseline = baseline + return baseline, nil +} + +// replacerFake delegates to the real replacer and can fail or count. +type replacerFake struct { + calls int + err error +} + +func (r *replacerFake) ReplaceResult(ctx context.Context, precondition filesystem.Precondition, spec filesystem.ReplacementSpec) (filesystem.ReplaceResult, error) { + r.calls++ + if r.err != nil { + return filesystem.ReplaceResult{}, r.err + } + return filesystem.NewReplacer().ReplaceResult(ctx, precondition, spec) +} + +func (r *replacerFake) RealizeAlias(ctx context.Context, precondition filesystem.Precondition, spec filesystem.AliasSpec) (filesystem.AliasRealization, error) { + return 0, nil +} + +// executePair bundles one evaluated service, its candidates, and the home. +type executePair struct { + service *Service + candidates Candidates + home string + baselines *baselineFake + replacer *replacerFake +} + +// executeFixture evaluates one source/target pair with the given target +// content and returns the seams for execution. +func executeFixture(t *testing.T, targetContent []byte) executePair { + t.Helper() + repo := t.TempDir() + home := t.TempDir() + file := ordinarySource(t, fileSpec{Repo: repo, Target: "a.conf", Relative: "files/a"}, []byte("source")) + if targetContent != nil { + writeTarget(t, targetPath(home, "a.conf"), targetContent) + } + baselines := &baselineFake{} + replacer := &replacerFake{} + service := evalFixture(t, evalInput{ + repo: repo, home: home, plan: evalPlan(t, repo, file), + baselines: baselines, replacer: replacer, + }) + candidates, err := service.Evaluate(context.Background(), evalRequest()) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + return executePair{service: service, candidates: candidates, home: home, baselines: baselines, replacer: replacer} +} + +// filePlan builds one write-source plan over the candidate target. +func filePlan(target string) PreparedPlan { + return PreparedPlan{actions: NewActionPlan([]PlanAction{{TargetPath: target, Kind: ActionKindWriteSource, SourcePath: "files/a"}})} +} + +// targetContent reads the HOME-relative target bytes. +func targetContent(t *testing.T, home, relative string) []byte { + t.Helper() + content, err := os.ReadFile(filepath.Join(home, filepath.FromSlash(relative))) + if err != nil { + t.Fatalf("read target: %v", err) + } + return content +} + +func testExecuteCreate(t *testing.T) { + pair := executeFixture(t, nil) + results, err := pair.service.ExecuteFiles(context.Background(), filePlan("a.conf"), pair.candidates) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(results) != 1 || results[0].Status != StatusCompleted { + t.Fatalf("results = %+v, want one completed record", results) + } + if string(targetContent(t, pair.home, "a.conf")) != "source" { + t.Fatal("target must carry the exact source bytes") + } + if pair.baselines.calls != 1 || pair.baselines.baseline.Status != state.StatusActive { + t.Fatalf("baseline = %+v, want one active row", pair.baselines.baseline) + } + if pair.replacer.calls != 1 { + t.Fatalf("replacer calls = %d, want 1", pair.replacer.calls) + } +} + +func testExecuteUpdate(t *testing.T) { + pair := executeFixture(t, []byte("stale")) + results, err := pair.service.ExecuteFiles(context.Background(), filePlan("a.conf"), pair.candidates) + if err != nil { + t.Fatalf("execute: %v", err) + } + if results[0].Status != StatusCompleted || string(targetContent(t, pair.home, "a.conf")) != "source" { + t.Fatalf("target must be updated and completed, results = %+v", results) + } +} + +func testExecuteModeFix(t *testing.T) { + pair := modeFixture(t) + results, err := pair.service.ExecuteFiles(context.Background(), filePlan("a.conf"), pair.candidates) + if err != nil { + t.Fatalf("execute: %v", err) + } + if results[0].Status != StatusCompleted { + t.Fatalf("results = %+v, want completed", results) + } + info, err := os.Stat(targetPath(pair.home, "a.conf")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("mode = %o, want preserved executable bits", info.Mode().Perm()) + } + if pair.replacer.calls != 1 { + t.Fatalf("mode fix must rewrite the target, calls = %d", pair.replacer.calls) + } +} + +func testExecuteBaselineFailure(t *testing.T) { + pair := twoTargetFixture(t) + pair.baselines.failCall = 2 + plan := twoTargetPlan() + results, err := pair.service.ExecuteFiles(context.Background(), plan, pair.candidates) + if err == nil || !kindIs(err, failure.Operational) { + t.Fatalf("baseline failure error = %v, want operational", err) + } + if len(results) != 2 || results[0].Status != StatusCompleted || results[1].Status != StatusPartial { + t.Fatalf("results = %+v, want completed then partial", results) + } + if string(targetContent(t, pair.home, "a.conf")) != "source a" { + t.Fatal("the earlier target must remain accurate") + } +} + +// twoTargetFixture evaluates two drifting sources with equal target bytes. +func twoTargetFixture(t *testing.T) executePair { + t.Helper() + repo := t.TempDir() + home := t.TempDir() + first := ordinarySource(t, fileSpec{Repo: repo, Target: "a.conf", Relative: "files/a"}, []byte("source a")) + second := ordinarySource(t, fileSpec{Repo: repo, Target: "b.conf", Relative: "files/b"}, []byte("source b")) + baselines := &baselineFake{} + replacer := &replacerFake{} + service := evalFixture(t, evalInput{repo: repo, home: home, plan: evalPlan(t, repo, first, second), baselines: baselines, replacer: replacer}) + candidates, err := service.Evaluate(context.Background(), evalRequest()) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + return executePair{service: service, candidates: candidates, home: home, baselines: baselines, replacer: replacer} +} + +// twoTargetPlan freezes the write-source actions of both targets. +func twoTargetPlan() PreparedPlan { + return PreparedPlan{actions: NewActionPlan([]PlanAction{ + {TargetPath: "a.conf", Kind: ActionKindWriteSource, SourcePath: "files/a"}, + {TargetPath: "b.conf", Kind: ActionKindWriteSource, SourcePath: "files/b"}, + })} +} + +func testExecuteReplaceFailure(t *testing.T) { + pair := executeFixture(t, []byte("stale")) + pair.replacer.err = os.ErrPermission + _, err := pair.service.ExecuteFiles(context.Background(), filePlan("a.conf"), pair.candidates) + if err == nil || !kindIs(err, failure.Operational) { + t.Fatalf("replace failure error = %v, want operational", err) + } + if string(targetContent(t, pair.home, "a.conf")) != "stale" { + t.Fatal("a failed replace must leave the old target intact") + } + if pair.baselines.calls != 0 { + t.Fatalf("no baseline may be written after a failed replace, calls = %d", pair.baselines.calls) + } +} + +func testExecuteRecovery(t *testing.T) { + pair := executeFixture(t, nil) + pair.baselines.failCall = 1 + results, err := pair.service.ExecuteFiles(context.Background(), filePlan("a.conf"), pair.candidates) + if err == nil || results[0].Status != StatusPartial { + t.Fatalf("results = %+v err = %v, want partial plus an error", results, err) + } + pair.baselines.failCall = 0 + results, err = pair.service.ExecuteFiles(context.Background(), filePlan("a.conf"), pair.candidates) + if err != nil || results[0].Status != StatusCompleted { + t.Fatalf("retry results = %+v err = %v, want completed", results, err) + } + if pair.replacer.calls != 1 { + t.Fatalf("recovery must skip the second write, calls = %d", pair.replacer.calls) + } +} + +func testExecuteSecret(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + file := secretSource(t, fileSpec{Repo: repo, Target: "target", Relative: "files/token"}) + baselines := &baselineFake{} + replacer := &replacerFake{} + service := evalFixture(t, evalInput{ + repo: repo, home: home, plan: evalPlan(t, repo, file), + client: sopsClient(t, []byte("plaintext")), baselines: baselines, replacer: replacer, + }) + candidates, err := service.Evaluate(context.Background(), evalRequest()) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + plan := PreparedPlan{actions: NewActionPlan([]PlanAction{{TargetPath: "target", Kind: ActionKindWriteSource, SourcePath: "files/token"}})} + results, err := service.ExecuteFiles(context.Background(), plan, candidates) + if err != nil { + t.Fatalf("execute: %v", err) + } + assertSecretResult(t, secretOutcome{results: results, home: home, baselines: baselines}) +} + +// assertSecretResult verifies the completed secret record, the plaintext +// target with mode 0600, and the keyed baseline fingerprints. +func assertSecretResult(t *testing.T, outcome secretOutcome) { + t.Helper() + if len(outcome.results) != 1 || outcome.results[0].Status != StatusCompleted || !outcome.results[0].Secret { + t.Fatalf("results = %+v, want a completed secret record", outcome.results) + } + if string(targetContent(t, outcome.home, "target")) != "plaintext" { + t.Fatal("the target must carry the decrypted plaintext") + } + info, err := os.Stat(targetPath(outcome.home, "target")) + if err != nil { + t.Fatal(err) + } + if mode := fs.FileMode(info.Mode().Perm()); mode != 0o600 { + t.Fatalf("secret mode = %o, want 0600", mode) + } + content := outcome.baselines.baseline.BaselineContentHash + want := deployment.SecretSemantic([]byte("plaintext"), [32]byte{7}) + if content != want { + t.Fatal("the baseline must carry the keyed content fingerprint") + } + if outcome.baselines.baseline.BaselineSourceHash != deployment.RawStorage([]byte(`{"data":"c2VjcmV0","sops":{"version":"3.9.0"}}`)) { + t.Fatal("the baseline must carry the raw ciphertext fingerprint") + } +} + +// secretOutcome bundles the results, home, and baselines of one secret +// execution. +type secretOutcome struct { + results []ItemResult + home string + baselines *baselineFake +} + +// modeFixture evaluates one executable-bit source against a 0644 target +// that already carries the exact content. +func modeFixture(t *testing.T) executePair { + t.Helper() + repo := t.TempDir() + home := t.TempDir() + source := ordinarySource(t, fileSpec{Repo: repo, Target: "a.conf", Relative: "files/a"}, []byte("source")) + source.SourceExecutableBits = 0o111 + writeTarget(t, targetPath(home, "a.conf"), []byte("source")) + if err := os.Chmod(targetPath(home, "a.conf"), 0o644); err != nil { + t.Fatal(err) + } + baselines := &baselineFake{} + replacer := &replacerFake{} + service := evalFixture(t, evalInput{repo: repo, home: home, plan: evalPlan(t, repo, source), baselines: baselines, replacer: replacer}) + candidates, err := service.Evaluate(context.Background(), evalRequest()) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + return executePair{service: service, candidates: candidates, home: home, baselines: baselines, replacer: replacer} +} diff --git a/internal/application/apply/prepare.go b/internal/application/apply/prepare.go index ac31b1b..7e8aad5 100644 --- a/internal/application/apply/prepare.go +++ b/internal/application/apply/prepare.go @@ -187,7 +187,7 @@ func classificationAction(candidate Candidate) (reconcile.Action, string, bool) // mapKind projects one reconcile action into the apply action vocabulary. func mapKind(action reconcile.Action) (ActionKind, bool) { switch action { - case reconcile.ActionCreateTarget, reconcile.ActionWriteSourceToTarget: + case reconcile.ActionCreateTarget, reconcile.ActionWriteSourceToTarget, reconcile.ActionEstablishBaseline: return ActionKindWriteSource, true case reconcile.ActionCorrectMode: return ActionKindReplaceFile, true