Repository: dexidp/dex
Pull request #2378 (2022-01-19 13:40:09)

distroless: Dockerfile works with distroless base image
<!--
Thank you for sending a pull request! Here are some tips for contributors:

1. Fill the description template below.
2. Sign a DCO (if you haven't already signed it).
3. Include appropriate tests (if necessary). Make sure that all CI checks passed.
4. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.
-->

#### Overview

This PR hopes to add support for a distroless Dex container image:
```
docker build --build-arg BASEIMAGE=gcr.io/distroless/static:latest -t andrew:distroless .
```

#### What this PR does / why we need it

In order to get the Dex container built on distroless, I had to:
1. rewrite docker-entrypoint.sh in go and compile it into the Dex image (to avoid the need for a shell in the Dex image)
1. fetch CA certificates in an earlier build stage and copy them into the Dex image (to avoid the need for a package manager in the Dex image)
1. add a "stager" build stage for creating some directories that should exist in the Dex container and copy them into the Dex image (to avoid the need for `mkdir(1)` in the Dex image)

Closes https://github.com/dexidp/dex/issues/1924.

#### Special notes for your reviewer

* Perhaps see initial chatter about this in https://github.com/dexidp/dex/issues/1924 comments.
* Question: how do I setup E2E tests to run in CI with this distroless Dex container image?

#### Does this PR introduce a user-facing change?

<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
Dex Dockerfile supports being built with distroless base layer (docker build --build-arg BASEIMAGE=gcr.io/distroless/static:latest ...)
```



Patch:
diff --git a/Dockerfile b/Dockerfile
index f00e0456..1a3117d4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,10 @@
+ARG BASEIMAGE=alpine:3.15.0
+
 FROM golang:1.17.6-alpine3.14 AS builder
 
 WORKDIR /usr/local/src/dex
 
-RUN apk add --no-cache --update alpine-sdk
+RUN apk add --no-cache --update alpine-sdk ca-certificates openssl
 
 ARG TARGETOS
 ARG TARGETARCH
@@ -20,6 +22,12 @@ COPY . .
 
 RUN make release-binary
 
+FROM alpine:3.15.0 AS stager
+
+RUN mkdir -p /var/dex
+RUN mkdir -p /etc/dex
+COPY config.docker.yaml /etc/dex/
+
 FROM alpine:3.15.0 AS gomplate
 
 ARG TARGETOS
@@ -33,34 +41,29 @@ RUN wget -O /usr/local/bin/gomplate \
     && chmod +x /usr/local/bin/gomplate
 
 
-FROM alpine:3.15.0
+FROM $BASEIMAGE
 
 # Dex connectors, such as GitHub and Google logins require root certificates.
 # Proper installations should manage those certificates, but it's a bad user
 # experience when this doesn't work out of the box.
 #
-# OpenSSL is required so wget can query HTTPS endpoints for health checking.
-RUN apk add --no-cache --update ca-certificates openssl
+# See https://go.dev/src/crypto/x509/root_linux.go for Go root CA bundle locations.
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
 
-RUN mkdir -p /var/dex
-RUN chown -R 1001:1001 /var/dex
-
-RUN mkdir -p /etc/dex
-COPY config.docker.yaml /etc/dex/
-RUN chown -R 1001:1001 /etc/dex
+COPY --from=stager --chown=1001:1001 /var/dex /var/dex
+COPY --from=stager --chown=1001:1001 /etc/dex /etc/dex
 
 # Copy module files for CVE scanning / dependency analysis.
 COPY --from=builder /usr/local/src/dex/go.mod /usr/local/src/dex/go.sum /usr/local/src/dex/
 COPY --from=builder /usr/local/src/dex/api/v2/go.mod /usr/local/src/dex/api/v2/go.sum /usr/local/src/dex/api/v2/
 
 COPY --from=builder /go/bin/dex /usr/local/bin/dex
+COPY --from=builder /go/bin/docker-entrypoint /usr/local/bin/docker-entrypoint
 COPY --from=builder /usr/local/src/dex/web /srv/dex/web
 
 COPY --from=gomplate /usr/local/bin/gomplate /usr/local/bin/gomplate
 
 USER 1001:1001
 
-COPY docker-entrypoint.sh /entrypoint.sh
-
-ENTRYPOINT ["/entrypoint.sh"]
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint"]
 CMD ["dex", "serve", "/etc/dex/config.docker.yaml"]
diff --git a/Makefile b/Makefile
index b92aee98..8572091e 100644
--- a/Makefile
+++ b/Makefile
@@ -18,7 +18,7 @@ group=$(shell id -g -n)
 
 export GOBIN=$(PWD)/bin
 
-LD_FLAGS="-w -X main.version=$(VERSION)"
+LD_FLAGS="-w -X main.version=$(VERSION) -extldflags \"-static\""
 
 # Dependency versions
 
@@ -48,6 +48,7 @@ bin/example-app:
 .PHONY: release-binary
 release-binary: generate
 	@go build -o /go/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
+	@go build -o /go/bin/docker-entrypoint -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/docker-entrypoint
 
 docker-compose.override.yaml:
 	cp docker-compose.override.yaml.dist docker-compose.override.yaml
diff --git a/cmd/docker-entrypoint/main.go b/cmd/docker-entrypoint/main.go
new file mode 100644
index 00000000..0c507d17
--- /dev/null
+++ b/cmd/docker-entrypoint/main.go
@@ -0,0 +1,92 @@
+// Package main provides a utility program to launch the Dex container process with an optional
+// templating step (provided by gomplate).
+//
+// This was originally written as a shell script, but we rewrote it as a Go program so that it could
+// run as a raw binary in a distroless container.
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"syscall"
+)
+
+func main() {
+	// Note that this docker-entrypoint program is args[0], and it is provided with the true process
+	// args.
+	args := os.Args[1:]
+
+	if err := run(args, realExec, realWhich); err != nil {
+		fmt.Println("error:", err.Error())
+		os.Exit(1)
+	}
+}
+
+func realExec(fork bool, args ...string) error {
+	if fork {
+		if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
+			return fmt.Errorf("cannot fork/exec command %s: %w (output: %q)", args, err, string(output))
+		}
+		return nil
+	}
+
+	argv0, err := exec.LookPath(args[0])
+	if err != nil {
+		return fmt.Errorf("cannot lookup path for command %s: %w", args[0], err)
+	}
+
+	if err := syscall.Exec(argv0, args, os.Environ()); err != nil {
+		return fmt.Errorf("cannot exec command %s (%q): %w", args, argv0, err)
+	}
+
+	return nil
+}
+
+func realWhich(path string) string {
+	fullPath, err := exec.LookPath(path)
+	if err != nil {
+		return ""
+	}
+	return fullPath
+}
+
+func run(args []string, execFunc func(bool, ...string) error, whichFunc func(string) string) error {
+	if args[0] != "dex" && args[0] != whichFunc("dex") {
+		return execFunc(false, args...)
+	}
+
+	if args[1] != "serve" {
+		return execFunc(false, args...)
+	}
+
+	newArgs := []string{}
+	for _, tplCandidate := range args {
+		if hasSuffixes(tplCandidate, ".tpl", ".tmpl", ".yaml") {
+			tmpFile, err := os.CreateTemp("/tmp", "dex.config.yaml-*")
+			if err != nil {
+				return fmt.Errorf("cannot create temp file: %w", err)
+			}
+
+			if err := execFunc(true, "gomplate", "-f", tplCandidate, "-o", tmpFile.Name()); err != nil {
+				return err
+			}
+
+			newArgs = append(newArgs, tmpFile.Name())
+		} else {
+			newArgs = append(newArgs, tplCandidate)
+		}
+	}
+
+	return execFunc(false, newArgs...)
+}
+
+func hasSuffixes(s string, suffixes ...string) bool {
+	for _, suffix := range suffixes {
+		if strings.HasSuffix(s, suffix) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
deleted file mode 100755
index bb12d313..00000000
--- a/docker-entrypoint.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/sh -e
-
-### Usage: /docker-entrypoint.sh <command> <args>
-function main() {
-  executable=$1
-  command=$2
-
-  if [[ "$executable" != "dex" ]] && [[ "$executable" != "$(which dex)" ]]; then
-    exec $@
-  fi
-
-  if [[ "$command" != "serve" ]]; then
-    exec $@
-  fi
-
-  for tpl_candidate in $@ ; do
-    case "$tpl_candidate" in
-      *.tpl|*.tmpl|*.yaml)
-        tmp_file=$(mktemp /tmp/dex.config.yaml-XXXXXX)
-        gomplate -f "$tpl_candidate" -o "$tmp_file"
-
-        args="${args} ${tmp_file}"
-        ;;
-      *)
-        args="${args} ${tpl_candidate}"
-        ;;
-    esac
-  done
-  exec $args
-}
-
-main $@


Test patch:
diff --git a/cmd/docker-entrypoint/main_test.go b/cmd/docker-entrypoint/main_test.go
new file mode 100644
index 00000000..c8aef169
--- /dev/null
+++ b/cmd/docker-entrypoint/main_test.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+	"strings"
+	"testing"
+)
+
+type execArgs struct {
+	fork        bool
+	argPrefixes []string
+}
+
+func TestRun(t *testing.T) {
+	tests := []struct {
+		name         string
+		args         []string
+		execReturns  error
+		whichReturns string
+		wantExecArgs []execArgs
+		wantErr      error
+	}{
+		{
+			name:         "executable not dex",
+			args:         []string{"tuna", "fish"},
+			wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"tuna", "fish"}}},
+		},
+		{
+			name:         "executable is full path to dex",
+			args:         []string{"/usr/local/bin/dex", "marshmallow", "zelda"},
+			whichReturns: "/usr/local/bin/dex",
+			wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"/usr/local/bin/dex", "marshmallow", "zelda"}}},
+		},
+		{
+			name:         "command is not serve",
+			args:         []string{"dex", "marshmallow", "zelda"},
+			wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "marshmallow", "zelda"}}},
+		},
+		{
+			name:         "no templates",
+			args:         []string{"dex", "serve", "config.yaml.not-a-template"},
+			wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}},
+		},
+		{
+			name:         "no templates",
+			args:         []string{"dex", "serve", "config.yaml.not-a-template"},
+			wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}},
+		},
+		{
+			name: ".tpl template",
+			args: []string{"dex", "serve", "config.tpl"},
+			wantExecArgs: []execArgs{
+				{fork: true, argPrefixes: []string{"gomplate", "-f", "config.tpl", "-o", "/tmp/dex.config.yaml-"}},
+				{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
+			},
+		},
+		{
+			name: ".tmpl template",
+			args: []string{"dex", "serve", "config.tmpl"},
+			wantExecArgs: []execArgs{
+				{fork: true, argPrefixes: []string{"gomplate", "-f", "config.tmpl", "-o", "/tmp/dex.config.yaml-"}},
+				{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
+			},
+		},
+		{
+			name: ".yaml template",
+			args: []string{"dex", "serve", "some/path/config.yaml"},
+			wantExecArgs: []execArgs{
+				{fork: true, argPrefixes: []string{"gomplate", "-f", "some/path/config.yaml", "-o", "/tmp/dex.config.yaml-"}},
+				{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			var gotExecForks []bool
+			var gotExecArgs [][]string
+			fakeExec := func(fork bool, args ...string) error {
+				gotExecForks = append(gotExecForks, fork)
+				gotExecArgs = append(gotExecArgs, args)
+				return test.execReturns
+			}
+
+			fakeWhich := func(_ string) string { return test.whichReturns }
+
+			gotErr := run(test.args, fakeExec, fakeWhich)
+			if (test.wantErr == nil) != (gotErr == nil) {
+				t.Errorf("wanted error %s, got %s", test.wantErr, gotErr)
+			}
+			if !execArgsMatch(test.wantExecArgs, gotExecForks, gotExecArgs) {
+				t.Errorf("wanted exec args %+v, got %+v %+v", test.wantExecArgs, gotExecForks, gotExecArgs)
+			}
+		})
+	}
+}
+
+func execArgsMatch(wantExecArgs []execArgs, gotForks []bool, gotExecArgs [][]string) bool {
+	if len(wantExecArgs) != len(gotForks) {
+		return false
+	}
+
+	for i := range wantExecArgs {
+		if wantExecArgs[i].fork != gotForks[i] {
+			return false
+		}
+		for j := range wantExecArgs[i].argPrefixes {
+			if !strings.HasPrefix(gotExecArgs[i][j], wantExecArgs[i].argPrefixes[j]) {
+				return false
+			}
+		}
+	}
+
+	return true
+}