package build import ( "reflect" "strings" "testing" ) func TestParseReadsTheStageOneInstructions(t *testing.T) { tests := []struct { name string in string want []Instruction }{ { "comments and blank lines are dropped", "# a build environment\n\nFROM debian:trixie\n\n# install\nRUN apt-get update\n", []Instruction{ {Keyword: "FROM", Args: "debian:trixie", Line: 3}, {Keyword: "RUN", Args: "apt-get update", Line: 6}, }, }, { "a line continuation joins into one instruction", "FROM debian:trixie\nRUN apt-get update \\\n && apt-get install -y jq \\\n && rm -rf /var/lib/apt/lists/*\n", []Instruction{ {Keyword: "FROM", Args: "debian:trixie", Line: 1}, {Keyword: "RUN", Args: "apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*", Line: 2}, }, }, { "a comment inside a continuation is dropped, as in docker", "FROM base\nRUN one \\\n# explaining the second half\n && two\n", []Instruction{ {Keyword: "FROM", Args: "base", Line: 1}, {Keyword: "RUN", Args: "one && two", Line: 2}, }, }, { "keywords are case-insensitive and normalized", "from base\nrun true\ncmd [\"/bin/sh\"]\n", []Instruction{ {Keyword: "FROM", Args: "base", Line: 1}, {Keyword: "RUN", Args: "true", Line: 2}, {Keyword: "CMD", Args: `["/bin/sh"]`, Line: 3}, }, }, { "the metadata instructions pass through verbatim", "FROM base\nENV JAVA_HOME=/opt/java\nWORKDIR /work\nLABEL org.opencontainers.image.title=\"build\"\nENTRYPOINT [\"/entry\"]\nCMD [\"-v\"]\nVOLUME /data\nUSER 1000\n", []Instruction{ {Keyword: "FROM", Args: "base", Line: 1}, {Keyword: "ENV", Args: "JAVA_HOME=/opt/java", Line: 2}, {Keyword: "WORKDIR", Args: "/work", Line: 3}, {Keyword: "LABEL", Args: `org.opencontainers.image.title="build"`, Line: 4}, {Keyword: "ENTRYPOINT", Args: `["/entry"]`, Line: 5}, {Keyword: "CMD", Args: `["-v"]`, Line: 6}, {Keyword: "VOLUME", Args: "/data", Line: 7}, {Keyword: "USER", Args: "1000", Line: 8}, }, }, { "trailing whitespace and a final line without a newline", "FROM base \nCOPY app /srv/app", []Instruction{ {Keyword: "FROM", Args: "base", Line: 1}, {Keyword: "COPY", Args: "app /srv/app", Line: 2}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Parse(strings.NewReader(tt.in)) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, tt.want) { t.Errorf("instructions:\n got %+v\nwant %+v", got, tt.want) } }) } } func TestParseRefusesWithTheReason(t *testing.T) { tests := []struct { name string in string wantErr string }{ {"no FROM first", "RUN true\n", "begins with FROM"}, {"empty file", "# nothing but a comment\n", "holds no instruction"}, {"unknown instruction", "FROM base\nFRobNICATE x\n", "unknown instruction FROBNICATE"}, {"EXPOSE", "FROM base\nEXPOSE 8080\n", "shares the host's network"}, {"HEALTHCHECK", "FROM base\nHEALTHCHECK CMD curl -f http://localhost/\n", "no daemon to poll"}, {"STOPSIGNAL", "FROM base\nSTOPSIGNAL SIGQUIT\n", "stop sends SIGTERM"}, {"ONBUILD", "FROM base\nONBUILD RUN true\n", "ONBUILD trigger"}, {"MAINTAINER", "FROM base\nMAINTAINER me\n", "deprecated by docker itself"}, {"ARG is a later stage", "FROM base\nARG VERSION=1\n", "stage 2 of RFC 0006"}, {"SHELL is a later stage", "FROM base\nSHELL [\"/bin/bash\", \"-c\"]\n", "stage 2 of RFC 0006"}, {"ADD is a later stage", "FROM base\nADD app.tar /srv\n", "stage 2 of RFC 0006"}, {"an instruction without an argument", "FROM base\nRUN\n", "RUN needs an argument"}, {"the refusal names the line", "FROM base\nRUN true\nEXPOSE 80\n", "line 3"}, {"RUN --mount", "FROM base\nRUN --mount=type=cache,target=/var/cache/apt apt-get update\n", "RUN --mount is not supported: a RUN step cannot mount"}, {"RUN --network", "FROM base\nRUN --network=none true\n", "RUN --network is not supported"}, {"RUN --security", "FROM base\nRUN --security=insecure true\n", "RUN --security is not supported"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := Parse(strings.NewReader(tt.in)) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("got error %v, want it to contain %q", err, tt.wantErr) } }) } } func TestChangesTreeSeparatesStepsFromMetadata(t *testing.T) { for _, keyword := range []string{"FROM", "RUN", "COPY"} { if !(Instruction{Keyword: keyword}).changesTree() { t.Errorf("%s changes the work tree", keyword) } } for _, keyword := range []string{"ENTRYPOINT", "CMD", "ENV", "WORKDIR", "USER", "VOLUME", "LABEL"} { if (Instruction{Keyword: keyword}).changesTree() { t.Errorf("%s is metadata, not a tree change", keyword) } } }