From b4799f9d16b85aa3f4a7d7d329c4dc1363f8b816 Mon Sep 17 00:00:00 2001 From: "Jonathan A. Sternberg" Date: Mon, 25 Mar 2024 16:10:04 -0500 Subject: [PATCH] metricutil: switch to using the cli meter provider The meter provider initialization that was located here has now been moved to a common area in the docker cli. This upgrades our CLI version and then uses this common code instead of our own version. As a piece of additional functionality, the docker OTEL endpoint can now be overwritten with `DOCKER_CLI_OTEL_EXPORTER_OTLP_ENDPOINT` for testing. This removes the OTLP exporter from the CLI that was previously locked behind `BUILDX_EXPERIMENTAL`. I do plan for this to return, but as a proper part of the `docker/cli` implementation rather than something special with `buildx`. Signed-off-by: Jonathan A. Sternberg --- commands/build.go | 7 +- go.mod | 10 +- go.sum | 4 +- util/metricutil/metric.go | 209 +----------------- util/metricutil/otlp.go | 45 ---- util/metricutil/resource.go | 53 ----- util/metricutil/resource_test.go | 33 --- .../docker/cli/cli-plugins/hooks/printer.go | 18 ++ .../docker/cli/cli-plugins/hooks/template.go | 115 ++++++++++ .../docker/cli/cli-plugins/manager/error.go | 3 + .../docker/cli/cli-plugins/manager/hooks.go | 127 +++++++++++ .../cli/cli-plugins/manager/metadata.go | 5 + .../docker/cli/cli-plugins/manager/plugin.go | 20 ++ .../docker/cli/cli-plugins/socket/socket.go | 120 ++++++++-- .../cli/cli-plugins/socket/socket_abstract.go | 9 + .../cli/cli-plugins/socket/socket_darwin.go | 19 -- .../cli-plugins/socket/socket_noabstract.go | 14 ++ .../cli/cli-plugins/socket/socket_nodarwin.go | 20 -- .../cli/cli-plugins/socket/socket_openbsd.go | 19 -- .../github.com/docker/cli/cli/command/cli.go | 32 +++ .../docker/cli/cli/command/telemetry.go | 202 +++++++++++++++++ .../cli/cli/command/telemetry_docker.go | 142 ++++++++++++ .../docker/cli/cli/command/telemetry_utils.go | 158 +++++++++++++ .../docker/cli/cli/config/configfile/file.go | 1 + vendor/modules.txt | 3 +- 25 files changed, 963 insertions(+), 425 deletions(-) delete mode 100644 util/metricutil/otlp.go delete mode 100644 util/metricutil/resource.go delete mode 100644 util/metricutil/resource_test.go create mode 100644 vendor/github.com/docker/cli/cli-plugins/hooks/printer.go create mode 100644 vendor/github.com/docker/cli/cli-plugins/hooks/template.go create mode 100644 vendor/github.com/docker/cli/cli-plugins/manager/hooks.go create mode 100644 vendor/github.com/docker/cli/cli-plugins/socket/socket_abstract.go delete mode 100644 vendor/github.com/docker/cli/cli-plugins/socket/socket_darwin.go create mode 100644 vendor/github.com/docker/cli/cli-plugins/socket/socket_noabstract.go delete mode 100644 vendor/github.com/docker/cli/cli-plugins/socket/socket_nodarwin.go delete mode 100644 vendor/github.com/docker/cli/cli-plugins/socket/socket_openbsd.go create mode 100644 vendor/github.com/docker/cli/cli/command/telemetry.go create mode 100644 vendor/github.com/docker/cli/cli/command/telemetry_docker.go create mode 100644 vendor/github.com/docker/cli/cli/command/telemetry_utils.go diff --git a/commands/build.go b/commands/build.go index 7abe4300812..662640a33a5 100644 --- a/commands/build.go +++ b/commands/build.go @@ -266,11 +266,8 @@ func (o *buildOptionsHash) String() string { } func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) (err error) { - mp, err := metricutil.NewMeterProvider(ctx, dockerCli) - if err != nil { - return err - } - defer mp.Report(context.Background()) + mp := dockerCli.MeterProvider(ctx) + defer metricutil.Shutdown(ctx, mp) ctx, end, err := tracing.TraceCurrentCommand(ctx, "build") if err != nil { diff --git a/go.mod b/go.mod index 38ea139f5f1..26b1b778820 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/containerd/typeurl/v2 v2.1.1 github.com/creack/pty v1.1.18 github.com/distribution/reference v0.5.0 - github.com/docker/cli v26.0.0+incompatible + github.com/docker/cli v26.0.1-0.20240401150816-155dc5e4e406+incompatible github.com/docker/cli-docs-tool v0.7.0 github.com/docker/docker v26.0.0-rc1+incompatible github.com/docker/go-units v0.5.0 @@ -42,11 +42,11 @@ require ( github.com/tonistiigi/fsutil v0.0.0-20240301111122-7525a1af2bb5 github.com/zclconf/go-cty v1.14.1 go.opentelemetry.io/otel v1.21.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.42.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 // indirect go.opentelemetry.io/otel/metric v1.21.0 - go.opentelemetry.io/otel/sdk v1.21.0 - go.opentelemetry.io/otel/sdk/metric v1.21.0 + go.opentelemetry.io/otel/sdk v1.21.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 golang.org/x/mod v0.14.0 golang.org/x/sync v0.6.0 diff --git a/go.sum b/go.sum index 3cdc1005b42..00244170b9b 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= 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/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I= -github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v26.0.1-0.20240401150816-155dc5e4e406+incompatible h1:+BQSfkbpbEDXXfHmFfmbfstUBFjhffsTkfa0iudOFLc= +github.com/docker/cli v26.0.1-0.20240401150816-155dc5e4e406+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli-docs-tool v0.7.0 h1:M2Da98Unz2kz3A5d4yeSGbhyOge2mfYSNjAFt01Rw0M= github.com/docker/cli-docs-tool v0.7.0/go.mod h1:zMjqTFCU361PRh8apiXzeAZ1Q/xupbIwTusYpzCXS/o= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= diff --git a/util/metricutil/metric.go b/util/metricutil/metric.go index b233e593afb..6e64067b9ac 100644 --- a/util/metricutil/metric.go +++ b/util/metricutil/metric.go @@ -2,218 +2,23 @@ package metricutil import ( "context" - "fmt" - "net/url" - "path" - "time" - "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/version" "github.com/docker/cli/cli/command" - "github.com/pkg/errors" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/metric/noop" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/metric/metricdata" - "golang.org/x/sync/errgroup" ) -const ( - otelConfigFieldName = "otel" - reportTimeout = 2 * time.Second -) - -// MeterProvider holds a MeterProvider for metric generation and the configured -// exporters for reporting metrics from the CLI. -type MeterProvider struct { - metric.MeterProvider - reader *sdkmetric.ManualReader - exporters []sdkmetric.Exporter -} - -// NewMeterProvider configures a MeterProvider from the CLI context. -func NewMeterProvider(ctx context.Context, cli command.Cli) (*MeterProvider, error) { - var exps []sdkmetric.Exporter - - if exp, err := dockerOtelExporter(cli); err != nil { - return nil, err - } else if exp != nil { - exps = append(exps, exp) - } - - if confutil.IsExperimental() { - // Expose the user-facing metric exporter only if the experimental flag is set. - if exp, err := detectOtlpExporter(ctx); err != nil { - return nil, err - } else if exp != nil { - exps = append(exps, exp) - } - } - - if len(exps) == 0 { - // No exporters are configured so use a noop provider. - return &MeterProvider{ - MeterProvider: noop.NewMeterProvider(), - }, nil - } - - reader := sdkmetric.NewManualReader( - sdkmetric.WithTemporalitySelector(deltaTemporality), - ) - mp := sdkmetric.NewMeterProvider( - sdkmetric.WithResource(Resource()), - sdkmetric.WithReader(reader), - ) - return &MeterProvider{ - MeterProvider: mp, - reader: reader, - exporters: exps, - }, nil -} - -// Report exports metrics to the configured exporter. This should be done before the CLI -// exits. -func (m *MeterProvider) Report(ctx context.Context) { - if m.reader == nil { - // Not configured. - return - } - - ctx, cancel := context.WithTimeout(ctx, reportTimeout) - defer cancel() - - var rm metricdata.ResourceMetrics - if err := m.reader.Collect(ctx, &rm); err != nil { - // Error when collecting metrics. Do not send any. - otel.Handle(err) - return - } - - var eg errgroup.Group - for _, exp := range m.exporters { - exp := exp - eg.Go(func() error { - if err := exp.Export(ctx, &rm); err != nil { - otel.Handle(err) - } - _ = exp.Shutdown(ctx) - return nil - }) - } - - // Can't report an error because we don't allow it to. - _ = eg.Wait() -} - -// dockerOtelExporter reads the CLI metadata to determine an OTLP exporter -// endpoint for docker metrics to be sent. -// -// This location, configuration, and usage is hard-coded as part of -// sending usage statistics so this metric reporting is not meant to be -// user facing. -func dockerOtelExporter(cli command.Cli) (sdkmetric.Exporter, error) { - endpoint, err := otelExporterOtlpEndpoint(cli) - if endpoint == "" || err != nil { - return nil, err - } - - // Parse the endpoint. The docker config expects the endpoint to be - // in the form of a URL to match the environment variable, but this - // option doesn't correspond directly to WithEndpoint. - // - // We pretend we're the same as the environment reader. - u, err := url.Parse(endpoint) - if err != nil { - return nil, errors.Errorf("docker otel endpoint is invalid: %s", err) - } - - var opts []otlpmetricgrpc.Option - switch u.Scheme { - case "unix": - // Unix sockets are a bit weird. OTEL seems to imply they - // can be used as an environment variable and are handled properly, - // but they don't seem to be as the behavior of the environment variable - // is to strip the scheme from the endpoint, but the underlying implementation - // needs the scheme to use the correct resolver. - // - // We'll just handle this in a special way and add the unix:// back to the endpoint. - opts = []otlpmetricgrpc.Option{ - otlpmetricgrpc.WithEndpoint(fmt.Sprintf("unix://%s", path.Join(u.Host, u.Path))), - otlpmetricgrpc.WithInsecure(), - } - case "http": - opts = []otlpmetricgrpc.Option{ - // Omit the scheme when using http or https. - otlpmetricgrpc.WithEndpoint(path.Join(u.Host, u.Path)), - otlpmetricgrpc.WithInsecure(), - } - default: - opts = []otlpmetricgrpc.Option{ - // Omit the scheme when using http or https. - otlpmetricgrpc.WithEndpoint(path.Join(u.Host, u.Path)), - } - } - - // Hardcoded endpoint from the endpoint. - exp, err := otlpmetricgrpc.New(context.Background(), opts...) - if err != nil { - return nil, err - } - return exp, nil -} - -// otelExporterOtlpEndpoint retrieves the OTLP endpoint used for the docker reporter -// from the current context. -func otelExporterOtlpEndpoint(cli command.Cli) (string, error) { - meta, err := cli.ContextStore().GetMetadata(cli.CurrentContext()) - if err != nil { - return "", err - } - - var otelCfg interface{} - switch m := meta.Metadata.(type) { - case command.DockerContext: - otelCfg = m.AdditionalFields[otelConfigFieldName] - case map[string]interface{}: - otelCfg = m[otelConfigFieldName] - } - - if otelCfg == nil { - return "", nil - } - - otelMap, ok := otelCfg.(map[string]interface{}) - if !ok { - return "", errors.Errorf( - "unexpected type for field %q: %T (expected: %T)", - otelConfigFieldName, - otelCfg, - otelMap, - ) - } - - // keys from https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/ - endpoint, _ := otelMap["OTEL_EXPORTER_OTLP_ENDPOINT"].(string) - return endpoint, nil -} - -// deltaTemporality sets the Temporality of every instrument to delta. -// -// This isn't really needed since we create a unique resource on each invocation, -// but it can help with cardinality concerns for downstream processors since they can -// perform aggregation for a time interval and then discard the data once that time -// period has passed. Cumulative temporality would imply to the downstream processor -// that they might receive a successive point and they may unnecessarily keep state -// they really shouldn't. -func deltaTemporality(_ sdkmetric.InstrumentKind) metricdata.Temporality { - return metricdata.DeltaTemporality -} - // Meter returns a Meter from the MetricProvider that indicates the measurement // comes from buildx with the appropriate version. func Meter(mp metric.MeterProvider) metric.Meter { return mp.Meter(version.Package, metric.WithInstrumentationVersion(version.Version)) } + +// Shutdown invokes Shutdown on the MeterProvider and then reports any error to the OTEL handler. +func Shutdown(ctx context.Context, mp command.MeterProvider) { + if err := mp.Shutdown(ctx); err != nil { + otel.Handle(err) + } +} diff --git a/util/metricutil/otlp.go b/util/metricutil/otlp.go deleted file mode 100644 index c07d244edf6..00000000000 --- a/util/metricutil/otlp.go +++ /dev/null @@ -1,45 +0,0 @@ -package metricutil - -import ( - "context" - "os" - - "github.com/pkg/errors" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" -) - -// detectOtlpExporter configures a metrics exporter based on environment variables. -// This is similar to the version of this in buildkit, but we need direct access -// to the exporter and the prometheus exporter doesn't work at all in a CLI context. -// -// There's some duplication here which I hope to remove when the detect package -// is refactored or extracted from buildkit so it can be utilized here. -// -// This version of the exporter is public facing in contrast to the -// docker otel collector. -func detectOtlpExporter(ctx context.Context) (sdkmetric.Exporter, error) { - set := os.Getenv("OTEL_METRICS_EXPORTER") == "otlp" || os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" || os.Getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") != "" - if !set { - return nil, nil - } - - proto := os.Getenv("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL") - if proto == "" { - proto = os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL") - } - if proto == "" { - proto = "grpc" - } - - switch proto { - case "grpc": - return otlpmetricgrpc.New(ctx) - case "http/protobuf": - return otlpmetrichttp.New(ctx) - // case "http/json": // unsupported by library - default: - return nil, errors.Errorf("unsupported otlp protocol %v", proto) - } -} diff --git a/util/metricutil/resource.go b/util/metricutil/resource.go deleted file mode 100644 index 65e95683762..00000000000 --- a/util/metricutil/resource.go +++ /dev/null @@ -1,53 +0,0 @@ -package metricutil - -import ( - "context" - "os" - "path/filepath" - "sync" - - "github.com/google/uuid" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/sdk/resource" - semconv "go.opentelemetry.io/otel/semconv/v1.21.0" -) - -var ( - res *resource.Resource - resOnce sync.Once -) - -// Resource retrieves the OTEL resource for the buildx CLI. -func Resource() *resource.Resource { - resOnce.Do(func() { - var err error - res, err = resource.New(context.Background(), - resource.WithDetectors(serviceNameDetector{}), - resource.WithAttributes( - // Use a unique instance id so OTEL knows that each invocation - // of the CLI is its own instance. Without this, downstream - // OTEL processors may think the same process is restarting - // continuously and reset the metric counters. - semconv.ServiceInstanceID(uuid.New().String()), - ), - resource.WithFromEnv(), - resource.WithTelemetrySDK(), - ) - if err != nil { - otel.Handle(err) - } - }) - return res -} - -type serviceNameDetector struct{} - -func (serviceNameDetector) Detect(ctx context.Context) (*resource.Resource, error) { - return resource.StringDetector( - semconv.SchemaURL, - semconv.ServiceNameKey, - func() (string, error) { - return filepath.Base(os.Args[0]), nil - }, - ).Detect(ctx) -} diff --git a/util/metricutil/resource_test.go b/util/metricutil/resource_test.go deleted file mode 100644 index 4b17e5c6798..00000000000 --- a/util/metricutil/resource_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package metricutil - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "go.opentelemetry.io/otel" -) - -func TestResource(t *testing.T) { - setErrorHandler(t) - - // Ensure resource creation doesn't result in an error. - // This is because the schema urls for the various attributes need to be - // the same, but it's really easy to import the wrong package when upgrading - // otel to anew version and the buildx CLI swallows any visible errors. - res := Resource() - - // Ensure an attribute is present. - assert.True(t, res.Set().HasValue("telemetry.sdk.version"), "resource attribute missing") -} - -func setErrorHandler(tb testing.TB) { - tb.Helper() - - errorHandler := otel.GetErrorHandler() - otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { - tb.Errorf("otel error: %s", err) - })) - tb.Cleanup(func() { - otel.SetErrorHandler(errorHandler) - }) -} diff --git a/vendor/github.com/docker/cli/cli-plugins/hooks/printer.go b/vendor/github.com/docker/cli/cli-plugins/hooks/printer.go new file mode 100644 index 00000000000..bedc87f929b --- /dev/null +++ b/vendor/github.com/docker/cli/cli-plugins/hooks/printer.go @@ -0,0 +1,18 @@ +package hooks + +import ( + "fmt" + "io" + + "github.com/morikuni/aec" +) + +func PrintNextSteps(out io.Writer, messages []string) { + if len(messages) == 0 { + return + } + fmt.Fprintln(out, aec.Bold.Apply("\nWhat's next:")) + for _, n := range messages { + _, _ = fmt.Fprintf(out, " %s\n", n) + } +} diff --git a/vendor/github.com/docker/cli/cli-plugins/hooks/template.go b/vendor/github.com/docker/cli/cli-plugins/hooks/template.go new file mode 100644 index 00000000000..d7e114e1cd4 --- /dev/null +++ b/vendor/github.com/docker/cli/cli-plugins/hooks/template.go @@ -0,0 +1,115 @@ +package hooks + +import ( + "bytes" + "errors" + "fmt" + "strconv" + "text/template" + + "github.com/spf13/cobra" +) + +type HookType int + +const ( + NextSteps = iota +) + +// HookMessage represents a plugin hook response. Plugins +// declaring support for CLI hooks need to print a json +// representation of this type when their hook subcommand +// is invoked. +type HookMessage struct { + Type HookType + Template string +} + +// TemplateReplaceSubcommandName returns a hook template string +// that will be replaced by the CLI subcommand being executed +// +// Example: +// +// "you ran the subcommand: " + TemplateReplaceSubcommandName() +// +// when being executed after the command: +// `docker run --name "my-container" alpine` +// will result in the message: +// `you ran the subcommand: run` +func TemplateReplaceSubcommandName() string { + return hookTemplateCommandName +} + +// TemplateReplaceFlagValue returns a hook template string +// that will be replaced by the flags value. +// +// Example: +// +// "you ran a container named: " + TemplateReplaceFlagValue("name") +// +// when being executed after the command: +// `docker run --name "my-container" alpine` +// will result in the message: +// `you ran a container named: my-container` +func TemplateReplaceFlagValue(flag string) string { + return fmt.Sprintf(hookTemplateFlagValue, flag) +} + +// TemplateReplaceArg takes an index i and returns a hook +// template string that the CLI will replace the template with +// the ith argument, after processing the passed flags. +// +// Example: +// +// "run this image with `docker run " + TemplateReplaceArg(0) + "`" +// +// when being executed after the command: +// `docker pull alpine` +// will result in the message: +// "Run this image with `docker run alpine`" +func TemplateReplaceArg(i int) string { + return fmt.Sprintf(hookTemplateArg, strconv.Itoa(i)) +} + +func ParseTemplate(hookTemplate string, cmd *cobra.Command) (string, error) { + tmpl := template.New("").Funcs(commandFunctions) + tmpl, err := tmpl.Parse(hookTemplate) + if err != nil { + return "", err + } + b := bytes.Buffer{} + err = tmpl.Execute(&b, cmd) + if err != nil { + return "", err + } + return b.String(), nil +} + +var ErrHookTemplateParse = errors.New("failed to parse hook template") + +const ( + hookTemplateCommandName = "{{.Name}}" + hookTemplateFlagValue = `{{flag . "%s"}}` + hookTemplateArg = "{{arg . %s}}" +) + +var commandFunctions = template.FuncMap{ + "flag": getFlagValue, + "arg": getArgValue, +} + +func getFlagValue(cmd *cobra.Command, flag string) (string, error) { + cmdFlag := cmd.Flag(flag) + if cmdFlag == nil { + return "", ErrHookTemplateParse + } + return cmdFlag.Value.String(), nil +} + +func getArgValue(cmd *cobra.Command, i int) (string, error) { + flags := cmd.Flags() + if flags == nil { + return "", ErrHookTemplateParse + } + return flags.Arg(i), nil +} diff --git a/vendor/github.com/docker/cli/cli-plugins/manager/error.go b/vendor/github.com/docker/cli/cli-plugins/manager/error.go index 4e1c3a29144..f802da1c5c1 100644 --- a/vendor/github.com/docker/cli/cli-plugins/manager/error.go +++ b/vendor/github.com/docker/cli/cli-plugins/manager/error.go @@ -41,6 +41,9 @@ func (e *pluginError) MarshalText() (text []byte, err error) { // wrapAsPluginError wraps an error in a pluginError with an // additional message, analogous to errors.Wrapf. func wrapAsPluginError(err error, msg string) error { + if err == nil { + return nil + } return &pluginError{cause: errors.Wrap(err, msg)} } diff --git a/vendor/github.com/docker/cli/cli-plugins/manager/hooks.go b/vendor/github.com/docker/cli/cli-plugins/manager/hooks.go new file mode 100644 index 00000000000..b4f8d16ddfb --- /dev/null +++ b/vendor/github.com/docker/cli/cli-plugins/manager/hooks.go @@ -0,0 +1,127 @@ +package manager + +import ( + "encoding/json" + "strings" + + "github.com/docker/cli/cli-plugins/hooks" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// HookPluginData is the type representing the information +// that plugins declaring support for hooks get passed when +// being invoked following a CLI command execution. +type HookPluginData struct { + RootCmd string + Flags map[string]string +} + +// RunPluginHooks calls the hook subcommand for all present +// CLI plugins that declare support for hooks in their metadata +// and parses/prints their responses. +func RunPluginHooks(dockerCli command.Cli, rootCmd, subCommand *cobra.Command, plugin string, args []string) error { + subCmdName := subCommand.Name() + if plugin != "" { + subCmdName = plugin + } + var flags map[string]string + if plugin == "" { + flags = getCommandFlags(subCommand) + } else { + flags = getNaiveFlags(args) + } + nextSteps := invokeAndCollectHooks(dockerCli, rootCmd, subCommand, subCmdName, flags) + + hooks.PrintNextSteps(dockerCli.Err(), nextSteps) + return nil +} + +func invokeAndCollectHooks(dockerCli command.Cli, rootCmd, subCmd *cobra.Command, hookCmdName string, flags map[string]string) []string { + pluginsCfg := dockerCli.ConfigFile().Plugins + if pluginsCfg == nil { + return nil + } + + nextSteps := make([]string, 0, len(pluginsCfg)) + for pluginName, cfg := range pluginsCfg { + if !registersHook(cfg, hookCmdName) { + continue + } + + p, err := GetPlugin(pluginName, dockerCli, rootCmd) + if err != nil { + continue + } + + hookReturn, err := p.RunHook(hookCmdName, flags) + if err != nil { + // skip misbehaving plugins, but don't halt execution + continue + } + + var hookMessageData hooks.HookMessage + err = json.Unmarshal(hookReturn, &hookMessageData) + if err != nil { + continue + } + + // currently the only hook type + if hookMessageData.Type != hooks.NextSteps { + continue + } + + processedHook, err := hooks.ParseTemplate(hookMessageData.Template, subCmd) + if err != nil { + continue + } + nextSteps = append(nextSteps, processedHook) + } + return nextSteps +} + +func registersHook(pluginCfg map[string]string, subCmdName string) bool { + hookCmdStr, ok := pluginCfg["hooks"] + if !ok { + return false + } + commands := strings.Split(hookCmdStr, ",") + for _, hookCmd := range commands { + if hookCmd == subCmdName { + return true + } + } + return false +} + +func getCommandFlags(cmd *cobra.Command) map[string]string { + flags := make(map[string]string) + cmd.Flags().Visit(func(f *pflag.Flag) { + var fValue string + if f.Value.Type() == "bool" { + fValue = f.Value.String() + } + flags[f.Name] = fValue + }) + return flags +} + +// getNaiveFlags string-matches argv and parses them into a map. +// This is used when calling hooks after a plugin command, since +// in this case we can't rely on the cobra command tree to parse +// flags in this case. In this case, no values are ever passed, +// since we don't have enough information to process them. +func getNaiveFlags(args []string) map[string]string { + flags := make(map[string]string) + for _, arg := range args { + if strings.HasPrefix(arg, "--") { + flags[arg[2:]] = "" + continue + } + if strings.HasPrefix(arg, "-") { + flags[arg[1:]] = "" + } + } + return flags +} diff --git a/vendor/github.com/docker/cli/cli-plugins/manager/metadata.go b/vendor/github.com/docker/cli/cli-plugins/manager/metadata.go index 2f244386382..f7aac06fe9c 100644 --- a/vendor/github.com/docker/cli/cli-plugins/manager/metadata.go +++ b/vendor/github.com/docker/cli/cli-plugins/manager/metadata.go @@ -8,6 +8,11 @@ const ( // which must be supported by every plugin and returns the // plugin metadata. MetadataSubcommandName = "docker-cli-plugin-metadata" + + // HookSubcommandName is the name of the plugin subcommand + // which must be implemented by plugins declaring support + // for hooks in their metadata. + HookSubcommandName = "docker-cli-plugin-hooks" ) // Metadata provided by the plugin. diff --git a/vendor/github.com/docker/cli/cli-plugins/manager/plugin.go b/vendor/github.com/docker/cli/cli-plugins/manager/plugin.go index 58ed6db72c1..88600f4e592 100644 --- a/vendor/github.com/docker/cli/cli-plugins/manager/plugin.go +++ b/vendor/github.com/docker/cli/cli-plugins/manager/plugin.go @@ -2,6 +2,7 @@ package manager import ( "encoding/json" + "os/exec" "path/filepath" "regexp" "strings" @@ -100,3 +101,22 @@ func newPlugin(c Candidate, cmds []*cobra.Command) (Plugin, error) { } return p, nil } + +// RunHook executes the plugin's hooks command +// and returns its unprocessed output. +func (p *Plugin) RunHook(cmdName string, flags map[string]string) ([]byte, error) { + hDataBytes, err := json.Marshal(HookPluginData{ + RootCmd: cmdName, + Flags: flags, + }) + if err != nil { + return nil, wrapAsPluginError(err, "failed to marshall hook data") + } + + hookCmdOutput, err := exec.Command(p.Path, p.Name, HookSubcommandName, string(hDataBytes)).Output() + if err != nil { + return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand") + } + + return hookCmdOutput, nil +} diff --git a/vendor/github.com/docker/cli/cli-plugins/socket/socket.go b/vendor/github.com/docker/cli/cli-plugins/socket/socket.go index 67ba11562e3..fc91e78d8fd 100644 --- a/vendor/github.com/docker/cli/cli-plugins/socket/socket.go +++ b/vendor/github.com/docker/cli/cli-plugins/socket/socket.go @@ -7,24 +7,114 @@ import ( "io" "net" "os" + "runtime" + "sync" ) -// EnvKey represents the well-known environment variable used to pass the plugin being -// executed the socket name it should listen on to coordinate with the host CLI. +// EnvKey represents the well-known environment variable used to pass the +// plugin being executed the socket name it should listen on to coordinate with +// the host CLI. const EnvKey = "DOCKER_CLI_PLUGIN_SOCKET" -// SetupConn sets up a Unix socket listener, establishes a goroutine to handle connections -// and update the conn pointer, and returns the listener for the socket (which the caller -// is responsible for closing when it's no longer needed). -func SetupConn(conn **net.UnixConn) (*net.UnixListener, error) { - listener, err := listen("docker_cli_" + randomID()) +// NewPluginServer creates a plugin server that listens on a new Unix domain +// socket. h is called for each new connection to the socket in a goroutine. +func NewPluginServer(h func(net.Conn)) (*PluginServer, error) { + // Listen on a Unix socket, with the address being platform-dependent. + // When a non-abstract address is used, Go will unlink(2) the socket + // for us once the listener is closed, as documented in + // [net.UnixListener.SetUnlinkOnClose]. + l, err := net.ListenUnix("unix", &net.UnixAddr{ + Name: socketName("docker_cli_" + randomID()), + Net: "unix", + }) if err != nil { return nil, err } - accept(listener, conn) + if h == nil { + h = func(net.Conn) {} + } + + pl := &PluginServer{ + l: l, + h: h, + } + + go func() { + defer pl.Close() + for { + err := pl.accept() + if err != nil { + return + } + } + }() - return listener, nil + return pl, nil +} + +type PluginServer struct { + mu sync.Mutex + conns []net.Conn + l *net.UnixListener + h func(net.Conn) + closed bool +} + +func (pl *PluginServer) accept() error { + conn, err := pl.l.Accept() + if err != nil { + return err + } + + pl.mu.Lock() + defer pl.mu.Unlock() + + if pl.closed { + // Handle potential race between Close and accept. + conn.Close() + return errors.New("plugin server is closed") + } + + pl.conns = append(pl.conns, conn) + + go pl.h(conn) + return nil +} + +// Addr returns the [net.Addr] of the underlying [net.Listener]. +func (pl *PluginServer) Addr() net.Addr { + return pl.l.Addr() +} + +// Close ensures that the server is no longer accepting new connections and +// closes all existing connections. Existing connections will receive [io.EOF]. +// +// The error value is that of the underlying [net.Listner.Close] call. +func (pl *PluginServer) Close() error { + // Close connections first to ensure the connections get io.EOF instead + // of a connection reset. + pl.closeAllConns() + + // Try to ensure that any active connections have a chance to receive + // io.EOF. + runtime.Gosched() + + return pl.l.Close() +} + +func (pl *PluginServer) closeAllConns() { + pl.mu.Lock() + defer pl.mu.Unlock() + + // Prevent new connections from being accepted. + pl.closed = true + + for _, conn := range pl.conns { + conn.Close() + } + + pl.conns = nil } func randomID() string { @@ -35,18 +125,6 @@ func randomID() string { return hex.EncodeToString(b) } -func accept(listener *net.UnixListener, conn **net.UnixConn) { - go func() { - for { - // ignore error here, if we failed to accept a connection, - // conn is nil and we fallback to previous behavior - *conn, _ = listener.AcceptUnix() - // perform any platform-specific actions on accept (e.g. unlink non-abstract sockets) - onAccept(*conn, listener) - } - }() -} - // ConnectAndWait connects to the socket passed via well-known env var, // if present, and attempts to read from it until it receives an EOF, at which // point cb is called. diff --git a/vendor/github.com/docker/cli/cli-plugins/socket/socket_abstract.go b/vendor/github.com/docker/cli/cli-plugins/socket/socket_abstract.go new file mode 100644 index 00000000000..a0276fa9c11 --- /dev/null +++ b/vendor/github.com/docker/cli/cli-plugins/socket/socket_abstract.go @@ -0,0 +1,9 @@ +//go:build windows || linux + +package socket + +func socketName(basename string) string { + // Address of an abstract socket -- this socket can be opened by name, + // but is not present in the filesystem. + return "@" + basename +} diff --git a/vendor/github.com/docker/cli/cli-plugins/socket/socket_darwin.go b/vendor/github.com/docker/cli/cli-plugins/socket/socket_darwin.go deleted file mode 100644 index 17ab6aa69e6..00000000000 --- a/vendor/github.com/docker/cli/cli-plugins/socket/socket_darwin.go +++ /dev/null @@ -1,19 +0,0 @@ -package socket - -import ( - "net" - "os" - "path/filepath" - "syscall" -) - -func listen(socketname string) (*net.UnixListener, error) { - return net.ListenUnix("unix", &net.UnixAddr{ - Name: filepath.Join(os.TempDir(), socketname), - Net: "unix", - }) -} - -func onAccept(conn *net.UnixConn, listener *net.UnixListener) { - syscall.Unlink(listener.Addr().String()) -} diff --git a/vendor/github.com/docker/cli/cli-plugins/socket/socket_noabstract.go b/vendor/github.com/docker/cli/cli-plugins/socket/socket_noabstract.go new file mode 100644 index 00000000000..7033a2a0c6d --- /dev/null +++ b/vendor/github.com/docker/cli/cli-plugins/socket/socket_noabstract.go @@ -0,0 +1,14 @@ +//go:build !windows && !linux + +package socket + +import ( + "os" + "path/filepath" +) + +func socketName(basename string) string { + // Because abstract sockets are unavailable, use a socket path in the + // system temporary directory. + return filepath.Join(os.TempDir(), basename) +} diff --git a/vendor/github.com/docker/cli/cli-plugins/socket/socket_nodarwin.go b/vendor/github.com/docker/cli/cli-plugins/socket/socket_nodarwin.go deleted file mode 100644 index aa6065ecb44..00000000000 --- a/vendor/github.com/docker/cli/cli-plugins/socket/socket_nodarwin.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build !darwin && !openbsd - -package socket - -import ( - "net" -) - -func listen(socketname string) (*net.UnixListener, error) { - return net.ListenUnix("unix", &net.UnixAddr{ - Name: "@" + socketname, - Net: "unix", - }) -} - -func onAccept(conn *net.UnixConn, listener *net.UnixListener) { - // do nothing - // while on darwin and OpenBSD we would unlink here; - // on non-darwin the socket is abstract and not present on the filesystem -} diff --git a/vendor/github.com/docker/cli/cli-plugins/socket/socket_openbsd.go b/vendor/github.com/docker/cli/cli-plugins/socket/socket_openbsd.go deleted file mode 100644 index 17ab6aa69e6..00000000000 --- a/vendor/github.com/docker/cli/cli-plugins/socket/socket_openbsd.go +++ /dev/null @@ -1,19 +0,0 @@ -package socket - -import ( - "net" - "os" - "path/filepath" - "syscall" -) - -func listen(socketname string) (*net.UnixListener, error) { - return net.ListenUnix("unix", &net.UnixAddr{ - Name: filepath.Join(os.TempDir(), socketname), - Net: "unix", - }) -} - -func onAccept(conn *net.UnixConn, listener *net.UnixListener) { - syscall.Unlink(listener.Addr().String()) -} diff --git a/vendor/github.com/docker/cli/cli/command/cli.go b/vendor/github.com/docker/cli/cli/command/cli.go index aa3b28d5812..a8b4c88bcad 100644 --- a/vendor/github.com/docker/cli/cli/command/cli.go +++ b/vendor/github.com/docker/cli/cli/command/cli.go @@ -65,6 +65,7 @@ type Cli interface { ContextStore() store.Store CurrentContext() string DockerEndpoint() docker.Endpoint + TelemetryClient } // DockerCli is an instance the docker command line client. @@ -85,6 +86,7 @@ type DockerCli struct { dockerEndpoint docker.Endpoint contextStoreConfig store.Config initTimeout time.Duration + res telemetryResource // baseCtx is the base context used for internal operations. In the future // this may be replaced by explicitly passing a context to functions that @@ -187,6 +189,36 @@ func (cli *DockerCli) BuildKitEnabled() (bool, error) { return cli.ServerInfo().OSType != "windows", nil } +// HooksEnabled returns whether plugin hooks are enabled. +func (cli *DockerCli) HooksEnabled() bool { + // legacy support DOCKER_CLI_HINTS env var + if v := os.Getenv("DOCKER_CLI_HINTS"); v != "" { + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled + } + // use DOCKER_CLI_HOOKS env var value if set and not empty + if v := os.Getenv("DOCKER_CLI_HOOKS"); v != "" { + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled + } + featuresMap := cli.ConfigFile().Features + if v, ok := featuresMap["hooks"]; ok { + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled + } + // default to false + return false +} + // ManifestStore returns a store for local manifests func (cli *DockerCli) ManifestStore() manifeststore.Store { // TODO: support override default location from config file diff --git a/vendor/github.com/docker/cli/cli/command/telemetry.go b/vendor/github.com/docker/cli/cli/command/telemetry.go new file mode 100644 index 00000000000..e10571e3314 --- /dev/null +++ b/vendor/github.com/docker/cli/cli/command/telemetry.go @@ -0,0 +1,202 @@ +package command + +import ( + "context" + "os" + "path/filepath" + "sync" + "time" + + "github.com/docker/distribution/uuid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" +) + +const exportTimeout = 50 * time.Millisecond + +// TracerProvider is an extension of the trace.TracerProvider interface for CLI programs. +type TracerProvider interface { + trace.TracerProvider + ForceFlush(ctx context.Context) error + Shutdown(ctx context.Context) error +} + +// MeterProvider is an extension of the metric.MeterProvider interface for CLI programs. +type MeterProvider interface { + metric.MeterProvider + ForceFlush(ctx context.Context) error + Shutdown(ctx context.Context) error +} + +// TelemetryClient provides the methods for using OTEL tracing or metrics. +type TelemetryClient interface { + // Resource returns the OTEL Resource configured with this TelemetryClient. + // This resource may be created lazily, but the resource should be the same + // each time this function is invoked. + Resource() *resource.Resource + + // TracerProvider returns a TracerProvider. This TracerProvider will be configured + // with the default tracing components for a CLI program along with any options given + // for the SDK. + TracerProvider(ctx context.Context, opts ...sdktrace.TracerProviderOption) TracerProvider + + // MeterProvider returns a MeterProvider. This MeterProvider will be configured + // with the default metric components for a CLI program along with any options given + // for the SDK. + MeterProvider(ctx context.Context, opts ...sdkmetric.Option) MeterProvider +} + +func (cli *DockerCli) Resource() *resource.Resource { + return cli.res.Get() +} + +func (cli *DockerCli) TracerProvider(ctx context.Context, opts ...sdktrace.TracerProviderOption) TracerProvider { + allOpts := make([]sdktrace.TracerProviderOption, 0, len(opts)+2) + allOpts = append(allOpts, sdktrace.WithResource(cli.Resource())) + allOpts = append(allOpts, dockerSpanExporter(ctx, cli)...) + allOpts = append(allOpts, opts...) + return sdktrace.NewTracerProvider(allOpts...) +} + +func (cli *DockerCli) MeterProvider(ctx context.Context, opts ...sdkmetric.Option) MeterProvider { + allOpts := make([]sdkmetric.Option, 0, len(opts)+2) + allOpts = append(allOpts, sdkmetric.WithResource(cli.Resource())) + allOpts = append(allOpts, dockerMetricExporter(ctx, cli)...) + allOpts = append(allOpts, opts...) + return sdkmetric.NewMeterProvider(allOpts...) +} + +// WithResourceOptions configures additional options for the default resource. The default +// resource will continue to include its default options. +func WithResourceOptions(opts ...resource.Option) CLIOption { + return func(cli *DockerCli) error { + cli.res.AppendOptions(opts...) + return nil + } +} + +// WithResource overwrites the default resource and prevents its creation. +func WithResource(res *resource.Resource) CLIOption { + return func(cli *DockerCli) error { + cli.res.Set(res) + return nil + } +} + +type telemetryResource struct { + res *resource.Resource + opts []resource.Option + once sync.Once +} + +func (r *telemetryResource) Set(res *resource.Resource) { + r.res = res +} + +func (r *telemetryResource) Get() *resource.Resource { + r.once.Do(r.init) + return r.res +} + +func (r *telemetryResource) init() { + if r.res != nil { + r.opts = nil + return + } + + opts := append(r.defaultOptions(), r.opts...) + res, err := resource.New(context.Background(), opts...) + if err != nil { + otel.Handle(err) + } + r.res = res + + // Clear the resource options since they'll never be used again and to allow + // the garbage collector to retrieve that memory. + r.opts = nil +} + +func (r *telemetryResource) defaultOptions() []resource.Option { + return []resource.Option{ + resource.WithDetectors(serviceNameDetector{}), + resource.WithAttributes( + // Use a unique instance id so OTEL knows that each invocation + // of the CLI is its own instance. Without this, downstream + // OTEL processors may think the same process is restarting + // continuously. + semconv.ServiceInstanceID(uuid.Generate().String()), + ), + resource.WithFromEnv(), + resource.WithTelemetrySDK(), + } +} + +func (r *telemetryResource) AppendOptions(opts ...resource.Option) { + if r.res != nil { + return + } + r.opts = append(r.opts, opts...) +} + +type serviceNameDetector struct{} + +func (serviceNameDetector) Detect(ctx context.Context) (*resource.Resource, error) { + return resource.StringDetector( + semconv.SchemaURL, + semconv.ServiceNameKey, + func() (string, error) { + return filepath.Base(os.Args[0]), nil + }, + ).Detect(ctx) +} + +// cliReader is an implementation of Reader that will automatically +// report to a designated Exporter when Shutdown is called. +type cliReader struct { + sdkmetric.Reader + exporter sdkmetric.Exporter +} + +func newCLIReader(exp sdkmetric.Exporter) sdkmetric.Reader { + reader := sdkmetric.NewManualReader( + sdkmetric.WithTemporalitySelector(deltaTemporality), + ) + return &cliReader{ + Reader: reader, + exporter: exp, + } +} + +func (r *cliReader) Shutdown(ctx context.Context) error { + var rm metricdata.ResourceMetrics + if err := r.Reader.Collect(ctx, &rm); err != nil { + return err + } + + // Place a pretty tight constraint on the actual reporting. + // We don't want CLI metrics to prevent the CLI from exiting + // so if there's some kind of issue we need to abort pretty + // quickly. + ctx, cancel := context.WithTimeout(ctx, exportTimeout) + defer cancel() + + return r.exporter.Export(ctx, &rm) +} + +// deltaTemporality sets the Temporality of every instrument to delta. +// +// This isn't really needed since we create a unique resource on each invocation, +// but it can help with cardinality concerns for downstream processors since they can +// perform aggregation for a time interval and then discard the data once that time +// period has passed. Cumulative temporality would imply to the downstream processor +// that they might receive a successive point and they may unnecessarily keep state +// they really shouldn't. +func deltaTemporality(_ sdkmetric.InstrumentKind) metricdata.Temporality { + return metricdata.DeltaTemporality +} diff --git a/vendor/github.com/docker/cli/cli/command/telemetry_docker.go b/vendor/github.com/docker/cli/cli/command/telemetry_docker.go new file mode 100644 index 00000000000..9f6253af481 --- /dev/null +++ b/vendor/github.com/docker/cli/cli/command/telemetry_docker.go @@ -0,0 +1,142 @@ +// FIXME(jsternberg): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.19 + +package command + +import ( + "context" + "fmt" + "net/url" + "os" + "path" + + "github.com/pkg/errors" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +const ( + otelContextFieldName string = "otel" + otelExporterOTLPEndpoint string = "OTEL_EXPORTER_OTLP_ENDPOINT" + debugEnvVarPrefix string = "DOCKER_CLI_" +) + +// dockerExporterOTLPEndpoint retrieves the OTLP endpoint used for the docker reporter +// from the current context. +func dockerExporterOTLPEndpoint(cli Cli) (endpoint string, secure bool) { + meta, err := cli.ContextStore().GetMetadata(cli.CurrentContext()) + if err != nil { + otel.Handle(err) + return "", false + } + + var otelCfg any + switch m := meta.Metadata.(type) { + case DockerContext: + otelCfg = m.AdditionalFields[otelContextFieldName] + case map[string]any: + otelCfg = m[otelContextFieldName] + } + + if otelCfg == nil { + return "", false + } + + otelMap, ok := otelCfg.(map[string]any) + if !ok { + otel.Handle(errors.Errorf( + "unexpected type for field %q: %T (expected: %T)", + otelContextFieldName, + otelCfg, + otelMap, + )) + return "", false + } + + // keys from https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/ + endpoint, _ = otelMap[otelExporterOTLPEndpoint].(string) + + // Override with env var value if it exists AND IS SET + // (ignore otel defaults for this override when the key exists but is empty) + if override := os.Getenv(debugEnvVarPrefix + otelExporterOTLPEndpoint); override != "" { + endpoint = override + } + + if endpoint == "" { + return "", false + } + + // Parse the endpoint. The docker config expects the endpoint to be + // in the form of a URL to match the environment variable, but this + // option doesn't correspond directly to WithEndpoint. + // + // We pretend we're the same as the environment reader. + u, err := url.Parse(endpoint) + if err != nil { + otel.Handle(errors.Errorf("docker otel endpoint is invalid: %s", err)) + return "", false + } + + switch u.Scheme { + case "unix": + // Unix sockets are a bit weird. OTEL seems to imply they + // can be used as an environment variable and are handled properly, + // but they don't seem to be as the behavior of the environment variable + // is to strip the scheme from the endpoint, but the underlying implementation + // needs the scheme to use the correct resolver. + // + // We'll just handle this in a special way and add the unix:// back to the endpoint. + endpoint = fmt.Sprintf("unix://%s", path.Join(u.Host, u.Path)) + case "https": + secure = true + fallthrough + case "http": + endpoint = path.Join(u.Host, u.Path) + } + return endpoint, secure +} + +func dockerSpanExporter(ctx context.Context, cli Cli) []sdktrace.TracerProviderOption { + endpoint, secure := dockerExporterOTLPEndpoint(cli) + if endpoint == "" { + return nil + } + + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(endpoint), + } + if !secure { + opts = append(opts, otlptracegrpc.WithInsecure()) + } + + exp, err := otlptracegrpc.New(ctx, opts...) + if err != nil { + otel.Handle(err) + return nil + } + return []sdktrace.TracerProviderOption{sdktrace.WithBatcher(exp, sdktrace.WithExportTimeout(exportTimeout))} +} + +func dockerMetricExporter(ctx context.Context, cli Cli) []sdkmetric.Option { + endpoint, secure := dockerExporterOTLPEndpoint(cli) + if endpoint == "" { + return nil + } + + opts := []otlpmetricgrpc.Option{ + otlpmetricgrpc.WithEndpoint(endpoint), + } + if !secure { + opts = append(opts, otlpmetricgrpc.WithInsecure()) + } + + exp, err := otlpmetricgrpc.New(ctx, opts...) + if err != nil { + otel.Handle(err) + return nil + } + return []sdkmetric.Option{sdkmetric.WithReader(newCLIReader(exp))} +} diff --git a/vendor/github.com/docker/cli/cli/command/telemetry_utils.go b/vendor/github.com/docker/cli/cli/command/telemetry_utils.go new file mode 100644 index 00000000000..034fa1f00ae --- /dev/null +++ b/vendor/github.com/docker/cli/cli/command/telemetry_utils.go @@ -0,0 +1,158 @@ +package command + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/docker/cli/cli/version" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// BaseMetricAttributes returns an attribute.Set containing attributes to attach to metrics/traces +func BaseMetricAttributes(cmd *cobra.Command) attribute.Set { + attrList := []attribute.KeyValue{ + attribute.String("command.name", getCommandName(cmd)), + } + return attribute.NewSet(attrList...) +} + +// InstrumentCobraCommands wraps all cobra commands' RunE funcs to set a command duration metric using otel. +// +// Note: this should be the last func to wrap/modify the PersistentRunE/RunE funcs before command execution. +// +// can also be used for spans! +func InstrumentCobraCommands(cmd *cobra.Command, mp metric.MeterProvider) { + meter := getDefaultMeter(mp) + // If PersistentPreRunE is nil, make it execute PersistentPreRun and return nil by default + ogPersistentPreRunE := cmd.PersistentPreRunE + if ogPersistentPreRunE == nil { + ogPersistentPreRun := cmd.PersistentPreRun + //nolint:unparam // necessary because error will always be nil here + ogPersistentPreRunE = func(cmd *cobra.Command, args []string) error { + ogPersistentPreRun(cmd, args) + return nil + } + cmd.PersistentPreRun = nil + } + + // wrap RunE in PersistentPreRunE so that this operation gets executed on all children commands + cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // If RunE is nil, make it execute Run and return nil by default + ogRunE := cmd.RunE + if ogRunE == nil { + ogRun := cmd.Run + //nolint:unparam // necessary because error will always be nil here + ogRunE = func(cmd *cobra.Command, args []string) error { + ogRun(cmd, args) + return nil + } + cmd.Run = nil + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + // start the timer as the first step of every cobra command + stopCobraCmdTimer := startCobraCommandTimer(cmd, meter) + cmdErr := ogRunE(cmd, args) + stopCobraCmdTimer(cmdErr) + return cmdErr + } + + return ogPersistentPreRunE(cmd, args) + } +} + +func startCobraCommandTimer(cmd *cobra.Command, meter metric.Meter) func(err error) { + ctx := cmd.Context() + baseAttrs := BaseMetricAttributes(cmd) + durationCounter, _ := meter.Float64Counter( + "command.time", + metric.WithDescription("Measures the duration of the cobra command"), + metric.WithUnit("ms"), + ) + start := time.Now() + + return func(err error) { + duration := float64(time.Since(start)) / float64(time.Millisecond) + cmdStatusAttrs := attributesFromError(err) + durationCounter.Add(ctx, duration, + metric.WithAttributeSet(baseAttrs), + metric.WithAttributeSet(attribute.NewSet(cmdStatusAttrs...)), + ) + } +} + +func attributesFromError(err error) []attribute.KeyValue { + attrs := []attribute.KeyValue{} + exitCode := 0 + if err != nil { + exitCode = 1 + if stderr, ok := err.(statusError); ok { + // StatusError should only be used for errors, and all errors should + // have a non-zero exit status, so only set this here if this value isn't 0 + if stderr.StatusCode != 0 { + exitCode = stderr.StatusCode + } + } + attrs = append(attrs, attribute.String("command.error.type", otelErrorType(err))) + } + attrs = append(attrs, attribute.String("command.status.code", strconv.Itoa(exitCode))) + + return attrs +} + +// otelErrorType returns an attribute for the error type based on the error category. +func otelErrorType(err error) string { + name := "generic" + if errors.Is(err, context.Canceled) { + name = "canceled" + } + return name +} + +// statusError reports an unsuccessful exit by a command. +type statusError struct { + Status string + StatusCode int +} + +func (e statusError) Error() string { + return fmt.Sprintf("Status: %s, Code: %d", e.Status, e.StatusCode) +} + +// getCommandName gets the cobra command name in the format +// `... parentCommandName commandName` by traversing it's parent commands recursively. +// until the root command is reached. +// +// Note: The root command's name is excluded. If cmd is the root cmd, return "" +func getCommandName(cmd *cobra.Command) string { + fullCmdName := getFullCommandName(cmd) + i := strings.Index(fullCmdName, " ") + if i == -1 { + return "" + } + return fullCmdName[i+1:] +} + +// getFullCommandName gets the full cobra command name in the format +// `... parentCommandName commandName` by traversing it's parent commands recursively +// until the root command is reached. +func getFullCommandName(cmd *cobra.Command) string { + if cmd.HasParent() { + return fmt.Sprintf("%s %s", getFullCommandName(cmd.Parent()), cmd.Name()) + } + return cmd.Name() +} + +// getDefaultMeter gets the default metric.Meter for the application +// using the given metric.MeterProvider +func getDefaultMeter(mp metric.MeterProvider) metric.Meter { + return mp.Meter( + "github.com/docker/cli", + metric.WithInstrumentationVersion(version.Version), + ) +} diff --git a/vendor/github.com/docker/cli/cli/config/configfile/file.go b/vendor/github.com/docker/cli/cli/config/configfile/file.go index 442c31110b1..ba9bc9d1d0d 100644 --- a/vendor/github.com/docker/cli/cli/config/configfile/file.go +++ b/vendor/github.com/docker/cli/cli/config/configfile/file.go @@ -41,6 +41,7 @@ type ConfigFile struct { CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"` Plugins map[string]map[string]string `json:"plugins,omitempty"` Aliases map[string]string `json:"aliases,omitempty"` + Features map[string]string `json:"features,omitempty"` } // ProxyConfig contains proxy configuration settings diff --git a/vendor/modules.txt b/vendor/modules.txt index 45b5d0ad746..2171562ffc1 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -215,9 +215,10 @@ github.com/davecgh/go-spew/spew # github.com/distribution/reference v0.5.0 ## explicit; go 1.20 github.com/distribution/reference -# github.com/docker/cli v26.0.0+incompatible +# github.com/docker/cli v26.0.1-0.20240401150816-155dc5e4e406+incompatible ## explicit github.com/docker/cli/cli +github.com/docker/cli/cli-plugins/hooks github.com/docker/cli/cli-plugins/manager github.com/docker/cli/cli-plugins/plugin github.com/docker/cli/cli-plugins/socket