blob: 5a537abd9ea924a7d8a5a882a93929559667d8bb [file] [log] [blame]
package main
/*
docker-run-wrapper is a wrapper around "docker run" which provides things like
authentication to replicate the setup in GKE or GCB.
*/
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/user"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/urfave/cli/v2"
"go.skia.org/infra/go/docker"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"golang.org/x/oauth2/google"
)
var (
projectMetadata = map[string]string{
"project-id": "skia-infra-public",
}
instanceMetadata = map[string]string{}
)
type Config struct {
Image string
Port string
WorkDir string
HomeDir string
User string
DinD bool
Volumes []string
Args []string
}
func main() {
var cfg Config
app := &cli.App{
Name: "docker-run-wrapper",
Description: "docker-run-wrapper is a wrapper around\"docker run\" which provides authentication to replicate the setup in GKE or GCB.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "image",
Usage: "The docker image to run.",
Required: true,
Destination: &cfg.Image,
},
&cli.StringFlag{
Name: "port",
Usage: "Run the metadata server on this port.",
Value: "8000",
Required: false,
Destination: &cfg.Port,
},
&cli.StringFlag{
Name: "workdir",
Usage: "Current working directory to use inside the container. May be specified with a colon to map a host directory into the container, eg \"--workdir=/container/dir\" or \"--workdir=/host/dir:/container/dir\".",
Value: "/workspace",
Required: false,
Destination: &cfg.WorkDir,
},
&cli.StringFlag{
Name: "home",
Usage: "Home directory inside the container.",
Value: "/home/builder",
Required: false,
Destination: &cfg.HomeDir,
},
&cli.StringFlag{
Name: "user",
Usage: "User to run as. If not specified, runs as the default user of the image. Follows the same semantics as the \"--user\" flag to \"docker run\", ie. it can be a user name or a user ID with optional group, eg. \"--user=myuser\", \"--user=1000\", \"--user=1000:1000\". Setting the special value \"@me\" will use the current host user ID and group ID.",
Value: "",
Required: false,
Destination: &cfg.User,
},
&cli.BoolFlag{
Name: "dind",
Usage: "Enables settings required for docker-in-docker, including privileged container and mounting /var/run/docker.sock into the container.",
Value: false,
Required: false,
Destination: &cfg.DinD,
},
&cli.MultiStringFlag{
Target: &cli.StringSliceFlag{
Name: "volume",
Usage: "Volumes to mount. Directly passed through to \"docker run\".",
Aliases: []string{"v"},
},
Value: cfg.Volumes,
Destination: &cfg.Volumes,
},
},
Action: func(ctx *cli.Context) error {
cfg.Args = ctx.Args().Slice()
if err := dockerRun(ctx.Context, cfg); err != nil {
return cli.Exit(err, 1)
}
return nil
},
}
sklog.Fatal(app.Run(os.Args))
}
func dockerRun(ctx context.Context, cfg Config) error {
// Start the metadata server.
host := "localhost"
if err := runMetadataServer(cfg.Port); err != nil {
return skerr.Wrapf(err, "failed to start metadata server")
}
sklog.Infof("Server running on :%s", cfg.Port)
// Handle --user.
user, err := user.Current()
if err != nil {
return skerr.Wrapf(err, "failed to find home directory")
}
userFlag := cfg.User
userEnvVar := ""
if userFlag == "@me" {
userFlag = fmt.Sprintf("%s:%s", user.Uid, user.Gid)
userEnvVar = fmt.Sprintf("USER=%s", user.Username)
}
// Create a temporary directory.
wd, err := os.MkdirTemp("", "docker-run-wrapper-")
if err != nil {
return skerr.Wrap(err)
}
defer util.RemoveAll(wd)
if err := os.Chmod(wd, 0755); err != nil {
return skerr.Wrap(err)
}
// Find application default credentials.
const adcEnvVar = "GOOGLE_APPLICATION_CREDENTIALS"
adcSrcPath := os.Getenv(adcEnvVar)
if adcSrcPath == "" {
adcSrcPath = filepath.Join(user.HomeDir, ".config", "gcloud", "application_default_credentials.json")
}
if _, err = os.Stat(adcSrcPath); os.IsNotExist(err) {
return skerr.Fmt("application default credentials file %s does not exist", adcSrcPath)
} else if err != nil {
return skerr.Wrap(err)
}
// Write to a new temporary file so that the container can have read perms.
adcHostPath := filepath.Join(wd, "application_default_credentials.json")
if err := util.CopyFile(adcSrcPath, adcHostPath); err != nil {
return skerr.Wrap(err)
}
if err := os.Chmod(adcHostPath, 0644); err != nil {
return skerr.Wrap(err)
}
adcContainerPath := filepath.Join(cfg.HomeDir, "application_default_credentials.json")
// Map the workdir as requested.
hostWorkDir := ""
containerWorkDir := ""
if split := strings.SplitN(cfg.WorkDir, ":", 2); len(split) == 1 {
hostWorkDir = filepath.Join(wd, "workspace")
if err := os.Mkdir(hostWorkDir, 0777); err != nil {
return skerr.Wrapf(err, "failed to create workdir")
}
if err := os.Chmod(hostWorkDir, 0777); err != nil {
return skerr.Wrap(err)
}
containerWorkDir = split[0]
} else if len(split) == 2 {
hostWorkDir = split[0]
containerWorkDir = split[1]
}
// Run Docker auth.
hostDockerConfigFilePath, err := docker.AutoUpdateConfigFileAuth(ctx)
if err != nil {
return skerr.Wrapf(err, "failed to run Docker auth")
}
containerDockerConfigFilePath := cfg.HomeDir + "/.docker/config.json"
// Run the command.
cmd := []string{
"docker", "run",
"--network", "host",
"--env", fmt.Sprintf("HOME=%s", cfg.HomeDir),
"--env", fmt.Sprintf("GCE_METADATA_HOST=%s:%s", host, cfg.Port),
"--env", fmt.Sprintf("%s=%s", adcEnvVar, adcContainerPath),
"--volume", fmt.Sprintf("%s:%s", adcHostPath, adcContainerPath),
"--volume", fmt.Sprintf("%s:%s", hostDockerConfigFilePath, containerDockerConfigFilePath),
}
if containerWorkDir != "" && hostWorkDir != "" {
cmd = append(cmd, "--workdir", containerWorkDir)
cmd = append(cmd, "--volume", fmt.Sprintf("%s:%s", hostWorkDir, containerWorkDir))
}
if userFlag != "" {
cmd = append(cmd, "--user", userFlag)
}
if userEnvVar != "" {
cmd = append(cmd, "--env", userEnvVar)
}
if cfg.DinD {
cmd = append(cmd, "--privileged")
cmd = append(cmd, "--volume", "/var/run/docker.sock:/var/run/docker.sock")
}
for _, volume := range cfg.Volumes {
cmd = append(cmd, "--volume", volume)
}
cmd = append(cmd, cfg.Image)
cmd = append(cmd, cfg.Args...)
sklog.Info(strings.Join(cmd, " "))
_, err = exec.RunCommand(ctx, &exec.Command{
Name: cmd[0],
Args: cmd[1:],
Stdout: os.Stdout,
Stderr: os.Stderr,
})
return skerr.Wrap(err)
}
// runMetadataServer starts up a metadata server.
func runMetadataServer(port string) error {
r := chi.NewRouter()
r.HandleFunc("/computeMetadata/v1/instance/service-accounts/{serviceAccount}/token", tokenHandler)
r.HandleFunc("/computeMetadata/v1/{category}/*", func(w http.ResponseWriter, r *http.Request) {
key := strings.Join(strings.Split(r.URL.Path, "/")[4:], "/")
var value string
var ok bool
switch chi.URLParam(r, "category") {
case "project":
value, ok = projectMetadata[key]
case "instance":
value, ok = instanceMetadata[key]
default:
http.Error(w, "unknown metadata category", http.StatusNotFound)
return
}
if !ok {
http.Error(w, "unknown metadata key", http.StatusNotFound)
return
}
_, _ = w.Write([]byte(value))
})
h := httputils.LoggingRequestResponse(r)
go func() {
sklog.Fatal(http.ListenAndServe(":"+port, h))
}()
return nil
}
func tokenHandler(w http.ResponseWriter, r *http.Request) {
scopes := r.URL.Query()["scopes"]
ts, err := google.DefaultTokenSource(r.Context(), scopes...)
if err != nil {
httputils.ReportError(w, err, "failed to get TokenSource", http.StatusInternalServerError)
return
}
tok, err := ts.Token()
// TODO(borenet): This is required, but I'm not sure why it's not already
// filled in, or derived when needed.
tok.ExpiresIn = int64(time.Until(tok.Expiry).Seconds())
if err != nil {
httputils.ReportError(w, err, "failed to get Token", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(tok); err != nil {
httputils.ReportError(w, err, "failed to get encode response", http.StatusInternalServerError)
return
}
}