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