From 612ec5358e554ac7d8324ecec270413713e9187d Mon Sep 17 00:00:00 2001 From: Matt Thalman Date: Thu, 12 Nov 2020 19:21:09 -0600 Subject: [PATCH] Add support for ENTRYPOINT instruction --- .../EntrypointInstructionTests.cs | 238 ++++++++++++++++++ .../DockerfileModel/DockerfileBuilder.cs | 15 ++ .../DockerfileModel/DockerfileParser.cs | 2 +- .../DockerfileModel/EntrypointInstruction.cs | 67 +++++ 4 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/DockerfileModel/DockerfileModel.Tests/EntrypointInstructionTests.cs create mode 100644 src/DockerfileModel/DockerfileModel/EntrypointInstruction.cs diff --git a/src/DockerfileModel/DockerfileModel.Tests/EntrypointInstructionTests.cs b/src/DockerfileModel/DockerfileModel.Tests/EntrypointInstructionTests.cs new file mode 100644 index 0000000..b8f988f --- /dev/null +++ b/src/DockerfileModel/DockerfileModel.Tests/EntrypointInstructionTests.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DockerfileModel.Tokens; +using Sprache; +using Xunit; + +using static DockerfileModel.Tests.TokenValidator; + +namespace DockerfileModel.Tests +{ + public class EntrypointInstructionTests + { + [Theory] + [MemberData(nameof(ParseTestInput))] + public void Parse(EntrypointInstructionParseTestScenario scenario) + { + if (scenario.ParseExceptionPosition is null) + { + EntrypointInstruction result = EntrypointInstruction.Parse(scenario.Text, scenario.EscapeChar); + Assert.Equal(scenario.Text, result.ToString()); + Assert.Collection(result.Tokens, scenario.TokenValidators); + scenario.Validate?.Invoke(result); + } + else + { + ParseException exception = Assert.Throws( + () => EntrypointInstruction.Parse(scenario.Text, scenario.EscapeChar)); + Assert.Equal(scenario.ParseExceptionPosition.Line, exception.Position.Line); + Assert.Equal(scenario.ParseExceptionPosition.Column, exception.Position.Column); + } + } + + [Theory] + [MemberData(nameof(CreateTestInput))] + public void Create(CreateTestScenario scenario) + { + EntrypointInstruction result; + if (scenario.Command != null) + { + result = EntrypointInstruction.Create(scenario.Command); + } + else + { + result = EntrypointInstruction.Create(scenario.Commands); + } + + Assert.Collection(result.Tokens, scenario.TokenValidators); + scenario.Validate?.Invoke(result); + } + + public static IEnumerable ParseTestInput() + { + EntrypointInstructionParseTestScenario[] testInputs = new EntrypointInstructionParseTestScenario[] + { + new EntrypointInstructionParseTestScenario + { + Text = "ENTRYPOINT echo hello", + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "echo hello", + token => ValidateLiteral(token, "echo hello")) + }, + Validate = result => + { + Assert.Empty(result.Comments); + Assert.Equal("ENTRYPOINT", result.InstructionName); + Assert.Equal(CommandType.ShellForm, result.Command.CommandType); + Assert.Equal("echo hello", result.Command.ToString()); + Assert.IsType(result.Command); + ShellFormCommand cmd = (ShellFormCommand)result.Command; + Assert.Equal("echo hello", cmd.Value); + } + }, + new EntrypointInstructionParseTestScenario + { + Text = "ENTRYPOINT $TEST", + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "$TEST", + token => ValidateLiteral(token, "$TEST")) + } + }, + new EntrypointInstructionParseTestScenario + { + Text = "ENTRYPOINT echo $TEST", + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "echo $TEST", + token => ValidateLiteral(token, "echo $TEST")) + } + }, + new EntrypointInstructionParseTestScenario + { + Text = "ENTRYPOINT T\\$EST", + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "T\\$EST", + token => ValidateLiteral(token, "T\\$EST")) + } + }, + new EntrypointInstructionParseTestScenario + { + Text = "ENTRYPOINT echo `\n#test comment\nhello", + EscapeChar = '`', + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "echo `\n#test comment\nhello", + token => ValidateQuotableAggregate(token, "echo `\n#test comment\nhello", null, + token => ValidateString(token, "echo "), + token => ValidateAggregate(token, "`\n", + token => ValidateSymbol(token, '`'), + token => ValidateNewLine(token, "\n")), + token => ValidateAggregate(token, "#test comment\n", + token => ValidateSymbol(token, '#'), + token => ValidateString(token, "test comment"), + token => ValidateNewLine(token, "\n")), + token => ValidateString(token, "hello"))) + }, + Validate = result => + { + Assert.Single(result.Comments); + Assert.Equal("test comment", result.Comments.First()); + Assert.Equal("ENTRYPOINT", result.InstructionName); + Assert.Equal(CommandType.ShellForm, result.Command.CommandType); + Assert.Equal("echo `\n#test comment\nhello", result.Command.ToString()); + Assert.IsType(result.Command); + ShellFormCommand cmd = (ShellFormCommand)result.Command; + Assert.Equal("echo hello", cmd.Value); + } + }, + new EntrypointInstructionParseTestScenario + { + Text = "ENTRYPOINT [\"/bin/bash\", \"-c\", \"echo hello\"]", + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "[\"/bin/bash\", \"-c\", \"echo hello\"]", + token => ValidateSymbol(token, '['), + token => ValidateLiteral(token, "/bin/bash", ParseHelper.DoubleQuote), + token => ValidateSymbol(token, ','), + token => ValidateWhitespace(token, " "), + token => ValidateLiteral(token, "-c", ParseHelper.DoubleQuote), + token => ValidateSymbol(token, ','), + token => ValidateWhitespace(token, " "), + token => ValidateLiteral(token, "echo hello", ParseHelper.DoubleQuote), + token => ValidateSymbol(token, ']')) + }, + Validate = result => + { + Assert.Empty(result.Comments); + Assert.Equal("ENTRYPOINT", result.InstructionName); + Assert.Equal(CommandType.ExecForm, result.Command.CommandType); + Assert.Equal("[\"/bin/bash\", \"-c\", \"echo hello\"]", result.Command.ToString()); + Assert.IsType(result.Command); + ExecFormCommand cmd = (ExecFormCommand)result.Command; + Assert.Equal( + new string[] + { + "/bin/bash", + "-c", + "echo hello" + }, + cmd.CommandArgs.ToArray()); + } + } + }; + + return testInputs.Select(input => new object[] { input }); + } + + public static IEnumerable CreateTestInput() + { + CreateTestScenario[] testInputs = new CreateTestScenario[] + { + new CreateTestScenario + { + Command = "echo hello", + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "echo hello", + token => ValidateLiteral(token, "echo hello")) + } + }, + new CreateTestScenario + { + Commands = new string[] + { + "/bin/bash", + "-c", + "echo hello" + }, + TokenValidators = new Action[] + { + token => ValidateKeyword(token, "ENTRYPOINT"), + token => ValidateWhitespace(token, " "), + token => ValidateAggregate(token, "[\"/bin/bash\", \"-c\", \"echo hello\"]", + token => ValidateSymbol(token, '['), + token => ValidateLiteral(token, "/bin/bash", ParseHelper.DoubleQuote), + token => ValidateSymbol(token, ','), + token => ValidateWhitespace(token, " "), + token => ValidateLiteral(token, "-c", ParseHelper.DoubleQuote), + token => ValidateSymbol(token, ','), + token => ValidateWhitespace(token, " "), + token => ValidateLiteral(token, "echo hello", ParseHelper.DoubleQuote), + token => ValidateSymbol(token, ']')) + } + } + }; + + return testInputs.Select(input => new object[] { input }); + } + + public class EntrypointInstructionParseTestScenario : ParseTestScenario + { + public char EscapeChar { get; set; } + } + + public class CreateTestScenario : TestScenario + { + public string Command { get; set; } + public IEnumerable Commands { get; set; } + } + } +} diff --git a/src/DockerfileModel/DockerfileModel/DockerfileBuilder.cs b/src/DockerfileModel/DockerfileModel/DockerfileBuilder.cs index a1c946c..d410b93 100644 --- a/src/DockerfileModel/DockerfileModel/DockerfileBuilder.cs +++ b/src/DockerfileModel/DockerfileModel/DockerfileBuilder.cs @@ -43,6 +43,9 @@ public DockerfileBuilder NewLine() => public DockerfileBuilder AddInstruction(IEnumerable sources, string destination, ChangeOwnerFlag? changeOwnerFlag = null) => AddConstruct(DockerfileModel.AddInstruction.Create(sources, destination, changeOwnerFlag, EscapeChar)); + public DockerfileBuilder AddInstruction(Action configureBuilder) => + ParseTokens(configureBuilder, DockerfileModel.AddInstruction.Parse); + public DockerfileBuilder ArgInstruction(string argName, string? argValue = null) => AddConstruct(DockerfileModel.ArgInstruction.Create(argName, argValue, EscapeChar)); @@ -68,6 +71,18 @@ public DockerfileBuilder Comment(Action configureBuilder) => public DockerfileBuilder CopyInstruction(IEnumerable sources, string destination, ChangeOwnerFlag? changeOwnerFlag = null) => AddConstruct(DockerfileModel.CopyInstruction.Create(sources, destination, changeOwnerFlag, EscapeChar)); + public DockerfileBuilder CopyInstruction(Action configureBuilder) => + ParseTokens(configureBuilder, DockerfileModel.CopyInstruction.Parse); + + public DockerfileBuilder EntrypointInstruction(string command) => + AddConstruct(DockerfileModel.EntrypointInstruction.Create(command, EscapeChar)); + + public DockerfileBuilder EntrypointInstruction(IEnumerable commands) => + AddConstruct(DockerfileModel.EntrypointInstruction.Create(commands, EscapeChar)); + + public DockerfileBuilder EntrypointInstruction(Action configureBuilder) => + ParseTokens(configureBuilder, DockerfileModel.EntrypointInstruction.Parse); + public DockerfileBuilder FromInstruction(string imageName, string? stageName = null, string? platform = null) => AddConstruct( DockerfileModel.FromInstruction.Create(imageName, stageName, platform, EscapeChar)); diff --git a/src/DockerfileModel/DockerfileModel/DockerfileParser.cs b/src/DockerfileModel/DockerfileModel/DockerfileParser.cs index e1f4818..159dd1e 100644 --- a/src/DockerfileModel/DockerfileModel/DockerfileParser.cs +++ b/src/DockerfileModel/DockerfileModel/DockerfileParser.cs @@ -18,7 +18,7 @@ internal static class DockerfileParser { "ARG", ArgInstruction.Parse }, { "CMD", CommandInstruction.Parse }, { "COPY", CopyInstruction.Parse }, - { "ENTRYPOINT", GenericInstruction.Parse }, + { "ENTRYPOINT", EntrypointInstruction.Parse }, { "EXPOSE", GenericInstruction.Parse }, { "ENV", GenericInstruction.Parse }, { "FROM", FromInstruction.Parse }, diff --git a/src/DockerfileModel/DockerfileModel/EntrypointInstruction.cs b/src/DockerfileModel/DockerfileModel/EntrypointInstruction.cs new file mode 100644 index 0000000..27f6e8d --- /dev/null +++ b/src/DockerfileModel/DockerfileModel/EntrypointInstruction.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using DockerfileModel.Tokens; +using Sprache; +using Validation; +using static DockerfileModel.ParseHelper; + +namespace DockerfileModel +{ + public class EntrypointInstruction : Instruction + { + private EntrypointInstruction(IEnumerable tokens) : base(tokens) + { + } + + public Command Command + { + get => this.Tokens.OfType().First(); + set + { + Requires.NotNull(value, nameof(value)); + SetToken(Command, value); + } + } + + public static EntrypointInstruction Parse(string text, char escapeChar = Dockerfile.DefaultEscapeChar) => + new EntrypointInstruction(GetTokens(text, GetInnerParser(escapeChar))); + + public static Parser GetParser(char escapeChar = Dockerfile.DefaultEscapeChar) => + from tokens in GetInnerParser(escapeChar) + select new EntrypointInstruction(tokens); + + public static EntrypointInstruction Create(string command, char escapeChar = Dockerfile.DefaultEscapeChar) + { + Requires.NotNullOrEmpty(command, nameof(command)); + return Parse($"ENTRYPOINT {command}", escapeChar); + } + + public static EntrypointInstruction Create(IEnumerable commands, char escapeChar = Dockerfile.DefaultEscapeChar) + { + Requires.NotNullEmptyOrNullElements(commands, nameof(commands)); + return Parse($"ENTRYPOINT {StringHelper.FormatAsJson(commands)}", escapeChar); + } + + public override string? ResolveVariables(char escapeChar, IDictionary? variables = null, ResolutionOptions? options = null) + { + // Do not resolve variables for the command of an ENTRYPOINT instruction. It is shell/runtime-specific. + return ToString(); + } + + private static Parser> GetInnerParser(char escapeChar = Dockerfile.DefaultEscapeChar) => + Instruction("ENTRYPOINT", escapeChar, + GetArgsParser(escapeChar)); + + private static Parser> GetArgsParser(char escapeChar) => + from mounts in ArgTokens(MountFlag.GetParser(escapeChar).AsEnumerable(), escapeChar).Many() + from whitespace in Whitespace() + from command in ArgTokens(GetCommandParser(escapeChar).AsEnumerable(), escapeChar) + select ConcatTokens( + mounts.Flatten(), whitespace, command); + + private static Parser GetCommandParser(char escapeChar) => + ExecFormCommand.GetParser(escapeChar) + .Cast() + .XOr(ShellFormCommand.GetParser(escapeChar)); + } +}