Skip to content

Commit 18ee52a

Browse files
authored
feat(boil): Add image size command (#1536)
* feat(boil): Add image size command This command calculates the compressed, per target platform size of images per repository and in total. The command works with no image selection (all images), a specific list of images, and the option to specify a specific version. * feat(boil): Add human-readable output (default) * feat(boil): Use stable sorting for output
1 parent b265100 commit 18ee52a

7 files changed

Lines changed: 230 additions & 15 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ clap_complete = "4.5.55"
1414
clap_complete_nushell = "4.5.8"
1515
git2 = "0.20.1"
1616
glob = "0.3.2"
17+
humansize = "2.1.3"
1718
oci-spec = "0.9.0"
1819
reqwest = { version = "0.13.2", features = ["json"] }
1920
rstest = "0.26.1"

rust/boil/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ clap_complete.workspace = true
1414
clap_complete_nushell.workspace = true
1515
git2.workspace = true
1616
glob.workspace = true
17+
humansize.workspace = true
1718
oci-spec.workspace = true
1819
reqwest.workspace = true
1920
regex.workspace = true

rust/boil/src/cli/image.rs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
use clap::{Args, Subcommand, ValueEnum};
22

3-
use crate::{cli::Cli, core::image::ImageSelector};
3+
use crate::{
4+
cli::Cli,
5+
core::{
6+
image::ImageSelector,
7+
platform::{Architecture, TargetPlatform},
8+
},
9+
};
410

511
#[derive(Debug, Args)]
612
pub struct ImageArguments {
@@ -17,6 +23,9 @@ pub enum ImageCommand {
1723
///
1824
/// Access tokens must be provided with the following name: `BOIL_REGISTRY_TOKEN_<REGISTRY_URI>`.
1925
Check(ImageCheckArguments),
26+
27+
/// Calculates the size of images known by boil.
28+
Size(ImageSizeArguments),
2029
}
2130

2231
#[derive(Debug, Args)]
@@ -35,21 +44,66 @@ pub struct ImageCheckArguments {
3544
pub image: Vec<ImageSelector>,
3645

3746
// NOTE (@Techassi): Should this maybe be renamed to vendor_version?
38-
/// The image version being built.
47+
/// The image version to check.
48+
#[arg(
49+
short, long,
50+
value_parser = Cli::parse_image_version,
51+
default_value_t = Cli::default_image_version(),
52+
help_heading = "Image Options"
53+
)]
54+
pub image_version: String,
55+
}
56+
57+
#[derive(Debug, Args)]
58+
pub struct ImageSizeArguments {
59+
/// Optionally specify one or more images to check. Checks all images by default.
60+
pub image: Vec<ImageSelector>,
61+
62+
// NOTE (@Techassi): Should this maybe be renamed to vendor_version?
63+
/// The image version to use.
3964
#[arg(
4065
short, long,
4166
value_parser = Cli::parse_image_version,
4267
default_value_t = Cli::default_image_version(),
4368
help_heading = "Image Options"
4469
)]
4570
pub image_version: String,
71+
72+
/// Target platform of the image.
73+
#[arg(
74+
short, long,
75+
short_alias = 'a', alias = "architecture",
76+
default_value_t = Self::default_architecture(),
77+
help_heading = "Image Options"
78+
)]
79+
pub target_platform: TargetPlatform,
80+
81+
/// Pretty print the structured output.
82+
#[arg(long, value_enum, default_value_t = Pretty::default(), help_heading = "Output Options")]
83+
pub pretty: Pretty,
84+
85+
#[arg(short, long, value_enum, default_value_t = Format::default(), help_heading = "Output Options")]
86+
pub format: Format,
87+
}
88+
89+
impl ImageSizeArguments {
90+
// TODO: Auto-detect this
91+
fn default_architecture() -> TargetPlatform {
92+
TargetPlatform::Linux(Architecture::Amd64)
93+
}
4694
}
4795

48-
// #[derive(Clone, Debug, Default, strum::Display, strum::EnumString)]
4996
#[derive(Clone, Debug, Default, ValueEnum)]
5097
pub enum Pretty {
5198
#[default]
5299
Auto,
53100
Always,
54101
Never,
55102
}
103+
104+
#[derive(Clone, Debug, Default, ValueEnum)]
105+
pub enum Format {
106+
#[default]
107+
Plain,
108+
Json,
109+
}

rust/boil/src/cmd/image.rs

Lines changed: 136 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
use std::{collections::BTreeMap, io::IsTerminal};
22

33
use secrecy::{ExposeSecret, SecretString};
4+
use serde::Serialize;
45
use snafu::{ResultExt, Snafu};
56

67
use crate::{
7-
cli::{ImageCheckArguments, ImageListArguments, Pretty},
8+
cli::{Format, ImageCheckArguments, ImageListArguments, ImageSizeArguments, Pretty},
89
config::Config,
910
core::bakefile::{self, Targets, TargetsOptions},
10-
models::TagList,
11-
utils::{format_image_index_manifest_tag, format_registry_token_env_var_name},
11+
models::{Manifest, TagList},
12+
utils::{
13+
format_image_index_manifest_tag, format_image_manifest_tag,
14+
format_registry_token_env_var_name,
15+
},
1216
};
1317

1418
#[derive(Debug, Snafu)]
@@ -22,8 +26,8 @@ pub enum Error {
2226
#[snafu(display("failed to build request client"))]
2327
BuildClient { source: reqwest::Error },
2428

25-
#[snafu(display("failed to send request"))]
26-
SendRequest { source: reqwest::Error },
29+
#[snafu(display("failed to send request ({url})"))]
30+
SendRequest { source: reqwest::Error, url: String },
2731

2832
#[snafu(display("failed to deserialize response"))]
2933
DeserializeResponse { source: reqwest::Error },
@@ -51,7 +55,7 @@ pub fn list_images(arguments: ImageListArguments) -> Result<(), Error> {
5155
.context(BuildTargetsSnafu)?
5256
};
5357

54-
let list = targets
58+
let list: BTreeMap<String, Vec<String>> = targets
5559
.into_iter()
5660
.map(|(image_name, (image_config, _))| {
5761
let versions: Vec<_> = image_config
@@ -63,7 +67,7 @@ pub fn list_images(arguments: ImageListArguments) -> Result<(), Error> {
6367
})
6468
.collect();
6569

66-
print_to_stdout(list, arguments.pretty)
70+
serialize_to_stdout(list, arguments.pretty)
6771
}
6872

6973
pub async fn check_images(arguments: ImageCheckArguments, config: Config) -> Result<(), Error> {
@@ -109,7 +113,7 @@ pub async fn check_images(arguments: ImageCheckArguments, config: Config) -> Res
109113
"https://{registry}/v2/{registry_namespace}/{image_name}/tags/list",
110114
registry_namespace = registry_options.namespace,
111115
);
112-
let request = client.get(url);
116+
let request = client.get(&url);
113117

114118
let request = match &registry_token {
115119
Some(registry_token) => request.bearer_auth(registry_token.expose_secret()),
@@ -119,7 +123,7 @@ pub async fn check_images(arguments: ImageCheckArguments, config: Config) -> Res
119123
let tag_list: TagList = request
120124
.send()
121125
.await
122-
.context(SendRequestSnafu)?
126+
.context(SendRequestSnafu { url })?
123127
.json()
124128
.await
125129
.context(DeserializeResponseSnafu)?;
@@ -148,14 +152,134 @@ pub async fn check_images(arguments: ImageCheckArguments, config: Config) -> Res
148152
Ok(())
149153
}
150154

151-
fn print_to_stdout(list: BTreeMap<String, Vec<String>>, pretty: Pretty) -> Result<(), Error> {
155+
pub async fn calculate_size(arguments: ImageSizeArguments, config: Config) -> Result<(), Error> {
156+
let targets = if arguments.image.is_empty() {
157+
Targets::all(TargetsOptions {
158+
only_entry: true,
159+
non_recursive: true,
160+
})
161+
.context(BuildTargetsSnafu)?
162+
} else {
163+
Targets::set(
164+
&arguments.image,
165+
TargetsOptions {
166+
only_entry: true,
167+
non_recursive: true,
168+
},
169+
)
170+
.context(BuildTargetsSnafu)?
171+
};
172+
173+
let mut registry_tokens = BTreeMap::new();
174+
let client = reqwest::ClientBuilder::new()
175+
.build()
176+
.context(BuildClientSnafu)?;
177+
178+
#[derive(Serialize)]
179+
struct SizeResult {
180+
images: BTreeMap<String, u64>,
181+
total: u64,
182+
}
183+
184+
let mut result = SizeResult {
185+
images: BTreeMap::new(),
186+
total: 0,
187+
};
188+
189+
for (image_name, (image_config, _)) in targets {
190+
for (registry, registry_options) in image_config.metadata.registries {
191+
// Add tokens to a map so that we don't need construct the key and retrieve the value
192+
// over and over again.
193+
let registry_token = registry_tokens.entry(registry.clone()).or_insert_with(|| {
194+
let name = format_registry_token_env_var_name(&registry);
195+
std::env::var(name).ok().map(SecretString::from)
196+
});
197+
198+
for (image_version, _) in image_config.versions.iter() {
199+
let image_index_manifest_tag = format_image_index_manifest_tag(
200+
image_version,
201+
&config.metadata.vendor_tag_prefix,
202+
&arguments.image_version,
203+
);
204+
205+
let manifest_tag = format_image_manifest_tag(
206+
&image_index_manifest_tag,
207+
arguments.target_platform.architecture(),
208+
// Never strip the architecture, because we need to reference the exact manifest
209+
// to be able to calculate the sizes
210+
false,
211+
);
212+
213+
let url = format!(
214+
"https://{registry}/v2/{registry_namespace}/{image_name}/manifests/{manifest_tag}",
215+
registry_namespace = registry_options.namespace,
216+
);
217+
218+
let request = client.get(&url);
219+
220+
let request = match &registry_token {
221+
Some(registry_token) => request.bearer_auth(registry_token.expose_secret()),
222+
None => request,
223+
};
224+
225+
let manifest: Manifest = request
226+
.send()
227+
.await
228+
.context(SendRequestSnafu { url })?
229+
.json()
230+
.await
231+
.context(DeserializeResponseSnafu)?;
232+
233+
let layer_size = manifest.layers.iter().fold(0u64, |acc, e| acc + e.size);
234+
235+
let size = result.images.entry(image_name.clone()).or_default();
236+
*size += layer_size;
237+
238+
result.total += layer_size;
239+
}
240+
}
241+
}
242+
243+
match arguments.format {
244+
Format::Plain => {
245+
if let Some(max_width) = result
246+
.images
247+
.iter()
248+
.map(|i| i.0.len())
249+
.max_by(|lhs, rhs| lhs.cmp(rhs))
250+
{
251+
// let sorted = result.images;
252+
for (image, size) in result.images {
253+
println!(
254+
"{image:max_width$} {size}",
255+
size = humansize::format_size(size, humansize::BINARY)
256+
);
257+
}
258+
259+
println!(
260+
"{total_text:max_width$} {total}",
261+
total_text = "Total",
262+
total = humansize::format_size(result.total, humansize::BINARY)
263+
)
264+
}
265+
266+
Ok(())
267+
}
268+
Format::Json => serialize_to_stdout(&result, arguments.pretty),
269+
}
270+
}
271+
272+
fn serialize_to_stdout<T>(value: T, pretty: Pretty) -> Result<(), Error>
273+
where
274+
T: Serialize,
275+
{
152276
let stdout = std::io::stdout();
153277

154278
match pretty {
155279
Pretty::Always | Pretty::Auto if stdout.is_terminal() => {
156-
serde_json::to_writer_pretty(stdout, &list)
280+
serde_json::to_writer_pretty(stdout, &value)
157281
}
158-
_ => serde_json::to_writer(stdout, &list),
282+
_ => serde_json::to_writer(stdout, &value),
159283
}
160284
.context(SerializeListSnafu)
161285
}

rust/boil/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ async fn main() -> Result<(), Error> {
7575
.await
7676
.context(ImageSnafu)
7777
}
78+
ImageCommand::Size(arguments) => {
79+
let config = Config::from_file(&cli.config_path).context(ReadConfigSnafu)?;
80+
cmd::image::calculate_size(arguments, config)
81+
.await
82+
.context(ImageSnafu)
83+
}
7884
},
7985
Command::Images(arguments) => cmd::image::list_images(arguments).context(ImageSnafu),
8086
Command::Completions(arguments) => {

rust/boil/src/models/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,16 @@ pub struct TagList {
66
// pub _name: String,
77
pub tags: Vec<String>,
88
}
9+
10+
// TODO (@Techassi): We should eventually use the complete, upstream types from oci-spec
11+
/// A partial OCI manifest.
12+
#[derive(Debug, Deserialize)]
13+
pub struct Manifest {
14+
pub layers: Vec<ManifestLayer>,
15+
}
16+
17+
/// A partial OCI manifest layer.
18+
#[derive(Debug, Deserialize)]
19+
pub struct ManifestLayer {
20+
pub size: u64,
21+
}

0 commit comments

Comments
 (0)