From c510c326ad06f8d95b9b1d42cfe04339cc7009ba Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Tue, 15 Sep 2026 15:25:32 +0200 Subject: [PATCH] PTD-23.1.7: Enforce portable Python wheel eligibility Invoke the provider-owned eligibility gate immediately after interpreter selection in fresh and cached Python resolver paths. Reject ineligible portable bindings before wheel resolution while preserving ordinary Python behavior. --- .../prepared_python_node_operations.go | 21 ++++ .../prepared_python_node_operations_test.go | 110 +++++++++++++++++- 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/internal/dockerdeploy/prepared_python_node_operations.go b/internal/dockerdeploy/prepared_python_node_operations.go index 720ca400..2f18ea98 100644 --- a/internal/dockerdeploy/prepared_python_node_operations.go +++ b/internal/dockerdeploy/prepared_python_node_operations.go @@ -137,6 +137,9 @@ func (operations PreparedPythonNodeOperations) validateCached( if err != nil { return providers.GraphConsumerValidation{}, err } + if err := operations.validatePortableToolEligibility(observed, request.Platform); err != nil { + return providers.GraphConsumerValidation{}, err + } lockedBytes, err := canonical.Marshal(locked) if err != nil { return providers.GraphConsumerValidation{}, fmt.Errorf("encode locked Python interpreter evidence: %w", err) @@ -187,6 +190,9 @@ func (operations PreparedPythonNodeOperations) resolveFresh( if err != nil { return providers.ResolveResult{}, providers.GraphConsumerValidation{}, err } + if err := operations.validatePortableToolEligibility(interpreter, request.Platform); err != nil { + return providers.ResolveResult{}, providers.GraphConsumerValidation{}, err + } verifiedWheels, err := FilterVerifiedPythonResolverArtifacts(session.artifacts, operations.ReusableWheels) if err != nil { return providers.ResolveResult{}, providers.GraphConsumerValidation{}, err @@ -310,6 +316,21 @@ func (operations PreparedPythonNodeOperations) resolveFresh( return resolution, consumer, nil } +func (operations PreparedPythonNodeOperations) validatePortableToolEligibility( + interpreter providers.ExecutableEvidence, + platform blueprint.Platform, +) error { + if operations.PortableToolBindings == nil { + return nil + } + if err := pythonprovider.ValidatePortableToolPythonBindingsV1( + *operations.PortableToolBindings, interpreter, platform, + ); err != nil { + return fmt.Errorf("validate portable Python wheel eligibility: %w", err) + } + return nil +} + func (operations PreparedPythonNodeOperations) materializeLocalOverrides( ctx context.Context, session *PythonResolverSession, diff --git a/internal/dockerdeploy/prepared_python_node_operations_test.go b/internal/dockerdeploy/prepared_python_node_operations_test.go index ae1612b3..b02f9dcf 100644 --- a/internal/dockerdeploy/prepared_python_node_operations_test.go +++ b/internal/dockerdeploy/prepared_python_node_operations_test.go @@ -101,17 +101,16 @@ func TestPreparedPythonNodeOperationsResolvesAndIngestsWheelsInSession(t *testin if err != nil { t.Fatal(err) } + portableToolBindings := portablePythonExecutionProjectionForTest("application/application/python").Components[0] operations := PreparedPythonNodeOperations{ Store: store, Validators: providers.ProviderOwnerValidators{ Profile: pythonprovider.ValidateRequirementProfileV1, Bundle: pythonprovider.ValidateResolvedBundlePayloadV1, }, - FinalImageConfig: pythonConsumerTestImageConfig(), - Artifacts: artifacts, - PortableToolBindings: &pythonprovider.PortableToolPythonComponentV1{ - TestedTags: testedTags, - }, + FinalImageConfig: pythonConsumerTestImageConfig(), + Artifacts: artifacts, + PortableToolBindings: &portableToolBindings, LocalOverrides: []PythonLocalOverrideV1{{ Distribution: "unused", HostDir: filepath.Join(t.TempDir(), "missing"), }}, @@ -171,6 +170,107 @@ func TestPreparedPythonNodeOperationsResolvesAndIngestsWheelsInSession(t *testin } } +func TestPreparedPythonNodeOperationsRejectsIneligiblePortableWheelBeforeFreshResolution(t *testing.T) { + descriptor := testProbeImageDescriptor(t, "linux/amd64") + workspace := testPreparedProbeWorkspace(t, descriptor.Platform, t.TempDir()) + request := preparedPythonResolveRequest(t, descriptor) + interpreterResponse := probe.ResponseV1{Schema: probe.ResponseSchemaV1, Observations: []probe.ExecutableObservationV1{ + pythonConsumerObservation("interpreter", "/usr/bin/python3"), + }} + testedTags := []string{"py3-none-any"} + resolverCalls := 0 + stubPythonInterpreterSelectionCommands( + t, mustCanonicalProbeResponse(t, interpreterResponse), + []string{string(pythonInspectionOutputV2ForTest("3.13.2", testedTags, testedTags))}, + func() error { resolverCalls++; return nil }, + ) + artifacts := testPreparedPythonResolverArtifacts(t) + session, err := OpenPythonResolverSession(context.Background(), descriptor, workspace, artifacts) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close(context.Background()) }) + session.observations[pythonCarrierRequirementID] = pythonConsumerObservation(pythonCarrierRequirementID, pythonCarrierPath) + session.observations[pythonLauncherRequirementID] = pythonConsumerObservation(pythonLauncherRequirementID, pythonLauncherPath) + component := portablePythonExecutionProjectionForTest("application/application/python").Components[0] + component.Bindings[0].SupportedPython = []string{"3.12"} + component.Bindings[0].Wheel.RequiresPython = ">=3.12,<3.13" + operations := PreparedPythonNodeOperations{ + FinalImageConfig: pythonConsumerTestImageConfig(), + Artifacts: artifacts, + PortableToolBindings: &component, + } + + _, _, err = operations.resolveFresh(context.Background(), session, request) + if err == nil || !strings.Contains(err.Error(), "does not support inspected interpreter 3.13.2") { + t.Fatalf("portable wheel eligibility error = %v", err) + } + if resolverCalls != 0 { + t.Fatalf("wheel resolver ran %d times after portable eligibility failed", resolverCalls) + } +} + +func TestPreparedPythonNodeOperationsRejectsIneligiblePortableWheelInCachedPath(t *testing.T) { + fixture := newPreparedPythonGraphReuseFixture(t) + reuse, err := LoadPreparedPythonGraphReuse( + fixture.store, fixture.request.Plan, fixture.request.Platform, + fixture.request.SourceCandidates, fixture.sourceWheels, &fixture.lock, + ) + if err != nil { + t.Fatal(err) + } + config := reuse.NodeConfigs[fixture.request.NodeID] + artifacts, cleanup, err := PreparePythonResolverArtifacts(fixture.store, config.ReusableWheels) + if err != nil { + t.Fatal(err) + } + t.Cleanup(cleanup) + descriptor := testProbeImageDescriptor(t, "linux/amd64") + workspace := testPreparedProbeWorkspace(t, descriptor.Platform, t.TempDir()) + interpreterResponse := probe.ResponseV1{Schema: probe.ResponseSchemaV1, Observations: []probe.ExecutableObservationV1{ + pythonConsumerObservation("interpreter", "/usr/bin/python3"), + }} + testedTags := []string{"py3-none-any"} + resolverCalls := 0 + stubPythonInterpreterSelectionCommands( + t, mustCanonicalProbeResponse(t, interpreterResponse), + []string{string(pythonInspectionOutputV2ForTest("3.13.2", testedTags, testedTags))}, + func() error { resolverCalls++; return nil }, + ) + session, err := OpenPythonResolverSession(context.Background(), descriptor, workspace, artifacts) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close(context.Background()) }) + session.observations[pythonCarrierRequirementID] = pythonConsumerObservation(pythonCarrierRequirementID, pythonCarrierPath) + session.observations[pythonLauncherRequirementID] = pythonConsumerObservation(pythonLauncherRequirementID, pythonLauncherPath) + component := portablePythonExecutionProjectionForTest("application/application/python").Components[0] + component.Bindings[0].SupportedPython = []string{"3.12"} + component.Bindings[0].Wheel.RequiresPython = ">=3.12,<3.13" + fixture.request.ReusableArtifacts = reuse.ReusableArtifacts[fixture.request.NodeID] + operations := PreparedPythonNodeOperations{ + Store: fixture.store, + Validators: providers.ProviderOwnerValidators{ + Profile: pythonprovider.ValidateRequirementProfileV1, + Bundle: pythonprovider.ValidateResolvedBundlePayloadV1, + }, + FinalImageConfig: pythonConsumerTestImageConfig(), + Artifacts: artifacts, + ReusableWheels: config.ReusableWheels, + PortableToolBindings: &component, + } + + _, err = operations.validateCached( + context.Background(), session, fixture.request, reuse.CachedResolutions[fixture.request.NodeID], + ) + if err == nil || !strings.Contains(err.Error(), "does not support inspected interpreter 3.13.2") { + t.Fatalf("cached portable wheel eligibility error = %v", err) + } + if resolverCalls != 0 { + t.Fatalf("wheel resolver ran %d times during cached portable eligibility validation", resolverCalls) + } +} + func TestPreparedPythonNodeOperationsStopsWhenInterpreterCannotImportPip(t *testing.T) { descriptor := testProbeImageDescriptor(t, "linux/amd64") workspace := testPreparedProbeWorkspace(t, descriptor.Platform, t.TempDir())