Skip to content

Commit 2b7371a

Browse files
authored
Docker daemon, docker save (#26)
1 parent 7e12d13 commit 2b7371a

20 files changed

+1942
-589
lines changed

CLAUDE.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,19 @@ cargo nextest run --package circe_lib path::to::module
2121
## Code Style Guidelines
2222
- **Formatting**: Use rustfmt, consistent with surrounding code
2323
- **Naming**: snake_case for functions/variables, CamelCase for types
24+
- **Variable Shadowing**: Prefer shadowing variables rather than using Hungarian notation (e.g., use `let path = path.to_string_lossy()` instead of `let path_str = path.to_string_lossy()`)
2425
- **Imports**: Group std lib, external crates, internal modules (alphabetically)
2526
- **Error Handling**: Use color-eyre with context(), ensure!(), bail!()
2627
- **Types**: Prefer Builder pattern, derive common traits, use strong types
27-
- **Documentation**: Comments explain "why" not "what", use proper sentences
28+
- **Documentation**: Comments explain "why" not "what", use proper sentences. Avoid redundant comments that merely describe what code does - good code should be self-explanatory
2829
- **Organization**: Modular approach, named module files (not mod.rs)
2930
- **Testing**: Add integration tests in tests/it/, use test_case macro
3031
- **Functional Style**: Avoid mutation, prefer functional patterns when possible
3132
- **Cargo**: Never edit Cargo.toml directly, use cargo edit commands
3233
- **Conversions**: Use Type::from(value) not let x: Type = value.into()
34+
- **String Formatting**:
35+
- For simple variables, use direct interpolation: `"Value: {variable}"` instead of `"Value: {}", variable`
36+
- For expressions (method calls, etc.), use traditional formatting: `"Value: {}", expression.method()`
37+
- This project enforces `clippy::uninlined_format_args` for simple variables
3338

3439
Set `RUST_LOG=debug` or `RUST_LOG=trace` for detailed logs during development.

bin/src/extract.rs

+129-15
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
use circe_lib::{
2-
extract::{extract, Strategy},
2+
docker::{Daemon, Tarball},
3+
extract::{extract, Report, Strategy},
34
registry::Registry,
4-
Authentication, Filters, Platform, Reference,
5+
Authentication, Filters, Platform, Reference, Source,
56
};
67
use clap::{Args, Parser, ValueEnum};
78
use color_eyre::eyre::{bail, Context, Result};
89
use derive_more::Debug;
910
use std::{path::PathBuf, str::FromStr};
1011
use tracing::{debug, info};
1112

13+
use crate::{try_strategies, Outcome};
14+
1215
#[derive(Debug, Parser)]
1316
pub struct Options {
1417
/// Target to extract
@@ -80,6 +83,30 @@ pub struct Options {
8083
file_regex: Option<Vec<String>>,
8184
}
8285

86+
impl Options {
87+
/// Combined filters for layers.
88+
pub fn layer_filters(&self) -> Result<Filters> {
89+
let layer_globs = Filters::parse_glob(self.layer_glob.iter().flatten())?;
90+
let layer_regexes = Filters::parse_regex(self.layer_regex.iter().flatten())?;
91+
Ok(layer_globs + layer_regexes)
92+
}
93+
94+
/// Combined filters for files.
95+
pub fn file_filters(&self) -> Result<Filters> {
96+
let file_globs = Filters::parse_glob(self.file_glob.iter().flatten())?;
97+
let file_regexes = Filters::parse_regex(self.file_regex.iter().flatten())?;
98+
Ok(file_globs + file_regexes)
99+
}
100+
101+
/// Registry authentication.
102+
pub async fn auth(&self, reference: &Reference) -> Result<Authentication> {
103+
Ok(match (&self.target.username, &self.target.password) {
104+
(Some(username), Some(password)) => Authentication::basic(username, password),
105+
_ => Authentication::docker(reference).await?,
106+
})
107+
}
108+
}
109+
83110
/// Shared options for any command that needs to work with the OCI registry for a given image.
84111
#[derive(Debug, Args)]
85112
pub struct Target {
@@ -130,6 +157,22 @@ pub struct Target {
130157
pub password: Option<String>,
131158
}
132159

160+
impl Target {
161+
/// Check if the image appears to be a path.
162+
/// The validation is performed simply by attempting to canonicalize the path, then checking if a file exists.
163+
/// If either operation fails or the file does not exist, the image is not considered a path.
164+
pub async fn is_path(&self) -> bool {
165+
// We could make this nicer with `futures::future::AndThen`, but we don't currently import `futures`.
166+
match tokio::fs::canonicalize(&self.image).await {
167+
Ok(path) => match tokio::fs::try_exists(path).await {
168+
Ok(exists) => exists,
169+
Err(_) => false,
170+
},
171+
Err(_) => false,
172+
}
173+
}
174+
}
175+
133176
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
134177
pub enum Mode {
135178
/// Squash all layers into a single output directory, resulting in a file system equivalent to a running container.
@@ -152,28 +195,91 @@ pub enum Mode {
152195
#[tracing::instrument]
153196
pub async fn main(opts: Options) -> Result<()> {
154197
info!("extracting image");
198+
try_strategies!(&opts; strategy_tarball, strategy_daemon, strategy_registry)
199+
}
200+
201+
async fn strategy_registry(opts: &Options) -> Result<Outcome> {
202+
if opts.target.is_path().await {
203+
debug!("input appears to be a file path, skipping strategy");
204+
return Ok(Outcome::Skipped);
205+
}
155206

156207
let reference = Reference::from_str(&opts.target.image)?;
157-
let layer_globs = Filters::parse_glob(opts.layer_glob.into_iter().flatten())?;
158-
let file_globs = Filters::parse_glob(opts.file_glob.into_iter().flatten())?;
159-
let layer_regexes = Filters::parse_regex(opts.layer_regex.into_iter().flatten())?;
160-
let file_regexes = Filters::parse_regex(opts.file_regex.into_iter().flatten())?;
161-
let auth = match (opts.target.username, opts.target.password) {
162-
(Some(username), Some(password)) => Authentication::basic(username, password),
163-
_ => Authentication::docker(&reference).await?,
164-
};
208+
let layer_filters = opts.layer_filters()?;
209+
let file_filters = opts.file_filters()?;
210+
let auth = opts.auth(&reference).await?;
165211

166-
let output = canonicalize_output_dir(&opts.output_dir, opts.overwrite)?;
167212
let registry = Registry::builder()
168-
.maybe_platform(opts.target.platform)
213+
.maybe_platform(opts.target.platform.as_ref())
169214
.reference(reference)
170215
.auth(auth)
171-
.layer_filters(layer_globs + layer_regexes)
172-
.file_filters(file_globs + file_regexes)
216+
.layer_filters(layer_filters)
217+
.file_filters(file_filters)
173218
.build()
174219
.await
175220
.context("configure remote registry")?;
176221

222+
extract_layers(opts, registry)
223+
.await
224+
.context("extract layers")
225+
.map(|_| Outcome::Success)
226+
}
227+
228+
async fn strategy_daemon(opts: &Options) -> Result<Outcome> {
229+
if opts.target.is_path().await {
230+
debug!("input appears to be a file path, skipping strategy");
231+
return Ok(Outcome::Skipped);
232+
}
233+
234+
let layer_filters = opts.layer_filters()?;
235+
let file_filters = opts.file_filters()?;
236+
let daemon = Daemon::builder()
237+
.reference(&opts.target.image)
238+
.layer_filters(layer_filters)
239+
.file_filters(file_filters)
240+
.build()
241+
.await
242+
.context("build daemon reference")?;
243+
244+
tracing::info!("pulled image from daemon");
245+
extract_layers(opts, daemon)
246+
.await
247+
.context("extract layers")
248+
.map(|_| Outcome::Success)
249+
}
250+
251+
async fn strategy_tarball(opts: &Options) -> Result<Outcome> {
252+
let path = PathBuf::from(&opts.target.image);
253+
if matches!(tokio::fs::try_exists(&path).await, Err(_) | Ok(false)) {
254+
bail!("path does not exist: {path:?}");
255+
}
256+
257+
let layer_filters = opts.layer_filters()?;
258+
let file_filters = opts.file_filters()?;
259+
let name = path
260+
.file_name()
261+
.map(|name| name.to_string_lossy())
262+
.unwrap_or_else(|| opts.target.image.clone().into())
263+
.to_string();
264+
265+
let tarball = Tarball::builder()
266+
.path(path)
267+
.name(name)
268+
.file_filters(file_filters)
269+
.layer_filters(layer_filters)
270+
.build()
271+
.await
272+
.context("build tarball reference")?;
273+
274+
tracing::info!("extracting layers from tarball");
275+
extract_layers(opts, tarball)
276+
.await
277+
.context("extract layers")
278+
.map(|_| Outcome::Success)
279+
}
280+
281+
#[tracing::instrument]
282+
async fn extract_layers(opts: &Options, registry: impl Source) -> Result<()> {
177283
let layers = registry.layers().await.context("list layers")?;
178284
if layers.is_empty() {
179285
bail!("no layers to extract found in image");
@@ -194,16 +300,24 @@ pub async fn main(opts: Options) -> Result<()> {
194300
},
195301
};
196302

197-
let report = extract(&registry, &output, strategies)
303+
let output = canonicalize_output_dir(&opts.output_dir, opts.overwrite)?;
304+
let digest = registry.digest().await.context("fetch digest")?;
305+
let layers = extract(&registry, &output, strategies)
198306
.await
199307
.context("extract image")?;
200308

309+
let report = Report::builder()
310+
.digest(digest.to_string())
311+
.layers(layers)
312+
.build();
313+
201314
report
202315
.write(&output)
203316
.await
204317
.context("write report to disk")?;
205318

206319
println!("{}", report.render()?);
320+
207321
Ok(())
208322
}
209323

bin/src/list.rs

+71-6
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
use circe_lib::{registry::Registry, Authentication, Reference};
1+
use circe_lib::{
2+
docker::{Daemon, Tarball},
3+
registry::Registry,
4+
Authentication, Reference, Source,
5+
};
26
use clap::Parser;
3-
use color_eyre::eyre::{Context, Result};
7+
use color_eyre::eyre::{bail, Context, Result};
48
use derive_more::Debug;
59
use pluralizer::pluralize;
6-
use std::{collections::HashMap, str::FromStr};
10+
use std::{collections::HashMap, path::PathBuf, str::FromStr};
711
use tracing::{debug, info};
812

9-
use crate::extract::Target;
13+
use crate::{extract::Target, try_strategies, Outcome};
1014

1115
#[derive(Debug, Parser)]
1216
pub struct Options {
@@ -18,23 +22,84 @@ pub struct Options {
1822
#[tracing::instrument]
1923
pub async fn main(opts: Options) -> Result<()> {
2024
info!("extracting image");
25+
try_strategies!(&opts; strategy_tarball, strategy_daemon, strategy_registry)
26+
}
27+
28+
async fn strategy_registry(opts: &Options) -> Result<Outcome> {
29+
if opts.target.is_path().await {
30+
debug!("input appears to be a file path, skipping strategy");
31+
return Ok(Outcome::Skipped);
32+
}
2133

2234
let reference = Reference::from_str(&opts.target.image)?;
23-
let auth = match (opts.target.username, opts.target.password) {
35+
let auth = match (&opts.target.username, &opts.target.password) {
2436
(Some(username), Some(password)) => Authentication::basic(username, password),
2537
_ => Authentication::docker(&reference).await?,
2638
};
2739

2840
let registry = Registry::builder()
29-
.maybe_platform(opts.target.platform)
41+
.maybe_platform(opts.target.platform.as_ref())
3042
.reference(reference)
3143
.auth(auth)
3244
.build()
3345
.await
3446
.context("configure remote registry")?;
3547

48+
list_files(registry)
49+
.await
50+
.context("list files")
51+
.map(|_| Outcome::Success)
52+
}
53+
54+
async fn strategy_daemon(opts: &Options) -> Result<Outcome> {
55+
if opts.target.is_path().await {
56+
debug!("input appears to be a file path, skipping strategy");
57+
return Ok(Outcome::Skipped);
58+
}
59+
60+
let daemon = Daemon::builder()
61+
.reference(&opts.target.image)
62+
.build()
63+
.await
64+
.context("build daemon reference")?;
65+
66+
tracing::info!("pulled image from daemon");
67+
list_files(daemon)
68+
.await
69+
.context("list files")
70+
.map(|_| Outcome::Success)
71+
}
72+
73+
async fn strategy_tarball(opts: &Options) -> Result<Outcome> {
74+
let path = PathBuf::from(&opts.target.image);
75+
if matches!(tokio::fs::try_exists(&path).await, Err(_) | Ok(false)) {
76+
bail!("path does not exist: {path:?}");
77+
}
78+
79+
let name = path
80+
.file_name()
81+
.map(|name| name.to_string_lossy())
82+
.unwrap_or_else(|| opts.target.image.clone().into())
83+
.to_string();
84+
let tarball = Tarball::builder()
85+
.path(path)
86+
.name(name)
87+
.build()
88+
.await
89+
.context("build tarball reference")?;
90+
91+
tracing::info!("listing files in tarball");
92+
list_files(tarball)
93+
.await
94+
.context("list files")
95+
.map(|_| Outcome::Success)
96+
}
97+
98+
#[tracing::instrument]
99+
async fn list_files(registry: impl Source) -> Result<()> {
36100
let layers = registry.layers().await.context("list layers")?;
37101
let count = layers.len();
102+
debug!(?count, ?layers, "listed layers");
38103
info!("enumerated {}", pluralize("layer", count as isize, true));
39104

40105
let mut listing = HashMap::new();

bin/src/main.rs

+32
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
#![deny(clippy::uninlined_format_args)]
2+
#![deny(clippy::unwrap_used)]
3+
#![deny(unsafe_code)]
4+
#![warn(rust_2018_idioms)]
5+
16
use clap::{
27
builder::{styling::AnsiColor, Styles},
38
Parser,
@@ -95,3 +100,30 @@ fn style() -> Styles {
95100
.invalid(AnsiColor::Red.on_default())
96101
.valid(AnsiColor::Blue.on_default())
97102
}
103+
104+
/// Try a list of asynchronous strategies in sequence.
105+
///
106+
/// The first strategy to succeed with [`Outcome::Success`] stops executing the rest.
107+
/// If all strategies fail, an error is returned
108+
///
109+
/// Note: this macro returns from the calling context, not from the current expression.
110+
#[macro_export]
111+
#[doc(hidden)]
112+
macro_rules! try_strategies {
113+
($opts:expr; $($strategy:expr),*) => {{
114+
$(match $strategy(&$opts).await {
115+
Ok($crate::Outcome::Success) => return Ok(()),
116+
Ok($crate::Outcome::Skipped) => {},
117+
Err(err) => tracing::warn!(?err, "strategy failed"),
118+
})*
119+
120+
color_eyre::eyre::bail!("all strategies failed")
121+
}}
122+
}
123+
124+
/// The result of executing a strategy.
125+
/// When executing multiple strategies, the first successful one stops the sequence.
126+
pub enum Outcome {
127+
Success,
128+
Skipped,
129+
}

0 commit comments

Comments
 (0)