Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 109 additions & 3 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@ import (

"github.com/mattn/go-isatty"
"github.com/mitchellh/go-wordwrap"
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel/propagation"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/cli/sessionstore"
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/pretty"
Expand Down Expand Up @@ -396,12 +400,14 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
}
})

// Add the PrintDeprecatedOptions middleware to all commands.
// Add the PrintDeprecatedOptions and client session ID middleware to all
// commands. clientSessionIDMiddleware runs first so the resolved ID is on
// the invocation context for every downstream middleware and handler.
cmd.Walk(func(cmd *serpent.Command) {
if cmd.Middleware == nil {
cmd.Middleware = PrintDeprecatedOptions()
cmd.Middleware = serpent.Chain(clientSessionIDMiddleware(), PrintDeprecatedOptions())
} else {
cmd.Middleware = serpent.Chain(cmd.Middleware, PrintDeprecatedOptions())
cmd.Middleware = serpent.Chain(clientSessionIDMiddleware(), cmd.Middleware, PrintDeprecatedOptions())
}
})

Expand Down Expand Up @@ -853,6 +859,9 @@ func (r *RootCmd) createHTTPClient(ctx context.Context, serverURL *url.URL, inv

transport = wrapTransportWithTelemetryHeader(transport, inv)
transport = wrapTransportWithUserAgentHeader(transport, inv)
if sessionID := clientSessionIDFromContext(inv.Context()); sessionID != "" {
transport = wrapTransportWithSessionIDHeader(transport, sessionID)
}
if !r.noVersionCheck {
buildInfoTransport, err := newHTTPTransport(r.tlsConfig)
if err != nil {
Expand Down Expand Up @@ -1734,6 +1743,103 @@ func wrapTransportWithUserAgentHeader(transport http.RoundTripper, inv *serpent.
})
}

// clientSessionIDEnv is the environment variable a spawning client (Toolbox,
// the VS Code plugin) can set so the CLI it launches reuses an existing client
// session ID instead of generating a new one.
const clientSessionIDEnv = "CODER_TRACE_SESSION_ID"

// annotationClientSessionID marks commands that establish a client session and
// should resolve a client_session_id. clientSessionIDMiddleware only resolves
// and attaches the ID for commands that opt in with this annotation, so
// long-running daemon commands (server, agent, provisionerd, and so on) never
// carry a meaningless session ID in their logs, request baggage, or telemetry.
const annotationClientSessionID = "client_session_id"

type clientSessionIDContextKey struct{}

// withClientSessionID returns a copy of ctx carrying the client session ID so
// non-log consumers (HTTP baggage, tailnet telemetry) can read it back.
func withClientSessionID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, clientSessionIDContextKey{}, id)
}

// clientSessionIDFromContext returns the client session ID stored on ctx, or
// the empty string if none was resolved for this invocation.
func clientSessionIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(clientSessionIDContextKey{}).(string)
return id
}

// resolveClientSessionID returns the client session ID for this invocation.
// When CODER_TRACE_SESSION_ID is set it is used verbatim so a spawning client
// can correlate the CLI it launches. A warning is logged if the value is not
// the canonical 32-character lowercase hex form, since coderd and agent
// middleware drop non-canonical values. Otherwise a new session ID is
// generated.
func resolveClientSessionID(inv *serpent.Invocation) (string, error) {
if id, ok := inv.Environ.Lookup(clientSessionIDEnv); ok && id != "" {
if !tracing.ValidSessionID(id) {
cliui.Warnf(inv.Stderr,
"%s is not a 32-character lowercase hexadecimal string; it will not correlate in coderd and agent logs.",
clientSessionIDEnv)
}
return id, nil
}
id, err := tracing.NewSessionID()
if err != nil {
return "", xerrors.Errorf("generate client session ID: %w", err)
}
return id, nil
}

// clientSessionIDMiddleware resolves a single client session ID per invocation
// and stores it on the invocation context for commands that opt in with the
// annotationClientSessionID annotation. It attaches the ID as a slog field so
// any log written with the invocation context (or a descendant) carries
// client_session_id regardless of which logger emits it, and stores the raw ID
// so createHTTPClient can attach it as W3C baggage and ssh can forward it as
// tailnet telemetry. Commands that do not opt in (and completion mode) are
// skipped so daemon logs stay free of an irrelevant session ID.
func clientSessionIDMiddleware() serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
if inv.IsCompletionMode() || inv.Command == nil ||
!inv.Command.Annotations.IsSet(annotationClientSessionID) {
return next(inv)
}
id, err := resolveClientSessionID(inv)
if err != nil {
return err
}
ctx := slog.With(inv.Context(), slog.F("client_session_id", id))
ctx = withClientSessionID(ctx, id)
return next(inv.WithContext(ctx))
}
}
}

// wrapTransportWithSessionIDHeader attaches the client session ID to every
// request as W3C baggage under the client_session_id key, so coderd and agent
// middleware can correlate logs, spans, and telemetry by session. It is set
// regardless of whether tracing is enabled, and merges with any baggage
// already present on the request rather than overwriting it.
func wrapTransportWithSessionIDHeader(transport http.RoundTripper, sessionID string) http.RoundTripper {
member, err := baggage.NewMemberRaw(tracing.SessionIDBaggageKey, sessionID)
if err != nil {
// An invalid session ID should never reach here. If it somehow does,
// skip attaching baggage rather than failing every request.
return transport
}
return roundTripper(func(req *http.Request) (*http.Response, error) {
ctx := propagation.Baggage{}.Extract(req.Context(), propagation.HeaderCarrier(req.Header))
if bag, err := baggage.FromContext(ctx).SetMember(member); err == nil {
ctx = baggage.ContextWithBaggage(ctx, bag)
propagation.Baggage{}.Inject(ctx, propagation.HeaderCarrier(req.Header))
}
return transport.RoundTrip(req)
})
}

type roundTripper func(req *http.Request) (*http.Response, error)

func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
Expand Down
157 changes: 157 additions & 0 deletions cli/root_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/goleak"

"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/pretty"
Expand Down Expand Up @@ -495,3 +498,157 @@ func TestNewHTTPTransportAppliesTLSConfigToClone(t *testing.T) {
require.True(t, ok)
require.Same(t, tlsConfig, httpTransport.TLSClientConfig)
}

func TestResolveClientSessionID(t *testing.T) {
t.Parallel()

t.Run("GeneratesWhenUnset", func(t *testing.T) {
t.Parallel()

inv := &serpent.Invocation{Stderr: io.Discard}
id, err := resolveClientSessionID(inv)
require.NoError(t, err)
require.True(t, tracing.ValidSessionID(id), "generated session ID must be valid")
})

t.Run("UsesValidEnv", func(t *testing.T) {
t.Parallel()

const want = "0123456789abcdef0123456789abcdef"
inv := &serpent.Invocation{Stderr: io.Discard}
inv.Environ.Set(clientSessionIDEnv, want)
id, err := resolveClientSessionID(inv)
require.NoError(t, err)
require.Equal(t, want, id)
})

t.Run("UsesMalformedEnvVerbatim", func(t *testing.T) {
t.Parallel()

const want = "not-a-valid-session-id"
inv := &serpent.Invocation{Stderr: io.Discard}
inv.Environ.Set(clientSessionIDEnv, want)
id, err := resolveClientSessionID(inv)
require.NoError(t, err)
require.Equal(t, want, id, "a set CODER_TRACE_SESSION_ID is used verbatim")
})

t.Run("GeneratesWhenEnvEmpty", func(t *testing.T) {
t.Parallel()

inv := &serpent.Invocation{Stderr: io.Discard}
inv.Environ.Set(clientSessionIDEnv, "")
id, err := resolveClientSessionID(inv)
require.NoError(t, err)
require.True(t, tracing.ValidSessionID(id), "empty env must fall back to a generated ID")
})
}

func TestClientSessionIDMiddleware(t *testing.T) {
t.Parallel()

// runMiddleware runs clientSessionIDMiddleware around a handler that
// captures the resulting invocation, returning it for assertions. The
// invocation opts in with the annotationClientSessionID annotation unless
// shouldAttachSessionID is false.
runMiddleware := func(t *testing.T, inv *serpent.Invocation, shouldAttachSessionID bool) *serpent.Invocation {
t.Helper()
annotations := serpent.Annotations{}
if shouldAttachSessionID {
annotations = annotations.Mark(annotationClientSessionID, "")
}
inv.Command = &serpent.Command{Annotations: annotations}
var got *serpent.Invocation
handler := clientSessionIDMiddleware()(func(i *serpent.Invocation) error {
got = i
return nil
})
require.NoError(t, handler(inv))
require.NotNil(t, got)
return got
}

t.Run("GeneratesAndStoresOnContext", func(t *testing.T) {
t.Parallel()

inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
got := runMiddleware(t, inv, true)
id := clientSessionIDFromContext(got.Context())
require.True(t, tracing.ValidSessionID(id), "middleware must store a valid generated ID")
})

t.Run("UsesEnv", func(t *testing.T) {
t.Parallel()

const want = "0123456789abcdef0123456789abcdef"
inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
inv.Environ.Set(clientSessionIDEnv, want)
got := runMiddleware(t, inv, true)
require.Equal(t, want, clientSessionIDFromContext(got.Context()))
})

t.Run("AttachesSlogField", func(t *testing.T) {
t.Parallel()

inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
got := runMiddleware(t, inv, true)
id := clientSessionIDFromContext(got.Context())
require.NotEmpty(t, id)

// A fresh logger that logs with the invocation context must include the
// client_session_id field, proving the field rides on the context rather
// than a specific logger instance.
var buf bytes.Buffer
logger := slog.Make(sloghuman.Sink(&buf))
logger.Info(got.Context(), "session id log line")
require.Contains(t, buf.String(), "client_session_id="+id)
})

t.Run("SkipsWithoutOptIn", func(t *testing.T) {
t.Parallel()

inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context())
inv.Environ.Set(clientSessionIDEnv, "0123456789abcdef0123456789abcdef")
got := runMiddleware(t, inv, false)
require.Empty(t, clientSessionIDFromContext(got.Context()),
"commands without the opt-in annotation must not resolve a session ID")
})

t.Run("SkipsCompletionMode", func(t *testing.T) {
t.Parallel()

inv := (&serpent.Invocation{Stderr: io.Discard}).
WithContext(t.Context())
inv.Environ.Set(serpent.CompletionModeEnv, "1")
got := runMiddleware(t, inv, true)
require.Empty(t, clientSessionIDFromContext(got.Context()),
"completion mode must not resolve a session ID")
})
}

func TestWrapTransportWithSessionIDHeader(t *testing.T) {
t.Parallel()

const sessionID = "0123456789abcdef0123456789abcdef"

var gotHeader http.Header
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Clone()
rw.WriteHeader(http.StatusOK)
}))
defer srv.Close()

client := &http.Client{
Transport: wrapTransportWithSessionIDHeader(http.DefaultTransport, sessionID),
}

req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

// The baggage header the server receives must carry the session ID under
// the client_session_id key so the tracing middleware can extract it.
require.Equal(t, tracing.SessionIDBaggageKey+"="+sessionID, gotHeader.Get("baggage"))
}
6 changes: 5 additions & 1 deletion cli/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func (r *RootCmd) ssh() *serpent.Command {
containerUser string
)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Annotations: serpent.Annotations(workspaceCommand).Mark(annotationClientSessionID, ""),
Use: "ssh <workspace> [command]",
Short: "Start a shell into a workspace or run a command",
Long: "This command does not have full parity with the standard SSH command. For users who need the full functionality of SSH, create an ssh configuration with `coder config-ssh`.\n\n" +
Expand Down Expand Up @@ -211,6 +211,9 @@ func (r *RootCmd) ssh() *serpent.Command {
return completions
},
Handler: func(inv *serpent.Invocation) (retErr error) {
// Get the session ID to additionally propagate it to tailnet telemetry.
sessionID := clientSessionIDFromContext(inv.Context())

client, err := r.InitClient(inv)
if err != nil {
return err
Expand Down Expand Up @@ -474,6 +477,7 @@ func (r *RootCmd) ssh() *serpent.Command {
Logger: logger,
BlockEndpoints: r.disableDirect,
EnableTelemetry: !r.disableNetworkTelemetry,
ClientSessionID: sessionID,
})
return err
}); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cli/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (r *RootCmd) start() *serpent.Command {
)

cmd := &serpent.Command{
Annotations: workspaceCommand,
Annotations: serpent.Annotations(workspaceCommand).Mark(annotationClientSessionID, ""),
Use: "start <workspace>",
Short: "Start a workspace",
Middleware: serpent.Chain(
Expand Down
2 changes: 1 addition & 1 deletion cli/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func (r *RootCmd) stop() *serpent.Command {
var bflags buildFlags
cmd := &serpent.Command{
Annotations: workspaceCommand,
Annotations: serpent.Annotations(workspaceCommand).Mark(annotationClientSessionID, ""),
Use: "stop <workspace>",
Short: "Stop a workspace",
Middleware: serpent.Chain(
Expand Down
2 changes: 1 addition & 1 deletion cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func (r *RootCmd) update() *serpent.Command {
bflags buildFlags
)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Annotations: serpent.Annotations(workspaceCommand).Mark(annotationClientSessionID, ""),
Use: "update <workspace>",
Short: "Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.",
Long: "Use --always-prompt to change the parameter values of the workspace.",
Expand Down
Loading
Loading