github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/dockerfile/ast.go (about)

     1  package dockerfile
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io"
     7  	"strings"
     8  
     9  	"github.com/distribution/reference"
    10  	"github.com/docker/cli/opts"
    11  	"github.com/moby/buildkit/frontend/dockerfile/command"
    12  	"github.com/moby/buildkit/frontend/dockerfile/instructions"
    13  	"github.com/moby/buildkit/frontend/dockerfile/parser"
    14  	"github.com/moby/buildkit/frontend/dockerfile/shell"
    15  	"github.com/pkg/errors"
    16  
    17  	"github.com/tilt-dev/tilt/internal/container"
    18  )
    19  
    20  type AST struct {
    21  	directives []*parser.Directive
    22  	result     *parser.Result
    23  }
    24  
    25  func ParseAST(df Dockerfile) (AST, error) {
    26  	result, err := parser.Parse(newReader(df))
    27  	if err != nil {
    28  		return AST{}, errors.Wrap(err, "dockerfile.ParseAST")
    29  	}
    30  
    31  	dParser := &parser.DirectiveParser{}
    32  	directives, err := dParser.ParseAll([]byte(df))
    33  	if err != nil {
    34  		return AST{}, errors.Wrap(err, "dockerfile.ParseAST")
    35  	}
    36  
    37  	return AST{
    38  		directives: directives,
    39  		result:     result,
    40  	}, nil
    41  }
    42  
    43  func (a AST) extractBaseNameInFromCommand(node *parser.Node, shlex *shell.Lex, metaArgs []instructions.ArgCommand) string {
    44  	if node.Next == nil {
    45  		return ""
    46  	}
    47  
    48  	inst, err := instructions.ParseInstruction(node)
    49  	if err != nil {
    50  		return node.Next.Value // if there's a parsing error, fallback to the first arg
    51  	}
    52  
    53  	fromInst, ok := inst.(*instructions.Stage)
    54  	if !ok || fromInst.BaseName == "" {
    55  		return ""
    56  	}
    57  
    58  	// The base image name may have ARG expansions in it. Do the default
    59  	// substitution.
    60  	argsMap := fakeArgsMap(shlex, metaArgs)
    61  	baseName, err := shlex.ProcessWordWithMap(fromInst.BaseName, argsMap)
    62  	if err != nil {
    63  		// If anything fails, just use the hard-coded BaseName.
    64  		return fromInst.BaseName
    65  	}
    66  	return baseName
    67  
    68  }
    69  
    70  // Find all images referenced in this dockerfile and call the visitor function.
    71  // If the visitor function returns a new image, substitute that image into the dockerfile.
    72  func (a AST) traverseImageRefs(visitor func(node *parser.Node, ref reference.Named) reference.Named, dockerfileArgs []instructions.ArgCommand) error {
    73  	metaArgs := append([]instructions.ArgCommand(nil), dockerfileArgs...)
    74  	shlex := shell.NewLex(a.result.EscapeToken)
    75  
    76  	return a.Traverse(func(node *parser.Node) error {
    77  		switch strings.ToLower(node.Value) {
    78  		case command.Arg:
    79  			inst, err := instructions.ParseInstruction(node)
    80  			if err != nil {
    81  				return nil // ignore parsing error
    82  			}
    83  
    84  			argCmd, ok := inst.(*instructions.ArgCommand)
    85  			if !ok {
    86  				return nil
    87  			}
    88  
    89  			// args within the Dockerfile are prepended because they provide defaults that are overridden by actual args
    90  			metaArgs = append([]instructions.ArgCommand{*argCmd}, metaArgs...)
    91  
    92  		case command.From:
    93  			baseName := a.extractBaseNameInFromCommand(node, shlex, metaArgs)
    94  			if baseName == "" {
    95  				return nil // ignore parsing error
    96  			}
    97  
    98  			ref, err := container.ParseNamed(baseName)
    99  			if err != nil {
   100  				return nil // drop the error, we don't care about malformed images
   101  			}
   102  			newRef := visitor(node, ref)
   103  			if newRef != nil {
   104  				node.Next.Value = container.FamiliarString(newRef)
   105  			}
   106  
   107  		case command.Copy:
   108  			if len(node.Flags) == 0 {
   109  				return nil
   110  			}
   111  
   112  			inst, err := instructions.ParseInstruction(node)
   113  			if err != nil {
   114  				return nil // ignore parsing error
   115  			}
   116  
   117  			copyCmd, ok := inst.(*instructions.CopyCommand)
   118  			if !ok {
   119  				return nil
   120  			}
   121  
   122  			ref, err := container.ParseNamed(copyCmd.From)
   123  			if err != nil {
   124  				return nil // drop the error, we don't care about malformed images
   125  			}
   126  
   127  			newRef := visitor(node, ref)
   128  			if newRef != nil {
   129  				for i, flag := range node.Flags {
   130  					if strings.HasPrefix(flag, "--from=") {
   131  						node.Flags[i] = fmt.Sprintf("--from=%s", container.FamiliarString(newRef))
   132  					}
   133  				}
   134  			}
   135  		}
   136  
   137  		return nil
   138  	})
   139  }
   140  
   141  func (a AST) InjectImageDigest(selector container.RefSelector, ref reference.NamedTagged, buildArgs []string) (bool, error) {
   142  	modified := false
   143  	err := a.traverseImageRefs(func(node *parser.Node, toReplace reference.Named) reference.Named {
   144  		if selector.Matches(toReplace) {
   145  			modified = true
   146  			return ref
   147  		}
   148  		return nil
   149  	}, argInstructions(buildArgs))
   150  	return modified, err
   151  }
   152  
   153  // Post-order traversal of the Dockerfile AST.
   154  // Halts immediately on error.
   155  func (a AST) Traverse(visit func(*parser.Node) error) error {
   156  	return a.traverseNode(a.result.AST, visit)
   157  }
   158  
   159  func (a AST) traverseNode(node *parser.Node, visit func(*parser.Node) error) error {
   160  	for _, c := range node.Children {
   161  		err := a.traverseNode(c, visit)
   162  		if err != nil {
   163  			return err
   164  		}
   165  	}
   166  	return visit(node)
   167  }
   168  
   169  func (a AST) Print() (Dockerfile, error) {
   170  	buf := bytes.NewBuffer(nil)
   171  	currentLine := 1
   172  
   173  	directiveFmt := "# %s = %s\n"
   174  	for _, v := range a.directives {
   175  		_, err := fmt.Fprintf(buf, directiveFmt, v.Name, v.Value)
   176  		if err != nil {
   177  			return "", err
   178  		}
   179  		currentLine++
   180  	}
   181  
   182  	for _, node := range a.result.AST.Children {
   183  		for currentLine < node.StartLine {
   184  			_, err := buf.Write([]byte("\n"))
   185  			if err != nil {
   186  				return "", err
   187  			}
   188  			currentLine++
   189  		}
   190  
   191  		lineCount, err := a.printNode(node, buf)
   192  		if err != nil {
   193  			return "", err
   194  		}
   195  
   196  		currentLine = node.StartLine + lineCount
   197  	}
   198  	return Dockerfile(buf.String()), nil
   199  }
   200  
   201  // Loosely adapted from
   202  // https://github.com/jessfraz/dockfmt/blob/master/format.go
   203  // Returns the number of lines printed.
   204  func (a AST) printNode(node *parser.Node, writer io.Writer) (int, error) {
   205  	var v string
   206  
   207  	// format per directive
   208  	switch strings.ToLower(node.Value) {
   209  	// all the commands that use parseMaybeJSON
   210  	// https://github.com/moby/buildkit/blob/2ec7d53b00f24624cda0adfbdceed982623a93b3/frontend/dockerfile/parser/parser.go#L152
   211  	case command.Cmd, command.Entrypoint, command.Run, command.Shell:
   212  		v = fmtCmd(node)
   213  	case command.Label:
   214  		v = fmtLabel(node)
   215  	default:
   216  		v = fmtDefault(node)
   217  	}
   218  
   219  	_, err := fmt.Fprintln(writer, v)
   220  	if err != nil {
   221  		return 0, err
   222  	}
   223  	return strings.Count(v, "\n") + 1, nil
   224  }
   225  
   226  func getCmd(n *parser.Node) []string {
   227  	if n == nil {
   228  		return nil
   229  	}
   230  
   231  	cmd := []string{strings.ToUpper(n.Value)}
   232  	if len(n.Flags) > 0 {
   233  		cmd = append(cmd, n.Flags...)
   234  	}
   235  
   236  	return append(cmd, getCmdArgs(n)...)
   237  }
   238  
   239  func getCmdArgs(n *parser.Node) []string {
   240  	if n == nil {
   241  		return nil
   242  	}
   243  
   244  	cmd := []string{}
   245  	for node := n.Next; node != nil; node = node.Next {
   246  		cmd = append(cmd, node.Value)
   247  		if len(node.Flags) > 0 {
   248  			cmd = append(cmd, node.Flags...)
   249  		}
   250  	}
   251  
   252  	return cmd
   253  }
   254  
   255  func appendHeredocs(node *parser.Node, cmdLine string) string {
   256  	if len(node.Heredocs) == 0 {
   257  		return cmdLine
   258  	}
   259  	lines := []string{cmdLine}
   260  	for _, h := range node.Heredocs {
   261  		lines = append(lines, fmt.Sprintf("\n%s%s", h.Content, h.Name))
   262  	}
   263  	return strings.Join(lines, "")
   264  }
   265  
   266  func fmtCmd(node *parser.Node) string {
   267  	if node.Attributes["json"] {
   268  		cmd := []string{strings.ToUpper(node.Value)}
   269  		if len(node.Flags) > 0 {
   270  			cmd = append(cmd, node.Flags...)
   271  		}
   272  
   273  		encoded := []string{}
   274  		for _, c := range getCmdArgs(node) {
   275  			encoded = append(encoded, fmt.Sprintf("%q", c))
   276  		}
   277  		return appendHeredocs(node, fmt.Sprintf("%s [%s]", strings.Join(cmd, " "), strings.Join(encoded, ", ")))
   278  	}
   279  
   280  	cmd := getCmd(node)
   281  	return appendHeredocs(node, strings.Join(cmd, " "))
   282  }
   283  
   284  func fmtDefault(node *parser.Node) string {
   285  	cmd := getCmd(node)
   286  	return appendHeredocs(node, strings.Join(cmd, " "))
   287  }
   288  
   289  func fmtLabel(node *parser.Node) string {
   290  	cmd := getCmd(node)
   291  	assignments := []string{cmd[0]}
   292  	for i := 1; i < len(cmd); i += 2 {
   293  		if i+1 < len(cmd) {
   294  			assignments = append(assignments, fmt.Sprintf("%s=%s", cmd[i], cmd[i+1]))
   295  		} else {
   296  			assignments = append(assignments, cmd[i])
   297  		}
   298  	}
   299  	return strings.Join(assignments, " ")
   300  }
   301  
   302  func newReader(df Dockerfile) io.Reader {
   303  	return bytes.NewBufferString(string(df))
   304  }
   305  
   306  // Loosely adapted from the buildkit code for turning args into a map.
   307  // Iterate through them and do substitutions in order.
   308  func fakeArgsMap(shlex *shell.Lex, args []instructions.ArgCommand) map[string]string {
   309  	m := make(map[string]string)
   310  	for _, argCmd := range args {
   311  		val := ""
   312  		for _, a := range argCmd.Args {
   313  			if a.Value != nil {
   314  				val, _ = shlex.ProcessWordWithMap(*(a.Value), m)
   315  			}
   316  			m[a.Key] = val
   317  		}
   318  	}
   319  	return m
   320  }
   321  
   322  // argInstructions converts a map of build arguments into a slice of ArgCommand structs.
   323  //
   324  // Since the map guarantees uniqueness, there is no defined order of the resulting slice.
   325  func argInstructions(buildArgs []string) []instructions.ArgCommand {
   326  	var out []instructions.ArgCommand
   327  	for k, v := range opts.ConvertKVStringsToMapWithNil(buildArgs) {
   328  		out = append(out, instructions.ArgCommand{Args: []instructions.KeyValuePairOptional{
   329  			{
   330  				Key:   k,
   331  				Value: v,
   332  			},
   333  		}})
   334  	}
   335  	return out
   336  }