scan/dependencies.go (226 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package scan
import (
"bufio"
"bytes"
"fmt"
"io"
"log"
"os"
"path"
"strings"
"github.com/Azure/acr-builder/pkg/image"
"github.com/Azure/acr-builder/util"
"github.com/docker/distribution/reference"
"github.com/pkg/errors"
)
const (
dockerfileComment = "#"
defaultDockerfile = "Dockerfile"
)
var (
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
)
// ScanForDependencies scans for base image dependencies.
func (s *Scanner) ScanForDependencies(context string, workingDir string, dockerfile string, buildArgs []string, pushTo []string, target string) (deps []*image.Dependencies, err error) {
dockerfilePath := createDockerfilePath(context, workingDir, dockerfile)
file, err := os.Open(dockerfilePath)
if err != nil {
return deps, fmt.Errorf("error opening dockerfile: %s, error: %v", dockerfilePath, err)
}
defer func() { _ = file.Close() }()
runtime, buildtime, err := resolveDockerfileDependencies(file, buildArgs, target)
if err != nil {
return deps, err
}
// Even though there's nothing to push to, we always invoke NewImageDependencies
// TODO: refactor this in the future to take in the full list as opposed to individual
// images.
var currDep *image.Dependencies
if len(pushTo) == 0 {
currDep, err = s.NewImageDependencies("", runtime, buildtime)
if err != nil {
return nil, err
}
deps = append(deps, currDep)
}
for _, imageName := range pushTo {
currDep, err = s.NewImageDependencies(imageName, runtime, buildtime)
if err != nil {
return nil, err
}
deps = append(deps, currDep)
}
return deps, err
}
// NewImageDependencies creates Dependencies with no references registered
func (s *Scanner) NewImageDependencies(img string, runtime string, buildtimes []string) (*image.Dependencies, error) {
var dependencies *image.Dependencies
if len(img) > 0 {
imageReference, err := NewImageReference(util.NormalizeImageTag(img))
if err != nil {
return nil, err
}
dependencies = &image.Dependencies{
Image: imageReference,
}
} else {
// we allow build without pushing image to registry so the image can be empty
dependencies = &image.Dependencies{
Image: nil,
}
}
runtimeDep, err := NewImageReference(util.NormalizeImageTag(runtime))
if err != nil {
return nil, err
}
dependencies.Runtime = runtimeDep
dict := map[string]bool{}
for _, buildtime := range buildtimes {
bt := util.NormalizeImageTag(buildtime)
// If the image is prefixed with "library/", remove it for comparisons.
// "library/" will be added again during image reference generation.
// This prevents duplicate dependencies when reading "library/golang" and
// "golang" from the Dockerfile.
bt = strings.TrimPrefix(bt, "library/")
// If we've already processed the tag after normalization, skip dependency
// generation. I.e., they specify "golang" and "golang:latest"
if dict[bt] {
continue
}
dict[bt] = true
buildtimeDep, err := NewImageReference(bt)
if err != nil {
return nil, err
}
dependencies.Buildtime = append(dependencies.Buildtime, buildtimeDep)
}
return dependencies, nil
}
// NewImageReference parses a path of a image and creates a ImageReference object
func NewImageReference(imagePath string) (*image.Reference, error) {
ref, err := reference.Parse(imagePath)
if err != nil {
return nil, errors.Wrapf(err, "Failed to parse image reference, ensure tags have a valid format: %s", imagePath)
}
result := &image.Reference{
Reference: ref.String(),
}
if named, ok := ref.(reference.Named); ok {
result.Registry = reference.Domain(named)
if strings.Contains(result.Registry, ".") {
// The domain is the registry, eg, registryname.azurecr.io
result.Repository = reference.Path(named)
} else {
// DockerHub
if result.Registry == "" {
result.Registry = DockerHubRegistry
result.Repository = strings.Join([]string{"library", reference.Path(named)}, "/")
} else {
// The domain is the DockerHub user name
result.Registry = DockerHubRegistry
result.Repository = strings.Join([]string{reference.Domain(named), reference.Path(named)}, "/")
}
}
}
if tagged, ok := ref.(reference.Tagged); ok {
result.Tag = tagged.Tag()
}
if digested, ok := ref.(reference.Digested); ok {
result.Digest = digested.Digest().String()
}
return result, nil
}
// resolveDockerfileDependencies resolves dependencies given an io.Reader for a Dockerfile.
func resolveDockerfileDependencies(r io.Reader, buildArgs []string, target string) (origin string, buildtimeDependencies []string, err error) {
scanner := bufio.NewScanner(r)
context, err := parseBuildArgs(buildArgs)
if err != nil {
return "", nil, err
}
originLookup := map[string]string{} // given an alias, look up its origin
allOrigins := map[string]bool{} // set of all origins
firstLine := true
SCAN: // label for the scan loop
for scanner.Scan() {
var line string
// Trim UTF-8 BOM if necessary.
if firstLine {
scannedBytes := scanner.Bytes()
scannedBytes = bytes.TrimPrefix(scannedBytes, utf8BOM)
line = string(scannedBytes)
firstLine = false
} else {
line = scanner.Text()
}
line = strings.TrimSpace(line)
// Skip comments.
if line == "" || strings.HasPrefix(line, dockerfileComment) {
continue
}
tokens := strings.Fields(line)
if len(tokens) > 0 {
switch strings.ToUpper(tokens[0]) {
case "FROM":
if len(tokens) < 2 {
return "", nil, fmt.Errorf("unable to understand line %s", line)
}
// trim surrounds single and double quotes from the image reference
var imageToken = os.Expand(util.TrimQuotes(tokens[1]), func(key string) string {
return context[key]
})
var found bool
origin, found = originLookup[imageToken]
if !found {
allOrigins[imageToken] = true
origin = imageToken
}
if len(tokens) > 2 {
if len(tokens) < 4 || !strings.EqualFold(tokens[2], "as") {
return "", nil, fmt.Errorf("unable to understand line %s", line)
}
// alias cannot contain variables it seems. So we don't call context.Expand on it
alias := tokens[3]
originLookup[alias] = origin
// Just ignore the rest of the tokens...
if len(tokens) > 4 {
log.Printf("Ignoring chunks from FROM clause: %v\n", tokens[4:])
}
// reach the target, stop the scanning
if len(target) > 0 && target == alias {
break SCAN
}
}
case "ARG":
if len(tokens) < 2 {
return "", nil, fmt.Errorf("dockerfile syntax requires ARG directive to have exactly 1 argument. LINE: %s", line)
}
if strings.Contains(tokens[1], "=") {
varName, varValue, err := parseAssignment(tokens[1])
if err != nil {
return "", nil, fmt.Errorf("unable to parse assignment %s, error: %s", tokens[1], err)
}
// This line matches docker's behavior here
// 1. If build arg is passed in, the value will not override
// 2. It is actually allowed for same ARG to be specified more than once in a Dockerfile
// However the subsequent value would be ignored instead of overriding the previous
if _, found := context[varName]; !found {
context[varName] = varValue
}
}
}
}
}
if len(allOrigins) == 0 {
return "", nil, errors.New("unexpected dockerfile format")
}
// note that origin variable now points to the runtime origin
for terminal := range allOrigins {
if terminal != origin {
buildtimeDependencies = append(buildtimeDependencies, terminal)
}
}
return origin, buildtimeDependencies, nil
}
func parseBuildArgs(args []string) (map[string]string, error) {
result := map[string]string{}
for _, assignment := range args {
name, value, err := parseAssignment(assignment)
if err != nil {
return nil, err
}
result[name] = value
}
return result, nil
}
func parseAssignment(in string) (name string, value string, err error) {
values := strings.SplitN(in, "=", 2)
if len(values) != 2 {
return "", "", fmt.Errorf("%s cannot be split into 2 tokens with '='", in)
}
return values[0], util.TrimQuotes(values[1]), nil
}
// createDockerfilePath determines where we should look for the dockerfile depending on the
// context and working directory.
func createDockerfilePath(context string, workingDir string, dockerfile string) string {
dockerfilePath := dockerfile
isLocalContext := util.IsLocalContext(context)
// For local context:
// - If the Dockerfile wasn't specified, we default its value and check for it relative to the working directory.
// - If the Dockerfile was specified, we simply look at the provided Dockerfile's path.
//
// For remote context:
// - The Dockerfile is scoped to the cloned or downloaded location and the working directory.
// I.e., for https://github.com/Azure/acr-builder.git#:foo/bar, which was downloaded to a directory "build",
// the path must be scoped to build/foo/bar/Dockerfile
if isLocalContext && dockerfile == "" {
dockerfilePath = path.Clean(path.Join(workingDir, defaultDockerfile))
} else if !isLocalContext {
if dockerfile == "" {
dockerfile = defaultDockerfile
}
dockerfilePath = path.Clean(path.Join(workingDir, dockerfile))
}
return dockerfilePath
}