Files
coder/codersdk/agentsdk/agentsdk.go
Kyle Carberry 7ad87505c8 chore: move agent functions from codersdk into agentsdk (#5903)
* chore: rename `AgentConn` to `WorkspaceAgentConn`

The codersdk was becoming bloated with consts for the workspace
agent that made no sense to a reader. `Tailnet*` is an example
of these consts.

* chore: remove `Get` prefix from *Client functions

* chore: remove `BypassRatelimits` option in `codersdk.Client`

It feels wrong to have this as a direct option because it's so infrequently
needed by API callers. It's better to directly modify headers in the two
places that we actually use it.

* Merge `appearance.go` and `buildinfo.go` into `deployment.go`

* Merge `experiments.go` and `features.go` into `deployment.go`

* Fix `make gen` referencing old type names

* Merge `error.go` into `client.go`

`codersdk.Response` lived in `error.go`, which is wrong.

* chore: refactor workspace agent functions into agentsdk

It was odd conflating the codersdk that clients should use
with functions that only the agent should use. This separates
them into two SDKs that are closely coupled, but separate.

* Merge `insights.go` into `deployment.go`

* Merge `organizationmember.go` into `organizations.go`

* Merge `quota.go` into `workspaces.go`

* Rename `sse.go` to `serversentevents.go`

* Rename `codersdk.WorkspaceAppHostResponse` to `codersdk.AppHostResponse`

* Format `.vscode/settings.json`

* Fix outdated naming in `api.ts`

* Fix app host response

* Fix unsupported type

* Fix imported type
2023-01-29 15:47:24 -06:00

521 lines
16 KiB
Go

package agentsdk
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"time"
"cloud.google.com/go/compute/metadata"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"tailscale.com/tailcfg"
"github.com/coder/retry"
"cdr.dev/slog"
"github.com/google/uuid"
"github.com/coder/coder/codersdk"
)
// New returns a client that is used to interact with the
// Coder API from a workspace agent.
func New(serverURL *url.URL) *Client {
return &Client{
SDK: codersdk.New(serverURL),
}
}
// Client wraps `codersdk.Client` with specific functions
// scoped to a workspace agent.
type Client struct {
SDK *codersdk.Client
}
func (c *Client) SetSessionToken(token string) {
c.SDK.SetSessionToken(token)
}
type GitSSHKey struct {
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
}
// GitSSHKey will return the user's SSH key pair for the workspace.
func (c *Client) GitSSHKey(ctx context.Context) (GitSSHKey, error) {
res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil)
if err != nil {
return GitSSHKey{}, xerrors.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GitSSHKey{}, codersdk.ReadBodyAsError(res)
}
var gitSSHKey GitSSHKey
return gitSSHKey, json.NewDecoder(res.Body).Decode(&gitSSHKey)
}
type Metadata struct {
// GitAuthConfigs stores the number of Git configurations
// the Coder deployment has. If this number is >0, we
// set up special configuration in the workspace.
GitAuthConfigs int `json:"git_auth_configs"`
VSCodePortProxyURI string `json:"vscode_port_proxy_uri"`
Apps []codersdk.WorkspaceApp `json:"apps"`
DERPMap *tailcfg.DERPMap `json:"derpmap"`
EnvironmentVariables map[string]string `json:"environment_variables"`
StartupScript string `json:"startup_script"`
StartupScriptTimeout time.Duration `json:"startup_script_timeout"`
Directory string `json:"directory"`
MOTDFile string `json:"motd_file"`
}
// Metadata fetches metadata for the currently authenticated workspace agent.
func (c *Client) Metadata(ctx context.Context) (Metadata, error) {
res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil)
if err != nil {
return Metadata{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Metadata{}, codersdk.ReadBodyAsError(res)
}
var agentMeta Metadata
err = json.NewDecoder(res.Body).Decode(&agentMeta)
if err != nil {
return Metadata{}, err
}
accessingPort := c.SDK.URL.Port()
if accessingPort == "" {
accessingPort = "80"
if c.SDK.URL.Scheme == "https" {
accessingPort = "443"
}
}
accessPort, err := strconv.Atoi(accessingPort)
if err != nil {
return Metadata{}, xerrors.Errorf("convert accessing port %q: %w", accessingPort, err)
}
// Agents can provide an arbitrary access URL that may be different
// that the globally configured one. This breaks the built-in DERP,
// which would continue to reference the global access URL.
//
// This converts all built-in DERPs to use the access URL that the
// metadata request was performed with.
for _, region := range agentMeta.DERPMap.Regions {
if !region.EmbeddedRelay {
continue
}
for _, node := range region.Nodes {
if node.STUNOnly {
continue
}
node.HostName = c.SDK.URL.Hostname()
node.DERPPort = accessPort
node.ForceHTTP = c.SDK.URL.Scheme == "http"
}
}
return agentMeta, nil
}
// Listen connects to the workspace agent coordinate WebSocket
// that handles connection negotiation.
func (c *Client) Listen(ctx context.Context) (net.Conn, error) {
coordinateURL, err := c.SDK.URL.Parse("/api/v2/workspaceagents/me/coordinate")
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(coordinateURL, []*http.Cookie{{
Name: codersdk.SessionTokenCookie,
Value: c.SDK.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.SDK.HTTPClient.Transport,
}
// nolint:bodyclose
conn, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
})
if err != nil {
if res == nil {
return nil, err
}
return nil, codersdk.ReadBodyAsError(res)
}
// Ping once every 30 seconds to ensure that the websocket is alive. If we
// don't get a response within 30s we kill the websocket and reconnect.
// See: https://github.com/coder/coder/pull/5824
go func() {
tick := 30 * time.Second
ticker := time.NewTicker(tick)
defer ticker.Stop()
defer func() {
c.SDK.Logger.Debug(ctx, "coordinate pinger exited")
}()
for {
select {
case <-ctx.Done():
return
case start := <-ticker.C:
ctx, cancel := context.WithTimeout(ctx, tick)
err := conn.Ping(ctx)
if err != nil {
c.SDK.Logger.Error(ctx, "workspace agent coordinate ping", slog.Error(err))
err := conn.Close(websocket.StatusGoingAway, "Ping failed")
if err != nil {
c.SDK.Logger.Error(ctx, "close workspace agent coordinate websocket", slog.Error(err))
}
cancel()
return
}
c.SDK.Logger.Debug(ctx, "got coordinate pong", slog.F("took", time.Since(start)))
cancel()
}
}
}()
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
}
type PostAppHealthsRequest struct {
// Healths is a map of the workspace app name and the health of the app.
Healths map[uuid.UUID]codersdk.WorkspaceAppHealth
}
// PostAppHealth updates the workspace agent app health status.
func (c *Client) PostAppHealth(ctx context.Context, req PostAppHealthsRequest) error {
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/app-health", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return codersdk.ReadBodyAsError(res)
}
return nil
}
// AuthenticateResponse is returned when an instance ID
// has been exchanged for a session token.
// @typescript-ignore AuthenticateResponse
type AuthenticateResponse struct {
SessionToken string `json:"session_token"`
}
type GoogleInstanceIdentityToken struct {
JSONWebToken string `json:"json_web_token" validate:"required"`
}
// AuthWorkspaceGoogleInstanceIdentity 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 (c *Client) AuthGoogleInstanceIdentity(ctx context.Context, serviceAccount string, gcpClient *metadata.Client) (AuthenticateResponse, error) {
if serviceAccount == "" {
// This is the default name specified by Google.
serviceAccount = "default"
}
if gcpClient == nil {
gcpClient = metadata.NewClient(c.SDK.HTTPClient)
}
// "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", serviceAccount))
if err != nil {
return AuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err)
}
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{
JSONWebToken: jwt,
})
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)
}
type AWSInstanceIdentityToken struct {
Signature string `json:"signature" validate:"required"`
Document string `json:"document" validate:"required"`
}
// AuthWorkspaceAWSInstanceIdentity uses the Amazon Metadata API to
// fetch a signed payload, 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 (c *Client) AuthAWSInstanceIdentity(ctx context.Context) (AuthenticateResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://169.254.169.254/latest/api/token", nil)
if err != nil {
return AuthenticateResponse{}, nil
}
req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
res, err := c.SDK.HTTPClient.Do(req)
if err != nil {
return AuthenticateResponse{}, err
}
defer res.Body.Close()
token, err := io.ReadAll(res.Body)
if err != nil {
return AuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/signature", nil)
if err != nil {
return AuthenticateResponse{}, nil
}
req.Header.Set("X-aws-ec2-metadata-token", string(token))
res, err = c.SDK.HTTPClient.Do(req)
if err != nil {
return AuthenticateResponse{}, err
}
defer res.Body.Close()
signature, err := io.ReadAll(res.Body)
if err != nil {
return AuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/document", nil)
if err != nil {
return AuthenticateResponse{}, nil
}
req.Header.Set("X-aws-ec2-metadata-token", string(token))
res, err = c.SDK.HTTPClient.Do(req)
if err != nil {
return AuthenticateResponse{}, err
}
defer res.Body.Close()
document, err := io.ReadAll(res.Body)
if err != nil {
return AuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
}
res, err = c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{
Signature: string(signature),
Document: string(document),
})
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)
}
type AzureInstanceIdentityToken struct {
Signature string `json:"signature" validate:"required"`
Encoding string `json:"encoding" validate:"required"`
}
// AuthWorkspaceAzureInstanceIdentity uses the Azure Instance Metadata Service to
// fetch a signed payload, and exchange it for a session token for a workspace agent.
func (c *Client) AuthAzureInstanceIdentity(ctx context.Context) (AuthenticateResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/metadata/attested/document?api-version=2020-09-01", nil)
if err != nil {
return AuthenticateResponse{}, nil
}
req.Header.Set("Metadata", "true")
res, err := c.SDK.HTTPClient.Do(req)
if err != nil {
return AuthenticateResponse{}, err
}
defer res.Body.Close()
var token AzureInstanceIdentityToken
err = json.NewDecoder(res.Body).Decode(&token)
if err != nil {
return AuthenticateResponse{}, err
}
res, err = c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token)
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)
}
// ReportStats begins a stat streaming connection with the Coder server.
// It is resilient to network failures and intermittent coderd issues.
func (c *Client) ReportStats(
ctx context.Context,
log slog.Logger,
getStats func() *Stats,
) (io.Closer, error) {
ctx, cancel := context.WithCancel(ctx)
go func() {
// Immediately trigger a stats push to get the correct interval.
timer := time.NewTimer(time.Nanosecond)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
}
var nextInterval time.Duration
for r := retry.New(100*time.Millisecond, time.Minute); r.Wait(ctx); {
resp, err := c.PostStats(ctx, getStats())
if err != nil {
if !xerrors.Is(err, context.Canceled) {
log.Error(ctx, "report stats", slog.Error(err))
}
continue
}
nextInterval = resp.ReportInterval
break
}
timer.Reset(nextInterval)
}
}()
return closeFunc(func() error {
cancel()
return nil
}), nil
}
// Stats records the Agent's network connection statistics for use in
// user-facing metrics and debugging.
type Stats struct {
// ConnsByProto is a count of connections by protocol.
ConnsByProto map[string]int64 `json:"conns_by_proto"`
// NumConns is the number of connections received by an agent.
NumConns int64 `json:"num_comms"`
// RxPackets is the number of received packets.
RxPackets int64 `json:"rx_packets"`
// RxBytes is the number of received bytes.
RxBytes int64 `json:"rx_bytes"`
// TxPackets is the number of transmitted bytes.
TxPackets int64 `json:"tx_packets"`
// TxBytes is the number of transmitted bytes.
TxBytes int64 `json:"tx_bytes"`
}
type StatsResponse struct {
// ReportInterval is the duration after which the agent should send stats
// again.
ReportInterval time.Duration `json:"report_interval"`
}
func (c *Client) PostStats(ctx context.Context, stats *Stats) (StatsResponse, error) {
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-stats", stats)
if err != nil {
return StatsResponse{}, xerrors.Errorf("send request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return StatsResponse{}, codersdk.ReadBodyAsError(res)
}
var interval StatsResponse
err = json.NewDecoder(res.Body).Decode(&interval)
if err != nil {
return StatsResponse{}, xerrors.Errorf("decode stats response: %w", err)
}
return interval, nil
}
type PostLifecycleRequest struct {
State codersdk.WorkspaceAgentLifecycle `json:"state"`
}
func (c *Client) PostLifecycle(ctx context.Context, req PostLifecycleRequest) error {
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-lifecycle", req)
if err != nil {
return xerrors.Errorf("agent state post request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return codersdk.ReadBodyAsError(res)
}
return nil
}
type PostVersionRequest struct {
Version string `json:"version"`
}
func (c *Client) PostVersion(ctx context.Context, version string) error {
versionReq := PostVersionRequest{Version: version}
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/version", versionReq)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return codersdk.ReadBodyAsError(res)
}
return nil
}
type GitAuthResponse struct {
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"url"`
}
// GitAuth submits a URL to fetch a GIT_ASKPASS username and password for.
// nolint:revive
func (c *Client) GitAuth(ctx context.Context, gitURL string, listen bool) (GitAuthResponse, error) {
reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL)
if listen {
reqURL += "&listen"
}
res, err := c.SDK.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return GitAuthResponse{}, xerrors.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GitAuthResponse{}, codersdk.ReadBodyAsError(res)
}
var authResp GitAuthResponse
return authResp, json.NewDecoder(res.Body).Decode(&authResp)
}
type closeFunc func() error
func (c closeFunc) Close() error {
return c()
}