Skip to content

Commit ee70100

Browse files
authored
Add support for configuring a custom Posit Package Manager URL (#906)
This commit builds on the existing support for installing packages from Posit Public Package Manager via `--default-repos=posit-ppm` by allowing users to pass a specific Package Manager repository, overriding the hardcoded https://packagemanager.posit.co/cran/latest URL. This allows users of self-hosted Package Manager installations to benefit from our logic for determining the the appropriate Linux binaries automatically. It also opens the door to users pointing at a specific CRAN snapshot on Public Package Manager rather than `latest`. Custom URLs are passed via a new `--ppm-repo` option, which is mutually exclusive with `--default-repos` and `--repos-conf`. Unit tests for the repository URL construction (including for the existing but previously untested logic) are included. Signed-off-by: Aaron Jacobs <aaron.jacobs@posit.co>
1 parent a182083 commit ee70100

File tree

2 files changed

+207
-28
lines changed

2 files changed

+207
-28
lines changed

crates/ark/src/main.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::env;
1212

1313
use amalthea::kernel;
1414
use amalthea::kernel_spec::KernelSpec;
15+
use anyhow::Context;
1516
use ark::interface::SessionMode;
1617
use ark::logger;
1718
use ark::repos::DefaultRepos;
@@ -48,6 +49,7 @@ Available options:
4849
"none" (do not alter the 'repos' option in any way)
4950
--repos-conf Set the default repositories to use from a configuration file
5051
containing a list of named repositories (`name = url`)
52+
--default-ppm-repo Set the default repositories to a custom Posit Package Manager URL.
5153
--version Print the version of Ark
5254
--log FILE Log to the given file (if not specified, stdout/stderr
5355
will be used)
@@ -139,12 +141,12 @@ fn main() -> anyhow::Result<()> {
139141
if let Some(repos) = argv.next() {
140142
if default_repos != DefaultRepos::Auto {
141143
return Err(anyhow::anyhow!(
142-
"The default repository options can only be specified once; (found '{repos}, had {default_repos:?}')."
144+
"Only one of `--default-repos`, `--repos-conf`, or `--default-ppm-repo` can be specified."
143145
));
144146
}
145147
default_repos = match repos.as_str() {
146148
"rstudio" => DefaultRepos::RStudio,
147-
"posit-ppm" => DefaultRepos::PositPPM,
149+
"posit-ppm" => DefaultRepos::PositPackageManager(None),
148150
"none" => DefaultRepos::None,
149151
_ => {
150152
return Err(anyhow::anyhow!(
@@ -162,7 +164,7 @@ fn main() -> anyhow::Result<()> {
162164
if let Some(repos) = argv.next() {
163165
if default_repos != DefaultRepos::Auto {
164166
return Err(anyhow::anyhow!(
165-
"The default repository options can only be specified once; (found '{repos}, had {default_repos:?}')."
167+
"Only one of `--default-repos`, `--repos-conf`, or `--default-ppm-repo` can be specified."
166168
));
167169
}
168170
let path = std::path::PathBuf::from(repos.clone());
@@ -180,6 +182,34 @@ fn main() -> anyhow::Result<()> {
180182
));
181183
}
182184
},
185+
"--default-ppm-repo" => {
186+
if let Some(url) = argv.next() {
187+
if default_repos != DefaultRepos::Auto {
188+
return Err(anyhow::anyhow!(
189+
"Only one of `--default-repos`, `--repos-conf`, or `--default-ppm-repo` can be specified."
190+
));
191+
}
192+
let parsed =
193+
url::Url::parse(&url).context("Failed to parse --default-ppm-repo URL")?;
194+
// The repository must be in the form /<repo>/<snapshot>
195+
// (i.e. /cran/latest) to work correctly. We validate this
196+
// upfront to reduce the number of downstream errors.
197+
let segments = parsed.path_segments().ok_or_else(|| {
198+
anyhow::anyhow!("Invalid Package Manager repository URL: {}", url)
199+
})?;
200+
if segments.count() != 2 {
201+
return Err(anyhow::anyhow!(
202+
"Invalid Package Manager repository URL: {}",
203+
url
204+
));
205+
}
206+
default_repos = DefaultRepos::PositPackageManager(Some(parsed))
207+
} else {
208+
return Err(anyhow::anyhow!(
209+
"The `--default-ppm-repo` option must be followed by a repository URL."
210+
));
211+
}
212+
},
183213
"--log" => {
184214
if let Some(file) = argv.next() {
185215
log_file = Some(file);

crates/ark/src/repos.rs

Lines changed: 174 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ pub enum DefaultRepos {
3737
/// Set the repository to the default CRAN repository, `cran.rstudio.com`
3838
RStudio,
3939

40-
/// Use Posit's Public Package Manager; this is a Posit-hosted service that hosts built
41-
/// binaries for many operating systems.
42-
PositPPM,
40+
/// Use a Posit Package Manager instance with this URL. When the URL is
41+
/// `None`, default to the latest CRAN repository on Posit Public Package
42+
/// Manager, a Posit-hosted service that hosts built binaries for many
43+
/// operating systems.
44+
PositPackageManager(Option<url::Url>),
4345

4446
/// Use the repositories specified in the given configuration file.
4547
ConfFile(PathBuf),
@@ -80,10 +82,19 @@ pub fn apply_default_repos(repos: DefaultRepos) -> anyhow::Result<()> {
8082
apply_default_repos(DefaultRepos::Auto)
8183
}
8284
},
83-
DefaultRepos::PositPPM => {
85+
DefaultRepos::PositPackageManager(None) => {
8486
log::info!("Setting default repositories to Posit's Public Package Manager");
8587
let mut repos = HashMap::new();
86-
repos.insert("CRAN".to_string(), get_p3m_binary_package_repo());
88+
repos.insert("CRAN".to_string(), get_ppm_binary_package_repo(None));
89+
apply_repos(repos)
90+
},
91+
DefaultRepos::PositPackageManager(Some(url)) => {
92+
log::info!(
93+
"Setting default repositories to custom Package Manager repo: {}",
94+
url
95+
);
96+
let mut repos = HashMap::new();
97+
repos.insert("CRAN".to_string(), get_ppm_binary_package_repo(Some(url)));
8798
apply_repos(repos)
8899
},
89100
}
@@ -198,7 +209,12 @@ pub fn apply_repos_conf(path: PathBuf) -> anyhow::Result<()> {
198209

199210
/// Checks the Linux distribution name and version to determine the appropriate P3M repository URL.
200211
#[cfg(target_os = "linux")]
201-
fn get_p3m_linux_repo(linux_name: String) -> String {
212+
fn get_ppm_linux_repo(repo_url: Option<url::Url>, linux_name: String) -> anyhow::Result<String> {
213+
let generic_url = match repo_url {
214+
Some(url) => url,
215+
None => url::Url::parse(GENERIC_P3M_REPO).unwrap(),
216+
};
217+
202218
// The following Linux names have 1:1 mappings to a P3M repository URL
203219
let repo_names = [
204220
String::from("bookworm"),
@@ -211,26 +227,28 @@ fn get_p3m_linux_repo(linux_name: String) -> String {
211227
String::from("rhel9"),
212228
];
213229

214-
// First check for an empty name, and default to the generic P3M repo in that case.
215-
// Then handle Linux names with a 1:1 mapping to a P3M repo.
216-
// Then handle the special cases which map to different P3M repos.
217-
// Otherwise, default to the generic P3M repo.
218-
if linux_name.is_empty() {
219-
return GENERIC_P3M_REPO.to_string();
220-
} else if repo_names.contains(&linux_name) {
221-
return format!(
222-
"https://packagemanager.posit.co/cran/__linux__/{}/latest",
223-
linux_name
224-
);
230+
// Handle special cases which map to different P3M repos.
231+
let distro = if repo_names.contains(&linux_name) {
232+
&linux_name
225233
} else if linux_name == "rhel8" {
226-
return "https://packagemanager.posit.co/cran/__linux__/centos8/latest".to_string();
234+
"centos8"
227235
} else if linux_name == "sles155" {
228-
return "https://packagemanager.posit.co/cran/__linux__/opensuse155/latest".to_string();
236+
"opensuse155"
229237
} else if linux_name == "sles156" {
230-
return "https://packagemanager.posit.co/cran/__linux__/opensuse156/latest".to_string();
238+
"opensuse156"
231239
} else {
232-
return GENERIC_P3M_REPO.to_string();
240+
return Ok(generic_url.to_string());
241+
};
242+
243+
let mut distro_url = generic_url.clone();
244+
if let Some(segments) = distro_url.path_segments() {
245+
let parts: Vec<&str> = segments.collect();
246+
if parts.len() == 2 {
247+
distro_url.set_path(&format!("{}/__linux__/{}/{}", parts[0], distro, parts[1]));
248+
return Ok(distro_url.to_string());
249+
}
233250
}
251+
anyhow::bail!("Invalid Package Manager repository URL: {}", distro_url);
234252
}
235253

236254
#[cfg(target_os = "linux")]
@@ -262,7 +280,12 @@ fn get_p3m_linux_codename(id: String, version: String, version_codename: String)
262280
}
263281
}
264282

265-
fn get_p3m_binary_package_repo() -> String {
283+
fn get_ppm_binary_package_repo(repo_url: Option<url::Url>) -> String {
284+
let generic_url = match repo_url {
285+
Some(ref url) => url.clone().to_string(),
286+
None => GENERIC_P3M_REPO.to_string(),
287+
};
288+
266289
#[cfg(target_os = "linux")]
267290
{
268291
// For Linux, we want a distro-specific URL if possible
@@ -285,14 +308,140 @@ fn get_p3m_binary_package_repo() -> String {
285308
version = line[version_id_key.len()..].to_string();
286309
}
287310
}
311+
} else {
312+
log::error!(
313+
"Error opening /etc/os-release, falling back to generic URL: {generic_url}",
314+
);
315+
return generic_url;
288316
}
289317

290-
get_p3m_linux_repo(get_p3m_linux_codename(id, version, version_codename))
318+
let codename = get_p3m_linux_codename(id, version, version_codename);
319+
match get_ppm_linux_repo(repo_url, codename) {
320+
Ok(url) => url,
321+
Err(e) => {
322+
log::error!(
323+
"Error determining Linux binary repository URL, falling back to generic URL '{generic_url}': {e}",
324+
);
325+
generic_url
326+
},
327+
}
291328
}
292329

293330
#[cfg(not(target_os = "linux"))]
294331
{
295-
// For non-Linux, we can use the generic P3M URL
296-
GENERIC_P3M_REPO.to_string()
332+
// For non-Linux, we can use the generic URL
333+
generic_url
334+
}
335+
}
336+
337+
#[cfg(test)]
338+
mod tests {
339+
use super::*;
340+
341+
#[test]
342+
#[cfg(target_os = "linux")]
343+
fn test_get_ppm_linux_repo() {
344+
let test_cases = vec![
345+
// Supported distros.
346+
(
347+
"bookworm",
348+
"https://packagemanager.posit.co/cran/__linux__/bookworm/latest",
349+
),
350+
(
351+
"bullseye",
352+
"https://packagemanager.posit.co/cran/__linux__/bullseye/latest",
353+
),
354+
(
355+
"focal",
356+
"https://packagemanager.posit.co/cran/__linux__/focal/latest",
357+
),
358+
(
359+
"jammy",
360+
"https://packagemanager.posit.co/cran/__linux__/jammy/latest",
361+
),
362+
(
363+
"noble",
364+
"https://packagemanager.posit.co/cran/__linux__/noble/latest",
365+
),
366+
(
367+
"opensuse155",
368+
"https://packagemanager.posit.co/cran/__linux__/opensuse155/latest",
369+
),
370+
(
371+
"opensuse156",
372+
"https://packagemanager.posit.co/cran/__linux__/opensuse156/latest",
373+
),
374+
(
375+
"rhel9",
376+
"https://packagemanager.posit.co/cran/__linux__/rhel9/latest",
377+
),
378+
// Special cases.
379+
(
380+
"rhel8",
381+
"https://packagemanager.posit.co/cran/__linux__/centos8/latest",
382+
),
383+
(
384+
"sles155",
385+
"https://packagemanager.posit.co/cran/__linux__/opensuse155/latest",
386+
),
387+
(
388+
"sles156",
389+
"https://packagemanager.posit.co/cran/__linux__/opensuse156/latest",
390+
),
391+
// Unsupported distros fall back to the generic URL.
392+
("centos7", GENERIC_P3M_REPO),
393+
("arch", GENERIC_P3M_REPO),
394+
("", GENERIC_P3M_REPO),
395+
];
396+
397+
for (distro, expected) in test_cases {
398+
let result = get_ppm_linux_repo(None, distro.to_string()).unwrap();
399+
assert_eq!(result, expected);
400+
}
401+
}
402+
403+
#[test]
404+
#[cfg(target_os = "linux")]
405+
fn test_get_custom_ppm_linux_repo() {
406+
let test_cases = vec![
407+
(
408+
"jammy",
409+
"https://ppm.internal/approved/__linux__/jammy/2025-03-02",
410+
),
411+
(
412+
"rhel8",
413+
"https://ppm.internal/approved/__linux__/centos8/2025-03-02",
414+
),
415+
("arch", "https://ppm.internal/approved/2025-03-02"),
416+
("", "https://ppm.internal/approved/2025-03-02"),
417+
];
418+
419+
let custom_url = url::Url::parse("https://ppm.internal/approved/2025-03-02").unwrap();
420+
for (distro, expected) in test_cases {
421+
let result = get_ppm_linux_repo(Some(custom_url.clone()), distro.to_string()).unwrap();
422+
assert_eq!(result, expected);
423+
}
424+
}
425+
426+
#[test]
427+
#[cfg(target_os = "linux")]
428+
fn test_invalid_ppm_url() {
429+
let custom_url = url::Url::parse("https://ppm.internal/not/a/repo").unwrap();
430+
let result = get_ppm_linux_repo(Some(custom_url), "jammy".to_string());
431+
assert!(result.is_err());
432+
}
433+
434+
#[test]
435+
#[cfg(not(target_os = "linux"))]
436+
fn test_custom_ppm_url_for_non_linux() {
437+
let custom_url = url::Url::parse("https://ppm.internal/approved/2025-03-02").unwrap();
438+
let result = get_ppm_binary_package_repo(Some(custom_url));
439+
assert_eq!(result, "https://ppm.internal/approved/2025-03-02");
440+
}
441+
442+
#[test]
443+
#[cfg(not(target_os = "linux"))]
444+
fn test_generic_ppm_url_for_non_linux() {
445+
assert_eq!(get_ppm_binary_package_repo(None), GENERIC_P3M_REPO);
297446
}
298447
}

0 commit comments

Comments
 (0)