diff --git a/.claude/tsc-cache/7378e2d5-3637-419e-a831-a196222e0379/affected-repos.txt b/.claude/tsc-cache/7378e2d5-3637-419e-a831-a196222e0379/affected-repos.txt new file mode 100644 index 00000000..eedd89b4 --- /dev/null +++ b/.claude/tsc-cache/7378e2d5-3637-419e-a831-a196222e0379/affected-repos.txt @@ -0,0 +1 @@ +api diff --git a/.claude/tsc-cache/7378e2d5-3637-419e-a831-a196222e0379/edited-files.log b/.claude/tsc-cache/7378e2d5-3637-419e-a831-a196222e0379/edited-files.log new file mode 100644 index 00000000..17ba693c --- /dev/null +++ b/.claude/tsc-cache/7378e2d5-3637-419e-a831-a196222e0379/edited-files.log @@ -0,0 +1 @@ +1769450049:/Users/ianfloressiaca/ptd-workspace/.worktrees/python-ppm-config/api/core/v1beta1/site_types.go:api diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 44bb4261..0462a0c3 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -109,10 +109,16 @@ type SiteSpec struct { // +kubebuilder:validation:Type=integer NetworkTrust NetworkTrust `json:"networkTrust,omitempty"` - // PackageManagerUrl specifies the Package Manager URL for Workbench to use + // PackageManagerUrl specifies the Package Manager URL for Workbench to use for R packages (CRAN) // If empty, Workbench will use the local Package Manager URL by default + // Example: https://packagemanager.example.com/cran/__linux__/jammy/latest PackageManagerUrl string `json:"packageManagerUrl,omitempty"` + // PythonPackageManagerUrl specifies the Package Manager URL for Workbench to use for Python packages (PyPI) + // If empty, Workbench will derive it from the Package Manager domain: https://{domain}/pypi/latest/simple + // Example: https://packagemanager.example.com/pypi/latest/simple + PythonPackageManagerUrl string `json:"pythonPackageManagerUrl,omitempty"` + // EFSEnabled indicates whether EFS is enabled for this site // When true, network policies will allow workbench sessions to access EFS mount targets EFSEnabled bool `json:"efsEnabled,omitempty"` diff --git a/client-go/applyconfiguration/core/v1beta1/sitespec.go b/client-go/applyconfiguration/core/v1beta1/sitespec.go index b13749bf..25d2827e 100644 --- a/client-go/applyconfiguration/core/v1beta1/sitespec.go +++ b/client-go/applyconfiguration/core/v1beta1/sitespec.go @@ -40,6 +40,7 @@ type SiteSpecApplyConfiguration struct { LogFormat *product.LogFormat `json:"logFormat,omitempty"` NetworkTrust *corev1beta1.NetworkTrust `json:"networkTrust,omitempty"` PackageManagerUrl *string `json:"packageManagerUrl,omitempty"` + PythonPackageManagerUrl *string `json:"pythonPackageManagerUrl,omitempty"` EFSEnabled *bool `json:"efsEnabled,omitempty"` VPCCIDR *string `json:"vpcCIDR,omitempty"` EnableFQDNHealthChecks *bool `json:"enableFqdnHealthChecks,omitempty"` @@ -280,6 +281,14 @@ func (b *SiteSpecApplyConfiguration) WithPackageManagerUrl(value string) *SiteSp return b } +// WithPythonPackageManagerUrl sets the PythonPackageManagerUrl field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PythonPackageManagerUrl field is set to the value of the last call. +func (b *SiteSpecApplyConfiguration) WithPythonPackageManagerUrl(value string) *SiteSpecApplyConfiguration { + b.PythonPackageManagerUrl = &value + return b +} + // WithEFSEnabled sets the EFSEnabled field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the EFSEnabled field is set to the value of the last call. diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index 647b764b..04f5fdca 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -809,8 +809,15 @@ spec: type: object packageManagerUrl: description: |- - PackageManagerUrl specifies the Package Manager URL for Workbench to use + PackageManagerUrl specifies the Package Manager URL for Workbench to use for R packages (CRAN) If empty, Workbench will use the local Package Manager URL by default + Example: https://packagemanager.example.com/cran/__linux__/jammy/latest + type: string + pythonPackageManagerUrl: + description: |- + PythonPackageManagerUrl specifies the Package Manager URL for Workbench to use for Python packages (PyPI) + If empty, Workbench will derive it from the Package Manager domain: https://{domain}/pypi/latest/simple + Example: https://packagemanager.example.com/pypi/latest/simple type: string secret: description: Secret configures the secret management for this Site diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index e0bd60ea..4f041026 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -830,8 +830,15 @@ spec: type: object packageManagerUrl: description: |- - PackageManagerUrl specifies the Package Manager URL for Workbench to use + PackageManagerUrl specifies the Package Manager URL for Workbench to use for R packages (CRAN) If empty, Workbench will use the local Package Manager URL by default + Example: https://packagemanager.example.com/cran/__linux__/jammy/latest + type: string + pythonPackageManagerUrl: + description: |- + PythonPackageManagerUrl specifies the Package Manager URL for Workbench to use for Python packages (PyPI) + If empty, Workbench will derive it from the Package Manager domain: https://{domain}/pypi/latest/simple + Example: https://packagemanager.example.com/pypi/latest/simple type: string secret: description: Secret configures the secret management for this Site diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index 441df348..c4e280a2 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -153,6 +153,12 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques packageManagerRepoUrl = site.Spec.PackageManagerUrl } + // Python PPM URL for pip index (PIP_INDEX_URL) + pythonPackageManagerUrl := fmt.Sprintf("https://%s/pypi/latest/simple", packageManagerUrl) + if site.Spec.PythonPackageManagerUrl != "" { + pythonPackageManagerUrl = site.Spec.PythonPackageManagerUrl + } + // VOLUMES // Determine if Connect is enabled (used for volume provisioning and later for reconciliation) @@ -367,6 +373,7 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques devStorageClassName, workbenchAdditionalVolumes, packageManagerRepoUrl, + pythonPackageManagerUrl, workbenchUrl, ); err != nil { l.Error(err, "error reconciling workbench") diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index 428218ef..cbe16f2e 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -24,6 +24,7 @@ func (r *SiteReconciler) reconcileWorkbench( storageClassName string, additionalVolumes []product.VolumeSpec, packageManagerRepoUrl string, + pythonPackageManagerUrl string, workbenchUrl string, ) error { @@ -355,12 +356,17 @@ func (r *SiteReconciler) reconcileWorkbench( } if site.Spec.Workbench.ExperimentalFeatures.LauncherEnvPath != "" { + // Initialize with PATH from experimental features + launcherEnv := map[string]string{ + "PATH": site.Spec.Workbench.ExperimentalFeatures.LauncherEnvPath, + } + // Add Python PPM URL + launcherEnv["PIP_INDEX_URL"] = pythonPackageManagerUrl + targetWorkbench.Spec.Config.WorkbenchDcfConfig = v1beta1.WorkbenchDcfConfig{ LauncherEnv: &v1beta1.WorkbenchLauncherEnvConfig{ - JobType: "session", - Environment: map[string]string{ - "PATH": site.Spec.Workbench.ExperimentalFeatures.LauncherEnvPath, - }, + JobType: "session", + Environment: launcherEnv, }, } } @@ -374,6 +380,19 @@ func (r *SiteReconciler) reconcileWorkbench( } } + // Configure Python to use Package Manager (PIP_INDEX_URL) + // Only set if WorkbenchDcfConfig.LauncherEnv wasn't already configured above + if targetWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv == nil { + targetWorkbench.Spec.Config.WorkbenchDcfConfig = v1beta1.WorkbenchDcfConfig{ + LauncherEnv: &v1beta1.WorkbenchLauncherEnvConfig{ + JobType: "session", + Environment: map[string]string{ + "PIP_INDEX_URL": pythonPackageManagerUrl, + }, + }, + } + } + // set user provisioning if site.Spec.Workbench.CreateUsersAutomatically { targetWorkbench.Spec.Config.RServer.UserProvisioningRegisterOnFirstLogin = 1 diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index ec910046..02a7f1af 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -1023,6 +1023,70 @@ func TestSiteReconciler_WorkbenchSessionImagePullPolicyNever(t *testing.T) { assert.Equal(t, corev1.PullNever, testWorkbench.Spec.SessionConfig.Pod.ImagePullPolicy) } +// TestSiteReconciler_PythonPPMUrl tests that Python sessions are configured to use PPM +func TestSiteReconciler_PythonPPMUrl(t *testing.T) { + siteName := "python-ppm-url" + siteNamespace := "posit-team" + site := defaultSite(siteName) + site.Spec.Domain = "example.posit.co" + site.Spec.PackageManager.DomainPrefix = "packagemanager" + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + + // Verify PIP_INDEX_URL is set in launcher-env + assert.NotNil(t, testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv) + assert.Equal(t, "session", testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.JobType) + assert.Contains(t, testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.Environment, "PIP_INDEX_URL") + + // Verify the URL is derived from package manager domain + expectedUrl := "https://packagemanager.example.posit.co/pypi/latest/simple" + assert.Equal(t, expectedUrl, testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.Environment["PIP_INDEX_URL"]) +} + +// TestSiteReconciler_PythonPPMUrlCustom tests custom Python PPM URL override +func TestSiteReconciler_PythonPPMUrlCustom(t *testing.T) { + siteName := "python-ppm-url-custom" + siteNamespace := "posit-team" + site := defaultSite(siteName) + site.Spec.PythonPackageManagerUrl = "https://custom-ppm.example.com/pypi/latest/simple" + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + + // Verify custom PIP_INDEX_URL is set + assert.NotNil(t, testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv) + assert.Equal(t, "https://custom-ppm.example.com/pypi/latest/simple", testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.Environment["PIP_INDEX_URL"]) +} + +// TestSiteReconciler_PythonPPMUrlWithLauncherEnvPath tests that both PATH and PIP_INDEX_URL are set +func TestSiteReconciler_PythonPPMUrlWithLauncherEnvPath(t *testing.T) { + siteName := "python-ppm-with-path" + siteNamespace := "posit-team" + site := defaultSite(siteName) + site.Spec.Domain = "example.posit.co" + site.Spec.PackageManager.DomainPrefix = "packagemanager" + site.Spec.Workbench.ExperimentalFeatures = &v1beta1.InternalWorkbenchExperimentalFeatures{ + LauncherEnvPath: "/opt/custom/bin:/usr/bin", + } + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + + // Verify both PATH and PIP_INDEX_URL are set + assert.NotNil(t, testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv) + assert.Contains(t, testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.Environment, "PATH") + assert.Contains(t, testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.Environment, "PIP_INDEX_URL") + assert.Equal(t, "/opt/custom/bin:/usr/bin", testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.Environment["PATH"]) + assert.Equal(t, "https://packagemanager.example.posit.co/pypi/latest/simple", testWorkbench.Spec.Config.WorkbenchDcfConfig.LauncherEnv.Environment["PIP_INDEX_URL"]) +} + func TestSiteReconciler_RegisterOnFirstLoginPropagation(t *testing.T) { siteName := "register-on-first-login" siteNamespace := "posit-team"