diff --git a/commands/build.go b/commands/build.go index 6145d4455edf..f9e8c91c979c 100644 --- a/commands/build.go +++ b/commands/build.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -36,6 +37,7 @@ import ( "github.com/moby/buildkit/util/appcontext" "github.com/moby/buildkit/util/grpcerrors" "github.com/morikuni/aec" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -43,6 +45,8 @@ import ( "google.golang.org/grpc/codes" ) +const DockerfileLabel = "com.docker.image.source.entrypoint" + const defaultTargetName = "default" type buildOptions struct { @@ -128,6 +132,12 @@ func runBuild(dockerCli command.Cli, in buildOptions) (err error) { return err } + gitLabels, err := addGitProvenance(ctx, in.contextPath, in.dockerfileName) + if err != nil { + return err + } + labels := append(in.labels, gitLabels...) + opts := build.Options{ Inputs: build.Inputs{ ContextPath: in.contextPath, @@ -138,7 +148,7 @@ func runBuild(dockerCli command.Cli, in buildOptions) (err error) { BuildArgs: listToMap(in.buildArgs, true), ExtraHosts: in.extraHosts, ImageIDFile: in.imageIDFile, - Labels: listToMap(in.labels, false), + Labels: listToMap(labels, false), NetworkMode: in.networkMode, NoCache: noCache, NoCacheFilter: in.noCacheFilter, @@ -643,6 +653,89 @@ func parsePrintFunc(str string) (*build.PrintFunc, error) { return f, nil } +func addGitProvenance(ctx context.Context, contextPath string, dockerfilePath string) ([]string, error) { + v, ok := os.LookupEnv("BUILDX_GIT_LABELS") + if !ok || contextPath == "" { + return nil, nil + } + labels := make([]string, 0) + + // figure out in which directory the git command needs to run in + var wd string + if filepath.IsAbs(contextPath) { + wd = contextPath + } else { + cwd, _ := os.Getwd() + wd, _ = filepath.Abs(filepath.Join(cwd, contextPath)) + } + + // check if inside git working tree + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree") + cmd.Dir = wd + err := cmd.Run() + if err != nil { + logrus.Warnf("Unable to determine Git information") + return nil, nil + } + + // obtain Git sha of current HEAD + cmd = exec.CommandContext(ctx, "git", "rev-parse", "HEAD") + cmd.Dir = wd + out, err := cmd.Output() + if err != nil { + return nil, errors.Wrap(err, "error obtaining git head") + } + sha := strings.TrimSpace(string(out)) + + // check if the current HEAD is clean + cmd = exec.CommandContext(ctx, "git", "status", "--porcelain", "--ignored") + cmd.Dir = wd + out, err = cmd.Output() + if err != nil { + return nil, errors.Wrap(err, "error obtaining git status") + } + if len(strings.TrimSpace(string(out))) != 0 { + sha += "-dirty" + } + labels = append(labels, fmt.Sprintf("%s=%s", ocispecs.AnnotationRevision, sha)) + + // add a remote url if full Git details are requested; if there aren't any remotes don't fail + if v == "full" { + cmd = exec.CommandContext(ctx, "git", "ls-remote", "--get-url") + cmd.Dir = wd + out, _ := cmd.Output() + if len(out) > 0 { + labels = append(labels, fmt.Sprintf("%s=%s", ocispecs.AnnotationSource, strings.TrimSpace(string(out)))) + } + } + + // add Dockerfile path; there is no org.opencontainers annotation for this + if dockerfilePath == "" { + dockerfilePath = "Dockerfile" + } + + // obtain Git root directory + cmd = exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + cmd.Dir = wd + out, err = cmd.Output() + if err != nil { + return nil, errors.Wrap(err, "failed to get git root") + } + root := strings.TrimSpace(string(out)) + + // record only Dockerfile paths that are within the Git root + if !filepath.IsAbs(dockerfilePath) { + cwd, _ := os.Getwd() + dockerfilePath = filepath.Join(cwd, dockerfilePath) + } + dockerfilePath, _ = filepath.Rel(root, dockerfilePath) + if !strings.HasPrefix(dockerfilePath, "..") { + labels = append(labels, fmt.Sprintf("%s=%s", DockerfileLabel, dockerfilePath)) + } + + return labels, nil +} + func writeMetadataFile(filename string, dt interface{}) error { b, err := json.MarshalIndent(dt, "", " ") if err != nil { diff --git a/commands/build_test.go b/commands/build_test.go new file mode 100644 index 000000000000..ffca9f99b125 --- /dev/null +++ b/commands/build_test.go @@ -0,0 +1,103 @@ +package commands + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" +) + +var repoDir string + +func setupTest(tb testing.TB) func(tb testing.TB) { + repoDir = tb.TempDir() + // required for local testing on mac to avoid strange /private symlinks + if runtime.GOOS == "darwin" { + repoDir, _ = filepath.EvalSymlinks(repoDir) + } + cmd := exec.Command("git", "init") + cmd.Dir = repoDir + err := cmd.Run() + assert.Nilf(tb, err, "failed to init git repo: %v", err) + + df := []byte("FROM alpine:latest\n") + err = os.WriteFile(filepath.Join(repoDir, "Dockerfile"), df, 0644) + assert.Nilf(tb, err, "failed to write file: %v", err) + + cmd = exec.Command("git", "add", "Dockerfile") + cmd.Dir = repoDir + err = cmd.Run() + assert.Nilf(tb, err, "failed to add file: %v", err) + + cmd = exec.Command("git", "config", "user.name", "buildx") + cmd.Dir = repoDir + err = cmd.Run() + assert.Nilf(tb, err, "failed to set git user.name: %v", err) + + cmd = exec.Command("git", "config", "user.email", "buildx@docker.com") + cmd.Dir = repoDir + err = cmd.Run() + assert.Nilf(tb, err, "failed to set git user.email: %v", err) + + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = repoDir + err = cmd.Run() + assert.Nilf(tb, err, "failed to commit: %v", err) + + return func(tb testing.TB) { + os.Unsetenv("BUILDX_GIT_LABELS") + os.RemoveAll(repoDir) + } +} + +func TestAddGitProvenanceDataWithoutEnv(t *testing.T) { + defer setupTest(t)(t) + labels, err := addGitProvenance(context.Background(), repoDir, filepath.Join(repoDir, "Dockerfile")) + assert.Nilf(t, err, "No error expected") + assert.Nilf(t, labels, "No labels expected") +} + +func TestAddGitProvenanceDataWithoutLabels(t *testing.T) { + defer setupTest(t)(t) + os.Setenv("BUILDX_GIT_LABELS", "full") + labels, err := addGitProvenance(context.Background(), repoDir, filepath.Join(repoDir, "Dockerfile")) + assert.Nilf(t, err, "No error expected") + assert.Equal(t, 2, len(labels), "Exactly 2 git provenance labels expected") + assert.Contains(t, labels, fmt.Sprintf("%s=Dockerfile", DockerfileLabel), "Expected a dockerfile path provenance label") + + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = repoDir + out, _ := cmd.Output() + assert.Contains(t, labels, fmt.Sprintf("%s=%s", ocispecs.AnnotationRevision, strings.TrimSpace(string(out))), "Expected a sha provenance label") +} + +func TestAddGitProvenanceDataWithLabels(t *testing.T) { + defer setupTest(t)(t) + // make a change to test dirty flag + df := []byte("FROM alpine:edge\n") + os.Mkdir(filepath.Join(repoDir, "dir"), 0755) + os.WriteFile(filepath.Join(repoDir, "dir", "Dockerfile"), df, 0644) + // add a remote + cmd := exec.Command("git", "remote", "add", "origin", "git@github.com:docker/buildx.git") + cmd.Dir = repoDir + cmd.Run() + + os.Setenv("BUILDX_GIT_LABELS", "full") + labels, err := addGitProvenance(context.Background(), repoDir, filepath.Join(repoDir, "Dockerfile")) + assert.Nilf(t, err, "No error expected") + assert.Equal(t, 3, len(labels), "Exactly 3 git provenance labels expected") + assert.Contains(t, labels, fmt.Sprintf("%s=Dockerfile", DockerfileLabel), "Expected a dockerfile path provenance label") + assert.Contains(t, labels, fmt.Sprintf("%s=git@github.com:docker/buildx.git", ocispecs.AnnotationSource), "Expected a remote provenance label") + + cmd = exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = repoDir + out, _ := cmd.Output() + assert.Contains(t, labels, fmt.Sprintf("%s=%s-dirty", ocispecs.AnnotationRevision, strings.TrimSpace(string(out))), "Expected a sha provenance label") +}