diff --git a/.gitignore b/.gitignore index 9b2c8b9c51f47..9e1150d4714c0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .*.swp .DS_Store thumbs.db +bin/ # local repository customization .envrc diff --git a/Dockerfile.fail b/Dockerfile.fail new file mode 100644 index 0000000000000..776a691574f7c --- /dev/null +++ b/Dockerfile.fail @@ -0,0 +1,2 @@ +FROM ubuntu +RUN touch /opt/i-should-not-exist && false && not-a-command \ No newline at end of file diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000000000..5fd96e1f8f81c --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,2 @@ +FROM ubuntu +RUN apt-get update && apt-get install -y git && apt-get clean \ No newline at end of file diff --git a/Makefile b/Makefile index 42a655437eb1c..66c0d927f999f 100644 --- a/Makefile +++ b/Makefile @@ -188,6 +188,11 @@ bundles: .PHONY: clean clean: clean-cache +.PHONY: interactive +interactive: # An interactive build option that will not fail on error of a layer + mkdir -p ./bin + go build -o ./bin/docker-build main.go + .PHONY: clean-cache clean-cache: ## remove the docker volumes that are used for caching in the dev-container docker volume rm -f docker-dev-cache docker-mod-cache diff --git a/api/server/backend/build/backend.go b/api/server/backend/build/backend.go index 63f650781d4c6..fec2cea74d061 100644 --- a/api/server/backend/build/backend.go +++ b/api/server/backend/build/backend.go @@ -59,18 +59,19 @@ func (b *Backend) Build(ctx context.Context, config backend.BuildConfig) (string return "", err } + // buildkit pings another grpc, so I am disabling for now var build *builder.Result - if useBuildKit { + /*if useBuildKit { build, err = b.buildkit.Build(ctx, config) if err != nil { return "", err } - } else { - build, err = b.builder.Build(ctx, config) - if err != nil { - return "", err - } + } else {*/ + build, err = b.builder.Build(ctx, config) + if err != nil { + return "", err } + // } if build == nil { return "", nil diff --git a/api/server/router/build/build_routes.go b/api/server/router/build/build_routes.go index 6daa4f01d2858..97e3211a9ecdd 100644 --- a/api/server/router/build/build_routes.go +++ b/api/server/router/build/build_routes.go @@ -38,6 +38,7 @@ func newImageBuildOptions(ctx context.Context, r *http.Request) (*types.ImageBui options := &types.ImageBuildOptions{ Version: types.BuilderV1, // Builder V1 is the default, but can be overridden Dockerfile: r.FormValue("dockerfile"), + Interactive: httputils.BoolValue(r, "interactive"), SuppressOutput: httputils.BoolValue(r, "q"), NoCache: httputils.BoolValue(r, "nocache"), ForceRemove: httputils.BoolValue(r, "forcerm"), diff --git a/api/types/client.go b/api/types/client.go index dce8260f328d1..b15d5117b858f 100644 --- a/api/types/client.go +++ b/api/types/client.go @@ -61,19 +61,22 @@ type ImageBuildOptions struct { Remove bool ForceRemove bool PullParent bool - Isolation container.Isolation - CPUSetCPUs string - CPUSetMems string - CPUShares int64 - CPUQuota int64 - CPUPeriod int64 - Memory int64 - MemorySwap int64 - CgroupParent string - NetworkMode string - ShmSize int64 - Dockerfile string - Ulimits []*container.Ulimit + + // Enable interactive debug build + Interactive bool + Isolation container.Isolation + CPUSetCPUs string + CPUSetMems string + CPUShares int64 + CPUQuota int64 + CPUPeriod int64 + Memory int64 + MemorySwap int64 + CgroupParent string + NetworkMode string + ShmSize int64 + Dockerfile string + Ulimits []*container.Ulimit // BuildArgs needs to be a *string instead of just a string so that // we can tell the difference between "" (empty string) and no value // at all (nil). See the parsing of buildArgs in diff --git a/builder/dockerfile/builder.go b/builder/dockerfile/builder.go index 9ad139b1f2f98..16ce7e70c79b6 100644 --- a/builder/dockerfile/builder.go +++ b/builder/dockerfile/builder.go @@ -281,7 +281,14 @@ func (b *Builder) dispatchDockerfileWithCancellation(ctx context.Context, parseR } dispatchRequest.state.updateRunConfig() fmt.Fprintf(b.Stdout, " ---> %s\n", stringid.TruncateID(dispatchRequest.state.imageID)) + + // Flag to indicate time to stop at particular command + // If the build fails, we stop, but pretend it did not. + stopAtCommand := false for _, cmd := range stage.Commands { + if stopAtCommand { + break + } select { case <-ctx.Done(): log.G(ctx).Debug("Builder: build cancelled!") @@ -294,7 +301,19 @@ func (b *Builder) dispatchDockerfileWithCancellation(ctx context.Context, parseR currentCommandIndex = printCommand(b.Stdout, currentCommandIndex, totalCommands, cmd) - if err := dispatch(ctx, dispatchRequest, cmd); err != nil { + // This is where we take a layer command, and run it! + // So if we want to cheat and fail and keep going, try it here? + err := dispatch(ctx, dispatchRequest, cmd) + + if err != nil { + + // Stop early if we have an interactive error + _, ok := err.(*InteractiveError) + if ok { + fmt.Fprintf(b.Stdout, " Error with ---> %s\n", cmd) + stopAtCommand = true + break + } return nil, err } dispatchRequest.state.updateRunConfig() diff --git a/builder/dockerfile/dispatchers.go b/builder/dockerfile/dispatchers.go index fe35dd206add8..6921c30ce18d3 100644 --- a/builder/dockerfile/dispatchers.go +++ b/builder/dockerfile/dispatchers.go @@ -15,6 +15,7 @@ import ( "sort" "strings" + "github.com/containerd/log" "github.com/containerd/platforms" "github.com/docker/docker/api" "github.com/docker/docker/api/types/strslice" @@ -31,6 +32,14 @@ import ( "github.com/pkg/errors" ) +// Custom error to indicate build errored, but in interactive mode +// and should continue with next layers +type InteractiveError struct { + msg string +} + +func (e *InteractiveError) Error() string { return e.msg } + // ENV foo bar // // Sets the environment variable foo to bar, also makes interpolation @@ -375,21 +384,41 @@ func dispatchRun(ctx context.Context, d dispatchRequest, c *instructions.RunComm return err } - if err := d.builder.containerManager.Run(ctx, cID, d.builder.Stdout, d.builder.Stderr); err != nil { - if err, ok := err.(*statusCodeError); ok { - // TODO: change error type, because jsonmessage.JSONError assumes HTTP - msg := fmt.Sprintf( - "The command '%s' returned a non-zero code: %d", - strings.Join(runConfig.Cmd, " "), err.StatusCode()) - if err.Error() != "" { - msg = fmt.Sprintf("%s: %s", msg, err.Error()) + err = d.builder.containerManager.Run(ctx, cID, d.builder.Stdout, d.builder.Stderr) + + // If the build is interactive, we allow it to continue and commit + if err != nil { + + // We will still return an error, but a custom type that indicates to the + // caller we should not proceed to the next layer. + if d.builder.options.Interactive { + log.G(ctx).Infof("[BUILDER] Error with: %s, interactive and continuing", runConfig.Cmd) + + // Commit and create a new interactive error to return + if d.state.operatingSystem == "windows" { + runConfigForCacheProbe.ArgsEscaped = stateRunConfig.ArgsEscaped + } + err := d.builder.commitContainer(ctx, d.state, cID, runConfigForCacheProbe) + if err != nil { + return err } - return &jsonmessage.JSONError{ - Message: msg, - Code: err.StatusCode(), + return &InteractiveError{msg: fmt.Sprintf("Error with %s, continuing", runConfig.Cmd)} + } else { + if err, ok := err.(*statusCodeError); ok { + // TODO: change error type, because jsonmessage.JSONError assumes HTTP + msg := fmt.Sprintf( + "The command '%s' returned a non-zero code: %d", + strings.Join(runConfig.Cmd, " "), err.StatusCode()) + if err.Error() != "" { + msg = fmt.Sprintf("%s: %s", msg, err.Error()) + } + return &jsonmessage.JSONError{ + Message: msg, + Code: err.StatusCode(), + } } + return err } - return err } // Don't persist the argsEscaped value in the committed image. Use the original diff --git a/client/image_build.go b/client/image_build.go index 62037c7f9460e..01ea05f7b01c8 100644 --- a/client/image_build.go +++ b/client/image_build.go @@ -76,6 +76,11 @@ func (cli *Client) imageBuildOptionsToQuery(ctx context.Context, options types.I query.Set("forcerm", "1") } + // Interactive debug build (don't fail but return early) + if options.Interactive { + query.Set("interactive", "1") + } + if options.PullParent { query.Set("pull", "1") } diff --git a/main.go b/main.go new file mode 100644 index 0000000000000..66ad0b1511bdc --- /dev/null +++ b/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/archive" + "github.com/docker/docker/pkg/jsonmessage" +) + +// This should be run in the .devcontainer environment, and following the instructions +// here: https://github.com/moby/moby/blob/master/docs/contributing/set-up-dev-env.md +// to build and start the daemon: + +// In one terminal (this builds and starts the daemon) +// hack/make.sh binary install-binary run + +// In another terminal: +// make interactive +// This will work +// ./bin/docker-build -t test -f Dockerfile.test . + +// This will fail +// ./bin/docker-build -t fail -f Dockerfile.fail . + +// But with interactive -i, it will work! +// ./bin/docker-build -i -t works -f Dockerfile.fail . + +// copyFile copies a file from a source to a destination +func copyFile(src, dest string) { + sourceFile, err := os.Open(src) + if err != nil { + panic(err) + } + defer sourceFile.Close() + + destinationFile, err := os.Create(dest) + if err != nil { + panic(err) + } + defer destinationFile.Close() + + _, err = io.Copy(destinationFile, sourceFile) + if err != nil { + panic(err) + } +} + +// GetImageIDFromBody reads the image ID from the build response body. +func GetImageIDFromBody(body io.Reader) string { + var ( + jm jsonmessage.JSONMessage + br types.BuildResult + dec = json.NewDecoder(body) + ) + for { + err := dec.Decode(&jm) + if err == io.EOF { + break + } + if jm.Aux == nil { + continue + } + json.Unmarshal(*jm.Aux, &br) + break + } + io.Copy(io.Discard, body) + return br.ID +} + +func main() { + + target := flag.String("t", "", "Build target") + dockerfile := flag.String("f", "Dockerfile", "Dockerfile path") + interactiveDebug := flag.Bool("i", false, "interactive debug build") + + flag.Parse() + args := flag.Args() + + if *target == "" { + log.Panicf("Please enter a -t target to build") + } + buildContext := "." + if len(args) > 0 { + buildContext = args[0] + } + fmt.Println("🦎 Dinosaur debug builder:") + fmt.Println(" interactive debug:", *interactiveDebug) + fmt.Println(" dockerfile:", *dockerfile) + fmt.Println(" context:", buildContext) + fmt.Println(" target:", *target) + + apiClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + panic(err) + } + defer apiClient.Close() + + // Create temporary directory for reader (context) + // We will copy dockerfile there + tmp, err := os.MkdirTemp("", "docker-dinosaur-build") + if err != nil { + log.Fatalf("could not create temporary directory: %v", err) + } + defer os.RemoveAll(tmp) + + copyFile(*dockerfile, filepath.Join(tmp, "Dockerfile")) + reader, err := archive.TarWithOptions(tmp, &archive.TarOptions{}) + if err != nil { + log.Fatalf("could not create tar: %v", err) + } + + resp, err := apiClient.ImageBuild( + context.Background(), + reader, + types.ImageBuildOptions{ + Remove: true, + ForceRemove: true, + Dockerfile: "Dockerfile", + Tags: []string{*target}, + Interactive: *interactiveDebug, + }, + ) + if err != nil { + log.Fatalf("could not build image: %v", err) + } + + if resp.Body != nil { + defer resp.Body.Close() + } + img := GetImageIDFromBody(resp.Body) + if img == "" { + fmt.Println("😭 Sorry, that image build failed.") + } else { + fmt.Println(img) + } +}