From bc379b2787cd7d827e26e9dc55704bf2a7d38175 Mon Sep 17 00:00:00 2001 From: Christian Dupuis Date: Sat, 20 Aug 2022 12:25:44 +0200 Subject: [PATCH] Add git provenance labels as per #1290 Signed-off-by: Christian Dupuis --- commands/build.go | 87 +++++++++++++++++++++++++- commands/build_test.go | 134 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 commands/build_test.go diff --git a/commands/build.go b/commands/build.go index 6145d4455edf..8dec33a61974 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,81 @@ 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)) + } + + // 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.Errorf("error obtaining git head: %v", err) + } + 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.Errorf("error obtaining git status: %v", err) + } + if len(strings.TrimSpace(string(out))) == 0 { + labels = append(labels, fmt.Sprintf("%s=%s", ocispecs.AnnotationRevision, sha)) + } else { + labels = append(labels, fmt.Sprintf("%s=%s-dirty", 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.Errorf("failed to get git root: %v", err) + } + 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..c35dbc821464 --- /dev/null +++ b/commands/build_test.go @@ -0,0 +1,134 @@ +package commands + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" +) + +var repoDir string + +func setupTest(tb testing.TB) func(tb testing.TB) { + repoDir = tb.TempDir() + cmd := exec.Command("git", "init") + cmd.Dir = repoDir + err := cmd.Run() + if err != nil { + tb.Errorf("failed to init git repo: %v", err) + } + + df := []byte("FROM alpine:latest\n") + err = os.WriteFile(filepath.Join(repoDir, "Dockerfile"), df, 0644) + if err != nil { + tb.Errorf("failed to write file: %v", err) + } + + cmd = exec.Command("git", "add", "Dockerfile") + cmd.Dir = repoDir + err = cmd.Run() + if err != nil { + tb.Errorf("failed to add file: %v", err) + } + + cmd = exec.Command("git", "config", "user.name", "buildx") + cmd.Dir = repoDir + err = cmd.Run() + if err != nil { + tb.Errorf("failed to set git user.name: %v", err) + } + + cmd = exec.Command("git", "config", "user.email", "buildx@docker.com") + cmd.Dir = repoDir + err = cmd.Run() + if err != nil { + tb.Errorf("failed to set git user.email: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = repoDir + out, err := cmd.Output() + if err != nil { + tb.Errorf("failed to commit: %v %s", err, out) + } + + 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")) + if err != nil { + t.Error("No error expected") + } + if labels != nil { + t.Error("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")) + if err != nil { + t.Errorf("No error expected: %v", err) + } + if len(labels) != 2 { + t.Error("Exactly 2 git provenance labels expected") + } + dockerfileLabel := strings.Split(labels[1], "=") + if dockerfileLabel[0] != DockerfileLabel || dockerfileLabel[1] != "Dockerfile" { + t.Error("Expected a dockerfile path provenance label") + } + shaLabel := strings.Split(labels[0], "=") + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = repoDir + out, _ := cmd.Output() + if shaLabel[0] != ocispecs.AnnotationRevision || shaLabel[1] != strings.TrimSpace(string(out)) { + t.Error("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")) + if err != nil { + t.Errorf("No error expected: %v", err) + } + if len(labels) != 3 { + t.Error("Exactly 3 git provenance labels expected") + } + dockerfileLabel := strings.Split(labels[2], "=") + if dockerfileLabel[0] != DockerfileLabel || dockerfileLabel[1] != "Dockerfile" { + t.Error("Expected a dockerfile path provenance label") + } + shaLabel := strings.Split(labels[0], "=") + cmd = exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = repoDir + out, _ := cmd.Output() + if shaLabel[0] != ocispecs.AnnotationRevision || shaLabel[1] != fmt.Sprintf("%s-dirty", strings.TrimSpace(string(out))) { + t.Error("Expected a sha provenance label") + } + originLabel := strings.Split(labels[1], "=") + if originLabel[0] != ocispecs.AnnotationSource || originLabel[1] != "git@github.com:docker/buildx.git" { + t.Error("Expected a origin provenance label") + } +}