diff --git a/internal/application/apply/dependencies.go b/internal/application/apply/dependencies.go index 0a02aad..2bc01e4 100644 --- a/internal/application/apply/dependencies.go +++ b/internal/application/apply/dependencies.go @@ -2,21 +2,12 @@ package apply import ( "context" - "errors" - "os" + "github.com/alyraffauf/cattery/internal/application/evaluation" "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/failure" ) -func compileFailure(message string, cause error) error { - var pathError *os.PathError - if errors.As(cause, &pathError) { - return failure.New(failure.Operational, message, cause) - } - return failure.New(failure.InvalidInput, message, cause) -} - // Preflight verifies the external dependencies the selected candidates // require: SOPS is probed only when a secret candidate needs on-demand // decryption (PLAN.md Sections 9.1 and 11.5). No version probing, state @@ -39,7 +30,7 @@ func (service *Service) Preflight(ctx context.Context, candidates Candidates) er // regular target must decrypt on demand. func needsSOPS(candidates Candidates) bool { for _, candidate := range candidates.All() { - if candidate.record.File.Kind == deployment.FileSecret && secretDecryptNeeded(candidate.record) { + if candidate.record.File.Kind == deployment.FileSecret && evaluation.SecretDecryptionNeeded(candidate.record) { return true } } diff --git a/internal/application/apply/evaluate.go b/internal/application/apply/evaluate.go index 22d876c..7aaed52 100644 --- a/internal/application/apply/evaluate.go +++ b/internal/application/apply/evaluate.go @@ -2,277 +2,91 @@ package apply import ( "context" - "os" - "path/filepath" - "slices" - "sort" + "github.com/alyraffauf/cattery/internal/application/evaluation" "github.com/alyraffauf/cattery/internal/deployment" - "github.com/alyraffauf/cattery/internal/failure" "github.com/alyraffauf/cattery/internal/reconcile" - "github.com/alyraffauf/cattery/internal/repository" "github.com/alyraffauf/cattery/internal/secrets" - "github.com/alyraffauf/cattery/internal/selection" - "github.com/alyraffauf/cattery/internal/state" ) type Service struct { - source RepositorySource - compiler Compiler - state StateReader - secrets *secrets.Client - client SecretClient - replacer AtomicReplacer - baselines BaselineStore - transitions TransitionStore - retirements RetirementStore - hooks HookExecutor - probe DependencyProbe - resolver DecisionResolver - protectedTrees []string - platform deployment.Layer - platformError error + evaluator *evaluation.Service + state StateReader + secrets *secrets.Client + client SecretClient + replacer AtomicReplacer + baselines BaselineStore + transitions TransitionStore + retirements RetirementStore + hooks HookExecutor + probe DependencyProbe + resolver DecisionResolver } func NewService(dependencies Dependencies) *Service { - platform, err := deployment.ParseLayer(dependencies.Platform) - if err != nil || platform == deployment.LayerBase { - platform = "" - } - var platformError error - if dependencies.Platform != "" && err != nil { - platformError = failure.New(failure.InvalidInput, "apply: invalid configured platform "+dependencies.Platform, err) - } return &Service{ - source: dependencies.RepositorySource, - compiler: dependencies.Compiler, - state: dependencies.State, - secrets: dependencies.Secrets, - client: dependencies.Client, - replacer: dependencies.Replacer, - baselines: dependencies.Baselines, - transitions: dependencies.Transitions, - retirements: dependencies.Retirements, - hooks: dependencies.Hooks, - probe: dependencies.Probe, - resolver: dependencies.Resolver, - protectedTrees: dependencies.ProtectedTrees, - platform: platform, - platformError: platformError, + evaluator: evaluation.NewService(evaluation.Dependencies{ + RepositorySource: dependencies.RepositorySource, + Compiler: dependencies.Compiler, + State: dependencies.State, + Secrets: dependencies.Secrets, + ProtectedTrees: dependencies.ProtectedTrees, + Platform: dependencies.Platform, + CommandLabel: "apply", + IncludeUnmanagedTargetDigest: true, + }), + state: dependencies.State, + secrets: dependencies.Secrets, + client: dependencies.Client, + replacer: dependencies.Replacer, + baselines: dependencies.Baselines, + transitions: dependencies.Transitions, + retirements: dependencies.Retirements, + hooks: dependencies.Hooks, + probe: dependencies.Probe, + resolver: dependencies.Resolver, } } + func (service *Service) Evaluate(ctx context.Context, request Request) (Candidates, error) { return service.evaluate(ctx, request) } func (service *Service) evaluate(ctx context.Context, request Request) (Candidates, error) { - if err := ctx.Err(); err != nil { - return Candidates{}, err - } - if service.platform == "" { - if service.platformError != nil { - return Candidates{}, service.platformError - } - return Candidates{}, failure.New(failure.InvalidInput, "apply: platform must be linux or darwin", nil) - } - identity, err := service.resolve(request.Repository) - if err != nil { - return Candidates{}, err - } - rows, err := service.readRows(identity) - if err != nil { - return Candidates{}, err - } - return service.evaluateRows(ctx, scopeInput{identity: identity, rows: rows, groups: request.Groups}) -} - -type scopeInput struct { - identity RepositoryIdentity - rows stateRows - groups []string -} - -func (service *Service) evaluateRows(ctx context.Context, input scopeInput) (Candidates, error) { - full, chosen, err := service.chosen(input) - if err != nil { - return Candidates{}, err - } - plan, snapshot, err := service.selected(input, full, chosen) - if err != nil { - return Candidates{}, err - } - assembly, err := reconcile.Assemble(plan, snapshot, service.secrets) - if err != nil { - return Candidates{}, failure.New(failure.Operational, "apply: assemble snapshot", err) - } - candidates, err := service.classify(ctx, assembly) - if err != nil { - return Candidates{}, err - } - return Candidates{root: input.identity.Root, home: input.identity.Home, platform: string(service.platform), hooks: plan.Hooks(), records: candidates}, nil -} - -func (service *Service) chosen(input scopeInput) (deployment.Plan, selection.Selection, error) { - full, err := service.compile(input.identity, nil) - if err != nil { - return deployment.Plan{}, selection.Selection{}, err - } - chosen, err := selection.CompiledAndPersisted(full.Groups(), persistedGroups(input.rows), input.groups) - if err != nil { - return deployment.Plan{}, selection.Selection{}, failure.New(failure.InvalidInput, "apply: select groups", err) - } - return full, chosen, nil -} - -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 { - return deployment.Plan{}, reconcile.StateSnapshot{}, err - } - snapshot, err := reconcile.NewStateSnapshot(selectedRows(input.identity, input.rows, chosen)) - if err != nil { - return deployment.Plan{}, reconcile.StateSnapshot{}, failure.New(failure.Operational, "apply: snapshot state", err) - } - return plan, snapshot, nil -} - -func (service *Service) resolve(input RepositoryInput) (RepositoryIdentity, error) { - identity, err := service.source.Resolve(repositoryRequest(input)) - if err != nil { - return RepositoryIdentity{}, failure.New(failure.InvalidInput, "apply: resolve repository", err) - } - return identity, nil -} - -func repositoryRequest(input RepositoryInput) selection.RepositoryRequest { - return selection.RepositoryRequest{ - RawExplicit: input.RawExplicit, - ExplicitSet: input.ExplicitSet, - RawEnv: input.RawEnv, - EnvSet: input.EnvSet, - WorkingDir: input.WorkingDir, - } -} - -func (service *Service) readRows(identity RepositoryIdentity) (stateRows, error) { - files, err := service.state.FileBaselines(identity.Root, identity.Home) - if err != nil { - return stateRows{}, failure.New(failure.Operational, "apply: read file rows", err) - } - aliases, err := service.state.AliasBaselines(identity.Root, identity.Home) - if err != nil { - return stateRows{}, failure.New(failure.Operational, "apply: read alias rows", err) - } - return stateRows{files: files, aliases: aliases}, nil -} - -func (service *Service) compile(identity RepositoryIdentity, selected []string) (deployment.Plan, error) { - plan, err := service.compiler.Compile(repository.CompileInput{ - Platform: service.platform, - RepositoryRoot: identity.Root, - HomeRoot: identity.Home, - Protected: service.protectedTrees, - Selected: selected, + shared, err := service.evaluator.Evaluate(ctx, evaluation.Request{ + Repository: evaluation.RepositoryInput{ + RawExplicit: request.Repository.RawExplicit, + ExplicitSet: request.Repository.ExplicitSet, + RawEnv: request.Repository.RawEnv, + EnvSet: request.Repository.EnvSet, + WorkingDir: request.Repository.WorkingDir, + }, + Groups: request.Groups, }) if err != nil { - return deployment.Plan{}, compileFailure("apply: compile plan", err) - } - return plan, nil -} - -func (service *Service) selectedPlan(identity RepositoryIdentity, full deployment.Plan, chosen selection.Selection) (deployment.Plan, error) { - if chosen.Root { - return full, nil - } - selected := intersectGroups(chosen.Groups, full.Groups()) - if len(selected) == 0 { - return emptyPlan(identity, service.platform) - } - return service.compile(identity, selected) -} - -func intersectGroups(selected, current []string) []string { - var common []string - for _, name := range selected { - if slices.Contains(current, name) { - common = append(common, name) - } - } - return common -} - -func emptyPlan(identity RepositoryIdentity, platform deployment.Layer) (deployment.Plan, error) { - return deployment.NewPlan(deployment.PlanInput{ - RepositoryRoot: identity.Root, - Platform: string(platform), - }) -} - -type stateRows struct { - files []state.FileBaseline - aliases []state.AliasBaseline -} - -func persistedGroups(rows stateRows) selection.PersistedGroups { - sets := &groupSets{active: make(map[string]bool), all: make(map[string]bool)} - for _, row := range rows.files { - sets.remember(row.GroupName, row.Status == state.StatusActive) - } - for _, row := range rows.aliases { - sets.remember(row.GroupName, row.Status == state.StatusActive) - } - return selection.PersistedGroups{Active: sortedKeys(sets.active), All: sortedKeys(sets.all)} -} - -type groupSets struct { - active map[string]bool - all map[string]bool -} - -func (sets *groupSets) remember(name string, active bool) { - if name == "" { - return - } - sets.all[name] = true - if active { - sets.active[name] = true - } -} - -func sortedKeys(names map[string]bool) []string { - keys := make([]string, 0, len(names)) - for name := range names { - keys = append(keys, name) - } - sort.Strings(keys) - return keys -} - -func selectedRows(identity RepositoryIdentity, rows stateRows, chosen selection.Selection) reconcile.StateRows { - return reconcile.StateRows{ - RepositoryRoot: identity.Root, - HomePath: identity.Home, - Files: keepFileRows(rows.files, chosen), - Aliases: keepAliasRows(rows.aliases, chosen), + return Candidates{}, err } -} -func keepFileRows(rows []state.FileBaseline, chosen selection.Selection) []state.FileBaseline { - kept := append([]state.FileBaseline(nil), rows...) - return slices.DeleteFunc(kept, func(row state.FileBaseline) bool { return !rowKept(row.GroupName, chosen) }) -} - -func keepAliasRows(rows []state.AliasBaseline, chosen selection.Selection) []state.AliasBaseline { - kept := append([]state.AliasBaseline(nil), rows...) - return slices.DeleteFunc(kept, func(row state.AliasBaseline) bool { return !rowKept(row.GroupName, chosen) }) -} - -func rowKept(group string, chosen selection.Selection) bool { - if group == "" { - return chosen.Root + records := shared.All() + candidates := make([]Candidate, 0, len(records)) + for _, record := range records { + candidates = append(candidates, Candidate{ + record: record.Evaluation, + file: record.File, + alias: record.Alias, + retirement: record.Retirement, + semantics: record.Semantics, + }) } - return slices.Contains(chosen.Groups, group) + return Candidates{ + root: shared.RepositoryRoot, + home: shared.HomePath, + platform: shared.Platform, + hooks: shared.HooksCopy(), + records: candidates, + }, nil } +// Candidate is the apply-owned projection of one shared evaluation record. type Candidate struct { record reconcile.Evaluation file reconcile.FileClassification @@ -281,7 +95,8 @@ type Candidate struct { semantics reconcile.FileSemantics } -// Candidates freezes the evaluated records of one apply in deterministic target-path order. +// Candidates freezes the evaluated records of one apply in deterministic +// target-path order. type Candidates struct { root string home string @@ -303,98 +118,3 @@ func (c Candidates) Hooks() []deployment.Hook { func (c Candidates) All() []Candidate { return append([]Candidate(nil), c.records...) } - -func (service *Service) classify(ctx context.Context, assembly reconcile.EvaluationSnapshot) ([]Candidate, error) { - semantics := &semanticState{reader: service.state, client: service.secrets} - records := assembly.All() - candidates := make([]Candidate, 0, len(records)) - for _, record := range records { - fingerprints, err := semantics.fingerprints(ctx, assembly.HomePath, record) - if err != nil { - return nil, err - } - candidates = append(candidates, Candidate{ - record: record, - file: reconcile.ClassifyFile(record, fingerprints), - alias: reconcile.ClassifyAlias(record, fingerprints), - retirement: reconcile.ClassifyRetirement(record, assembly.Platform), - semantics: fingerprints, - }) - } - return candidates, nil -} - -type semanticState struct { - reader StateReader - client *secrets.Client - key [32]byte - haveKey bool -} - -func (state *semanticState) fingerprints(ctx context.Context, home string, record reconcile.Evaluation) (reconcile.FileSemantics, error) { - if record.Entry != reconcile.PlanEntryFile { - if record.Target.Kind() == reconcile.KindFile { - return reconcile.FileSemantics{Target: record.Target.Digest()}, nil - } - return reconcile.FileSemantics{}, nil - } - if record.File.Kind == deployment.FileOrdinary { - return reconcile.FileSemantics{ - Source: record.Source.Snapshot().Semantic(), - Target: record.Target.Digest(), - }, nil - } - return state.secretFingerprints(ctx, home, record) -} - -func (state *semanticState) secretFingerprints(ctx context.Context, home string, record reconcile.Evaluation) (reconcile.FileSemantics, error) { - semantics := reconcile.FileSemantics{} - targetFile := record.Target.Kind() == reconcile.KindFile - if !targetFile && !secretDecryptNeeded(record) { - return semantics, nil - } - if err := state.recover(); err != nil { - return semantics, err - } - if targetFile { - content, err := os.ReadFile(filepath.Join(home, filepath.FromSlash(record.TargetPath))) - if err != nil { - return semantics, failure.New(failure.Operational, "apply: read target "+record.TargetPath, err) - } - semantics.Target = deployment.SecretSemantic(content, state.key) - } - if secretDecryptNeeded(record) { - source, err := record.Source.KeyedSemantic(ctx, state.key) - if err != nil { - return semantics, categorized(err, "apply: decrypt source "+record.File.SourceRepositoryPath) - } - semantics.Source = source - } - return semantics, nil -} - -func categorized(err error, message string) error { - if _, ok := failure.HasKind(err); ok { - return err - } - return failure.New(failure.Operational, message, err) -} - -func secretDecryptNeeded(record reconcile.Evaluation) bool { - if record.FileState == nil { - return record.Target.Kind() == reconcile.KindFile - } - return record.Source.Snapshot().Storage() != record.FileState.BaselineSource() -} - -func (state *semanticState) recover() error { - if state.haveKey { - return nil - } - key, err := state.reader.RecoverHashKey() - if err != nil { - return failure.New(failure.Operational, "apply: recover hash key", err) - } - state.key, state.haveKey = key, true - return nil -} diff --git a/internal/application/apply/evaluate_test.go b/internal/application/apply/evaluate_test.go index 05863cd..b6c5f58 100644 --- a/internal/application/apply/evaluate_test.go +++ b/internal/application/apply/evaluate_test.go @@ -61,6 +61,11 @@ type evalInput struct { resolver DecisionResolver } +type stateRows struct { + files []state.FileBaseline + aliases []state.AliasBaseline +} + // evalFixture builds an evaluation service over the frozen input. func evalFixture(t *testing.T, input evalInput) *Service { t.Helper() diff --git a/internal/application/apply/types.go b/internal/application/apply/types.go index e300885..67f9f64 100644 --- a/internal/application/apply/types.go +++ b/internal/application/apply/types.go @@ -9,12 +9,11 @@ import ( "context" "fmt" + "github.com/alyraffauf/cattery/internal/application/evaluation" "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/filesystem" "github.com/alyraffauf/cattery/internal/hooks" - "github.com/alyraffauf/cattery/internal/repository" "github.com/alyraffauf/cattery/internal/secrets" - "github.com/alyraffauf/cattery/internal/selection" "github.com/alyraffauf/cattery/internal/state" ) @@ -42,29 +41,18 @@ type Dependencies struct { // RepositorySource resolves the canonical repository pair for a selection // request. The composition root satisfies it with a selection resolver bound // to the canonical home and the state default lookup. -type RepositorySource interface { - Resolve(selection.RepositoryRequest) (RepositoryIdentity, error) -} +type RepositorySource = evaluation.RepositorySource // RepositoryIdentity is the canonical repository pair one apply compiles from. -type RepositoryIdentity struct { - Root string - Home string -} +type RepositoryIdentity = evaluation.RepositoryIdentity // Compiler compiles the current-platform plan from a repository. -type Compiler interface { - Compile(repository.CompileInput) (deployment.Plan, error) -} +type Compiler = evaluation.Compiler // StateReader is the narrow read-only port over the persisted rows and the // per-installation secret hash key of one repository pair. It never // registers, retires, or mutates rows. -type StateReader interface { - FileBaselines(root, home string) ([]state.FileBaseline, error) - AliasBaselines(root, home string) ([]state.AliasBaseline, error) - RecoverHashKey() ([32]byte, error) -} +type StateReader = evaluation.StateReader // BaselineStore establishes or replaces the equal source/target baseline // after a durable write, for both file and alias rows. diff --git a/internal/application/evaluation/semantics.go b/internal/application/evaluation/semantics.go new file mode 100644 index 0000000..86ec205 --- /dev/null +++ b/internal/application/evaluation/semantics.go @@ -0,0 +1,120 @@ +package evaluation + +import ( + "context" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/reconcile" + "github.com/alyraffauf/cattery/internal/secrets" +) + +func (service *Service) classify(ctx context.Context, assembly reconcile.EvaluationSnapshot) ([]Record, error) { + semantics := &semanticState{reader: service.state, client: service.secrets, commandLabel: service.commandLabel} + records := assembly.All() + evaluated := make([]Record, 0, len(records)) + for _, record := range records { + fingerprints, err := semantics.fingerprints(fingerprintInput{ + context: ctx, home: assembly.HomePath, record: record, + includeUnmanagedTargetDigest: service.includeUnmanagedTargetDigest, + }) + if err != nil { + return nil, err + } + evaluated = append(evaluated, Record{ + Evaluation: record, + File: reconcile.ClassifyFile(record, fingerprints), + Alias: reconcile.ClassifyAlias(record, fingerprints), + Retirement: reconcile.ClassifyRetirement(record, assembly.Platform), + Semantics: fingerprints, + }) + } + return evaluated, nil +} + +type fingerprintInput struct { + context context.Context + home string + record reconcile.Evaluation + includeUnmanagedTargetDigest bool +} + +// semanticState recovers the installation hash key at most once per run. +type semanticState struct { + reader StateReader + client *secrets.Client + commandLabel string + key [32]byte + haveKey bool +} + +func (state *semanticState) fingerprints(input fingerprintInput) (reconcile.FileSemantics, error) { + ctx, home, record := input.context, input.home, input.record + if record.Entry != reconcile.PlanEntryFile { + if input.includeUnmanagedTargetDigest && record.Target.Kind() == reconcile.KindFile { + return reconcile.FileSemantics{Target: record.Target.Digest()}, nil + } + return reconcile.FileSemantics{}, nil + } + if record.File.Kind == deployment.FileOrdinary { + return reconcile.FileSemantics{ + Source: record.Source.Snapshot().Semantic(), + Target: record.Target.Digest(), + }, nil + } + return state.secretFingerprints(ctx, home, record) +} + +func (state *semanticState) secretFingerprints(ctx context.Context, home string, record reconcile.Evaluation) (reconcile.FileSemantics, error) { + semantics := reconcile.FileSemantics{} + targetFile := record.Target.Kind() == reconcile.KindFile + if !targetFile && !SecretDecryptionNeeded(record) { + return semantics, nil + } + if err := state.recover(); err != nil { + return semantics, err + } + if targetFile { + content, err := ReadTargetContent(home, record, state.commandLabel) + if err != nil { + return semantics, err + } + semantics.Target = deployment.SecretSemantic(content, state.key) + } + if SecretDecryptionNeeded(record) { + source, err := record.Source.KeyedSemantic(ctx, state.key) + if err != nil { + return semantics, categorized(err, state.commandLabel+": decrypt source "+record.File.SourceRepositoryPath) + } + semantics.Source = source + } + return semantics, nil +} + +func categorized(err error, message string) error { + if _, ok := failure.HasKind(err); ok { + return err + } + return failure.New(failure.Operational, message, err) +} + +// SecretDecryptionNeeded reports whether classification needs a secret source +// plaintext for this record. +func SecretDecryptionNeeded(record reconcile.Evaluation) bool { + if record.FileState == nil { + return record.Target.Kind() == reconcile.KindFile + } + return record.Source.Snapshot().Storage() != record.FileState.BaselineSource() +} + +func (state *semanticState) recover() error { + if state.haveKey { + return nil + } + key, err := state.reader.RecoverHashKey() + if err != nil { + return failure.New(failure.Operational, state.commandLabel+": recover hash key", err) + } + state.key, state.haveKey = key, true + return nil +} diff --git a/internal/application/evaluation/service.go b/internal/application/evaluation/service.go new file mode 100644 index 0000000..01697cf --- /dev/null +++ b/internal/application/evaluation/service.go @@ -0,0 +1,267 @@ +package evaluation + +import ( + "context" + "errors" + "os" + "slices" + "sort" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/reconcile" + "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/secrets" + "github.com/alyraffauf/cattery/internal/selection" + "github.com/alyraffauf/cattery/internal/state" +) + +// Service runs one configured immutable evaluation pipeline. +type Service struct { + source RepositorySource + compiler Compiler + state StateReader + secrets *secrets.Client + protectedTrees []string + platform deployment.Layer + platformError error + commandLabel string + includeUnmanagedTargetDigest bool +} + +// NewService constructs an evaluation service bound to its dependencies. +func NewService(dependencies Dependencies) *Service { + platform, err := deployment.ParseLayer(dependencies.Platform) + if err != nil || platform == deployment.LayerBase { + platform = "" + } + var platformError error + if dependencies.Platform != "" && err != nil { + platformError = failure.New(failure.InvalidInput, dependencies.CommandLabel+": invalid configured platform "+dependencies.Platform, err) + } + return &Service{ + source: dependencies.RepositorySource, + compiler: dependencies.Compiler, + state: dependencies.State, + secrets: dependencies.Secrets, + protectedTrees: dependencies.ProtectedTrees, + platform: platform, + platformError: platformError, + commandLabel: dependencies.CommandLabel, + includeUnmanagedTargetDigest: dependencies.IncludeUnmanagedTargetDigest, + } +} + +// Evaluate resolves the repository and runs selection, compilation, snapshot, +// assembly, semantic fingerprinting, and classification without mutation. +func (service *Service) Evaluate(ctx context.Context, request Request) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{}, err + } + if service.platform == "" { + if service.platformError != nil { + return Result{}, service.platformError + } + return Result{}, failure.New(failure.InvalidInput, service.commandLabel+": platform must be linux or darwin", nil) + } + identity, err := service.resolve(request.Repository) + if err != nil { + return Result{}, err + } + rows, err := service.readRows(identity) + if err != nil { + return Result{}, err + } + return service.evaluateRows(ctx, evaluationInput{identity: identity, rows: rows, groups: request.Groups}) +} + +type evaluationInput struct { + identity RepositoryIdentity + rows stateRows + groups []string +} + +func (service *Service) evaluateRows(ctx context.Context, input evaluationInput) (Result, error) { + full, chosen, err := service.chosen(input) + if err != nil { + return Result{}, err + } + plan, snapshot, err := service.selected(input, full, chosen) + if err != nil { + return Result{}, err + } + assembly, err := reconcile.Assemble(plan, snapshot, service.secrets) + if err != nil { + return Result{}, failure.New(failure.Operational, service.commandLabel+": assemble snapshot", err) + } + records, err := service.classify(ctx, assembly) + if err != nil { + return Result{}, err + } + return Result{ + RepositoryRoot: input.identity.Root, + HomePath: input.identity.Home, + Platform: string(service.platform), + Hooks: plan.Hooks(), + Records: records, + }, nil +} + +func (service *Service) chosen(input evaluationInput) (deployment.Plan, selection.Selection, error) { + full, err := service.compile(input.identity, nil) + if err != nil { + return deployment.Plan{}, selection.Selection{}, err + } + chosen, err := selection.CompiledAndPersisted(full.Groups(), persistedGroups(input.rows), input.groups) + if err != nil { + return deployment.Plan{}, selection.Selection{}, failure.New(failure.InvalidInput, service.commandLabel+": select groups", err) + } + return full, chosen, nil +} + +func (service *Service) selected(input evaluationInput, full deployment.Plan, chosen selection.Selection) (deployment.Plan, reconcile.StateSnapshot, error) { + plan, err := service.selectedPlan(input.identity, full, chosen) + if err != nil { + return deployment.Plan{}, reconcile.StateSnapshot{}, err + } + snapshot, err := reconcile.NewStateSnapshot(selectedRows(input.identity, input.rows, chosen)) + if err != nil { + return deployment.Plan{}, reconcile.StateSnapshot{}, failure.New(failure.Operational, service.commandLabel+": snapshot state", err) + } + return plan, snapshot, nil +} + +func (service *Service) resolve(input RepositoryInput) (RepositoryIdentity, error) { + identity, err := service.source.Resolve(selection.RepositoryRequest{ + RawExplicit: input.RawExplicit, + ExplicitSet: input.ExplicitSet, + RawEnv: input.RawEnv, + EnvSet: input.EnvSet, + WorkingDir: input.WorkingDir, + }) + if err != nil { + return RepositoryIdentity{}, failure.New(failure.InvalidInput, service.commandLabel+": resolve repository", err) + } + return identity, nil +} + +func (service *Service) readRows(identity RepositoryIdentity) (stateRows, error) { + files, err := service.state.FileBaselines(identity.Root, identity.Home) + if err != nil { + return stateRows{}, failure.New(failure.Operational, service.commandLabel+": read file rows", err) + } + aliases, err := service.state.AliasBaselines(identity.Root, identity.Home) + if err != nil { + return stateRows{}, failure.New(failure.Operational, service.commandLabel+": read alias rows", err) + } + return stateRows{files: files, aliases: aliases}, nil +} + +func (service *Service) compile(identity RepositoryIdentity, selected []string) (deployment.Plan, error) { + plan, err := service.compiler.Compile(repository.CompileInput{ + Platform: service.platform, + RepositoryRoot: identity.Root, + HomeRoot: identity.Home, + Protected: service.protectedTrees, + Selected: selected, + }) + if err != nil { + return deployment.Plan{}, compileFailure(service.commandLabel+": compile plan", err) + } + return plan, nil +} + +func compileFailure(message string, cause error) error { + var pathError *os.PathError + if errors.As(cause, &pathError) { + return failure.New(failure.Operational, message, cause) + } + return failure.New(failure.InvalidInput, message, cause) +} + +func (service *Service) selectedPlan(identity RepositoryIdentity, full deployment.Plan, chosen selection.Selection) (deployment.Plan, error) { + if chosen.Root { + return full, nil + } + selected := intersectGroups(chosen.Groups, full.Groups()) + if len(selected) == 0 { + return deployment.NewPlan(deployment.PlanInput{RepositoryRoot: identity.Root, Platform: string(service.platform)}) + } + return service.compile(identity, selected) +} + +func intersectGroups(selected, current []string) []string { + var common []string + for _, name := range selected { + if slices.Contains(current, name) { + common = append(common, name) + } + } + return common +} + +type stateRows struct { + files []state.FileBaseline + aliases []state.AliasBaseline +} + +func persistedGroups(rows stateRows) selection.PersistedGroups { + sets := &groupSets{active: make(map[string]bool), all: make(map[string]bool)} + for _, row := range rows.files { + sets.remember(row.GroupName, row.Status == state.StatusActive) + } + for _, row := range rows.aliases { + sets.remember(row.GroupName, row.Status == state.StatusActive) + } + return selection.PersistedGroups{Active: sortedKeys(sets.active), All: sortedKeys(sets.all)} +} + +type groupSets struct { + active map[string]bool + all map[string]bool +} + +func (sets *groupSets) remember(name string, active bool) { + if name == "" { + return + } + sets.all[name] = true + if active { + sets.active[name] = true + } +} + +func sortedKeys(names map[string]bool) []string { + keys := make([]string, 0, len(names)) + for name := range names { + keys = append(keys, name) + } + sort.Strings(keys) + return keys +} + +func selectedRows(identity RepositoryIdentity, rows stateRows, chosen selection.Selection) reconcile.StateRows { + return reconcile.StateRows{ + RepositoryRoot: identity.Root, + HomePath: identity.Home, + Files: keepFileRows(rows.files, chosen), + Aliases: keepAliasRows(rows.aliases, chosen), + } +} + +func keepFileRows(rows []state.FileBaseline, chosen selection.Selection) []state.FileBaseline { + kept := append([]state.FileBaseline(nil), rows...) + return slices.DeleteFunc(kept, func(row state.FileBaseline) bool { return !rowKept(row.GroupName, chosen) }) +} + +func keepAliasRows(rows []state.AliasBaseline, chosen selection.Selection) []state.AliasBaseline { + kept := append([]state.AliasBaseline(nil), rows...) + return slices.DeleteFunc(kept, func(row state.AliasBaseline) bool { return !rowKept(row.GroupName, chosen) }) +} + +func rowKept(group string, chosen selection.Selection) bool { + if group == "" { + return chosen.Root + } + return slices.Contains(chosen.Groups, group) +} diff --git a/internal/application/evaluation/target.go b/internal/application/evaluation/target.go new file mode 100644 index 0000000..8dd7c3b --- /dev/null +++ b/internal/application/evaluation/target.go @@ -0,0 +1,67 @@ +package evaluation + +import ( + "io" + "os" + "path/filepath" + + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/reconcile" +) + +// ReadTargetContent reads the regular target only while its captured identity +// and mode remain stable. This prevents semantic classification from trusting +// bytes read through a swapped target path. +func ReadTargetContent(home string, record reconcile.Evaluation, commandLabel string) ([]byte, error) { + if record.Target.Kind() != reconcile.KindFile { + return nil, nil + } + path := filepath.Join(home, filepath.FromSlash(record.TargetPath)) + file, err := openValidatedTarget(path, record, commandLabel) + if err != nil { + return nil, err + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, failure.New(failure.Operational, commandLabel+": read target "+record.TargetPath, err) + } + if err := validateOpenedTarget(targetReadInput{file: file, record: record, path: path, commandLabel: commandLabel}); err != nil { + return nil, err + } + return content, nil +} + +func openValidatedTarget(path string, record reconcile.Evaluation, commandLabel string) (*os.File, error) { + entry, err := os.Lstat(path) + if err != nil { + return nil, failure.New(failure.Operational, commandLabel+": read target "+record.TargetPath, err) + } + if !entry.Mode().IsRegular() || !record.Target.Identity().SameFileInfo(entry) { + return nil, failure.New(failure.Operational, commandLabel+": target changed "+record.TargetPath, nil) + } + file, err := os.Open(path) + if err != nil { + return nil, failure.New(failure.Operational, commandLabel+": read target "+record.TargetPath, err) + } + if err := validateOpenedTarget(targetReadInput{file: file, record: record, path: path, commandLabel: commandLabel}); err != nil { + file.Close() + return nil, err + } + return file, nil +} + +type targetReadInput struct { + file *os.File + record reconcile.Evaluation + path string + commandLabel string +} + +func validateOpenedTarget(input targetReadInput) error { + info, err := input.file.Stat() + if err != nil || !input.record.Target.Identity().SameFileInfo(info) || info.Mode().Perm() != input.record.Target.Mode() { + return failure.New(failure.Operational, input.commandLabel+": target changed "+input.path, err) + } + return nil +} diff --git a/internal/application/evaluation/types.go b/internal/application/evaluation/types.go new file mode 100644 index 0000000..fcee986 --- /dev/null +++ b/internal/application/evaluation/types.go @@ -0,0 +1,95 @@ +// Package evaluation owns the shared immutable application evaluation +// pipeline: repository resolution, state selection, plan compilation, +// snapshot assembly, semantic fingerprints, and classifications. +package evaluation + +import ( + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/reconcile" + "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/secrets" + "github.com/alyraffauf/cattery/internal/selection" + "github.com/alyraffauf/cattery/internal/state" +) + +// Dependencies are the read-only seams required by one evaluation. +type Dependencies struct { + RepositorySource RepositorySource + Compiler Compiler + State StateReader + Secrets *secrets.Client + ProtectedTrees []string + Platform string + CommandLabel string + + // IncludeUnmanagedTargetDigest preserves apply's classification of a + // state-only regular target without a producing plan entry. + IncludeUnmanagedTargetDigest bool +} + +// RepositorySource resolves the canonical repository pair for a raw request. +type RepositorySource interface { + Resolve(selection.RepositoryRequest) (RepositoryIdentity, error) +} + +// RepositoryIdentity is the canonical repository and home pair. +type RepositoryIdentity struct { + Root string + Home string +} + +// Compiler validates and compiles one platform plan. +type Compiler interface { + Compile(repository.CompileInput) (deployment.Plan, error) +} + +// StateReader reads persisted rows and the installation hash key. +type StateReader interface { + FileBaselines(root, home string) ([]state.FileBaseline, error) + AliasBaselines(root, home string) ([]state.AliasBaseline, error) + RecoverHashKey() ([32]byte, error) +} + +// RepositoryInput carries raw repository selection fields. +type RepositoryInput struct { + RawExplicit string + ExplicitSet bool + RawEnv string + EnvSet bool + WorkingDir string +} + +// Request is the shared input of one evaluation. +type Request struct { + Repository RepositoryInput + Groups []string +} + +// Record joins one immutable reconciliation evaluation with all classifications +// and its semantic fingerprints. +type Record struct { + Evaluation reconcile.Evaluation + File reconcile.FileClassification + Alias reconcile.AliasClassification + Retirement reconcile.RetirementClassification + Semantics reconcile.FileSemantics +} + +// Result is the shared outcome consumed by the inspect and apply adapters. +type Result struct { + RepositoryRoot string + HomePath string + Platform string + Hooks []deployment.Hook + Records []Record +} + +// All returns a defensive copy of the evaluated records. +func (result Result) All() []Record { + return append([]Record(nil), result.Records...) +} + +// HooksCopy returns a defensive copy of the trusted hooks. +func (result Result) HooksCopy() []deployment.Hook { + return append([]deployment.Hook(nil), result.Hooks...) +} diff --git a/internal/application/inspect/diff.go b/internal/application/inspect/diff.go index 8477c07..a770873 100644 --- a/internal/application/inspect/diff.go +++ b/internal/application/inspect/diff.go @@ -2,10 +2,8 @@ package inspect import ( "context" - "io" - "os" - "path/filepath" + "github.com/alyraffauf/cattery/internal/application/evaluation" "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/diff" "github.com/alyraffauf/cattery/internal/failure" @@ -199,7 +197,7 @@ func fileDiffRecord(home string, evaluated evaluatedRecord) (DiffRecord, error) path: evaluated.file.TargetPath, kind: StatusKindFile, action: evaluated.file.Action, reason: evaluated.file.Reason, convergence: evaluated.file.Convergence, }) - content, err := readTargetContent(home, evaluated.record) + content, err := evaluation.ReadTargetContent(home, evaluated.record, "diff") if err != nil { return DiffRecord{}, err } @@ -210,56 +208,6 @@ func fileDiffRecord(home string, evaluated evaluatedRecord) (DiffRecord, error) return DiffRecord{status: status, safe: safe}, nil } -// readTargetContent reads the exact bytes of a regular-file target captured -// beside the evaluation; every other target kind yields nil because no -// content exists to render. -func readTargetContent(home string, record reconcile.Evaluation) ([]byte, error) { - if record.Target.Kind() != reconcile.KindFile { - return nil, nil - } - path := filepath.Join(home, filepath.FromSlash(record.TargetPath)) - file, err := openValidatedTarget(path, record) - if err != nil { - return nil, err - } - defer file.Close() - content, err := io.ReadAll(file) - if err != nil { - return nil, failure.New(failure.Operational, "diff: read target "+record.TargetPath, err) - } - if err := validateOpenedTarget(file, record, path); err != nil { - return nil, err - } - return content, nil -} - -func openValidatedTarget(path string, record reconcile.Evaluation) (*os.File, error) { - entry, err := os.Lstat(path) - if err != nil { - return nil, failure.New(failure.Operational, "diff: read target "+record.TargetPath, err) - } - if !entry.Mode().IsRegular() || !record.Target.Identity().SameFileInfo(entry) { - return nil, failure.New(failure.Operational, "diff: target changed "+record.TargetPath, nil) - } - file, err := os.Open(path) - if err != nil { - return nil, failure.New(failure.Operational, "diff: read target "+record.TargetPath, err) - } - if err := validateOpenedTarget(file, record, path); err != nil { - file.Close() - return nil, err - } - return file, nil -} - -func validateOpenedTarget(file *os.File, record reconcile.Evaluation, path string) error { - info, err := file.Stat() - if err != nil || !record.Target.Identity().SameFileInfo(info) || info.Mode().Perm() != record.Target.Mode() { - return failure.New(failure.Operational, "diff: target changed "+path, err) - } - return nil -} - // diffCounts tallies the per-kind records of one diff result. func diffCounts(records []DiffRecord) (files, aliases, retired int) { return countRecordKinds(records) diff --git a/internal/application/inspect/service.go b/internal/application/inspect/service.go index dfa6981..b962c08 100644 --- a/internal/application/inspect/service.go +++ b/internal/application/inspect/service.go @@ -2,47 +2,26 @@ package inspect import ( "context" - "slices" - "sort" - "github.com/alyraffauf/cattery/internal/deployment" - "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/application/evaluation" "github.com/alyraffauf/cattery/internal/reconcile" - "github.com/alyraffauf/cattery/internal/repository" - "github.com/alyraffauf/cattery/internal/secrets" - "github.com/alyraffauf/cattery/internal/selection" - "github.com/alyraffauf/cattery/internal/state" ) type Service struct { - source RepositorySource - compiler Compiler - state StateReader - secrets *secrets.Client - protectedTrees []string - platform deployment.Layer - platformError error + evaluator *evaluation.Service } // NewService constructs the inspection service bound to the dependencies. func NewService(dependencies Dependencies) *Service { - platform, err := deployment.ParseLayer(dependencies.Platform) - if err != nil || platform == deployment.LayerBase { - platform = "" - } - var platformError error - if dependencies.Platform != "" && err != nil { - platformError = failure.New(failure.InvalidInput, "inspect: invalid configured platform "+dependencies.Platform, err) - } - return &Service{ - source: dependencies.RepositorySource, - compiler: dependencies.Compiler, - state: dependencies.State, - secrets: dependencies.Secrets, - protectedTrees: dependencies.ProtectedTrees, - platform: platform, - platformError: platformError, - } + return &Service{evaluator: evaluation.NewService(evaluation.Dependencies{ + RepositorySource: dependencies.RepositorySource, + Compiler: dependencies.Compiler, + State: dependencies.State, + Secrets: dependencies.Secrets, + ProtectedTrees: dependencies.ProtectedTrees, + Platform: dependencies.Platform, + CommandLabel: "inspect", + })} } // Evaluate performs one immutable selection, compile, snapshot, and @@ -53,348 +32,37 @@ func (service *Service) Evaluate(ctx context.Context, request Request) (Result, return service.evaluate(ctx, request) } -// evaluate resolves the repository and runs the selection, compile, -// snapshot, and classification pipeline. func (service *Service) evaluate(ctx context.Context, request Request) (Result, error) { - if err := ctx.Err(); err != nil { - return Result{}, err - } - if service.platform == "" { - if service.platformError != nil { - return Result{}, service.platformError - } - return Result{}, failure.New(failure.InvalidInput, "inspect: platform must be linux or darwin", nil) - } - identity, err := service.resolve(request.Repository) - if err != nil { - return Result{}, err - } - rows, err := service.readRows(identity) - if err != nil { - return Result{}, err - } - 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 - groups []string -} - -// evaluateRows compiles the full plan, selects the scopes, and assembles -// one classified snapshot of the selected rows. -func (service *Service) evaluateRows(ctx context.Context, input scopeInput) (Result, error) { - full, chosen, err := service.chosen(input) - if err != nil { - return Result{}, err - } - plan, snapshot, err := service.selected(input, full, chosen) - if err != nil { - return Result{}, err - } - assembly, err := reconcile.Assemble(plan, snapshot, service.secrets) - if err != nil { - return Result{}, failure.New(failure.Operational, "inspect: assemble snapshot", err) - } - records, err := service.classify(ctx, assembly) - if err != nil { - return Result{}, err - } - return Result{home: input.identity.Home, records: records}, 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 { - return deployment.Plan{}, selection.Selection{}, err - } - chosen, err := selection.CompiledAndPersisted(full.Groups(), persistedGroups(input.rows), input.groups) - if err != nil { - return deployment.Plan{}, selection.Selection{}, failure.New(failure.InvalidInput, "inspect: select groups", err) - } - 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 { - return deployment.Plan{}, reconcile.StateSnapshot{}, err - } - snapshot, err := reconcile.NewStateSnapshot(selectedRows(input.identity, input.rows, chosen)) - if err != nil { - return deployment.Plan{}, reconcile.StateSnapshot{}, failure.New(failure.Operational, "inspect: snapshot state", err) - } - return plan, snapshot, nil -} - -// resolve maps the raw repository fields and resolves the canonical pair. -func (service *Service) resolve(input RepositoryInput) (RepositoryIdentity, error) { - identity, err := service.source.Resolve(repositoryRequest(input)) - if err != nil { - return RepositoryIdentity{}, failure.New(failure.InvalidInput, "inspect: resolve repository", err) - } - return identity, nil -} - -// repositoryRequest copies the raw repository fields into the selection -// request shape. -func repositoryRequest(input RepositoryInput) selection.RepositoryRequest { - return selection.RepositoryRequest{ - RawExplicit: input.RawExplicit, - ExplicitSet: input.ExplicitSet, - RawEnv: input.RawEnv, - EnvSet: input.EnvSet, - WorkingDir: input.WorkingDir, - } -} - -func (service *Service) readRows(identity RepositoryIdentity) (stateRows, error) { - files, err := service.state.FileBaselines(identity.Root, identity.Home) - if err != nil { - return stateRows{}, failure.New(failure.Operational, "inspect: read file rows", err) - } - aliases, err := service.state.AliasBaselines(identity.Root, identity.Home) - if err != nil { - return stateRows{}, failure.New(failure.Operational, "inspect: read alias rows", err) - } - return stateRows{files: files, aliases: aliases}, nil -} - -// compile validates the repository and returns the plan restricted to the -// selection (nil selects everything). -func (service *Service) compile(identity RepositoryIdentity, selected []string) (deployment.Plan, error) { - plan, err := service.compiler.Compile(repository.CompileInput{ - Platform: service.platform, - RepositoryRoot: identity.Root, - HomeRoot: identity.Home, - Protected: service.protectedTrees, - Selected: selected, + shared, err := service.evaluator.Evaluate(ctx, evaluation.Request{ + Repository: evaluation.RepositoryInput{ + RawExplicit: request.Repository.RawExplicit, + ExplicitSet: request.Repository.ExplicitSet, + RawEnv: request.Repository.RawEnv, + EnvSet: request.Repository.EnvSet, + WorkingDir: request.Repository.WorkingDir, + }, + Groups: request.Groups, }) if err != nil { - return deployment.Plan{}, compileFailure("inspect: compile plan", err) - } - return plan, nil -} - -// selectedPlan restricts the full plan to the selection: root-only -// selections keep it, explicit selections filter to the selected repository -// groups, and pure state-only selections yield an empty plan. -func (service *Service) selectedPlan(identity RepositoryIdentity, full deployment.Plan, chosen selection.Selection) (deployment.Plan, error) { - if chosen.Root { - return full, nil - } - selected := intersectGroups(chosen.Groups, full.Groups()) - if len(selected) == 0 { - return emptyPlan(identity, service.platform) - } - return service.compile(identity, selected) -} - -func intersectGroups(selected, current []string) []string { - var common []string - for _, name := range selected { - if slices.Contains(current, name) { - common = append(common, name) - } - } - return common -} - -// emptyPlan builds the degenerate plan of a pure state-only selection, -// which carries no producer and only joins persisted rows. -func emptyPlan(identity RepositoryIdentity, platform deployment.Layer) (deployment.Plan, error) { - return deployment.NewPlan(deployment.PlanInput{ - RepositoryRoot: identity.Root, - Platform: string(platform), - }) -} - -type stateRows struct { - files []state.FileBaseline - aliases []state.AliasBaseline -} - -// persistedGroups derives Active and All group names from the persisted -// rows. -func persistedGroups(rows stateRows) selection.PersistedGroups { - sets := &groupSets{active: make(map[string]bool), all: make(map[string]bool)} - for _, row := range rows.files { - sets.remember(row.GroupName, row.Status == state.StatusActive) - } - for _, row := range rows.aliases { - sets.remember(row.GroupName, row.Status == state.StatusActive) - } - return selection.PersistedGroups{Active: sortedKeys(sets.active), All: sortedKeys(sets.all)} -} - -type groupSets struct { - active map[string]bool - all map[string]bool -} - -func (sets *groupSets) remember(name string, active bool) { - if name == "" { - return - } - sets.all[name] = true - if active { - sets.active[name] = true - } -} - -// sortedKeys returns the map keys in bytewise order. -func sortedKeys(names map[string]bool) []string { - keys := make([]string, 0, len(names)) - for name := range names { - keys = append(keys, name) - } - sort.Strings(keys) - return keys -} - -// selectedRows keeps root rows only for root selections and rows of the -// selected groups otherwise. -func selectedRows(identity RepositoryIdentity, rows stateRows, chosen selection.Selection) reconcile.StateRows { - return reconcile.StateRows{ - RepositoryRoot: identity.Root, - HomePath: identity.Home, - Files: keepFileRows(rows.files, chosen), - Aliases: keepAliasRows(rows.aliases, chosen), + return Result{}, err } -} - -func keepFileRows(rows []state.FileBaseline, chosen selection.Selection) []state.FileBaseline { - kept := append([]state.FileBaseline(nil), rows...) - return slices.DeleteFunc(kept, func(row state.FileBaseline) bool { return !rowKept(row.GroupName, chosen) }) -} - -func keepAliasRows(rows []state.AliasBaseline, chosen selection.Selection) []state.AliasBaseline { - kept := append([]state.AliasBaseline(nil), rows...) - 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 + records := shared.All() + evaluated := make([]evaluatedRecord, 0, len(records)) + for _, record := range records { + evaluated = append(evaluated, evaluatedRecord{ + record: record.Evaluation, + file: record.File, + alias: record.Alias, + retirement: record.Retirement, + }) } - return slices.Contains(chosen.Groups, group) + return Result{home: shared.HomePath, records: evaluated}, nil } -// evaluatedRecord pairs one snapshot evaluation with its three -// classifications. +// evaluatedRecord is the inspect-owned projection used by status and diff. type evaluatedRecord struct { record reconcile.Evaluation file reconcile.FileClassification alias reconcile.AliasClassification retirement reconcile.RetirementClassification } - -// classify computes the semantic fingerprints and the file, alias, and -// retirement classifications of every evaluation record. -func (service *Service) classify(ctx context.Context, assembly reconcile.EvaluationSnapshot) ([]evaluatedRecord, error) { - semantics := &semanticState{reader: service.state, client: service.secrets} - records := assembly.All() - evaluated := make([]evaluatedRecord, 0, len(records)) - for _, record := range records { - fingerprints, err := semantics.fingerprints(ctx, assembly.HomePath, record) - if err != nil { - return nil, err - } - evaluated = append(evaluated, evaluatedRecord{ - record: record, - file: reconcile.ClassifyFile(record, fingerprints), - alias: reconcile.ClassifyAlias(record, fingerprints), - retirement: reconcile.ClassifyRetirement(record, assembly.Platform), - }) - } - return evaluated, nil -} - -// semanticState carries the per-evaluation hash key, recovered once at -// most and only when a secret record needs fingerprints. -type semanticState struct { - reader StateReader - client *secrets.Client - key [32]byte - haveKey bool -} - -// fingerprints derives the semantic fingerprints of one evaluation record; -// secrets fingerprint on demand per PLAN.md Section 9.1. -func (state *semanticState) fingerprints(ctx context.Context, home string, record reconcile.Evaluation) (reconcile.FileSemantics, error) { - if record.Entry != reconcile.PlanEntryFile { - return reconcile.FileSemantics{}, nil - } - if record.File.Kind == deployment.FileOrdinary { - return reconcile.FileSemantics{ - Source: record.Source.Snapshot().Semantic(), - Target: record.Target.Digest(), - }, nil - } - return state.secretFingerprints(ctx, home, record) -} - -// secretFingerprints derives the keyed fingerprints of one secret record: -// the source decrypts only when its raw storage changed or the row is -// unbaselined with a regular target. -func (state *semanticState) secretFingerprints(ctx context.Context, home string, record reconcile.Evaluation) (reconcile.FileSemantics, error) { - semantics := reconcile.FileSemantics{} - targetFile := record.Target.Kind() == reconcile.KindFile - if !targetFile && !secretDecryptNeeded(record) { - return semantics, nil - } - if err := state.recover(); err != nil { - return semantics, err - } - if targetFile { - content, err := readTargetContent(home, record) - if err != nil { - return semantics, failure.New(failure.Operational, "inspect: read target "+record.TargetPath, err) - } - semantics.Target = deployment.SecretSemantic(content, state.key) - } - if secretDecryptNeeded(record) { - source, err := record.Source.KeyedSemantic(ctx, state.key) - if err != nil { - return semantics, categorized(err, "inspect: decrypt source "+record.File.SourceRepositoryPath) - } - semantics.Source = source - } - return semantics, nil -} - -func categorized(err error, message string) error { - if _, ok := failure.HasKind(err); ok { - return err - } - return failure.New(failure.Operational, message, err) -} - -// secretDecryptNeeded reports whether a secret source must decrypt for -// classification (PLAN.md Section 9.1). -func secretDecryptNeeded(record reconcile.Evaluation) bool { - if record.FileState == nil { - return record.Target.Kind() == reconcile.KindFile - } - return record.Source.Snapshot().Storage() != record.FileState.BaselineSource() -} - -// recover loads the per-installation hash key once for the evaluation. -func (state *semanticState) recover() error { - if state.haveKey { - return nil - } - key, err := state.reader.RecoverHashKey() - if err != nil { - return failure.New(failure.Operational, "inspect: recover hash key", err) - } - state.key, state.haveKey = key, true - return nil -} diff --git a/internal/application/inspect/service_test.go b/internal/application/inspect/service_test.go index d9ec0fa..0742bab 100644 --- a/internal/application/inspect/service_test.go +++ b/internal/application/inspect/service_test.go @@ -49,7 +49,6 @@ func newFixture(t *testing.T) *fixture { return &fixture{service: service, source: source, rows: rows, root: root, home: home} } -// compileFunc adapts the package compiler function to the narrow port. type compileFunc func(repository.CompileInput) (deployment.Plan, error) func (adapter compileFunc) Compile(input repository.CompileInput) (deployment.Plan, error) { @@ -104,7 +103,6 @@ func writeFile(t *testing.T, path, content string) { } } -// writePrivateFile writes a file with the exact forced secret mode 0600. func writePrivateFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o600); err != nil { @@ -112,8 +110,6 @@ func writePrivateFile(t *testing.T, path, content string) { } } -// repositoryTree creates a repository with one ordinary file per group -// plus a root file. func repositoryTree(t *testing.T, groups []string) string { t.Helper() root := t.TempDir() @@ -124,14 +120,12 @@ func repositoryTree(t *testing.T, groups []string) string { return root } -// useRepository points the fixture at a fresh repository tree. func useRepository(t *testing.T, fx *fixture, groups []string) { t.Helper() fx.root = repositoryTree(t, groups) fx.source.identity.Root = fx.root } -// baselineInput bundles the target, group, source, and content of one row. type baselineInput struct { target string group string @@ -329,7 +323,14 @@ func testDecryptClassifies(t *testing.T) { secretRepository(t, fx) ciphertext := secretFixtureJSON() writePrivateFile(t, filepath.Join(fx.home, "token"), string(ciphertext)) - fx.service.secrets = fakeClient(t) + fx.service = NewService(Dependencies{ + RepositorySource: fx.source, + Compiler: compileFunc(repository.Compile), + State: fx.rows, + Secrets: fakeClient(t), + ProtectedTrees: []string{filepath.Join(fx.home, "state")}, + Platform: "linux", + }) fx.rows.rows = stateRows{files: []state.FileBaseline{ fileRow(baselineInput{target: "token", group: "g1", source: []byte("older ciphertext\n"), content: ciphertext}, &fx.rows.key), }} @@ -372,7 +373,9 @@ func testFailureResolution(t *testing.T) { } func testFailurePlatform(t *testing.T) { - evaluateFailure(t, failure.InvalidInput, func(t *testing.T, fx *fixture) { fx.service.platform = "" }) + evaluateFailure(t, failure.InvalidInput, func(t *testing.T, fx *fixture) { + fx.service = NewService(Dependencies{Platform: ""}) + }) } func testFailureCompilation(t *testing.T) { diff --git a/internal/application/inspect/state_rows_test.go b/internal/application/inspect/state_rows_test.go new file mode 100644 index 0000000..32db68a --- /dev/null +++ b/internal/application/inspect/state_rows_test.go @@ -0,0 +1,8 @@ +package inspect + +import "github.com/alyraffauf/cattery/internal/state" + +type stateRows struct { + files []state.FileBaseline + aliases []state.AliasBaseline +} diff --git a/internal/application/inspect/status.go b/internal/application/inspect/status.go index d9fc12e..53a9dd8 100644 --- a/internal/application/inspect/status.go +++ b/internal/application/inspect/status.go @@ -2,22 +2,12 @@ package inspect import ( "context" - "errors" "fmt" - "os" "github.com/alyraffauf/cattery/internal/failure" "github.com/alyraffauf/cattery/internal/reconcile" ) -func compileFailure(message string, cause error) error { - var pathError *os.PathError - if errors.As(cause, &pathError) { - return failure.New(failure.Operational, message, cause) - } - return failure.New(failure.InvalidInput, message, cause) -} - // StatusKind names the representation class of one status record. type StatusKind int diff --git a/internal/application/inspect/types.go b/internal/application/inspect/types.go index 66b464f..5c13b46 100644 --- a/internal/application/inspect/types.go +++ b/internal/application/inspect/types.go @@ -8,11 +8,8 @@ package inspect import ( - "github.com/alyraffauf/cattery/internal/deployment" - "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/application/evaluation" "github.com/alyraffauf/cattery/internal/secrets" - "github.com/alyraffauf/cattery/internal/selection" - "github.com/alyraffauf/cattery/internal/state" ) // Dependencies bundles the injectable seams of the inspection service. @@ -34,31 +31,20 @@ type Dependencies struct { // RepositorySource resolves the canonical repository pair for a selection // request. The composition root satisfies it with a selection resolver bound // to the canonical home and the state default lookup. -type RepositorySource interface { - Resolve(selection.RepositoryRequest) (RepositoryIdentity, error) -} +type RepositorySource = evaluation.RepositorySource // RepositoryIdentity is the canonical repository pair one inspection // evaluates from. It is the inspect-owned projection of the lower selection // result so no backend type leaks through the application seam. -type RepositoryIdentity struct { - Root string - Home string -} +type RepositoryIdentity = evaluation.RepositoryIdentity // Compiler validates and compiles one platform plan from a repository. -type Compiler interface { - Compile(repository.CompileInput) (deployment.Plan, error) -} +type Compiler = evaluation.Compiler // StateReader is the narrow read-only port over the persisted rows and the // per-installation secret hash key of one repository pair. It never // registers, retires, or mutates rows. -type StateReader interface { - FileBaselines(root, home string) ([]state.FileBaseline, error) - AliasBaselines(root, home string) ([]state.AliasBaseline, error) - RecoverHashKey() ([32]byte, error) -} +type StateReader = evaluation.StateReader // RepositoryInput carries the raw repository fields the CLI adapter copies // mechanically: the explicit --repo value and its presence, the raw diff --git a/internal/quality/architecture_test.go b/internal/quality/architecture_test.go index e620ff6..9492245 100644 --- a/internal/quality/architecture_test.go +++ b/internal/quality/architecture_test.go @@ -37,8 +37,9 @@ var allowedFamilies = map[string][]string{ "selection": {"deployment", "pathsafe", "state"}, "application/initialize": {"failure", "pathsafe", "state"}, "application/validate": {"deployment", "failure", "repository", "selection"}, - "application/inspect": {"deployment", "diff", "failure", "reconcile", "repository", "secrets", "selection", "state"}, - "application/apply": {"deployment", "diff", "failure", "filesystem", "hooks", "pathsafe", "reconcile", "repository", "secrets", "selection", "state"}, + "application/evaluation": {"deployment", "failure", "reconcile", "repository", "secrets", "selection", "state"}, + "application/inspect": {"application/evaluation", "deployment", "diff", "failure", "reconcile", "repository", "secrets", "selection", "state"}, + "application/apply": {"application/evaluation", "deployment", "diff", "failure", "filesystem", "hooks", "pathsafe", "reconcile", "repository", "secrets", "selection", "state"}, "application/add": {"deployment", "failure", "filesystem", "pathsafe", "reconcile", "repository", "secrets", "selection", "state"}, "application/version": {"buildinfo"}, "bootstrap": {"application/initialize", "application/validate", "application/inspect", "application/apply", "application/add", "application/version", "cli", "deployment", "failure", "filesystem", "hooks", "repository", "secrets", "selection", "state"},