diff --git a/go.mod b/go.mod index df99377..08ac901 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/ecr v1.18.7 github.com/bep/debounce v1.2.1 github.com/briandowns/spinner v1.23.0 - github.com/cenkalti/backoff/v4 v4.2.0 github.com/containerd/containerd v1.7.2 github.com/docker/cli v23.0.6+incompatible github.com/docker/docker v23.0.6+incompatible @@ -154,7 +153,6 @@ require ( github.com/moby/locker v1.0.1 // indirect github.com/moby/patternmatcher v0.5.0 // indirect github.com/moby/spdystream v0.2.0 // indirect - github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/sys/signal v0.7.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 385b241..cd7c138 100644 --- a/go.sum +++ b/go.sum @@ -145,8 +145,6 @@ github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx2 github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng= github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembjv71DPz3uX/V/6MMlSyD9JBQ6kQ= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= -github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= -github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -585,7 +583,6 @@ github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8 github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= -github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI= github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA= diff --git a/launchpad/build.go b/launchpad/build.go index 0fe5c53..fe75517 100644 --- a/launchpad/build.go +++ b/launchpad/build.go @@ -2,45 +2,26 @@ package launchpad import ( "context" - "encoding/base64" - "encoding/json" "fmt" - "io" - "net" "os" "os/exec" "path/filepath" - "strconv" "strings" - "sync" "time" - "github.com/cenkalti/backoff/v4" dockertypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/api/types/versions" dockerclient "github.com/docker/docker/client" - "github.com/docker/docker/pkg/archive" - "github.com/docker/docker/pkg/jsonmessage" - "github.com/moby/buildkit/frontend/dockerfile/dockerignore" - "github.com/moby/buildkit/session" - "github.com/moby/buildkit/session/sshforward/sshprovider" - "github.com/moby/buildkit/util/progress/progressui" - "github.com/moby/term" "github.com/pkg/errors" "github.com/samber/lo" "github.com/spf13/afero" "go.jetpack.io/launchpad/goutil/errorutil" - "go.jetpack.io/launchpad/launchpad/authprovider" "go.jetpack.io/launchpad/padcli/hook" "go.jetpack.io/launchpad/padcli/jetconfig" "go.jetpack.io/launchpad/padcli/provider" "go.jetpack.io/launchpad/pkg/docker" "go.jetpack.io/launchpad/pkg/jetlog" "go.jetpack.io/launchpad/pkg/kubevalidate" - - controlapi "github.com/moby/buildkit/api/services/control" - buildkitClient "github.com/moby/buildkit/client" ) const ( @@ -299,241 +280,17 @@ func executePlanUsingDocker(ctx context.Context, plan *BuildPlan) (err error) { // Pulling out in case we want to allow customizing this var in the future. dockerfile := "Dockerfile" - imageBuildOptions := dockertypes.ImageBuildOptions{ + imageBuildOptions := docker.BuildOpts{ BuildArgs: lo.MapValues(plan.buildOpts.BuildArgs, func(val, _ string) *string { return &val }), - Dockerfile: dockerfile, - Platform: plan.buildOpts.Platform, - SuppressOutput: false, - Tags: []string{plan.image.String()}, - Labels: plan.imageLabels, - } - - if useCli, _ := strconv.ParseBool(os.Getenv("LAUNCHPAD_USE_DOCKER_CLI")); useCli { - return docker.Build(ctx, filepath.Dir(plan.dockerfilePath), imageBuildOptions) - } - - cli, err := dockerclient.NewClientWithOpts( - dockerclient.FromEnv, - dockerclient.WithAPIVersionNegotiation(), - ) - if err != nil { - return errors.WithStack(err) - } - - version, err := getDockerBuilderVersion(ctx, plan.projectDir) - if err != nil { - return errors.Wrap(err, "failed to detect docker builder version") - } - imageBuildOptions.Version = version - - buildCtx, err := dockerBuildContext(plan, dockerfile) - if err != nil { - return errors.Wrap(err, "tar docker build context") - } - defer buildCtx.Close() - - // TODO: docker.Build does not currently implement remote cache - if plan.buildOpts.RemoteCache { - imageBuildOptions.BuildArgs["BUILDKIT_INLINE_CACHE"] = lo.ToPtr("1") - c, err := decodeCredentials(plan.buildOpts.RepoConfig) - if err != nil { - return errors.WithStack(err) - } - imageBuildOptions.AuthConfigs = map[string]dockertypes.AuthConfig{ - plan.buildOpts.GetRepoHost(): { - Username: c.Username, - Password: c.Password, - }, - } - if plan.buildOpts.ImageRepoForCache != "" { - imageBuildOptions.CacheFrom = []string{ - plan.buildOpts.ImageRepoForCache + ":latest", - } - } - } - - if err = initBuildkitSession(ctx, cli, &imageBuildOptions, plan); err != nil { - return errors.WithStack(err) + Dockerfile: dockerfile, + Platform: plan.buildOpts.Platform, + Tags: []string{plan.image.String()}, + Labels: plan.imageLabels, } - // Sometimes Docker fails with a "no active session" error. - // There isn't a reliable fix for this other than retry. This - // retry mechanism reduces the chance of that error persisting. - b := backoff.NewExponentialBackOff() - // 3 minutes seems long, we can shorten this if we observe retry is taking too long - b.MaxElapsedTime = 3 * time.Minute - resp := dockertypes.ImageBuildResponse{} - imageBuildRetrier := func() error { - resp, err = cli.ImageBuild(ctx, buildCtx, imageBuildOptions) - if err != nil { - if strings.Contains(err.Error(), "no active session") { - jetlog.Logger(ctx).Print( - "\nERROR: No active Buildkit session found. Retrying...\n", - ) - } - return err - } else { - // if error is not "no active session" we don't need to retry - return backoff.Permanent(err) - } - } - err = backoff.Retry(imageBuildRetrier, b) - if err != nil { - return errors.Wrapf( - err, - "failed docker image build for %s", - plan.image.String(), - ) - } - - defer resp.Body.Close() - - if version == dockertypes.BuilderBuildKit { - customWriter := dockerWriter{} - err = writeDockerOutput(ctx, customWriter, resp) - return errors.Wrap(err, "failed to print docker image build output") - } - - // thank you: https://stackoverflow.com/a/58742917 - termFd, isTerm := term.GetFdInfo(os.Stderr) - customWriter := dockerWriter{} - err = jsonmessage.DisplayJSONMessagesStream(resp.Body, customWriter, termFd, isTerm, nil /*auxCallback */) - return errors.Wrap(err, "failed to print image-build output") -} - -// writeDockerOutput will convert the buildkit graph structures to be printable. -// -// Specifically: -// - it converts the `resp types.ImageBuildResponse` argument into JSONMessage structs -// - JSONMessage.Aux has protobuf messages of the buildkit graph -// - These protobuf messages are converted to buildkit's golang structs, which are placed into SolveStatus struct. -// - SolveStatus structs are consumed by moby's progressui library to be printed -// -// inspired by: -// https://github.com/docker/docker-ce/blob/master/components/cli/cli/command/image/build_buildkit.go -func writeDockerOutput(ctx context.Context, jetpackPrinter io.Writer, resp dockertypes.ImageBuildResponse) error { - - // This WaitGroup is needed to ensure that all the solveStatus values below are printed - // prior to exiting. Without this, the next launchpad-step's output (publish) may begin printing. - var wg sync.WaitGroup - defer wg.Wait() - - // This channel is used to send SolveStatus from auxCallback to progressui.DisplaySolveStatus - solveStatus := make(chan *buildkitClient.SolveStatus) - defer close(solveStatus) - - // The caller must wait for this goroutine to print all SolveStatuses - wg.Add(1) - go func() { - defer wg.Done() - - // A console can be used to print docker build output, and then "rewrite it" so that - // it becomes compact if the build steps were successful. This is similar to what - // docker CLI does for `docker image build`. - // - // However, due to how `jetpackPrinter` rewrites the output (via indentation) - // the progressui output gets messed up. Hence, this is commented out for now. - // - // For now, we can pass "nil" as console to progressui.DisplaySolveStatus. - // - // import "github.com/containerd/console" - //cons, err := console.ConsoleFromFile(jetpackPrinter) - //if err != nil { - // return errors.Wrap(err, "failed to set console") - //} - - _, err := progressui.DisplaySolveStatus(ctx, "", nil /*console*/, jetpackPrinter, solveStatus) - if err != nil { - // Note, we are okay printing and continuing due to this error. - // The progressui is for printing docker build info, but we shouldn't - // block a deploy if there is some error due to it. - fmt.Printf("ERROR: from DisplaySolveStatus. %v\n", err) - } - }() - - // auxCallback constructs SolveStatus structs to send to the solveStatus channel, - // which is in turn consumed by DisplaySolveStatus in the above goroutine. - // - // auxCallback is invoked by jsonmessage.DisplayJSONMessagesStream below. - auxCallback := func(msg jsonmessage.JSONMessage) { - if msg.ID != "moby.buildkit.trace" { - return - } - - var dt []byte - // ignoring all messages that are not understood - if err := json.Unmarshal(*msg.Aux, &dt); err != nil { - // deliberately not logging error - return - } - var resp controlapi.StatusResponse - if err := (&resp).Unmarshal(dt); err != nil { - // deliberately not logging error - return - } - - // The following lines copy protobuf structs of the buildkit Graph - // into the buildkit's golang structs for the graph represented by SolveStatus - s := buildkitClient.SolveStatus{} - for _, v := range resp.Vertexes { - s.Vertexes = append(s.Vertexes, &buildkitClient.Vertex{ - Digest: v.Digest, - Inputs: v.Inputs, - Name: v.Name, - Started: v.Started, - Completed: v.Completed, - Error: v.Error, - Cached: v.Cached, - }) - } - for _, v := range resp.Statuses { - s.Statuses = append(s.Statuses, &buildkitClient.VertexStatus{ - ID: v.ID, - Vertex: v.Vertex, - Name: v.Name, - Total: v.Total, - Current: v.Current, - Timestamp: v.Timestamp, - Started: v.Started, - Completed: v.Completed, - }) - } - for _, v := range resp.Logs { - s.Logs = append(s.Logs, &buildkitClient.VertexLog{ - Vertex: v.Vertex, - Stream: int(v.Stream), - Data: v.Msg, - Timestamp: v.Timestamp, - }) - } - - solveStatus <- &s - } - - termFd, isTerm := term.GetFdInfo(jetpackPrinter) - err := jsonmessage.DisplayJSONMessagesStream(resp.Body, jetpackPrinter, termFd, isTerm, auxCallback) - return errors.Wrap(err, "failed to print image-push output") -} - -func dockerBuildContext( - plan *BuildPlan, - dockerfileName string, -) (io.ReadCloser, error) { - opts := archive.TarOptions{} - f, err := os.Open(filepath.Join(plan.projectDir, ".dockerignore")) - if err == nil { - defer f.Close() - patterns, err := dockerignore.ReadAll(f) - if err != nil { - return nil, errors.WithStack(err) - } - opts.ExcludePatterns = lo.Filter(patterns, func(s string, _ int) bool { - return s != dockerfileName - }) - } - return archive.TarWithOptions(plan.projectDir, &opts) + return docker.Build(ctx, filepath.Dir(plan.dockerfilePath), imageBuildOptions) } // getImageNameAndTag returns a valid docker image name @@ -548,110 +305,6 @@ func getImageNameAndTag(ctx context.Context, opts *BuildOptions) (string, string return name, generateDateImageTag(opts.TagPrefix), nil } -// This function inspired by https://github.com/hashicorp/waypoint/pull/1937 -func initBuildkitSession( - ctx context.Context, - dockerClient *dockerclient.Client, - buildOpts *dockertypes.ImageBuildOptions, - plan *BuildPlan, -) error { - if buildOpts.Version == dockertypes.BuilderV1 { - return nil - } - - const minDockerVersion = "1.39" // This is only required for buildkit. - - if !versions.GreaterThanOrEqualTo( - dockerClient.ClientVersion(), minDockerVersion) { - return errOldDockerAPIVersion - } - - buildkitSession, err := session.NewSession(ctx, "jetpack", "") - if err != nil { - return errors.WithStack(err) - } - defer buildkitSession.Close() - - // This env-var is set when sshagent is configured - if os.Getenv("SSH_AUTH_SOCK") != "" { - // This allows us to use --mount=type=ssh in the dockerfile - configs := []sshprovider.AgentConfig{{ID: "default"}} - if a, err := sshprovider.NewSSHAgentProvider(configs); err != nil { - // TODO(Landau), show good user error if ssh keys are not added to agent - return errors.WithStack(err) - } else { - buildkitSession.Allow(a) - } - } else { - jetlog.Logger(ctx).IndentedPrintln("Warning: Did not find SSH_AUTH_SOCK env var. " + - "Skipping ssh agent provider for docker buildkit.") - } - - if plan.buildOpts.RemoteCache { - c, err := decodeCredentials(plan.buildOpts.RepoConfig) - if err != nil { - return errors.WithStack(err) - } - buildkitSession.Allow(authprovider.NewDockerAuthProvider(authprovider.NewConfig( - plan.buildOpts.GetRepoHost(), - c.Username, - c.Password, - ))) - } - - dialSession := func( - ctx context.Context, - proto string, - meta map[string][]string, - ) (net.Conn, error) { - return dockerClient.DialHijack(ctx, "/session", proto, meta) - } - - go func() { - err = buildkitSession.Run(ctx, dialSession) - if err != nil { - panic(err) // Is there a better way to handle this? - } - }() - - buildOpts.SessionID = buildkitSession.ID() - - return nil -} - -func getDockerBuilderVersion(ctx context.Context, path string) (dockertypes.BuilderVersion, error) { - // Respect env-var - if os.Getenv("DOCKER_BUILDKIT") == "1" { - jetlog.Logger(ctx).IndentedPrintln("Detecting DOCKER_BUILDKIT=1. Using Buildkit Docker builder.") - return dockertypes.BuilderBuildKit, nil - } - if os.Getenv("DOCKER_BUILDKIT") == "0" { - jetlog.Logger(ctx).IndentedPrintln("Detecting DOCKER_BUILDKIT=0. Using v1 Docker builder.") - return dockertypes.BuilderV1, nil - } - - // Fallback to reading the Dockerfile - content, err := os.ReadFile(filepath.Join(path, "Dockerfile")) - if err != nil { - jetlog.Logger(ctx).IndentedPrintf("No detecting Dockerfile at %s. Using v1 Docker builder.\n", path) - return dockertypes.BuilderV1, errors.WithStack(err) - } - trimmed := strings.TrimSpace(string(content)) - - if strings.HasPrefix(trimmed, "# syntax") { - jetlog.Logger(ctx).IndentedPrintln("Detecting # syntax in Dockerfile. Using Buildkit Docker builder.") - return dockertypes.BuilderBuildKit, nil - } - - if strings.Contains(trimmed, "RUN --mount") { - jetlog.Logger(ctx).IndentedPrintln("Detecting RUN --mount in Dockerfile. Using Buildkit Docker builder.") - return dockertypes.BuilderBuildKit, nil - } - - jetlog.Logger(ctx).IndentedPrintln("Using v1 Docker builder.") - return dockertypes.BuilderV1, nil -} - // DockerCleanup deletes all docker images that are not the latest based on timestamp. // This will only delete the images that belong to the current project. func DockerCleanup(ctx context.Context, labelIdentifier string) error { @@ -700,20 +353,3 @@ func DockerCleanup(ctx context.Context, labelIdentifier string) error { return nil } - -type imageRepoCredentials struct { - Username string `json:"username"` - Password string `json:"password"` -} - -func decodeCredentials(c provider.RepoConfig) (*imageRepoCredentials, error) { - if c == nil || c.GetCredentials() == "" { - return nil, nil - } - s, err := base64.StdEncoding.DecodeString(c.GetCredentials()) - if err != nil { - return nil, errors.WithStack(err) - } - creds := &imageRepoCredentials{} - return creds, json.Unmarshal(s, creds) -} diff --git a/launchpad/errors.go b/launchpad/errors.go index b960fa0..73afc08 100644 --- a/launchpad/errors.go +++ b/launchpad/errors.go @@ -32,10 +32,6 @@ var errUserNoDockerClient = errorutil.NewUserError( "Unable to get docker cli client. Are you sure Docker is installed?", ) -var errOldDockerAPIVersion = errorutil.NewUserError( - "Launchpad requires your Docker API version to be at least 1.39", -) - var errNoValidChartVersions = errors.New( "Could not find any valid chart versions", ) diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go index f7227b8..2842eff 100644 --- a/pkg/docker/docker.go +++ b/pkg/docker/docker.go @@ -1,18 +1,33 @@ package docker import ( + "bytes" "context" "fmt" "os" "os/exec" "path/filepath" - "github.com/docker/docker/api/types" + "go.jetpack.io/launchpad/goutil/errorutil" ) -func Build(ctx context.Context, path string, opts types.ImageBuildOptions) error { - cmd := command(ctx, "docker", "build", path) - cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1") +type BuildOpts struct { + BuildArgs map[string]*string + Dockerfile string + Labels map[string]string + Platform string + Tags []string +} + +func Build(ctx context.Context, path string, opts BuildOpts) error { + if err := ensureDocker(); err != nil { + return err + } + + // TODO implement remote-cache + cmd := exec.CommandContext(ctx, "docker", "build", path) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr for _, tag := range opts.Tags { cmd.Args = append(cmd.Args, "-t", tag) } @@ -25,13 +40,36 @@ func Build(ctx context.Context, path string, opts types.ImageBuildOptions) error if opts.Dockerfile != "" { cmd.Args = append(cmd.Args, "-f", filepath.Join(path, opts.Dockerfile)) } - fmt.Fprintln(os.Stderr, cmd.String()) + for k, v := range opts.BuildArgs { + cmd.Args = append(cmd.Args, "--build-arg", fmt.Sprintf("%s=%s", k, *v)) + } + if os.Getenv("SSH_AUTH_SOCK") != "" { + cmd.Args = append(cmd.Args, "--ssh", "default") + } + fmt.Fprintf(os.Stderr, "Running command: %s\n", cmd.String()) return cmd.Run() } -func command(ctx context.Context, name string, arg ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, name, arg...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd +func ensureDocker() error { + _, err := exec.LookPath("docker") + if err != nil { + return errorutil.NewUserError( + "docker not found in PATH. Ensure Docker is installed and in your PATH.") + } + cmd := exec.Command("docker", "version", "--format", "{{.Server.Version}}") + out, err := cmd.Output() + if err != nil { + return errorutil.NewUserError( + "failed to get Docker daemon version. Ensure Docker daemon is running.") + } + version := string(bytes.TrimSpace(out)) + if version < "1.39" { + return errOldDockerAPIVersion + } + fmt.Fprintf( + os.Stderr, + "Using Docker daemon version: %s\n", + version, + ) + return nil } diff --git a/pkg/docker/errors.go b/pkg/docker/errors.go new file mode 100644 index 0000000..f245acd --- /dev/null +++ b/pkg/docker/errors.go @@ -0,0 +1,7 @@ +package docker + +import "go.jetpack.io/launchpad/goutil/errorutil" + +var errOldDockerAPIVersion = errorutil.NewUserError( + "Launchpad requires your Docker API version to be at least 1.39", +)