implement docker trust as plugin

move the `trust` subcommands to a plugin, so that the subcommands can
be installed separate from the `docker trust` integration in push/pull
(for situations where trust verification happens on the daemon side).

    make binary
    go build -o /usr/libexec/docker/cli-plugins/docker-trust ./cmd/docker-trust

    docker info
    Client:
     Version:    28.2.0-dev
     Context:    default
     Debug Mode: false
     Plugins:
      buildx: Docker Buildx (Docker Inc.)
        Version:  v0.24.0
        Path:     /usr/libexec/docker/cli-plugins/docker-buildx
      trust: Manage trust on Docker images (Docker Inc.)
        Version:  unknown-version
        Path:     /usr/libexec/docker/cli-plugins/docker-trust

    docker trust --help
    Usage:  docker trust [OPTIONS] COMMAND

    Extended build capabilities with BuildKit

    Options:
      -D, --debug   Enable debug logging

    Management Commands:
      key         Manage keys for signing Docker images
      signer      Manage entities who can sign Docker images

    Commands:
      inspect     Return low-level information about keys and signatures
      revoke      Remove trust for an image
      sign        Sign an image

    Run 'docker trust COMMAND --help' for more information on a command.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn
2025-06-02 17:28:41 +02:00
parent face4a61be
commit c9bb291154
60 changed files with 408 additions and 60 deletions

View File

@ -69,6 +69,15 @@ dynbinary: ## build dynamically linked binary
plugins: ## build example CLI plugins
scripts/build/plugins
.PHONY: trust-plugin
trust-plugin: ## build docker-trust CLI plugins
scripts/build/trust-plugin
.PHONY: install-trust-plugin
install-trust-plugin: trust-plugin
install-trust-plugin: ## install docker-trust CLI plugins
install -D -m 0755 "$$(readlink -f build/docker-trust)" /usr/libexec/docker/cli-plugins/docker-trust
.PHONY: vendor
vendor: ## update vendor with go modules
rm -rf vendor

View File

@ -18,7 +18,6 @@ import (
_ "github.com/docker/cli/cli/command/stack"
_ "github.com/docker/cli/cli/command/swarm"
_ "github.com/docker/cli/cli/command/system"
_ "github.com/docker/cli/cli/command/trust"
_ "github.com/docker/cli/cli/command/volume"
"github.com/docker/cli/internal/commands"
"github.com/spf13/cobra"

View File

@ -0,0 +1,128 @@
package test
import (
"bytes"
"errors"
"io"
"strings"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/streams"
"github.com/moby/moby/client"
notaryclient "github.com/theupdateframework/notary/client"
)
// NotaryClientFuncType defines a function that returns a fake notary client
type NotaryClientFuncType func() (notaryclient.Repository, error)
// FakeCli emulates the default DockerCli
type FakeCli struct {
command.DockerCli
client client.APIClient
configfile *configfile.ConfigFile
out *streams.Out
outBuffer *bytes.Buffer
err *streams.Out
errBuffer *bytes.Buffer
in *streams.In
server command.ServerInfo
notaryClientFunc NotaryClientFuncType
currentContext string
}
// NewFakeCli returns a fake for the command.Cli interface
func NewFakeCli(apiClient client.APIClient, opts ...func(*FakeCli)) *FakeCli {
outBuffer := new(bytes.Buffer)
errBuffer := new(bytes.Buffer)
c := &FakeCli{
client: apiClient,
out: streams.NewOut(outBuffer),
outBuffer: outBuffer,
err: streams.NewOut(errBuffer),
errBuffer: errBuffer,
in: streams.NewIn(io.NopCloser(strings.NewReader(""))),
// Use an empty string for filename so that tests don't create configfiles
// Set cli.ConfigFile().Filename to a tempfile to support Save.
configfile: configfile.New(""),
currentContext: command.DefaultContextName,
}
for _, opt := range opts {
opt(c)
}
return c
}
// SetIn sets the input of the cli to the specified ReadCloser
func (c *FakeCli) SetIn(in *streams.In) {
c.in = in
}
// SetErr sets the stderr stream for the cli to the specified io.Writer
func (c *FakeCli) SetErr(err *streams.Out) {
c.err = err
}
// SetOut sets the stdout stream for the cli to the specified io.Writer
func (c *FakeCli) SetOut(out *streams.Out) {
c.out = out
}
// Client returns a docker API client
func (c *FakeCli) Client() client.APIClient {
return c.client
}
// CurrentVersion returns the API version used by FakeCli.
// func (*FakeCli) CurrentVersion() string {
// return client.MaxAPIVersion
// }
// Out returns the output stream (stdout) the cli should write on
func (c *FakeCli) Out() *streams.Out {
return c.out
}
// Err returns the output stream (stderr) the cli should write on
func (c *FakeCli) Err() *streams.Out {
return c.err
}
// In returns the input stream the cli will use
func (c *FakeCli) In() *streams.In {
return c.in
}
// ConfigFile returns the cli configfile object (to get client configuration)
func (c *FakeCli) ConfigFile() *configfile.ConfigFile {
return c.configfile
}
// OutBuffer returns the stdout buffer
func (c *FakeCli) OutBuffer() *bytes.Buffer {
return c.outBuffer
}
// ErrBuffer Buffer returns the stderr buffer
func (c *FakeCli) ErrBuffer() *bytes.Buffer {
return c.errBuffer
}
// ResetOutputBuffers resets the .OutBuffer() and.ErrBuffer() back to empty
func (c *FakeCli) ResetOutputBuffers() {
c.outBuffer.Reset()
c.errBuffer.Reset()
}
// SetNotaryClient sets the internal getter for retrieving a NotaryClient
func (c *FakeCli) SetNotaryClient(notaryClientFunc NotaryClientFuncType) {
c.notaryClientFunc = notaryClientFunc
}
// NotaryClient returns an err for testing unless defined
func (c *FakeCli) NotaryClient() (notaryclient.Repository, error) {
if c.notaryClientFunc != nil {
return c.notaryClientFunc()
}
return nil, errors.New("no notary client available unless defined")
}

View File

@ -0,0 +1,15 @@
package test
import (
"crypto/rand"
"encoding/hex"
)
// RandomID returns a unique, 64-character ID consisting of a-z, 0-9.
func RandomID() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err) // This shouldn't happen
}
return hex.EncodeToString(b)
}

114
cmd/docker-trust/main.go Normal file
View File

@ -0,0 +1,114 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
cerrdefs "github.com/containerd/errdefs"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/docker/cli/cli-plugins/plugin"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/version"
"github.com/docker/cli/cmd/docker-trust/trust"
"go.opentelemetry.io/otel"
)
func runStandalone(cmd *command.DockerCli) error {
defer flushMetrics(cmd)
executable := os.Args[0]
rootCmd := trust.NewRootCmd(filepath.Base(executable), false, cmd)
return rootCmd.Execute()
}
// flushMetrics will manually flush metrics from the configured
// meter provider. This is needed when running in standalone mode
// because the meter provider is initialized by the cli library,
// but the mechanism for forcing it to report is not presently
// exposed and not invoked when run in standalone mode.
// There are plans to fix that in the next release, but this is
// needed temporarily until the API for this is more thorough.
func flushMetrics(cmd *command.DockerCli) {
if mp, ok := cmd.MeterProvider().(command.MeterProvider); ok {
if err := mp.ForceFlush(context.Background()); err != nil {
otel.Handle(err)
}
}
}
func runPlugin(cmd *command.DockerCli) error {
rootCmd := trust.NewRootCmd("trust", true, cmd)
return plugin.RunPlugin(cmd, rootCmd, metadata.Metadata{
SchemaVersion: "0.1.0",
Vendor: "Docker Inc.",
Version: version.Version,
})
}
func run(cmd *command.DockerCli) error {
if plugin.RunningStandalone() {
return runStandalone(cmd)
}
return runPlugin(cmd)
}
type errCtxSignalTerminated struct {
signal os.Signal
}
func (errCtxSignalTerminated) Error() string {
return ""
}
func main() {
cmd, err := command.NewDockerCli()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err = run(cmd); err == nil {
return
}
if errors.As(err, &errCtxSignalTerminated{}) {
os.Exit(getExitCode(err))
}
if !cerrdefs.IsCanceled(err) {
if err.Error() != "" {
_, _ = fmt.Fprintln(cmd.Err(), err)
}
os.Exit(getExitCode(err))
}
}
// getExitCode returns the exit-code to use for the given error.
// If err is a [cli.StatusError] and has a StatusCode set, it uses the
// status-code from it, otherwise it returns "1" for any error.
func getExitCode(err error) int {
if err == nil {
return 0
}
var userTerminatedErr errCtxSignalTerminated
if errors.As(err, &userTerminatedErr) {
s, ok := userTerminatedErr.signal.(syscall.Signal)
if !ok {
return 1
}
return 128 + int(s)
}
var stErr cli.StatusError
if errors.As(err, &stErr) && stErr.StatusCode != 0 { // FIXME(thaJeztah): StatusCode should never be used with a zero status-code. Check if we do this anywhere.
return stErr.StatusCode
}
// No status-code provided; all errors should have a non-zero exit code.
return 1
}

View File

@ -0,0 +1,81 @@
package trust
import (
"fmt"
"github.com/docker/cli-docs-tool/annotation"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli-plugins/plugin"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/debug"
cliflags "github.com/docker/cli/cli/flags"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func NewRootCmd(name string, isPlugin bool, dockerCLI *command.DockerCli) *cobra.Command {
var opt rootOptions
cmd := &cobra.Command{
Use: name,
Short: "Manage trust on Docker images",
Long: `Extended build capabilities with BuildKit`,
Annotations: map[string]string{
annotation.CodeDelimiter: `"`,
},
CompletionOptions: cobra.CompletionOptions{
HiddenDefaultCmd: true,
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if opt.debug {
debug.Enable()
}
// cmd.SetContext(appcontext.Context())
if !isPlugin {
// InstallFlags and SetDefaultOptions are necessary to match
// the plugin mode behavior to handle env vars such as
// DOCKER_TLS, DOCKER_TLS_VERIFY, ... and we also need to use a
// new flagset to avoid conflict with the global debug flag
// that we already handle in the root command otherwise it
// would panic.
nflags := pflag.NewFlagSet(cmd.DisplayName(), pflag.ContinueOnError)
options := cliflags.NewClientOptions()
options.InstallFlags(nflags)
options.SetDefaultOptions(nflags)
return dockerCLI.Initialize(options)
}
return plugin.PersistentPreRunE(cmd, args)
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
_ = cmd.Help()
return cli.StatusError{
StatusCode: 1,
Status: fmt.Sprintf("ERROR: unknown command: %q", args[0]),
}
},
}
if !isPlugin {
// match plugin behavior for standalone mode
// https://github.com/docker/cli/blob/6c9eb708fa6d17765d71965f90e1c59cea686ee9/cli-plugins/plugin/plugin.go#L117-L127
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.TraverseChildren = true
cmd.DisableFlagsInUseLine = true
}
cmd.AddCommand(
newRevokeCommand(dockerCLI),
newSignCommand(dockerCLI),
newTrustKeyCommand(dockerCLI),
newTrustSignerCommand(dockerCLI),
newInspectCommand(dockerCLI),
)
return cmd
}
type rootOptions struct {
debug bool
}

View File

@ -10,7 +10,7 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/fvbommel/sortorder"
registrytypes "github.com/moby/moby/api/types/registry"
"github.com/sirupsen/logrus"

View File

@ -3,7 +3,7 @@ package trust
import (
"testing"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/theupdateframework/notary/client"
"github.com/theupdateframework/notary/tuf/data"
"gotest.tools/v3/assert"

View File

@ -5,7 +5,7 @@ import (
"testing"
"github.com/docker/cli/cli/command/formatter"
"github.com/docker/cli/internal/test"
"github.com/docker/cli/cmd/docker-trust/internal/test"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)

View File

@ -3,7 +3,7 @@ package trust
import (
"strings"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/theupdateframework/notary/client"
"github.com/theupdateframework/notary/tuf/data"
)

View File

@ -8,9 +8,9 @@ import (
"net/http"
"testing"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/internal/test"
notaryfake "github.com/docker/cli/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/test"
notaryfake "github.com/docker/cli/cmd/docker-trust/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/moby/moby/client"
"github.com/theupdateframework/notary"
notaryclient "github.com/theupdateframework/notary/client"

View File

@ -4,8 +4,8 @@ import (
"io"
"testing"
"github.com/docker/cli/internal/test"
"github.com/docker/cli/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/test"
"github.com/docker/cli/cmd/docker-trust/internal/test/notary"
"github.com/theupdateframework/notary/client"
"gotest.tools/v3/assert"
"gotest.tools/v3/golden"

View File

@ -9,7 +9,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/docker/cli/internal/lazyregexp"
"github.com/spf13/cobra"
"github.com/theupdateframework/notary"

View File

@ -9,7 +9,7 @@ import (
"testing"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/internal/test"
"github.com/docker/cli/cmd/docker-trust/internal/test"
"github.com/theupdateframework/notary"
"github.com/theupdateframework/notary/trustmanager"
tufutils "github.com/theupdateframework/notary/tuf/utils"

View File

@ -11,7 +11,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/spf13/cobra"
"github.com/theupdateframework/notary"
"github.com/theupdateframework/notary/storage"

View File

@ -10,7 +10,7 @@ import (
"testing"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/internal/test"
"github.com/docker/cli/cmd/docker-trust/internal/test"
"github.com/theupdateframework/notary"
"github.com/theupdateframework/notary/storage"
"github.com/theupdateframework/notary/trustmanager"

View File

@ -7,7 +7,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/docker/cli/internal/prompt"
"github.com/spf13/cobra"
"github.com/theupdateframework/notary/client"

View File

@ -5,8 +5,8 @@ import (
"io"
"testing"
"github.com/docker/cli/internal/test"
"github.com/docker/cli/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/test"
"github.com/docker/cli/cmd/docker-trust/internal/test/notary"
"github.com/theupdateframework/notary/client"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"

View File

@ -12,7 +12,7 @@ import (
"github.com/distribution/reference"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/moby/moby/api/pkg/authconfig"
"github.com/moby/moby/client"
"github.com/spf13/cobra"

View File

@ -8,9 +8,9 @@ import (
"testing"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/internal/test"
notaryfake "github.com/docker/cli/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/test"
notaryfake "github.com/docker/cli/cmd/docker-trust/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/theupdateframework/notary"
"github.com/theupdateframework/notary/client"
"github.com/theupdateframework/notary/client/changelist"

View File

@ -11,7 +11,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/docker/cli/internal/lazyregexp"
"github.com/docker/cli/opts"
"github.com/spf13/cobra"

View File

@ -9,8 +9,8 @@ import (
"testing"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/internal/test"
notaryfake "github.com/docker/cli/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/test"
notaryfake "github.com/docker/cli/cmd/docker-trust/internal/test/notary"
"github.com/theupdateframework/notary"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"

View File

@ -8,7 +8,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/docker/cli/internal/prompt"
"github.com/spf13/cobra"
"github.com/theupdateframework/notary/client"

View File

@ -5,8 +5,8 @@ import (
"io"
"testing"
"github.com/docker/cli/internal/test"
notaryfake "github.com/docker/cli/internal/test/notary"
"github.com/docker/cli/cmd/docker-trust/internal/test"
notaryfake "github.com/docker/cli/cmd/docker-trust/internal/test/notary"
"github.com/theupdateframework/notary/client"
"github.com/theupdateframework/notary/tuf/data"
"gotest.tools/v3/assert"

View File

@ -59,7 +59,6 @@ The base command for the Docker CLI.
| [`system`](system.md) | Manage Docker |
| [`tag`](tag.md) | Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE |
| [`top`](top.md) | Display the running processes of a container |
| [`trust`](trust.md) | Manage trust on Docker images |
| [`unpause`](unpause.md) | Unpause all processes within one or more containers |
| [`update`](update.md) | Update configuration of one or more containers |
| [`version`](version.md) | Show the Docker version information |

View File

@ -2,7 +2,6 @@ package test
import (
"bytes"
"errors"
"io"
"strings"
@ -14,29 +13,24 @@ import (
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/registryclient"
"github.com/moby/moby/client"
notaryclient "github.com/theupdateframework/notary/client"
)
// NotaryClientFuncType defines a function that returns a fake notary client
type NotaryClientFuncType func() (notaryclient.Repository, error)
// FakeCli emulates the default DockerCli
type FakeCli struct {
command.DockerCli
client client.APIClient
configfile *configfile.ConfigFile
out *streams.Out
outBuffer *bytes.Buffer
err *streams.Out
errBuffer *bytes.Buffer
in *streams.In
server command.ServerInfo
notaryClientFunc NotaryClientFuncType
manifestStore manifeststore.Store
registryClient registryclient.RegistryClient
contextStore store.Store
currentContext string
dockerEndpoint docker.Endpoint
client client.APIClient
configfile *configfile.ConfigFile
out *streams.Out
outBuffer *bytes.Buffer
err *streams.Out
errBuffer *bytes.Buffer
in *streams.In
server command.ServerInfo
manifestStore manifeststore.Store
registryClient registryclient.RegistryClient
contextStore store.Store
currentContext string
dockerEndpoint docker.Endpoint
}
// NewFakeCli returns a fake for the command.Cli interface
@ -162,19 +156,6 @@ func (c *FakeCli) ResetOutputBuffers() {
c.errBuffer.Reset()
}
// SetNotaryClient sets the internal getter for retrieving a NotaryClient
func (c *FakeCli) SetNotaryClient(notaryClientFunc NotaryClientFuncType) {
c.notaryClientFunc = notaryClientFunc
}
// NotaryClient returns an err for testing unless defined
func (c *FakeCli) NotaryClient() (notaryclient.Repository, error) {
if c.notaryClientFunc != nil {
return c.notaryClientFunc()
}
return nil, errors.New("no notary client available unless defined")
}
// ManifestStore returns a fake store used for testing
func (c *FakeCli) ManifestStore() manifeststore.Store {
return c.manifestStore

22
scripts/build/trust-plugin Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
#
# Build plugins examples for the host OS/ARCH
#
set -eu -o pipefail
# Disable CGO - we don't need it for these plugins.
#
# Important: this must be done before sourcing "./scripts/build/.variables",
# because some other variables are conditionally set whether CGO is enabled.
export CGO_ENABLED=0
source ./scripts/build/.variables
TARGET_PLUGIN="$(dirname "${TARGET}")/docker-trust-${GOOS}-${GOARCH}"
mkdir -p "$(dirname "${TARGET_PLUGIN}")"
echo "Building $GO_LINKMODE $(basename "${TARGET_PLUGIN}")"
(set -x ; GO111MODULE=auto go build -o "${TARGET_PLUGIN}" -tags "${GO_BUILDTAGS}" -ldflags "${GO_LDFLAGS}" ${GO_BUILDMODE} "github.com/docker/cli/cmd/docker-trust")
ln -sf "$(basename "${TARGET_PLUGIN}")" "$(dirname "${TARGET}")/docker-trust"