Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Added
body: Reject final images that drift from locked portable Python interpreter and wheel-tag evidence.
35 changes: 35 additions & 0 deletions internal/dockerdeploy/full_validation_python_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"slices"

"github.com/omry/reploy/internal/canonical"
"github.com/omry/reploy/internal/probe"
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions internal/dockerdeploy/full_validation_python_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
Loading