Skip to main content

greentic_dev/
release_cmd.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::str::FromStr;
6
7use anyhow::{Context, Result, anyhow, bail};
8use async_trait::async_trait;
9use oci_distribution::Reference;
10use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
11use oci_distribution::secrets::RegistryAuth;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use time::format_description::well_known::Rfc3339;
16
17use crate::cli::{
18    ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs, 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 = CargoSearchVersionResolver;
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 = CargoSearchVersionResolver;
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    let manifest = generate_manifest(
246        release,
247        from,
248        source.as_ref(),
249        &resolver,
250        Some(created_at_now()?),
251    )?;
252    let path = if args.dry_run {
253        println!("{}", serde_json::to_string_pretty(&manifest)?);
254        None
255    } else {
256        Some(PublishManifestSource::Generated(write_manifest(
257            &args.out, &manifest,
258        )?))
259    };
260    Ok((release.to_string(), manifest, path))
261}
262
263fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
264    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
265    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
266}
267
268pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
269    if args.dry_run {
270        println!(
271            "Dry run: would promote {} to {}",
272            toolchain_ref(&args.repo, &args.release),
273            toolchain_ref(&args.repo, &args.tag)
274        );
275        return Ok(());
276    }
277
278    let auth = registry_auth(args.token.as_deref())?;
279    block_on_maybe_runtime(async {
280        let client = oci_client();
281        let source_ref = parse_reference(&args.repo, &args.release)?;
282        let target_ref = parse_reference(&args.repo, &args.tag)?;
283        let (manifest, _) = client
284            .pull_manifest(&source_ref, &auth)
285            .await
286            .with_context(|| {
287                format!(
288                    "failed to resolve source release `{}`",
289                    toolchain_ref(&args.repo, &args.release)
290                )
291            })?;
292        client
293            .push_manifest(&target_ref, &manifest)
294            .await
295            .with_context(|| {
296                format!(
297                    "failed to update tag `{}`",
298                    toolchain_ref(&args.repo, &args.tag)
299                )
300            })?;
301        Ok(())
302    })?;
303    println!(
304        "Promoted {} to {}",
305        toolchain_ref(&args.repo, &args.release),
306        toolchain_ref(&args.repo, &args.tag)
307    );
308    Ok(())
309}
310
311pub fn view(args: ReleaseViewArgs) -> Result<()> {
312    let tag = release_view_tag(&args)?;
313    let manifest = block_on_maybe_runtime(load_source_manifest(
314        &args.repo,
315        &tag,
316        args.token.as_deref(),
317    ))
318    .with_context(|| {
319        format!(
320            "failed to resolve manifest `{}`",
321            toolchain_ref(&args.repo, &tag)
322        )
323    })?
324    .with_context(|| {
325        format!(
326            "manifest `{}` was not found or is not authorized for this token",
327            toolchain_ref(&args.repo, &tag)
328        )
329    })?;
330    println!("{}", serde_json::to_string_pretty(&manifest)?);
331    Ok(())
332}
333
334pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
335    let manifest = latest_manifest(Some(created_at_now()?));
336    if args.dry_run {
337        println!("{}", serde_json::to_string_pretty(&manifest)?);
338        println!(
339            "Dry run: would publish {}",
340            toolchain_ref(&args.repo, "latest")
341        );
342        return Ok(());
343    }
344
345    let auth = registry_auth(args.token.as_deref())?;
346    block_on_maybe_runtime(async {
347        let client = oci_client();
348        let latest_ref = parse_reference(&args.repo, "latest")?;
349        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
350            bail!(
351                "latest tag `{}` already exists; pass --force to overwrite it",
352                toolchain_ref(&args.repo, "latest")
353            );
354        }
355        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
356    })?;
357    println!("Published {}", toolchain_ref(&args.repo, "latest"));
358    Ok(())
359}
360
361fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
362    ToolchainManifest {
363        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
364        toolchain: TOOLCHAIN_NAME.to_string(),
365        version: "latest".to_string(),
366        channel: Some("latest".to_string()),
367        created_at,
368        packages: latest_manifest_packages(),
369        extension_packs: Some(
370            GREENTIC_EXTENSION_PACK_PACKAGES
371                .iter()
372                .map(|package| ExtensionPackRef {
373                    id: package.package.to_string(),
374                    version: "latest".to_string(),
375                })
376                .collect(),
377        ),
378        components: Some(
379            GREENTIC_COMPONENT_PACKAGES
380                .iter()
381                .map(|package| ComponentRef {
382                    id: package.package.to_string(),
383                    version: "latest".to_string(),
384                })
385                .collect(),
386        ),
387    }
388}
389
390fn latest_manifest_packages() -> Vec<ToolchainPackage> {
391    std::iter::once(ToolchainPackage {
392        crate_name: TOOLCHAIN_NAME.to_string(),
393        bins: vec![delegated_binary_name_for_channel(
394            TOOLCHAIN_NAME,
395            ToolchainChannel::Development,
396        )],
397        version: "latest".to_string(),
398    })
399    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
400        ToolchainPackage {
401            crate_name: package.crate_name.to_string(),
402            bins: package
403                .bins
404                .iter()
405                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
406                .collect(),
407            version: "latest".to_string(),
408        }
409    }))
410    .collect()
411}
412
413fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
414    match (&args.release, &args.tag) {
415        (Some(release), None) => Ok(release.clone()),
416        (None, Some(tag)) => Ok(tag.clone()),
417        _ => bail!("pass exactly one of --release or --tag"),
418    }
419}
420
421pub fn generate_manifest<R: CrateVersionResolver>(
422    release: &str,
423    from: &str,
424    source: Option<&ToolchainManifest>,
425    resolver: &R,
426    created_at: Option<String>,
427) -> Result<ToolchainManifest> {
428    let artifact_resolver = ReleaseArtifactVersionResolver { release };
429    generate_manifest_with_artifact_resolver(
430        release,
431        from,
432        source,
433        resolver,
434        &artifact_resolver,
435        created_at,
436    )
437}
438
439pub fn generate_manifest_with_artifact_resolver<R, A>(
440    release: &str,
441    from: &str,
442    source: Option<&ToolchainManifest>,
443    resolver: &R,
444    artifact_resolver: &A,
445    created_at: Option<String>,
446) -> Result<ToolchainManifest>
447where
448    R: CrateVersionResolver,
449    A: ArtifactVersionResolver,
450{
451    if let Some(source) = source {
452        validate_manifest(source)?;
453    }
454    let source_versions = source_version_map(source);
455    let mut packages = Vec::new();
456    for package in GREENTIC_TOOLCHAIN_PACKAGES {
457        let source_version = source_versions.get(package.crate_name);
458        let version = match source_version.map(String::as_str) {
459            Some(version) if version != "latest" => version.to_string(),
460            _ => resolver.resolve_latest(package.crate_name)?,
461        };
462        packages.push(ToolchainPackage {
463            crate_name: package.crate_name.to_string(),
464            bins: manifest_bins_for_source(from, package.bins),
465            version,
466        });
467    }
468    Ok(ToolchainManifest {
469        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
470        toolchain: TOOLCHAIN_NAME.to_string(),
471        version: release.to_string(),
472        channel: Some(from.to_string()),
473        created_at,
474        packages,
475        extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
476        components: Some(component_refs_for_release(source, artifact_resolver)?),
477    })
478}
479
480fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
481    let channel = match from {
482        "dev" => ToolchainChannel::Development,
483        "rnd" => ToolchainChannel::Rnd,
484        _ => ToolchainChannel::Stable,
485    };
486    bins.iter()
487        .map(|bin| delegated_binary_name_for_channel(bin, channel))
488        .collect()
489}
490
491fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
492    source: Option<&ToolchainManifest>,
493    artifact_resolver: &A,
494) -> Result<Vec<ExtensionPackRef>> {
495    let source_versions = source_ref_version_map(source.and_then(|manifest| {
496        manifest
497            .extension_packs
498            .as_ref()
499            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
500    }));
501    GREENTIC_EXTENSION_PACK_PACKAGES
502        .iter()
503        .map(|package| {
504            Ok(ExtensionPackRef {
505                id: package.package.to_string(),
506                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
507            })
508        })
509        .collect()
510}
511
512fn component_refs_for_release<A: ArtifactVersionResolver>(
513    source: Option<&ToolchainManifest>,
514    artifact_resolver: &A,
515) -> Result<Vec<ComponentRef>> {
516    let source_versions = source_ref_version_map(source.and_then(|manifest| {
517        manifest
518            .components
519            .as_ref()
520            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
521    }));
522    GREENTIC_COMPONENT_PACKAGES
523        .iter()
524        .map(|package| {
525            Ok(ComponentRef {
526                id: package.package.to_string(),
527                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
528            })
529        })
530        .collect()
531}
532
533fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
534where
535    I: Iterator<Item = (&'a String, &'a String)>,
536{
537    let mut out = BTreeMap::new();
538    if let Some(refs) = refs {
539        for (id, version) in refs {
540            out.insert(id.clone(), version.clone());
541        }
542    }
543    out
544}
545
546fn ref_version_for_package(
547    package: &OciPackageSpec,
548    source_versions: &BTreeMap<String, String>,
549    artifact_resolver: &impl ArtifactVersionResolver,
550) -> Result<String> {
551    match source_versions.get(package.package).map(String::as_str) {
552        Some(version) if version != "latest" => Ok(version.to_string()),
553        _ => artifact_resolver
554            .resolve_latest(package.package)
555            .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
556    }
557}
558
559pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
560    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
561        bail!(
562            "unsupported toolchain manifest schema `{}`",
563            manifest.schema
564        );
565    }
566    if manifest.toolchain != TOOLCHAIN_NAME {
567        bail!("unsupported toolchain `{}`", manifest.toolchain);
568    }
569    Ok(())
570}
571
572pub fn toolchain_ref(repo: &str, tag: &str) -> String {
573    format!("{repo}:{tag}")
574}
575
576fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
577    let mut out = BTreeMap::new();
578    if let Some(source) = source {
579        for package in &source.packages {
580            out.insert(package.crate_name.clone(), package.version.clone());
581        }
582    }
583    out
584}
585
586fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
587    fs::create_dir_all(out_dir)
588        .with_context(|| format!("failed to create {}", out_dir.display()))?;
589    let path = out_dir.join(manifest_file_name(manifest));
590    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
591    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
592    Ok(path)
593}
594
595fn manifest_file_name(manifest: &ToolchainManifest) -> String {
596    match manifest.channel.as_deref() {
597        Some("stable") | None => format!("gtc-{}.json", manifest.version),
598        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
599    }
600}
601
602fn created_at_now() -> Result<String> {
603    OffsetDateTime::now_utc()
604        .format(&Rfc3339)
605        .context("failed to format current time")
606}
607
608pub trait CrateVersionResolver {
609    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
610}
611
612pub trait ArtifactVersionResolver {
613    fn resolve_latest(&self, package: &str) -> Result<String>;
614}
615
616struct CargoSearchVersionResolver;
617
618impl CrateVersionResolver for CargoSearchVersionResolver {
619    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
620        let output = Command::new("cargo")
621            .arg("search")
622            .arg(crate_name)
623            .arg("--limit")
624            .arg("1")
625            .output()
626            .with_context(|| format!("failed to execute `cargo search {crate_name} --limit 1`"))?;
627        if !output.status.success() {
628            bail!(
629                "`cargo search {crate_name} --limit 1` failed with exit code {:?}",
630                output.status.code()
631            );
632        }
633        let stdout = String::from_utf8(output.stdout).with_context(|| {
634            format!("`cargo search {crate_name} --limit 1` returned non-UTF8 output")
635        })?;
636        parse_cargo_search_version(crate_name, &stdout)
637    }
638}
639
640struct ReleaseArtifactVersionResolver<'a> {
641    release: &'a str,
642}
643
644impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
645    fn resolve_latest(&self, _package: &str) -> Result<String> {
646        Ok(self.release.to_string())
647    }
648}
649
650struct GhcrArtifactVersionResolver {
651    client: reqwest::blocking::Client,
652    registry: String,
653    namespace: String,
654    basic_token: Option<String>,
655}
656
657impl GhcrArtifactVersionResolver {
658    fn new(raw_token: Option<&str>) -> Result<Self> {
659        Ok(Self {
660            client: reqwest::blocking::Client::builder()
661                .build()
662                .context("failed to build GHCR HTTP client")?,
663            registry: "ghcr.io".to_string(),
664            namespace: "greenticai".to_string(),
665            basic_token: resolve_registry_token(raw_token)?
666                .or_else(|| std::env::var("GHCR_TOKEN").ok())
667                .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
668        })
669    }
670
671    fn bearer_token(&self, repository: &str) -> Result<String> {
672        let scope = format!("repository:{repository}:pull");
673        let mut request = self
674            .client
675            .get(format!("https://{}/token", self.registry))
676            .query(&[
677                ("service", self.registry.as_str()),
678                ("scope", scope.as_str()),
679            ]);
680        if let Some(token) = &self.basic_token {
681            request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
682        }
683        let response = request
684            .send()
685            .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
686            .error_for_status()
687            .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
688        let body: GhcrTokenResponse = response
689            .json()
690            .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
691        Ok(body.token)
692    }
693
694    fn tags(&self, repository: &str) -> Result<Vec<String>> {
695        let token = self.bearer_token(repository)?;
696        let response = self
697            .client
698            .get(format!(
699                "https://{}/v2/{repository}/tags/list",
700                self.registry
701            ))
702            .bearer_auth(token)
703            .send()
704            .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
705            .error_for_status()
706            .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
707        let body: GhcrTagsResponse = response
708            .json()
709            .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
710        Ok(body.tags)
711    }
712}
713
714impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
715    fn resolve_latest(&self, package: &str) -> Result<String> {
716        let repository = format!("{}/{}", self.namespace, package);
717        let tags = self.tags(&repository)?;
718        select_latest_semver_tag(&tags)
719            .with_context(|| format!("no semver tags found for GHCR package `{repository}`"))
720    }
721}
722
723#[derive(Deserialize)]
724struct GhcrTokenResponse {
725    token: String,
726}
727
728#[derive(Deserialize)]
729struct GhcrTagsResponse {
730    #[serde(default)]
731    tags: Vec<String>,
732}
733
734fn select_latest_semver_tag(tags: &[String]) -> Result<String> {
735    tags.iter()
736        .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
737        .max_by(|(left, _), (right, _)| left.cmp(right))
738        .map(|(_, tag)| tag.clone())
739        .context("no semver tags found")
740}
741
742fn parse_cargo_search_version(crate_name: &str, stdout: &str) -> Result<String> {
743    let first_line = stdout
744        .lines()
745        .find(|line| !line.trim().is_empty())
746        .ok_or_else(|| anyhow!("`cargo search {crate_name} --limit 1` returned no results"))?;
747    let Some((found_name, rhs)) = first_line.split_once('=') else {
748        bail!("unexpected cargo search output: {first_line}");
749    };
750    if found_name.trim() != crate_name {
751        bail!(
752            "`cargo search {crate_name} --limit 1` returned `{}` first",
753            found_name.trim()
754        );
755    }
756    let quoted = rhs
757        .split('#')
758        .next()
759        .map(str::trim)
760        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
761    let version = quoted.trim_matches('"');
762    Version::parse(version)
763        .with_context(|| format!("failed to parse crate version from `{first_line}`"))?;
764    Ok(version.to_string())
765}
766
767#[async_trait]
768trait ToolchainManifestSource {
769    async fn load_manifest(
770        &self,
771        repo: &str,
772        tag: &str,
773        token: Option<&str>,
774    ) -> Result<Option<ToolchainManifest>>;
775}
776
777struct OciToolchainManifestSource;
778
779#[async_trait]
780impl ToolchainManifestSource for OciToolchainManifestSource {
781    async fn load_manifest(
782        &self,
783        repo: &str,
784        tag: &str,
785        token: Option<&str>,
786    ) -> Result<Option<ToolchainManifest>> {
787        let auth = optional_registry_auth(token)?;
788        let client = oci_client();
789        let reference = parse_reference(repo, tag)?;
790        let image = match client
791            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
792            .await
793        {
794            Ok(image) => image,
795            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
796                return Ok(None);
797            }
798            Err(err) => {
799                return Err(err)
800                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
801            }
802        };
803        let Some(layer) = image
804            .layers
805            .into_iter()
806            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
807        else {
808            return Ok(None);
809        };
810        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
811            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
812        validate_manifest(&manifest)?;
813        Ok(Some(manifest))
814    }
815}
816
817async fn load_source_manifest(
818    repo: &str,
819    tag: &str,
820    token: Option<&str>,
821) -> Result<Option<ToolchainManifest>> {
822    OciToolchainManifestSource
823        .load_manifest(repo, tag, token)
824        .await
825}
826
827fn oci_client() -> Client {
828    Client::new(ClientConfig {
829        protocol: ClientProtocol::Https,
830        ..Default::default()
831    })
832}
833
834fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
835    let token = resolve_registry_token(raw_token)?
836        .or_else(|| std::env::var("GHCR_TOKEN").ok())
837        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
838        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
839    if token.trim().is_empty() {
840        bail!("GHCR token is empty");
841    }
842    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
843}
844
845fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
846    match registry_auth(raw_token) {
847        Ok(auth) => Ok(auth),
848        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
849        Err(err) => Err(err),
850    }
851}
852
853fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
854    let Some(raw_token) = raw_token else {
855        return Ok(None);
856    };
857    if let Some(var) = raw_token.strip_prefix("env:") {
858        let token =
859            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
860        if token.trim().is_empty() {
861            bail!("env var {var} resolved to an empty token");
862        }
863        return Ok(Some(token));
864    }
865    if raw_token.trim().is_empty() {
866        bail!("GHCR token is empty");
867    }
868    Ok(Some(raw_token.to_string()))
869}
870
871fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
872    Reference::from_str(&toolchain_ref(repo, tag))
873        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
874}
875
876async fn manifest_exists(
877    client: &Client,
878    reference: &Reference,
879    auth: &RegistryAuth,
880) -> Result<bool> {
881    match client.pull_manifest(reference, auth).await {
882        Ok(_) => Ok(true),
883        Err(err) if is_missing_manifest_error(&err) => Ok(false),
884        Err(err) => Err(err).context("failed to check whether release tag exists"),
885    }
886}
887
888fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
889    let msg = err.to_string().to_ascii_lowercase();
890    msg.contains("manifest unknown")
891        || msg.contains("name unknown")
892        || msg.contains("not found")
893        || msg.contains("404")
894}
895
896fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
897    let msg = err.to_string().to_ascii_lowercase();
898    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
899}
900
901async fn push_manifest_layer(
902    client: &Client,
903    reference: &Reference,
904    auth: &RegistryAuth,
905    manifest: &ToolchainManifest,
906) -> Result<()> {
907    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
908    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
909    let config = Config::new(
910        br#"{"toolchain":"gtc"}"#.to_vec(),
911        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
912        None,
913    );
914    client
915        .push(reference, &[layer], config, auth, None)
916        .await
917        .context("failed to push toolchain manifest")?;
918    Ok(())
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    struct FixedResolver;
926
927    impl CrateVersionResolver for FixedResolver {
928        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
929            Ok(match crate_name {
930                "greentic-runner" => "0.5.10",
931                _ => "1.2.3",
932            }
933            .to_string())
934        }
935    }
936
937    struct FixedArtifactResolver;
938
939    impl ArtifactVersionResolver for FixedArtifactResolver {
940        fn resolve_latest(&self, package: &str) -> Result<String> {
941            Ok(match package {
942                "packs/messaging/messaging-webchat-gui" => "0.4.93",
943                "components/component-adaptive-card" => "0.5.8",
944                _ => "0.1.0",
945            }
946            .to_string())
947        }
948    }
949
950    #[test]
951    fn parses_cargo_search_version() {
952        let version = parse_cargo_search_version(
953            "greentic-dev",
954            r#"greentic-dev = "0.5.1"    # Developer CLI"#,
955        )
956        .unwrap();
957        assert_eq!(version, "0.5.1");
958    }
959
960    #[test]
961    fn selects_latest_semver_tag() {
962        let tags = vec![
963            "latest".to_string(),
964            "0.4.93".to_string(),
965            "0.4.9".to_string(),
966            "1.0.0-beta.1".to_string(),
967            "1.0.0".to_string(),
968        ];
969
970        assert_eq!(select_latest_semver_tag(&tags).unwrap(), "1.0.0");
971    }
972
973    #[test]
974    fn generates_manifest_from_catalogue() {
975        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
976        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
977        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
978        assert_eq!(manifest.version, "1.0.5");
979        assert_eq!(manifest.channel.as_deref(), Some("latest"));
980        assert!(
981            manifest
982                .packages
983                .iter()
984                .any(|package| package.crate_name == "greentic-bundle"
985                    && package.bins == ["greentic-bundle"])
986        );
987        assert!(
988            manifest
989                .packages
990                .iter()
991                .any(|package| package.crate_name == "greentic-runner"
992                    && package.bins == ["greentic-runner"])
993        );
994        assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 93);
995        assert_eq!(manifest.components.as_ref().unwrap().len(), 9);
996        assert!(
997            manifest
998                .extension_packs
999                .as_ref()
1000                .unwrap()
1001                .iter()
1002                .all(|item| item.version == "1.0.5")
1003        );
1004        assert!(
1005            manifest
1006                .components
1007                .as_ref()
1008                .unwrap()
1009                .iter()
1010                .all(|item| item.version == "1.0.5")
1011        );
1012    }
1013
1014    #[test]
1015    fn generated_manifest_can_use_artifact_resolver_versions() {
1016        let manifest = generate_manifest_with_artifact_resolver(
1017            "1.0.17",
1018            "stable",
1019            None,
1020            &FixedResolver,
1021            &FixedArtifactResolver,
1022            None,
1023        )
1024        .unwrap();
1025
1026        assert!(
1027            manifest
1028                .extension_packs
1029                .as_ref()
1030                .unwrap()
1031                .iter()
1032                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1033                    && item.version == "0.4.93")
1034        );
1035        assert!(
1036            manifest
1037                .components
1038                .as_ref()
1039                .unwrap()
1040                .iter()
1041                .any(|item| item.id == "components/component-adaptive-card"
1042                    && item.version == "0.5.8")
1043        );
1044    }
1045
1046    #[test]
1047    fn source_manifest_can_pin_package_versions() {
1048        let source = ToolchainManifest {
1049            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1050            toolchain: TOOLCHAIN_NAME.to_string(),
1051            version: "latest".to_string(),
1052            channel: Some("latest".to_string()),
1053            created_at: None,
1054            packages: vec![ToolchainPackage {
1055                crate_name: "greentic-dev".to_string(),
1056                bins: vec!["greentic-dev".to_string()],
1057                version: "0.5.9".to_string(),
1058            }],
1059            extension_packs: None,
1060            components: None,
1061        };
1062        let manifest =
1063            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
1064        let greentic_dev = manifest
1065            .packages
1066            .iter()
1067            .find(|package| package.crate_name == "greentic-dev")
1068            .unwrap();
1069        assert_eq!(greentic_dev.version, "0.5.9");
1070    }
1071
1072    #[test]
1073    fn from_argument_controls_generated_channel_over_source_manifest() {
1074        let source = ToolchainManifest {
1075            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1076            toolchain: TOOLCHAIN_NAME.to_string(),
1077            version: "latest".to_string(),
1078            channel: Some("stable".to_string()),
1079            created_at: None,
1080            packages: Vec::new(),
1081            extension_packs: None,
1082            components: None,
1083        };
1084        let manifest =
1085            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
1086        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1087        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
1088    }
1089
1090    #[test]
1091    fn generate_from_dev_uses_dev_binary_names() {
1092        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1093        assert!(
1094            manifest
1095                .packages
1096                .iter()
1097                .flat_map(|package| package.bins.iter())
1098                .all(|bin| bin.ends_with("-dev"))
1099        );
1100        assert!(manifest.packages.iter().any(|package| {
1101            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-dev"]
1102        }));
1103        assert!(manifest.packages.iter().any(|package| {
1104            package.crate_name == "greentic-component" && package.bins == ["greentic-component-dev"]
1105        }));
1106    }
1107
1108    #[test]
1109    fn generate_from_rnd_uses_rnd_binary_names() {
1110        let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
1111        assert_eq!(manifest.channel.as_deref(), Some("rnd"));
1112        assert!(
1113            manifest
1114                .packages
1115                .iter()
1116                .flat_map(|package| package.bins.iter())
1117                .all(|bin| bin.ends_with("-rnd"))
1118        );
1119        assert!(manifest.packages.iter().any(|package| {
1120            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
1121        }));
1122    }
1123
1124    #[test]
1125    fn bootstrap_source_manifest_uses_source_tag_identity() {
1126        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1127        assert_eq!(manifest.version, "latest");
1128        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1129        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1130        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1131        assert!(
1132            manifest
1133                .packages
1134                .iter()
1135                .all(|package| package.version != "latest")
1136        );
1137    }
1138
1139    #[test]
1140    fn validates_schema_and_toolchain() {
1141        let mut manifest =
1142            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1143        assert!(validate_manifest(&manifest).is_ok());
1144        manifest.schema = "wrong".to_string();
1145        assert!(validate_manifest(&manifest).is_err());
1146    }
1147
1148    #[test]
1149    fn resolves_inline_registry_token() {
1150        assert_eq!(
1151            resolve_registry_token(Some("secret-token"))
1152                .unwrap()
1153                .as_deref(),
1154            Some("secret-token")
1155        );
1156    }
1157
1158    #[test]
1159    fn release_view_tag_prefers_release_or_tag() {
1160        let args = ReleaseViewArgs {
1161            release: Some("1.0.5".to_string()),
1162            tag: None,
1163            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1164            token: None,
1165        };
1166        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1167
1168        let args = ReleaseViewArgs {
1169            release: None,
1170            tag: Some("stable".to_string()),
1171            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1172            token: None,
1173        };
1174        assert_eq!(release_view_tag(&args).unwrap(), "stable");
1175    }
1176
1177    #[test]
1178    fn publish_manifest_input_uses_local_manifest_version() {
1179        let dir = tempfile::tempdir().unwrap();
1180        let path = dir.path().join("gtc-1.0.12.json");
1181        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1182        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1183        let args = ReleasePublishArgs {
1184            release: None,
1185            from: None,
1186            tag: Some("stable".to_string()),
1187            manifest: Some(path.clone()),
1188            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1189            token: None,
1190            out: dir.path().to_path_buf(),
1191            dry_run: true,
1192            force: true,
1193        };
1194        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1195        assert_eq!(release, "1.0.12");
1196        assert_eq!(loaded, manifest);
1197        assert_eq!(
1198            source_path,
1199            Some(PublishManifestSource::Local(path.clone()))
1200        );
1201    }
1202
1203    #[test]
1204    fn publish_manifest_input_allows_release_override_for_local_manifest() {
1205        let dir = tempfile::tempdir().unwrap();
1206        let path = dir.path().join("gtc-1.0.13.json");
1207        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1208        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1209        let args = ReleasePublishArgs {
1210            release: Some("1.0.13".to_string()),
1211            from: None,
1212            tag: Some("stable".to_string()),
1213            manifest: Some(path.clone()),
1214            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1215            token: None,
1216            out: dir.path().to_path_buf(),
1217            dry_run: true,
1218            force: true,
1219        };
1220        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1221        assert_eq!(release, "1.0.13");
1222        assert_eq!(loaded.version, "1.0.13");
1223        assert_eq!(
1224            source_path,
1225            Some(PublishManifestSource::Local(path.clone()))
1226        );
1227    }
1228
1229    #[test]
1230    fn manifest_file_name_omits_stable_channel() {
1231        let manifest = ToolchainManifest {
1232            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1233            toolchain: TOOLCHAIN_NAME.to_string(),
1234            version: "1.0.12".to_string(),
1235            channel: Some("stable".to_string()),
1236            created_at: None,
1237            packages: Vec::new(),
1238            extension_packs: None,
1239            components: None,
1240        };
1241        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
1242    }
1243
1244    #[test]
1245    fn parses_manifest_without_extension_sections() {
1246        let manifest: ToolchainManifest = serde_json::from_str(
1247            r#"{
1248              "schema": "greentic.toolchain-manifest.v1",
1249              "toolchain": "gtc",
1250              "version": "1.0.16",
1251              "channel": "stable",
1252              "packages": []
1253            }"#,
1254        )
1255        .unwrap();
1256
1257        assert_eq!(manifest.extension_packs, None);
1258        assert_eq!(manifest.components, None);
1259    }
1260
1261    #[test]
1262    fn generated_manifest_includes_catalogue_extension_sections() {
1263        let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
1264        let json = serde_json::to_value(&manifest).unwrap();
1265
1266        assert!(json.get("extension_packs").is_some());
1267        assert!(json.get("components").is_some());
1268        assert!(
1269            manifest
1270                .extension_packs
1271                .as_ref()
1272                .unwrap()
1273                .iter()
1274                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1275                    && item.version == "1.0.16")
1276        );
1277        assert!(
1278            manifest
1279                .components
1280                .as_ref()
1281                .unwrap()
1282                .iter()
1283                .any(|item| item.id == "component/component-llm-openai"
1284                    && item.version == "1.0.16")
1285        );
1286    }
1287
1288    #[test]
1289    fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
1290        let source = ToolchainManifest {
1291            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1292            toolchain: TOOLCHAIN_NAME.to_string(),
1293            version: "dev".to_string(),
1294            channel: Some("dev".to_string()),
1295            created_at: None,
1296            packages: Vec::new(),
1297            extension_packs: Some(vec![ExtensionPackRef {
1298                id: "packs/messaging/messaging-webchat-gui".to_string(),
1299                version: "0.5.4".to_string(),
1300            }]),
1301            components: Some(vec![ComponentRef {
1302                id: "components/component-adaptive-card".to_string(),
1303                version: "0.5.8".to_string(),
1304            }]),
1305        };
1306
1307        let manifest =
1308            generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
1309
1310        assert!(
1311            manifest
1312                .extension_packs
1313                .as_ref()
1314                .unwrap()
1315                .iter()
1316                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1317                    && item.version == "0.5.4")
1318        );
1319        assert!(
1320            manifest
1321                .components
1322                .as_ref()
1323                .unwrap()
1324                .iter()
1325                .any(|item| item.id == "components/component-adaptive-card"
1326                    && item.version == "0.5.8")
1327        );
1328    }
1329
1330    #[test]
1331    fn manifest_file_name_includes_non_stable_channel() {
1332        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1333        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
1334
1335        manifest.channel = Some("customer-a".to_string());
1336        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
1337    }
1338
1339    #[test]
1340    fn latest_manifest_uses_latest_dev_bins() {
1341        let manifest = latest_manifest(None);
1342        assert_eq!(manifest.version, "latest");
1343        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1344        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1345        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1346        assert!(!manifest.packages.is_empty());
1347        assert!(
1348            manifest
1349                .packages
1350                .iter()
1351                .all(|package| package.version == "latest")
1352        );
1353        assert!(
1354            manifest
1355                .packages
1356                .iter()
1357                .flat_map(|package| package.bins.iter())
1358                .all(|bin| bin.ends_with("-dev"))
1359        );
1360        assert!(
1361            manifest
1362                .packages
1363                .iter()
1364                .any(|package| { package.crate_name == "gtc" && package.bins == ["gtc-dev"] })
1365        );
1366        assert!(manifest.packages.iter().any(|package| {
1367            package.crate_name == "greentic-dev" && package.bins == ["greentic-dev-dev"]
1368        }));
1369        assert!(
1370            manifest
1371                .extension_packs
1372                .as_ref()
1373                .unwrap()
1374                .iter()
1375                .all(|item| item.version == "latest")
1376        );
1377        assert!(
1378            manifest
1379                .components
1380                .as_ref()
1381                .unwrap()
1382                .iter()
1383                .all(|item| item.version == "latest")
1384        );
1385    }
1386
1387    #[test]
1388    fn builds_toolchain_ref() {
1389        assert_eq!(
1390            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
1391            "ghcr.io/greenticai/greentic-versions/gtc:stable"
1392        );
1393    }
1394}