diff --git a/cmd/cattery/main.go b/cmd/cattery/main.go index 6429f7d..0dd5d54 100644 --- a/cmd/cattery/main.go +++ b/cmd/cattery/main.go @@ -10,7 +10,6 @@ import ( "os" "os/signal" "path/filepath" - "strings" "syscall" "time" @@ -20,6 +19,16 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() (exitStatus int) { + defer func() { + if recover() != nil { + _, _ = os.Stderr.WriteString("cattery: internal panic\n") + exitStatus = cli.ExitFailure + } + }() ctx, cancel := context.WithCancelCause(context.Background()) signals := make(chan os.Signal, 1) signal.Notify(signals, os.Interrupt, syscall.SIGTERM) @@ -34,7 +43,7 @@ func main() { Now: time.Now, Protected: []string{stateHome}, }) - os.Exit(exitCode(ctx, application, os.Args[1:])) + return exitCode(ctx, application, os.Args[1:]) } // exitCode runs the application and maps an interrupted context to the @@ -51,9 +60,9 @@ func exitCode(ctx context.Context, application *cli.Application, args []string) func signalCode(cause error) int { var interruption *failure.Interruption if errors.As(cause, &interruption) && interruption.Signal == failure.Terminate { - return 143 + return cli.ExitTerminate } - return 130 + return cli.ExitInterrupt } // forwardSignals cancels the process context with the typed interruption. @@ -73,9 +82,9 @@ func forwardSignals(ctx context.Context, cancel context.CancelCauseFunc, signals // stateHomeOf derives the XDG state base directory; the state store // appends its own cattery directory beneath it. func stateHomeOf(environment []string) string { - base := envValue(environment, "XDG_STATE_HOME") + base := cli.EnvironmentValue(environment, "XDG_STATE_HOME") if base == "" { - home := envValue(environment, "HOME") + home := cli.EnvironmentValue(environment, "HOME") if home == "" { return "" } @@ -84,17 +93,6 @@ func stateHomeOf(environment []string) string { return base } -// envValue returns the value of one environment entry. -func envValue(environment []string, name string) string { - prefix := name + "=" - for _, entry := range environment { - if strings.HasPrefix(entry, prefix) { - return strings.TrimPrefix(entry, prefix) - } - } - return "" -} - // workingDir returns the initial working directory. func workingDir() string { directory, err := os.Getwd() diff --git a/cmd/cattery/main_test.go b/cmd/cattery/main_test.go index 25b28de..dba1985 100644 --- a/cmd/cattery/main_test.go +++ b/cmd/cattery/main_test.go @@ -30,24 +30,24 @@ func TestMainBoundary(t *testing.T) { } func testBoundaryInterrupt(t *testing.T) { - if code := signalCode(failure.NewInterruption(failure.Interrupt)); code != 130 { - t.Fatalf("code = %d, want 130", code) + if code := signalCode(failure.NewInterruption(failure.Interrupt)); code != cli.ExitInterrupt { + t.Fatalf("code = %d, want %d", code, cli.ExitInterrupt) } - if code := signalCode(errors.New("other")); code != 130 { - t.Fatalf("unknown cause code = %d, want 130", code) + if code := signalCode(errors.New("other")); code != cli.ExitInterrupt { + t.Fatalf("unknown cause code = %d, want %d", code, cli.ExitInterrupt) } } func testBoundaryTerminate(t *testing.T) { - if code := signalCode(failure.NewInterruption(failure.Terminate)); code != 143 { - t.Fatalf("code = %d, want 143", code) + if code := signalCode(failure.NewInterruption(failure.Terminate)); code != cli.ExitTerminate { + t.Fatalf("code = %d, want %d", code, cli.ExitTerminate) } } func testBoundaryPassthrough(t *testing.T) { application := stubApplication(t) - if code := exitCode(context.Background(), application, nil); code != 0 { - t.Fatalf("code = %d, want 0", code) + if code := exitCode(context.Background(), application, nil); code != cli.ExitSuccess { + t.Fatalf("code = %d, want %d", code, cli.ExitSuccess) } } @@ -69,8 +69,8 @@ func testBoundaryForwarding(t *testing.T) { application := stubApplication(t) ctx, cancel := context.WithCancelCause(context.Background()) cancel(failure.NewInterruption(failure.Interrupt)) - if code := exitCode(ctx, application, nil); code != 130 { - t.Fatalf("code = %d, want 130 for an interrupted context", code) + if code := exitCode(ctx, application, nil); code != cli.ExitInterrupt { + t.Fatalf("code = %d, want %d for an interrupted context", code, cli.ExitInterrupt) } } diff --git a/internal/application/apply/evaluate.go b/internal/application/apply/evaluate.go index 7aaed52..a853f4b 100644 --- a/internal/application/apply/evaluate.go +++ b/internal/application/apply/evaluate.go @@ -9,6 +9,7 @@ import ( "github.com/alyraffauf/cattery/internal/secrets" ) +// Service evaluates an apply request before decisions, hooks, or mutations. type Service struct { evaluator *evaluation.Service state StateReader @@ -23,6 +24,7 @@ type Service struct { resolver DecisionResolver } +// NewService constructs the apply service over its injected ports. func NewService(dependencies Dependencies) *Service { return &Service{ evaluator: evaluation.NewService(evaluation.Dependencies{ @@ -48,6 +50,7 @@ func NewService(dependencies Dependencies) *Service { } } +// Evaluate returns the immutable candidate set for one apply request. func (service *Service) Evaluate(ctx context.Context, request Request) (Candidates, error) { return service.evaluate(ctx, request) } @@ -87,6 +90,8 @@ func (service *Service) evaluate(ctx context.Context, request Request) (Candidat } // Candidate is the apply-owned projection of one shared evaluation record. +// Candidate joins one target evaluation with its classifications and semantic +// fingerprints for the apply phases. type Candidate struct { record reconcile.Evaluation file reconcile.FileClassification @@ -105,16 +110,21 @@ type Candidates struct { records []Candidate } +// Root returns the canonical repository root. func (c Candidates) Root() string { return c.root } +// Home returns the canonical home root. func (c Candidates) Home() string { return c.home } +// Platform returns the selected deployment platform. func (c Candidates) Platform() string { return c.platform } +// Hooks returns a defensive copy of the compiled hooks. func (c Candidates) Hooks() []deployment.Hook { return append([]deployment.Hook(nil), c.hooks...) } +// All returns a defensive copy of candidates in target-path order. func (c Candidates) All() []Candidate { return append([]Candidate(nil), c.records...) } diff --git a/internal/application/apply/hooks.go b/internal/application/apply/hooks.go index 23096e4..4b29b38 100644 --- a/internal/application/apply/hooks.go +++ b/internal/application/apply/hooks.go @@ -14,12 +14,6 @@ const ( hookResultPartial = "partial" ) -// RunHookPipeline runs the hook-gated apply filesystem phase: before hooks -// with CATTERY_RESULT=pending, the all-source guard and the file and alias -// executors, then after hooks only when the phase completed, with -// CATTERY_RESULT=success or partial (PLAN.md Sections 10.4-10.5). A -// mid-filesystem operational failure skips every after hook, and after -// failures never roll back completed writes. // PipelineInput bundles the request, plan, and candidates of one // hook-gated apply phase. type PipelineInput struct { @@ -28,6 +22,12 @@ type PipelineInput struct { Candidates Candidates } +// RunHookPipeline runs the hook-gated apply filesystem phase: before hooks +// with CATTERY_RESULT=pending, the all-source guard and the file and alias +// executors, then after hooks only when the phase completed, with +// CATTERY_RESULT=success or partial (PLAN.md Sections 10.4-10.5). A +// mid-filesystem operational failure skips every after hook, and after +// failures never roll back completed writes. func (service *Service) RunHookPipeline(ctx context.Context, input PipelineInput) ([]ItemResult, error) { records := input.Plan.Records() if input.Plan.WithHooks() { diff --git a/internal/application/evaluation/target.go b/internal/application/evaluation/target.go index 8dd7c3b..b0f8d94 100644 --- a/internal/application/evaluation/target.go +++ b/internal/application/evaluation/target.go @@ -26,6 +26,7 @@ func ReadTargetContent(home string, record reconcile.Evaluation, commandLabel st if err != nil { return nil, failure.New(failure.Operational, commandLabel+": read target "+record.TargetPath, err) } + // Revalidate after reading: the target may have been replaced during ReadAll. if err := validateOpenedTarget(targetReadInput{file: file, record: record, path: path, commandLabel: commandLabel}); err != nil { return nil, err } diff --git a/internal/bootstrap/build.go b/internal/bootstrap/build.go index 2820af7..bcfd15d 100644 --- a/internal/bootstrap/build.go +++ b/internal/bootstrap/build.go @@ -3,7 +3,6 @@ package bootstrap import ( "log/slog" "runtime" - "strings" "time" "github.com/alyraffauf/cattery/internal/cli" @@ -30,7 +29,7 @@ func Build(input BuildInput) *cli.Application { adapters := NewAdapters(input.StateHome, input.Now) services := BuildApplications(ApplicationsInput{ Adapters: adapters, - Home: envValue(input.Environment, "HOME"), + Home: cli.EnvironmentValue(input.Environment, "HOME"), Platform: currentPlatform(), Protected: input.Protected, Stdin: input.Streams.Stdin, @@ -61,17 +60,6 @@ func Build(input BuildInput) *cli.Application { }, runtimeValues) } -// envValue returns the value of one environment entry. -func envValue(environment []string, name string) string { - prefix := name + "=" - for _, entry := range environment { - if strings.HasPrefix(entry, prefix) { - return strings.TrimPrefix(entry, prefix) - } - } - return "" -} - // currentPlatform derives the deployment layer from the runtime GOOS. func currentPlatform() deployment.Layer { platform, err := deployment.ParseLayer(runtime.GOOS) diff --git a/internal/cli/execute.go b/internal/cli/execute.go index 6bdd9b0..cb4a047 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -8,14 +8,24 @@ import ( "github.com/alyraffauf/cattery/internal/failure" ) +const ( + ExitSuccess = 0 + ExitFailure = 1 + ExitDifference = 2 + ExitHook = 3 + ExitDependency = 4 + ExitInterrupt = 130 + ExitTerminate = 143 +) + // Execute runs one application over the given arguments, writes one // diagnostic on failure, and maps every joined category and signal to the -// Section 11.8 exit status. Numeric statuses exist only here and -// os.Exit stays in the process entrypoint. +// Section 11.8 exit status. Status constants stay here and os.Exit remains in +// the process entrypoint. func Execute(ctx context.Context, application *Application, args []string) int { err := application.Execute(ctx, args) if err == nil { - return 0 + return ExitSuccess } _, _ = fmt.Fprintf(application.root.ErrOrStderr(), "%s\n", err) return exitStatus(err) @@ -28,21 +38,21 @@ func exitStatus(err error) int { var interruption *failure.Interruption if errors.As(err, &interruption) { if interruption.Signal == failure.Terminate { - return 143 + return ExitTerminate } - return 130 + return ExitInterrupt } kind, ok := failure.HasKind(err) if !ok { - return 1 + return ExitFailure } switch kind { case failure.Hook: - return 3 + return ExitHook case failure.Dependency: - return 4 + return ExitDependency case failure.Difference: - return 2 + return ExitDifference } - return 1 + return ExitFailure } diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go index 2e3e659..0d76ba7 100644 --- a/internal/cli/runtime.go +++ b/internal/cli/runtime.go @@ -113,3 +113,14 @@ func (r Runtime) EnvValue(name string) (string, bool) { } return "", false } + +// EnvironmentValue returns the value of the first matching environment entry. +func EnvironmentValue(environment []string, name string) string { + prefix := name + "=" + for _, entry := range environment { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} diff --git a/internal/diff/safe.go b/internal/diff/safe.go index f71c702..547991d 100644 --- a/internal/diff/safe.go +++ b/internal/diff/safe.go @@ -65,10 +65,6 @@ func ParseTag(name string) Tag { // Valid reports whether tag is one of the supported constants. func (t Tag) Valid() bool { return t >= TagNone && t <= TagSecret } -// SafeRecord is one immutable, output-safe diff record for a destination. -// Text records carry precomputed printable unified-diff lines, binary records -// carry ordinary-file sizes and hashes, and secret records carry no payload -// at all (PLAN.md Sections 9.6 and 12.4). // SafeRecordInput carries the renderable fields of one safe record. type SafeRecordInput struct { TargetPath string @@ -98,6 +94,10 @@ func NewSafeRecord(input SafeRecordInput) SafeRecord { } } +// SafeRecord is one immutable, output-safe diff record for a destination. +// Text records carry precomputed printable unified-diff lines, binary records +// carry ordinary-file sizes and hashes, and secret records carry no payload +// at all (PLAN.md Sections 9.6 and 12.4). type SafeRecord struct { targetPath string tag Tag