Skip to content

Commit 6944db8

Browse files
authored
Merge pull request #805 from Dstack-TEE/feat/dstackup-release-api
feat(dstackup): support configurable release API
2 parents e8c7d8b + 445cd1a commit 6944db8

6 files changed

Lines changed: 351 additions & 17 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Test `dstackup image pull` with a local release API
2+
3+
`--release-api-base-url` accepts plain HTTP URLs, including localhost. A small
4+
GitHub Releases API overlay is included for local testing. Locally published
5+
releases shadow GitHub; API requests that are not present locally are relayed to
6+
`https://api.github.com`.
7+
8+
Start it:
9+
10+
```bash
11+
./tools/mock-github-releases.py --port 8000
12+
```
13+
14+
Publish a release and copy a local tarball into the mock asset store. The mock
15+
calculates and publishes its SHA-256 digest automatically:
16+
17+
```bash
18+
curl -fsS -X POST \
19+
http://localhost:8000/__admin/repos/Dstack-TEE/dstack/releases \
20+
-H 'content-type: application/json' \
21+
-d '{
22+
"tag_name": "guest-os-v99.0.0",
23+
"assets": [{"local_path": "/tmp/dstack-99.0.0.tar.gz"}]
24+
}'
25+
```
26+
27+
Pull the locally published latest release:
28+
29+
```bash
30+
sudo dstackup image pull \
31+
--release-api-base-url http://localhost:8000/repos \
32+
--force
33+
```
34+
35+
A release can instead reference an already hosted asset by supplying `name`,
36+
`browser_download_url`, and optionally `digest` in the asset object. State is
37+
in memory and resets when the mock exits; copied assets remain in `--asset-dir`.
38+
GitHub API rate limits still apply to relayed requests. Set an `Authorization`
39+
header on a direct request to the mock when testing authenticated relays.

dstack/crates/dstackup/src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub(crate) const DEFAULT_AUTH_BIN: &str = "dstack-auth";
1212
pub(crate) const DEFAULT_SUPERVISOR_BIN: &str = "supervisor";
1313
pub(crate) const DEFAULT_SOURCE_REPO: &str = "https://github.com/Dstack-TEE/dstack";
1414
pub(crate) const DEFAULT_SOURCE_REF: &str = "master";
15+
pub(crate) const DEFAULT_RELEASE_API_BASE_URL: &str = "https://api.github.com/repos";
1516

1617
#[derive(Parser)]
1718
#[command(name = "dstackup", version, about = "set up and manage a dstack host")]
@@ -21,6 +22,11 @@ pub(crate) struct Cli {
2122
#[arg(long, global = true)]
2223
pub(crate) host: Option<String>,
2324

25+
/// Base URL of the GitHub-compatible releases API, including its `/repos`
26+
/// prefix. The owner/repository path is appended automatically.
27+
#[arg(long, global = true, default_value = DEFAULT_RELEASE_API_BASE_URL)]
28+
pub(crate) release_api_base_url: String,
29+
2430
#[command(subcommand)]
2531
pub(crate) command: Command,
2632
}

dstack/crates/dstackup/src/image.rs

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ struct PullSpec {
6464
gpu: bool,
6565
}
6666

67-
pub(crate) async fn cmd_image(cmd: ImageCmd) -> Result<()> {
67+
pub(crate) async fn cmd_image(cmd: ImageCmd, release_api_base_url: &str) -> Result<()> {
6868
match cmd {
6969
ImageCmd::Pull {
7070
version,
@@ -75,7 +75,15 @@ pub(crate) async fn cmd_image(cmd: ImageCmd) -> Result<()> {
7575
} => {
7676
let image_dir = loc.dir();
7777
validate_image_dir(&image_dir)?;
78-
pull(version.as_deref(), gpu, &image_dir, force, insecure).await?;
78+
pull(
79+
version.as_deref(),
80+
gpu,
81+
&image_dir,
82+
force,
83+
insecure,
84+
release_api_base_url,
85+
)
86+
.await?;
7987
Ok(())
8088
}
8189
ImageCmd::List { loc } => {
@@ -98,6 +106,7 @@ pub(crate) async fn pull(
98106
image_dir: &str,
99107
force: bool,
100108
insecure: bool,
109+
release_api_base_url: &str,
101110
) -> Result<String> {
102111
println!(
103112
"dstackup image pull — {} image",
@@ -107,7 +116,7 @@ pub(crate) async fn pull(
107116
"unified"
108117
}
109118
);
110-
let release = fetch_release(version).await?;
119+
let release = fetch_release(version, release_api_base_url).await?;
111120

112121
let asset = pick_asset(&release.assets, gpu).with_context(|| {
113122
format!(
@@ -376,6 +385,7 @@ pub(crate) async fn resolve_or_pull_image(
376385
requested: Option<&str>,
377386
require: bool,
378387
required_files: &[&str],
388+
release_api_base_url: &str,
379389
) -> Result<Option<String>> {
380390
if let Some(name) = requested {
381391
if !valid_image_name(name) {
@@ -391,7 +401,15 @@ pub(crate) async fn resolve_or_pull_image(
391401
}
392402
if let Some(spec) = pull_spec(name) {
393403
println!(" [..] image {name} not found locally; downloading it");
394-
let pulled = pull(Some(&spec.version), spec.gpu, image_dir, false, false).await?;
404+
let pulled = pull(
405+
Some(&spec.version),
406+
spec.gpu,
407+
image_dir,
408+
false,
409+
false,
410+
release_api_base_url,
411+
)
412+
.await?;
395413
ensure_image_has_required_files(image_dir, &pulled, required_files)?;
396414
return Ok(Some(pulled));
397415
}
@@ -459,7 +477,7 @@ pub(crate) async fn resolve_or_pull_image(
459477
} else {
460478
println!(" [..] no local guest image found; downloading the latest cpu image");
461479
}
462-
let pulled = pull(None, false, image_dir, false, false).await?;
480+
let pulled = pull(None, false, image_dir, false, false, release_api_base_url).await?;
463481

464482
if Path::new(image_dir)
465483
.join(&pulled)
@@ -599,18 +617,19 @@ fn missing_named_image_message(image_dir: &str, name: &str) -> String {
599617
/// released from this monorepo. Do not probe the new repository first for old
600618
/// versions: the version boundary is authoritative and avoids redundant or
601619
/// misleading requests.
602-
async fn fetch_release(version: Option<&str>) -> Result<Release> {
620+
async fn fetch_release(version: Option<&str>, release_api_base_url: &str) -> Result<Release> {
603621
let client = reqwest::Client::new();
604622
if let Some(version) = version {
605-
let (version, url, releases_url) = tagged_release_location(version)?;
623+
let (version, url, releases_url) = tagged_release_location(version, release_api_base_url)?;
606624
return fetch_tagged_release(&client, &url, releases_url)
607625
.await?
608626
.with_context(|| {
609627
format!("guest-OS version {version} was not found; check {releases_url}")
610628
});
611629
}
612630

613-
let list_url = format!("https://api.github.com/repos/{REPO}/releases?per_page=100");
631+
let api_base = release_api_base_url.trim().trim_end_matches('/');
632+
let list_url = format!("{api_base}/{REPO}/releases?per_page=100");
614633
let releases: Vec<Release> = client
615634
.get(&list_url)
616635
.header("user-agent", "dstackup")
@@ -630,13 +649,16 @@ async fn fetch_release(version: Option<&str>) -> Result<Release> {
630649
return Ok(release);
631650
}
632651

633-
let legacy_url = format!("https://api.github.com/repos/{LEGACY_REPO}/releases/latest");
652+
let legacy_url = format!("{api_base}/{LEGACY_REPO}/releases/latest");
634653
fetch_tagged_release(&client, &legacy_url, LEGACY_RELEASES_URL)
635654
.await?
636655
.with_context(|| format!("no guest-OS release found; check {RELEASES_URL}"))
637656
}
638657

639-
fn tagged_release_location(version: &str) -> Result<(String, String, &'static str)> {
658+
fn tagged_release_location(
659+
version: &str,
660+
release_api_base_url: &str,
661+
) -> Result<(String, String, &'static str)> {
640662
let version = version
641663
.trim_start_matches(RELEASE_TAG_PREFIX)
642664
.trim_start_matches('v');
@@ -648,7 +670,10 @@ fn tagged_release_location(version: &str) -> Result<(String, String, &'static st
648670
};
649671
Ok((
650672
version.to_string(),
651-
format!("https://api.github.com/repos/{repo}/releases/tags/{tag_prefix}{version}"),
673+
format!(
674+
"{}/{repo}/releases/tags/{tag_prefix}{version}",
675+
release_api_base_url.trim().trim_end_matches('/')
676+
),
652677
releases_url,
653678
))
654679
}
@@ -830,7 +855,8 @@ mod tests {
830855
#[test]
831856
fn routes_pinned_releases_at_the_monorepo_boundary() {
832857
for version in ["0.5.11", "v0.5.11", "guest-os-v0.5.11"] {
833-
let (normalized, url, releases_url) = tagged_release_location(version).unwrap();
858+
let (normalized, url, releases_url) =
859+
tagged_release_location(version, crate::cli::DEFAULT_RELEASE_API_BASE_URL).unwrap();
834860
assert_eq!(normalized, "0.5.11");
835861
assert_eq!(
836862
url,
@@ -840,7 +866,8 @@ mod tests {
840866
}
841867

842868
for version in ["0.6.0", "0.6.0.a2", "1.0.0"] {
843-
let (normalized, url, releases_url) = tagged_release_location(version).unwrap();
869+
let (normalized, url, releases_url) =
870+
tagged_release_location(version, crate::cli::DEFAULT_RELEASE_API_BASE_URL).unwrap();
844871
assert_eq!(normalized, version);
845872
assert_eq!(
846873
url,
@@ -853,10 +880,23 @@ mod tests {
853880
#[test]
854881
fn rejects_versions_without_a_numeric_core() {
855882
for version in ["0.6", "latest", "0.x.0", "0.6.x"] {
856-
assert!(tagged_release_location(version).is_err(), "{version}");
883+
assert!(
884+
tagged_release_location(version, crate::cli::DEFAULT_RELEASE_API_BASE_URL).is_err(),
885+
"{version}"
886+
);
857887
}
858888
}
859889

890+
#[test]
891+
fn release_api_base_url_is_configurable_and_trailing_slash_safe() {
892+
let (_, url, _) =
893+
tagged_release_location("0.6.0", " http://127.0.0.1:1234/api/ ").unwrap();
894+
assert_eq!(
895+
url,
896+
"http://127.0.0.1:1234/api/Dstack-TEE/dstack/releases/tags/guest-os-v0.6.0"
897+
);
898+
}
899+
860900
#[test]
861901
fn messages_mention_the_pull_command() {
862902
assert!(no_image_message("/d").contains("dstackup image pull"));

dstack/crates/dstackup/src/install.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const DAEMON_BINARIES: &[(&str, &str)] = &[
2828
("supervisor", "supervisor"),
2929
];
3030

31-
pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
31+
pub(crate) async fn cmd_install(mut o: InstallOpts, release_api_base_url: &str) -> Result<()> {
3232
// --expose is not safe yet: the rendered vmm.toml now gates the management
3333
// API behind a generated token, but the transport is still plain HTTP, so
3434
// exposing it would send that bearer token in cleartext to anyone on-path.
@@ -113,6 +113,7 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
113113
o.image.as_deref(),
114114
!o.no_kms,
115115
required_image_files,
116+
release_api_base_url,
116117
)
117118
.await?;
118119
let os_image_hash = resolve_image_pin(&o, &images, platform)?;

dstack/crates/dstackup/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ async fn main() -> Result<()> {
3838
let platform = default_platform(prefix.as_deref()).or_else(host::Platform::detect);
3939
cmd_status(&host, platform).await
4040
}
41-
Command::Install(opts) => install::cmd_install(opts).await,
42-
Command::Image(cmd) => image::cmd_image(cmd).await,
41+
Command::Install(opts) => install::cmd_install(opts, &cli.release_api_base_url).await,
42+
Command::Image(cmd) => image::cmd_image(cmd, &cli.release_api_base_url).await,
4343
Command::Destroy { prefix, purge } => destroy::cmd_destroy(prefix.as_deref(), purge).await,
4444
}
4545
}

0 commit comments

Comments
 (0)