dofigen_lib/
generator.rs

1use std::collections::HashMap;
2
3use crate::errors::Error;
4
5use crate::lock::DEFAULT_PORT;
6use crate::{
7    DOCKERFILE_VERSION, FILE_HEADER_COMMENTS, LintMessage, LintSession, Result,
8    dockerfile_struct::*, dofigen_struct::*,
9};
10
11pub const LINE_SEPARATOR: &str = " \\\n    ";
12pub const DEFAULT_FROM: &str = "scratch";
13
14#[derive(Debug, Clone, PartialEq, Default)]
15pub struct GenerationContext {
16    dofigen: Dofigen,
17    pub(crate) user: Option<User>,
18    pub(crate) stage_name: String,
19    pub(crate) default_from: FromContext,
20    state_stack: Vec<GenerationContextState>,
21    pub(crate) lint_session: LintSession,
22}
23
24impl GenerationContext {
25    pub fn get_lint_messages(&self) -> Vec<LintMessage> {
26        self.lint_session.messages()
27    }
28
29    fn push_state(&mut self, state: GenerationContextState) {
30        let mut prev_state = GenerationContextState::default();
31        if let Some(user) = &state.user {
32            prev_state.user = Some(self.user.clone());
33            self.user = user.clone();
34        }
35        if let Some(stage_name) = &state.stage_name {
36            prev_state.stage_name = Some(self.stage_name.clone());
37            self.stage_name = stage_name.clone();
38        }
39        if let Some(default_from) = &state.default_from {
40            prev_state.default_from = Some(self.default_from.clone());
41            self.default_from = default_from.clone();
42        }
43        self.state_stack.push(prev_state);
44    }
45
46    fn pop_state(&mut self) {
47        let prev_state = self.state_stack.pop().expect("The state stack is empty");
48        if let Some(user) = prev_state.user {
49            self.user = user;
50        }
51        if let Some(stage_name) = prev_state.stage_name {
52            self.stage_name = stage_name;
53        }
54        if let Some(default_from) = prev_state.default_from {
55            self.default_from = default_from;
56        }
57    }
58
59    pub fn from(dofigen: Dofigen) -> Self {
60        let lint_session = LintSession::analyze(&dofigen);
61        Self {
62            dofigen,
63            lint_session,
64            ..Default::default()
65        }
66    }
67
68    pub fn generate_dockerfile(&mut self) -> Result<String> {
69        let mut lines = self.dofigen.clone().generate_dockerfile_lines(self)?;
70        let mut line_number = 1;
71
72        for line in FILE_HEADER_COMMENTS {
73            lines.insert(line_number, DockerfileLine::Comment(line.to_string()));
74            line_number += 1;
75        }
76
77        Ok(format!(
78            "{}\n",
79            lines
80                .iter()
81                .map(DockerfileLine::generate_content)
82                .collect::<Vec<String>>()
83                .join("\n")
84        ))
85    }
86
87    pub fn generate_dockerignore(&self) -> Result<String> {
88        let mut content = String::new();
89
90        for line in FILE_HEADER_COMMENTS {
91            content.push_str("# ");
92            content.push_str(line);
93            content.push_str("\n");
94        }
95        content.push_str("\n");
96
97        if !self.dofigen.context.is_empty() {
98            content.push_str("**\n");
99            self.dofigen.context.iter().for_each(|path| {
100                content.push_str("!");
101                content.push_str(path);
102                content.push_str("\n");
103            });
104        }
105        if !self.dofigen.ignore.is_empty() {
106            self.dofigen.ignore.iter().for_each(|path| {
107                content.push_str(path);
108                content.push_str("\n");
109            });
110        }
111        Ok(content)
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Default)]
116pub struct GenerationContextState {
117    user: Option<Option<User>>,
118    stage_name: Option<String>,
119    default_from: Option<FromContext>,
120}
121
122pub trait DockerfileGenerator {
123    fn generate_dockerfile_lines(
124        &self,
125        context: &mut GenerationContext,
126    ) -> Result<Vec<DockerfileLine>>;
127}
128
129impl Dofigen {
130    pub fn get_base_image(&self) -> Option<ImageName> {
131        let mut stage = &self.stage;
132        while let FromContext::FromBuilder(builder_name) = &stage.from {
133            if let Some(builder) = self.builders.get(builder_name) {
134                stage = builder;
135            } else {
136                return None;
137            }
138        }
139        match &stage.from {
140            FromContext::FromImage(image) => Some(image.clone()),
141            // For basic context we can't know if it's an image, a builder not strongly typed or a build context
142            FromContext::FromContext(_) => None,
143            FromContext::FromBuilder(_) => unreachable!(),
144        }
145    }
146}
147
148impl Stage {
149    pub fn from(&self, context: &GenerationContext) -> FromContext {
150        match &self.from {
151            FromContext::FromImage(image) => FromContext::FromImage(image.clone()),
152            FromContext::FromBuilder(builder) => FromContext::FromBuilder(builder.clone()),
153            FromContext::FromContext(Some(context)) => {
154                FromContext::FromContext(Some(context.clone()))
155            }
156            _ => match &context.default_from {
157                FromContext::FromImage(image) => FromContext::FromImage(image.clone()),
158                FromContext::FromBuilder(builder) => FromContext::FromBuilder(builder.clone()),
159                FromContext::FromContext(context) => {
160                    FromContext::FromContext(context.clone().or(Some(DEFAULT_FROM.to_string())))
161                }
162            },
163        }
164    }
165
166    pub fn user(&self, context: &GenerationContext) -> Option<User> {
167        self.user.clone().or(context.user.clone())
168    }
169}
170
171impl Run {
172    pub fn is_empty(&self) -> bool {
173        self.run.is_empty()
174    }
175}
176
177impl User {
178    pub fn uid(&self) -> Option<u16> {
179        self.user.parse::<u16>().ok()
180    }
181
182    pub fn gid(&self) -> Option<u16> {
183        self.group
184            .as_ref()
185            .map(|group| group.parse::<u16>().ok())
186            .flatten()
187    }
188
189    pub fn into(&self) -> String {
190        let name = self.user.clone();
191        match &self.group {
192            Some(group) => format!("{}:{}", name, group),
193            _ => name,
194        }
195    }
196
197    // Static methods
198
199    pub fn new(user: &str) -> Self {
200        Self {
201            user: user.into(),
202            group: Some(user.into()),
203        }
204    }
205
206    pub fn new_without_group(user: &str) -> Self {
207        Self {
208            user: user.into(),
209            group: None,
210        }
211    }
212}
213
214impl ToString for ImageName {
215    fn to_string(&self) -> String {
216        let mut registry = String::new();
217        if let Some(host) = &self.host {
218            registry.push_str(host);
219            if let Some(port) = self.port.clone() {
220                if port != DEFAULT_PORT {
221                    registry.push_str(":");
222                    registry.push_str(port.to_string().as_str());
223                }
224            }
225            registry.push_str("/");
226        }
227        let mut version = String::new();
228        match &self.version {
229            Some(ImageVersion::Tag(tag)) => {
230                version.push_str(":");
231                version.push_str(tag);
232            }
233            Some(ImageVersion::Digest(digest)) => {
234                version.push_str("@");
235                version.push_str(digest);
236            }
237            _ => {}
238        }
239        format!(
240            "{registry}{path}{version}",
241            path = self.path,
242            registry = registry,
243            version = version
244        )
245    }
246}
247
248impl ToString for User {
249    fn to_string(&self) -> String {
250        let mut chown = String::new();
251        chown.push_str(self.user.as_str());
252        if let Some(group) = &self.group {
253            chown.push_str(":");
254            chown.push_str(group);
255        }
256        chown
257    }
258}
259
260impl ToString for Port {
261    fn to_string(&self) -> String {
262        match &self.protocol {
263            Some(protocol) => {
264                format!(
265                    "{port}/{protocol}",
266                    port = self.port,
267                    protocol = protocol.to_string()
268                )
269            }
270            _ => self.port.to_string(),
271        }
272    }
273}
274
275impl ToString for PortProtocol {
276    fn to_string(&self) -> String {
277        match self {
278            PortProtocol::Tcp => "tcp".into(),
279            PortProtocol::Udp => "udp".into(),
280        }
281    }
282}
283
284impl ToString for Resource {
285    fn to_string(&self) -> String {
286        match self {
287            Resource::File(file) => file.to_string_lossy().to_string(),
288            Resource::Url(url) => url.to_string(),
289        }
290    }
291}
292
293impl ToString for CacheSharing {
294    fn to_string(&self) -> String {
295        match self {
296            CacheSharing::Shared => "shared".into(),
297            CacheSharing::Private => "private".into(),
298            CacheSharing::Locked => "locked".into(),
299        }
300    }
301}
302
303impl ToString for Network {
304    fn to_string(&self) -> String {
305        match self {
306            Network::Default => "default".into(),
307            Network::None => "none".into(),
308            Network::Host => "host".into(),
309        }
310    }
311}
312
313impl ToString for Security {
314    fn to_string(&self) -> String {
315        match self {
316            Security::Sandbox => "sandbox".into(),
317            Security::Insecure => "insecure".into(),
318        }
319    }
320}
321
322impl ToString for FromContext {
323    fn to_string(&self) -> String {
324        match self {
325            FromContext::FromBuilder(name) => name.clone(),
326            FromContext::FromImage(image) => image.to_string(),
327            FromContext::FromContext(context) => context.clone().unwrap_or_default(),
328        }
329    }
330}
331
332impl DockerfileGenerator for Dofigen {
333    fn generate_dockerfile_lines(
334        &self,
335        context: &mut GenerationContext,
336    ) -> Result<Vec<DockerfileLine>> {
337        context.push_state(GenerationContextState {
338            default_from: Some(self.stage.from(context).clone()),
339            ..Default::default()
340        });
341        let mut lines = vec![DockerfileLine::Comment(format!(
342            "syntax=docker/dockerfile:{}",
343            DOCKERFILE_VERSION
344        ))];
345
346        // Arg
347        if !self.global_arg.is_empty() {
348            lines.push(DockerfileLine::Empty);
349            let args = generate_arg_command(&self.global_arg);
350            lines.extend(args);
351        }
352
353        let builder_names = context.lint_session.get_sorted_builders();
354
355        for name in builder_names {
356            context.push_state(GenerationContextState {
357                stage_name: Some(name.clone()),
358                ..Default::default()
359            });
360            let builder = self
361                .builders
362                .get(&name)
363                .expect(format!("The builder '{}' not found", name).as_str());
364
365            lines.push(DockerfileLine::Empty);
366            lines.append(&mut Stage::generate_dockerfile_lines(builder, context)?);
367            context.pop_state();
368        }
369
370        context.push_state(GenerationContextState {
371            user: Some(Some(User::new("1000"))),
372            stage_name: Some("runtime".into()),
373            default_from: Some(FromContext::default()),
374        });
375        lines.push(DockerfileLine::Empty);
376        lines.append(&mut self.stage.generate_dockerfile_lines(context)?);
377        context.pop_state();
378
379        self.volume.iter().for_each(|volume| {
380            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
381                command: "VOLUME".into(),
382                content: volume.clone(),
383                options: vec![],
384            }))
385        });
386
387        self.expose.iter().for_each(|port| {
388            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
389                command: "EXPOSE".into(),
390                content: port.to_string(),
391                options: vec![],
392            }))
393        });
394        if let Some(healthcheck) = &self.healthcheck {
395            let mut options = vec![];
396            if let Some(interval) = &healthcheck.interval {
397                options.push(InstructionOption::WithValue(
398                    "interval".into(),
399                    interval.into(),
400                ));
401            }
402            if let Some(timeout) = &healthcheck.timeout {
403                options.push(InstructionOption::WithValue(
404                    "timeout".into(),
405                    timeout.into(),
406                ));
407            }
408            if let Some(start_period) = &healthcheck.start {
409                options.push(InstructionOption::WithValue(
410                    "start-period".into(),
411                    start_period.into(),
412                ));
413            }
414            if let Some(retries) = &healthcheck.retries {
415                options.push(InstructionOption::WithValue(
416                    "retries".into(),
417                    retries.to_string(),
418                ));
419            }
420            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
421                command: "HEALTHCHECK".into(),
422                content: format!("CMD {}", healthcheck.cmd.clone()),
423                options,
424            }))
425        }
426        if !self.entrypoint.is_empty() {
427            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
428                command: "ENTRYPOINT".into(),
429                content: string_vec_into(self.entrypoint.to_vec()),
430                options: vec![],
431            }))
432        }
433        if !self.cmd.is_empty() {
434            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
435                command: "CMD".into(),
436                content: string_vec_into(self.cmd.to_vec()),
437                options: vec![],
438            }))
439        }
440        Ok(lines)
441    }
442}
443
444impl DockerfileGenerator for Stage {
445    fn generate_dockerfile_lines(
446        &self,
447        context: &mut GenerationContext,
448    ) -> Result<Vec<DockerfileLine>> {
449        context.push_state(GenerationContextState {
450            user: Some(self.user(context)),
451            ..Default::default()
452        });
453        let stage_name = context.stage_name.clone();
454
455        // From
456        let mut lines = vec![
457            DockerfileLine::Comment(stage_name.clone()),
458            DockerfileLine::Instruction(DockerfileInsctruction {
459                command: "FROM".into(),
460                content: format!(
461                    "{image_name} AS {stage_name}",
462                    image_name = self.from(context).to_string()
463                ),
464                options: match &self.from {
465                    FromContext::FromImage(ImageName {
466                        platform: Some(platform),
467                        ..
468                    }) => {
469                        vec![InstructionOption::WithValue(
470                            "platform".into(),
471                            platform.clone(),
472                        )]
473                    }
474                    _ => vec![],
475                },
476            }),
477        ];
478
479        // Arg
480        if !self.arg.is_empty() {
481            let args = generate_arg_command(&self.arg);
482            lines.extend(args);
483        }
484
485        // Label
486        if !self.label.is_empty() {
487            let mut keys = self.label.keys().collect::<Vec<&String>>();
488            keys.sort();
489            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
490                command: "LABEL".into(),
491                content: keys
492                    .iter()
493                    .map(|&key| {
494                        format!(
495                            "{}=\"{}\"",
496                            key,
497                            self.label.get(key).unwrap().replace("\n", "\\\n")
498                        )
499                    })
500                    .collect::<Vec<String>>()
501                    .join(LINE_SEPARATOR),
502                options: vec![],
503            }));
504        }
505
506        // Env
507        if !self.env.is_empty() {
508            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
509                command: "ENV".into(),
510                content: self
511                    .env
512                    .iter()
513                    .map(|(key, value)| format!("{}=\"{}\"", key, value))
514                    .collect::<Vec<String>>()
515                    .join(LINE_SEPARATOR),
516                options: vec![],
517            }));
518        }
519
520        // Workdir
521        if let Some(workdir) = &self.workdir {
522            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
523                command: "WORKDIR".into(),
524                content: workdir.clone(),
525                options: vec![],
526            }));
527        }
528
529        // Copy resources
530        for copy in self.copy.iter() {
531            lines.append(&mut copy.generate_dockerfile_lines(context)?);
532        }
533
534        // Root
535        if let Some(root) = &self.root {
536            if !root.is_empty() {
537                let root_user = User::new("0");
538                // User
539                lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
540                    command: "USER".into(),
541                    content: root_user.to_string(),
542                    options: vec![],
543                }));
544
545                context.push_state(GenerationContextState {
546                    user: Some(Some(root_user)),
547                    ..Default::default()
548                });
549                // Run
550                lines.append(&mut root.generate_dockerfile_lines(context)?);
551                context.pop_state();
552            }
553        }
554
555        // User
556        if let Some(user) = self.user(context) {
557            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
558                command: "USER".into(),
559                content: user.to_string(),
560                options: vec![],
561            }));
562        }
563
564        // Run
565        lines.append(&mut self.run.generate_dockerfile_lines(context)?);
566
567        context.pop_state();
568
569        Ok(lines)
570    }
571}
572
573impl DockerfileGenerator for Run {
574    fn generate_dockerfile_lines(
575        &self,
576        context: &mut GenerationContext,
577    ) -> Result<Vec<DockerfileLine>> {
578        let script = &self.run;
579        if script.is_empty() {
580            return Ok(vec![]);
581        }
582        let script_lines = script
583            .iter()
584            .flat_map(|command| command.lines())
585            .collect::<Vec<&str>>();
586        let content = match script_lines.len() {
587            0 => {
588                return Ok(vec![]);
589            }
590            1 => script_lines[0].into(),
591            _ => format!("<<EOF\n{}\nEOF", script_lines.join("\n")),
592        };
593        let mut options = vec![];
594
595        // Mount binds
596        self.bind.iter().for_each(|bind| {
597            let mut bind_options = vec![
598                InstructionOptionOption::new("type", "bind".into()),
599                InstructionOptionOption::new("target", bind.target.clone()),
600            ];
601            let from = match &bind.from {
602                FromContext::FromImage(image) => Some(image.to_string()),
603                FromContext::FromBuilder(builder) => Some(builder.clone()),
604                FromContext::FromContext(context) => context.clone(),
605            };
606            if let Some(from) = from {
607                bind_options.push(InstructionOptionOption::new("from", from));
608            }
609            if let Some(source) = bind.source.as_ref() {
610                bind_options.push(InstructionOptionOption::new("source", source.clone()));
611            }
612            if bind.readwrite.unwrap_or(false) {
613                bind_options.push(InstructionOptionOption::new_flag("readwrite"));
614            }
615            options.push(InstructionOption::WithOptions("mount".into(), bind_options));
616        });
617
618        // Mount caches
619        for cache in self.cache.iter() {
620            let target = cache.target.clone();
621
622            let mut cache_options = vec![
623                InstructionOptionOption::new("type", "cache".into()),
624                InstructionOptionOption::new("target", target),
625            ];
626            if let Some(id) = cache.id.as_ref() {
627                cache_options.push(InstructionOptionOption::new("id", id.clone()));
628            }
629            let from = match &cache.from {
630                FromContext::FromImage(image) => Some(image.to_string()),
631                FromContext::FromBuilder(builder) => Some(builder.clone()),
632                FromContext::FromContext(context) => context.clone(),
633            };
634            if let Some(from) = from {
635                cache_options.push(InstructionOptionOption::new("from", from));
636                if let Some(source) = cache.source.as_ref() {
637                    cache_options.push(InstructionOptionOption::new("source", source.clone()));
638                }
639            }
640            if let Some(user) = cache.chown.as_ref().or(context.user.as_ref()) {
641                if let Some(uid) = user.uid() {
642                    cache_options.push(InstructionOptionOption::new("uid", uid.to_string()));
643                }
644                if let Some(gid) = user.gid() {
645                    cache_options.push(InstructionOptionOption::new("gid", gid.to_string()));
646                }
647            }
648            if let Some(chmod) = cache.chmod.as_ref() {
649                cache_options.push(InstructionOptionOption::new("chmod", chmod.clone()));
650            }
651            cache_options.push(InstructionOptionOption::new(
652                "sharing",
653                cache.sharing.clone().unwrap_or_default().to_string(),
654            ));
655            if cache.readonly.unwrap_or(false) {
656                cache_options.push(InstructionOptionOption::new_flag("readonly"));
657            }
658
659            options.push(InstructionOption::WithOptions(
660                "mount".into(),
661                cache_options,
662            ));
663        }
664
665        // Mount tmpfs
666        self.tmpfs.iter().for_each(|mount| {
667            let mut mount_options = vec![
668                InstructionOptionOption::new("type", "tmpfs".into()),
669                InstructionOptionOption::new("target", mount.target.clone()),
670            ];
671            if let Some(size) = mount.size.as_ref() {
672                mount_options.push(InstructionOptionOption::new("size", size.clone()));
673            }
674            options.push(InstructionOption::WithOptions(
675                "mount".into(),
676                mount_options,
677            ));
678        });
679
680        // Mount secrets
681        self.secret.iter().for_each(|mount| {
682            let mut mount_options = vec![InstructionOptionOption::new("type", "secret".into())];
683            if let Some(id) = mount.id.as_ref() {
684                mount_options.push(InstructionOptionOption::new("id", id.clone()));
685            }
686            if let Some(target) = mount.target.as_ref() {
687                mount_options.push(InstructionOptionOption::new("target", target.clone()));
688            }
689            if let Some(env) = mount.env.as_ref() {
690                mount_options.push(InstructionOptionOption::new("env", env.clone()));
691            }
692            if let Some(required) = mount.required.as_ref() {
693                mount_options.push(InstructionOptionOption::new(
694                    "required",
695                    required.to_string(),
696                ));
697            }
698            if let Some(mode) = mount.mode.as_ref() {
699                mount_options.push(InstructionOptionOption::new("mode", mode.to_string()));
700            }
701            if let Some(uid) = mount.uid.as_ref() {
702                mount_options.push(InstructionOptionOption::new("uid", uid.to_string()));
703            }
704            if let Some(gid) = mount.gid.as_ref() {
705                mount_options.push(InstructionOptionOption::new("gid", gid.to_string()));
706            }
707
708            options.push(InstructionOption::WithOptions(
709                "mount".into(),
710                mount_options,
711            ));
712        });
713
714        // Mount ssh
715        self.ssh.iter().for_each(|mount| {
716            let mut mount_options = vec![InstructionOptionOption::new("type", "ssh".into())];
717            if let Some(id) = mount.id.as_ref() {
718                mount_options.push(InstructionOptionOption::new("id", id.clone()));
719            }
720            if let Some(target) = mount.target.as_ref() {
721                mount_options.push(InstructionOptionOption::new("target", target.clone()));
722            }
723            if let Some(required) = mount.required.as_ref() {
724                mount_options.push(InstructionOptionOption::new(
725                    "required",
726                    required.to_string(),
727                ));
728            }
729            if let Some(mode) = mount.mode.as_ref() {
730                mount_options.push(InstructionOptionOption::new("mode", mode.to_string()));
731            }
732            if let Some(uid) = mount.uid.as_ref() {
733                mount_options.push(InstructionOptionOption::new("uid", uid.to_string()));
734            }
735            if let Some(gid) = mount.gid.as_ref() {
736                mount_options.push(InstructionOptionOption::new("gid", gid.to_string()));
737            }
738            options.push(InstructionOption::WithOptions(
739                "mount".into(),
740                mount_options,
741            ));
742        });
743
744        let mut lines = vec![];
745
746        // Shell
747        if !self.shell.is_empty() {
748            lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
749                command: "SHELL".into(),
750                content: string_vec_into(self.shell.to_vec()),
751                options: vec![],
752            }));
753        }
754
755        if let Some(network) = &self.network {
756            options.push(InstructionOption::WithValue(
757                "network".into(),
758                network.to_string(),
759            ));
760        }
761
762        if let Some(security) = &self.security {
763            options.push(InstructionOption::WithValue(
764                "security".into(),
765                security.to_string(),
766            ));
767        }
768
769        lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
770            command: "RUN".into(),
771            content,
772            options,
773        }));
774
775        Ok(lines)
776    }
777}
778
779impl DockerfileGenerator for CopyResource {
780    fn generate_dockerfile_lines(
781        &self,
782        context: &mut GenerationContext,
783    ) -> Result<Vec<DockerfileLine>> {
784        match self {
785            CopyResource::Copy(copy) => copy.generate_dockerfile_lines(context),
786            CopyResource::Content(content) => content.generate_dockerfile_lines(context),
787            CopyResource::Add(add_web_file) => add_web_file.generate_dockerfile_lines(context),
788            CopyResource::AddGitRepo(add_git_repo) => {
789                add_git_repo.generate_dockerfile_lines(context)
790            }
791        }
792    }
793}
794
795fn add_copy_options(
796    inst_options: &mut Vec<InstructionOption>,
797    copy_options: &CopyOptions,
798    context: &GenerationContext,
799) {
800    if let Some(chown) = copy_options.chown.as_ref().or(context.user.as_ref().into()) {
801        inst_options.push(InstructionOption::WithValue("chown".into(), chown.into()));
802    }
803    if let Some(chmod) = &copy_options.chmod {
804        inst_options.push(InstructionOption::WithValue("chmod".into(), chmod.into()));
805    }
806    if *copy_options.link.as_ref().unwrap_or(&true) {
807        inst_options.push(InstructionOption::Flag("link".into()));
808    }
809}
810
811impl DockerfileGenerator for Copy {
812    fn generate_dockerfile_lines(
813        &self,
814        context: &mut GenerationContext,
815    ) -> Result<Vec<DockerfileLine>> {
816        let mut options: Vec<InstructionOption> = vec![];
817
818        let from = match &self.from {
819            FromContext::FromImage(image) => Some(image.to_string()),
820            FromContext::FromBuilder(builder) => Some(builder.clone()),
821            FromContext::FromContext(context) => context.clone(),
822        };
823        if let Some(from) = from {
824            options.push(InstructionOption::WithValue("from".into(), from));
825        }
826        add_copy_options(&mut options, &self.options, context);
827
828        for path in self.exclude.iter() {
829            options.push(InstructionOption::WithValue("exclude".into(), path.clone()));
830        }
831
832        if self.parents.unwrap_or(false) {
833            options.push(InstructionOption::Flag("parents".into()));
834        }
835
836        Ok(vec![DockerfileLine::Instruction(DockerfileInsctruction {
837            command: "COPY".into(),
838            content: copy_paths_into(self.paths.to_vec(), &self.options.target),
839            options,
840        })])
841    }
842}
843
844impl DockerfileGenerator for CopyContent {
845    fn generate_dockerfile_lines(
846        &self,
847        context: &mut GenerationContext,
848    ) -> Result<Vec<DockerfileLine>> {
849        let mut options: Vec<InstructionOption> = vec![];
850
851        add_copy_options(&mut options, &self.options, context);
852
853        let mut start_delimiter = "EOF".to_string();
854        if !self.substitute.clone().unwrap_or(true) {
855            start_delimiter = format!("\"{start_delimiter}\"");
856        }
857        let target = self.options.target.clone().ok_or(Error::Custom(
858            "The target file must be defined when coying content".into(),
859        ))?;
860        let content = format!(
861            "<<{start_delimiter} {target}\n{}\nEOF",
862            self.content.clone()
863        );
864
865        Ok(vec![DockerfileLine::Instruction(DockerfileInsctruction {
866            command: "COPY".into(),
867            content,
868            options,
869        })])
870    }
871}
872
873impl DockerfileGenerator for Add {
874    fn generate_dockerfile_lines(
875        &self,
876        context: &mut GenerationContext,
877    ) -> Result<Vec<DockerfileLine>> {
878        let mut options: Vec<InstructionOption> = vec![];
879        if let Some(checksum) = &self.checksum {
880            options.push(InstructionOption::WithValue(
881                "checksum".into(),
882                checksum.into(),
883            ));
884        }
885        if let Some(unpack) = &self.unpack {
886            options.push(InstructionOption::WithValue(
887                "unpack".into(),
888                unpack.to_string(),
889            ));
890        }
891        add_copy_options(&mut options, &self.options, context);
892
893        Ok(vec![DockerfileLine::Instruction(DockerfileInsctruction {
894            command: "ADD".into(),
895            content: copy_paths_into(
896                self.files
897                    .iter()
898                    .map(|file| file.to_string())
899                    .collect::<Vec<String>>(),
900                &self.options.target,
901            ),
902            options,
903        })])
904    }
905}
906
907impl DockerfileGenerator for AddGitRepo {
908    fn generate_dockerfile_lines(
909        &self,
910        context: &mut GenerationContext,
911    ) -> Result<Vec<DockerfileLine>> {
912        let mut options: Vec<InstructionOption> = vec![];
913        add_copy_options(&mut options, &self.options, context);
914
915        for path in self.exclude.iter() {
916            options.push(InstructionOption::WithValue("exclude".into(), path.clone()));
917        }
918        if let Some(keep_git_dir) = &self.keep_git_dir {
919            options.push(InstructionOption::WithValue(
920                "keep-git-dir".into(),
921                keep_git_dir.to_string(),
922            ));
923        }
924        if let Some(checksum) = &self.checksum {
925            options.push(InstructionOption::WithValue(
926                "checksum".into(),
927                checksum.into(),
928            ));
929        }
930
931        Ok(vec![DockerfileLine::Instruction(DockerfileInsctruction {
932            command: "ADD".into(),
933            content: copy_paths_into(vec![self.repo.clone()], &self.options.target),
934            options,
935        })])
936    }
937}
938
939fn copy_paths_into(paths: Vec<String>, target: &Option<String>) -> String {
940    let mut parts = paths.clone();
941    parts.push(target.clone().unwrap_or("./".into()));
942    parts
943        .iter()
944        .map(|p| format!("\"{}\"", p))
945        .collect::<Vec<String>>()
946        .join(" ")
947}
948
949fn string_vec_into(string_vec: Vec<String>) -> String {
950    format!(
951        "[{}]",
952        string_vec
953            .iter()
954            .map(|s| format!("\"{}\"", s))
955            .collect::<Vec<String>>()
956            .join(", ")
957    )
958}
959
960fn generate_arg_command(arg: &HashMap<String, String>) -> Vec<DockerfileLine> {
961    let mut lines = vec![];
962    let mut keys = arg.keys().collect::<Vec<&String>>();
963    keys.sort();
964    keys.iter().for_each(|key| {
965        let value = arg.get(*key).unwrap();
966        lines.push(DockerfileLine::Instruction(DockerfileInsctruction {
967            command: "ARG".into(),
968            content: if value.is_empty() {
969                key.to_string()
970            } else {
971                format!("{}={}", key, value)
972            },
973            options: vec![],
974        }));
975    });
976    lines
977}
978
979#[cfg(test)]
980mod test {
981    use super::*;
982    use pretty_assertions_sorted::assert_eq_sorted;
983
984    mod stage {
985        use std::collections::HashMap;
986
987        use super::*;
988
989        #[test]
990        fn user_with_user() {
991            let stage = Stage {
992                user: Some(User::new_without_group("my-user").into()),
993                ..Default::default()
994            };
995            let user = stage.user(&GenerationContext::default());
996            assert_eq_sorted!(
997                user,
998                Some(User {
999                    user: "my-user".into(),
1000                    group: None,
1001                })
1002            );
1003        }
1004
1005        #[test]
1006        fn user_without_user() {
1007            let stage = Stage::default();
1008            let user = stage.user(&GenerationContext::default());
1009            assert_eq_sorted!(user, None);
1010        }
1011
1012        #[test]
1013        fn stage_args() {
1014            let stage = Stage {
1015                arg: HashMap::from([("arg2".into(), "".into()), ("arg1".into(), "value1".into())]),
1016                ..Default::default()
1017            };
1018
1019            let lines = stage.generate_dockerfile_lines(&mut GenerationContext {
1020                stage_name: "test".into(),
1021                ..Default::default()
1022            });
1023
1024            assert_eq_sorted!(
1025                lines.unwrap(),
1026                vec![
1027                    DockerfileLine::Comment("test".into()),
1028                    DockerfileLine::Instruction(DockerfileInsctruction {
1029                        command: "FROM".into(),
1030                        content: "scratch AS test".into(),
1031                        options: vec![],
1032                    }),
1033                    DockerfileLine::Instruction(DockerfileInsctruction {
1034                        command: "ARG".into(),
1035                        content: "arg1=value1".into(),
1036                        options: vec![],
1037                    }),
1038                    DockerfileLine::Instruction(DockerfileInsctruction {
1039                        command: "ARG".into(),
1040                        content: "arg2".into(),
1041                        options: vec![],
1042                    }),
1043                ]
1044            );
1045        }
1046    }
1047
1048    mod copy {
1049        use super::*;
1050
1051        #[test]
1052        fn with_chmod() {
1053            let copy = Copy {
1054                paths: vec!["/path/to/file".into()],
1055                options: CopyOptions {
1056                    target: Some("/app/".into()),
1057                    chmod: Some("755".into()),
1058                    ..Default::default()
1059                },
1060                ..Default::default()
1061            };
1062
1063            let lines = copy
1064                .generate_dockerfile_lines(&mut GenerationContext::default())
1065                .unwrap();
1066
1067            assert_eq_sorted!(
1068                lines,
1069                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1070                    command: "COPY".into(),
1071                    content: "\"/path/to/file\" \"/app/\"".into(),
1072                    options: vec![
1073                        InstructionOption::WithValue("chmod".into(), "755".into()),
1074                        InstructionOption::Flag("link".into())
1075                    ],
1076                })]
1077            );
1078        }
1079
1080        #[test]
1081        fn from_content() {
1082            let copy = CopyContent {
1083                content: "echo hello".into(),
1084                options: CopyOptions {
1085                    target: Some("test.sh".into()),
1086                    ..Default::default()
1087                },
1088                ..Default::default()
1089            };
1090
1091            let lines = copy
1092                .generate_dockerfile_lines(&mut GenerationContext::default())
1093                .unwrap();
1094
1095            assert_eq_sorted!(
1096                lines,
1097                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1098                    command: "COPY".into(),
1099                    content: "<<EOF test.sh\necho hello\nEOF".into(),
1100                    options: vec![InstructionOption::Flag("link".into())],
1101                })]
1102            );
1103        }
1104    }
1105
1106    mod image_name {
1107        use super::*;
1108
1109        #[test]
1110        fn user_with_user() {
1111            let dofigen = Dofigen {
1112                stage: Stage {
1113                    user: Some(User::new_without_group("my-user").into()),
1114                    from: FromContext::FromImage(ImageName {
1115                        path: String::from("my-image"),
1116                        ..Default::default()
1117                    }),
1118                    ..Default::default()
1119                },
1120                ..Default::default()
1121            };
1122            let user = dofigen.stage.user(&GenerationContext {
1123                user: Some(User::new("1000")),
1124                ..Default::default()
1125            });
1126            assert_eq_sorted!(
1127                user,
1128                Some(User {
1129                    user: String::from("my-user"),
1130                    group: None,
1131                })
1132            );
1133        }
1134
1135        #[test]
1136        fn user_without_user() {
1137            let dofigen = Dofigen {
1138                stage: Stage {
1139                    from: FromContext::FromImage(ImageName {
1140                        path: String::from("my-image"),
1141                        ..Default::default()
1142                    }),
1143                    ..Default::default()
1144                },
1145                ..Default::default()
1146            };
1147            let user = dofigen.stage.user(&GenerationContext {
1148                user: Some(User::new("1000")),
1149                ..Default::default()
1150            });
1151            assert_eq_sorted!(
1152                user,
1153                Some(User {
1154                    user: String::from("1000"),
1155                    group: Some(String::from("1000")),
1156                })
1157            );
1158        }
1159
1160        #[test]
1161        fn with_platform() {
1162            let stage = Stage {
1163                from: FromContext::FromImage(ImageName {
1164                    path: String::from("alpine"),
1165                    platform: Some("linux/amd64".into()),
1166                    ..Default::default()
1167                }),
1168                ..Default::default()
1169            };
1170            assert_eq_sorted!(
1171                stage
1172                    .generate_dockerfile_lines(&mut GenerationContext {
1173                        stage_name: "runtime".into(),
1174                        ..Default::default()
1175                    })
1176                    .unwrap(),
1177                vec![
1178                    DockerfileLine::Comment("runtime".into()),
1179                    DockerfileLine::Instruction(DockerfileInsctruction {
1180                        command: "FROM".into(),
1181                        content: "alpine AS runtime".into(),
1182                        options: vec![InstructionOption::WithValue(
1183                            "platform".into(),
1184                            "linux/amd64".into()
1185                        )],
1186                    })
1187                ]
1188            );
1189        }
1190    }
1191
1192    mod run {
1193        use super::*;
1194
1195        #[test]
1196        fn simple() {
1197            let builder = Run {
1198                run: vec!["echo Hello".into()].into(),
1199                ..Default::default()
1200            };
1201            assert_eq_sorted!(
1202                builder
1203                    .generate_dockerfile_lines(&mut GenerationContext::default())
1204                    .unwrap(),
1205                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1206                    command: "RUN".into(),
1207                    content: "echo Hello".into(),
1208                    options: vec![],
1209                })]
1210            );
1211        }
1212
1213        #[test]
1214        fn without_run() {
1215            let builder = Run {
1216                ..Default::default()
1217            };
1218            assert_eq_sorted!(
1219                builder
1220                    .generate_dockerfile_lines(&mut GenerationContext::default())
1221                    .unwrap(),
1222                vec![]
1223            );
1224        }
1225
1226        #[test]
1227        fn with_empty_run() {
1228            let builder = Run {
1229                run: vec![].into(),
1230                ..Default::default()
1231            };
1232            assert_eq_sorted!(
1233                builder
1234                    .generate_dockerfile_lines(&mut GenerationContext::default())
1235                    .unwrap(),
1236                vec![]
1237            );
1238        }
1239
1240        #[test]
1241        fn with_script_and_caches_with_named_user() {
1242            let builder = Run {
1243                run: vec!["echo Hello".into()].into(),
1244                cache: vec![Cache {
1245                    target: "/path/to/cache".into(),
1246                    readonly: Some(true),
1247                    ..Default::default()
1248                }]
1249                .into(),
1250                ..Default::default()
1251            };
1252            let mut context = GenerationContext {
1253                user: Some(User::new("test")),
1254                ..Default::default()
1255            };
1256            assert_eq_sorted!(
1257                builder.generate_dockerfile_lines(&mut context).unwrap(),
1258                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1259                    command: "RUN".into(),
1260                    content: "echo Hello".into(),
1261                    options: vec![InstructionOption::WithOptions(
1262                        "mount".into(),
1263                        vec![
1264                            InstructionOptionOption::new("type", "cache".into()),
1265                            InstructionOptionOption::new("target", "/path/to/cache".into()),
1266                            InstructionOptionOption::new("sharing", "locked".into()),
1267                            InstructionOptionOption::new_flag("readonly"),
1268                        ],
1269                    )],
1270                })]
1271            );
1272        }
1273
1274        #[test]
1275        fn with_script_and_caches_with_uid_user() {
1276            let builder = Run {
1277                run: vec!["echo Hello".into()].into(),
1278                cache: vec![Cache {
1279                    target: "/path/to/cache".into(),
1280                    ..Default::default()
1281                }],
1282                ..Default::default()
1283            };
1284            let mut context = GenerationContext {
1285                user: Some(User::new("1000")),
1286                ..Default::default()
1287            };
1288            assert_eq_sorted!(
1289                builder.generate_dockerfile_lines(&mut context).unwrap(),
1290                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1291                    command: "RUN".into(),
1292                    content: "echo Hello".into(),
1293                    options: vec![InstructionOption::WithOptions(
1294                        "mount".into(),
1295                        vec![
1296                            InstructionOptionOption::new("type", "cache".into()),
1297                            InstructionOptionOption::new("target", "/path/to/cache".into()),
1298                            InstructionOptionOption::new("uid", "1000".into()),
1299                            InstructionOptionOption::new("gid", "1000".into()),
1300                            InstructionOptionOption::new("sharing", "locked".into()),
1301                        ],
1302                    )],
1303                })]
1304            );
1305        }
1306
1307        #[test]
1308        fn with_script_and_caches_with_uid_user_without_group() {
1309            let builder = Run {
1310                run: vec!["echo Hello".into()].into(),
1311                cache: vec![Cache {
1312                    target: "/path/to/cache".into(),
1313                    ..Default::default()
1314                }],
1315                ..Default::default()
1316            };
1317            let mut context = GenerationContext {
1318                user: Some(User::new_without_group("1000")),
1319                ..Default::default()
1320            };
1321            assert_eq_sorted!(
1322                builder.generate_dockerfile_lines(&mut context).unwrap(),
1323                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1324                    command: "RUN".into(),
1325                    content: "echo Hello".into(),
1326                    options: vec![InstructionOption::WithOptions(
1327                        "mount".into(),
1328                        vec![
1329                            InstructionOptionOption::new("type", "cache".into()),
1330                            InstructionOptionOption::new("target", "/path/to/cache".into()),
1331                            InstructionOptionOption::new("uid", "1000".into()),
1332                            InstructionOptionOption::new("sharing", "locked".into()),
1333                        ],
1334                    )],
1335                })]
1336            );
1337        }
1338
1339        #[test]
1340        fn with_tmpfs() {
1341            let builder = Run {
1342                run: vec!["echo Hello".into()].into(),
1343                tmpfs: vec![TmpFs {
1344                    target: "/path/to/tmpfs".into(),
1345                    ..Default::default()
1346                }],
1347                ..Default::default()
1348            };
1349            let mut context = GenerationContext {
1350                user: Some(User::new_without_group("1000")),
1351                ..Default::default()
1352            };
1353            assert_eq_sorted!(
1354                builder.generate_dockerfile_lines(&mut context).unwrap(),
1355                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1356                    command: "RUN".into(),
1357                    content: "echo Hello".into(),
1358                    options: vec![InstructionOption::WithOptions(
1359                        "mount".into(),
1360                        vec![
1361                            InstructionOptionOption::new("type", "tmpfs".into()),
1362                            InstructionOptionOption::new("target", "/path/to/tmpfs".into()),
1363                        ],
1364                    )],
1365                })]
1366            );
1367        }
1368
1369        #[test]
1370        fn with_secret() {
1371            let builder = Run {
1372                run: vec!["echo Hello".into()].into(),
1373                secret: vec![Secret {
1374                    id: Some("test".into()),
1375                    ..Default::default()
1376                }],
1377                ..Default::default()
1378            };
1379            let mut context = GenerationContext {
1380                user: Some(User::new_without_group("1000")),
1381                ..Default::default()
1382            };
1383            assert_eq_sorted!(
1384                builder.generate_dockerfile_lines(&mut context).unwrap(),
1385                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1386                    command: "RUN".into(),
1387                    content: "echo Hello".into(),
1388                    options: vec![InstructionOption::WithOptions(
1389                        "mount".into(),
1390                        vec![
1391                            InstructionOptionOption::new("type", "secret".into()),
1392                            InstructionOptionOption::new("id", "test".into()),
1393                        ],
1394                    )],
1395                })]
1396            );
1397        }
1398
1399        #[test]
1400        fn with_secret_empty() {
1401            let builder = Run {
1402                run: vec!["echo Hello".into()].into(),
1403                secret: vec![Secret::default()],
1404                ..Default::default()
1405            };
1406            let mut context = GenerationContext {
1407                user: Some(User::new_without_group("1000")),
1408                ..Default::default()
1409            };
1410            assert_eq_sorted!(
1411                builder.generate_dockerfile_lines(&mut context).unwrap(),
1412                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1413                    command: "RUN".into(),
1414                    content: "echo Hello".into(),
1415                    options: vec![InstructionOption::WithOptions(
1416                        "mount".into(),
1417                        vec![InstructionOptionOption::new("type", "secret".into()),],
1418                    )],
1419                })]
1420            );
1421        }
1422
1423        #[test]
1424        fn with_ssh() {
1425            let builder = Run {
1426                run: vec!["echo Hello".into()].into(),
1427                ssh: vec![Ssh {
1428                    id: Some("test".into()),
1429                    ..Default::default()
1430                }],
1431                ..Default::default()
1432            };
1433            let mut context = GenerationContext {
1434                user: Some(User::new_without_group("1000")),
1435                ..Default::default()
1436            };
1437            assert_eq_sorted!(
1438                builder.generate_dockerfile_lines(&mut context).unwrap(),
1439                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1440                    command: "RUN".into(),
1441                    content: "echo Hello".into(),
1442                    options: vec![InstructionOption::WithOptions(
1443                        "mount".into(),
1444                        vec![
1445                            InstructionOptionOption::new("type", "ssh".into()),
1446                            InstructionOptionOption::new("id", "test".into()),
1447                        ],
1448                    )],
1449                })]
1450            );
1451        }
1452
1453        #[test]
1454        fn with_ssh_empty() {
1455            let builder = Run {
1456                run: vec!["echo Hello".into()].into(),
1457                ssh: vec![Ssh::default()],
1458                ..Default::default()
1459            };
1460            let mut context = GenerationContext {
1461                user: Some(User::new_without_group("1000")),
1462                ..Default::default()
1463            };
1464            assert_eq_sorted!(
1465                builder.generate_dockerfile_lines(&mut context).unwrap(),
1466                vec![DockerfileLine::Instruction(DockerfileInsctruction {
1467                    command: "RUN".into(),
1468                    content: "echo Hello".into(),
1469                    options: vec![InstructionOption::WithOptions(
1470                        "mount".into(),
1471                        vec![InstructionOptionOption::new("type", "ssh".into()),],
1472                    )],
1473                })]
1474            );
1475        }
1476    }
1477
1478    mod label {
1479        use std::collections::HashMap;
1480
1481        use crate::{DofigenContext, lock::Lock};
1482
1483        use super::*;
1484
1485        #[test]
1486        fn with_label() {
1487            let stage = Stage {
1488                label: HashMap::from([("key".into(), "value".into())]),
1489                ..Default::default()
1490            };
1491            let lines = stage
1492                .generate_dockerfile_lines(&mut GenerationContext::default())
1493                .unwrap();
1494            assert_eq_sorted!(
1495                lines[2],
1496                DockerfileLine::Instruction(DockerfileInsctruction {
1497                    command: "LABEL".into(),
1498                    content: "key=\"value\"".into(),
1499                    options: vec![],
1500                })
1501            );
1502        }
1503
1504        #[test]
1505        fn with_many_multiline_labels() {
1506            let stage = Stage {
1507                label: HashMap::from([
1508                    ("key1".into(), "value1".into()),
1509                    ("key2".into(), "value2\nligne2".into()),
1510                ]),
1511                ..Default::default()
1512            };
1513            let lines = stage
1514                .generate_dockerfile_lines(&mut GenerationContext::default())
1515                .unwrap();
1516            assert_eq_sorted!(
1517                lines[2],
1518                DockerfileLine::Instruction(DockerfileInsctruction {
1519                    command: "LABEL".into(),
1520                    content: "key1=\"value1\" \\\n    key2=\"value2\\\nligne2\"".into(),
1521                    options: vec![],
1522                })
1523            );
1524        }
1525
1526        #[test]
1527        fn locked_with_many_multiline_labels() {
1528            let dofigen = Dofigen {
1529                stage: Stage {
1530                    label: HashMap::from([
1531                        ("key1".into(), "value1".into()),
1532                        ("key2".into(), "value2\nligne2".into()),
1533                    ]),
1534                    ..Default::default()
1535                },
1536                ..Default::default()
1537            };
1538            let dofigen = dofigen.lock(&mut DofigenContext::new()).unwrap();
1539            let lines = dofigen
1540                .generate_dockerfile_lines(&mut GenerationContext::default())
1541                .unwrap();
1542            assert_eq_sorted!(
1543                lines[4],
1544                DockerfileLine::Instruction(DockerfileInsctruction {
1545                    command: "LABEL".into(),
1546                    content: "io.dofigen.version=\"0.0.0\" \\\n    key1=\"value1\" \\\n    key2=\"value2\\\nligne2\"".into(),
1547                    options: vec![],
1548                })
1549            );
1550        }
1551    }
1552}