Files
pulumi-docker-build/provider/internal/cli.go
Bryce Lampe 195fbfc784 Add support for multiple exporters (#235)
Buildkit 0.13 introduced support for [multiple
exporters](https://docs.docker.com/build/exporters/#multiple-exporters).
We currently return an error in these situations.

Instead, inspect the builder's version when loading the node and relax
this error if we see it's running at least 0.13.

Fixes https://github.com/pulumi/pulumi-docker-build/issues/21.
2024-12-10 09:05:36 -08:00

425 lines
11 KiB
Go

// Copyright 2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:generate go run go.uber.org/mock/mockgen -typed -package internal -source cli.go -destination mockcli_test.go --self_package github.com/pulumi/pulumi-docker-build/provider/internal
package internal
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/docker/buildx/commands"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config/credentials"
cfgtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/streams"
"github.com/moby/buildkit/client"
cp "github.com/otiai10/copy"
"github.com/regclient/regclient"
"github.com/regclient/regclient/config"
"github.com/sirupsen/logrus"
provider "github.com/pulumi/pulumi-go-provider"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
)
// cli wraps a DockerCLI instance with scoped auth credentials. It satisfies
// the Cli interface so it can be used with Docker's cobra.Commands directly.
//
// It buffers stdout/stderr, and layers temporary auth configs on top of the
// host's existing auth.
type cli struct {
command.Cli
auths map[string]cfgtypes.AuthConfig
host *host
in string // stdin
r, w *os.File // stdout
err bytes.Buffer // stderr
dumplogs bool // if true then tail() will re-log status messages
done chan struct{} // signaled when all logs have been forwarded to the engine.
}
// Cli wraps the Docker interface for mock generation.
type Cli interface {
command.Cli
}
// wrap creates a new cli client with auth configs layered on top of our host's
// auth. Repeated auth for the same host will take precedence over earlier
// credentials.
func wrap(host *host, registries ...Registry) (*cli, error) {
// We need to create a new DockerCLI instance because we don't want the
// auth changes we make to the ConfigFile to leak to the host.
docker, err := newDockerCLI(host.config)
if err != nil {
return nil, err
}
auths := map[string]cfgtypes.AuthConfig{}
for k, v := range host.auths {
if k != config.DockerRegistryAuth {
k = credentials.ConvertToHostname(k)
}
auths[k] = cfgtypes.AuthConfig{
ServerAddress: v.ServerAddress,
Username: v.Username,
Password: v.Password,
}
}
for _, r := range registries {
// HostNewName takes care of DockerHub's special-casing for us.
h := config.HostNewName(credentials.ConvertToHostname(r.Address))
key := h.CredHost
if key == "" {
key = h.Hostname
}
auths[key] = cfgtypes.AuthConfig{
ServerAddress: h.Hostname,
Username: r.Username,
Password: r.Password,
}
}
// Override our config's auth and disable any credential helpers. Auth
// lookups will now only return whatever we have in memory.
cfg := docker.ConfigFile()
cfg.AuthConfigs = auths
cfg.CredentialHelpers = nil
cfg.CredentialsStore = ""
r, w, err := os.Pipe()
if err != nil {
return nil, err
}
wrapped := &cli{
Cli: docker,
host: host,
auths: auths,
r: r,
w: w,
}
return wrapped, nil
}
func (c *cli) In() *streams.In {
return streams.NewIn(io.NopCloser(strings.NewReader(c.in)))
}
func (c *cli) Out() *streams.Out {
return streams.NewOut(c.w)
}
func (c *cli) Err() *streams.Out {
return streams.NewOut(&c.err)
}
func (c *cli) SupportsMultipleExports() bool {
return c.host.supportsMultipleExports
}
// rc returns a registry client with matching auth.
func (c *cli) rc() *regclient.RegClient {
hosts := []config.Host{}
for k, v := range c.auths {
h := config.HostNewName(k)
h.User = v.Username
h.Pass = v.Password
hosts = append(hosts, *h)
}
return regclient.New(
regclient.WithConfigHost(hosts...),
)
}
// tail is meant to be called as a goroutine and will pipe output from the CLI
// back to the Pulumi engine. Requires a corresponding call to close.
func (c *cli) tail(ctx context.Context) {
c.done = make(chan struct{}, 1)
defer func() {
c.done <- struct{}{}
if err := recover(); err != nil {
fmt.Fprintf(os.Stderr, "recovered: %s\n", err)
}
}()
b := bytes.Buffer{}
s := bufio.NewScanner(c.r)
for s.Scan() {
text := s.Text()
provider.GetLogger(ctx).InfoStatus(text)
_, _ = b.WriteString(text + "\n")
}
provider.GetLogger(ctx).InfoStatus("") // clear confusing "DONE" statements.
if c.dumplogs {
// Persist the full Docker output on error for easier debugging.
if b.Len() > 0 {
provider.GetLogger(ctx).Info(b.String())
}
if c.err.Len() > 0 {
provider.GetLogger(ctx).Error(c.err.String())
}
}
}
// close flushes any outstanding logs and cleans up resources.
func (c *cli) Close() error {
err := errors.Join(c.w.Close(), c.r.Close())
if c.done != nil {
<-c.done
}
return err
}
// execBuild performs a build by os.Exec'ing the docker-buildx binary.
// Credentials are communicated to docker-buildx via a temporary directory.
// Secrets are communicated via dynamic environment variables.
func (c *cli) execBuild(b Build) (*client.SolveResponse, error) {
// Setup a temporary directory for auth, and clean it up when we're done.
tmp, err := os.MkdirTemp("", "pulumi-docker-")
if err != nil {
return nil, err
}
defer contract.IgnoreError(os.RemoveAll(tmp))
opts := b.BuildOptions()
builder, err := c.host.builderFor(b)
if err != nil {
return nil, err
}
// Docker expects a "$DOCKER_CONFIG/contexts" directory in addition to
// "$DOCKER_CONFIG/config.json", so we attempt to copy this from the host
// to our temporary directory. This doesn't always exist, so we ignore errors.
hostConfigDir := filepath.Dir(c.ConfigFile().Filename)
_ = cp.Copy(
filepath.Join(hostConfigDir, "contexts"),
filepath.Join(tmp, "contexts"),
)
// Save our temporary credentials to $tmp/config.json.
tmpCfg := filepath.Join(tmp, filepath.Base(c.ConfigFile().Filename))
c.ConfigFile().Filename = tmpCfg
err = c.ConfigFile().Save()
if err != nil {
return nil, err
}
// We will spawn docker-buildx with DOCKER_CONFIG set to our temporary
// directory for auth, but BUILDX_CONFIG will point to the host. There's a
// bunch of builder state in there that we want to preserve.
env := []string{
"DOCKER_CONFIG=" + tmp,
"BUILDX_CONFIG=" + filepath.Join(hostConfigDir, "buildx"),
}
// We need to write to this file in order to recover information about the
// build, like the digest.
metadata := filepath.Clean(filepath.Join(tmp, "metadata.json"))
args := []string{
"buildx",
"build",
"--progress", "plain",
"--metadata-file", metadata,
"--builder", builder.name,
}
// TODO: --allow
// TODO: --annotation
// TODO: --attest
// TODO: --cgroup-parent
for k, v := range opts.BuildArgs {
args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, v))
}
if opts.Builder != "" {
args = append(args, "--builder", opts.Builder)
}
for _, c := range opts.CacheFrom {
args = append(args, "--cache-from", attrcsv(c.Type, c.Attrs))
}
for _, c := range opts.CacheTo {
args = append(args, "--cache-to", attrcsv(c.Type, c.Attrs))
}
if opts.ExportLoad {
args = append(args, "--load")
}
if opts.ExportPush {
args = append(args, "--push")
}
for _, e := range opts.Exports {
args = append(args, "--output", attrcsv(e.Type, e.Attrs))
}
for _, h := range opts.ExtraHosts {
args = append(args, "--add-host", h)
}
for k, v := range opts.NamedContexts {
args = append(args, "--build-context", fmt.Sprintf("%s=%s", k, v))
}
for k, v := range opts.Labels {
args = append(args, "--label", fmt.Sprintf("%s=%s", k, v))
}
if opts.NetworkMode != "" {
args = append(args, "--network", opts.NetworkMode)
}
if opts.NoCache {
args = append(args, "--no-cache")
}
for _, p := range opts.Platforms {
args = append(args, "--platform", p)
}
if opts.Pull {
args = append(args, "--pull")
}
for _, ssh := range opts.SSH {
s := ssh.ID
if len(ssh.Paths) > 0 {
s += "=" + strings.Join(ssh.Paths, ",")
}
args = append(args, "--ssh", s)
}
for _, t := range opts.Tags {
args = append(args, "--tag", t)
}
if opts.Target != "" {
args = append(args, "--target", opts.Target)
}
if opts.DockerfileName != "" {
args = append(args, "-f", opts.DockerfileName)
}
if in := b.Inline(); in != "" {
c.in = in
args = append(args, "-f", "-")
}
if opts.ContextPath != "" {
args = append(args, opts.ContextPath)
}
// We pass secrets by value via dynamic PULUMI_DOCKER_* environment
// variables.
for _, s := range opts.Secrets {
envvar, err := resource.NewUniqueHex("PULUMI_DOCKER_", 0, 0)
if err != nil {
return nil, err
}
// We abuse the pb.Secret proto by stuffing the secret's value in
// Env. We never serialize this proto so this is tolerable.
env = append(env, fmt.Sprintf("%s=%s", envvar, s.Env))
args = append(args, "--secret", fmt.Sprintf("id=%s,env=%s", s.ID, envvar))
}
// Invoke docker-buildx.
err = c.exec(args, env)
if err != nil {
return nil, err
}
// Read the metadata file and transform it back into the map[string]string
// structure originally returned by the exporter.
_, err = os.Stat(metadata)
if err != nil {
return nil, fmt.Errorf("missing metadata: %w", err)
}
out, err := os.ReadFile(metadata)
if err != nil {
return nil, err
}
var raw map[string]any
err = json.Unmarshal(out, &raw)
if err != nil {
return nil, err
}
resp := map[string]string{}
for k, v := range raw {
switch vv := v.(type) {
case string:
resp[k] = vv
default:
out, err := json.Marshal(v)
if err != nil {
continue
}
resp[k] = string(out)
}
}
return &client.SolveResponse{ExporterResponse: resp}, nil
}
// exec invokes a Docker plugin binary. The first argument should be the name
// of the plugin's subcommand, e.g. "buildx".
func (c *cli) exec(args, extraEnv []string) error {
if len(args) == 0 {
return errors.New("args must be non-empty")
}
name := args[0]
root := commands.NewRootCmd(name, false, c)
plug, err := manager.GetPlugin(name, c, root)
if err != nil {
return err
}
if plug.Err != nil {
return plug.Err
}
defer contract.IgnoreClose(c.w)
cmd, err := manager.PluginRunCommand(c, name, root)
if err != nil {
return err
}
cmd.Args = append([]string{cmd.Args[0]}, args...)
cmd.Stderr = c.Err()
cmd.Stdout = c.Out()
cmd.Stdin = c.In()
cmd.Env = append(cmd.Env, extraEnv...)
return cmd.Run()
}
// attrcsv transforms key/values into a CSV: key1=value1,key2=value2,...
func attrcsv(typ string, m map[string]string) string {
s := []string{"type=" + typ}
for k, v := range m {
s = append(s, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(s, ",")
}
func init() {
// Disable the CLI's tendency to log randomly to stdout.
logrus.SetOutput(io.Discard)
}