diff --git a/.changes/unreleased/+portable-python-final-image-compatibility.yaml b/.changes/unreleased/+portable-python-final-image-compatibility.yaml new file mode 100644 index 00000000..1774ad04 --- /dev/null +++ b/.changes/unreleased/+portable-python-final-image-compatibility.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Reject final images that drift from locked portable Python interpreter and wheel-tag evidence. diff --git a/internal/dockerdeploy/full_validation_python_profile.go b/internal/dockerdeploy/full_validation_python_profile.go index d265e129..c21bffc7 100644 --- a/internal/dockerdeploy/full_validation_python_profile.go +++ b/internal/dockerdeploy/full_validation_python_profile.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "slices" "github.com/omry/reploy/internal/canonical" "github.com/omry/reploy/internal/probe" @@ -74,6 +75,9 @@ func validatePythonProfileObservation( locked.InvocationPath, err, ) } + if err := validatePortablePythonFinalImageFacts(lockedFacts, facts); err != nil { + return providers.ExecutableEvidence{}, err + } matches, err := pythonprovider.InterpreterVersionSatisfies(requirement.VersionConstraint, facts.Version) if err != nil { return providers.ExecutableEvidence{}, err @@ -94,6 +98,37 @@ func validatePythonProfileObservation( return fresh, nil } +func validatePortablePythonFinalImageFacts( + locked pythonprovider.InterpreterInspectionFactsV2, + fresh pythonprovider.InterpreterInspectionFactsV2, +) error { + if len(locked.TestedTags) == 0 { + return nil + } + if fresh.Version != locked.Version { + return fmt.Errorf("portable Python final-image interpreter version changed from %q to %q", locked.Version, fresh.Version) + } + if fresh.Implementation != locked.Implementation { + return fmt.Errorf("portable Python final-image interpreter implementation changed from %q to %q", locked.Implementation, fresh.Implementation) + } + if fresh.ABI != locked.ABI { + return fmt.Errorf("portable Python final-image interpreter ABI changed from %q to %q", locked.ABI, fresh.ABI) + } + if fresh.Libc != locked.Libc { + return fmt.Errorf("portable Python final-image interpreter libc changed from %q to %q", locked.Libc, fresh.Libc) + } + if fresh.LibcMajor != locked.LibcMajor || fresh.LibcMinor != locked.LibcMinor { + return fmt.Errorf( + "portable Python final-image interpreter libc release changed from %s.%s to %s.%s", + locked.LibcMajor, locked.LibcMinor, fresh.LibcMajor, fresh.LibcMinor, + ) + } + if !slices.Equal(fresh.CompatibleTags, locked.CompatibleTags) { + return fmt.Errorf("portable Python final-image interpreter compatible tags changed") + } + return nil +} + func (session *ImageValidationSession) runPythonInterpreterInspection( ctx context.Context, interpreterPath string, diff --git a/internal/dockerdeploy/full_validation_python_profile_test.go b/internal/dockerdeploy/full_validation_python_profile_test.go index a7f4a4aa..b85a561f 100644 --- a/internal/dockerdeploy/full_validation_python_profile_test.go +++ b/internal/dockerdeploy/full_validation_python_profile_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/omry/reploy/internal/canonical" "github.com/omry/reploy/internal/providers" pythonprovider "github.com/omry/reploy/internal/providers/python" ) @@ -114,3 +115,107 @@ func TestValidatePythonProfileObservationRejectsMalformedProbeBeforeExecution(t t.Fatalf("malformed interpreter error = %v", err) } } + +func TestValidatePythonProfileObservationPreservesPortableInterpreterFacts(t *testing.T) { + completion, operation, _ := providerBuildCompletionFixture(t) + defer operation.Unlock() + input := completion.Validation.Final + profile := input.Profiles[0] + profile.SelectedExecutables = append([]providers.ExecutableEvidence{}, profile.SelectedExecutables...) + locked := profile.SelectedExecutables[0] + lockedFacts, err := pythonprovider.DecodeInterpreterFactsV2(locked.Facts) + if err != nil { + t.Fatal(err) + } + lockedFacts.TestedTags = []string{"cp313-cp313-manylinux_2_35_x86_64", "py3-none-any"} + lockedFacts.CompatibleTags = append([]string{}, lockedFacts.TestedTags...) + locked.Facts = pythonprovider.CanonicalInterpreterFactsV2(lockedFacts) + profile.SelectedExecutables[0] = locked + launcher := directExecutableObservation("shared_launcher", pythonLauncherPath) + interpreter := directExecutableObservation("shared_interpreter", locked.InvocationPath) + interpreter.Terminal.Size = "2" + + previous := runImageValidationFollowupCommand + t.Cleanup(func() { runImageValidationFollowupCommand = previous }) + session := &ImageValidationSession{descriptor: input.Image.Descriptor, containerName: "held-validation"} + runImageValidationFollowupCommand = func(_ CommandSpec, options RunOptions) error { + _, _ = options.Stdout.Write(pythonInspectionOutputFromFactsV2ForTest(t, locked.Facts)) + return nil + } + if _, err := validatePythonProfileObservation(context.Background(), session, profile, launcher, interpreter); err != nil { + t.Fatalf("unchanged portable interpreter facts: %v", err) + } + + tests := []struct { + name string + mutate func(*pythonprovider.InterpreterInspectionFactsV2) + want string + }{ + {name: "version", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { facts.Version = "3.13.3" }, want: "interpreter version changed"}, + {name: "implementation", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { facts.Implementation = "pypy" }, want: "interpreter implementation changed"}, + {name: "ABI", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { facts.ABI = "abi3" }, want: "interpreter ABI changed"}, + {name: "libc", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { facts.Libc = "musl" }, want: "interpreter libc changed"}, + {name: "libc major", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { facts.LibcMajor = "3" }, want: "interpreter libc release changed"}, + {name: "libc minor", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { facts.LibcMinor = "36" }, want: "interpreter libc release changed"}, + {name: "tested tags", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { + facts.TestedTags = []string{"py3-none-any"} + facts.CompatibleTags = []string{"py3-none-any"} + }, want: "tested tags do not equal requested tags"}, + {name: "compatible tags", mutate: func(facts *pythonprovider.InterpreterInspectionFactsV2) { + facts.CompatibleTags = []string{"py3-none-any"} + }, want: "interpreter compatible tags changed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fresh := lockedFacts + fresh.TestedTags = append([]string{}, lockedFacts.TestedTags...) + fresh.CompatibleTags = append([]string{}, lockedFacts.CompatibleTags...) + test.mutate(&fresh) + encoded, err := canonical.Marshal(fresh) + if err != nil { + t.Fatal(err) + } + runImageValidationFollowupCommand = func(_ CommandSpec, options RunOptions) error { + _, _ = options.Stdout.Write(encoded) + return nil + } + if _, err := validatePythonProfileObservation(context.Background(), session, profile, launcher, interpreter); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("drift error = %v, want %q", err, test.want) + } + }) + } +} + +func TestValidatePythonProfileObservationKeepsOrdinaryCompatibleVersionPolicy(t *testing.T) { + completion, operation, _ := providerBuildCompletionFixture(t) + defer operation.Unlock() + input := completion.Validation.Final + profile := input.Profiles[0] + locked := profile.SelectedExecutables[0] + lockedFacts, err := pythonprovider.DecodeInterpreterFactsV2(locked.Facts) + if err != nil { + t.Fatal(err) + } + if len(lockedFacts.TestedTags) != 0 { + t.Fatalf("ordinary profile tested tags = %#v", lockedFacts.TestedTags) + } + launcher := directExecutableObservation("shared_launcher", pythonLauncherPath) + interpreter := directExecutableObservation("shared_interpreter", locked.InvocationPath) + interpreter.Terminal.Size = "2" + lockedFacts.Version = "3.13.3" + + previous := runImageValidationFollowupCommand + t.Cleanup(func() { runImageValidationFollowupCommand = previous }) + encoded, err := canonical.Marshal(lockedFacts) + if err != nil { + t.Fatal(err) + } + runImageValidationFollowupCommand = func(_ CommandSpec, options RunOptions) error { + _, _ = options.Stdout.Write(encoded) + return nil + } + session := &ImageValidationSession{descriptor: input.Image.Descriptor, containerName: "held-validation"} + if _, err := validatePythonProfileObservation(context.Background(), session, profile, launcher, interpreter); err != nil { + t.Fatalf("compatible ordinary version transition: %v", err) + } +}