-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathbakefile.rs
More file actions
658 lines (554 loc) · 24 KB
/
Copy pathbakefile.rs
File metadata and controls
658 lines (554 loc) · 24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
use std::{
collections::BTreeMap,
fmt::Debug,
ops::{Deref, DerefMut},
path::PathBuf,
};
use cap_std::{ambient_authority, fs::Dir};
use glob::glob;
use oci_spec::image::{
ANNOTATION_AUTHORS, ANNOTATION_CREATED, ANNOTATION_DOCUMENTATION, ANNOTATION_LICENSES,
ANNOTATION_REVISION, ANNOTATION_SOURCE, ANNOTATION_VENDOR, ANNOTATION_VERSION,
};
use serde::Serialize;
use snafu::{OptionExt, ResultExt, Snafu, ensure};
use time::format_description::well_known::Rfc3339;
use crate::{
cli::{self, HostPort},
config::{self, Config, Metadata},
constants::DOCKER_LABEL_BUILD_DATE,
core::{
docker,
image::{ImageConfig, ImageConfigError, ImageOptions, ImageSelector, VersionOptionsPair},
platform::TargetPlatform,
},
utils,
};
pub const COMMON_TARGET_NAME: &str = "common--target";
pub const ENTRY_TARGET_NAME_PREFIX: &str = "entry--";
#[derive(Debug, Snafu)]
pub enum GitError {
#[snafu(display("failed to open git repository"))]
OpenRepository { source: git2::Error },
#[snafu(display("failed to parse HEAD revision"))]
ParseHeadRevision { source: git2::Error },
#[snafu(display("failed to find starting point of rev range"))]
InvalidRange,
}
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("failed to format current datetime"))]
FormatTime { source: time::error::Format },
#[snafu(display("failed to get revision"))]
GetRevision { source: GitError },
#[snafu(display("failed to create target graph"))]
CreateGraph { source: TargetsError },
#[snafu(display("failed to parse build arguments"))]
ParseBuildArguments {
source: docker::ParseBuildArgumentsError,
},
#[snafu(display("failed to locate containerfile relative to the {path:?} directory"))]
NoSuchContainerfileExists { path: String },
#[snafu(display("failed to open scoped directory as {path}"))]
OpenScopedDirectory {
source: std::io::Error,
path: String,
},
}
#[derive(Debug, Snafu)]
pub enum TargetsError {
#[snafu(display("encountered invalid image version"))]
InvalidImageVersion { source: ImageConfigError },
#[snafu(display("failed to read image config"))]
ReadImageConfig { source: ImageConfigError },
#[snafu(display("failed to resolve parent directory of image config at {path}", path = path.display()))]
ResolveParentDirectory { path: PathBuf },
}
#[derive(Debug, Default)]
pub struct TargetsOptions {
pub only_entry: bool,
}
/// Contains targets selected by the user.
///
/// This is a map which uses the image/target name as the key. Each key points to another map,
/// which contains one entry per version of the target. Each value contains the image options and
/// a boolean flag to indicate if this target is an entry target.
#[derive(Debug, Default)]
pub struct Targets(BTreeMap<String, BTreeMap<String, (ImageOptions, bool)>>);
impl Deref for Targets {
type Target = BTreeMap<String, BTreeMap<String, (ImageOptions, bool)>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Targets {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl IntoIterator for Targets {
type IntoIter =
std::collections::btree_map::IntoIter<String, BTreeMap<String, (ImageOptions, bool)>>;
type Item = (String, BTreeMap<String, (ImageOptions, bool)>);
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl Targets {
/// Returns a map of all targets by globbing for (nested) image config files.
///
/// The search behaviour can be customized using the provided [`TargetsOptions`].
//
// SAFETY: We purposefully allow the `clippy::unwrap_in_result` lint below in this function.
// We can use expect here, because the glob pattern is defined as a constant and the glob
// function only returns an error if the pattern is invalid. We must ensure the pattern is
// valid at compile time, because there is no need to allow an invalid pattern which would
// render this tool inoperable.
//
// FIXME (@Techassi): This attribute can be used on individual unwrap and expect calls since
// Rust 1.91.0. We should move this attribute to not contaminate an unnecessarily large scope
// once we bump the toolchain to 1.91.0.
// See https://github.com/rust-lang/rust-clippy/pull/15445
#[allow(clippy::unwrap_in_result)]
pub fn all(options: TargetsOptions) -> Result<Self, TargetsError> {
let image_config_paths = glob(ImageConfig::ALL_CONFIGS_GLOB_PATTERN)
.expect("constant glob pattern must be valid")
.filter_map(Result::ok);
let mut targets = Self::default();
for image_config_path in image_config_paths {
let image_config =
ImageConfig::from_file(&image_config_path).context(ReadImageConfigSnafu)?;
let image_name = image_config_path
.parent()
.with_context(|| ResolveParentDirectorySnafu {
path: image_config_path.clone(),
})?
.to_string_lossy()
.into_owned();
let pairs = image_config.all();
targets.insert_targets(image_name.to_owned(), pairs, &options, true)?;
}
Ok(targets)
}
/// Returns a filtered set out of all targets by looking up selected image config files.
///
/// The search behaviour can be customized using the provided [`TargetsOptions`].
pub fn set(images: &[ImageSelector], options: TargetsOptions) -> Result<Self, TargetsError> {
let mut targets = Self::default();
for image in images {
// TODO (@Techassi): We should instead build the graph based on the Dockerfile(s),
// because this is the source of truth and what ultimately gets built. The boil config
// files are not a source a truth, but just provide data needed during the build.
let image_config_path = PathBuf::new()
.join(&image.name)
.join(ImageConfig::DEFAULT_FILE_NAME);
// Read the image config which defines supported image versions and their dependencies as
// well as other values.
let image_config =
ImageConfig::from_file(image_config_path).context(ReadImageConfigSnafu)?;
// Create a list of image versions we need to generate targets for in the bakefile.
let pairs = image_config
.filter_by_version(&image.versions)
.context(InvalidImageVersionSnafu)?;
targets.insert_targets(image.name.clone(), pairs, &options, true)?;
}
Ok(targets)
}
fn insert_targets(
&mut self,
image_name: String,
pairs: Vec<VersionOptionsPair>,
options: &TargetsOptions,
is_entry: bool,
) -> Result<(), TargetsError> {
for VersionOptionsPair {
version: image_version,
options: image_options,
} in pairs
{
if !options.only_entry {
// TODO (@Techassi): Add cycle detection
for (image_name, image_version) in &image_options.local_images {
if self
.get(image_name)
.is_some_and(|image_versions| image_versions.contains_key(image_version))
{
continue;
}
let image_config_path = PathBuf::new()
.join(image_name)
.join(ImageConfig::DEFAULT_FILE_NAME);
let image_config =
ImageConfig::from_file(image_config_path).context(ReadImageConfigSnafu)?;
let pairs = image_config
.filter_by_version(&[image_version])
.context(InvalidImageVersionSnafu)?;
// Wowzers, recursion!
self.insert_targets(image_name.clone(), pairs, options, false)?;
}
}
self.entry(image_name.clone())
.or_default()
.insert(image_version, (image_options, is_entry));
}
Ok(())
}
}
#[derive(Debug, Default, Serialize)]
pub struct Bakefile {
#[serde(rename = "group")]
pub groups: BTreeMap<String, BakefileGroup>,
#[serde(rename = "target")]
pub targets: BTreeMap<String, BakefileTarget>,
}
impl Bakefile {
/// Create a bakefile from the [`BuildArguments`](cli::BuildArguments) provided via the CLI.
///
/// This will only create targets for selected entry images and their dependencies. There is no
/// need to filter anything out afterwards. The filtering is done automatically internally.
pub fn from_cli_args(cli_args: &cli::BuildArguments, config: Config) -> Result<Self, Error> {
let targets =
Targets::set(&cli_args.images, TargetsOptions::default()).context(CreateGraphSnafu)?;
Self::from_targets(targets, cli_args, config)
}
/// Returns all image manifest URIs for entry images.
pub fn image_manifest_uris(&self) -> Vec<&str> {
self.targets
.iter()
// We only care about the entry targets, because those are the primary images boil
// builds.
.filter(|(target_name, _)| target_name.starts_with(ENTRY_TARGET_NAME_PREFIX))
// The image manifest URIs file only contains the image tags
.flat_map(|(_, target)| &target.tags)
// Flatten multiple tags (boil currently only ever writes a single one, but the data
// structure can accept a list).
.map(|s| s.as_str())
.collect()
}
/// Creates the common target, containing shared data, which will be inherited by other targets.
fn common_target(
cli_args: &cli::BuildArguments,
container_build_args: docker::BuildArguments,
metadata: &Metadata,
) -> Result<BakefileTarget, Error> {
let revision = Self::git_head_revision().context(GetRevisionSnafu)?;
let date_time = Self::now()?;
// Load build arguments from a file if the user requested it
let mut user_container_build_args = cli_args.build_arguments.clone();
if let Some(path) = &cli_args.build_arguments_file {
let build_arguments_from_file =
docker::BuildArguments::from_file(path).context(ParseBuildArgumentsSnafu)?;
user_container_build_args.extend(build_arguments_from_file);
}
let target = BakefileTarget::common(
date_time,
revision,
cli_args.image_version.clone(),
container_build_args,
user_container_build_args,
metadata,
);
Ok(target)
}
fn from_targets(
targets: Targets,
cli_args: &cli::BuildArguments,
config: Config,
) -> Result<Self, Error> {
let mut bakefile_targets = BTreeMap::new();
let mut groups: BTreeMap<String, BakefileGroup> = BTreeMap::new();
// Destructure config so that we can move and borrow fields separately.
let Config {
build_arguments,
metadata,
} = config;
// Create a common target, which contains shared data, like annotations, arguments, labels, etc...
let common_target = Self::common_target(cli_args, build_arguments, &metadata)?;
bakefile_targets.insert(COMMON_TARGET_NAME.to_owned(), common_target);
// The image registry, eg. `oci.stackable.tech` or `localhost`
let image_registry = if cli_args.use_localhost_registry {
&HostPort::localhost()
} else {
&cli_args.registry
};
for (image_name, image_versions) in targets.into_iter() {
for (image_version, (image_options, is_entry)) in image_versions {
let image_repository_uri = utils::format_image_repository_uri(
image_registry,
&cli_args.registry_namespace,
&image_name,
);
let image_index_manifest_tag = utils::format_image_index_manifest_tag(
&image_version,
&metadata.vendor_tag_prefix,
&cli_args.image_version,
);
let image_manifest_tag = utils::format_image_manifest_tag(
&image_index_manifest_tag,
cli_args.target_platform.architecture(),
cli_args.strip_architecture,
);
let image_manifest_uri =
utils::format_image_manifest_uri(&image_repository_uri, &image_manifest_tag);
// TODO (@Techassi): Clean this up
// TODO (@Techassi): Move the arg formatting into functions
let mut build_arguments = docker::BuildArguments::new();
let local_version_docker_args: Vec<_> = image_options
.local_images
.iter()
.map(|(image_name, image_version)| {
docker::BuildArgument::local_image_version(
image_name.to_string(),
image_version.to_string(),
)
})
.collect();
build_arguments.extend(image_options.build_arguments);
build_arguments.extend(local_version_docker_args);
// TODO (@Techassi): Rename this to IMAGE_VERSION
build_arguments.insert(docker::BuildArgument::new(
"PRODUCT_VERSION".to_owned(),
image_version.to_string(),
));
build_arguments.insert(docker::BuildArgument::new(
"IMAGE_REPOSITORY_URI".to_owned(),
image_repository_uri,
));
build_arguments.insert(docker::BuildArgument::new(
"IMAGE_INDEX_MANIFEST_TAG".to_owned(),
image_index_manifest_tag,
));
build_arguments.insert(docker::BuildArgument::new(
"IMAGE_MANIFEST_TAG".to_owned(),
image_manifest_tag,
));
build_arguments.insert(docker::BuildArgument::new(
"IMAGE_MANIFEST_URI".to_owned(),
image_manifest_uri.clone(),
));
// By using a cap-std Dir, we can ensure that the paths provided must be relative to
// the appropriate image folder and wont escape it by providing absolute or relative
// paths with traversals (..).
let image_dir = Dir::open_ambient_dir(&image_name, ambient_authority())
.with_context(|_| OpenScopedDirectorySnafu {
path: image_name.clone(),
})?;
let dockerfile_path = if let Some(custom_path) = &image_options.dockerfile {
ensure!(
image_dir.exists(custom_path),
NoSuchContainerfileExistsSnafu { path: image_name }
);
PathBuf::new().join(&image_name).join(custom_path)
} else {
ensure!(
image_dir.exists(&cli_args.target_containerfile),
NoSuchContainerfileExistsSnafu { path: image_name }
);
PathBuf::new()
.join(&image_name)
.join(&cli_args.target_containerfile)
};
let target_name = if is_entry {
Self::format_entry_target_name(&image_name, &image_version)
} else {
Self::format_target_name(&image_name, &image_version)
};
let contexts: BTreeMap<_, _> = image_options
.local_images
.iter()
.map(|(image_name, image_version)| {
let context_name = Self::format_context_name(image_name);
let context_target = Self::format_context_target(image_name, image_version);
(context_name, context_target)
})
.collect();
let annotations = BakefileTarget::image_version_annotation(
&image_version,
&metadata.vendor_tag_prefix,
&cli_args.image_version,
);
let target = BakefileTarget {
tags: vec![image_manifest_uri],
arguments: build_arguments,
platforms: vec![cli_args.target_platform.clone()],
// NOTE (@Techassi): Should this instead be scoped to the folder of the image we build
context: Some(PathBuf::from(".")),
dockerfile: Some(dockerfile_path),
inherits: vec![COMMON_TARGET_NAME.to_owned()],
annotations,
contexts,
..Default::default()
};
bakefile_targets.insert(target_name, target);
// Add the target to the default group if it is an entry
if is_entry {
groups
.entry("default".to_owned())
.or_default()
.targets
.push(Self::format_entry_target_name(&image_name, &image_version));
}
}
}
Ok(Self {
targets: bakefile_targets,
groups,
})
}
/// Formats and returns the entry target name, eg. `entry--opa-1_4_2`.
fn format_entry_target_name(image_name: &str, image_version: &str) -> String {
let target_name = Self::format_target_name(image_name, image_version);
format!("{ENTRY_TARGET_NAME_PREFIX}{target_name}")
}
/// Formats and returns the target name, eg. `stackable-base-1_0_0`.
fn format_target_name(image_name: &str, image_version: &str) -> String {
// Replace any slashes from nested image names, eg. shared/protobuf, because docker buildx
// has this weird restriction (because it also supports push, which we do on our own). We
// are therefore artificially limited what target names we can use: [a-zA-Z0-9_-]+
let image_name = image_name.replace('/', "__");
// The dots in the semantic version also need to be replaced.
let image_version = image_version.replace('.', "_");
format!("{image_name}-{image_version}")
}
/// Formats and return the context name, eg. `stackable/image/stackable-base-1_0_0`.
fn format_context_name(name: &str) -> String {
format!("local-image/{name}")
}
/// Formats and returns the context target name, eg. `target:stackable-base-1_0_0`.
fn format_context_target(name: &str, version: &str) -> String {
let target_name = Self::format_target_name(name, version);
format!("target:{target_name}")
}
fn now() -> Result<String, Error> {
time::UtcDateTime::now()
.format(&Rfc3339)
.context(FormatTimeSnafu)
}
fn git_head_revision() -> Result<String, GitError> {
let repo = git2::Repository::open(".").context(OpenRepositorySnafu)?;
let rev = repo.revparse("HEAD").context(ParseHeadRevisionSnafu)?;
let rev = rev.from().context(InvalidRangeSnafu)?.id().to_string();
Ok(rev)
}
}
// TODO (@Techassi): Figure out of we can use borrowed data in here. This would avoid a whole bunch
// of cloning.
#[derive(Debug, Default, Serialize)]
pub struct BakefileTarget {
/// Defines build arguments for the target.
#[serde(
rename = "args",
skip_serializing_if = "docker::BuildArguments::is_empty"
)]
pub arguments: docker::BuildArguments,
/// Adds annotations to images built with bake.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<String>,
/// Specifies the location of the build context to use for this target.
///
/// Accepts a URL or a directory path.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<PathBuf>,
/// Additional build contexts.
///
/// This attribute takes a map, where keys result in named contexts that you can reference in
/// your builds.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub contexts: BTreeMap<String, String>,
/// Name of the Dockerfile to use for the build.
#[serde(skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<PathBuf>,
/// A target can inherit attributes from other targets.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub inherits: Vec<String>,
/// Assigns image labels to the build.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
// TODO (@Techassi): Explore how we can build multiple platforms at once
/// Set target platforms for the build target.
///
/// Technically, multiple architectures can be listed in here, but boil chooses to build only
/// one architecture at a time.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub platforms: Vec<TargetPlatform>,
/// Image names and tags to use for the build target.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl BakefileTarget {
fn common(
date_time: String,
revision: String,
release_version: String,
container_build_args: docker::BuildArguments,
user_container_build_args: Vec<docker::BuildArgument>,
metadata: &Metadata,
) -> Self {
let config::Metadata {
documentation: docs,
licenses,
authors,
source,
vendor,
..
} = metadata;
// Annotations describe OCI image components.
// Add annotations which are always present.
let mut annotations = vec![
format!("{ANNOTATION_CREATED}={date_time}"),
format!("{ANNOTATION_REVISION}={revision}"),
];
// Add optional annotations.
if let Some(authors) = authors {
annotations.push(format!("{ANNOTATION_AUTHORS}={authors}"));
}
if let Some(docs) = docs {
annotations.push(format!("{ANNOTATION_DOCUMENTATION}={docs}"));
}
if let Some(source) = source {
annotations.push(format!("{ANNOTATION_SOURCE}={source}"));
}
if let Some(licenses) = licenses {
annotations.push(format!("{ANNOTATION_LICENSES}={licenses}"));
}
if let Some(vendor) = vendor {
annotations.push(format!("{ANNOTATION_VENDOR}={vendor}"));
}
let mut arguments = container_build_args;
arguments.extend(user_container_build_args);
arguments.insert(docker::BuildArgument::new(
"RELEASE_VERSION".to_owned(),
release_version,
));
// Labels describe Docker resources, and con be considered legacy. We
// should use annotations instead. These labels are only added to be
// consistent with `bake`.
let labels = BTreeMap::from([
(ANNOTATION_CREATED.to_owned(), date_time.clone()),
(ANNOTATION_REVISION.to_owned(), revision),
(DOCKER_LABEL_BUILD_DATE.to_owned(), date_time),
]);
Self {
annotations,
arguments,
labels,
..Default::default()
}
}
fn image_version_annotation(
image_version: &str,
vendor_tag_prefix: &str,
vendor_image_version: &str,
) -> Vec<String> {
let image_index_manifest_tag = utils::format_image_index_manifest_tag(
image_version,
vendor_tag_prefix,
vendor_image_version,
);
vec![format!("{ANNOTATION_VERSION}={image_index_manifest_tag}")]
}
}
#[derive(Debug, Default, Serialize)]
pub struct BakefileGroup {
targets: Vec<String>,
}