Skip to content

Commit 445cd1a

Browse files
committed
fix: address release API review feedback
1 parent d65bb7a commit 445cd1a

3 files changed

Lines changed: 73 additions & 19 deletions

File tree

dstack/crates/dstackup/src/cli.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ pub(crate) struct Cli {
2222
#[arg(long, global = true)]
2323
pub(crate) host: Option<String>,
2424

25-
/// Base URL of the GitHub-compatible releases API. The repository path is
26-
/// appended automatically (useful for an API proxy or a test server).
25+
/// Base URL of the GitHub-compatible releases API, including its `/repos`
26+
/// prefix. The owner/repository path is appended automatically.
2727
#[arg(long, global = true, default_value = DEFAULT_RELEASE_API_BASE_URL)]
2828
pub(crate) release_api_base_url: String,
2929

dstack/crates/dstackup/src/image.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,7 @@ async fn fetch_release(version: Option<&str>, release_api_base_url: &str) -> Res
628628
});
629629
}
630630

631-
let api_base = release_api_base_url.trim_end_matches('/');
631+
let api_base = release_api_base_url.trim().trim_end_matches('/');
632632
let list_url = format!("{api_base}/{REPO}/releases?per_page=100");
633633
let releases: Vec<Release> = client
634634
.get(&list_url)
@@ -672,7 +672,7 @@ fn tagged_release_location(
672672
version.to_string(),
673673
format!(
674674
"{}/{repo}/releases/tags/{tag_prefix}{version}",
675-
release_api_base_url.trim_end_matches('/')
675+
release_api_base_url.trim().trim_end_matches('/')
676676
),
677677
releases_url,
678678
))
@@ -889,7 +889,8 @@ mod tests {
889889

890890
#[test]
891891
fn release_api_base_url_is_configurable_and_trailing_slash_safe() {
892-
let (_, url, _) = tagged_release_location("0.6.0", "http://127.0.0.1:1234/api/").unwrap();
892+
let (_, url, _) =
893+
tagged_release_location("0.6.0", " http://127.0.0.1:1234/api/ ").unwrap();
893894
assert_eq!(
894895
url,
895896
"http://127.0.0.1:1234/api/Dstack-TEE/dstack/releases/tags/guest-os-v0.6.0"

tools/mock-github-releases.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
26
"""Local GitHub Releases API overlay for dstackup development.
37
48
Locally published releases shadow GitHub. Requests not satisfied locally are
@@ -22,12 +26,16 @@
2226

2327

2428
class State:
29+
"""Thread-safe-by-snapshot in-memory release overlay."""
30+
2531
def __init__(self, asset_dir: Path):
32+
"""Create an empty overlay and its local asset directory."""
2633
self.asset_dir = asset_dir.resolve()
2734
self.asset_dir.mkdir(parents=True, exist_ok=True)
2835
self.releases = {} # repo -> tag -> release; insertion order is newest first
2936

3037
def publish(self, repo, release, public_base):
38+
"""Publish a release as the newest local release for a repository."""
3139
tag = release.get("tag_name")
3240
if not isinstance(tag, str) or not tag:
3341
raise ValueError("tag_name must be a non-empty string")
@@ -49,19 +57,28 @@ def publish(self, repo, release, public_base):
4957
digest=f"sha256:{digest}",
5058
)
5159
if not asset.get("name") or not asset.get("browser_download_url"):
52-
raise ValueError("each asset needs name and browser_download_url, or local_path")
60+
raise ValueError(
61+
"each asset needs name and browser_download_url, or local_path"
62+
)
5363
assets.append(asset)
5464
release = dict(release, assets=assets)
55-
releases = self.releases.setdefault(repo, {})
56-
releases.pop(tag, None)
57-
self.releases[repo] = {tag: release, **releases}
65+
# Build a new snapshot rather than mutating one observed by a concurrent
66+
# GET handler. Assignment is atomic under CPython's GIL.
67+
releases = self.releases.get(repo, {})
68+
self.releases[repo] = {
69+
tag: release,
70+
**{old_tag: old for old_tag, old in releases.items() if old_tag != tag},
71+
}
5872
return release
5973

6074

6175
class Handler(BaseHTTPRequestHandler):
76+
"""Serve the local release overlay and relay misses to GitHub."""
77+
6278
server_version = "dstack-release-mock/1"
6379

6480
def json_response(self, status, value):
81+
"""Send a JSON response with a fixed content length."""
6582
body = json.dumps(value, indent=2).encode()
6683
self.send_response(status)
6784
self.send_header("content-type", "application/json")
@@ -70,9 +87,14 @@ def json_response(self, status, value):
7087
self.wfile.write(body)
7188

7289
def do_POST(self):
90+
"""Publish a release through the local administration endpoint."""
7391
# POST /__admin/repos/{owner}/{repo}/releases
7492
parts = self.path.strip("/").split("/")
75-
if len(parts) != 5 or parts[:2] != ["__admin", "repos"] or parts[4:] != ["releases"]:
93+
if (
94+
len(parts) != 5
95+
or parts[:2] != ["__admin", "repos"]
96+
or parts[4:] != ["releases"]
97+
):
7698
self.send_error(404)
7799
return
78100
try:
@@ -87,6 +109,7 @@ def do_POST(self):
87109
self.json_response(400, {"error": str(error)})
88110

89111
def do_GET(self):
112+
"""Serve an asset or a GitHub-compatible releases API request."""
90113
path = urlsplit(self.path).path
91114
if path.startswith("/__assets/"):
92115
self.serve_asset(unquote(path.removeprefix("/__assets/")))
@@ -102,7 +125,9 @@ def do_GET(self):
102125
upstream = self.fetch_upstream_json()
103126
if isinstance(upstream, list):
104127
local_tags = set(local)
105-
upstream = [r for r in upstream if r.get("tag_name") not in local_tags]
128+
upstream = [
129+
r for r in upstream if r.get("tag_name") not in local_tags
130+
]
106131
else:
107132
upstream = []
108133
self.json_response(200, list(local.values()) + upstream)
@@ -118,6 +143,7 @@ def do_GET(self):
118143
self.relay()
119144

120145
def serve_asset(self, name):
146+
"""Serve one basename-only file from the configured asset directory."""
121147
if not name or Path(name).name != name:
122148
self.send_error(400, "invalid asset name")
123149
return
@@ -126,65 +152,92 @@ def serve_asset(self, name):
126152
self.send_error(404)
127153
return
128154
self.send_response(200)
129-
self.send_header("content-type", mimetypes.guess_type(name)[0] or "application/octet-stream")
155+
self.send_header(
156+
"content-type", mimetypes.guess_type(name)[0] or "application/octet-stream"
157+
)
130158
self.send_header("content-length", str(path.stat().st_size))
131159
self.end_headers()
132160
with path.open("rb") as source:
133161
shutil.copyfileobj(source, self.wfile)
134162

135163
def upstream_request(self):
164+
"""Build the corresponding authenticated GitHub API request."""
136165
url = UPSTREAM + self.path
137-
headers = {"user-agent": "dstack-release-mock", "accept": "application/vnd.github+json"}
166+
headers = {
167+
"user-agent": "dstack-release-mock",
168+
"accept": "application/vnd.github+json",
169+
}
138170
auth = self.headers.get("authorization")
139171
if auth:
140172
headers["authorization"] = auth
141173
return urllib.request.Request(url, headers=headers)
142174

143175
def fetch_upstream_json(self):
176+
"""Fetch upstream JSON for merging, returning an empty list on failure."""
144177
try:
145-
with urllib.request.urlopen(self.upstream_request(), timeout=30) as response:
178+
with urllib.request.urlopen(
179+
self.upstream_request(), timeout=30
180+
) as response:
146181
return json.load(response)
147182
except (urllib.error.URLError, json.JSONDecodeError):
148183
return []
149184

150185
def relay(self):
186+
"""Relay the current request and its HTTP status to GitHub."""
151187
try:
152-
with urllib.request.urlopen(self.upstream_request(), timeout=30) as response:
188+
with urllib.request.urlopen(
189+
self.upstream_request(), timeout=30
190+
) as response:
153191
body = response.read()
154192
self.send_response(response.status)
155-
self.send_header("content-type", response.headers.get("content-type", "application/json"))
193+
self.send_header(
194+
"content-type",
195+
response.headers.get("content-type", "application/json"),
196+
)
156197
self.send_header("content-length", str(len(body)))
157198
self.end_headers()
158199
self.wfile.write(body)
159200
except urllib.error.HTTPError as error:
160201
body = error.read()
161202
self.send_response(error.code)
162-
self.send_header("content-type", error.headers.get("content-type", "application/json"))
203+
self.send_header(
204+
"content-type", error.headers.get("content-type", "application/json")
205+
)
163206
self.send_header("content-length", str(len(body)))
164207
self.end_headers()
165208
self.wfile.write(body)
166209
except urllib.error.URLError as error:
167210
self.json_response(502, {"error": f"upstream request failed: {error}"})
168211

169212
def log_message(self, fmt, *args):
213+
"""Write HTTP access logs to stderr."""
170214
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
171215

172216

173217
class Server(ThreadingHTTPServer):
218+
"""Threading HTTP server carrying shared overlay state."""
219+
174220
def __init__(self, address, state):
221+
"""Bind the server and attach its overlay state."""
175222
super().__init__(address, Handler)
176223
self.state = state
177224

178225

179226
def main():
227+
"""Run the local releases API overlay until interrupted."""
180228
parser = argparse.ArgumentParser(description=__doc__)
181229
parser.add_argument("--listen", default="127.0.0.1")
182230
parser.add_argument("--port", type=int, default=8000)
183-
parser.add_argument("--asset-dir", type=Path, default=Path("/tmp/dstack-release-mock-assets"))
231+
parser.add_argument(
232+
"--asset-dir", type=Path, default=Path("/tmp/dstack-release-mock-assets")
233+
)
184234
args = parser.parse_args()
185235
server = Server((args.listen, args.port), State(args.asset_dir))
186236
print(f"release API: http://{args.listen}:{args.port}/repos", flush=True)
187-
print(f"publish API: http://{args.listen}:{args.port}/__admin/repos/OWNER/REPO/releases", flush=True)
237+
print(
238+
f"publish API: http://{args.listen}:{args.port}/__admin/repos/OWNER/REPO/releases",
239+
flush=True,
240+
)
188241
try:
189242
server.serve_forever()
190243
except KeyboardInterrupt:

0 commit comments

Comments
 (0)