modus_lib/
dockerfile.rs

1// Modus, a language for building container images
2// Copyright (C) 2022 University College London
3
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as
6// published by the Free Software Foundation, either version 3 of the
7// License, or (at your option) any later version.
8
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17use std::fmt;
18use std::str;
19
20#[derive(Clone, PartialEq, Debug)]
21pub struct Image {
22    registry: String,
23    namespace: String,
24    repo: String,
25    tag: String,
26}
27
28#[derive(Clone, PartialEq, Debug)]
29pub enum ResolvedParent {
30    Image(Image),
31    Stage(String),
32}
33
34#[derive(Clone, PartialEq, Debug)]
35pub struct UnresolvedParent(String);
36
37#[derive(Clone, PartialEq, Debug)]
38pub struct From<P> {
39    pub parent: P,
40    pub alias: Option<String>,
41}
42
43#[derive(Clone, PartialEq, Debug)]
44pub struct Copy(pub String);
45
46#[derive(Clone, PartialEq, Debug)]
47pub struct Run(pub String);
48
49#[derive(Clone, PartialEq, Debug)]
50pub struct Env(pub String);
51
52#[derive(Clone, PartialEq, Debug)]
53pub struct Arg(pub String);
54
55#[derive(Clone, PartialEq, Debug)]
56pub struct Workdir(pub String);
57
58#[derive(Clone, PartialEq, Debug)]
59pub enum Instruction<P> {
60    From(From<P>),
61    Run(Run),
62    Cmd(String),
63    Label(String, String),
64    // Maintainer(String),
65    // Expose(String),
66    Env(Env),
67    // Add(String),
68    Copy(Copy),
69    Entrypoint(String),
70    // Volume(String),
71    // User(String),
72    Workdir(Workdir),
73    Arg(Arg),
74    // Onbuild(String),
75    // Stopsignal(String),
76    // Healthcheck(String),
77    // Shell(String)
78}
79
80#[derive(Clone, PartialEq, Debug)]
81pub struct Dockerfile<P>(pub Vec<Instruction<P>>);
82
83pub type ResolvedDockerfile = Dockerfile<ResolvedParent>;
84
85impl Image {
86    pub fn from_repo_tag(repo: String, tag: String) -> Image {
87        Image {
88            registry: String::new(),
89            namespace: String::new(),
90            repo,
91            tag,
92        }
93    }
94}
95
96impl fmt::Display for Image {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        if !self.registry.is_empty() {
99            write!(f, "{}/", self.registry)?;
100        }
101        if !self.namespace.is_empty() {
102            write!(f, "{}/", self.namespace)?;
103        }
104        write!(f, "{}", self.repo)?;
105        if self.tag.is_empty() {
106            write!(f, ":latest") // being implicit about tag
107        } else {
108            write!(f, ":{}", self.tag)
109        }
110    }
111}
112
113impl str::FromStr for Image {
114    type Err = String;
115
116    fn from_str(s: &str) -> Result<Self, Self::Err> {
117        match parser::image(s) {
118            Result::Ok(("", o)) => Ok(o),
119            Result::Ok((rem, _)) => Err(format!("Unexpected {}", rem)),
120            Result::Err(e) => Result::Err(format!("{}", e)),
121        }
122    }
123}
124
125impl<P> fmt::Display for From<P>
126where
127    P: fmt::Display,
128{
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match &self.alias {
131            Some(a) => write!(f, "{} AS {}", self.parent, a),
132            None => write!(f, "{}", self.parent),
133        }
134    }
135}
136
137impl fmt::Display for ResolvedParent {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match &self {
140            ResolvedParent::Image(i) => write!(f, "{}", i),
141            ResolvedParent::Stage(s) => write!(f, "{}", s),
142        }
143    }
144}
145
146impl fmt::Display for UnresolvedParent {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        write!(f, "{}", self.0)
149    }
150}
151
152impl fmt::Display for Run {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        write!(f, "{}", self.0)
155    }
156}
157
158impl fmt::Display for Copy {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(f, "{}", self.0)
161    }
162}
163
164impl fmt::Display for Env {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        write!(f, "{}", self.0)
167    }
168}
169
170impl fmt::Display for Arg {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "{}", self.0)
173    }
174}
175
176impl fmt::Display for Workdir {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        write!(f, "{}", self.0)
179    }
180}
181
182impl str::FromStr for Dockerfile<UnresolvedParent> {
183    type Err = String;
184
185    fn from_str(s: &str) -> Result<Self, Self::Err> {
186        match parser::dockerfile(s) {
187            Result::Ok((_, o)) => Ok(o),
188            Result::Err(e) => Result::Err(format!("{}", e)),
189        }
190    }
191}
192
193impl<T> fmt::Display for Dockerfile<T>
194where
195    T: fmt::Display,
196{
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        for i in self.0.iter() {
199            match i {
200                Instruction::Arg(s) => writeln!(f, "ARG {}", s),
201                Instruction::Copy(s) => writeln!(f, "COPY {}", s),
202                Instruction::From(image) => writeln!(f, "\nFROM {}", image),
203                Instruction::Run(s) => writeln!(f, "RUN {}", s),
204                Instruction::Env(s) => writeln!(f, "ENV {}", s),
205                Instruction::Workdir(s) => writeln!(f, "WORKDIR {}", s),
206                Instruction::Entrypoint(s) => writeln!(f, "ENTRYPOINT {}", s),
207                Instruction::Cmd(s) => writeln!(f, "CMD {}", s),
208                Instruction::Label(k, v) => writeln!(f, "LABEL {:?}={:?}", k, v),
209            }?;
210        }
211        Ok(())
212    }
213}
214
215pub mod parser {
216    use crate::logic::parser::{IResult, Span};
217
218    use super::*;
219
220    use nom::{
221        branch::alt,
222        bytes::complete::{is_not, tag_no_case},
223        character::complete::{alpha1, alphanumeric1, char, line_ending, one_of, space0, space1},
224        combinator::{eof, map, opt, peek, recognize, value},
225        multi::{many0, many1, separated_list1},
226        sequence::{delimited, pair, preceded, terminated, tuple},
227    };
228    use nom_supreme::tag::complete::tag;
229
230    //TODO: need to double-check
231    pub fn alias_identifier(i: &str) -> IResult<&str, &str> {
232        recognize(pair(
233            alpha1,
234            many0(alt((alphanumeric1, tag("_"), tag("-")))),
235        ))(i)
236    }
237
238    //TODO: need to double-check
239    pub fn repo_identifier(i: &str) -> IResult<&str, &str> {
240        recognize(pair(
241            alt((alphanumeric1, tag("_"))),
242            many0(alt((alphanumeric1, tag("_"), tag("-"), tag("/")))),
243        ))(i)
244    }
245
246    //TODO: need to double-check
247    pub fn tag_identifier(i: &str) -> IResult<&str, &str> {
248        recognize(many0(alt((alphanumeric1, tag("_"), tag("-"), tag(".")))))(i)
249    }
250
251    fn line_continuation(i: &str) -> IResult<&str, ()> {
252        value(
253            (), // Output is thrown away.
254            tuple((char('\\'), space0, line_ending)),
255        )(i)
256    }
257
258    fn empty_line(i: &str) -> IResult<&str, ()> {
259        value(
260            (), // Output is thrown away.
261            alt((preceded(space0, line_ending), preceded(space1, peek(eof)))),
262        )(i)
263    }
264
265    fn empty_line_for_span(i: Span) -> IResult<Span, ()> {
266        value(
267            (), // Output is thrown away.
268            alt((preceded(space0, line_ending), preceded(space1, peek(eof)))),
269        )(i)
270    }
271
272    pub fn ignored_line(i: &str) -> IResult<&str, ()> {
273        value(
274            (), // Output is thrown away.
275            alt((comment_line, empty_line)),
276        )(i)
277    }
278
279    pub fn ignored_line_for_span(i: Span) -> IResult<Span, ()> {
280        value(
281            (), // Output is thrown away.
282            alt((comment_line_for_span, empty_line_for_span)),
283        )(i)
284    }
285
286    // one line continuation followed by an arbitrary number of comments
287    fn continuation_with_comments(i: &str) -> IResult<&str, ()> {
288        value(
289            (), // Output is thrown away.
290            terminated(line_continuation, many0(comment_line)),
291        )(i)
292    }
293
294    fn space(i: &str) -> IResult<&str, ()> {
295        value(
296            (), // Output is thrown away.
297            one_of(" \t"),
298        )(i)
299    }
300
301    fn optional_space(i: &str) -> IResult<&str, ()> {
302        value(
303            (), // Output is thrown away.
304            many0(alt((space, continuation_with_comments))),
305        )(i)
306    }
307
308    pub fn mandatory_space(i: &str) -> IResult<&str, ()> {
309        value(
310            (), // Output is thrown away.
311            delimited(many0(continuation_with_comments), space, optional_space),
312        )(i)
313    }
314
315    fn comment(i: &str) -> IResult<&str, &str> {
316        preceded(char('#'), is_not("\n\r"))(i)
317    }
318
319    fn comment_for_span(i: Span) -> IResult<Span, Span> {
320        preceded(char('#'), is_not("\n\r"))(i)
321    }
322
323    fn comment_line(i: &str) -> IResult<&str, ()> {
324        value(
325            (), // Output is thrown away.
326            delimited(space0, comment, alt((line_ending, peek(eof)))),
327        )(i)
328    }
329
330    fn comment_line_for_span(i: Span) -> IResult<Span, ()> {
331        value(
332            (), // Output is thrown away.
333            delimited(space0, comment_for_span, alt((line_ending, peek(eof)))),
334        )(i)
335    }
336
337    //TODO: I need to test alias parsing
338
339    fn parent(i: &str) -> IResult<&str, UnresolvedParent> {
340        map(recognize(image), |s| UnresolvedParent(s.into()))(i)
341    }
342
343    fn from_content(i: &str) -> IResult<&str, From<UnresolvedParent>> {
344        map(
345            pair(
346                parent,
347                opt(map(
348                    preceded(
349                        delimited(optional_space, tag("AS"), space),
350                        alias_identifier,
351                    ),
352                    String::from,
353                )),
354            ),
355            |(parent, alias)| From { parent, alias },
356        )(i)
357    }
358
359    pub fn multiline_string(i: &str) -> IResult<&str, String> {
360        let one_line = map(
361            many1(alt((
362                is_not("\\\n\r"),
363                recognize(tuple((char('\\'), many0(space), is_not(" \n\r")))),
364            ))),
365            |s| s.join(""),
366        );
367        let body = map(separated_list1(many1(line_continuation), one_line), |s| {
368            s.join("")
369        });
370        preceded(many0(line_continuation), body)(i)
371    }
372
373    pub fn from_instr(i: &str) -> IResult<&str, From<UnresolvedParent>> {
374        preceded(pair(tag_no_case("FROM"), mandatory_space), from_content)(i)
375    }
376
377    pub fn env_instr(i: &str) -> IResult<&str, Env> {
378        let body = map(multiline_string, Env);
379        preceded(pair(tag_no_case("ENV"), mandatory_space), body)(i)
380    }
381
382    pub fn copy_instr(i: &str) -> IResult<&str, Copy> {
383        let body = map(multiline_string, Copy);
384        preceded(pair(tag_no_case("COPY"), mandatory_space), body)(i)
385    }
386
387    pub fn arg_instr(i: &str) -> IResult<&str, Arg> {
388        let body = map(multiline_string, Arg);
389        preceded(pair(tag_no_case("ARG"), mandatory_space), body)(i)
390    }
391
392    pub fn run_instr(i: &str) -> IResult<&str, Run> {
393        let body = map(multiline_string, Run);
394        preceded(pair(tag_no_case("RUN"), mandatory_space), body)(i)
395    }
396
397    pub fn workdir_instr(i: &str) -> IResult<&str, Workdir> {
398        let body = map(multiline_string, Workdir);
399        preceded(pair(tag_no_case("WORKDIR"), mandatory_space), body)(i)
400    }
401
402    fn docker_instruction(i: &str) -> IResult<&str, Instruction<UnresolvedParent>> {
403        alt((
404            map(
405                terminated(from_instr, alt((line_ending, peek(eof)))),
406                Instruction::From,
407            ),
408            map(
409                terminated(copy_instr, alt((line_ending, peek(eof)))),
410                Instruction::Copy,
411            ),
412            map(
413                terminated(arg_instr, alt((line_ending, peek(eof)))),
414                Instruction::Arg,
415            ),
416            map(
417                terminated(run_instr, alt((line_ending, peek(eof)))),
418                Instruction::Run,
419            ),
420            map(
421                terminated(env_instr, alt((line_ending, peek(eof)))),
422                Instruction::Env,
423            ),
424            map(
425                terminated(workdir_instr, alt((line_ending, peek(eof)))),
426                Instruction::Workdir,
427            ),
428        ))(i)
429    }
430
431    pub fn dockerfile(i: &str) -> IResult<&str, Dockerfile<UnresolvedParent>> {
432        map(
433            terminated(
434                many0(preceded(many0(ignored_line), docker_instruction)),
435                terminated(many0(ignored_line), eof),
436            ),
437            Dockerfile,
438        )(i)
439    }
440
441    pub fn host_identifier(i: &str) -> IResult<&str, &str> {
442        recognize(delimited(
443            many0(alt((alphanumeric1, tag("_"), tag("-")))),
444            tag("."), //needs to be at least one dot
445            many0(alt((alphanumeric1, tag("_"), tag("-"), tag(".")))),
446        ))(i)
447    }
448
449    //FIXME: this is very approximate
450    pub fn image(i: &str) -> IResult<&str, Image> {
451        map(
452            pair(
453                opt(terminated(host_identifier, tag("/"))),
454                pair(
455                    opt(recognize(many1(terminated(
456                        many0(alt((alphanumeric1, tag("_"), tag("-")))),
457                        tag("/"),
458                    )))),
459                    pair(repo_identifier, opt(preceded(tag(":"), tag_identifier))),
460                ),
461            ),
462            |(registry, (namespace, (repo, tag)))| Image {
463                registry: registry.unwrap_or("").into(),
464                namespace: match namespace {
465                    Some(s) => {
466                        let mut n = s.to_string();
467                        n.pop();
468                        n
469                    }
470                    None => String::new(),
471                },
472                repo: repo.into(),
473                tag: tag.unwrap_or("").into(),
474            },
475        )(i)
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn from_ubuntu_latest() -> From<UnresolvedParent> {
484        From {
485            parent: UnresolvedParent("ubuntu".into()),
486            alias: None,
487        }
488    }
489
490    fn from_ubuntu_20_04() -> From<UnresolvedParent> {
491        From {
492            parent: UnresolvedParent("ubuntu:20.04".into()),
493            alias: None,
494        }
495    }
496
497    #[test]
498    fn only_simple_from() {
499        let f = Instruction::From(from_ubuntu_latest());
500        let e = Dockerfile(vec![f]);
501        assert_eq!(Ok(e), "FROM ubuntu\n".parse());
502    }
503
504    #[test]
505    fn only_from_with_tag() {
506        let f = Instruction::From(from_ubuntu_20_04());
507        let e = Dockerfile(vec![f]);
508        assert_eq!(Ok(e), "FROM ubuntu:20.04\n".parse());
509    }
510
511    #[test]
512    fn two_instructions() {
513        let f = Instruction::From(from_ubuntu_latest());
514        let r = Instruction::Run(Run("ls".into()));
515        let e = Dockerfile(vec![f, r]);
516        assert_eq!(Ok(e), "FROM ubuntu\nRUN ls\n".parse());
517    }
518
519    #[test]
520    fn no_newline() {
521        let f = Instruction::From(from_ubuntu_latest());
522        let r = Instruction::Run(Run("ls".into()));
523        let e = Dockerfile(vec![f, r]);
524        assert_eq!(Ok(e), "FROM ubuntu\nRUN ls".parse());
525    }
526
527    #[test]
528    fn empty_line() {
529        let f = Instruction::From(from_ubuntu_latest());
530        let r = Instruction::Run(Run("ls".into()));
531        let e = Dockerfile(vec![f, r]);
532        assert_eq!(Ok(e), "\nFROM ubuntu\n  \nRUN ls\n \n".parse());
533    }
534
535    #[test]
536    fn comment() {
537        let f = Instruction::From(from_ubuntu_latest());
538        let e = Dockerfile(vec![f]);
539        assert_eq!(Ok(e), "# hello world\nFROM ubuntu".parse());
540    }
541
542    #[test]
543    fn from_line_continuation_with_comment() {
544        let f = Instruction::From(from_ubuntu_latest());
545        let e = Dockerfile(vec![f]);
546        assert_eq!(Ok(e), "FROM\\\n# hello world\n ubuntu".parse());
547    }
548
549    #[test]
550    fn from_line_continuation() {
551        let f = Instruction::From(from_ubuntu_latest());
552        let e = Dockerfile(vec![f]);
553        assert_eq!(Ok(e), "FROM\\ \n ubuntu".parse());
554    }
555
556    #[test]
557    fn run_line_continuation_beginning() {
558        let f = Instruction::From(from_ubuntu_latest());
559        let r = Instruction::Run(Run("ls -la".into()));
560        let e = Dockerfile(vec![f, r]);
561        assert_eq!(Ok(e), "FROM ubuntu\nRUN\\\n ls -la".parse());
562    }
563
564    #[test]
565    fn run_line_continuation_middle() {
566        let f = Instruction::From(from_ubuntu_latest());
567        let r = Instruction::Run(Run("ls -la".into()));
568        let e = Dockerfile(vec![f, r]);
569        assert_eq!(Ok(e), "FROM ubuntu\nRUN ls \\\n-la".parse());
570    }
571
572    #[test]
573    fn parse_simple_image() {
574        let i = Image::from_repo_tag("ubuntu".into(), "latest".into());
575        assert_eq!(Ok(i), "ubuntu:latest".parse());
576    }
577
578    #[test]
579    fn parse_complex_image() {
580        let i = Image {
581            registry: "registry.access.redhat.com".into(),
582            namespace: "rhel7".into(),
583            repo: "rhel".into(),
584            tag: "7.3-53".into(),
585        };
586        assert_eq!(
587            ("", i),
588            parser::image("registry.access.redhat.com/rhel7/rhel:7.3-53").unwrap()
589        );
590    }
591
592    #[test]
593    fn parse_long_namespace() {
594        let i = Image {
595            registry: "".into(),
596            namespace: "a/b/c".into(),
597            repo: "r".into(),
598            tag: "".into(),
599        };
600        assert_eq!(("", i), parser::image("a/b/c/r").unwrap());
601    }
602}