Skip to content

Commit aaf7eba

Browse files
committed
feat(boil): Add new image check command
This command allows users to check that all versions of an image are available in the specified registry.
1 parent 6df0045 commit aaf7eba

12 files changed

Lines changed: 1275 additions & 157 deletions

File tree

Cargo.lock

Lines changed: 971 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ license = "Apache-2.0"
99

1010
[workspace.dependencies]
1111
cap-std = "4.0.2"
12-
clap = { version = "4.5.41", features = ["derive"] }
12+
clap = { version = "4.5.41", features = ["derive", "env"] }
1313
clap_complete = "4.5.55"
1414
clap_complete_nushell = "4.5.8"
1515
git2 = "0.20.1"
1616
glob = "0.3.2"
1717
oci-spec = "0.9.0"
18+
reqwest = { version = "0.13.2", features = ["json"] }
1819
rstest = "0.26.1"
20+
secrecy = "0.10.3"
1921
semver = { version = "1.0.26", features = ["serde"] }
2022
serde = { version = "1.0.217", features = ["derive"] }
2123
serde_json = "1.0.140"

rust/boil/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ clap_complete_nushell.workspace = true
1515
git2.workspace = true
1616
glob.workspace = true
1717
oci-spec.workspace = true
18+
reqwest.workspace = true
19+
secrecy.workspace = true
1820
semver.workspace = true
1921
serde.workspace = true
2022
serde_json.workspace = true

rust/boil/src/cli/build.rs

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ use snafu::{ResultExt, Snafu, ensure};
1010
use strum::EnumDiscriminants;
1111
use url::Host;
1212

13-
use crate::core::{
14-
docker::BuildArgument,
15-
image::ImageSelector,
16-
platform::{Architecture, TargetPlatform},
13+
use crate::{
14+
cli::Cli,
15+
core::{
16+
docker::BuildArgument,
17+
image::ImageSelector,
18+
platform::{Architecture, TargetPlatform},
19+
},
1720
};
1821

1922
#[derive(Debug, Args)]
@@ -26,8 +29,8 @@ pub struct BuildArguments {
2629
/// The image version being built.
2730
#[arg(
2831
short, long,
29-
value_parser = BuildArguments::parse_image_version,
30-
default_value_t = Self::default_image_version(),
32+
value_parser = Cli::parse_image_version,
33+
default_value_t = Cli::default_image_version(),
3134
help_heading = "Image Options"
3235
)]
3336
pub image_version: Version,
@@ -45,7 +48,7 @@ pub struct BuildArguments {
4548
/// The format is host[:port].
4649
#[arg(
4750
short, long,
48-
default_value_t = Self::default_registry(),
51+
default_value_t = Cli::default_registry(),
4952
value_hint = ValueHint::Hostname,
5053
help_heading = "Registry Options"
5154
)]
@@ -130,24 +133,6 @@ pub struct BuildArguments {
130133
}
131134

132135
impl BuildArguments {
133-
fn parse_image_version(input: &str) -> Result<Version, ParseImageVersionError> {
134-
let version = Version::from_str(input).context(ParseVersionSnafu)?;
135-
ensure!(version.build.is_empty(), ContainsBuildMetadataSnafu);
136-
137-
Ok(version)
138-
}
139-
140-
fn default_image_version() -> Version {
141-
"0.0.0-dev".parse().expect("must be a valid SemVer")
142-
}
143-
144-
fn default_registry() -> HostPort {
145-
HostPort {
146-
host: Host::Domain(String::from("oci.stackable.tech")),
147-
port: None,
148-
}
149-
}
150-
151136
// TODO: Auto-detect this
152137
fn default_architecture() -> TargetPlatform {
153138
TargetPlatform::Linux(Architecture::Amd64)
@@ -158,15 +143,6 @@ impl BuildArguments {
158143
}
159144
}
160145

161-
#[derive(Debug, Snafu)]
162-
pub enum ParseImageVersionError {
163-
#[snafu(display("failed to parse semantic version"))]
164-
ParseVersion { source: semver::Error },
165-
166-
#[snafu(display("semantic version must not contain build metadata"))]
167-
ContainsBuildMetadata,
168-
}
169-
170146
#[derive(Debug, PartialEq, Snafu, EnumDiscriminants)]
171147
pub enum ParseHostPortError {
172148
#[snafu(display("unexpected empty input"))]

rust/boil/src/cli/image.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use clap::{Args, Subcommand, ValueEnum};
2+
use semver::Version;
23

3-
use crate::core::image::ImageSelector;
4+
use crate::{cli::Cli, core::image::ImageSelector};
45

56
#[derive(Debug, Args)]
67
pub struct ImageArguments {
@@ -11,6 +12,7 @@ pub struct ImageArguments {
1112
#[derive(Debug, Subcommand)]
1213
pub enum ImageCommand {
1314
List(ImageListArguments),
15+
Check(ImageCheckArguments),
1416
}
1517

1618
#[derive(Debug, Args)]
@@ -23,6 +25,22 @@ pub struct ImageListArguments {
2325
pub pretty: Pretty,
2426
}
2527

28+
#[derive(Debug, Args)]
29+
pub struct ImageCheckArguments {
30+
/// Optionally specify one or more images to check. Checks all images by default.
31+
pub image: Vec<ImageSelector>,
32+
33+
// NOTE (@Techassi): Should this maybe be renamed to vendor_version?
34+
/// The image version being built.
35+
#[arg(
36+
short, long,
37+
value_parser = Cli::parse_image_version,
38+
default_value_t = Cli::default_image_version(),
39+
help_heading = "Image Options"
40+
)]
41+
pub image_version: Version,
42+
}
43+
2644
// #[derive(Clone, Debug, Default, strum::Display, strum::EnumString)]
2745
#[derive(Clone, Debug, Default, ValueEnum)]
2846
pub enum Pretty {

rust/boil/src/cli/mod.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
use std::path::PathBuf;
1+
use std::{path::PathBuf, str::FromStr};
22

33
use clap::{Parser, Subcommand};
4+
use semver::Version;
5+
use snafu::{ResultExt, Snafu, ensure};
46

57
mod build;
68
mod completions;
@@ -9,6 +11,16 @@ mod image;
911
pub use build::*;
1012
pub use completions::*;
1113
pub use image::*;
14+
use url::Host;
15+
16+
#[derive(Debug, Snafu)]
17+
pub enum ParseImageVersionError {
18+
#[snafu(display("failed to parse semantic version"))]
19+
ParseVersion { source: semver::Error },
20+
21+
#[snafu(display("semantic version must not contain build metadata"))]
22+
ContainsBuildMetadata,
23+
}
1224

1325
#[derive(Debug, Parser)]
1426
#[command(author, version, about)]
@@ -25,6 +37,26 @@ impl Cli {
2537
fn default_config_path() -> PathBuf {
2638
PathBuf::from("./boil.toml")
2739
}
40+
41+
pub(super) fn default_image_version() -> Version {
42+
"0.0.0-dev"
43+
.parse()
44+
.expect("static string must be a valid SemVer")
45+
}
46+
47+
pub(super) fn default_registry() -> HostPort {
48+
HostPort {
49+
host: Host::Domain(String::from("oci.stackable.tech")),
50+
port: None,
51+
}
52+
}
53+
54+
pub(super) fn parse_image_version(input: &str) -> Result<Version, ParseImageVersionError> {
55+
let version = Version::from_str(input).context(ParseVersionSnafu)?;
56+
ensure!(version.build.is_empty(), ContainsBuildMetadataSnafu);
57+
58+
Ok(version)
59+
}
2860
}
2961

3062
#[derive(Debug, Subcommand)]

rust/boil/src/cmd/image.rs

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,154 @@
11
use std::{collections::BTreeMap, io::IsTerminal};
22

3-
use snafu::{ResultExt, Snafu};
3+
use secrecy::{ExposeSecret, SecretString};
4+
use snafu::{ResultExt, Snafu, ensure};
45

56
use crate::{
6-
cli::{ImageListArguments, Pretty},
7+
cli::{ImageCheckArguments, ImageListArguments, Pretty},
8+
config::Config,
79
core::bakefile::{self, Targets, TargetsOptions},
10+
models::TagList,
11+
utils::format_image_index_manifest_tag,
812
};
913

1014
#[derive(Debug, Snafu)]
1115
pub enum Error {
1216
#[snafu(display("failed to serialize list as JSON"))]
13-
SerializeList { source: serde_json::Error },
17+
SerializeList {
18+
source: serde_json::Error,
19+
},
1420

1521
#[snafu(display("failed to build list of targets"))]
16-
BuildTargets { source: bakefile::TargetsError },
22+
BuildTargets {
23+
source: bakefile::TargetsError,
24+
},
25+
26+
#[snafu(display("failed to build request client"))]
27+
BuildClient {
28+
source: reqwest::Error,
29+
},
30+
31+
#[snafu(display("failed to send request"))]
32+
SendRequest {
33+
source: reqwest::Error,
34+
},
35+
36+
#[snafu(display("failed to deserialize response"))]
37+
DeserializeResponse {
38+
source: reqwest::Error,
39+
},
40+
41+
MissingVersion {
42+
image_name: String,
43+
},
1744
}
1845

1946
/// This is the `boil show images` command handler function.
2047
pub fn list_images(arguments: ImageListArguments) -> Result<(), Error> {
21-
let list: BTreeMap<_, _> = if arguments.image.is_empty() {
22-
Targets::all(TargetsOptions { only_entry: true })
23-
.context(BuildTargetsSnafu)?
24-
.into_iter()
48+
let targets = if arguments.image.is_empty() {
49+
Targets::all(TargetsOptions {
50+
only_entry: true,
51+
flat: false,
52+
})
53+
.context(BuildTargetsSnafu)?
2554
} else {
26-
Targets::set(&arguments.image, TargetsOptions { only_entry: true })
27-
.context(BuildTargetsSnafu)?
28-
.into_iter()
29-
}
30-
.map(|(image_name, image_versions)| {
31-
let versions: Vec<_> = image_versions
32-
.into_iter()
33-
.map(|(image_version, (_, _))| image_version)
34-
.collect();
35-
(image_name, versions)
36-
})
37-
.collect();
55+
Targets::set(
56+
&arguments.image,
57+
TargetsOptions {
58+
only_entry: true,
59+
flat: false,
60+
},
61+
)
62+
.context(BuildTargetsSnafu)?
63+
};
64+
65+
let list = targets
66+
.into_iter()
67+
.map(|(image_name, (image_config, _))| {
68+
let versions: Vec<_> = image_config
69+
.versions
70+
.into_iter()
71+
.map(|(image_version, _)| image_version)
72+
.collect();
73+
(image_name, versions)
74+
})
75+
.collect();
3876

3977
print_to_stdout(list, arguments.pretty)
4078
}
4179

80+
pub async fn check_images(arguments: ImageCheckArguments, config: Config) -> Result<(), Error> {
81+
let targets = if arguments.image.is_empty() {
82+
Targets::all(TargetsOptions {
83+
only_entry: true,
84+
flat: true,
85+
})
86+
.context(BuildTargetsSnafu)?
87+
} else {
88+
Targets::set(
89+
&arguments.image,
90+
TargetsOptions {
91+
only_entry: true,
92+
flat: false,
93+
},
94+
)
95+
.context(BuildTargetsSnafu)?
96+
};
97+
98+
let registry_token = std::env::var("REGISTRY_TOKEN").ok().map(SecretString::from);
99+
let client = reqwest::ClientBuilder::new()
100+
.build()
101+
.context(BuildClientSnafu)?;
102+
103+
for (image_name, (image_config, _)) in targets {
104+
// TODO (@Techassi): Do these checks for all registries
105+
let Some((registry, registry_options)) = image_config.metadata.registries.first_key_value()
106+
else {
107+
continue;
108+
};
109+
110+
println!(
111+
"Checking for {registry}/{registry_namespace}/{image_name}",
112+
registry_namespace = registry_options.namespace,
113+
);
114+
115+
let url = format!(
116+
"https://{registry}/v2/{registry_namespace}/{image_name}/tags/list",
117+
registry_namespace = registry_options.namespace,
118+
);
119+
let request = client.get(url);
120+
121+
let request = match &registry_token {
122+
Some(registry_token) => request.bearer_auth(registry_token.expose_secret()),
123+
None => request,
124+
};
125+
126+
let tag_list: TagList = request
127+
.send()
128+
.await
129+
.context(SendRequestSnafu)?
130+
.json()
131+
.await
132+
.context(DeserializeResponseSnafu)?;
133+
134+
ensure!(
135+
image_config.versions.iter().all(|(image_version, _)| {
136+
let index_manifest_tag = format_image_index_manifest_tag(
137+
image_version,
138+
&config.metadata.vendor_tag_prefix,
139+
&arguments.image_version,
140+
);
141+
142+
println!("- {image_name}:{index_manifest_tag}");
143+
tag_list.tags.contains(&index_manifest_tag)
144+
}),
145+
MissingVersionSnafu { image_name }
146+
);
147+
}
148+
149+
Ok(())
150+
}
151+
42152
fn print_to_stdout(list: BTreeMap<String, Vec<String>>, pretty: Pretty) -> Result<(), Error> {
43153
let stdout = std::io::stdout();
44154

rust/boil/src/config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub enum ConfigError {
1717
#[serde(rename_all = "kebab-case")]
1818
pub struct Config {
1919
pub build_arguments: docker::BuildArguments,
20-
pub metadata: Metadata,
20+
pub metadata: MetadataOptions,
2121
}
2222

2323
impl Config {
@@ -31,7 +31,7 @@ impl Config {
3131
// are optional, the appropriate annotations are only emitted if set.
3232
#[derive(Debug, Deserialize)]
3333
#[serde(rename_all = "kebab-case")]
34-
pub struct Metadata {
34+
pub struct MetadataOptions {
3535
/// The URL to the documentation page.
3636
pub documentation: Option<Url>,
3737

0 commit comments

Comments
 (0)