mirror of
https://github.com/docker/cli.git
synced 2026-11-06 13:10:41 -04:00
Add the Docker secrets engine client, used to read the Docker Desktop session for Docker Hub. It brings in github.com/docker/secrets-engine/x v0.8.5 and connectrpc.com/connect v1.19.1. The client module requires go1.26.10, which raises the go directive in vendor.mod from 1.26.0. Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
393 lines
13 KiB
Go
393 lines
13 KiB
Go
// Copyright 2021-2025 The Connect Authors
|
|
//
|
|
// 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.
|
|
|
|
package connect
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// Client is a reusable, concurrency-safe client for a single procedure.
|
|
// Depending on the procedure's type, use the CallUnary, CallClientStream,
|
|
// CallServerStream, or CallBidiStream method.
|
|
//
|
|
// By default, clients use the Connect protocol with the binary Protobuf Codec,
|
|
// ask for gzipped responses, and send uncompressed requests. To use the gRPC
|
|
// or gRPC-Web protocols, use the [WithGRPC] or [WithGRPCWeb] options.
|
|
type Client[Req, Res any] struct {
|
|
config *clientConfig
|
|
callUnary func(context.Context, *Request[Req]) (*Response[Res], error)
|
|
protocolClient protocolClient
|
|
err error
|
|
}
|
|
|
|
// NewClient constructs a new Client.
|
|
func NewClient[Req, Res any](httpClient HTTPClient, url string, options ...ClientOption) *Client[Req, Res] {
|
|
client := &Client[Req, Res]{}
|
|
config, err := newClientConfig(url, options)
|
|
if err != nil {
|
|
client.err = err
|
|
return client
|
|
}
|
|
client.config = config
|
|
protocolClient, protocolErr := client.config.Protocol.NewClient(
|
|
&protocolClientParams{
|
|
CompressionName: config.RequestCompressionName,
|
|
CompressionPools: newReadOnlyCompressionPools(
|
|
config.CompressionPools,
|
|
config.CompressionNames,
|
|
),
|
|
Codec: config.Codec,
|
|
Protobuf: config.protobuf(),
|
|
CompressMinBytes: config.CompressMinBytes,
|
|
HTTPClient: httpClient,
|
|
URL: config.URL,
|
|
BufferPool: config.BufferPool,
|
|
ReadMaxBytes: config.ReadMaxBytes,
|
|
SendMaxBytes: config.SendMaxBytes,
|
|
EnableGet: config.EnableGet,
|
|
GetURLMaxBytes: config.GetURLMaxBytes,
|
|
GetUseFallback: config.GetUseFallback,
|
|
},
|
|
)
|
|
if protocolErr != nil {
|
|
client.err = protocolErr
|
|
return client
|
|
}
|
|
client.protocolClient = protocolClient
|
|
// Rather than applying unary interceptors along the hot path, we can do it
|
|
// once at client creation.
|
|
unarySpec := config.newSpec(StreamTypeUnary)
|
|
unaryFunc := UnaryFunc(func(ctx context.Context, request AnyRequest) (AnyResponse, error) {
|
|
conn := client.protocolClient.NewConn(ctx, unarySpec, request.Header())
|
|
conn.onRequestSend(func(r *http.Request) {
|
|
request.setRequestMethod(r.Method)
|
|
callInfo, ok := clientCallInfoForContext(ctx)
|
|
if ok {
|
|
callInfo.method = r.Method
|
|
callInfo.responseSource = conn
|
|
}
|
|
})
|
|
// Send always returns an io.EOF unless the error is from the client-side.
|
|
// We want the user to continue to call Receive in those cases to get the
|
|
// full error from the server-side.
|
|
if err := conn.Send(request.Any()); err != nil && !errors.Is(err, io.EOF) {
|
|
_ = conn.CloseRequest()
|
|
_ = conn.CloseResponse()
|
|
return nil, err
|
|
}
|
|
if err := conn.CloseRequest(); err != nil {
|
|
_ = conn.CloseResponse()
|
|
return nil, err
|
|
}
|
|
response, err := receiveUnaryResponse[Res](conn, config.Initializer)
|
|
if err != nil {
|
|
_ = conn.CloseResponse()
|
|
return nil, err
|
|
}
|
|
return response, conn.CloseResponse()
|
|
})
|
|
if interceptor := config.Interceptor; interceptor != nil {
|
|
// interceptor is the full chain of all interceptors provided
|
|
unaryFunc = interceptor.WrapUnary(unaryFunc)
|
|
}
|
|
client.callUnary = func(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
|
|
// To make the specification, peer, and RPC headers visible to the full
|
|
// interceptor chain (as though they were supplied by the caller), we'll
|
|
// add them here.
|
|
request.spec = unarySpec
|
|
request.peer = client.protocolClient.Peer()
|
|
protocolClient.WriteRequestHeader(StreamTypeUnary, request.Header())
|
|
|
|
// Also set them in the context if there's a call info present
|
|
callInfo, callInfoOk := clientCallInfoForContext(ctx)
|
|
if callInfoOk {
|
|
callInfo.peer = request.Peer()
|
|
callInfo.spec = request.Spec()
|
|
// A client could have set request headers in the call info OR the request wrapper
|
|
// So if a callInfo exists in context, merge any headers from there into the request wrapper
|
|
// so that all headers are sent in the request
|
|
mergeHeaders(request.Header(), callInfo.requestHeader)
|
|
|
|
// Copy the call info into a sentinel value. This is so we can compare
|
|
// the sentinel value against the call info in context. If they're different,
|
|
// we can stop the request. This protects against changing the context in interceptors.
|
|
ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo)
|
|
}
|
|
|
|
response, err := unaryFunc(ctx, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
typed, ok := response.(*Response[Res])
|
|
if !ok {
|
|
return nil, errorf(CodeInternal, "unexpected client response type %T", response)
|
|
}
|
|
return typed, nil
|
|
}
|
|
return client
|
|
}
|
|
|
|
// CallUnary calls a request-response procedure.
|
|
func (c *Client[Req, Res]) CallUnary(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
|
|
if c.err != nil {
|
|
return nil, c.err
|
|
}
|
|
return c.callUnary(ctx, request)
|
|
}
|
|
|
|
// CallClientStream calls a client streaming procedure.
|
|
//
|
|
// Request headers can be sent via the [ClientStreamForClient.RequestHeader] method on the stream. Note that the
|
|
// request headers are not sent automatically when this method is invoked and instead require an explicit call to
|
|
// [ClientStreamForClient.Send].
|
|
func (c *Client[Req, Res]) CallClientStream(ctx context.Context) *ClientStreamForClient[Req, Res] {
|
|
if c.err != nil {
|
|
return &ClientStreamForClient[Req, Res]{err: c.err}
|
|
}
|
|
return &ClientStreamForClient[Req, Res]{
|
|
conn: c.newConn(ctx, StreamTypeClient, nil),
|
|
initializer: c.config.Initializer,
|
|
}
|
|
}
|
|
|
|
// CallClientStreamSimple calls a client streaming procedure.
|
|
//
|
|
// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers are
|
|
// transmitted when this method is called and do not require an explicit call to [ClientStreamForClientSimple.Send].
|
|
//
|
|
// In addition, when calling [ClientStreamForClientSimple.CloseAndReceive] on the returned stream, the returned response
|
|
// is the response type defined for the stream and _not_ a Connect [Response] wrapper type. As a result, any response
|
|
// headers and trailers should be read from the [CallInfo] object in context.
|
|
func (c *Client[Req, Res]) CallClientStreamSimple(ctx context.Context) (*ClientStreamForClientSimple[Req, Res], error) {
|
|
if c.err != nil {
|
|
return &ClientStreamForClientSimple[Req, Res]{
|
|
stream: &ClientStreamForClient[Req, Res]{err: c.err},
|
|
}, c.err
|
|
}
|
|
|
|
stream := &ClientStreamForClientSimple[Req, Res]{
|
|
stream: &ClientStreamForClient[Req, Res]{
|
|
conn: c.newConn(ctx, StreamTypeClient, nil),
|
|
initializer: c.config.Initializer,
|
|
},
|
|
}
|
|
if err := stream.Send(nil); err != nil {
|
|
return nil, err
|
|
}
|
|
return stream, nil
|
|
}
|
|
|
|
// CallServerStream calls a server streaming procedure.
|
|
func (c *Client[Req, Res]) CallServerStream(ctx context.Context, request *Request[Req]) (*ServerStreamForClient[Res], error) {
|
|
if c.err != nil {
|
|
return nil, c.err
|
|
}
|
|
conn := c.newConn(ctx, StreamTypeServer, func(r *http.Request) {
|
|
request.method = r.Method
|
|
})
|
|
request.peer = conn.Peer()
|
|
request.spec = conn.Spec()
|
|
|
|
mergeHeaders(conn.RequestHeader(), request.header)
|
|
|
|
// Send always returns an io.EOF unless the error is from the client-side.
|
|
// We want the user to continue to call Receive in those cases to get the
|
|
// full error from the server-side.
|
|
if err := conn.Send(request.Msg); err != nil && !errors.Is(err, io.EOF) {
|
|
_ = conn.CloseRequest()
|
|
_ = conn.CloseResponse()
|
|
return nil, err
|
|
}
|
|
if err := conn.CloseRequest(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ServerStreamForClient[Res]{
|
|
conn: conn,
|
|
initializer: c.config.Initializer,
|
|
}, nil
|
|
}
|
|
|
|
// CallBidiStream calls a bidirectional streaming procedure.
|
|
//
|
|
// Request headers can be sent via the [BidiStreamForClient.RequestHeader] method. Note that the
|
|
// request headers are not sent automatically when this method is invoked and instead require an explicit call to
|
|
// [BidiStreamForClient.Send].
|
|
func (c *Client[Req, Res]) CallBidiStream(ctx context.Context) *BidiStreamForClient[Req, Res] {
|
|
if c.err != nil {
|
|
return &BidiStreamForClient[Req, Res]{err: c.err}
|
|
}
|
|
return &BidiStreamForClient[Req, Res]{
|
|
conn: c.newConn(ctx, StreamTypeBidi, nil),
|
|
initializer: c.config.Initializer,
|
|
}
|
|
}
|
|
|
|
// CallBidiStreamSimple calls a bidirectional streaming procedure.
|
|
//
|
|
// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers
|
|
// are transmitted when this method is called and do not require an explicit call to [BidiStreamForClient.Send].
|
|
//
|
|
// Likewise, response headers and trailers should be read from the [CallInfo] object in context.
|
|
func (c *Client[Req, Res]) CallBidiStreamSimple(ctx context.Context) (*BidiStreamForClientSimple[Req, Res], error) {
|
|
if c.err != nil {
|
|
return &BidiStreamForClientSimple[Req, Res]{
|
|
stream: &BidiStreamForClient[Req, Res]{err: c.err},
|
|
}, c.err
|
|
}
|
|
|
|
stream := &BidiStreamForClientSimple[Req, Res]{
|
|
stream: &BidiStreamForClient[Req, Res]{
|
|
conn: c.newConn(ctx, StreamTypeBidi, nil),
|
|
initializer: c.config.Initializer,
|
|
},
|
|
}
|
|
|
|
if err := stream.Send(nil); err != nil {
|
|
return nil, err
|
|
}
|
|
return stream, nil
|
|
}
|
|
|
|
func (c *Client[Req, Res]) newConn(ctx context.Context, streamType StreamType, onRequestSend func(r *http.Request)) StreamingClientConn {
|
|
callInfo, callInfoOk := clientCallInfoForContext(ctx)
|
|
// Set values in the context if there's a call info present
|
|
if callInfoOk {
|
|
// Copy the call info into a sentinel value. This is so we can compare
|
|
// the sentinel value against the call info in context. If they're different,
|
|
// we can stop the request. This protects against changing the context in interceptors.
|
|
ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo)
|
|
}
|
|
newConn := func(ctx context.Context, spec Spec) StreamingClientConn {
|
|
header := make(http.Header, 8) // arbitrary power of two, prevent immediate resizing
|
|
c.protocolClient.WriteRequestHeader(streamType, header)
|
|
conn := c.protocolClient.NewConn(ctx, spec, header)
|
|
conn.onRequestSend(onRequestSend)
|
|
return conn
|
|
}
|
|
if interceptor := c.config.Interceptor; interceptor != nil {
|
|
newConn = interceptor.WrapStreamingClient(newConn)
|
|
}
|
|
conn := newConn(ctx, c.config.newSpec(streamType))
|
|
|
|
// Set values in the context if there's a call info present
|
|
if callInfoOk {
|
|
callInfo.peer = conn.Peer()
|
|
callInfo.spec = conn.Spec()
|
|
callInfo.responseSource = conn
|
|
|
|
// Merge any callInfo request headers first, then do the request,
|
|
// so that context headers show first in the list of headers.
|
|
mergeHeaders(conn.RequestHeader(), callInfo.RequestHeader())
|
|
}
|
|
|
|
return conn
|
|
}
|
|
|
|
type clientConfig struct {
|
|
URL *url.URL
|
|
Protocol protocol
|
|
Procedure string
|
|
Schema any
|
|
Initializer maybeInitializer
|
|
CompressMinBytes int
|
|
Interceptor Interceptor
|
|
CompressionPools map[string]*compressionPool
|
|
CompressionNames []string
|
|
Codec Codec
|
|
RequestCompressionName string
|
|
BufferPool *bufferPool
|
|
ReadMaxBytes int
|
|
SendMaxBytes int
|
|
EnableGet bool
|
|
GetURLMaxBytes int
|
|
GetUseFallback bool
|
|
IdempotencyLevel IdempotencyLevel
|
|
}
|
|
|
|
func newClientConfig(rawURL string, options []ClientOption) (*clientConfig, *Error) {
|
|
url, err := parseRequestURL(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
protoPath := extractProtoPath(url.Path)
|
|
config := clientConfig{
|
|
URL: url,
|
|
Protocol: &protocolConnect{},
|
|
Procedure: protoPath,
|
|
CompressionPools: make(map[string]*compressionPool),
|
|
BufferPool: newBufferPool(),
|
|
}
|
|
withProtoBinaryCodec().applyToClient(&config)
|
|
withGzip().applyToClient(&config)
|
|
for _, opt := range options {
|
|
opt.applyToClient(&config)
|
|
}
|
|
if err := config.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &config, nil
|
|
}
|
|
|
|
func (c *clientConfig) validate() *Error {
|
|
if c.Codec == nil || c.Codec.Name() == "" {
|
|
return errorf(CodeUnknown, "no codec configured")
|
|
}
|
|
if c.RequestCompressionName != "" && c.RequestCompressionName != compressionIdentity {
|
|
if _, ok := c.CompressionPools[c.RequestCompressionName]; !ok {
|
|
return errorf(CodeUnknown, "unknown compression %q", c.RequestCompressionName)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *clientConfig) protobuf() Codec {
|
|
if c.Codec.Name() == codecNameProto {
|
|
return c.Codec
|
|
}
|
|
return &protoBinaryCodec{}
|
|
}
|
|
|
|
func (c *clientConfig) newSpec(t StreamType) Spec {
|
|
return Spec{
|
|
StreamType: t,
|
|
Procedure: c.Procedure,
|
|
Schema: c.Schema,
|
|
IsClient: true,
|
|
IdempotencyLevel: c.IdempotencyLevel,
|
|
}
|
|
}
|
|
|
|
func parseRequestURL(rawURL string) (*url.URL, *Error) {
|
|
url, err := url.ParseRequestURI(rawURL)
|
|
if err == nil {
|
|
return url, nil
|
|
}
|
|
if !strings.Contains(rawURL, "://") {
|
|
// URL doesn't have a scheme, so the user is likely accustomed to
|
|
// grpc-go's APIs.
|
|
err = fmt.Errorf(
|
|
"URL %q missing scheme: use http:// or https:// (unlike grpc-go)",
|
|
rawURL,
|
|
)
|
|
}
|
|
return nil, NewError(CodeUnavailable, err)
|
|
}
|