1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use anyhow::{Context, Result, anyhow, bail};
7use async_trait::async_trait;
8use oci_distribution::Reference;
9use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
10use oci_distribution::secrets::RegistryAuth;
11use semver::Version;
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
15
16use crate::cli::{
17 ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs,
18 ReleaseSnapshotArgs, ReleaseViewArgs,
19};
20use crate::install::block_on_maybe_runtime;
21use crate::passthrough::{ToolchainChannel, delegated_binary_name_for_channel};
22use crate::toolchain_catalogue::{
23 GREENTIC_COMPONENT_PACKAGES, GREENTIC_EXTENSION_PACK_PACKAGES, GREENTIC_TOOLCHAIN_PACKAGES,
24 OciPackageSpec,
25};
26
27const DEFAULT_OAUTH_USER: &str = "oauth2";
28pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
29pub const TOOLCHAIN_NAME: &str = "gtc";
30pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
31const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct ToolchainManifest {
35 pub schema: String,
36 pub toolchain: String,
37 pub version: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub channel: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub created_at: Option<String>,
42 pub packages: Vec<ToolchainPackage>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub extension_packs: Option<Vec<ExtensionPackRef>>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub components: Option<Vec<ComponentRef>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct ToolchainPackage {
51 #[serde(rename = "crate")]
52 pub crate_name: String,
53 pub bins: Vec<String>,
54 pub version: String,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ExtensionPackRef {
59 pub id: String,
60 pub version: String,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ComponentRef {
65 pub id: String,
66 pub version: String,
67}
68
69pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
70 let resolver = default_resolver();
71 let artifact_resolver = GhcrArtifactVersionResolver::new(args.token.as_deref())?;
72 let source = block_on_maybe_runtime(load_source_manifest(
73 &args.repo,
74 &args.from,
75 args.token.as_deref(),
76 ))
77 .with_context(|| {
78 format!(
79 "failed to resolve source manifest `{}`",
80 toolchain_ref(&args.repo, &args.from)
81 )
82 })?;
83 let source = match source {
84 Some(source) => Some(source),
85 None => bootstrap_source_manifest_if_needed(
86 &args.repo,
87 &args.from,
88 args.token.as_deref(),
89 args.dry_run,
90 &resolver,
91 )?,
92 };
93 let manifest = generate_manifest_with_artifact_resolver(
94 &args.release,
95 &args.from,
96 source.as_ref(),
97 &resolver,
98 &artifact_resolver,
99 Some(created_at_now()?),
100 )?;
101 if args.dry_run {
102 println!("{}", serde_json::to_string_pretty(&manifest)?);
103 return Ok(());
104 }
105 let path = write_manifest(&args.out, &manifest)?;
106 println!("Wrote {}", path.display());
107 Ok(())
108}
109
110fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
111 repo: &str,
112 tag: &str,
113 token: Option<&str>,
114 dry_run: bool,
115 resolver: &R,
116) -> Result<Option<ToolchainManifest>> {
117 let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
118 if dry_run {
119 eprintln!(
120 "Dry run: would bootstrap missing source manifest {}",
121 toolchain_ref(repo, tag)
122 );
123 return Ok(Some(manifest));
124 }
125
126 let auth = match optional_registry_auth(token)? {
127 RegistryAuth::Anonymous => {
128 eprintln!(
129 "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
130 toolchain_ref(repo, tag)
131 );
132 return Ok(Some(manifest));
133 }
134 auth => auth,
135 };
136 block_on_maybe_runtime(async {
137 let client = oci_client();
138 let source_ref = parse_reference(repo, tag)?;
139 push_manifest_layer(&client, &source_ref, &auth, &manifest).await
140 })
141 .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
142 println!("Bootstrapped {}", toolchain_ref(repo, tag));
143 Ok(Some(manifest))
144}
145
146fn bootstrap_source_manifest<R: CrateVersionResolver>(
147 tag: &str,
148 resolver: &R,
149 created_at: Option<String>,
150) -> Result<ToolchainManifest> {
151 generate_manifest(tag, tag, None, resolver, created_at)
152}
153
154pub fn publish(args: ReleasePublishArgs) -> Result<()> {
155 let (release, manifest, source) = publish_manifest_input(&args)?;
156
157 if args.dry_run {
158 println!(
159 "Dry run: would publish {}",
160 toolchain_ref(&args.repo, &release)
161 );
162 if let Some(tag) = &args.tag {
163 println!(
164 "Dry run: would tag {} as {}",
165 toolchain_ref(&args.repo, &release),
166 toolchain_ref(&args.repo, tag)
167 );
168 }
169 return Ok(());
170 }
171
172 let auth = registry_auth(args.token.as_deref())?;
173 block_on_maybe_runtime(async {
174 let client = oci_client();
175 let release_ref = parse_reference(&args.repo, &release)?;
176 if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
177 bail!(
178 "release tag `{}` already exists; pass --force to overwrite it",
179 toolchain_ref(&args.repo, &release)
180 );
181 }
182 push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
183 if let Some(tag) = &args.tag {
184 let tag_ref = parse_reference(&args.repo, tag)?;
185 push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
186 }
187 Ok(())
188 })?;
189
190 if let Some(source) = source {
191 match source {
192 PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
193 PublishManifestSource::Local(path) => println!("Read {}", path.display()),
194 }
195 }
196 println!("Published {}", toolchain_ref(&args.repo, &release));
197 if let Some(tag) = &args.tag {
198 println!("Updated {}", toolchain_ref(&args.repo, tag));
199 }
200 Ok(())
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204enum PublishManifestSource {
205 Generated(PathBuf),
206 Local(PathBuf),
207}
208
209fn publish_manifest_input(
210 args: &ReleasePublishArgs,
211) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
212 if let Some(path) = &args.manifest {
213 let mut manifest = read_manifest_file(path)?;
214 validate_manifest(&manifest)?;
215 let release = if let Some(release) = &args.release {
216 manifest.version = release.clone();
217 release.clone()
218 } else {
219 manifest.version.clone()
220 };
221 return Ok((
222 release,
223 manifest,
224 Some(PublishManifestSource::Local(path.clone())),
225 ));
226 }
227
228 let release = args
229 .release
230 .as_deref()
231 .context("pass --release or --manifest")?;
232 let from = args.from.as_deref().unwrap_or("latest");
233 let resolver = default_resolver();
234 let source = block_on_maybe_runtime(load_source_manifest(
235 &args.repo,
236 from,
237 args.token.as_deref(),
238 ))
239 .with_context(|| {
240 format!(
241 "failed to resolve source manifest `{}`",
242 toolchain_ref(&args.repo, from)
243 )
244 })?;
245 if let Some(source_manifest) = source.as_ref()
246 && source_manifest_has_concrete_pins(source_manifest)
247 {
248 eprintln!(
249 "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
250 of querying crates.io. To refresh a channel from the latest crates.io versions, use \
251 `release snapshot --channel <dev|stable>`. To copy an existing release tag without \
252 re-resolving, use `release promote`. The conflated `--from` semantics will be \
253 removed in a future release.",
254 toolchain_ref(&args.repo, from),
255 );
256 }
257 let manifest = generate_manifest(
258 release,
259 from,
260 source.as_ref(),
261 &resolver,
262 Some(created_at_now()?),
263 )?;
264 let path = if args.dry_run {
265 println!("{}", serde_json::to_string_pretty(&manifest)?);
266 None
267 } else {
268 Some(PublishManifestSource::Generated(write_manifest(
269 &args.out, &manifest,
270 )?))
271 };
272 Ok((release.to_string(), manifest, path))
273}
274
275fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
280 manifest
281 .packages
282 .iter()
283 .any(|package| package.version != "latest")
284}
285
286fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
287 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
288 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
289}
290
291pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
292 if args.dry_run {
293 println!(
294 "Dry run: would promote {} to {}",
295 toolchain_ref(&args.repo, &args.release),
296 toolchain_ref(&args.repo, &args.tag)
297 );
298 return Ok(());
299 }
300
301 let auth = registry_auth(args.token.as_deref())?;
302 block_on_maybe_runtime(async {
303 let client = oci_client();
304 let source_ref = parse_reference(&args.repo, &args.release)?;
305 let target_ref = parse_reference(&args.repo, &args.tag)?;
306 let (manifest, _) = client
307 .pull_manifest(&source_ref, &auth)
308 .await
309 .with_context(|| {
310 format!(
311 "failed to resolve source release `{}`",
312 toolchain_ref(&args.repo, &args.release)
313 )
314 })?;
315 client
316 .push_manifest(&target_ref, &manifest)
317 .await
318 .with_context(|| {
319 format!(
320 "failed to update tag `{}`",
321 toolchain_ref(&args.repo, &args.tag)
322 )
323 })?;
324 Ok(())
325 })?;
326 println!(
327 "Promoted {} to {}",
328 toolchain_ref(&args.repo, &args.release),
329 toolchain_ref(&args.repo, &args.tag)
330 );
331 Ok(())
332}
333
334pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
342 let channel = parse_channel(&args.channel)?;
343 let resolver = CratesIoApiVersionResolver::default();
344 let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
345
346 if args.dry_run {
347 println!("{}", serde_json::to_string_pretty(&manifest)?);
348 println!(
349 "Dry run: would publish {}",
350 toolchain_ref(&args.repo, &args.release)
351 );
352 if let Some(tag) = &args.tag {
353 println!(
354 "Dry run: would tag {} as {}",
355 toolchain_ref(&args.repo, &args.release),
356 toolchain_ref(&args.repo, tag)
357 );
358 }
359 return Ok(());
360 }
361
362 let path = write_manifest(&args.out, &manifest)?;
363 println!("Wrote {}", path.display());
364
365 let auth = registry_auth(args.token.as_deref())?;
366 block_on_maybe_runtime(async {
367 let client = oci_client();
368 let release_ref = parse_reference(&args.repo, &args.release)?;
369 if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
370 bail!(
371 "release tag `{}` already exists; pass --force to overwrite it",
372 toolchain_ref(&args.repo, &args.release)
373 );
374 }
375 push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
376 if let Some(tag) = &args.tag {
377 let tag_ref = parse_reference(&args.repo, tag)?;
378 push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
379 }
380 Ok(())
381 })?;
382 println!("Published {}", toolchain_ref(&args.repo, &args.release));
383 if let Some(tag) = &args.tag {
384 println!("Updated {}", toolchain_ref(&args.repo, tag));
385 }
386 Ok(())
387}
388
389fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
390 match channel {
391 "dev" | "development" => Ok(ToolchainChannel::Development),
392 "stable" => Ok(ToolchainChannel::Stable),
393 other => bail!(
394 "unknown channel `{other}` (expected `dev` or `stable`); pass --channel dev for the \
395 dev lane or --channel stable for the stable lane"
396 ),
397 }
398}
399
400fn channel_tag(channel: ToolchainChannel) -> &'static str {
401 match channel {
402 ToolchainChannel::Stable => "stable",
403 ToolchainChannel::Development => "dev",
404 ToolchainChannel::Rnd => "rnd",
405 }
406}
407
408pub fn snapshot_manifest<R: CrateVersionResolver>(
409 release: &str,
410 channel: ToolchainChannel,
411 resolver: &R,
412 created_at: Option<String>,
413) -> Result<ToolchainManifest> {
414 let from = channel_tag(channel);
415 let mut packages = Vec::new();
416 for package in GREENTIC_TOOLCHAIN_PACKAGES {
417 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
418 let version = resolver
419 .resolve_latest(&crate_in_manifest)
420 .with_context(|| {
421 format!("failed to resolve latest version for `{crate_in_manifest}`")
422 })?;
423 packages.push(ToolchainPackage {
424 crate_name: crate_in_manifest,
425 bins: manifest_bins_for_source(from, package.bins),
426 version,
427 });
428 }
429 Ok(ToolchainManifest {
430 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
431 toolchain: TOOLCHAIN_NAME.to_string(),
432 version: release.to_string(),
433 channel: Some(from.to_string()),
434 created_at,
435 packages,
436 extension_packs: None,
437 components: None,
438 })
439}
440
441pub fn view(args: ReleaseViewArgs) -> Result<()> {
442 let tag = release_view_tag(&args)?;
443 let manifest = block_on_maybe_runtime(load_source_manifest(
444 &args.repo,
445 &tag,
446 args.token.as_deref(),
447 ))
448 .with_context(|| {
449 format!(
450 "failed to resolve manifest `{}`",
451 toolchain_ref(&args.repo, &tag)
452 )
453 })?
454 .with_context(|| {
455 format!(
456 "manifest `{}` was not found or is not authorized for this token",
457 toolchain_ref(&args.repo, &tag)
458 )
459 })?;
460 println!("{}", serde_json::to_string_pretty(&manifest)?);
461 Ok(())
462}
463
464pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
465 let manifest = latest_manifest(Some(created_at_now()?));
466 if args.dry_run {
467 println!("{}", serde_json::to_string_pretty(&manifest)?);
468 println!(
469 "Dry run: would publish {}",
470 toolchain_ref(&args.repo, "latest")
471 );
472 return Ok(());
473 }
474
475 let auth = registry_auth(args.token.as_deref())?;
476 block_on_maybe_runtime(async {
477 let client = oci_client();
478 let latest_ref = parse_reference(&args.repo, "latest")?;
479 if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
480 bail!(
481 "latest tag `{}` already exists; pass --force to overwrite it",
482 toolchain_ref(&args.repo, "latest")
483 );
484 }
485 push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
486 })?;
487 println!("Published {}", toolchain_ref(&args.repo, "latest"));
488 Ok(())
489}
490
491fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
492 ToolchainManifest {
493 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
494 toolchain: TOOLCHAIN_NAME.to_string(),
495 version: "latest".to_string(),
496 channel: Some("latest".to_string()),
497 created_at,
498 packages: latest_manifest_packages(),
499 extension_packs: Some(
500 GREENTIC_EXTENSION_PACK_PACKAGES
501 .iter()
502 .map(|package| ExtensionPackRef {
503 id: package.package.to_string(),
504 version: "latest".to_string(),
505 })
506 .collect(),
507 ),
508 components: Some(
509 GREENTIC_COMPONENT_PACKAGES
510 .iter()
511 .map(|package| ComponentRef {
512 id: package.package.to_string(),
513 version: "latest".to_string(),
514 })
515 .collect(),
516 ),
517 }
518}
519
520fn latest_manifest_packages() -> Vec<ToolchainPackage> {
521 std::iter::once(ToolchainPackage {
522 crate_name: delegated_binary_name_for_channel(
523 TOOLCHAIN_NAME,
524 ToolchainChannel::Development,
525 ),
526 bins: vec![delegated_binary_name_for_channel(
527 TOOLCHAIN_NAME,
528 ToolchainChannel::Development,
529 )],
530 version: "latest".to_string(),
531 })
532 .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
533 ToolchainPackage {
534 crate_name: delegated_binary_name_for_channel(
535 package.crate_name,
536 ToolchainChannel::Development,
537 ),
538 bins: package
539 .bins
540 .iter()
541 .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
542 .collect(),
543 version: "latest".to_string(),
544 }
545 }))
546 .collect()
547}
548
549fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
550 match (&args.release, &args.tag) {
551 (Some(release), None) => Ok(release.clone()),
552 (None, Some(tag)) => Ok(tag.clone()),
553 _ => bail!("pass exactly one of --release or --tag"),
554 }
555}
556
557pub fn generate_manifest<R: CrateVersionResolver>(
558 release: &str,
559 from: &str,
560 source: Option<&ToolchainManifest>,
561 resolver: &R,
562 created_at: Option<String>,
563) -> Result<ToolchainManifest> {
564 let artifact_resolver = ReleaseArtifactVersionResolver { release };
565 generate_manifest_with_artifact_resolver(
566 release,
567 from,
568 source,
569 resolver,
570 &artifact_resolver,
571 created_at,
572 )
573}
574
575pub fn generate_manifest_with_artifact_resolver<R, A>(
576 release: &str,
577 from: &str,
578 source: Option<&ToolchainManifest>,
579 resolver: &R,
580 artifact_resolver: &A,
581 created_at: Option<String>,
582) -> Result<ToolchainManifest>
583where
584 R: CrateVersionResolver,
585 A: ArtifactVersionResolver,
586{
587 if let Some(source) = source {
588 validate_manifest(source)?;
589 }
590 let source_versions = source_version_map(source);
591 let mut packages = Vec::new();
592 for package in GREENTIC_TOOLCHAIN_PACKAGES {
593 let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
594 let source_version = source_versions.get(&crate_in_manifest);
595 let version = match source_version.map(String::as_str) {
596 Some(version) if version != "latest" => version.to_string(),
597 _ => resolver.resolve_latest(&crate_in_manifest)?,
598 };
599 packages.push(ToolchainPackage {
600 crate_name: crate_in_manifest,
601 bins: manifest_bins_for_source(from, package.bins),
602 version,
603 });
604 }
605 Ok(ToolchainManifest {
606 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
607 toolchain: TOOLCHAIN_NAME.to_string(),
608 version: release.to_string(),
609 channel: Some(from.to_string()),
610 created_at,
611 packages,
612 extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
613 components: Some(component_refs_for_release(source, artifact_resolver)?),
614 })
615}
616
617fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
618 let channel = match from {
619 "dev" => ToolchainChannel::Development,
620 "rnd" => ToolchainChannel::Rnd,
621 _ => ToolchainChannel::Stable,
622 };
623 bins.iter()
624 .map(|bin| delegated_binary_name_for_channel(bin, channel))
625 .collect()
626}
627
628fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
629 source: Option<&ToolchainManifest>,
630 artifact_resolver: &A,
631) -> Result<Vec<ExtensionPackRef>> {
632 let source_versions = source_ref_version_map(source.and_then(|manifest| {
633 manifest
634 .extension_packs
635 .as_ref()
636 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
637 }));
638 GREENTIC_EXTENSION_PACK_PACKAGES
639 .iter()
640 .map(|package| {
641 Ok(ExtensionPackRef {
642 id: package.package.to_string(),
643 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
644 })
645 })
646 .collect()
647}
648
649fn component_refs_for_release<A: ArtifactVersionResolver>(
650 source: Option<&ToolchainManifest>,
651 artifact_resolver: &A,
652) -> Result<Vec<ComponentRef>> {
653 let source_versions = source_ref_version_map(source.and_then(|manifest| {
654 manifest
655 .components
656 .as_ref()
657 .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
658 }));
659 GREENTIC_COMPONENT_PACKAGES
660 .iter()
661 .map(|package| {
662 Ok(ComponentRef {
663 id: package.package.to_string(),
664 version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
665 })
666 })
667 .collect()
668}
669
670fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
671where
672 I: Iterator<Item = (&'a String, &'a String)>,
673{
674 let mut out = BTreeMap::new();
675 if let Some(refs) = refs {
676 for (id, version) in refs {
677 out.insert(id.clone(), version.clone());
678 }
679 }
680 out
681}
682
683fn ref_version_for_package(
684 package: &OciPackageSpec,
685 source_versions: &BTreeMap<String, String>,
686 artifact_resolver: &impl ArtifactVersionResolver,
687) -> Result<String> {
688 match source_versions.get(package.package).map(String::as_str) {
689 Some(version) if version != "latest" => Ok(version.to_string()),
690 _ => artifact_resolver
691 .resolve_latest(package.package)
692 .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
693 }
694}
695
696fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
704 if from == "dev" {
705 delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
706 } else {
707 crate_name.to_string()
708 }
709}
710
711pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
712 if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
713 bail!(
714 "unsupported toolchain manifest schema `{}`",
715 manifest.schema
716 );
717 }
718 if manifest.toolchain != TOOLCHAIN_NAME {
719 bail!("unsupported toolchain `{}`", manifest.toolchain);
720 }
721 Ok(())
722}
723
724pub fn toolchain_ref(repo: &str, tag: &str) -> String {
725 format!("{repo}:{tag}")
726}
727
728fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
729 let mut out = BTreeMap::new();
730 if let Some(source) = source {
731 for package in &source.packages {
732 out.insert(package.crate_name.clone(), package.version.clone());
733 }
734 }
735 out
736}
737
738fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
739 fs::create_dir_all(out_dir)
740 .with_context(|| format!("failed to create {}", out_dir.display()))?;
741 let path = out_dir.join(manifest_file_name(manifest));
742 let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
743 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
744 Ok(path)
745}
746
747fn manifest_file_name(manifest: &ToolchainManifest) -> String {
748 match manifest.channel.as_deref() {
749 Some("stable") | None => format!("gtc-{}.json", manifest.version),
750 Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
751 }
752}
753
754fn created_at_now() -> Result<String> {
755 OffsetDateTime::now_utc()
756 .format(&Rfc3339)
757 .context("failed to format current time")
758}
759
760pub trait CrateVersionResolver {
761 fn resolve_latest(&self, crate_name: &str) -> Result<String>;
762}
763
764fn default_resolver() -> CratesIoApiVersionResolver {
768 CratesIoApiVersionResolver::default()
769}
770
771pub trait ArtifactVersionResolver {
772 fn resolve_latest(&self, package: &str) -> Result<String>;
773}
774
775const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
776const CRATES_IO_USER_AGENT: &str = concat!(
777 "greentic-dev/",
778 env!("CARGO_PKG_VERSION"),
779 " (https://github.com/greenticai/greentic-dev)"
780);
781
782pub struct CratesIoApiVersionResolver {
789 base_url: String,
790 client: reqwest::blocking::Client,
791}
792
793impl Default for CratesIoApiVersionResolver {
794 fn default() -> Self {
795 Self::new(CRATES_IO_API_BASE)
796 }
797}
798
799impl CratesIoApiVersionResolver {
800 pub fn new(base_url: impl Into<String>) -> Self {
801 let client = reqwest::blocking::Client::builder()
802 .user_agent(CRATES_IO_USER_AGENT)
803 .build()
804 .expect("failed to build crates.io API client");
805 Self {
806 base_url: base_url.into(),
807 client,
808 }
809 }
810}
811
812struct ReleaseArtifactVersionResolver<'a> {
813 release: &'a str,
814}
815
816impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
817 fn resolve_latest(&self, _package: &str) -> Result<String> {
818 Ok(self.release.to_string())
819 }
820}
821
822struct GhcrArtifactVersionResolver {
823 client: reqwest::blocking::Client,
824 registry: String,
825 namespace: String,
826 basic_token: Option<String>,
827}
828
829impl GhcrArtifactVersionResolver {
830 fn new(raw_token: Option<&str>) -> Result<Self> {
831 Ok(Self {
832 client: reqwest::blocking::Client::builder()
833 .build()
834 .context("failed to build GHCR HTTP client")?,
835 registry: "ghcr.io".to_string(),
836 namespace: "greenticai".to_string(),
837 basic_token: resolve_registry_token(raw_token)?
838 .or_else(|| std::env::var("GHCR_TOKEN").ok())
839 .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
840 })
841 }
842
843 fn bearer_token(&self, repository: &str) -> Result<String> {
844 let scope = format!("repository:{repository}:pull");
845 let mut request = self
846 .client
847 .get(format!("https://{}/token", self.registry))
848 .query(&[
849 ("service", self.registry.as_str()),
850 ("scope", scope.as_str()),
851 ]);
852 if let Some(token) = &self.basic_token {
853 request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
854 }
855 let response = request
856 .send()
857 .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
858 .error_for_status()
859 .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
860 let body: GhcrTokenResponse = response
861 .json()
862 .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
863 Ok(body.token)
864 }
865
866 fn tags(&self, repository: &str) -> Result<Vec<String>> {
867 let token = self.bearer_token(repository)?;
868 let response = self
869 .client
870 .get(format!(
871 "https://{}/v2/{repository}/tags/list",
872 self.registry
873 ))
874 .bearer_auth(token)
875 .send()
876 .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
877 .error_for_status()
878 .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
879 let body: GhcrTagsResponse = response
880 .json()
881 .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
882 Ok(body.tags)
883 }
884}
885
886impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
887 fn resolve_latest(&self, package: &str) -> Result<String> {
888 let repository = format!("{}/{}", self.namespace, package);
889 let tags = self.tags(&repository)?;
890 select_latest_artifact_tag(&tags)
891 .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
892 }
893}
894
895#[derive(Deserialize)]
896struct GhcrTokenResponse {
897 token: String,
898}
899
900#[derive(Deserialize)]
901struct GhcrTagsResponse {
902 #[serde(default)]
903 tags: Vec<String>,
904}
905
906fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
907 tags.iter()
908 .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
909 .max_by(|(left, _), (right, _)| left.cmp(right))
910 .map(|(_, tag)| tag.clone())
911 .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
912 .context("no semver or latest tags found")
913}
914
915impl CrateVersionResolver for CratesIoApiVersionResolver {
916 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
917 let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
918 let response = self
919 .client
920 .get(&url)
921 .send()
922 .with_context(|| format!("failed to GET {url}"))?;
923 let status = response.status();
924 let body = response
925 .text()
926 .with_context(|| format!("failed to read body of {url}"))?;
927 if !status.is_success() {
928 bail!("crates.io API GET {url} returned {status}: {body}");
929 }
930 parse_crates_io_version(crate_name, &body)
931 }
932}
933
934fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
935 let payload: serde_json::Value = serde_json::from_str(body)
936 .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
937 let crate_obj = payload.get("crate").ok_or_else(|| {
938 anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
939 })?;
940 let version = crate_obj
941 .get("max_stable_version")
942 .and_then(|v| v.as_str())
943 .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
944 .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
945 .ok_or_else(|| {
946 anyhow!(
947 "crates.io API for `{crate_name}` does not expose max_stable_version, \
948 newest_version, or max_version"
949 )
950 })?;
951 Version::parse(version).with_context(|| {
952 format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
953 })?;
954 Ok(version.to_string())
955}
956
957#[async_trait]
958trait ToolchainManifestSource {
959 async fn load_manifest(
960 &self,
961 repo: &str,
962 tag: &str,
963 token: Option<&str>,
964 ) -> Result<Option<ToolchainManifest>>;
965}
966
967struct OciToolchainManifestSource;
968
969#[async_trait]
970impl ToolchainManifestSource for OciToolchainManifestSource {
971 async fn load_manifest(
972 &self,
973 repo: &str,
974 tag: &str,
975 token: Option<&str>,
976 ) -> Result<Option<ToolchainManifest>> {
977 let auth = optional_registry_auth(token)?;
978 let client = oci_client();
979 let reference = parse_reference(repo, tag)?;
980 let image = match client
981 .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
982 .await
983 {
984 Ok(image) => image,
985 Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
986 return Ok(None);
987 }
988 Err(err) => {
989 return Err(err)
990 .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
991 }
992 };
993 let Some(layer) = image
994 .layers
995 .into_iter()
996 .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
997 else {
998 return Ok(None);
999 };
1000 let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1001 .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1002 validate_manifest(&manifest)?;
1003 Ok(Some(manifest))
1004 }
1005}
1006
1007async fn load_source_manifest(
1008 repo: &str,
1009 tag: &str,
1010 token: Option<&str>,
1011) -> Result<Option<ToolchainManifest>> {
1012 OciToolchainManifestSource
1013 .load_manifest(repo, tag, token)
1014 .await
1015}
1016
1017fn oci_client() -> Client {
1018 Client::new(ClientConfig {
1019 protocol: ClientProtocol::Https,
1020 ..Default::default()
1021 })
1022}
1023
1024fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1025 let token = resolve_registry_token(raw_token)?
1026 .or_else(|| std::env::var("GHCR_TOKEN").ok())
1027 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1028 .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1029 if token.trim().is_empty() {
1030 bail!("GHCR token is empty");
1031 }
1032 Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1033}
1034
1035fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1036 match registry_auth(raw_token) {
1037 Ok(auth) => Ok(auth),
1038 Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1039 Err(err) => Err(err),
1040 }
1041}
1042
1043fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1044 let Some(raw_token) = raw_token else {
1045 return Ok(None);
1046 };
1047 if let Some(var) = raw_token.strip_prefix("env:") {
1048 let token =
1049 std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1050 if token.trim().is_empty() {
1051 bail!("env var {var} resolved to an empty token");
1052 }
1053 return Ok(Some(token));
1054 }
1055 if raw_token.trim().is_empty() {
1056 bail!("GHCR token is empty");
1057 }
1058 Ok(Some(raw_token.to_string()))
1059}
1060
1061fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1062 Reference::from_str(&toolchain_ref(repo, tag))
1063 .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1064}
1065
1066async fn manifest_exists(
1067 client: &Client,
1068 reference: &Reference,
1069 auth: &RegistryAuth,
1070) -> Result<bool> {
1071 match client.pull_manifest(reference, auth).await {
1072 Ok(_) => Ok(true),
1073 Err(err) if is_missing_manifest_error(&err) => Ok(false),
1074 Err(err) => Err(err).context("failed to check whether release tag exists"),
1075 }
1076}
1077
1078fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1079 let msg = err.to_string().to_ascii_lowercase();
1080 msg.contains("manifest unknown")
1081 || msg.contains("name unknown")
1082 || msg.contains("not found")
1083 || msg.contains("404")
1084}
1085
1086fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1087 let msg = err.to_string().to_ascii_lowercase();
1088 msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1089}
1090
1091async fn push_manifest_layer(
1092 client: &Client,
1093 reference: &Reference,
1094 auth: &RegistryAuth,
1095 manifest: &ToolchainManifest,
1096) -> Result<()> {
1097 let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1098 let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1099 let config = Config::new(
1100 br#"{"toolchain":"gtc"}"#.to_vec(),
1101 TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1102 None,
1103 );
1104 client
1105 .push(reference, &[layer], config, auth, None)
1106 .await
1107 .context("failed to push toolchain manifest")?;
1108 Ok(())
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use once_cell::sync::Lazy;
1115 use std::sync::Mutex;
1116
1117 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1118
1119 struct FixedResolver;
1120
1121 impl CrateVersionResolver for FixedResolver {
1122 fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1123 Ok(match crate_name {
1124 "greentic-runner" => "0.5.10",
1125 _ => "1.2.3",
1126 }
1127 .to_string())
1128 }
1129 }
1130
1131 struct FixedArtifactResolver;
1132
1133 impl ArtifactVersionResolver for FixedArtifactResolver {
1134 fn resolve_latest(&self, package: &str) -> Result<String> {
1135 Ok(match package {
1136 "packs/messaging/messaging-webchat-gui" => "0.4.93",
1137 "components/component-adaptive-card" => "0.5.8",
1138 _ => "0.1.0",
1139 }
1140 .to_string())
1141 }
1142 }
1143
1144 #[test]
1145 fn parses_crates_io_max_stable_version() {
1146 let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1147 let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1148 assert_eq!(version, "0.5.123");
1149 }
1150
1151 #[test]
1152 fn parses_crates_io_falls_back_to_newest_version() {
1153 let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1154 let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1155 assert_eq!(version, "0.6.7");
1156 }
1157
1158 #[test]
1159 fn parses_crates_io_falls_back_to_max_version() {
1160 let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1161 let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1162 assert_eq!(version, "0.4.99");
1163 }
1164
1165 #[test]
1166 fn rejects_crates_io_payload_without_versions() {
1167 let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1168 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1169 assert!(
1170 err.to_string()
1171 .contains("does not expose max_stable_version")
1172 );
1173 }
1174
1175 #[test]
1176 fn rejects_crates_io_payload_with_unparseable_version() {
1177 let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1178 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1179 assert!(err.to_string().contains("unparseable version"));
1180 }
1181
1182 #[test]
1183 fn rejects_crates_io_payload_without_crate_object() {
1184 let body = r#"{"errors":[{"detail":"not found"}]}"#;
1185 let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1186 assert!(
1187 err.to_string()
1188 .contains("missing the top-level `crate` object")
1189 );
1190 }
1191
1192 #[test]
1193 fn selects_latest_semver_tag() {
1194 let tags = vec![
1195 "latest".to_string(),
1196 "0.4.93".to_string(),
1197 "0.4.9".to_string(),
1198 "1.0.0-beta.1".to_string(),
1199 "1.0.0".to_string(),
1200 ];
1201
1202 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1203 }
1204
1205 #[test]
1206 fn selects_latest_tag_when_no_semver_tags_exist() {
1207 let tags = vec!["latest".to_string()];
1208
1209 assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
1210 }
1211
1212 #[test]
1213 fn generates_manifest_from_catalogue() {
1214 let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1215 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1216 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1217 assert_eq!(manifest.version, "1.0.5");
1218 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1219 assert!(
1220 manifest
1221 .packages
1222 .iter()
1223 .any(|package| package.crate_name == "greentic-bundle"
1224 && package.bins == ["greentic-bundle"])
1225 );
1226 assert!(
1227 manifest
1228 .packages
1229 .iter()
1230 .any(|package| package.crate_name == "greentic-runner"
1231 && package.bins == ["greentic-runner"])
1232 );
1233 assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 94);
1234 assert_eq!(manifest.components.as_ref().unwrap().len(), 9);
1235 assert!(
1236 manifest
1237 .extension_packs
1238 .as_ref()
1239 .unwrap()
1240 .iter()
1241 .all(|item| item.version == "1.0.5")
1242 );
1243 assert!(
1244 manifest
1245 .components
1246 .as_ref()
1247 .unwrap()
1248 .iter()
1249 .all(|item| item.version == "1.0.5")
1250 );
1251 }
1252
1253 #[test]
1254 fn generated_manifest_can_use_artifact_resolver_versions() {
1255 let manifest = generate_manifest_with_artifact_resolver(
1256 "1.0.17",
1257 "stable",
1258 None,
1259 &FixedResolver,
1260 &FixedArtifactResolver,
1261 None,
1262 )
1263 .unwrap();
1264
1265 assert!(
1266 manifest
1267 .extension_packs
1268 .as_ref()
1269 .unwrap()
1270 .iter()
1271 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1272 && item.version == "0.4.93")
1273 );
1274 assert!(
1275 manifest
1276 .components
1277 .as_ref()
1278 .unwrap()
1279 .iter()
1280 .any(|item| item.id == "components/component-adaptive-card"
1281 && item.version == "0.5.8")
1282 );
1283 }
1284
1285 #[test]
1286 fn source_manifest_can_pin_package_versions() {
1287 let source = ToolchainManifest {
1288 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1289 toolchain: TOOLCHAIN_NAME.to_string(),
1290 version: "latest".to_string(),
1291 channel: Some("latest".to_string()),
1292 created_at: None,
1293 packages: vec![ToolchainPackage {
1294 crate_name: "greentic-dev".to_string(),
1295 bins: vec!["greentic-dev".to_string()],
1296 version: "0.5.9".to_string(),
1297 }],
1298 extension_packs: None,
1299 components: None,
1300 };
1301 let manifest =
1302 generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
1303 let greentic_dev = manifest
1304 .packages
1305 .iter()
1306 .find(|package| package.crate_name == "greentic-dev")
1307 .unwrap();
1308 assert_eq!(greentic_dev.version, "0.5.9");
1309 }
1310
1311 #[test]
1312 fn from_argument_controls_generated_channel_over_source_manifest() {
1313 let source = ToolchainManifest {
1314 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1315 toolchain: TOOLCHAIN_NAME.to_string(),
1316 version: "latest".to_string(),
1317 channel: Some("stable".to_string()),
1318 created_at: None,
1319 packages: Vec::new(),
1320 extension_packs: None,
1321 components: None,
1322 };
1323 let manifest =
1324 generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
1325 assert_eq!(manifest.channel.as_deref(), Some("dev"));
1326 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
1327 }
1328
1329 #[test]
1330 fn generate_from_dev_uses_dev_crate_and_binary_names() {
1331 let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1332 assert!(
1333 manifest
1334 .packages
1335 .iter()
1336 .flat_map(|package| package.bins.iter())
1337 .all(|bin| bin.ends_with("-dev"))
1338 );
1339 assert!(
1340 manifest
1341 .packages
1342 .iter()
1343 .all(|package| package.crate_name.ends_with("-dev")),
1344 "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
1345 );
1346 assert!(manifest.packages.iter().any(|package| {
1347 package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
1348 }));
1349 assert!(manifest.packages.iter().any(|package| {
1350 package.crate_name == "greentic-component-dev"
1351 && package.bins == ["greentic-component-dev"]
1352 }));
1353 assert!(manifest.packages.iter().any(|package| {
1354 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1355 }));
1356 }
1357
1358 #[test]
1359 fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
1360 let manifest =
1361 snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
1362 .unwrap();
1363 assert_eq!(manifest.version, "1.1.5");
1364 assert_eq!(manifest.channel.as_deref(), Some("dev"));
1365 for package in &manifest.packages {
1366 assert!(
1367 package.crate_name.ends_with("-dev"),
1368 "dev snapshot must pin -dev crate names; got {}",
1369 package.crate_name
1370 );
1371 assert!(
1372 package.bins.iter().all(|bin| bin.ends_with("-dev")),
1373 "dev snapshot must pin -dev bin names; got {:?}",
1374 package.bins
1375 );
1376 assert_ne!(
1377 package.version, "latest",
1378 "snapshot must always resolve concrete versions"
1379 );
1380 }
1381 assert!(
1382 manifest
1383 .packages
1384 .iter()
1385 .any(|package| package.crate_name == "greentic-operator-dev")
1386 );
1387 }
1388
1389 #[test]
1390 fn snapshot_manifest_stable_channel_keeps_plain_names() {
1391 let manifest =
1392 snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
1393 assert_eq!(manifest.channel.as_deref(), Some("stable"));
1394 let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
1398 .iter()
1399 .map(|spec| spec.crate_name)
1400 .collect();
1401 for package in &manifest.packages {
1402 assert!(
1403 catalogue_names.contains(package.crate_name.as_str()),
1404 "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
1405 package.crate_name
1406 );
1407 }
1408 }
1409
1410 #[test]
1411 fn snapshot_manifest_resolves_via_resolver() {
1412 let manifest =
1413 snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
1414 .unwrap();
1415 for package in &manifest.packages {
1421 assert_eq!(
1422 package.version, "1.2.3",
1423 "resolver must be hit for {}",
1424 package.crate_name
1425 );
1426 }
1427 }
1428
1429 #[test]
1430 fn parses_dev_channel_argument() {
1431 assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
1432 assert_eq!(
1433 parse_channel("development").unwrap(),
1434 ToolchainChannel::Development
1435 );
1436 assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
1437 assert!(parse_channel("rc").is_err());
1438 }
1439
1440 #[test]
1441 fn detects_concrete_pins_for_publish_deprecation_warning() {
1442 let with_pins = ToolchainManifest {
1443 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1444 toolchain: TOOLCHAIN_NAME.to_string(),
1445 version: "0.0.1".to_string(),
1446 channel: Some("dev".to_string()),
1447 created_at: None,
1448 packages: vec![ToolchainPackage {
1449 crate_name: "greentic-operator-dev".to_string(),
1450 bins: vec!["greentic-operator-dev".to_string()],
1451 version: "0.5.123".to_string(),
1452 }],
1453 extension_packs: None,
1454 components: None,
1455 };
1456 assert!(source_manifest_has_concrete_pins(&with_pins));
1457
1458 let only_latest = ToolchainManifest {
1459 packages: vec![ToolchainPackage {
1460 crate_name: "greentic-operator".to_string(),
1461 bins: vec!["greentic-operator".to_string()],
1462 version: "latest".to_string(),
1463 }],
1464 ..with_pins
1465 };
1466 assert!(!source_manifest_has_concrete_pins(&only_latest));
1467 }
1468
1469 #[test]
1470 fn generate_from_rnd_uses_rnd_binary_names() {
1471 let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
1472 assert_eq!(manifest.channel.as_deref(), Some("rnd"));
1473 assert!(
1474 manifest
1475 .packages
1476 .iter()
1477 .flat_map(|package| package.bins.iter())
1478 .all(|bin| bin.ends_with("-rnd"))
1479 );
1480 assert!(manifest.packages.iter().any(|package| {
1481 package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
1482 }));
1483 }
1484
1485 #[test]
1486 fn bootstrap_source_manifest_uses_source_tag_identity() {
1487 let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1488 assert_eq!(manifest.version, "latest");
1489 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1490 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1491 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1492 assert!(
1493 manifest
1494 .packages
1495 .iter()
1496 .all(|package| package.version != "latest")
1497 );
1498 }
1499
1500 #[test]
1501 fn validates_schema_and_toolchain() {
1502 let mut manifest =
1503 generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1504 assert!(validate_manifest(&manifest).is_ok());
1505 manifest.schema = "wrong".to_string();
1506 assert!(validate_manifest(&manifest).is_err());
1507 manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
1508 manifest.toolchain = "other".to_string();
1509 assert!(validate_manifest(&manifest).is_err());
1510 }
1511
1512 #[test]
1513 fn resolves_inline_registry_token() {
1514 assert_eq!(
1515 resolve_registry_token(Some("secret-token"))
1516 .unwrap()
1517 .as_deref(),
1518 Some("secret-token")
1519 );
1520 }
1521
1522 #[test]
1523 fn resolves_registry_token_from_environment_reference() {
1524 let _guard = ENV_LOCK.lock().unwrap();
1525 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1526 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
1527
1528 let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
1529 assert_eq!(resolved.as_deref(), Some("env-secret"));
1530
1531 match previous {
1532 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1533 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1534 }
1535 }
1536
1537 #[test]
1538 fn rejects_empty_registry_token_from_environment_reference() {
1539 let _guard = ENV_LOCK.lock().unwrap();
1540 let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1541 unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", " ") };
1542
1543 let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
1544 assert!(err.to_string().contains("resolved to an empty token"));
1545
1546 match previous {
1547 Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1548 None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1549 }
1550 }
1551
1552 #[test]
1553 fn registry_auth_uses_environment_fallbacks() {
1554 let _guard = ENV_LOCK.lock().unwrap();
1555 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1556 let previous_github = std::env::var("GITHUB_TOKEN").ok();
1557 unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
1558 unsafe { std::env::remove_var("GITHUB_TOKEN") };
1559
1560 let auth = registry_auth(None).unwrap();
1561 match auth {
1562 RegistryAuth::Basic(user, token) => {
1563 assert_eq!(user, DEFAULT_OAUTH_USER);
1564 assert_eq!(token, "ghcr-secret");
1565 }
1566 _ => panic!("expected basic auth"),
1567 }
1568
1569 match previous_ghcr {
1570 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1571 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1572 }
1573 match previous_github {
1574 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1575 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1576 }
1577 }
1578
1579 #[test]
1580 fn optional_registry_auth_allows_missing_implicit_token() {
1581 let _guard = ENV_LOCK.lock().unwrap();
1582 let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1583 let previous_github = std::env::var("GITHUB_TOKEN").ok();
1584 unsafe { std::env::remove_var("GHCR_TOKEN") };
1585 unsafe { std::env::remove_var("GITHUB_TOKEN") };
1586
1587 let auth = optional_registry_auth(None).unwrap();
1588 assert!(matches!(auth, RegistryAuth::Anonymous));
1589
1590 match previous_ghcr {
1591 Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1592 None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1593 }
1594 match previous_github {
1595 Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1596 None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1597 }
1598 }
1599
1600 #[test]
1601 fn release_view_tag_prefers_release_or_tag() {
1602 let args = ReleaseViewArgs {
1603 release: Some("1.0.5".to_string()),
1604 tag: None,
1605 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1606 token: None,
1607 };
1608 assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1609
1610 let args = ReleaseViewArgs {
1611 release: None,
1612 tag: Some("stable".to_string()),
1613 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1614 token: None,
1615 };
1616 assert_eq!(release_view_tag(&args).unwrap(), "stable");
1617 }
1618
1619 #[test]
1620 fn release_view_tag_rejects_invalid_argument_combinations() {
1621 let err = release_view_tag(&ReleaseViewArgs {
1622 release: Some("1.0.5".to_string()),
1623 tag: Some("stable".to_string()),
1624 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1625 token: None,
1626 })
1627 .unwrap_err();
1628 assert!(
1629 err.to_string()
1630 .contains("pass exactly one of --release or --tag")
1631 );
1632
1633 let err = release_view_tag(&ReleaseViewArgs {
1634 release: None,
1635 tag: None,
1636 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1637 token: None,
1638 })
1639 .unwrap_err();
1640 assert!(
1641 err.to_string()
1642 .contains("pass exactly one of --release or --tag")
1643 );
1644 }
1645
1646 #[test]
1647 fn publish_manifest_input_uses_local_manifest_version() {
1648 let dir = tempfile::tempdir().unwrap();
1649 let path = dir.path().join("gtc-1.0.12.json");
1650 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1651 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1652 let args = ReleasePublishArgs {
1653 release: None,
1654 from: None,
1655 tag: Some("stable".to_string()),
1656 manifest: Some(path.clone()),
1657 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1658 token: None,
1659 out: dir.path().to_path_buf(),
1660 dry_run: true,
1661 force: true,
1662 };
1663 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1664 assert_eq!(release, "1.0.12");
1665 assert_eq!(loaded, manifest);
1666 assert_eq!(
1667 source_path,
1668 Some(PublishManifestSource::Local(path.clone()))
1669 );
1670 }
1671
1672 #[test]
1673 fn publish_manifest_input_allows_release_override_for_local_manifest() {
1674 let dir = tempfile::tempdir().unwrap();
1675 let path = dir.path().join("gtc-1.0.13.json");
1676 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1677 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1678 let args = ReleasePublishArgs {
1679 release: Some("1.0.13".to_string()),
1680 from: None,
1681 tag: Some("stable".to_string()),
1682 manifest: Some(path.clone()),
1683 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1684 token: None,
1685 out: dir.path().to_path_buf(),
1686 dry_run: true,
1687 force: true,
1688 };
1689 let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1690 assert_eq!(release, "1.0.13");
1691 assert_eq!(loaded.version, "1.0.13");
1692 assert_eq!(
1693 source_path,
1694 Some(PublishManifestSource::Local(path.clone()))
1695 );
1696 }
1697
1698 #[test]
1699 fn manifest_file_name_omits_stable_channel() {
1700 let manifest = ToolchainManifest {
1701 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1702 toolchain: TOOLCHAIN_NAME.to_string(),
1703 version: "1.0.12".to_string(),
1704 channel: Some("stable".to_string()),
1705 created_at: None,
1706 packages: Vec::new(),
1707 extension_packs: None,
1708 components: None,
1709 };
1710 assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
1711 }
1712
1713 #[test]
1714 fn parses_manifest_without_extension_sections() {
1715 let manifest: ToolchainManifest = serde_json::from_str(
1716 r#"{
1717 "schema": "greentic.toolchain-manifest.v1",
1718 "toolchain": "gtc",
1719 "version": "1.0.16",
1720 "channel": "stable",
1721 "packages": []
1722 }"#,
1723 )
1724 .unwrap();
1725
1726 assert_eq!(manifest.extension_packs, None);
1727 assert_eq!(manifest.components, None);
1728 }
1729
1730 #[test]
1731 fn generated_manifest_includes_catalogue_extension_sections() {
1732 let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
1733 let json = serde_json::to_value(&manifest).unwrap();
1734
1735 assert!(json.get("extension_packs").is_some());
1736 assert!(json.get("components").is_some());
1737 assert!(
1738 manifest
1739 .extension_packs
1740 .as_ref()
1741 .unwrap()
1742 .iter()
1743 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1744 && item.version == "1.0.16")
1745 );
1746 assert!(
1747 manifest
1748 .extension_packs
1749 .as_ref()
1750 .unwrap()
1751 .iter()
1752 .any(|item| item.id == "greentic-bundle/providers" && item.version == "1.0.16")
1753 );
1754 assert!(
1755 manifest
1756 .components
1757 .as_ref()
1758 .unwrap()
1759 .iter()
1760 .any(|item| item.id == "component/component-llm-openai"
1761 && item.version == "1.0.16")
1762 );
1763 }
1764
1765 #[test]
1766 fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
1767 let source = ToolchainManifest {
1768 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1769 toolchain: TOOLCHAIN_NAME.to_string(),
1770 version: "dev".to_string(),
1771 channel: Some("dev".to_string()),
1772 created_at: None,
1773 packages: Vec::new(),
1774 extension_packs: Some(vec![ExtensionPackRef {
1775 id: "packs/messaging/messaging-webchat-gui".to_string(),
1776 version: "0.5.4".to_string(),
1777 }]),
1778 components: Some(vec![ComponentRef {
1779 id: "components/component-adaptive-card".to_string(),
1780 version: "0.5.8".to_string(),
1781 }]),
1782 };
1783
1784 let manifest =
1785 generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
1786
1787 assert!(
1788 manifest
1789 .extension_packs
1790 .as_ref()
1791 .unwrap()
1792 .iter()
1793 .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1794 && item.version == "0.5.4")
1795 );
1796 assert!(
1797 manifest
1798 .components
1799 .as_ref()
1800 .unwrap()
1801 .iter()
1802 .any(|item| item.id == "components/component-adaptive-card"
1803 && item.version == "0.5.8")
1804 );
1805 }
1806
1807 #[test]
1808 fn manifest_file_name_includes_non_stable_channel() {
1809 let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1810 assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
1811
1812 manifest.channel = Some("customer-a".to_string());
1813 assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
1814 }
1815
1816 #[test]
1817 fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
1818 assert_eq!(
1819 manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
1820 vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
1821 );
1822 assert_eq!(
1823 manifest_bins_for_source("dev", &["greentic-dev"]),
1824 vec!["greentic-dev-dev".to_string()]
1825 );
1826 assert_eq!(
1827 manifest_crate_name_for_source("latest", "greentic-runner"),
1828 "greentic-runner"
1829 );
1830 assert_eq!(
1831 manifest_crate_name_for_source("dev", "greentic-runner"),
1832 "greentic-runner-dev"
1833 );
1834 }
1835
1836 #[test]
1837 fn source_version_map_handles_missing_and_present_sources() {
1838 assert!(source_version_map(None).is_empty());
1839
1840 let source = ToolchainManifest {
1841 schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1842 toolchain: TOOLCHAIN_NAME.to_string(),
1843 version: "latest".to_string(),
1844 channel: Some("latest".to_string()),
1845 created_at: None,
1846 packages: vec![ToolchainPackage {
1847 crate_name: "greentic-dev".to_string(),
1848 bins: vec!["greentic-dev".to_string()],
1849 version: "0.6.0".to_string(),
1850 }],
1851 extension_packs: None,
1852 components: None,
1853 };
1854
1855 let versions = source_version_map(Some(&source));
1856 assert_eq!(
1857 versions.get("greentic-dev").map(String::as_str),
1858 Some("0.6.0")
1859 );
1860 }
1861
1862 #[test]
1863 fn write_manifest_persists_json_to_expected_file_name() {
1864 let dir = tempfile::tempdir().unwrap();
1865 let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1866
1867 let path = write_manifest(dir.path(), &manifest).unwrap();
1868 assert_eq!(
1869 path.file_name().and_then(|name| name.to_str()),
1870 Some("gtc-dev-1.0.12.json")
1871 );
1872
1873 let roundtrip = read_manifest_file(&path).unwrap();
1874 assert_eq!(roundtrip, manifest);
1875 }
1876
1877 #[test]
1878 fn latest_manifest_uses_latest_dev_bins() {
1879 let manifest = latest_manifest(None);
1880 assert_eq!(manifest.version, "latest");
1881 assert_eq!(manifest.channel.as_deref(), Some("latest"));
1882 assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1883 assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1884 assert!(!manifest.packages.is_empty());
1885 assert!(
1886 manifest
1887 .packages
1888 .iter()
1889 .all(|package| package.version == "latest")
1890 );
1891 assert!(
1892 manifest
1893 .packages
1894 .iter()
1895 .flat_map(|package| package.bins.iter())
1896 .all(|bin| bin.ends_with("-dev"))
1897 );
1898 assert!(
1899 manifest
1900 .packages
1901 .iter()
1902 .all(|package| package.crate_name.ends_with("-dev")),
1903 "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
1904 );
1905 assert!(
1906 manifest
1907 .packages
1908 .iter()
1909 .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
1910 );
1911 assert!(manifest.packages.iter().any(|package| {
1912 package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1913 }));
1914 assert!(
1915 manifest
1916 .extension_packs
1917 .as_ref()
1918 .unwrap()
1919 .iter()
1920 .all(|item| item.version == "latest")
1921 );
1922 assert!(
1923 manifest
1924 .components
1925 .as_ref()
1926 .unwrap()
1927 .iter()
1928 .all(|item| item.version == "latest")
1929 );
1930 }
1931
1932 #[test]
1933 fn publish_dry_run_with_local_manifest_succeeds() {
1934 let dir = tempfile::tempdir().unwrap();
1935 let path = dir.path().join("gtc-1.0.12.json");
1936 let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1937 fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1938
1939 publish(ReleasePublishArgs {
1940 release: None,
1941 from: None,
1942 tag: Some("stable".to_string()),
1943 manifest: Some(path),
1944 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1945 token: None,
1946 out: dir.path().to_path_buf(),
1947 dry_run: true,
1948 force: false,
1949 })
1950 .unwrap();
1951 }
1952
1953 #[test]
1954 fn latest_dry_run_succeeds() {
1955 latest(ReleaseLatestArgs {
1956 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1957 token: None,
1958 dry_run: true,
1959 force: false,
1960 })
1961 .unwrap();
1962 }
1963
1964 #[test]
1965 fn promote_dry_run_succeeds() {
1966 promote(ReleasePromoteArgs {
1967 release: "1.0.12".to_string(),
1968 tag: "stable".to_string(),
1969 repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1970 token: None,
1971 dry_run: true,
1972 })
1973 .unwrap();
1974 }
1975
1976 #[test]
1977 fn builds_toolchain_ref() {
1978 assert_eq!(
1979 toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
1980 "ghcr.io/greenticai/greentic-versions/gtc:stable"
1981 );
1982 }
1983}