Repository: coder/envbuilder (go)

Issue:
PoC: Build a tool to validate file hashes against layer caches
This issue tracks the implementation of a PoC to validate the path forward for #128.

To support #185, we must be able to figure out if a cached layer image is valid, given the state  of files relevant to building the container (think contents of `Dockerfile`, `devcontainer.json`, and hashes of any files pulled in via `COPY`-directive).

In this PoC, we will implement this logic (files -> layer) as a subcommand of envbulider. This will require parsing and understanding of `Dockerfile` to understand if a change modifies the outcome, and which layers remain intact.

Pull request description:
feat: implement reproducible build and get cached image
This PR is a PoC, it builds upon #197 and https://github.com/coder/kaniko/pull/12 and shows how build cache + pushed image can be utilized.

Example get-cached-image:

```
❯ registry_ip=$(docker inspect envbuilder-registry | jq -r '.[].NetworkSettings.IPAddress'); docker run -it --rm -v /tmp/envbuilder:/workspaces -e ENVBUILDER_CACHE_REPO=${registry_ip}:5000/local/cache -e ENVBUILDER_GIT_URL=https://github.com/coder/envbuilder-starter-devcontainer -e ENVBUILDER_INIT_SCRIPT=exit -e ENVBUILDER_GET_CACHED_IMAGE=true envbuilder:latest
envbuilder - Build development environments from repositories in a container
#1: 📦 Cloning https://github.com/coder/envbuilder-starter-devcontainer to /workspaces/envbuilder-starter-devcontainer...
...
2: RUN apt-get update
2: unable to build fake image: error fake building stage: uncached command *commands.RunMarkerCommand is not supported in fake build
```

Now, do build:

```
❯ registry_ip=$(docker inspect envbuilder-registry | jq -r '.[].NetworkSettings.IPAddress'); docker run -it --rm -v /tmp/envbuilder:/workspaces -e ENVBUILDER_CACHE_REPO=${registry_ip}:5000/local/cache -e ENVBUILDER_GIT_URL=https://github.com/coder/envbuilder-starter-devcontainer -e ENVBUILDER_INIT_SCRIPT=exit -e ENVBUILDER_PUSH_IMAGE=true envbuilder:latest
envbuilder - Build development environments from repositories in a container
#1: 📦 Cloning https://github.com/coder/envbuilder-starter-devcontainer to /workspaces/envbuilder-starter-devcontainer...
...
2: Running: [/bin/sh -c echo "PS1='🐳 \[\033[1;36m\]\h \[\033[1;34m\]\W\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'" > ~/.bashrc]
2: Taking snapshot of files...
2: Pushing layer 172.17.0.3:5000/local/cache:445691a7ed1a9ce9faac28095b3f3b77a0043c7604b099c299bd4abed5089f57 to cache now
2: Pushing image to 172.17.0.3:5000/local/cache:445691a7ed1a9ce9faac28095b3f3b77a0043c7604b099c299bd4abed5089f57
2: Pushed 172.17.0.3:5000/local/cache@sha256:440a1a035eb3ba7597aa7b3f1b5e08c67c3c5afed33bea057dd18640420ca115
#2: 🏗️ Built image! [16.509477016s]
#3: 🏗️ Pushing image...
3: Pushing image to 172.17.0.3:5000/local/cache
3: Pushed 172.17.0.3:5000/local/cache@sha256:9c102cd02585011898abd11ea367ba5368415b9cd9ecebbb07dcbb57f6e4cd63
#3: 🏗️ Pushed image! [1.003674439s]
=== Running the init command /bin/sh [-c exit] as the "coder" user...
```

Try get-cached-image again:

```
❯ registry_ip=$(docker inspect envbuilder-registry | jq -r '.[].NetworkSettings.IPAddress'); docker run -it --rm -v /tmp/envbuilder:/workspaces -e ENVBUILDER_CACHE_REPO=${registry_ip}:5000/local/cache -e ENVBUILDER_GIT_URL=https://github.com/coder/envbuilder-starter-devcontainer -e ENVBUILDER_INIT_SCRIPT=exit -e ENVBUILDER_GET_CACHED_IMAGE=true envbuilder:latest
envbuilder - Build development environments from repositories in a container
#1: 📦 Cloning https://github.com/coder/envbuilder-starter-devcontainer to /workspaces/envbuilder-starter-devcontainer...
...
#2: 🏗️ Building fake image...
2: Retrieving image manifest ubuntu
2: Retrieving image ubuntu from registry index.docker.io
2: Built cross stage deps: map[]
2: Retrieving image manifest ubuntu
2: Returning cached image manifest
2: Executing 0 build triggers
2: Checking for cached layer 172.17.0.3:5000/local/cache:bd0def545b590c39144a57152c120a5baf8e3cad7c70298bbacfa5116ef1ca3c...
2: Using caching version of cmd: RUN apt-get update
2: Checking for cached layer 172.17.0.3:5000/local/cache:591f8c35a55cc60f7bc49fa3f2c99360616a9b6b35908b8aaa7392e7820b0c36...
2: Using caching version of cmd: RUN apt-get install vim sudo curl -y
2: Checking for cached layer 172.17.0.3:5000/local/cache:1cb1d8b00537df6f0638913717931b76e7b80b8c150448ce4096f4eef042b355...
2: Using caching version of cmd: RUN useradd -m -s /bin/bash -G sudo coder
2: Cmd: USER
2: Checking for cached layer 172.17.0.3:5000/local/cache:445691a7ed1a9ce9faac28095b3f3b77a0043c7604b099c299bd4abed5089f57...
2: Using caching version of cmd: RUN echo "PS1='🐳 \[\033[1;36m\]\h \[\033[1;34m\]\W\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'" > ~/.bashrc
2: RUN apt-get update
2: Found cached layer, faking extraction to filesystem
2: RUN apt-get install vim sudo curl -y
2: Found cached layer, faking extraction to filesystem
2: RUN useradd -m -s /bin/bash -G sudo coder
2: Found cached layer, faking extraction to filesystem
2: USER coder
2: Cmd: USER
2: No files changed in this command, skipping snapshotting.
2: RUN echo "PS1='🐳 \[\033[1;36m\]\h \[\033[1;34m\]\W\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'" > ~/.bashrc
2: Found cached layer, faking extraction to filesystem
2: Mapping stage idx 0 to digest sha256:3ff463dd27a13ebcdfc74fc790e7a8da0b5373110b4edd1dc1426c4b4ed80e27
2: Mapping digest sha256:3ff463dd27a13ebcdfc74fc790e7a8da0b5373110b4edd1dc1426c4b4ed80e27 to cachekey 445691a7ed1a9ce9faac28095b3f3b77a0043c7604b099c299bd4abed5089f57
#2: 🏗️ Built fake image! [1.298919769s]
172.17.0.3:5000/local/cache@sha256:9c102cd02585011898abd11ea367ba5368415b9cd9ecebbb07dcbb57f6e4cd63
```

And finally:

```
❯ docker run -it --rm --entrypoint /usr/bin/bash localhost:5000/local/cache@sha256:9c102cd02585011898abd11ea367ba5368415b9cd9ecebbb07dcbb57f6e4cd63
Unable to find image 'localhost:5000/local/cache@sha256:9c102cd02585011898abd11ea367ba5368415b9cd9ecebbb07dcbb57f6e4cd63' locally
localhost:5000/local/cache@sha256:9c102cd02585011898abd11ea367ba5368415b9cd9ecebbb07dcbb57f6e4cd63: Pulling from local/cache
49b384cc7b4a: Already exists
4e5c7f007bcd: Pull complete
b3cda1764760: Pull complete
62220e490978: Pull complete
07141b433862: Pull complete
Digest: sha256:9c102cd02585011898abd11ea367ba5368415b9cd9ecebbb07dcbb57f6e4cd63
Status: Downloaded newer image for localhost:5000/local/cache@sha256:9c102cd02585011898abd11ea367ba5368415b9cd9ecebbb07dcbb57f6e4cd63
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

🐳 e54cf491d1a7 / #
```

Closes #186.

Patch:
diff --git a/README.md b/README.md
index 229f0e5..362a0dd 100644
--- a/README.md
+++ b/README.md
@@ -363,4 +363,6 @@ On MacOS or Windows systems, we recommend either using a VM or the provided `.de
 | `--coder-agent-url` | `CODER_AGENT_URL` |  | URL of the Coder deployment. If CODER_AGENT_TOKEN is also set, logs from envbuilder will be forwarded here and will be visible in the workspace build logs. |
 | `--coder-agent-token` | `CODER_AGENT_TOKEN` |  | Authentication token for a Coder agent. If this is set, then CODER_AGENT_URL must also be set. |
 | `--coder-agent-subsystem` | `CODER_AGENT_SUBSYSTEM` |  | Coder agent subsystems to report when forwarding logs. The envbuilder subsystem is always included. |
+| `--push-image` | `ENVBUILDER_PUSH_IMAGE` |  | Push the built image to a remote registry. This option forces a reproducible build. |
+| `--get-cached-image` | `ENVBUILDER_GET_CACHED_IMAGE` |  | Print the digest of the cached image, if available. Exits with an error if not found. |
 <!--- END docsgen --->
diff --git a/envbuilder.go b/envbuilder.go
index a6dc36a..307a55d 100644
--- a/envbuilder.go
+++ b/envbuilder.go
@@ -98,6 +98,9 @@ func Run(ctx context.Context, options Options) error {
 	if options.InitCommand == "" {
 		options.InitCommand = "/bin/sh"
 	}
+	if options.CacheRepo == "" && options.PushImage {
+		return fmt.Errorf("--cache-repo must be set when using --push-image")
+	}
 	// Default to the shell!
 	initArgs := []string{"-c", options.InitScript}
 	if options.InitArgs != "" {
@@ -118,11 +121,11 @@ func Run(ctx context.Context, options Options) error {
 		options.WorkspaceFolder = f
 	}
 
-	stageNumber := 1
+	stageNumber := 0
 	startStage := func(format string, args ...any) func(format string, args ...any) {
 		now := time.Now()
-		stageNum := stageNumber
 		stageNumber++
+		stageNum := stageNumber
 		options.Logger(notcodersdk.LogLevelInfo, "#%d: %s", stageNum, fmt.Sprintf(format, args...))
 
 		return func(format string, args ...any) {
@@ -341,7 +344,7 @@ func Run(ctx context.Context, options Options) error {
 
 	HijackLogrus(func(entry *logrus.Entry) {
 		for _, line := range strings.Split(entry.Message, "\r") {
-			options.Logger(notcodersdk.LogLevelInfo, "#2: %s", color.HiBlackString(line))
+			options.Logger(notcodersdk.LogLevelInfo, "#%d: %s", stageNumber, color.HiBlackString(line))
 		}
 	})
 
@@ -471,20 +474,24 @@ func Run(ctx context.Context, options Options) error {
 			cacheTTL = time.Hour * 24 * time.Duration(options.CacheTTLDays)
 		}
 
-		endStage := startStage("🏗️ Building image...")
 		// At this point we have all the context, we can now build!
 		registryMirror := []string{}
 		if val, ok := os.LookupEnv("KANIKO_REGISTRY_MIRROR"); ok {
 			registryMirror = strings.Split(val, ";")
 		}
-		image, err := executor.DoBuild(&config.KanikoOptions{
+		var destinations []string
+		if options.CacheRepo != "" {
+			destinations = append(destinations, options.CacheRepo)
+		}
+		opts := &config.KanikoOptions{
 			// Boilerplate!
 			CustomPlatform:    platforms.Format(platforms.Normalize(platforms.DefaultSpec())),
 			SnapshotMode:      "redo",
 			RunV2:             true,
 			RunStdout:         stdoutWriter,
 			RunStderr:         stderrWriter,
-			Destinations:      []string{"local"},
+			Destinations:      destinations,
+			NoPush:            !options.PushImage || len(destinations) == 0,
 			CacheRunLayers:    true,
 			CacheCopyLayers:   true,
 			CompressedCaching: true,
@@ -515,11 +522,40 @@ func Run(ctx context.Context, options Options) error {
 				RegistryMirrors: registryMirror,
 			},
 			SrcContext: buildParams.BuildContext,
-		})
+
+			// For cached image utilization, produce reproducible builds.
+			Reproducible: options.PushImage,
+		}
+
+		if options.GetCachedImage {
+			endStage := startStage("🏗️ Checking for cached image...")
+			image, err := executor.DoCacheProbe(opts)
+			if err != nil {
+				return nil, xerrors.Errorf("get cached image: %w", err)
+			}
+			digest, err := image.Digest()
+			if err != nil {
+				return nil, xerrors.Errorf("get cached image digest: %w", err)
+			}
+			endStage("🏗️ Found cached image!")
+			_, _ = fmt.Fprintf(os.Stdout, "%s@%s\n", options.CacheRepo, digest.String())
+			os.Exit(0)
+		}
+
+		endStage := startStage("🏗️ Building image...")
+		image, err := executor.DoBuild(opts)
 		if err != nil {
-			return nil, err
+			return nil, xerrors.Errorf("do build: %w", err)
 		}
 		endStage("🏗️ Built image!")
+		if options.PushImage {
+			endStage = startStage("🏗️ Pushing image...")
+			if err := executor.DoPush(image, opts); err != nil {
+				return nil, xerrors.Errorf("do push: %w", err)
+			}
+			endStage("🏗️ Pushed image!")
+		}
+
 		return image, err
 	}
 
diff --git a/go.mod b/go.mod
index 70ea7ea..74d3b3d 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ toolchain go1.22.3
 
 // There are a few options we need added to Kaniko!
 // See: https://github.com/GoogleContainerTools/kaniko/compare/main...coder:kaniko:main
-replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20240524082248-9d0d55902c34
+replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20240612094751-9d2f7eaa733c
 
 require (
 	cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6
@@ -105,7 +105,6 @@ require (
 	github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
-	github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 // indirect
 	github.com/ePirat/docker-credential-gitlabci v1.0.0 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -118,7 +117,6 @@ require (
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.4 // indirect
-	github.com/gomodule/redigo v1.8.9 // indirect
 	github.com/google/go-cmp v0.6.0 // indirect
 	github.com/gorilla/handlers v1.5.1 // indirect
 	github.com/gorilla/mux v1.8.1 // indirect
diff --git a/go.sum b/go.sum
index 1d23e50..7e3a7fc 100644
--- a/go.sum
+++ b/go.sum
@@ -98,6 +98,9 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
 github.com/breml/rootcerts v0.2.10 h1:UGVZ193UTSUASpGtg6pbDwzOd7XQP+at0Ssg1/2E4h8=
 github.com/breml/rootcerts v0.2.10/go.mod h1:24FDtzYMpqIeYC7QzaE8VPRQaFZU5TIUDlyk8qwjD88=
 github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
+github.com/bsm/ginkgo/v2 v2.9.5 h1:rtVBYPs3+TC5iLUVOis1B9tjLTup7Cj5IfzosKtvTJ0=
+github.com/bsm/ginkgo/v2 v2.9.5/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
 github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
 github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
@@ -123,8 +126,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
 github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo=
 github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
 github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
-github.com/coder/kaniko v0.0.0-20240524082248-9d0d55902c34 h1:Wm7sMNc1aTN5l0NerYHb3LZdQJVQp4QrW4v83N21sfc=
-github.com/coder/kaniko v0.0.0-20240524082248-9d0d55902c34/go.mod h1:YMK7BlxerzLlMwihGxNWUaFoN9LXCij4P+w/8/fNlcM=
+github.com/coder/kaniko v0.0.0-20240612094751-9d2f7eaa733c h1:m/cK7QW+IIydq+7zmuGesY1k6CEZlKooSF+KtIcXke8=
+github.com/coder/kaniko v0.0.0-20240612094751-9d2f7eaa733c/go.mod h1:YMK7BlxerzLlMwihGxNWUaFoN9LXCij4P+w/8/fNlcM=
 github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
 github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
 github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc=
@@ -165,12 +168,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
-github.com/distribution/distribution/v3 v3.0.0-20230629214736-bac7f02e02a1 h1:yRwt9RluqBtKyDLRY7J0Cf/TVqvG56vKx2Eyndy8qNQ=
-github.com/distribution/distribution/v3 v3.0.0-20230629214736-bac7f02e02a1/go.mod h1:+fqBJ4vPYo4Uu1ZE4d+bUtTLRXfdSL3NvCZIZ9GHv58=
 github.com/distribution/distribution/v3 v3.0.0-alpha.1 h1:jn7I1gvjOvmLztH1+1cLiUFud7aeJCIQcgzugtwjyJo=
 github.com/distribution/distribution/v3 v3.0.0-alpha.1/go.mod h1:LCp4JZp1ZalYg0W/TN05jarCQu+h4w7xc7ZfQF4Y/cY=
-github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
-github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/docker/cli v26.1.0+incompatible h1:+nwRy8Ocd8cYNQ60mozDDICICD8aoFGtlPXifX/UQ3Y=
@@ -189,8 +188,6 @@ github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQ
 github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arXfYcAtECDFgAgHklGI8CxgjHnXKJ4=
-github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
 github.com/ePirat/docker-credential-gitlabci v1.0.0 h1:YRkUSvkON6rT88vtscClAmPEYWhtltGEAuRVYtz1/+Y=
 github.com/ePirat/docker-credential-gitlabci v1.0.0/go.mod h1:Ptmh+D0lzBQtgb6+QHjXl9HqOn3T1P8fKUHldiSQQGA=
 github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
@@ -260,8 +257,6 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
-github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -280,8 +275,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
 github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
-github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
-github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
 github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
@@ -393,8 +386,6 @@ github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
 github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
-github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
-github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
 github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
 github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg=
diff --git a/options.go b/options.go
index f0eec63..2913fde 100644
--- a/options.go
+++ b/options.go
@@ -138,6 +138,13 @@ type Options struct {
 	// CoderAgentSubsystem is the Coder agent subsystems to report when forwarding
 	// logs. The envbuilder subsystem is always included.
 	CoderAgentSubsystem []string
+
+	// PushImage is a flag to determine if the image should be pushed to the
+	// container registry. This option implies reproducible builds.
+	PushImage bool
+	// GetCachedImage is a flag to determine if the cached image is available,
+	// and if it is, to return it.
+	GetCachedImage bool
 }
 
 const envPrefix = "ENVBUILDER_"
@@ -395,6 +402,20 @@ func (o *Options) CLI() serpent.OptionSet {
 			Description: "Coder agent subsystems to report when forwarding logs. " +
 				"The envbuilder subsystem is always included.",
 		},
+		{
+			Flag:  "push-image",
+			Env:   WithEnvPrefix("PUSH_IMAGE"),
+			Value: serpent.BoolOf(&o.PushImage),
+			Description: "Push the built image to a remote registry. " +
+				"This option forces a reproducible build.",
+		},
+		{
+			Flag:  "get-cached-image",
+			Env:   WithEnvPrefix("GET_CACHED_IMAGE"),
+			Value: serpent.BoolOf(&o.GetCachedImage),
+			Description: "Print the digest of the cached image, if available. " +
+				"Exits with an error if not found.",
+		},
 	}
 
 	// Add options without the prefix for backward compatibility. These options


Test patch:
diff --git a/integration/integration_test.go b/integration/integration_test.go
index 8733788..85d6a87 100644
--- a/integration/integration_test.go
+++ b/integration/integration_test.go
@@ -34,6 +34,8 @@ import (
 	"github.com/go-git/go-billy/v5/memfs"
 	"github.com/google/go-containerregistry/pkg/authn"
 	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/registry"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
 	"github.com/google/go-containerregistry/pkg/v1/remote/transport"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -987,6 +989,252 @@ COPY %s .`, testImageAlpine, inclFile)
 	}
 }
 
+func TestPushImage(t *testing.T) {
+	t.Parallel()
+
+	t.Run("CacheWithoutPush", func(t *testing.T) {
+		t.Parallel()
+
+		srv := createGitServer(t, gitServerOptions{
+			files: map[string]string{
+				".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine),
+				".devcontainer/devcontainer.json": `{
+				"name": "Test",
+				"build": {
+					"dockerfile": "Dockerfile"
+				},
+			}`,
+			},
+		})
+
+		// Given: an empty registry
+		testReg := setupInMemoryRegistry(t)
+		testRepo := testReg + "/test"
+		ref, err := name.ParseReference(testRepo + ":latest")
+		require.NoError(t, err)
+		_, err = remote.Image(ref)
+		require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push")
+
+		// When: we run envbuilder with GET_CACHED_IMAGE
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("GET_CACHED_IMAGE", "1"),
+		}})
+		require.ErrorContains(t, err, "error probing build cache: uncached command")
+		// Then: it should fail to build the image and nothing should be pushed
+		_, err = remote.Image(ref)
+		require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push")
+
+		// When: we run envbuilder with PUSH_IMAGE set
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+		}})
+		require.NoError(t, err)
+
+		// Then: the image tag should not be present, only the layers
+		_, err = remote.Image(ref)
+		require.ErrorContains(t, err, "MANIFEST_UNKNOWN", "expected image to not be present before build + push")
+
+		// Then: re-running envbuilder with GET_CACHED_IMAGE should succeed
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("GET_CACHED_IMAGE", "1"),
+		}})
+		require.NoError(t, err)
+	})
+
+	t.Run("CacheAndPush", func(t *testing.T) {
+		t.Parallel()
+
+		srv := createGitServer(t, gitServerOptions{
+			files: map[string]string{
+				".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine),
+				".devcontainer/devcontainer.json": `{
+				"name": "Test",
+				"build": {
+					"dockerfile": "Dockerfile"
+				},
+			}`,
+			},
+		})
+
+		// Given: an empty registry
+		testReg := setupInMemoryRegistry(t)
+		testRepo := testReg + "/test"
+		ref, err := name.ParseReference(testRepo + ":latest")
+		require.NoError(t, err)
+		_, err = remote.Image(ref)
+		require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push")
+
+		// When: we run envbuilder with GET_CACHED_IMAGE
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("GET_CACHED_IMAGE", "1"),
+		}})
+		require.ErrorContains(t, err, "error probing build cache: uncached command")
+		// Then: it should fail to build the image and nothing should be pushed
+		_, err = remote.Image(ref)
+		require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push")
+
+		// When: we run envbuilder with PUSH_IMAGE set
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("PUSH_IMAGE", "1"),
+		}})
+		require.NoError(t, err)
+
+		// Then: the image should be pushed
+		_, err = remote.Image(ref)
+		require.NoError(t, err, "expected image to be present after build + push")
+
+		// Then: re-running envbuilder with GET_CACHED_IMAGE should succeed
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("GET_CACHED_IMAGE", "1"),
+		}})
+		require.NoError(t, err)
+	})
+
+	t.Run("CacheAndPushMultistage", func(t *testing.T) {
+		// Currently fails with:
+		//         /home/coder/src/coder/envbuilder/integration/integration_test.go:1417: "error: unable to get cached image: error fake building stage: failed to optimize instructions: failed to get files used from context: failed to get fileinfo for /.envbuilder/0/root/date.txt: lstat /.envbuilder/0/root/date.txt: no such file or directory"
+		// /home/coder/src/coder/envbuilder/integration/integration_test.go:1156:
+		// Error Trace:	/home/coder/src/coder/envbuilder/integration/integration_test.go:1156
+		// Error:      	Received unexpected error:
+		// error: unable to get cached image: error fake building stage: failed to optimize instructions: failed to get files used from context: failed to get fileinfo for /.envbuilder/0/root/date.txt: lstat /.envbuilder/0/root/date.txt: no such file or directory
+		// Test:       	TestPushImage/CacheAndPushMultistage
+		t.Skip("TODO: https://github.com/coder/envbuilder/issues/230")
+		t.Parallel()
+
+		srv := createGitServer(t, gitServerOptions{
+			files: map[string]string{
+				"Dockerfile": fmt.Sprintf(`FROM %s AS a
+RUN date --utc > /root/date.txt
+FROM %s as b
+COPY --from=a /root/date.txt /date.txt`, testImageAlpine, testImageAlpine),
+			},
+		})
+
+		// Given: an empty registry
+		testReg := setupInMemoryRegistry(t)
+		testRepo := testReg + "/test"
+		ref, err := name.ParseReference(testRepo + ":latest")
+		require.NoError(t, err)
+		_, err = remote.Image(ref)
+		require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push")
+
+		// When: we run envbuilder with GET_CACHED_IMAGE
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("GET_CACHED_IMAGE", "1"),
+			envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
+		}})
+		require.ErrorContains(t, err, "error probing build cache: uncached command")
+		// Then: it should fail to build the image and nothing should be pushed
+		_, err = remote.Image(ref)
+		require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push")
+
+		// When: we run envbuilder with PUSH_IMAGE set
+		ctrID, err := runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("PUSH_IMAGE", "1"),
+			envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
+		}})
+		require.NoError(t, err)
+		// Then: The file copied from stage a should be present
+		out := execContainer(t, ctrID, "cat /date.txt")
+		require.NotEmpty(t, out)
+
+		// Then: the image should be pushed
+		_, err = remote.Image(ref)
+		require.NoError(t, err, "expected image to be present after build + push")
+
+		// Then: re-running envbuilder with GET_CACHED_IMAGE should succeed
+		_, err = runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", testRepo),
+			envbuilderEnv("GET_CACHED_IMAGE", "1"),
+			envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
+		}})
+		require.NoError(t, err)
+	})
+
+	t.Run("PushImageRequiresCache", func(t *testing.T) {
+		t.Parallel()
+
+		srv := createGitServer(t, gitServerOptions{
+			files: map[string]string{
+				".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine),
+				".devcontainer/devcontainer.json": `{
+				"name": "Test",
+				"build": {
+					"dockerfile": "Dockerfile"
+				},
+			}`,
+			},
+		})
+
+		// When: we run envbuilder with PUSH_IMAGE set but no cache repo set
+		_, err := runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("PUSH_IMAGE", "1"),
+		}})
+
+		// Then: Envbuilder should fail explicitly, as it does not make sense to
+		// specify PUSH_IMAGE
+		require.ErrorContains(t, err, "--cache-repo must be set when using --push-image")
+	})
+
+	t.Run("PushErr", func(t *testing.T) {
+		t.Parallel()
+
+		srv := createGitServer(t, gitServerOptions{
+			files: map[string]string{
+				".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine),
+				".devcontainer/devcontainer.json": `{
+				"name": "Test",
+				"build": {
+					"dockerfile": "Dockerfile"
+				},
+			}`,
+			},
+		})
+
+		// Given: registry is not set up (in this case, not a registry)
+		notRegSrv := httptest.NewServer(http.NotFoundHandler())
+		notRegURL := strings.TrimPrefix(notRegSrv.URL, "http://") + "/test"
+
+		// When: we run envbuilder with PUSH_IMAGE set
+		_, err := runEnvbuilder(t, options{env: []string{
+			envbuilderEnv("GIT_URL", srv.URL),
+			envbuilderEnv("CACHE_REPO", notRegURL),
+			envbuilderEnv("PUSH_IMAGE", "1"),
+		}})
+
+		// Then: envbuilder should fail with a descriptive error
+		require.ErrorContains(t, err, "failed to push to destination")
+	})
+}
+
+func setupInMemoryRegistry(t *testing.T) string {
+	t.Helper()
+	tempDir := t.TempDir()
+	testReg := registry.New(registry.WithBlobHandler(registry.NewDiskBlobHandler(tempDir)))
+	regSrv := httptest.NewServer(testReg)
+	t.Cleanup(func() { regSrv.Close() })
+	regSrvURL, err := url.Parse(regSrv.URL)
+	require.NoError(t, err)
+	return fmt.Sprintf("localhost:%s", regSrvURL.Port())
+}
+
 // TestMain runs before all tests to build the envbuilder image.
 func TestMain(m *testing.M) {
 	checkTestRegistry()
diff --git a/testdata/options.golden b/testdata/options.golden
index bab60c2..73e6854 100644
--- a/testdata/options.golden
+++ b/testdata/options.golden
@@ -78,6 +78,10 @@ OPTIONS:
           your system! This is used in cases where bypass is needed to unblock
           customers.
 
+      --get-cached-image bool, $ENVBUILDER_GET_CACHED_IMAGE
+          Print the digest of the cached image, if available. Exits with an
+          error if not found.
+
       --git-clone-depth int, $ENVBUILDER_GIT_CLONE_DEPTH
           The depth to use when cloning the Git repository.
 
@@ -130,6 +134,10 @@ OPTIONS:
           should check for the presence of this script and execute it after
           successful startup.
 
+      --push-image bool, $ENVBUILDER_PUSH_IMAGE
+          Push the built image to a remote registry. This option forces a
+          reproducible build.
+
       --setup-script string, $ENVBUILDER_SETUP_SCRIPT
           The script to run before the init script. It runs as the root user
           regardless of the user specified in the devcontainer.json file.