1use std::collections::HashMap;
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use a3s_box_core::error::{BoxError, Result};
12use a3s_box_core::platform::Platform;
13
14use super::cache::{hash_context_sources, BuildCache};
15use super::dockerfile::{Dockerfile, Instruction, RunBindMount, RunCacheMount};
16use super::dockerignore::DockerIgnore;
17use super::layer::{sha256_bytes, sha256_file, LayerInfo};
18use crate::oci::image::OciImageConfig;
19use crate::oci::layers::extract_layer;
20use crate::oci::store::ImageStore;
21use crate::oci::{ImagePuller, RegistryAuth};
22
23mod handlers;
24mod stages;
25mod utils;
26
27#[cfg(test)]
28mod tests;
29
30use handlers::{
31 apply_base_config, execute_onbuild_trigger, handle_add, handle_copy, handle_run,
32 handle_run_with_pool, instruction_to_string,
33};
34use stages::{global_arg_decls, resolve_stage_rootfs, split_into_stages};
35use utils::{compute_diff_id, expand_args, format_size, resolve_path};
36
37#[derive(Debug, Clone)]
39pub struct BuildConfig {
40 pub context_dir: PathBuf,
42 pub dockerfile_path: PathBuf,
44 pub tag: Option<String>,
46 pub build_args: HashMap<String, String>,
48 pub quiet: bool,
50 pub platforms: Vec<Platform>,
53 pub target: Option<String>,
56 pub no_cache: bool,
58 pub metrics: Option<crate::prom::RuntimeMetrics>,
60 pub run_pool: Option<BuildRunPoolConfig>,
62}
63
64#[derive(Debug, Clone)]
66pub struct BuildRunPoolConfig {
67 pub socket: String,
69 pub image: Option<String>,
71 pub vcpus: u32,
73 pub memory_mb: u32,
75 pub guest_rootfs: String,
77 pub timeout_ns: u64,
79 pub run_cache_dir: PathBuf,
81}
82
83#[derive(Debug)]
85pub struct BuildResult {
86 pub reference: String,
88 pub digest: String,
90 pub size: u64,
92 pub layer_count: usize,
94}
95
96#[cfg_attr(not(feature = "pool"), allow(dead_code))]
97struct BuildRunPoolSession {
98 guest_rootfs: String,
99 timeout_ns: u64,
100 run_cache_dir: PathBuf,
101 #[cfg(feature = "pool")]
102 lease: crate::pool::PoolLeaseClient,
103}
104
105impl BuildRunPoolSession {
106 async fn acquire(config: &BuildRunPoolConfig, rootfs_dir: &Path) -> Result<Self> {
107 #[cfg(feature = "pool")]
108 {
109 let rootfs_dir = rootfs_dir.canonicalize().map_err(|e| {
110 BoxError::BuildError(format!(
111 "Failed to canonicalize build RUN rootfs {}: {}",
112 rootfs_dir.display(),
113 e
114 ))
115 })?;
116 let volume = format!("{}:{}:rw", rootfs_dir.display(), config.guest_rootfs);
117 let lease = crate::pool::PoolLeaseClient::acquire(crate::pool::PoolClientLease {
118 socket: config.socket.clone(),
119 image: config.image.clone(),
120 volumes: vec![volume],
121 vcpus: config.vcpus,
122 memory_mb: config.memory_mb,
123 })
124 .await
125 .map_err(|e| {
126 BoxError::BuildError(format!(
127 "Failed to lease warm-pool VM for Dockerfile RUN: {}",
128 e
129 ))
130 })?;
131 Ok(Self {
132 guest_rootfs: config.guest_rootfs.clone(),
133 timeout_ns: config.timeout_ns,
134 run_cache_dir: config.run_cache_dir.clone(),
135 lease,
136 })
137 }
138
139 #[cfg(not(feature = "pool"))]
140 {
141 let _ = (config, rootfs_dir);
142 Err(BoxError::BuildError(
143 "Dockerfile RUN warm-pool execution requires the runtime 'pool' feature"
144 .to_string(),
145 ))
146 }
147 }
148
149 async fn release(self) -> Result<()> {
150 #[cfg(feature = "pool")]
151 {
152 self.lease.release().await.map_err(|e| {
153 BoxError::BuildError(format!(
154 "Failed to release warm-pool Dockerfile RUN lease: {}",
155 e
156 ))
157 })
158 }
159
160 #[cfg(not(feature = "pool"))]
161 {
162 Ok(())
163 }
164 }
165}
166
167fn run_bind_mount_input_hash(
168 context_dir: &Path,
169 completed_stages: &[(Option<String>, PathBuf)],
170 bind_mounts: &[RunBindMount],
171) -> Option<String> {
172 let mut input = String::new();
173
174 for mount in bind_mounts {
175 if has_parent_component(&mount.source) {
176 return None;
177 }
178
179 let source = if mount.source.is_empty() {
180 "."
181 } else {
182 mount.source.as_str()
183 };
184 let (origin, source_root) = match mount.from.as_deref() {
185 Some(from_ref) => (
186 format!("stage:{from_ref}"),
187 resolve_stage_rootfs(from_ref, completed_stages).ok()?,
188 ),
189 None => ("context".to_string(), context_dir),
190 };
191
192 let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
193 input.push_str(&origin);
194 input.push('\0');
195 input.push_str(source);
196 input.push('\0');
197 input.push_str(&source_hash);
198 input.push('\0');
199
200 if mount.from.is_none() {
201 let dockerignore = context_dir.join(".dockerignore");
202 if let Ok(bytes) = std::fs::read(&dockerignore) {
203 input.push_str(".dockerignore");
204 input.push('\0');
205 input.push_str(&sha256_bytes(&bytes));
206 input.push('\0');
207 }
208 }
209 }
210
211 Some(sha256_bytes(input.as_bytes()))
212}
213
214fn run_cache_mount_input_hash(
215 completed_stages: &[(Option<String>, PathBuf)],
216 cache_mounts: &[RunCacheMount],
217) -> Option<String> {
218 let mut input = String::new();
219 let mut saw_seeded_cache = false;
220
221 for mount in cache_mounts {
222 let Some(from_ref) = mount.from.as_deref() else {
223 continue;
224 };
225 if has_parent_component(&mount.source) {
226 return None;
227 }
228
229 saw_seeded_cache = true;
230 let source = if mount.source.is_empty() {
231 "."
232 } else {
233 mount.source.as_str()
234 };
235 let source_root = resolve_stage_rootfs(from_ref, completed_stages).ok()?;
236 let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
237 input.push_str("cache-seed:");
238 input.push_str(from_ref);
239 input.push('\0');
240 input.push_str(source);
241 input.push('\0');
242 input.push_str(&source_hash);
243 input.push('\0');
244 }
245
246 saw_seeded_cache.then(|| sha256_bytes(input.as_bytes()))
247}
248
249fn run_mount_input_hash(
250 context_dir: &Path,
251 completed_stages: &[(Option<String>, PathBuf)],
252 cache_mounts: &[RunCacheMount],
253 bind_mounts: &[RunBindMount],
254) -> Option<String> {
255 let bind_hash = if bind_mounts.is_empty() {
256 None
257 } else {
258 run_bind_mount_input_hash(context_dir, completed_stages, bind_mounts)
259 };
260 let cache_hash = run_cache_mount_input_hash(completed_stages, cache_mounts);
261
262 match (bind_hash, cache_hash) {
263 (None, None) => None,
264 (Some(hash), None) | (None, Some(hash)) => Some(hash),
265 (Some(bind_hash), Some(cache_hash)) => Some(sha256_bytes(
266 format!("bind\0{bind_hash}\0cache\0{cache_hash}").as_bytes(),
267 )),
268 }
269}
270
271fn has_parent_component(path: &str) -> bool {
272 Path::new(path)
273 .components()
274 .any(|component| matches!(component, std::path::Component::ParentDir))
275}
276
277async fn resolve_run_mount_source_roots(
278 completed_stages: &[(Option<String>, PathBuf)],
279 bind_mounts: &[RunBindMount],
280 cache_mounts: &[RunCacheMount],
281 store: &Arc<ImageStore>,
282 build_dir: &Path,
283 external_from_rootfs: &mut HashMap<String, PathBuf>,
284) -> Result<Option<Vec<(Option<String>, PathBuf)>>> {
285 let mut roots: Option<Vec<(Option<String>, PathBuf)>> = None;
286 let mut external_refs = HashSet::new();
287
288 let mut from_refs: Vec<&str> = Vec::new();
289 from_refs.extend(bind_mounts.iter().filter_map(|mount| mount.from.as_deref()));
290 from_refs.extend(
291 cache_mounts
292 .iter()
293 .filter_map(|mount| mount.from.as_deref()),
294 );
295
296 for from_ref in from_refs {
297 if resolve_stage_rootfs(from_ref, completed_stages).is_ok()
298 || roots
299 .as_deref()
300 .is_some_and(|resolved| resolve_stage_rootfs(from_ref, resolved).is_ok())
301 {
302 continue;
303 }
304
305 if !external_refs.insert(from_ref.to_string()) {
306 continue;
307 }
308
309 let rootfs = resolve_external_from_rootfs(
310 from_ref,
311 "RUN bind mount",
312 store,
313 build_dir,
314 external_from_rootfs,
315 )
316 .await?;
317 roots
318 .get_or_insert_with(|| completed_stages.to_vec())
319 .push((Some(from_ref.to_string()), rootfs));
320 }
321
322 Ok(roots)
323}
324
325pub(super) struct BuildState {
327 pub(super) workdir: String,
329 pub(super) env: Vec<(String, String)>,
331 pub(super) entrypoint: Option<Vec<String>>,
333 pub(super) cmd: Option<Vec<String>>,
335 pub(super) user: Option<String>,
337 pub(super) exposed_ports: Vec<String>,
339 pub(super) labels: HashMap<String, String>,
341 pub(super) layers: Vec<LayerInfo>,
343 pub(super) diff_ids: Vec<String>,
345 pub(super) history: Vec<HistoryEntry>,
347 pub(super) build_args: HashMap<String, String>,
350 pub(super) declared_args: HashSet<String>,
354 pub(super) shell: Vec<String>,
356 pub(super) stop_signal: Option<String>,
358 pub(super) health_check: Option<OciHealthCheck>,
360 pub(super) onbuild: Vec<String>,
362 pub(super) volumes: Vec<String>,
364}
365
366#[derive(Debug, Clone)]
368pub(super) struct HistoryEntry {
369 pub(super) created_by: String,
370 pub(super) empty_layer: bool,
371}
372
373pub use crate::oci::image::OciHealthCheck;
374
375impl BuildState {
376 fn new(build_args: HashMap<String, String>) -> Self {
377 Self {
378 workdir: "/".to_string(),
379 env: Vec::new(),
380 entrypoint: None,
381 cmd: None,
382 user: None,
383 exposed_ports: Vec::new(),
384 labels: HashMap::new(),
385 layers: Vec::new(),
386 diff_ids: Vec::new(),
387 history: Vec::new(),
388 build_args,
389 declared_args: HashSet::new(),
390 shell: vec!["/bin/sh".to_string(), "-c".to_string()],
391 stop_signal: None,
392 health_check: None,
393 onbuild: Vec::new(),
394 volumes: Vec::new(),
395 }
396 }
397
398 fn declared_build_args(&self) -> HashMap<String, String> {
401 self.build_args
402 .iter()
403 .filter(|(name, _)| self.declared_args.contains(*name))
404 .map(|(name, value)| (name.clone(), value.clone()))
405 .collect()
406 }
407
408 fn expansion_vars(&self) -> HashMap<String, String> {
412 let mut vars = self.declared_build_args();
413 for (key, value) in &self.env {
414 vars.insert(key.clone(), value.clone());
415 }
416 vars
417 }
418
419 fn run_env(&self) -> Vec<(String, String)> {
423 let mut vars = self.declared_build_args();
424 for (key, value) in &self.env {
425 vars.insert(key.clone(), value.clone());
426 }
427 let mut pairs = vars.into_iter().collect::<Vec<_>>();
428 pairs.sort_by(|a, b| a.0.cmp(&b.0));
429 pairs
430 }
431
432 fn seed_global_arg(&mut self, name: &str, default: Option<&str>) {
435 self.declared_args.insert(name.to_string());
436 if !self.build_args.contains_key(name) {
437 if let Some(val) = default {
438 self.build_args.insert(name.to_string(), val.to_string());
439 }
440 }
441 }
442}
443
444pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
459 validate_build_config(&config)?;
460
461 let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
463
464 let dockerignore = DockerIgnore::load(&config.context_dir);
466
467 if !config.quiet {
468 println!("Building from {}", config.dockerfile_path.display());
469 if !dockerignore.is_empty() {
470 println!("Using .dockerignore");
471 }
472 }
473
474 let stages = split_into_stages(&dockerfile.instructions);
476 let global_args = global_arg_decls(&dockerfile.instructions);
480 let total_stages = stages.len();
481
482 let output_stage_idx = match config.target.as_deref() {
486 Some(target) => stages
487 .iter()
488 .position(|s| s.alias.as_deref() == Some(target))
489 .or_else(|| target.parse::<usize>().ok().filter(|i| *i < total_stages))
490 .ok_or_else(|| {
491 BoxError::BuildError(format!("target build stage '{}' not found", target))
492 })?,
493 None => total_stages - 1,
494 };
495
496 let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
498 let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
501
502 let build_dir = tempfile::TempDir::new()
504 .map_err(|e| BoxError::BuildError(format!("Failed to create build directory: {}", e)))?;
505
506 let mut final_state = BuildState::new(config.build_args.clone());
507 let mut final_base_layers: Vec<LayerInfo> = Vec::new();
508 let mut final_base_diff_ids: Vec<String> = Vec::new();
509
510 let total_instructions = dockerfile.instructions.len();
511 let mut global_step = 0;
512
513 for (stage_idx, stage) in stages.iter().enumerate() {
514 let is_final_stage = stage_idx == output_stage_idx;
515
516 let rootfs_dir = build_dir.path().join(format!("rootfs_{}", stage_idx));
517 let layers_dir = build_dir.path().join(format!("layers_{}", stage_idx));
518 std::fs::create_dir_all(&rootfs_dir).map_err(|e| {
519 BoxError::BuildError(format!("Failed to create rootfs directory: {}", e))
520 })?;
521 std::fs::create_dir_all(&layers_dir).map_err(|e| {
522 BoxError::BuildError(format!("Failed to create layers directory: {}", e))
523 })?;
524
525 let mut state = BuildState::new(config.build_args.clone());
526 if stage_idx > 0 {
530 for (name, default) in &global_args {
531 state.seed_global_arg(name, default.as_deref());
532 }
533 }
534 let mut base_layers: Vec<LayerInfo> = Vec::new();
535 let mut base_diff_ids: Vec<String> = Vec::new();
536 let cache = if config.no_cache {
538 None
539 } else {
540 BuildCache::open()
541 };
542 let mut chain_key = String::new();
544 let mut cache_valid = true;
546
547 for instruction in &stage.instructions {
548 global_step += 1;
549 let step = global_step;
550 let run_mount_source_roots = if let Instruction::Run {
551 bind_mounts,
552 cache_mounts,
553 ..
554 } = instruction
555 {
556 resolve_run_mount_source_roots(
557 &completed_stages,
558 bind_mounts,
559 cache_mounts,
560 &store,
561 build_dir.path(),
562 &mut external_from_rootfs,
563 )
564 .await?
565 } else {
566 None
567 };
568
569 if !matches!(instruction, Instruction::From { .. }) {
574 let repr = match instruction {
580 Instruction::Env { vars } => {
581 let pairs: Vec<String> = vars
582 .iter()
583 .map(|(k, v)| {
584 format!("{}={}", k, expand_args(v, &state.expansion_vars()))
585 })
586 .collect();
587 format!("ENV {}", pairs.join(" "))
588 }
589 Instruction::Arg { name, default } => {
590 let effective = state
591 .build_args
592 .get(name)
593 .cloned()
594 .or_else(|| default.clone())
595 .unwrap_or_default();
596 format!("ARG {}={}", name, effective)
597 }
598 other => instruction_to_string(other),
599 };
600 let input_hash = match instruction {
601 Instruction::Copy {
602 src, from: None, ..
603 } => hash_context_sources(&config.context_dir, src),
604 Instruction::Copy {
605 src,
606 from: Some(from_ref),
607 ..
608 } => {
609 resolve_stage_rootfs(from_ref, &completed_stages)
617 .ok()
618 .and_then(|rootfs| hash_context_sources(rootfs, src))
619 }
620 Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
621 Instruction::Run {
622 cache_mounts,
623 bind_mounts,
624 ..
625 } => run_mount_input_hash(
626 &config.context_dir,
627 run_mount_source_roots
628 .as_deref()
629 .unwrap_or(&completed_stages),
630 cache_mounts,
631 bind_mounts,
632 ),
633 _ => None,
634 };
635 chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
636 }
637
638 match instruction {
639 Instruction::From { image, alias } => {
640 if !config.quiet {
641 if total_stages > 1 {
642 println!(
643 "Step {}/{}: FROM {} (stage {}/{}{})",
644 step,
645 total_instructions,
646 image,
647 stage_idx + 1,
648 total_stages,
649 alias
650 .as_ref()
651 .map(|a| format!(" as {}", a))
652 .unwrap_or_default()
653 );
654 } else {
655 println!("Step {}/{}: FROM {}", step, total_instructions, image);
656 }
657 }
658 let (layers, diff_ids, base_config) = handle_from(
659 image,
660 &rootfs_dir,
661 &layers_dir,
662 &store,
663 &state.declared_build_args(),
664 )
665 .await?;
666 base_layers = layers;
667 base_diff_ids = diff_ids;
668
669 chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
673 cache_valid = true;
674
675 apply_base_config(&mut state, &base_config);
677
678 if !base_config.onbuild.is_empty() && !config.quiet {
680 println!(
681 " Executing {} ONBUILD trigger(s) from base image",
682 base_config.onbuild.len()
683 );
684 }
685 for trigger in &base_config.onbuild {
686 execute_onbuild_trigger(
687 trigger,
688 &mut state,
689 &config,
690 &rootfs_dir,
691 &layers_dir,
692 &base_layers,
693 &completed_stages,
694 )?;
695 }
696
697 state.history.push(HistoryEntry {
698 created_by: format!("FROM {}", image),
699 empty_layer: true,
700 });
701 }
702
703 Instruction::Copy {
704 src,
705 dst,
706 from,
707 chown,
708 } => {
709 let created_by = if let Some(from_ref) = from {
710 format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
711 } else if let Some(owner) = chown {
712 format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
713 } else {
714 format!("COPY {} {}", src.join(" "), dst)
715 };
716 if try_reuse_cached_layer(
717 CachedLayerReuse {
718 cache_valid,
719 cache: cache.as_ref(),
720 chain_key: &chain_key,
721 rootfs_dir: &rootfs_dir,
722 layers_dir: &layers_dir,
723 layer_index: state.layers.len() + base_layers.len(),
724 created_by: &created_by,
725 },
726 &mut state,
727 )?
728 .is_some()
729 {
730 if !config.quiet {
731 println!(
732 "Step {}/{}: {} (CACHED)",
733 step, total_instructions, created_by
734 );
735 }
736 continue;
737 }
738 cache_valid = false;
739
740 if let Some(from_ref) = from {
741 if !config.quiet {
742 println!(
743 "Step {}/{}: COPY --from={} {} {}",
744 step,
745 total_instructions,
746 from_ref,
747 src.join(" "),
748 dst
749 );
750 }
751 let from_rootfs: PathBuf =
755 match resolve_stage_rootfs(from_ref, &completed_stages) {
756 Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
757 Err(_) => {
758 resolve_external_from_rootfs(
759 from_ref,
760 "COPY --from",
761 &store,
762 build_dir.path(),
763 &mut external_from_rootfs,
764 )
765 .await?
766 }
767 };
768 let layer_info = handle_copy(
771 src,
772 dst,
773 chown.as_deref(),
774 &from_rootfs,
775 &rootfs_dir,
776 &layers_dir,
777 &state.workdir,
778 state.layers.len() + base_layers.len(),
779 None,
780 )?;
781 let diff_id = compute_diff_id(&layer_info.path)?;
782 if let Some(c) = &cache {
783 c.store(&chain_key, &layer_info, &diff_id);
784 }
785 state.diff_ids.push(diff_id);
786 state.layers.push(layer_info);
787 state.history.push(HistoryEntry {
788 created_by: format!(
789 "COPY --from={} {} {}",
790 from_ref,
791 src.join(" "),
792 dst
793 ),
794 empty_layer: false,
795 });
796 } else {
797 if !config.quiet {
798 println!(
799 "Step {}/{}: COPY {} {}",
800 step,
801 total_instructions,
802 src.join(" "),
803 dst
804 );
805 }
806 let layer_info = handle_copy(
807 src,
808 dst,
809 chown.as_deref(),
810 &config.context_dir,
811 &rootfs_dir,
812 &layers_dir,
813 &state.workdir,
814 state.layers.len() + base_layers.len(),
815 Some(&dockerignore),
816 )?;
817 let diff_id = compute_diff_id(&layer_info.path)?;
818 if let Some(c) = &cache {
819 c.store(&chain_key, &layer_info, &diff_id);
820 }
821 state.diff_ids.push(diff_id);
822 state.layers.push(layer_info);
823 state.history.push(HistoryEntry {
824 created_by: format!("COPY {} {}", src.join(" "), dst),
825 empty_layer: false,
826 });
827 }
828 }
829
830 Instruction::Add { src, dst, chown } => {
831 let created_by = format!("ADD {} {}", src.join(" "), dst);
832 if try_reuse_cached_layer(
833 CachedLayerReuse {
834 cache_valid,
835 cache: cache.as_ref(),
836 chain_key: &chain_key,
837 rootfs_dir: &rootfs_dir,
838 layers_dir: &layers_dir,
839 layer_index: state.layers.len() + base_layers.len(),
840 created_by: &created_by,
841 },
842 &mut state,
843 )?
844 .is_some()
845 {
846 if !config.quiet {
847 println!(
848 "Step {}/{}: {} (CACHED)",
849 step, total_instructions, created_by
850 );
851 }
852 continue;
853 }
854 cache_valid = false;
855
856 if !config.quiet {
857 println!(
858 "Step {}/{}: ADD {} {}",
859 step,
860 total_instructions,
861 src.join(" "),
862 dst
863 );
864 }
865 let layer_info = handle_add(
866 src,
867 dst,
868 chown.as_deref(),
869 &config.context_dir,
870 &rootfs_dir,
871 &layers_dir,
872 &state.workdir,
873 state.layers.len() + base_layers.len(),
874 Some(&dockerignore),
875 )?;
876 let diff_id = compute_diff_id(&layer_info.path)?;
877 if let Some(c) = &cache {
878 c.store(&chain_key, &layer_info, &diff_id);
879 }
880 state.diff_ids.push(diff_id);
881 state.layers.push(layer_info);
882 state.history.push(HistoryEntry {
883 created_by: format!("ADD {} {}", src.join(" "), dst),
884 empty_layer: false,
885 });
886 }
887
888 Instruction::Run {
889 command,
890 cache_mounts,
891 bind_mounts,
892 tmpfs_mounts,
893 } => {
894 let created_by = instruction_to_string(instruction);
895 if try_reuse_cached_layer(
896 CachedLayerReuse {
897 cache_valid,
898 cache: cache.as_ref(),
899 chain_key: &chain_key,
900 rootfs_dir: &rootfs_dir,
901 layers_dir: &layers_dir,
902 layer_index: state.layers.len() + base_layers.len(),
903 created_by: &created_by,
904 },
905 &mut state,
906 )?
907 .is_some()
908 {
909 if !config.quiet {
910 println!(
911 "Step {}/{}: {} (CACHED)",
912 step, total_instructions, created_by
913 );
914 }
915 continue;
916 }
917 cache_valid = false;
918
919 if !config.quiet {
920 println!("Step {}/{}: {}", step, total_instructions, created_by);
921 }
922 let layer_opt = if let Some(pool_config) = &config.run_pool {
923 let session =
927 BuildRunPoolSession::acquire(pool_config, &rootfs_dir).await?;
928 handle_run_with_pool(
929 command,
930 cache_mounts,
931 bind_mounts,
932 tmpfs_mounts,
933 &config.context_dir,
934 run_mount_source_roots
935 .as_deref()
936 .unwrap_or(&completed_stages),
937 &rootfs_dir,
938 &layers_dir,
939 &state.workdir,
940 &state.run_env(),
941 &state.shell,
942 state.user.as_deref(),
943 state.layers.len() + base_layers.len(),
944 config.quiet,
945 session,
946 Some(&dockerignore),
947 )
948 .await?
949 } else {
950 handle_run(
951 command,
952 cache_mounts,
953 bind_mounts,
954 tmpfs_mounts,
955 &config.context_dir,
956 run_mount_source_roots
957 .as_deref()
958 .unwrap_or(&completed_stages),
959 &rootfs_dir,
960 &layers_dir,
961 &state.workdir,
962 &state.run_env(),
963 &state.shell,
964 state.layers.len() + base_layers.len(),
965 config.quiet,
966 Some(&dockerignore),
967 )?
968 };
969 if let Some(layer_info) = layer_opt {
970 let diff_id = compute_diff_id(&layer_info.path)?;
971 if let Some(c) = &cache {
972 c.store(&chain_key, &layer_info, &diff_id);
973 }
974 state.diff_ids.push(diff_id);
975 state.layers.push(layer_info);
976 state.history.push(HistoryEntry {
977 created_by: created_by.clone(),
978 empty_layer: false,
979 });
980 } else {
981 state.history.push(HistoryEntry {
982 created_by: created_by.clone(),
983 empty_layer: true,
984 });
985 }
986 }
987
988 Instruction::Workdir { path } => {
989 if !config.quiet {
990 println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
991 }
992 let expanded_path = expand_args(path, &state.expansion_vars());
994 state.workdir = resolve_path(&state.workdir, &expanded_path);
995 crate::oci::rootfs::ensure_guest_directory(
996 &rootfs_dir,
997 state.workdir.trim_start_matches('/'),
998 )?;
999 state.history.push(HistoryEntry {
1000 created_by: format!("WORKDIR {}", path),
1001 empty_layer: true,
1002 });
1003 }
1004
1005 Instruction::Env { vars } => {
1006 let display: Vec<String> =
1007 vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1008 let display = display.join(" ");
1009 if !config.quiet {
1010 println!("Step {}/{}: ENV {}", step, total_instructions, display);
1011 }
1012 for (key, value) in vars {
1013 let expanded_value = expand_args(value, &state.expansion_vars());
1016 if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
1017 existing.1 = expanded_value;
1018 } else {
1019 state.env.push((key.clone(), expanded_value));
1020 }
1021 }
1022 state.history.push(HistoryEntry {
1023 created_by: format!("ENV {}", display),
1024 empty_layer: true,
1025 });
1026 }
1027
1028 Instruction::Entrypoint { exec } => {
1029 if !config.quiet {
1030 println!(
1031 "Step {}/{}: ENTRYPOINT {:?}",
1032 step, total_instructions, exec
1033 );
1034 }
1035 state.entrypoint = Some(exec.clone());
1036 state.history.push(HistoryEntry {
1037 created_by: format!("ENTRYPOINT {:?}", exec),
1038 empty_layer: true,
1039 });
1040 }
1041
1042 Instruction::Cmd { exec } => {
1043 if !config.quiet {
1044 println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
1045 }
1046 state.cmd = Some(exec.clone());
1047 state.history.push(HistoryEntry {
1048 created_by: format!("CMD {:?}", exec),
1049 empty_layer: true,
1050 });
1051 }
1052
1053 Instruction::Expose { ports } => {
1054 let joined = ports.join(" ");
1055 if !config.quiet {
1056 println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
1057 }
1058 for port in ports {
1059 if !state.exposed_ports.contains(port) {
1060 state.exposed_ports.push(port.clone());
1061 }
1062 }
1063 state.history.push(HistoryEntry {
1064 created_by: format!("EXPOSE {}", joined),
1065 empty_layer: true,
1066 });
1067 }
1068
1069 Instruction::Label { pairs } => {
1070 let joined = pairs
1071 .iter()
1072 .map(|(k, v)| format!("{}={}", k, v))
1073 .collect::<Vec<_>>()
1074 .join(" ");
1075 if !config.quiet {
1076 println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
1077 }
1078 for (key, value) in pairs {
1079 state.labels.insert(key.clone(), value.clone());
1080 }
1081 state.history.push(HistoryEntry {
1082 created_by: format!("LABEL {}", joined),
1083 empty_layer: true,
1084 });
1085 }
1086
1087 Instruction::User { user } => {
1088 if !config.quiet {
1089 println!("Step {}/{}: USER {}", step, total_instructions, user);
1090 }
1091 state.user = Some(user.clone());
1092 state.history.push(HistoryEntry {
1093 created_by: format!("USER {}", user),
1094 empty_layer: true,
1095 });
1096 }
1097
1098 Instruction::Arg { name, default } => {
1099 if !config.quiet {
1100 println!("Step {}/{}: ARG {}", step, total_instructions, name);
1101 }
1102 state.declared_args.insert(name.clone());
1103 if !state.build_args.contains_key(name) {
1104 if let Some(val) = default {
1105 state.build_args.insert(name.clone(), val.clone());
1106 }
1107 }
1108 state.history.push(HistoryEntry {
1109 created_by: format!("ARG {}", name),
1110 empty_layer: true,
1111 });
1112 }
1113
1114 Instruction::Shell { exec } => {
1115 if !config.quiet {
1116 println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
1117 }
1118 state.shell = exec.clone();
1119 state.history.push(HistoryEntry {
1120 created_by: format!("SHELL {:?}", exec),
1121 empty_layer: true,
1122 });
1123 }
1124
1125 Instruction::StopSignal { signal } => {
1126 if !config.quiet {
1127 println!(
1128 "Step {}/{}: STOPSIGNAL {}",
1129 step, total_instructions, signal
1130 );
1131 }
1132 state.stop_signal = Some(signal.clone());
1133 state.history.push(HistoryEntry {
1134 created_by: format!("STOPSIGNAL {}", signal),
1135 empty_layer: true,
1136 });
1137 }
1138
1139 Instruction::HealthCheck {
1140 cmd,
1141 interval,
1142 timeout,
1143 retries,
1144 start_period,
1145 } => {
1146 if !config.quiet {
1147 if cmd.is_some() {
1148 println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
1149 } else {
1150 println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
1151 }
1152 }
1153 state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
1154 test: c.clone(),
1155 interval: *interval,
1156 timeout: *timeout,
1157 retries: *retries,
1158 start_period: *start_period,
1159 });
1160 state.history.push(HistoryEntry {
1161 created_by: if cmd.is_some() {
1162 "HEALTHCHECK CMD ...".to_string()
1163 } else {
1164 "HEALTHCHECK NONE".to_string()
1165 },
1166 empty_layer: true,
1167 });
1168 }
1169
1170 Instruction::OnBuild { instruction } => {
1171 let trigger = format!("{:?}", instruction);
1172 if !config.quiet {
1173 println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
1174 }
1175 state.onbuild.push(instruction_to_string(instruction));
1177 state.history.push(HistoryEntry {
1178 created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
1179 empty_layer: true,
1180 });
1181 }
1182
1183 Instruction::Volume { paths } => {
1184 if !config.quiet {
1185 println!(
1186 "Step {}/{}: VOLUME {}",
1187 step,
1188 total_instructions,
1189 paths.join(" ")
1190 );
1191 }
1192 for p in paths {
1193 if !state.volumes.contains(p) {
1194 state.volumes.push(p.clone());
1195 }
1196 }
1197 for p in paths {
1199 crate::oci::rootfs::ensure_guest_directory(
1200 &rootfs_dir,
1201 p.trim_start_matches('/'),
1202 )?;
1203 }
1204 state.history.push(HistoryEntry {
1205 created_by: format!("VOLUME {}", paths.join(" ")),
1206 empty_layer: true,
1207 });
1208 }
1209 }
1210 }
1211
1212 completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
1214
1215 if is_final_stage {
1216 final_state = state;
1217 final_base_layers = base_layers;
1218 final_base_diff_ids = base_diff_ids;
1219 break;
1221 }
1222 }
1223
1224 let reference = config
1226 .tag
1227 .clone()
1228 .unwrap_or_else(|| "a3s-build:latest".to_string());
1229
1230 let final_layers_dir = build_dir
1231 .path()
1232 .join(format!("layers_{}", output_stage_idx));
1233
1234 let target_platform = config
1236 .platforms
1237 .first()
1238 .cloned()
1239 .unwrap_or_else(default_target_platform);
1240
1241 let result = assemble_image(
1242 &reference,
1243 &final_state,
1244 &final_base_layers,
1245 &final_base_diff_ids,
1246 &final_layers_dir,
1247 &store,
1248 &target_platform,
1249 )
1250 .await?;
1251
1252 if !config.quiet {
1253 println!(
1254 "Successfully built {} ({} layers, {}, {})",
1255 reference,
1256 result.layer_count,
1257 format_size(result.size),
1258 target_platform,
1259 );
1260 }
1261
1262 if let Some(ref m) = config.metrics {
1263 m.image_build_total.inc();
1264 }
1265
1266 Ok(result)
1267}
1268
1269struct CachedLayerReuse<'a> {
1281 cache_valid: bool,
1282 cache: Option<&'a BuildCache>,
1283 chain_key: &'a str,
1284 rootfs_dir: &'a Path,
1285 layers_dir: &'a Path,
1286 layer_index: usize,
1287 created_by: &'a str,
1288}
1289
1290fn try_reuse_cached_layer(
1291 request: CachedLayerReuse<'_>,
1292 state: &mut BuildState,
1293) -> Result<Option<()>> {
1294 if !request.cache_valid {
1295 return Ok(None);
1296 }
1297 let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
1298 return Ok(None);
1299 };
1300
1301 let local_layer = request.layers_dir.join(format!(
1302 "cached_{}_{}.tar.gz",
1303 request.layer_index, cached.digest
1304 ));
1305 if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
1306 tracing::warn!(
1307 key = %request.chain_key,
1308 source = %cached.blob_path.display(),
1309 error = %error,
1310 "Build cache blob disappeared before it could be materialized; rebuilding instruction"
1311 );
1312 return Ok(None);
1313 }
1314
1315 extract_layer(&local_layer, request.rootfs_dir)?;
1317 let local_size = std::fs::metadata(&local_layer)
1318 .map(|metadata| metadata.len())
1319 .unwrap_or(cached.size);
1320
1321 state.layers.push(LayerInfo {
1322 path: local_layer,
1323 digest: cached.digest,
1324 size: local_size,
1325 });
1326 state.diff_ids.push(cached.diff_id);
1327 state.history.push(HistoryEntry {
1328 created_by: request.created_by.to_string(),
1329 empty_layer: false,
1330 });
1331 Ok(Some(()))
1332}
1333
1334async fn handle_from(
1338 image: &str,
1339 rootfs_dir: &Path,
1340 _layers_dir: &Path,
1341 store: &Arc<ImageStore>,
1342 build_args: &HashMap<String, String>,
1343) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
1344 let image_ref = expand_args(image, build_args);
1345 if image_ref == "scratch" {
1346 return Ok((Vec::new(), Vec::new(), scratch_config()));
1347 }
1348
1349 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1351 let oci_image = puller.pull(&image_ref).await?;
1352
1353 for layer_path in oci_image.layer_paths() {
1355 extract_layer(layer_path, rootfs_dir)?;
1356 }
1357
1358 let mut base_layers = Vec::new();
1360 let mut base_diff_ids = Vec::new();
1361
1362 for layer_path in oci_image.layer_paths() {
1363 let digest = sha256_file(layer_path)?;
1364 let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
1365
1366 let diff_id = compute_diff_id(layer_path)?;
1368 base_diff_ids.push(diff_id);
1369
1370 base_layers.push(LayerInfo {
1371 path: layer_path.to_path_buf(),
1372 digest,
1373 size,
1374 });
1375 }
1376
1377 let config = oci_image.config().clone();
1378 Ok((base_layers, base_diff_ids, config))
1379}
1380
1381async fn resolve_external_from_rootfs(
1385 image_ref: &str,
1386 operation: &str,
1387 store: &Arc<ImageStore>,
1388 build_dir: &Path,
1389 cache: &mut HashMap<String, PathBuf>,
1390) -> Result<PathBuf> {
1391 if let Some(dir) = cache.get(image_ref) {
1392 return Ok(dir.clone());
1393 }
1394
1395 let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1396 std::fs::create_dir_all(&dir).map_err(|e| {
1397 BoxError::BuildError(format!(
1398 "Failed to create {operation} image rootfs {}: {}",
1399 dir.display(),
1400 e
1401 ))
1402 })?;
1403
1404 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1405 let oci_image = puller.pull(image_ref).await.map_err(|e| {
1406 BoxError::BuildError(format!(
1407 "{operation} from={}: not a build stage and could not be pulled as an image: {}",
1408 image_ref, e
1409 ))
1410 })?;
1411 for layer_path in oci_image.layer_paths() {
1412 extract_layer(layer_path, &dir)?;
1413 }
1414
1415 cache.insert(image_ref.to_string(), dir.clone());
1416 Ok(dir)
1417}
1418
1419fn validate_build_config(config: &BuildConfig) -> Result<()> {
1420 if config.platforms.len() > 1 {
1421 return Err(BoxError::BuildError(
1422 "Multi-platform builds are not implemented yet; pass a single target platform"
1423 .to_string(),
1424 ));
1425 }
1426
1427 for platform in &config.platforms {
1428 if platform.os != "linux" {
1429 return Err(BoxError::BuildError(format!(
1430 "Only linux target platforms are supported for image builds, got {}",
1431 platform
1432 )));
1433 }
1434 }
1435
1436 Ok(())
1437}
1438
1439fn default_target_platform() -> Platform {
1440 let host = Platform::host();
1441 Platform::new("linux", host.architecture)
1442}
1443
1444fn scratch_config() -> OciImageConfig {
1445 OciImageConfig {
1446 entrypoint: None,
1447 cmd: None,
1448 env: Vec::new(),
1449 working_dir: None,
1450 user: None,
1451 exposed_ports: Vec::new(),
1452 labels: HashMap::new(),
1453 volumes: Vec::new(),
1454 stop_signal: None,
1455 health_check: None,
1456 onbuild: Vec::new(),
1457 }
1458}
1459
1460async fn assemble_image(
1462 reference: &str,
1463 state: &BuildState,
1464 base_layers: &[LayerInfo],
1465 base_diff_ids: &[String],
1466 layers_dir: &Path,
1467 store: &Arc<ImageStore>,
1468 target_platform: &Platform,
1469) -> Result<BuildResult> {
1470 let output_dir = layers_dir.join("_output");
1472 let blobs_dir = output_dir.join("blobs").join("sha256");
1473 std::fs::create_dir_all(&blobs_dir)
1474 .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1475
1476 let mut all_layer_descriptors = Vec::new();
1478 let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1479
1480 for layer in base_layers {
1482 let blob_path = blobs_dir.join(&layer.digest);
1483 if !blob_path.exists() {
1484 copy_layer_blob(layer, &blob_path, "base layer")?;
1485 }
1486 all_layer_descriptors.push(serde_json::json!({
1487 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1488 "digest": layer.prefixed_digest(),
1489 "size": layer.size
1490 }));
1491 }
1492
1493 for (i, layer) in state.layers.iter().enumerate() {
1495 let blob_path = blobs_dir.join(&layer.digest);
1496 if !blob_path.exists() {
1497 copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
1498 }
1499 all_layer_descriptors.push(serde_json::json!({
1500 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1501 "digest": layer.prefixed_digest(),
1502 "size": layer.size
1503 }));
1504 }
1505
1506 all_diff_ids.extend(state.diff_ids.iter().cloned());
1508
1509 let now = chrono::Utc::now().to_rfc3339();
1511 let arch = target_platform.oci_arch();
1512
1513 let env_list: Vec<String> = state
1514 .env
1515 .iter()
1516 .map(|(k, v)| format!("{}={}", k, v))
1517 .collect();
1518
1519 let mut config_obj = serde_json::json!({
1520 "architecture": arch,
1521 "os": "linux",
1522 "created": now,
1523 "config": {},
1524 "rootfs": {
1525 "type": "layers",
1526 "diff_ids": all_diff_ids.iter()
1527 .map(|d| format!("sha256:{}", d))
1528 .collect::<Vec<_>>()
1529 },
1530 "history": state.history.iter().map(|h| {
1531 let mut entry = serde_json::json!({
1532 "created": now,
1533 "created_by": h.created_by
1534 });
1535 if h.empty_layer {
1536 entry["empty_layer"] = serde_json::json!(true);
1537 }
1538 entry
1539 }).collect::<Vec<_>>()
1540 });
1541
1542 let config_section = config_obj["config"].as_object_mut().unwrap();
1544 if !env_list.is_empty() {
1545 config_section.insert("Env".to_string(), serde_json::json!(env_list));
1546 }
1547 if let Some(ref ep) = state.entrypoint {
1548 config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1549 }
1550 if let Some(ref cmd) = state.cmd {
1551 config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1552 }
1553 if state.workdir != "/" {
1554 config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1555 }
1556 if let Some(ref user) = state.user {
1557 config_section.insert("User".to_string(), serde_json::json!(user));
1558 }
1559 if !state.exposed_ports.is_empty() {
1560 let ports: HashMap<String, serde_json::Value> = state
1561 .exposed_ports
1562 .iter()
1563 .map(|p| (p.clone(), serde_json::json!({})))
1564 .collect();
1565 config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1566 }
1567 if !state.labels.is_empty() {
1568 config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1569 }
1570 if let Some(ref sig) = state.stop_signal {
1571 config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1572 }
1573 if let Some(ref hc) = state.health_check {
1574 let mut hc_obj = serde_json::json!({
1575 "Test": hc.test,
1576 });
1577 if let Some(interval) = hc.interval {
1578 hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1580 }
1581 if let Some(timeout) = hc.timeout {
1582 hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1583 }
1584 if let Some(retries) = hc.retries {
1585 hc_obj["Retries"] = serde_json::json!(retries);
1586 }
1587 if let Some(start_period) = hc.start_period {
1588 hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1589 }
1590 config_section.insert("Healthcheck".to_string(), hc_obj);
1591 }
1592 if !state.onbuild.is_empty() {
1593 config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1594 }
1595 if !state.volumes.is_empty() {
1596 let vols: HashMap<String, serde_json::Value> = state
1597 .volumes
1598 .iter()
1599 .map(|v| (v.clone(), serde_json::json!({})))
1600 .collect();
1601 config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1602 }
1603
1604 let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1606 let config_digest = sha256_bytes(&config_bytes);
1607 std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1608 .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1609
1610 let manifest = serde_json::json!({
1612 "schemaVersion": 2,
1613 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1614 "config": {
1615 "mediaType": "application/vnd.oci.image.config.v1+json",
1616 "digest": format!("sha256:{}", config_digest),
1617 "size": config_bytes.len()
1618 },
1619 "layers": all_layer_descriptors
1620 });
1621
1622 let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1623 let manifest_digest = sha256_bytes(&manifest_bytes);
1624 std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1625 .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1626
1627 let mut platform_obj = serde_json::json!({
1629 "os": target_platform.os,
1630 "architecture": target_platform.architecture
1631 });
1632 if let Some(ref variant) = target_platform.variant {
1633 platform_obj["variant"] = serde_json::json!(variant);
1634 }
1635
1636 let index = serde_json::json!({
1637 "schemaVersion": 2,
1638 "mediaType": "application/vnd.oci.image.index.v1+json",
1639 "manifests": [{
1640 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1641 "digest": format!("sha256:{}", manifest_digest),
1642 "size": manifest_bytes.len(),
1643 "platform": platform_obj
1644 }]
1645 });
1646 std::fs::write(
1647 output_dir.join("index.json"),
1648 serde_json::to_string_pretty(&index)?,
1649 )
1650 .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1651
1652 std::fs::write(
1654 output_dir.join("oci-layout"),
1655 r#"{"imageLayoutVersion":"1.0.0"}"#,
1656 )
1657 .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1658
1659 let digest_str = format!("sha256:{}", manifest_digest);
1661 let stored = store.put(reference, &digest_str, &output_dir).await?;
1662
1663 let total_layers = base_layers.len() + state.layers.len();
1664
1665 Ok(BuildResult {
1666 reference: reference.to_string(),
1667 digest: digest_str,
1668 size: stored.size_bytes,
1669 layer_count: total_layers,
1670 })
1671}
1672
1673fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
1674 if !layer.path.exists() {
1675 return Err(BoxError::BuildError(format!(
1676 "Failed to copy {label}: source layer {} for digest {} does not exist",
1677 layer.path.display(),
1678 layer.prefixed_digest()
1679 )));
1680 }
1681
1682 std::fs::copy(&layer.path, blob_path).map_err(|e| {
1683 BoxError::BuildError(format!(
1684 "Failed to copy {label} from {} to {} (digest {}): {}",
1685 layer.path.display(),
1686 blob_path.display(),
1687 layer.prefixed_digest(),
1688 e
1689 ))
1690 })?;
1691 Ok(())
1692}