mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
e5707a13d6
> This PR was authored by Mux on behalf of Mike. ## Summary Adds support for multiple peer root workspace agents sharing the same `auth_instance_id`, so AWS, Azure, and GCP instance-identity auth can issue the correct session token for a selected agent instead of assuming a single root agent per instance. ## Problem When a Terraform template attaches two or more `coder_agent` resources (with `auth = "aws-instance-identity"`) to a single compute instance, every agent shares the same cloud instance ID. The existing singular lookup picks whichever agent was created most recently, silently ignoring the others. ## Solution Introduce an optional pre-auth agent selector (`CODER_AGENT_NAME`) and make the server-side lookup ambiguity-aware. **Database layer:** - `GetWorkspaceAgentsByInstanceID` (`:many`): returns all matching root agents for an instance ID. - `GetWorkspaceAgentByInstanceIDAndName` (`:one`): returns the named root agent for disambiguation. **SDK and CLI:** - `agent_name` field added to AWS, Azure, and GCP request structs (`omitempty` for backward compatibility). - `CODER_AGENT_NAME` env var and `--agent-name` flag wired into the agent bootstrap before instance-identity auth runs. **Server handler (`handleAuthInstanceID`):** - When `agent_name` is present: direct lookup by (instance ID, name). - When absent: legacy lookup, then resource-scoped ambiguity check. Returns 409 with available agent names if multiple root agents match. - Whitespace-only names are trimmed and treated as unspecified. - Sub-agents remain excluded (`parent_id IS NULL` filter). **Verification template:** - `examples/templates/aws-multi-agent/` provisions one EC2 instance with two agents (`main` and `dev`), both using instance-identity auth with `CODER_AGENT_NAME` set in the cloud-init user data. ## Backward compatibility Existing single-agent deployments work unchanged. The `agent_name` field is optional with `omitempty`, and the unnamed path preserves today's behavior when only one root agent matches.
80 lines
2.7 KiB
Go
80 lines
2.7 KiB
Go
package agentsdk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"cloud.google.com/go/compute/metadata"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
type GoogleInstanceIdentityToken struct {
|
|
JSONWebToken string `json:"json_web_token" validate:"required"`
|
|
// AgentName optionally selects a specific agent when multiple
|
|
// agents share the same instance identity. An empty string is
|
|
// treated as unspecified.
|
|
AgentName string `json:"agent_name,omitempty"`
|
|
}
|
|
|
|
// GoogleSessionTokenExchanger exchanges a Google instance JWT document for a Coder session token.
|
|
// @typescript-ignore GoogleSessionTokenExchanger
|
|
type GoogleSessionTokenExchanger struct {
|
|
serviceAccount string
|
|
gcpClient *metadata.Client
|
|
client *codersdk.Client
|
|
agentName string
|
|
}
|
|
|
|
func WithGoogleInstanceIdentity(serviceAccount string, gcpClient *metadata.Client, opts ...InstanceIdentityOption) SessionTokenSetup {
|
|
cfg := applyInstanceIdentityOptions(opts)
|
|
return func(client *codersdk.Client) RefreshableSessionTokenProvider {
|
|
return &InstanceIdentitySessionTokenProvider{
|
|
TokenExchanger: &GoogleSessionTokenExchanger{
|
|
client: client,
|
|
gcpClient: gcpClient,
|
|
serviceAccount: serviceAccount,
|
|
agentName: cfg.AgentName,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// exchange uses the Google Compute Engine Metadata API to fetch a signed JWT, and exchange it for a session token for a
|
|
// workspace agent.
|
|
//
|
|
// The requesting instance must be registered as a resource in the latest history for a workspace.
|
|
func (g *GoogleSessionTokenExchanger) exchange(ctx context.Context) (AuthenticateResponse, error) {
|
|
if g.serviceAccount == "" {
|
|
// This is the default name specified by Google.
|
|
g.serviceAccount = "default"
|
|
}
|
|
gcpClient := metadata.NewClient(g.client.HTTPClient)
|
|
if g.gcpClient != nil {
|
|
gcpClient = g.gcpClient
|
|
}
|
|
|
|
// "format=full" is required, otherwise the responding payload will be missing "instance_id".
|
|
jwt, err := gcpClient.Get(fmt.Sprintf("instance/service-accounts/%s/identity?audience=coder&format=full", g.serviceAccount))
|
|
if err != nil {
|
|
return AuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err)
|
|
}
|
|
// request without the token to avoid re-entering this function
|
|
res, err := g.client.RequestWithoutSessionToken(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{
|
|
JSONWebToken: jwt,
|
|
AgentName: g.agentName,
|
|
})
|
|
if err != nil {
|
|
return AuthenticateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return AuthenticateResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp AuthenticateResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|