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
This commit is contained in:
Kyle Carberry
2023-01-29 15:47:24 -06:00
committed by GitHub
parent e49f41652f
commit 7ad87505c8
115 changed files with 2491 additions and 2567 deletions

View File

@ -0,0 +1,520 @@
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()
}

View File

@ -23,6 +23,7 @@ type APIKey struct {
LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"`
}
// LoginType is the type of login used to create the API key.
type LoginType string
const (
@ -35,7 +36,10 @@ const (
type APIKeyScope string
const (
APIKeyScopeAll APIKeyScope = "all"
// APIKeyScopeAll is a scope that allows the user to do everything.
APIKeyScopeAll APIKeyScope = "all"
// APIKeyScopeApplicationConnect is a scope that allows the user
// to connect to applications in a workspace.
APIKeyScopeApplicationConnect APIKeyScope = "application_connect"
)
@ -49,7 +53,9 @@ type GenerateAPIKeyResponse struct {
Key string `json:"key"`
}
// CreateToken generates an API key that doesn't expire.
// CreateToken generates an API key for the user ID provided with
// custom expiration. These tokens can be used for long-lived access,
// like for use with CI.
func (c *Client) CreateToken(ctx context.Context, userID string, req CreateTokenRequest) (GenerateAPIKeyResponse, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), req)
if err != nil {
@ -57,7 +63,7 @@ func (c *Client) CreateToken(ctx context.Context, userID string, req CreateToken
}
defer res.Body.Close()
if res.StatusCode > http.StatusCreated {
return GenerateAPIKeyResponse{}, readBodyAsError(res)
return GenerateAPIKeyResponse{}, ReadBodyAsError(res)
}
var apiKey GenerateAPIKeyResponse
@ -73,36 +79,36 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyR
}
defer res.Body.Close()
if res.StatusCode > http.StatusCreated {
return GenerateAPIKeyResponse{}, readBodyAsError(res)
return GenerateAPIKeyResponse{}, ReadBodyAsError(res)
}
var apiKey GenerateAPIKeyResponse
return apiKey, json.NewDecoder(res.Body).Decode(&apiKey)
}
// GetTokens list machine API keys.
func (c *Client) GetTokens(ctx context.Context, userID string) ([]APIKey, error) {
// Tokens list machine API keys.
func (c *Client) Tokens(ctx context.Context, userID string) ([]APIKey, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode > http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var apiKey = []APIKey{}
return apiKey, json.NewDecoder(res.Body).Decode(&apiKey)
}
// GetAPIKey returns the api key by id.
func (c *Client) GetAPIKey(ctx context.Context, userID string, id string) (*APIKey, error) {
// APIKey returns the api key by id.
func (c *Client) APIKey(ctx context.Context, userID string, id string) (*APIKey, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode > http.StatusCreated {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
apiKey := &APIKey{}
return apiKey, json.NewDecoder(res.Body).Decode(apiKey)
@ -116,7 +122,7 @@ func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) err
}
defer res.Body.Close()
if res.StatusCode > http.StatusNoContent {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}

View File

@ -1,43 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
)
type AppearanceConfig struct {
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
}
type ServiceBannerConfig struct {
Enabled bool `json:"enabled"`
Message string `json:"message,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`
}
func (c *Client) Appearance(ctx context.Context) (AppearanceConfig, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/appearance", nil)
if err != nil {
return AppearanceConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AppearanceConfig{}, readBodyAsError(res)
}
var cfg AppearanceConfig
return cfg, json.NewDecoder(res.Body).Decode(&cfg)
}
func (c *Client) UpdateAppearance(ctx context.Context, appearance AppearanceConfig) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/appearance", appearance)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}

View File

@ -59,7 +59,7 @@ const (
AuditActionStop AuditAction = "stop"
)
func (a AuditAction) FriendlyString() string {
func (a AuditAction) Friendly() string {
switch a {
case AuditActionCreate:
return "created"
@ -142,7 +142,7 @@ func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogR
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AuditLogResponse{}, readBodyAsError(res)
return AuditLogResponse{}, ReadBodyAsError(res)
}
var logRes AuditLogResponse
@ -154,6 +154,8 @@ func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogR
return logRes, nil
}
// CreateTestAuditLog creates a fake audit log. Only owners of the organization
// can perform this action. It's used for testing purposes.
func (c *Client) CreateTestAuditLog(ctx context.Context, req CreateTestAuditLogRequest) error {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", req)
if err != nil {

View File

@ -56,14 +56,16 @@ type AuthorizationObject struct {
ResourceID string `json:"resource_id,omitempty"`
}
func (c *Client) CheckAuthorization(ctx context.Context, req AuthorizationRequest) (AuthorizationResponse, error) {
// AuthCheck allows the authenticated user to check if they have the given permissions
// to a set of resources.
func (c *Client) AuthCheck(ctx context.Context, req AuthorizationRequest) (AuthorizationResponse, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/authcheck", req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AuthorizationResponse{}, readBodyAsError(res)
return AuthorizationResponse{}, ReadBodyAsError(res)
}
var resp AuthorizationResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)

View File

@ -1,23 +0,0 @@
package codersdk
import (
"context"
"net/http"
)
type UpdateBrandingRequest struct {
LogoURL string `json:"logo_url"`
}
// UpdateBranding applies customization settings available to Enterprise customers.
func (c *Client) UpdateBranding(ctx context.Context, req UpdateBrandingRequest) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/branding", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}

View File

@ -1,44 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"strings"
"golang.org/x/mod/semver"
)
// BuildInfoResponse contains build information for this instance of Coder.
type BuildInfoResponse struct {
// ExternalURL references the current Coder version.
// For production builds, this will link directly to a release. For development builds, this will link to a commit.
ExternalURL string `json:"external_url"`
// Version returns the semantic version of the build.
Version string `json:"version"`
}
// CanonicalVersion trims build information from the version.
// E.g. 'v0.7.4-devel+11573034' -> 'v0.7.4'.
func (b BuildInfoResponse) CanonicalVersion() string {
// We do a little hack here to massage the string into a form
// that works well with semver.
trimmed := strings.ReplaceAll(b.Version, "-devel+", "+devel-")
return semver.Canonical(trimmed)
}
// BuildInfo returns build information for this instance of Coder.
func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
if err != nil {
return BuildInfoResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return BuildInfoResponse{}, readBodyAsError(res)
}
var buildInfo BuildInfoResponse
return buildInfo, json.NewDecoder(res.Body).Decode(&buildInfo)
}

View File

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"strings"
@ -28,17 +29,23 @@ import (
// shouldn't be likely to conflict with any user-application set cookies.
// Be sure to strip additional cookies in httpapi.StripCoderCookies!
const (
// SessionTokenKey represents the name of the cookie or query parameter the API key is stored in.
SessionTokenKey = "coder_session_token"
// SessionCustomHeader is the custom header to use for authentication.
SessionCustomHeader = "Coder-Session-Token"
OAuth2StateKey = "oauth_state"
OAuth2RedirectKey = "oauth_redirect"
// SessionTokenCookie represents the name of the cookie or query parameter the API key is stored in.
SessionTokenCookie = "coder_session_token"
// SessionTokenHeader is the custom header to use for authentication.
SessionTokenHeader = "Coder-Session-Token"
// OAuth2StateCookie is the name of the cookie that stores the oauth2 state.
OAuth2StateCookie = "oauth_state"
// OAuth2RedirectCookie is the name of the cookie that stores the oauth2 redirect.
OAuth2RedirectCookie = "oauth_redirect"
// BypassRatelimitHeader is the custom header to use to bypass ratelimits.
// Only owners can bypass rate limits. This is typically used for scale testing.
// nolint: gosec
BypassRatelimitHeader = "X-Coder-Bypass-Ratelimit"
)
// loggableMimeTypes is a list of MIME types that are safe to log
// the output of. This is useful for debugging or testing.
var loggableMimeTypes = map[string]struct{}{
"application/json": {},
"text/plain": {},
@ -63,65 +70,32 @@ type Client struct {
HTTPClient *http.Client
URL *url.URL
// Logger can be provided to log requests. Request method, URL and response
// status code will be logged by default.
// Logger is optionally provided to log requests.
// Method, URL, and response code will be logged by default.
Logger slog.Logger
// LogBodies determines whether the request and response bodies are logged
// to the provided Logger. This is useful for debugging or testing.
// LogBodies can be enabled to print request and response bodies to the logger.
LogBodies bool
// BypassRatelimits is an optional flag that can be set by the site owner to
// disable ratelimit checks for the client.
BypassRatelimits bool
// PropagateTracing is an optional flag that can be set to propagate tracing
// spans to the Coder API. This is useful for seeing the entire request
// from end-to-end.
PropagateTracing bool
// Trace can be enabled to propagate tracing spans to the Coder API.
// This is useful for tracking a request end-to-end.
Trace bool
}
// SessionToken returns the currently set token for the client.
func (c *Client) SessionToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.sessionToken
}
// SetSessionToken returns the currently set token for the client.
func (c *Client) SetSessionToken(token string) {
c.mu.Lock()
defer c.mu.Unlock()
c.sessionToken = token
}
func (c *Client) Clone() *Client {
c.mu.Lock()
defer c.mu.Unlock()
hc := *c.HTTPClient
u := *c.URL
return &Client{
HTTPClient: &hc,
sessionToken: c.sessionToken,
URL: &u,
Logger: c.Logger,
LogBodies: c.LogBodies,
BypassRatelimits: c.BypassRatelimits,
PropagateTracing: c.PropagateTracing,
}
}
type RequestOption func(*http.Request)
func WithQueryParam(key, value string) RequestOption {
return func(r *http.Request) {
if value == "" {
return
}
q := r.URL.Query()
q.Add(key, value)
r.URL.RawQuery = q.Encode()
}
}
// Request performs a HTTP request with the body provided. The caller is
// responsible for closing the response body.
func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) (*http.Response, error) {
@ -165,10 +139,7 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
if err != nil {
return nil, xerrors.Errorf("create request: %w", err)
}
req.Header.Set(SessionCustomHeader, c.SessionToken())
if c.BypassRatelimits {
req.Header.Set(BypassRatelimitHeader, "true")
}
req.Header.Set(SessionTokenHeader, c.SessionToken())
if r != nil {
req.Header.Set("Content-Type", "application/json")
@ -181,7 +152,7 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
span.SetAttributes(semconv.HTTPClientAttributesFromHTTPRequest(req)...)
// Inject tracing headers if enabled.
if c.PropagateTracing {
if c.Trace {
tmp := otel.GetTextMapPropagator()
hc := propagation.HeaderCarrier(req.Header)
tmp.Inject(ctx, hc)
@ -235,28 +206,28 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
return resp, err
}
// readBodyAsError reads the response as an .Message, and
// ReadBodyAsError reads the response as a codersdk.Response, and
// wraps it in a codersdk.Error type for easy marshaling.
func readBodyAsError(res *http.Response) error {
func ReadBodyAsError(res *http.Response) error {
if res == nil {
return xerrors.Errorf("no body returned")
}
defer res.Body.Close()
contentType := res.Header.Get("Content-Type")
var method, u string
var requestMethod, requestURL string
if res.Request != nil {
method = res.Request.Method
requestMethod = res.Request.Method
if res.Request.URL != nil {
u = res.Request.URL.String()
requestURL = res.Request.URL.String()
}
}
var helper string
var helpMessage string
if res.StatusCode == http.StatusUnauthorized {
// 401 means the user is not logged in
// 403 would mean that the user is not authorized
helper = "Try logging in using 'coder login <url>'."
helpMessage = "Try logging in using 'coder login <url>'."
}
resp, err := io.ReadAll(res.Body)
@ -278,7 +249,7 @@ func readBodyAsError(res *http.Response) error {
Message: fmt.Sprintf("unexpected non-JSON response %q", contentType),
Detail: string(resp),
},
Helper: helper,
Helper: helpMessage,
}
}
@ -291,7 +262,7 @@ func readBodyAsError(res *http.Response) error {
Response: Response{
Message: "empty response body",
},
Helper: helper,
Helper: helpMessage,
}
}
return xerrors.Errorf("decode body: %w", err)
@ -307,9 +278,9 @@ func readBodyAsError(res *http.Response) error {
return &Error{
Response: m,
statusCode: res.StatusCode,
method: method,
url: u,
Helper: helper,
method: requestMethod,
url: requestURL,
Helper: helpMessage,
}
}
@ -370,3 +341,68 @@ func parseMimeType(contentType string) string {
return mimeType
}
// Response represents a generic HTTP response.
type Response struct {
// Message is an actionable message that depicts actions the request took.
// These messages should be fully formed sentences with proper punctuation.
// Examples:
// - "A user has been created."
// - "Failed to create a user."
Message string `json:"message"`
// Detail is a debug message that provides further insight into why the
// action failed. This information can be technical and a regular golang
// err.Error() text.
// - "database: too many open connections"
// - "stat: too many open files"
Detail string `json:"detail,omitempty"`
// Validations are form field-specific friendly error messages. They will be
// shown on a form field in the UI. These can also be used to add additional
// context if there is a set of errors in the primary 'Message'.
Validations []ValidationError `json:"validations,omitempty"`
}
// ValidationError represents a scoped error to a user input.
type ValidationError struct {
Field string `json:"field" validate:"required"`
Detail string `json:"detail" validate:"required"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("field: %s detail: %s", e.Field, e.Detail)
}
var _ error = (*ValidationError)(nil)
// IsConnectionError is a convenience function for checking if the source of an
// error is due to a 'connection refused', 'no such host', etc.
func IsConnectionError(err error) bool {
var (
// E.g. no such host
dnsErr *net.DNSError
// Eg. connection refused
opErr *net.OpError
)
return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr)
}
func AsError(err error) (*Error, bool) {
var e *Error
return e, xerrors.As(err, &e)
}
// RequestOption is a function that can be used to modify an http.Request.
type RequestOption func(*http.Request)
// WithQueryParam adds a query parameter to the request.
func WithQueryParam(key, value string) RequestOption {
return func(r *http.Request) {
if value == "" {
return
}
q := r.URL.Query()
q.Add(key, value)
r.URL.RawQuery = q.Encode()
}
}

View File

@ -6,9 +6,11 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
@ -31,6 +33,59 @@ import (
const jsonCT = "application/json"
func TestIsConnectionErr(t *testing.T) {
t.Parallel()
type tc = struct {
name string
err error
expectedResult bool
}
cases := []tc{
{
// E.g. "no such host"
name: "DNSError",
err: &net.DNSError{
Err: "no such host",
Name: "foofoo",
Server: "1.1.1.1:53",
IsTimeout: false,
IsTemporary: false,
IsNotFound: true,
},
expectedResult: true,
},
{
// E.g. "connection refused"
name: "OpErr",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Source: nil,
Addr: nil,
Err: &os.SyscallError{},
},
expectedResult: true,
},
{
name: "OpaqueError",
err: xerrors.Errorf("I'm opaque!"),
expectedResult: false,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, c.expectedResult, IsConnectionError(c.err))
})
}
}
func Test_Client(t *testing.T) {
t.Parallel()
@ -43,8 +98,7 @@ func Test_Client(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, method, r.Method)
assert.Equal(t, path, r.URL.Path)
assert.Equal(t, token, r.Header.Get(SessionCustomHeader))
assert.Equal(t, "true", r.Header.Get(BypassRatelimitHeader))
assert.Equal(t, token, r.Header.Get(SessionTokenHeader))
assert.NotEmpty(t, r.Header.Get("Traceparent"))
for k, v := range r.Header {
t.Logf("header %q: %q", k, strings.Join(v, ", "))
@ -59,7 +113,6 @@ func Test_Client(t *testing.T) {
require.NoError(t, err)
client := New(u)
client.SetSessionToken(token)
client.BypassRatelimits = true
logBuf := bytes.NewBuffer(nil)
client.Logger = slog.Make(sloghuman.Sink(logBuf)).Leveled(slog.LevelDebug)
@ -83,7 +136,7 @@ func Test_Client(t *testing.T) {
),
)
otel.SetLogger(logr.Discard())
client.PropagateTracing = true
client.Trace = true
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
@ -240,7 +293,7 @@ func Test_readBodyAsError(t *testing.T) {
c.res.Request = c.req
err := readBodyAsError(c.res)
err := ReadBodyAsError(c.res)
c.assert(t, err)
})
}

View File

@ -4,11 +4,106 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
)
// Entitlement represents whether a feature is licensed.
type Entitlement string
const (
EntitlementEntitled Entitlement = "entitled"
EntitlementGracePeriod Entitlement = "grace_period"
EntitlementNotEntitled Entitlement = "not_entitled"
)
// FeatureName represents the internal name of a feature.
// To add a new feature, add it to this set of enums as well as the FeatureNames
// array below.
type FeatureName string
const (
FeatureUserLimit FeatureName = "user_limit"
FeatureAuditLog FeatureName = "audit_log"
FeatureBrowserOnly FeatureName = "browser_only"
FeatureSCIM FeatureName = "scim"
FeatureTemplateRBAC FeatureName = "template_rbac"
FeatureHighAvailability FeatureName = "high_availability"
FeatureMultipleGitAuth FeatureName = "multiple_git_auth"
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
FeatureAppearance FeatureName = "appearance"
)
// FeatureNames must be kept in-sync with the Feature enum above.
var FeatureNames = []FeatureName{
FeatureUserLimit,
FeatureAuditLog,
FeatureBrowserOnly,
FeatureSCIM,
FeatureTemplateRBAC,
FeatureHighAvailability,
FeatureMultipleGitAuth,
FeatureExternalProvisionerDaemons,
FeatureAppearance,
}
// Humanize returns the feature name in a human-readable format.
func (n FeatureName) Humanize() string {
switch n {
case FeatureTemplateRBAC:
return "Template RBAC"
case FeatureSCIM:
return "SCIM"
default:
return strings.Title(strings.ReplaceAll(string(n), "_", " "))
}
}
// AlwaysEnable returns if the feature is always enabled if entitled.
// Warning: We don't know if we need this functionality.
// This method may disappear at any time.
func (n FeatureName) AlwaysEnable() bool {
return map[FeatureName]bool{
FeatureMultipleGitAuth: true,
FeatureExternalProvisionerDaemons: true,
FeatureAppearance: true,
}[n]
}
type Feature struct {
Entitlement Entitlement `json:"entitlement"`
Enabled bool `json:"enabled"`
Limit *int64 `json:"limit,omitempty"`
Actual *int64 `json:"actual,omitempty"`
}
type Entitlements struct {
Features map[FeatureName]Feature `json:"features"`
Warnings []string `json:"warnings"`
Errors []string `json:"errors"`
HasLicense bool `json:"has_license"`
Trial bool `json:"trial"`
// DEPRECATED: use Experiments instead.
Experimental bool `json:"experimental"`
}
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil)
if err != nil {
return Entitlements{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Entitlements{}, ReadBodyAsError(res)
}
var ent Entitlements
return ent, json.NewDecoder(res.Body).Decode(&ent)
}
// DeploymentConfig is the central configuration for the coder server.
type DeploymentConfig struct {
AccessURL *DeploymentConfigField[string] `json:"access_url" typescript:",notnull"`
@ -234,9 +329,173 @@ func (c *Client) DeploymentConfig(ctx context.Context) (DeploymentConfig, error)
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return DeploymentConfig{}, readBodyAsError(res)
return DeploymentConfig{}, ReadBodyAsError(res)
}
var df DeploymentConfig
return df, json.NewDecoder(res.Body).Decode(&df)
}
type AppearanceConfig struct {
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
}
type ServiceBannerConfig struct {
Enabled bool `json:"enabled"`
Message string `json:"message,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`
}
// Appearance returns the configuration that modifies the visual
// display of the dashboard.
func (c *Client) Appearance(ctx context.Context) (AppearanceConfig, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/appearance", nil)
if err != nil {
return AppearanceConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AppearanceConfig{}, ReadBodyAsError(res)
}
var cfg AppearanceConfig
return cfg, json.NewDecoder(res.Body).Decode(&cfg)
}
func (c *Client) UpdateAppearance(ctx context.Context, appearance AppearanceConfig) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/appearance", appearance)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ReadBodyAsError(res)
}
return nil
}
// BuildInfoResponse contains build information for this instance of Coder.
type BuildInfoResponse struct {
// ExternalURL references the current Coder version.
// For production builds, this will link directly to a release. For development builds, this will link to a commit.
ExternalURL string `json:"external_url"`
// Version returns the semantic version of the build.
Version string `json:"version"`
}
// CanonicalVersion trims build information from the version.
// E.g. 'v0.7.4-devel+11573034' -> 'v0.7.4'.
func (b BuildInfoResponse) CanonicalVersion() string {
// We do a little hack here to massage the string into a form
// that works well with semver.
trimmed := strings.ReplaceAll(b.Version, "-devel+", "+devel-")
return semver.Canonical(trimmed)
}
// BuildInfo returns build information for this instance of Coder.
func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
if err != nil {
return BuildInfoResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return BuildInfoResponse{}, ReadBodyAsError(res)
}
var buildInfo BuildInfoResponse
return buildInfo, json.NewDecoder(res.Body).Decode(&buildInfo)
}
type Experiment string
const (
// ExperimentAuthzQuerier is an internal experiment that enables the ExperimentAuthzQuerier
// interface for all RBAC operations. NOT READY FOR PRODUCTION USE.
ExperimentAuthzQuerier Experiment = "authz_querier"
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
var (
// ExperimentsAll should include all experiments that are safe for
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
ExperimentsAll = Experiments{}
)
// Experiments is a list of experiments that are enabled for the deployment.
// Multiple experiments may be enabled at the same time.
// Experiments are not safe for production use, and are not guaranteed to
// be backwards compatible. They may be removed or renamed at any time.
type Experiments []Experiment
func (e Experiments) Enabled(ex Experiment) bool {
for _, v := range e {
if v == ex {
return true
}
}
return false
}
func (c *Client) Experiments(ctx context.Context) (Experiments, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var exp []Experiment
return exp, json.NewDecoder(res.Body).Decode(&exp)
}
type DeploymentDAUsResponse struct {
Entries []DAUEntry `json:"entries"`
}
func (c *Client) DeploymentDAUs(ctx context.Context) (*DeploymentDAUsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/insights/daus", nil)
if err != nil {
return nil, xerrors.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var resp DeploymentDAUsResponse
return &resp, json.NewDecoder(res.Body).Decode(&resp)
}
type AppHostResponse struct {
// Host is the externally accessible URL for the Coder instance.
Host string `json:"host"`
}
// AppHost returns the site-wide application wildcard hostname without the
// leading "*.", e.g. "apps.coder.com". Apps are accessible at:
// "<app-name>--<agent-name>--<workspace-name>--<username>.<app-host>", e.g.
// "my-app--agent--workspace--username.apps.coder.com".
//
// If the app host is not set, the response will contain an empty string.
func (c *Client) AppHost(ctx context.Context) (AppHostResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/applications/host", nil)
if err != nil {
return AppHostResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AppHostResponse{}, ReadBodyAsError(res)
}
var host AppHostResponse
return host, json.NewDecoder(res.Body).Decode(&host)
}

View File

@ -1,58 +0,0 @@
package codersdk
import (
"fmt"
"net"
"golang.org/x/xerrors"
)
// Response represents a generic HTTP response.
type Response struct {
// Message is an actionable message that depicts actions the request took.
// These messages should be fully formed sentences with proper punctuation.
// Examples:
// - "A user has been created."
// - "Failed to create a user."
Message string `json:"message"`
// Detail is a debug message that provides further insight into why the
// action failed. This information can be technical and a regular golang
// err.Error() text.
// - "database: too many open connections"
// - "stat: too many open files"
Detail string `json:"detail,omitempty"`
// Validations are form field-specific friendly error messages. They will be
// shown on a form field in the UI. These can also be used to add additional
// context if there is a set of errors in the primary 'Message'.
Validations []ValidationError `json:"validations,omitempty"`
}
// ValidationError represents a scoped error to a user input.
type ValidationError struct {
Field string `json:"field" validate:"required"`
Detail string `json:"detail" validate:"required"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("field: %s detail: %s", e.Field, e.Detail)
}
var _ error = (*ValidationError)(nil)
// IsConnectionErr is a convenience function for checking if the source of an
// error is due to a 'connection refused', 'no such host', etc.
func IsConnectionErr(err error) bool {
var (
// E.g. no such host
dnsErr *net.DNSError
// Eg. connection refused
opErr *net.OpError
)
return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr)
}
func AsError(err error) (*Error, bool) {
var e *Error
return e, xerrors.As(err, &e)
}

View File

@ -1,65 +0,0 @@
package codersdk_test
import (
"net"
"os"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
)
func TestIsConnectionErr(t *testing.T) {
t.Parallel()
type tc = struct {
name string
err error
expectedResult bool
}
cases := []tc{
{
// E.g. "no such host"
name: "DNSError",
err: &net.DNSError{
Err: "no such host",
Name: "foofoo",
Server: "1.1.1.1:53",
IsTimeout: false,
IsTemporary: false,
IsNotFound: true,
},
expectedResult: true,
},
{
// E.g. "connection refused"
name: "OpErr",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Source: nil,
Addr: nil,
Err: &os.SyscallError{},
},
expectedResult: true,
},
{
name: "OpaqueError",
err: xerrors.Errorf("I'm opaque!"),
expectedResult: false,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, c.expectedResult, codersdk.IsConnectionErr(c.err))
})
}
}

View File

@ -1,54 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
)
type Experiment string
const (
// ExperimentAuthzQuerier is an internal experiment that enables the ExperimentAuthzQuerier
// interface for all RBAC operations. NOT READY FOR PRODUCTION USE.
ExperimentAuthzQuerier Experiment = "authz_querier"
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
var (
// ExperimentsAll should include all experiments that are safe for
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
ExperimentsAll = Experiments{}
)
// Experiments is a list of experiments that are enabled for the deployment.
// Multiple experiments may be enabled at the same time.
// Experiments are not safe for production use, and are not guaranteed to
// be backwards compatible. They may be removed or renamed at any time.
type Experiments []Experiment
func (e Experiments) Enabled(ex Experiment) bool {
for _, v := range e {
if v == ex {
return true
}
}
return false
}
func (c *Client) Experiments(ctx context.Context) (Experiments, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var exp []Experiment
return exp, json.NewDecoder(res.Body).Decode(&exp)
}

View File

@ -1,99 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"strings"
)
type Entitlement string
const (
EntitlementEntitled Entitlement = "entitled"
EntitlementGracePeriod Entitlement = "grace_period"
EntitlementNotEntitled Entitlement = "not_entitled"
)
// To add a new feature, modify this set of enums as well as the FeatureNames
// array below.
type FeatureName string
const (
FeatureUserLimit FeatureName = "user_limit"
FeatureAuditLog FeatureName = "audit_log"
FeatureBrowserOnly FeatureName = "browser_only"
FeatureSCIM FeatureName = "scim"
FeatureTemplateRBAC FeatureName = "template_rbac"
FeatureHighAvailability FeatureName = "high_availability"
FeatureMultipleGitAuth FeatureName = "multiple_git_auth"
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
FeatureAppearance FeatureName = "appearance"
)
// FeatureNames must be kept in-sync with the Feature enum above.
var FeatureNames = []FeatureName{
FeatureUserLimit,
FeatureAuditLog,
FeatureBrowserOnly,
FeatureSCIM,
FeatureTemplateRBAC,
FeatureHighAvailability,
FeatureMultipleGitAuth,
FeatureExternalProvisionerDaemons,
FeatureAppearance,
}
// Humanize returns the feature name in a human-readable format.
func (n FeatureName) Humanize() string {
switch n {
case FeatureTemplateRBAC:
return "Template RBAC"
case FeatureSCIM:
return "SCIM"
default:
return strings.Title(strings.ReplaceAll(string(n), "_", " "))
}
}
// AlwaysEnable returns if the feature is always enabled if entitled.
// Warning: We don't know if we need this functionality.
// This method may disappear at any time.
func (n FeatureName) AlwaysEnable() bool {
return map[FeatureName]bool{
FeatureMultipleGitAuth: true,
FeatureExternalProvisionerDaemons: true,
FeatureAppearance: true,
}[n]
}
type Feature struct {
Entitlement Entitlement `json:"entitlement"`
Enabled bool `json:"enabled"`
Limit *int64 `json:"limit,omitempty"`
Actual *int64 `json:"actual,omitempty"`
}
type Entitlements struct {
Features map[FeatureName]Feature `json:"features"`
Warnings []string `json:"warnings"`
Errors []string `json:"errors"`
HasLicense bool `json:"has_license"`
Trial bool `json:"trial"`
// DEPRECATED: use Experiments instead.
Experimental bool `json:"experimental"`
}
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil)
if err != nil {
return Entitlements{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Entitlements{}, readBodyAsError(res)
}
var ent Entitlements
return ent, json.NewDecoder(res.Body).Decode(&ent)
}

View File

@ -30,7 +30,7 @@ func (c *Client) Upload(ctx context.Context, contentType string, content []byte)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK {
return UploadResponse{}, readBodyAsError(res)
return UploadResponse{}, ReadBodyAsError(res)
}
var resp UploadResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -44,7 +44,7 @@ func (c *Client) Download(ctx context.Context, id uuid.UUID) ([]byte, string, er
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, "", readBodyAsError(res)
return nil, "", ReadBodyAsError(res)
}
data, err := io.ReadAll(res.Body)
if err != nil {

View File

@ -18,11 +18,6 @@ type GitSSHKey struct {
PublicKey string `json:"public_key"`
}
type AgentGitSSHKey struct {
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
}
// GitSSHKey returns the user's git SSH public key.
func (c *Client) GitSSHKey(ctx context.Context, user string) (GitSSHKey, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil)
@ -32,7 +27,7 @@ func (c *Client) GitSSHKey(ctx context.Context, user string) (GitSSHKey, error)
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GitSSHKey{}, readBodyAsError(res)
return GitSSHKey{}, ReadBodyAsError(res)
}
var gitsshkey GitSSHKey
@ -48,25 +43,9 @@ func (c *Client) RegenerateGitSSHKey(ctx context.Context, user string) (GitSSHKe
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GitSSHKey{}, readBodyAsError(res)
return GitSSHKey{}, ReadBodyAsError(res)
}
var gitsshkey GitSSHKey
return gitsshkey, json.NewDecoder(res.Body).Decode(&gitsshkey)
}
// AgentGitSSHKey will return the user's SSH key pair for the workspace.
func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil)
if err != nil {
return AgentGitSSHKey{}, xerrors.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AgentGitSSHKey{}, readBodyAsError(res)
}
var agentgitsshkey AgentGitSSHKey
return agentgitsshkey, json.NewDecoder(res.Body).Decode(&agentgitsshkey)
}

View File

@ -36,7 +36,7 @@ func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGro
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return Group{}, readBodyAsError(res)
return Group{}, ReadBodyAsError(res)
}
var resp Group
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -53,7 +53,7 @@ func (c *Client) GroupsByOrganization(ctx context.Context, orgID uuid.UUID) ([]G
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var groups []Group
@ -71,7 +71,7 @@ func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name st
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Group{}, readBodyAsError(res)
return Group{}, ReadBodyAsError(res)
}
var resp Group
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -88,7 +88,7 @@ func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) {
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Group{}, readBodyAsError(res)
return Group{}, ReadBodyAsError(res)
}
var resp Group
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -113,7 +113,7 @@ func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroup
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Group{}, readBodyAsError(res)
return Group{}, ReadBodyAsError(res)
}
var resp Group
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -130,7 +130,7 @@ func (c *Client) DeleteGroup(ctx context.Context, group uuid.UUID) error {
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}

View File

@ -1,28 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"golang.org/x/xerrors"
)
type DeploymentDAUsResponse struct {
Entries []DAUEntry `json:"entries"`
}
func (c *Client) DeploymentDAUs(ctx context.Context) (*DeploymentDAUsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/insights/daus", nil)
if err != nil {
return nil, xerrors.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var resp DeploymentDAUsResponse
return &resp, json.NewDecoder(res.Body).Decode(&resp)
}

View File

@ -57,7 +57,7 @@ func (c *Client) AddLicense(ctx context.Context, r AddLicenseRequest) (License,
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return License{}, readBodyAsError(res)
return License{}, ReadBodyAsError(res)
}
var l License
d := json.NewDecoder(res.Body)
@ -72,7 +72,7 @@ func (c *Client) Licenses(ctx context.Context) ([]License, error) {
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var licenses []License
d := json.NewDecoder(res.Body)
@ -87,7 +87,7 @@ func (c *Client) DeleteLicense(ctx context.Context, id int32) error {
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}

View File

@ -1,15 +0,0 @@
package codersdk
import (
"time"
"github.com/google/uuid"
)
type OrganizationMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"`
CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"`
Roles []Role `db:"roles" json:"roles"`
}

View File

@ -32,6 +32,14 @@ type Organization struct {
UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"`
}
type OrganizationMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"`
CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"`
Roles []Role `db:"roles" json:"roles"`
}
// CreateTemplateVersionRequest enables callers to create a new Template Version.
type CreateTemplateVersionRequest struct {
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
@ -99,7 +107,7 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization,
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Organization{}, readBodyAsError(res)
return Organization{}, ReadBodyAsError(res)
}
var organization Organization
@ -118,7 +126,7 @@ func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, e
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var daemons []ProvisionerDaemon
@ -138,7 +146,7 @@ func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return TemplateVersion{}, readBodyAsError(res)
return TemplateVersion{}, ReadBodyAsError(res)
}
var templateVersion TemplateVersion
@ -157,7 +165,7 @@ func (c *Client) TemplateVersionByOrganizationAndName(ctx context.Context, organ
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return TemplateVersion{}, readBodyAsError(res)
return TemplateVersion{}, ReadBodyAsError(res)
}
var templateVersion TemplateVersion
@ -176,7 +184,7 @@ func (c *Client) CreateTemplate(ctx context.Context, organizationID uuid.UUID, r
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return Template{}, readBodyAsError(res)
return Template{}, ReadBodyAsError(res)
}
var template Template
@ -195,7 +203,7 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var templates []Template
@ -214,7 +222,7 @@ func (c *Client) TemplateByName(ctx context.Context, organizationID uuid.UUID, n
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Template{}, readBodyAsError(res)
return Template{}, ReadBodyAsError(res)
}
var template Template
@ -230,7 +238,7 @@ func (c *Client) CreateWorkspace(ctx context.Context, organizationID uuid.UUID,
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return Workspace{}, readBodyAsError(res)
return Workspace{}, ReadBodyAsError(res)
}
var workspace Workspace

View File

@ -110,7 +110,7 @@ func (c *Client) CreateParameter(ctx context.Context, scope ParameterScope, id u
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return Parameter{}, readBodyAsError(res)
return Parameter{}, ReadBodyAsError(res)
}
var param Parameter
@ -125,7 +125,7 @@ func (c *Client) DeleteParameter(ctx context.Context, scope ParameterScope, id u
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
_, _ = io.Copy(io.Discard, res.Body)
@ -140,7 +140,7 @@ func (c *Client) Parameters(ctx context.Context, scope ParameterScope, id uuid.U
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var parameters []Parameter

View File

@ -104,7 +104,7 @@ func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, befo
}
if res.StatusCode != http.StatusOK {
defer res.Body.Close()
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var logs []ProvisionerJobLog
@ -126,7 +126,7 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after
return nil, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(followURL, []*http.Cookie{{
Name: SessionTokenKey,
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
@ -140,7 +140,7 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after
if res == nil {
return nil, nil, err
}
return nil, nil, readBodyAsError(res)
return nil, nil, ReadBodyAsError(res)
}
logs := make(chan ProvisionerJobLog)
decoder := json.NewDecoder(websocket.NetConn(ctx, conn, websocket.MessageText))
@ -188,7 +188,7 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, organization uuid.U
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: SessionTokenKey,
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
@ -203,7 +203,7 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, organization uuid.U
if res == nil {
return nil, err
}
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
// Align with the frame size of yamux.
conn.SetReadLimit(256 * 1024)

View File

@ -1,26 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type WorkspaceQuota struct {
CreditsConsumed int `json:"credits_consumed"`
Budget int `json:"budget"`
}
func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQuota, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil)
if err != nil {
return WorkspaceQuota{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceQuota{}, readBodyAsError(res)
}
var quota WorkspaceQuota
return quota, json.NewDecoder(res.Body).Decode(&quota)
}

View File

@ -36,7 +36,7 @@ func (c *Client) Replicas(ctx context.Context) ([]Replica, error) {
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var replicas []Replica

View File

@ -27,7 +27,7 @@ func (c *Client) ListSiteRoles(ctx context.Context) ([]AssignableRoles, error) {
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var roles []AssignableRoles
return roles, json.NewDecoder(res.Body).Decode(&roles)
@ -41,7 +41,7 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]As
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var roles []AssignableRoles
return roles, json.NewDecoder(res.Body).Decode(&roles)

View File

@ -100,7 +100,7 @@ func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, er
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Template{}, readBodyAsError(res)
return Template{}, ReadBodyAsError(res)
}
var resp Template
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -113,7 +113,7 @@ func (c *Client) DeleteTemplate(ctx context.Context, template uuid.UUID) error {
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -128,7 +128,7 @@ func (c *Client) UpdateTemplateMeta(ctx context.Context, templateID uuid.UUID, r
return Template{}, xerrors.New("template metadata not modified")
}
if res.StatusCode != http.StatusOK {
return Template{}, readBodyAsError(res)
return Template{}, ReadBodyAsError(res)
}
var updated Template
return updated, json.NewDecoder(res.Body).Decode(&updated)
@ -141,7 +141,7 @@ func (c *Client) UpdateTemplateACL(ctx context.Context, templateID uuid.UUID, re
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -153,7 +153,7 @@ func (c *Client) TemplateACL(ctx context.Context, templateID uuid.UUID) (Templat
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return TemplateACL{}, readBodyAsError(res)
return TemplateACL{}, ReadBodyAsError(res)
}
var acl TemplateACL
return acl, json.NewDecoder(res.Body).Decode(&acl)
@ -168,7 +168,7 @@ func (c *Client) UpdateActiveTemplateVersion(ctx context.Context, template uuid.
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -188,7 +188,7 @@ func (c *Client) TemplateVersionsByTemplate(ctx context.Context, req TemplateVer
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var templateVersion []TemplateVersion
return templateVersion, json.NewDecoder(res.Body).Decode(&templateVersion)
@ -203,7 +203,7 @@ func (c *Client) TemplateVersionByName(ctx context.Context, template uuid.UUID,
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return TemplateVersion{}, readBodyAsError(res)
return TemplateVersion{}, ReadBodyAsError(res)
}
var templateVersion TemplateVersion
return templateVersion, json.NewDecoder(res.Body).Decode(&templateVersion)
@ -227,7 +227,7 @@ func (c *Client) TemplateDAUs(ctx context.Context, templateID uuid.UUID) (*Templ
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var resp TemplateDAUsResponse
@ -258,7 +258,7 @@ func (c *Client) TemplateExamples(ctx context.Context, organizationID uuid.UUID)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var templateExamples []TemplateExample
return templateExamples, json.NewDecoder(res.Body).Decode(&templateExamples)

View File

@ -55,7 +55,7 @@ func (c *Client) TemplateVersion(ctx context.Context, id uuid.UUID) (TemplateVer
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return TemplateVersion{}, readBodyAsError(res)
return TemplateVersion{}, ReadBodyAsError(res)
}
var version TemplateVersion
return version, json.NewDecoder(res.Body).Decode(&version)
@ -69,7 +69,7 @@ func (c *Client) CancelTemplateVersion(ctx context.Context, version uuid.UUID) e
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -82,7 +82,7 @@ func (c *Client) TemplateVersionRichParameters(ctx context.Context, version uuid
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var params []TemplateVersionParameter
return params, json.NewDecoder(res.Body).Decode(&params)
@ -96,7 +96,7 @@ func (c *Client) TemplateVersionSchema(ctx context.Context, version uuid.UUID) (
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var params []ParameterSchema
return params, json.NewDecoder(res.Body).Decode(&params)
@ -110,7 +110,7 @@ func (c *Client) TemplateVersionParameters(ctx context.Context, version uuid.UUI
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var params []ComputedParameter
return params, json.NewDecoder(res.Body).Decode(&params)
@ -124,7 +124,7 @@ func (c *Client) TemplateVersionResources(ctx context.Context, version uuid.UUID
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var resources []WorkspaceResource
return resources, json.NewDecoder(res.Body).Decode(&resources)
@ -157,7 +157,7 @@ func (c *Client) CreateTemplateVersionDryRun(ctx context.Context, version uuid.U
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return ProvisionerJob{}, readBodyAsError(res)
return ProvisionerJob{}, ReadBodyAsError(res)
}
var job ProvisionerJob
@ -173,7 +173,7 @@ func (c *Client) TemplateVersionDryRun(ctx context.Context, version, job uuid.UU
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ProvisionerJob{}, readBodyAsError(res)
return ProvisionerJob{}, ReadBodyAsError(res)
}
var j ProvisionerJob
@ -189,7 +189,7 @@ func (c *Client) TemplateVersionDryRunResources(ctx context.Context, version, jo
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var resources []WorkspaceResource
@ -216,7 +216,7 @@ func (c *Client) CancelTemplateVersionDryRun(ctx context.Context, version, job u
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -228,7 +228,7 @@ func (c *Client) PreviousTemplateVersion(ctx context.Context, organization uuid.
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return TemplateVersion{}, readBodyAsError(res)
return TemplateVersion{}, ReadBodyAsError(res)
}
var version TemplateVersion
return version, json.NewDecoder(res.Body).Decode(&version)

View File

@ -26,7 +26,7 @@ func (c *Client) UpdateCheck(ctx context.Context) (UpdateCheckResponse, error) {
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UpdateCheckResponse{}, readBodyAsError(res)
return UpdateCheckResponse{}, ReadBodyAsError(res)
}
var buildInfo UpdateCheckResponse

View File

@ -123,7 +123,7 @@ func (c *Client) HasFirstUser(ctx context.Context) (bool, error) {
return false, nil
}
if res.StatusCode != http.StatusOK {
return false, readBodyAsError(res)
return false, ReadBodyAsError(res)
}
return true, nil
}
@ -137,7 +137,7 @@ func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return CreateFirstUserResponse{}, readBodyAsError(res)
return CreateFirstUserResponse{}, ReadBodyAsError(res)
}
var resp CreateFirstUserResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -151,7 +151,7 @@ func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, e
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return User{}, readBodyAsError(res)
return User{}, ReadBodyAsError(res)
}
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
@ -165,7 +165,7 @@ func (c *Client) DeleteUser(ctx context.Context, id uuid.UUID) error {
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -178,7 +178,7 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, readBodyAsError(res)
return User{}, ReadBodyAsError(res)
}
var resp User
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -202,7 +202,7 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, readBodyAsError(res)
return User{}, ReadBodyAsError(res)
}
var resp User
@ -218,7 +218,7 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -232,7 +232,7 @@ func (c *Client) UpdateUserRoles(ctx context.Context, user string, req UpdateRol
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, readBodyAsError(res)
return User{}, ReadBodyAsError(res)
}
var resp User
return resp, json.NewDecoder(res.Body).Decode(&resp)
@ -247,21 +247,21 @@ func (c *Client) UpdateOrganizationMemberRoles(ctx context.Context, organization
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OrganizationMember{}, readBodyAsError(res)
return OrganizationMember{}, ReadBodyAsError(res)
}
var member OrganizationMember
return member, json.NewDecoder(res.Body).Decode(&member)
}
// GetUserRoles returns all roles the user has
func (c *Client) GetUserRoles(ctx context.Context, user string) (UserRoles, error) {
// UserRoles returns all roles the user has
func (c *Client) UserRoles(ctx context.Context, user string) (UserRoles, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/roles", user), nil)
if err != nil {
return UserRoles{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserRoles{}, readBodyAsError(res)
return UserRoles{}, ReadBodyAsError(res)
}
var roles UserRoles
return roles, json.NewDecoder(res.Body).Decode(&roles)
@ -276,7 +276,7 @@ func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordReq
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return LoginWithPasswordResponse{}, readBodyAsError(res)
return LoginWithPasswordResponse{}, ReadBodyAsError(res)
}
var resp LoginWithPasswordResponse
err = json.NewDecoder(res.Body).Decode(&resp)
@ -307,7 +307,7 @@ func (c *Client) User(ctx context.Context, userIdent string) (User, error) {
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, readBodyAsError(res)
return User{}, ReadBodyAsError(res)
}
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
@ -343,7 +343,7 @@ func (c *Client) Users(ctx context.Context, req UsersRequest) (GetUsersResponse,
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GetUsersResponse{}, readBodyAsError(res)
return GetUsersResponse{}, ReadBodyAsError(res)
}
var usersRes GetUsersResponse
@ -358,7 +358,7 @@ func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organi
}
defer res.Body.Close()
if res.StatusCode > http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var orgs []Organization
return orgs, json.NewDecoder(res.Body).Decode(&orgs)
@ -371,7 +371,7 @@ func (c *Client) OrganizationByName(ctx context.Context, user string, name strin
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Organization{}, readBodyAsError(res)
return Organization{}, ReadBodyAsError(res)
}
var org Organization
return org, json.NewDecoder(res.Body).Decode(&org)
@ -386,7 +386,7 @@ func (c *Client) CreateOrganization(ctx context.Context, req CreateOrganizationR
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return Organization{}, readBodyAsError(res)
return Organization{}, ReadBodyAsError(res)
}
var org Organization
@ -402,7 +402,7 @@ func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) {
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AuthMethods{}, readBodyAsError(res)
return AuthMethods{}, ReadBodyAsError(res)
}
var userAuth AuthMethods

View File

@ -24,21 +24,21 @@ import (
)
var (
// TailnetIP is a static IPv6 address with the Tailscale prefix that is used to route
// WorkspaceAgentIP is a static IPv6 address with the Tailscale prefix that is used to route
// connections from clients to this node. A dynamic address is not required because a Tailnet
// client only dials a single agent at a time.
TailnetIP = netip.MustParseAddr("fd7a:115c:a1e0:49d6:b259:b7ac:b1b2:48f4")
WorkspaceAgentIP = netip.MustParseAddr("fd7a:115c:a1e0:49d6:b259:b7ac:b1b2:48f4")
)
const (
TailnetSSHPort = 1
TailnetReconnectingPTYPort = 2
TailnetSpeedtestPort = 3
// TailnetStatisticsPort serves a HTTP server with endpoints for gathering
WorkspaceAgentSSHPort = 1
WorkspaceAgentReconnectingPTYPort = 2
WorkspaceAgentSpeedtestPort = 3
// WorkspaceAgentStatisticsPort serves a HTTP server with endpoints for gathering
// agent statistics.
TailnetStatisticsPort = 4
WorkspaceAgentStatisticsPort = 4
// MinimumListeningPort is the minimum port that the listening-ports
// WorkspaceAgentMinimumListeningPort is the minimum port that the listening-ports
// endpoint will return to the client, and the minimum port that is accepted
// by the proxy applications endpoint. Coder consumes ports 1-4 at the
// moment, and we reserve some extra ports for future use. Port 9 and up are
@ -47,15 +47,15 @@ const (
// This is not enforced in the CLI intentionally as we don't really care
// *that* much. The user could bypass this in the CLI by using SSH instead
// anyways.
MinimumListeningPort = 9
WorkspaceAgentMinimumListeningPort = 9
)
// IgnoredListeningPorts contains a list of ports in the global ignore list.
// This list contains common TCP ports that are not HTTP servers, such as
// databases, SSH, FTP, etc.
// WorkspaceAgentIgnoredListeningPorts contains a list of ports to ignore when looking for
// running applications inside a workspace. We want to ignore non-HTTP servers,
// so we pre-populate this list with common ports that are not HTTP servers.
//
// This is implemented as a map for fast lookup.
var IgnoredListeningPorts = map[uint16]struct{}{
var WorkspaceAgentIgnoredListeningPorts = map[uint16]struct{}{
0: {},
// Ports 1-8 are reserved for future use by the Coder agent.
1: {},
@ -111,15 +111,57 @@ var IgnoredListeningPorts = map[uint16]struct{}{
}
func init() {
if !strings.HasSuffix(os.Args[0], ".test") {
return
}
// Add a thousand more ports to the ignore list during tests so it's easier
// to find an available port.
if strings.HasSuffix(os.Args[0], ".test") {
for i := 63000; i < 64000; i++ {
IgnoredListeningPorts[uint16(i)] = struct{}{}
}
for i := 63000; i < 64000; i++ {
WorkspaceAgentIgnoredListeningPorts[uint16(i)] = struct{}{}
}
}
// WorkspaceAgentConn represents a connection to a workspace agent.
// @typescript-ignore WorkspaceAgentConn
type WorkspaceAgentConn struct {
*tailnet.Conn
CloseFunc func()
}
// AwaitReachable waits for the agent to be reachable.
func (c *WorkspaceAgentConn) AwaitReachable(ctx context.Context) bool {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
return c.Conn.AwaitReachable(ctx, WorkspaceAgentIP)
}
// Ping pings the agent and returns the round-trip time.
// The bool returns true if the ping was made P2P.
func (c *WorkspaceAgentConn) Ping(ctx context.Context) (time.Duration, bool, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
return c.Conn.Ping(ctx, WorkspaceAgentIP)
}
// Close ends the connection to the workspace agent.
func (c *WorkspaceAgentConn) Close() error {
if c.CloseFunc != nil {
c.CloseFunc()
}
return c.Conn.Close()
}
// WorkspaceAgentReconnectingPTYInit initializes a new reconnecting PTY session.
// @typescript-ignore WorkspaceAgentReconnectingPTYInit
type WorkspaceAgentReconnectingPTYInit struct {
ID uuid.UUID
Height uint16
Width uint16
Command string
}
// ReconnectingPTYRequest is sent from the client to the server
// to pipe data to a PTY.
// @typescript-ignore ReconnectingPTYRequest
@ -129,56 +171,18 @@ type ReconnectingPTYRequest struct {
Width uint16 `json:"width"`
}
// @typescript-ignore AgentConn
type AgentConn struct {
*tailnet.Conn
CloseFunc func()
}
func (c *AgentConn) AwaitReachable(ctx context.Context) bool {
// ReconnectingPTY spawns a new reconnecting terminal session.
// `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn.
// Raw terminal output will be read from the returned net.Conn.
func (c *WorkspaceAgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string) (net.Conn, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
return c.Conn.AwaitReachable(ctx, TailnetIP)
}
// Ping pings the agent and returns the round-trip time.
// The bool returns true if the ping was made P2P.
func (c *AgentConn) Ping(ctx context.Context) (time.Duration, bool, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
return c.Conn.Ping(ctx, TailnetIP)
}
func (c *AgentConn) CloseWithError(_ error) error {
return c.Close()
}
func (c *AgentConn) Close() error {
if c.CloseFunc != nil {
c.CloseFunc()
}
return c.Conn.Close()
}
// @typescript-ignore ReconnectingPTYInit
type ReconnectingPTYInit struct {
ID uuid.UUID
Height uint16
Width uint16
Command string
}
func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string) (net.Conn, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
conn, err := c.DialContextTCP(ctx, netip.AddrPortFrom(TailnetIP, TailnetReconnectingPTYPort))
conn, err := c.DialContextTCP(ctx, netip.AddrPortFrom(WorkspaceAgentIP, WorkspaceAgentReconnectingPTYPort))
if err != nil {
return nil, err
}
data, err := json.Marshal(ReconnectingPTYInit{
data, err := json.Marshal(WorkspaceAgentReconnectingPTYInit{
ID: id,
Height: height,
Width: width,
@ -199,15 +203,17 @@ func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w
return conn, nil
}
func (c *AgentConn) SSH(ctx context.Context) (net.Conn, error) {
// SSH pipes the SSH protocol over the returned net.Conn.
// This connects to the built-in SSH server in the workspace agent.
func (c *WorkspaceAgentConn) SSH(ctx context.Context) (net.Conn, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
return c.DialContextTCP(ctx, netip.AddrPortFrom(TailnetIP, TailnetSSHPort))
return c.DialContextTCP(ctx, netip.AddrPortFrom(WorkspaceAgentIP, WorkspaceAgentSSHPort))
}
// SSHClient calls SSH to create a client that uses a weak cipher
// for high throughput.
func (c *AgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
// to improve throughput.
func (c *WorkspaceAgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
netConn, err := c.SSH(ctx)
@ -226,10 +232,11 @@ func (c *AgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
return ssh.NewClient(sshConn, channels, requests), nil
}
func (c *AgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
// Speedtest runs a speedtest against the workspace agent.
func (c *WorkspaceAgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
speedConn, err := c.DialContextTCP(ctx, netip.AddrPortFrom(TailnetIP, TailnetSpeedtestPort))
speedConn, err := c.DialContextTCP(ctx, netip.AddrPortFrom(WorkspaceAgentIP, WorkspaceAgentSpeedtestPort))
if err != nil {
return nil, xerrors.Errorf("dial speedtest: %w", err)
}
@ -240,7 +247,9 @@ func (c *AgentConn) Speedtest(ctx context.Context, direction speedtest.Direction
return results, err
}
func (c *AgentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
// DialContext dials the address provided in the workspace agent.
// The network must be "tcp" or "udp".
func (c *WorkspaceAgentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
if network == "unix" {
@ -248,14 +257,62 @@ func (c *AgentConn) DialContext(ctx context.Context, network string, addr string
}
_, rawPort, _ := net.SplitHostPort(addr)
port, _ := strconv.ParseUint(rawPort, 10, 16)
ipp := netip.AddrPortFrom(TailnetIP, uint16(port))
ipp := netip.AddrPortFrom(WorkspaceAgentIP, uint16(port))
if network == "udp" {
return c.Conn.DialContextUDP(ctx, ipp)
}
return c.Conn.DialContextTCP(ctx, ipp)
}
func (c *AgentConn) statisticsClient() *http.Client {
type WorkspaceAgentListeningPortsResponse struct {
// If there are no ports in the list, nothing should be displayed in the UI.
// There must not be a "no ports available" message or anything similar, as
// there will always be no ports displayed on platforms where our port
// detection logic is unsupported.
Ports []WorkspaceAgentListeningPort `json:"ports"`
}
type WorkspaceAgentListeningPort struct {
ProcessName string `json:"process_name"` // may be empty
Network string `json:"network"` // only "tcp" at the moment
Port uint16 `json:"port"`
}
// ListeningPorts lists the ports that are currently in use by the workspace.
func (c *WorkspaceAgentConn) ListeningPorts(ctx context.Context) (WorkspaceAgentListeningPortsResponse, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.requestStatisticsServer(ctx, http.MethodGet, "/api/v0/listening-ports", nil)
if err != nil {
return WorkspaceAgentListeningPortsResponse{}, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceAgentListeningPortsResponse{}, ReadBodyAsError(res)
}
var resp WorkspaceAgentListeningPortsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// requestStatisticsServer makes a request to the workspace agent's statistics server.
func (c *WorkspaceAgentConn) requestStatisticsServer(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
host := net.JoinHostPort(WorkspaceAgentIP.String(), strconv.Itoa(WorkspaceAgentStatisticsPort))
url := fmt.Sprintf("http://%s%s", host, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, xerrors.Errorf("new statistics server request to %q: %w", url, err)
}
return c.statisticsServerClient().Do(req)
}
// statisticsServerClient returns an HTTP client that can be used to make
// requests to the workspace agent's statistics server.
func (c *WorkspaceAgentConn) statisticsServerClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
// Disable keep alives as we're usually only making a single
@ -271,11 +328,11 @@ func (c *AgentConn) statisticsClient() *http.Client {
}
// Verify that host is TailnetIP and port is
// TailnetStatisticsPort.
if host != TailnetIP.String() || port != strconv.Itoa(TailnetStatisticsPort) {
if host != WorkspaceAgentIP.String() || port != strconv.Itoa(WorkspaceAgentStatisticsPort) {
return nil, xerrors.Errorf("request %q does not appear to be for statistics server", addr)
}
conn, err := c.DialContextTCP(context.Background(), netip.AddrPortFrom(TailnetIP, TailnetStatisticsPort))
conn, err := c.DialContextTCP(context.Background(), netip.AddrPortFrom(WorkspaceAgentIP, WorkspaceAgentStatisticsPort))
if err != nil {
return nil, xerrors.Errorf("dial statistics: %w", err)
}
@ -285,53 +342,3 @@ func (c *AgentConn) statisticsClient() *http.Client {
},
}
}
func (c *AgentConn) doStatisticsRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
host := net.JoinHostPort(TailnetIP.String(), strconv.Itoa(TailnetStatisticsPort))
url := fmt.Sprintf("http://%s%s", host, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, xerrors.Errorf("new statistics server request to %q: %w", url, err)
}
return c.statisticsClient().Do(req)
}
type ListeningPortsResponse struct {
// If there are no ports in the list, nothing should be displayed in the UI.
// There must not be a "no ports available" message or anything similar, as
// there will always be no ports displayed on platforms where our port
// detection logic is unsupported.
Ports []ListeningPort `json:"ports"`
}
type ListeningPortNetwork string
const (
ListeningPortNetworkTCP ListeningPortNetwork = "tcp"
)
type ListeningPort struct {
ProcessName string `json:"process_name"` // may be empty
Network ListeningPortNetwork `json:"network"` // only "tcp" at the moment
Port uint16 `json:"port"`
}
func (c *AgentConn) ListeningPorts(ctx context.Context) (ListeningPortsResponse, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.doStatisticsRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil)
if err != nil {
return ListeningPortsResponse{}, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ListeningPortsResponse{}, readBodyAsError(res)
}
var resp ListeningPortsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}

View File

@ -5,16 +5,13 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/netip"
"net/url"
"strconv"
"time"
"cloud.google.com/go/compute/metadata"
"github.com/google/uuid"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
@ -83,55 +80,11 @@ type WorkspaceAgent struct {
StartupScriptTimeoutSeconds int32 `db:"startup_script_timeout_seconds" json:"startup_script_timeout_seconds"`
}
type WorkspaceAgentResourceMetadata struct {
MemoryTotal uint64 `json:"memory_total"`
DiskTotal uint64 `json:"disk_total"`
CPUCores uint64 `json:"cpu_cores"`
CPUModel string `json:"cpu_model"`
CPUMhz float64 `json:"cpu_mhz"`
}
type DERPRegion struct {
Preferred bool `json:"preferred"`
LatencyMilliseconds float64 `json:"latency_ms"`
}
type WorkspaceAgentInstanceMetadata struct {
JailOrchestrator string `json:"jail_orchestrator"`
OperatingSystem string `json:"operating_system"`
Platform string `json:"platform"`
PlatformFamily string `json:"platform_family"`
KernelVersion string `json:"kernel_version"`
KernelArchitecture string `json:"kernel_architecture"`
Cloud string `json:"cloud"`
Jail string `json:"jail"`
VNC bool `json:"vnc"`
}
// @typescript-ignore GoogleInstanceIdentityToken
type GoogleInstanceIdentityToken struct {
JSONWebToken string `json:"json_web_token" validate:"required"`
}
// @typescript-ignore AWSInstanceIdentityToken
type AWSInstanceIdentityToken struct {
Signature string `json:"signature" validate:"required"`
Document string `json:"document" validate:"required"`
}
// @typescript-ignore ReconnectingPTYRequest
type AzureInstanceIdentityToken struct {
Signature string `json:"signature" validate:"required"`
Encoding string `json:"encoding" validate:"required"`
}
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
// has been exchanged for a session token.
// @typescript-ignore WorkspaceAgentAuthenticateResponse
type WorkspaceAgentAuthenticateResponse struct {
SessionToken string `json:"session_token"`
}
// WorkspaceAgentConnectionInfo returns required information for establishing
// a connection with a workspace.
// @typescript-ignore WorkspaceAgentConnectionInfo
@ -139,272 +92,6 @@ type WorkspaceAgentConnectionInfo struct {
DERPMap *tailcfg.DERPMap `json:"derp_map"`
}
// @typescript-ignore PostWorkspaceAgentVersionRequest
// @Description x-apidocgen:skip
type PostWorkspaceAgentVersionRequest struct {
Version string `json:"version"`
}
// @typescript-ignore WorkspaceAgentMetadata
type WorkspaceAgentMetadata 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 []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"`
}
// 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) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, serviceAccount string, gcpClient *metadata.Client) (WorkspaceAgentAuthenticateResponse, error) {
if serviceAccount == "" {
// This is the default name specified by Google.
serviceAccount = "default"
}
if gcpClient == nil {
gcpClient = metadata.NewClient(c.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 WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err)
}
res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{
JSONWebToken: jwt,
})
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
}
var resp WorkspaceAgentAuthenticateResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// 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) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (WorkspaceAgentAuthenticateResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://169.254.169.254/latest/api/token", nil)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, nil
}
req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
res, err := c.HTTPClient.Do(req)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
token, err := io.ReadAll(res.Body)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, 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 WorkspaceAgentAuthenticateResponse{}, nil
}
req.Header.Set("X-aws-ec2-metadata-token", string(token))
res, err = c.HTTPClient.Do(req)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
signature, err := io.ReadAll(res.Body)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, 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 WorkspaceAgentAuthenticateResponse{}, nil
}
req.Header.Set("X-aws-ec2-metadata-token", string(token))
res, err = c.HTTPClient.Do(req)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
document, err := io.ReadAll(res.Body)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
}
res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{
Signature: string(signature),
Document: string(document),
})
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
}
var resp WorkspaceAgentAuthenticateResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// 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) AuthWorkspaceAzureInstanceIdentity(ctx context.Context) (WorkspaceAgentAuthenticateResponse, 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 WorkspaceAgentAuthenticateResponse{}, nil
}
req.Header.Set("Metadata", "true")
res, err := c.HTTPClient.Do(req)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
var token AzureInstanceIdentityToken
err = json.NewDecoder(res.Body).Decode(&token)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token)
if err != nil {
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
}
var resp WorkspaceAgentAuthenticateResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// WorkspaceAgentMetadata fetches metadata for the currently authenticated workspace agent.
func (c *Client) WorkspaceAgentMetadata(ctx context.Context) (WorkspaceAgentMetadata, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil)
if err != nil {
return WorkspaceAgentMetadata{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceAgentMetadata{}, readBodyAsError(res)
}
var agentMetadata WorkspaceAgentMetadata
err = json.NewDecoder(res.Body).Decode(&agentMetadata)
if err != nil {
return WorkspaceAgentMetadata{}, err
}
accessingPort := c.URL.Port()
if accessingPort == "" {
accessingPort = "80"
if c.URL.Scheme == "https" {
accessingPort = "443"
}
}
accessPort, err := strconv.Atoi(accessingPort)
if err != nil {
return WorkspaceAgentMetadata{}, 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 agentMetadata.DERPMap.Regions {
if !region.EmbeddedRelay {
continue
}
for _, node := range region.Nodes {
if node.STUNOnly {
continue
}
node.HostName = c.URL.Hostname()
node.DERPPort = accessPort
node.ForceHTTP = c.URL.Scheme == "http"
}
}
return agentMetadata, nil
}
func (c *Client) ListenWorkspaceAgent(ctx context.Context) (net.Conn, error) {
coordinateURL, err := c.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: SessionTokenKey,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.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, 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.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.Logger.Error(ctx, "workspace agent coordinate ping", slog.Error(err))
err := conn.Close(websocket.StatusGoingAway, "Ping failed")
if err != nil {
c.Logger.Error(ctx, "close workspace agent coordinate websocket", slog.Error(err))
}
cancel()
return
}
c.Logger.Debug(ctx, "got coordinate pong", slog.F("took", time.Since(start)))
cancel()
}
}
}()
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
}
// @typescript-ignore DialWorkspaceAgentOptions
type DialWorkspaceAgentOptions struct {
Logger slog.Logger
@ -413,7 +100,7 @@ type DialWorkspaceAgentOptions struct {
EnableTrafficStats bool
}
func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *DialWorkspaceAgentOptions) (*AgentConn, error) {
func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *DialWorkspaceAgentOptions) (*WorkspaceAgentConn, error) {
if options == nil {
options = &DialWorkspaceAgentOptions{}
}
@ -423,7 +110,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var connInfo WorkspaceAgentConnectionInfo
err = json.NewDecoder(res.Body).Decode(&connInfo)
@ -452,7 +139,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(coordinateURL, []*http.Cookie{{
Name: SessionTokenKey,
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
@ -475,7 +162,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
})
if isFirst {
if res != nil && res.StatusCode == http.StatusConflict {
first <- readBodyAsError(res)
first <- ReadBodyAsError(res)
return
}
isFirst = false
@ -513,7 +200,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
return nil, err
}
return &AgentConn{
return &WorkspaceAgentConn{
Conn: conn,
CloseFunc: func() {
cancelFunc()
@ -530,39 +217,12 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceAgent{}, readBodyAsError(res)
return WorkspaceAgent{}, ReadBodyAsError(res)
}
var workspaceAgent WorkspaceAgent
return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent)
}
// PostWorkspaceAgentAppHealth updates the workspace agent app health status.
func (c *Client) PostWorkspaceAgentAppHealth(ctx context.Context, req PostWorkspaceAppHealthsRequest) error {
res, err := c.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 readBodyAsError(res)
}
return nil
}
func (c *Client) PostWorkspaceAgentVersion(ctx context.Context, version string) error {
versionReq := PostWorkspaceAgentVersionRequest{Version: version}
res, err := c.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 readBodyAsError(res)
}
return nil
}
// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
// Responses are PTY output that can be rendered.
@ -583,7 +243,7 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: SessionTokenKey,
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
@ -596,114 +256,26 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec
if res == nil {
return nil, err
}
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
}
// WorkspaceAgentListeningPorts returns a list of ports that are currently being
// listened on inside the workspace agent's network namespace.
func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.UUID) (ListeningPortsResponse, error) {
func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.UUID) (WorkspaceAgentListeningPortsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/listening-ports", agentID), nil)
if err != nil {
return ListeningPortsResponse{}, err
return WorkspaceAgentListeningPortsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ListeningPortsResponse{}, readBodyAsError(res)
return WorkspaceAgentListeningPortsResponse{}, ReadBodyAsError(res)
}
var listeningPorts ListeningPortsResponse
var listeningPorts WorkspaceAgentListeningPortsResponse
return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts)
}
// Stats records the Agent's network connection statistics for use in
// user-facing metrics and debugging.
// @typescript-ignore AgentStats
type AgentStats 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"`
}
// @typescript-ignore AgentStatsResponse
type AgentStatsResponse struct {
// ReportInterval is the duration after which the agent should send stats
// again.
ReportInterval time.Duration `json:"report_interval"`
}
func (c *Client) PostAgentStats(ctx context.Context, stats *AgentStats) (AgentStatsResponse, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-stats", stats)
if err != nil {
return AgentStatsResponse{}, xerrors.Errorf("send request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AgentStatsResponse{}, readBodyAsError(res)
}
var interval AgentStatsResponse
err = json.NewDecoder(res.Body).Decode(&interval)
if err != nil {
return AgentStatsResponse{}, xerrors.Errorf("decode stats response: %w", err)
}
return interval, nil
}
// AgentReportStats begins a stat streaming connection with the Coder server.
// It is resilient to network failures and intermittent coderd issues.
func (c *Client) AgentReportStats(
ctx context.Context,
log slog.Logger,
getStats func() *AgentStats,
) (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.PostAgentStats(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
}
// GitProvider is a constant that represents the
// type of providers that are supported within Coder.
// @typescript-ignore GitProvider
@ -715,49 +287,3 @@ const (
GitProviderGitLab = "gitlab"
GitProviderBitBucket = "bitbucket"
)
type WorkspaceAgentGitAuthResponse struct {
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"url"`
}
// WorkspaceAgentGitAuth submits a URL to fetch a GIT_ASKPASS username
// and password for.
// nolint:revive
func (c *Client) WorkspaceAgentGitAuth(ctx context.Context, gitURL string, listen bool) (WorkspaceAgentGitAuthResponse, error) {
reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL)
if listen {
reqURL += "&listen"
}
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return WorkspaceAgentGitAuthResponse{}, xerrors.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceAgentGitAuthResponse{}, readBodyAsError(res)
}
var authResp WorkspaceAgentGitAuthResponse
return authResp, json.NewDecoder(res.Body).Decode(&authResp)
}
// @typescript-ignore PostWorkspaceAgentLifecycleRequest
type PostWorkspaceAgentLifecycleRequest struct {
State WorkspaceAgentLifecycle `json:"state"`
}
func (c *Client) PostWorkspaceAgentLifecycle(ctx context.Context, req PostWorkspaceAgentLifecycleRequest) error {
res, err := c.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 readBodyAsError(res)
}
return nil
}

View File

@ -15,7 +15,7 @@ import (
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/testutil"
)
@ -24,7 +24,7 @@ func TestWorkspaceAgentMetadata(t *testing.T) {
// This test ensures that the DERP map returned properly
// mutates built-in DERPs with the client access URL.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, codersdk.WorkspaceAgentMetadata{
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.Metadata{
DERPMap: &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
@ -41,8 +41,8 @@ func TestWorkspaceAgentMetadata(t *testing.T) {
}))
parsed, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.New(parsed)
metadata, err := client.WorkspaceAgentMetadata(context.Background())
client := agentsdk.New(parsed)
metadata, err := client.Metadata(context.Background())
require.NoError(t, err)
region := metadata.DERPMap.Regions[1]
require.True(t, region.EmbeddedRelay)
@ -58,17 +58,17 @@ func TestAgentReportStats(t *testing.T) {
var numReports atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
numReports.Add(1)
httpapi.Write(context.Background(), w, http.StatusOK, codersdk.AgentStatsResponse{
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.StatsResponse{
ReportInterval: 5 * time.Millisecond,
})
}))
parsed, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.New(parsed)
client := agentsdk.New(parsed)
ctx := context.Background()
closeStream, err := client.AgentReportStats(ctx, slogtest.Make(t, nil), func() *codersdk.AgentStats {
return &codersdk.AgentStats{}
closeStream, err := client.ReportStats(ctx, slogtest.Make(t, nil), func() *agentsdk.Stats {
return &agentsdk.Stats{}
})
require.NoError(t, err)
defer closeStream.Close()

View File

@ -56,9 +56,3 @@ type Healthcheck struct {
// Threshold specifies the number of consecutive failed health checks before returning "unhealthy".
Threshold int32 `json:"threshold"`
}
// @typescript-ignore PostWorkspaceAppHealthsRequest
type PostWorkspaceAppHealthsRequest struct {
// Healths is a map of the workspace app name and the health of the app.
Healths map[uuid.UUID]WorkspaceAppHealth
}

View File

@ -110,7 +110,7 @@ func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBui
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceBuild{}, readBodyAsError(res)
return WorkspaceBuild{}, ReadBodyAsError(res)
}
var workspaceBuild WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
@ -124,7 +124,7 @@ func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error {
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -147,7 +147,7 @@ func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]by
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
return io.ReadAll(res.Body)
}
@ -159,7 +159,7 @@ func (c *Client) WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx cont
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceBuild{}, readBodyAsError(res)
return WorkspaceBuild{}, ReadBodyAsError(res)
}
var workspaceBuild WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
@ -172,7 +172,7 @@ func (c *Client) WorkspaceBuildParameters(ctx context.Context, build uuid.UUID)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var params []WorkspaceBuildParameter
return params, json.NewDecoder(res.Body).Decode(&params)

View File

@ -96,7 +96,7 @@ func (c *Client) getWorkspace(ctx context.Context, id uuid.UUID, opts ...Request
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Workspace{}, readBodyAsError(res)
return Workspace{}, ReadBodyAsError(res)
}
var workspace Workspace
return workspace, json.NewDecoder(res.Body).Decode(&workspace)
@ -119,7 +119,7 @@ func (c *Client) WorkspaceBuilds(ctx context.Context, req WorkspaceBuildsRequest
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
var workspaceBuild []WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
@ -133,7 +133,7 @@ func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID,
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return WorkspaceBuild{}, readBodyAsError(res)
return WorkspaceBuild{}, ReadBodyAsError(res)
}
var workspaceBuild WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
@ -148,7 +148,7 @@ func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (<-chan Works
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
return nil, ReadBodyAsError(res)
}
nextEvent := ServerSentEventReader(ctx, res.Body)
@ -198,7 +198,7 @@ func (c *Client) UpdateWorkspace(ctx context.Context, id uuid.UUID, req UpdateWo
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -218,7 +218,7 @@ func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -238,7 +238,7 @@ func (c *Client) UpdateWorkspaceTTL(ctx context.Context, id uuid.UUID, req Updat
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -258,7 +258,7 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNotModified {
return readBodyAsError(res)
return ReadBodyAsError(res)
}
return nil
}
@ -323,7 +323,7 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (Worksp
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspacesResponse{}, readBodyAsError(res)
return WorkspacesResponse{}, ReadBodyAsError(res)
}
var wres WorkspacesResponse
@ -343,37 +343,29 @@ func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, owner string, name
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Workspace{}, readBodyAsError(res)
return Workspace{}, ReadBodyAsError(res)
}
var workspace Workspace
return workspace, json.NewDecoder(res.Body).Decode(&workspace)
}
type GetAppHostResponse struct {
// Host is the externally accessible URL for the Coder instance.
Host string `json:"host"`
type WorkspaceQuota struct {
CreditsConsumed int `json:"credits_consumed"`
Budget int `json:"budget"`
}
// GetAppHost returns the site-wide application wildcard hostname without the
// leading "*.", e.g. "apps.coder.com". Apps are accessible at:
// "<app-name>--<agent-name>--<workspace-name>--<username>.<app-host>", e.g.
// "my-app--agent--workspace--username.apps.coder.com".
//
// If the app host is not set, the response will contain an empty string.
func (c *Client) GetAppHost(ctx context.Context) (GetAppHostResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/applications/host", nil)
func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQuota, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil)
if err != nil {
return GetAppHostResponse{}, err
return WorkspaceQuota{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GetAppHostResponse{}, readBodyAsError(res)
return WorkspaceQuota{}, ReadBodyAsError(res)
}
var host GetAppHostResponse
return host, json.NewDecoder(res.Body).Decode(&host)
var quota WorkspaceQuota
return quota, json.NewDecoder(res.Body).Decode(&quota)
}
// WorkspaceNotifyChannel is the PostgreSQL NOTIFY