2022-04-11 21:06:15 +00:00
|
|
|
package coderd
|
|
|
|
|
|
|
|
import (
|
2023-03-14 14:46:47 +00:00
|
|
|
"bufio"
|
2022-06-03 09:50:10 +00:00
|
|
|
"context"
|
2022-04-11 21:06:15 +00:00
|
|
|
"database/sql"
|
|
|
|
"encoding/json"
|
2022-10-25 00:46:24 +00:00
|
|
|
"errors"
|
2023-04-01 21:36:21 +00:00
|
|
|
"flag"
|
2022-04-11 21:06:15 +00:00
|
|
|
"fmt"
|
2022-04-18 22:40:25 +00:00
|
|
|
"net"
|
2022-04-11 21:06:15 +00:00
|
|
|
"net/http"
|
2022-09-01 01:09:44 +00:00
|
|
|
"net/netip"
|
2022-10-11 15:10:02 +00:00
|
|
|
"net/url"
|
2023-03-13 09:54:53 +00:00
|
|
|
"runtime/pprof"
|
2022-04-18 22:40:25 +00:00
|
|
|
"strconv"
|
2022-09-01 01:09:44 +00:00
|
|
|
"strings"
|
2023-03-13 09:54:53 +00:00
|
|
|
"sync"
|
2023-03-23 19:09:13 +00:00
|
|
|
"sync/atomic"
|
2022-04-11 21:06:15 +00:00
|
|
|
"time"
|
|
|
|
|
2023-04-01 21:36:21 +00:00
|
|
|
"github.com/bep/debounce"
|
2023-03-30 13:24:51 +00:00
|
|
|
"github.com/go-chi/chi/v5"
|
2022-04-29 22:30:10 +00:00
|
|
|
"github.com/google/uuid"
|
2023-03-31 20:26:19 +00:00
|
|
|
"golang.org/x/exp/slices"
|
2022-08-31 15:33:50 +00:00
|
|
|
"golang.org/x/mod/semver"
|
2023-04-27 10:34:00 +00:00
|
|
|
"golang.org/x/sync/errgroup"
|
2022-04-11 21:06:15 +00:00
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"nhooyr.io/websocket"
|
2022-09-01 01:09:44 +00:00
|
|
|
"tailscale.com/tailcfg"
|
2022-04-11 21:06:15 +00:00
|
|
|
|
|
|
|
"cdr.dev/slog"
|
|
|
|
"github.com/coder/coder/coderd/database"
|
2023-02-14 14:27:06 +00:00
|
|
|
"github.com/coder/coder/coderd/database/dbauthz"
|
2022-10-25 00:46:24 +00:00
|
|
|
"github.com/coder/coder/coderd/gitauth"
|
2022-04-11 21:06:15 +00:00
|
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
|
|
"github.com/coder/coder/coderd/httpmw"
|
2022-06-10 14:46:48 +00:00
|
|
|
"github.com/coder/coder/coderd/rbac"
|
2023-04-06 01:58:54 +00:00
|
|
|
"github.com/coder/coder/coderd/util/ptr"
|
2022-04-11 21:06:15 +00:00
|
|
|
"github.com/coder/coder/codersdk"
|
2023-01-29 21:47:24 +00:00
|
|
|
"github.com/coder/coder/codersdk/agentsdk"
|
2022-09-01 01:09:44 +00:00
|
|
|
"github.com/coder/coder/tailnet"
|
2022-04-11 21:06:15 +00:00
|
|
|
)
|
|
|
|
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Summary Get workspace agent by ID
|
|
|
|
// @ID get-workspace-agent-by-id
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
|
|
|
// @Success 200 {object} codersdk.WorkspaceAgent
|
|
|
|
// @Router /workspaceagents/{workspaceagent} [get]
|
2022-05-26 03:14:08 +00:00
|
|
|
func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) {
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx := r.Context()
|
2022-04-26 01:03:54 +00:00
|
|
|
workspaceAgent := httpmw.WorkspaceAgentParam(r)
|
2023-03-21 14:10:22 +00:00
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
dbApps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
2022-06-04 20:13:37 +00:00
|
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: "Internal error fetching workspace agent applications.",
|
|
|
|
Detail: err.Error(),
|
2022-06-04 20:13:37 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-03-07 21:10:01 +00:00
|
|
|
apiAgent, err := convertWorkspaceAgent(
|
|
|
|
api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout,
|
|
|
|
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
|
|
|
|
)
|
2022-04-11 21:06:15 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: "Internal error reading workspace agent.",
|
2022-06-03 21:48:09 +00:00
|
|
|
Detail: err.Error(),
|
2022-04-11 21:06:15 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-04-12 15:17:33 +00:00
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, apiAgent)
|
2022-04-11 21:06:15 +00:00
|
|
|
}
|
|
|
|
|
2023-03-31 20:26:19 +00:00
|
|
|
// @Summary Get authorized workspace agent manifest
|
|
|
|
// @ID get-authorized-workspace-agent-manifest
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Agents
|
2023-03-31 20:26:19 +00:00
|
|
|
// @Success 200 {object} agentsdk.Manifest
|
|
|
|
// @Router /workspaceagents/me/manifest [get]
|
|
|
|
func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) {
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx := r.Context()
|
2022-04-26 01:03:54 +00:00
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
2023-03-07 21:10:01 +00:00
|
|
|
apiAgent, err := convertWorkspaceAgent(
|
|
|
|
api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout,
|
|
|
|
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
|
|
|
|
)
|
2022-04-25 18:30:39 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: "Internal error reading workspace agent.",
|
2022-06-03 21:48:09 +00:00
|
|
|
Detail: err.Error(),
|
2022-04-25 18:30:39 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
dbApps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
2022-10-24 03:35:08 +00:00
|
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-10-24 03:35:08 +00:00
|
|
|
Message: "Internal error fetching workspace agent applications.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-03-31 20:26:19 +00:00
|
|
|
|
|
|
|
metadata, err := api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error fetching workspace agent metadata.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
2022-11-04 04:45:43 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-11-04 04:45:43 +00:00
|
|
|
Message: "Internal error fetching workspace resource.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
2022-11-04 04:45:43 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-11-04 04:45:43 +00:00
|
|
|
Message: "Internal error fetching workspace build.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
|
2022-11-04 04:45:43 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-11-04 04:45:43 +00:00
|
|
|
Message: "Internal error fetching workspace.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
2022-11-04 04:45:43 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-11-04 04:45:43 +00:00
|
|
|
Message: "Internal error fetching workspace owner.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vscodeProxyURI := strings.ReplaceAll(api.AppHostname, "*",
|
|
|
|
fmt.Sprintf("%s://{{port}}--%s--%s--%s",
|
|
|
|
api.AccessURL.Scheme,
|
|
|
|
workspaceAgent.Name,
|
|
|
|
workspace.Name,
|
|
|
|
owner.Username,
|
|
|
|
))
|
2022-12-01 20:39:19 +00:00
|
|
|
if api.AccessURL.Port() != "" {
|
|
|
|
vscodeProxyURI += fmt.Sprintf(":%s", api.AccessURL.Port())
|
|
|
|
}
|
2022-06-24 15:25:01 +00:00
|
|
|
|
2023-03-31 20:26:19 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Manifest{
|
2023-03-06 19:34:00 +00:00
|
|
|
Apps: convertApps(dbApps),
|
|
|
|
DERPMap: api.DERPMap,
|
|
|
|
GitAuthConfigs: len(api.GitAuthConfigs),
|
|
|
|
EnvironmentVariables: apiAgent.EnvironmentVariables,
|
|
|
|
StartupScript: apiAgent.StartupScript,
|
|
|
|
Directory: apiAgent.Directory,
|
|
|
|
VSCodePortProxyURI: vscodeProxyURI,
|
|
|
|
MOTDFile: workspaceAgent.MOTDFile,
|
|
|
|
StartupScriptTimeout: time.Duration(apiAgent.StartupScriptTimeoutSeconds) * time.Second,
|
|
|
|
ShutdownScript: apiAgent.ShutdownScript,
|
|
|
|
ShutdownScriptTimeout: time.Duration(apiAgent.ShutdownScriptTimeoutSeconds) * time.Second,
|
2023-03-31 20:26:19 +00:00
|
|
|
Metadata: convertWorkspaceAgentMetadataDesc(metadata),
|
2022-04-26 01:03:54 +00:00
|
|
|
})
|
2022-04-25 18:30:39 +00:00
|
|
|
}
|
|
|
|
|
2023-02-07 21:35:09 +00:00
|
|
|
// @Summary Submit workspace agent startup
|
|
|
|
// @ID submit-workspace-agent-startup
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Security CoderSessionToken
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Accept json
|
|
|
|
// @Produce json
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Tags Agents
|
2023-02-07 21:35:09 +00:00
|
|
|
// @Param request body agentsdk.PostStartupRequest true "Startup request"
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Success 200
|
2023-02-07 21:35:09 +00:00
|
|
|
// @Router /workspaceagents/me/startup [post]
|
2023-01-05 14:27:10 +00:00
|
|
|
// @x-apidocgen {"skip": true}
|
2023-02-07 21:35:09 +00:00
|
|
|
func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Request) {
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx := r.Context()
|
2022-08-31 15:33:50 +00:00
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
2023-03-07 21:10:01 +00:00
|
|
|
apiAgent, err := convertWorkspaceAgent(
|
|
|
|
api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout,
|
|
|
|
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
|
|
|
|
)
|
2022-08-31 15:33:50 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-08-31 15:33:50 +00:00
|
|
|
Message: "Internal error reading workspace agent.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-07 21:35:09 +00:00
|
|
|
var req agentsdk.PostStartupRequest
|
2022-09-21 22:07:00 +00:00
|
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
2022-08-31 15:33:50 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
api.Logger.Info(ctx, "post workspace agent version", slog.F("agent_id", apiAgent.ID), slog.F("agent_version", req.Version))
|
2022-08-31 15:33:50 +00:00
|
|
|
|
|
|
|
if !semver.IsValid(req.Version) {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-08-31 15:33:50 +00:00
|
|
|
Message: "Invalid workspace agent version provided.",
|
|
|
|
Detail: fmt.Sprintf("invalid semver version: %q", req.Version),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-07 21:35:09 +00:00
|
|
|
if err := api.Database.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{
|
|
|
|
ID: apiAgent.ID,
|
|
|
|
Version: req.Version,
|
|
|
|
ExpandedDirectory: req.ExpandedDirectory,
|
2022-08-31 15:33:50 +00:00
|
|
|
}); err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-08-31 15:33:50 +00:00
|
|
|
Message: "Error setting agent version",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, nil)
|
2022-08-31 15:33:50 +00:00
|
|
|
}
|
|
|
|
|
2023-03-23 19:09:13 +00:00
|
|
|
// @Summary Patch workspace agent startup logs
|
|
|
|
// @ID patch-workspace-agent-startup-logs
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Accept json
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param request body agentsdk.PatchStartupLogs true "Startup logs"
|
|
|
|
// @Success 200 {object} codersdk.Response
|
|
|
|
// @Router /workspaceagents/me/startup-logs [patch]
|
|
|
|
// @x-apidocgen {"skip": true}
|
|
|
|
func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := r.Context()
|
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
|
|
|
|
|
|
|
var req agentsdk.PatchStartupLogs
|
|
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if len(req.Logs) == 0 {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "No logs provided.",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
createdAt := make([]time.Time, 0)
|
|
|
|
output := make([]string, 0)
|
2023-04-10 19:29:59 +00:00
|
|
|
level := make([]database.LogLevel, 0)
|
2023-03-23 19:09:13 +00:00
|
|
|
outputLength := 0
|
2023-04-27 10:34:00 +00:00
|
|
|
for _, logEntry := range req.Logs {
|
|
|
|
createdAt = append(createdAt, logEntry.CreatedAt)
|
|
|
|
output = append(output, logEntry.Output)
|
|
|
|
outputLength += len(logEntry.Output)
|
|
|
|
if logEntry.Level == "" {
|
2023-04-10 19:29:59 +00:00
|
|
|
// Default to "info" to support older agents that didn't have the level field.
|
2023-04-27 10:34:00 +00:00
|
|
|
logEntry.Level = codersdk.LogLevelInfo
|
2023-04-10 19:29:59 +00:00
|
|
|
}
|
2023-04-27 10:34:00 +00:00
|
|
|
parsedLevel := database.LogLevel(logEntry.Level)
|
2023-04-10 19:29:59 +00:00
|
|
|
if !parsedLevel.Valid() {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Invalid log level provided.",
|
2023-04-27 10:34:00 +00:00
|
|
|
Detail: fmt.Sprintf("invalid log level: %q", logEntry.Level),
|
2023-04-10 19:29:59 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
level = append(level, parsedLevel)
|
2023-03-23 19:09:13 +00:00
|
|
|
}
|
|
|
|
logs, err := api.Database.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{
|
|
|
|
AgentID: workspaceAgent.ID,
|
|
|
|
CreatedAt: createdAt,
|
|
|
|
Output: output,
|
2023-04-10 19:29:59 +00:00
|
|
|
Level: level,
|
2023-03-23 19:09:13 +00:00
|
|
|
OutputLength: int32(outputLength),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
if database.IsStartupLogsLimitError(err) {
|
|
|
|
if !workspaceAgent.StartupLogsOverflowed {
|
|
|
|
err := api.Database.UpdateWorkspaceAgentStartupLogOverflowByID(ctx, database.UpdateWorkspaceAgentStartupLogOverflowByIDParams{
|
|
|
|
ID: workspaceAgent.ID,
|
|
|
|
StartupLogsOverflowed: true,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
// We don't want to return here, because the agent will retry
|
|
|
|
// on failure and this isn't a huge deal. The overflow state
|
|
|
|
// is just a hint to the user that the logs are incomplete.
|
|
|
|
api.Logger.Warn(ctx, "failed to update workspace agent startup log overflow", slog.Error(err))
|
|
|
|
}
|
|
|
|
|
|
|
|
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to get workspace resource.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Internal error fetching workspace build job.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
api.publishWorkspaceUpdate(ctx, build.WorkspaceID)
|
|
|
|
}
|
|
|
|
|
|
|
|
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
|
|
|
|
Message: "Startup logs limit exceeded",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to upload startup logs",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if workspaceAgent.StartupLogsLength == 0 {
|
|
|
|
// If these are the first logs being appended, we publish a UI update
|
|
|
|
// to notify the UI that logs are now available.
|
|
|
|
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to get workspace resource.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Internal error fetching workspace build job.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
api.publishWorkspaceUpdate(ctx, build.WorkspaceID)
|
|
|
|
}
|
|
|
|
|
|
|
|
lowestID := logs[0].ID
|
|
|
|
// Publish by the lowest log ID inserted so the
|
|
|
|
// log stream will fetch everything from that point.
|
|
|
|
data, err := json.Marshal(agentsdk.StartupLogsNotifyMessage{
|
|
|
|
CreatedAfter: lowestID - 1,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to marshal startup logs notify message",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
err = api.Pubsub.Publish(agentsdk.StartupLogsNotifyChannel(workspaceAgent.ID), data)
|
|
|
|
if err != nil {
|
|
|
|
// We don't want to return an error to the agent here,
|
|
|
|
// otherwise it might try to reinsert the logs.
|
|
|
|
api.Logger.Warn(ctx, "failed to publish startup logs notify message", slog.Error(err))
|
|
|
|
}
|
|
|
|
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, nil)
|
|
|
|
}
|
|
|
|
|
|
|
|
// workspaceAgentStartupLogs returns the logs sent from a workspace agent
|
|
|
|
// during startup.
|
|
|
|
//
|
|
|
|
// @Summary Get startup logs by workspace agent
|
|
|
|
// @ID get-startup-logs-by-workspace-agent
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
|
|
|
// @Param before query int false "Before log id"
|
|
|
|
// @Param after query int false "After log id"
|
|
|
|
// @Param follow query bool false "Follow log stream"
|
|
|
|
// @Success 200 {array} codersdk.WorkspaceAgentStartupLog
|
|
|
|
// @Router /workspaceagents/{workspaceagent}/startup-logs [get]
|
|
|
|
func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
// This mostly copies how provisioner job logs are streamed!
|
|
|
|
var (
|
|
|
|
ctx = r.Context()
|
2023-03-23 20:02:29 +00:00
|
|
|
actor, _ = dbauthz.ActorFromContext(ctx)
|
2023-03-23 19:09:13 +00:00
|
|
|
workspaceAgent = httpmw.WorkspaceAgentParam(r)
|
|
|
|
logger = api.Logger.With(slog.F("workspace_agent_id", workspaceAgent.ID))
|
|
|
|
follow = r.URL.Query().Has("follow")
|
|
|
|
afterRaw = r.URL.Query().Get("after")
|
|
|
|
)
|
|
|
|
|
|
|
|
var after int64
|
|
|
|
// Only fetch logs created after the time provided.
|
|
|
|
if afterRaw != "" {
|
|
|
|
var err error
|
|
|
|
after, err = strconv.ParseInt(afterRaw, 10, 64)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Query param \"after\" must be an integer.",
|
|
|
|
Validations: []codersdk.ValidationError{
|
|
|
|
{Field: "after", Detail: "Must be an integer"},
|
|
|
|
},
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{
|
|
|
|
AgentID: workspaceAgent.ID,
|
|
|
|
CreatedAfter: after,
|
|
|
|
})
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
err = nil
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error fetching provisioner logs.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if logs == nil {
|
|
|
|
logs = []database.WorkspaceAgentStartupLog{}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !follow {
|
|
|
|
logger.Debug(ctx, "Finished non-follow job logs")
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspaceAgentStartupLogs(logs))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
api.WebsocketWaitMutex.Lock()
|
|
|
|
api.WebsocketWaitGroup.Add(1)
|
|
|
|
api.WebsocketWaitMutex.Unlock()
|
|
|
|
defer api.WebsocketWaitGroup.Done()
|
|
|
|
conn, err := websocket.Accept(rw, r, nil)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to accept websocket.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
go httpapi.Heartbeat(ctx, conn)
|
|
|
|
|
|
|
|
ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageText)
|
|
|
|
defer wsNetConn.Close() // Also closes conn.
|
|
|
|
|
|
|
|
// The Go stdlib JSON encoder appends a newline character after message write.
|
|
|
|
encoder := json.NewEncoder(wsNetConn)
|
|
|
|
err = encoder.Encode(convertWorkspaceAgentStartupLogs(logs))
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if workspaceAgent.LifecycleState == database.WorkspaceAgentLifecycleStateReady {
|
|
|
|
// The startup script has finished running, so we can close the connection.
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
bufferedLogs = make(chan []database.WorkspaceAgentStartupLog, 128)
|
|
|
|
endOfLogs atomic.Bool
|
|
|
|
lastSentLogID atomic.Int64
|
|
|
|
)
|
|
|
|
|
|
|
|
sendLogs := func(logs []database.WorkspaceAgentStartupLog) {
|
|
|
|
select {
|
|
|
|
case bufferedLogs <- logs:
|
|
|
|
lastSentLogID.Store(logs[len(logs)-1].ID)
|
|
|
|
default:
|
|
|
|
logger.Warn(ctx, "workspace agent startup log overflowing channel")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
closeSubscribe, err := api.Pubsub.Subscribe(
|
|
|
|
agentsdk.StartupLogsNotifyChannel(workspaceAgent.ID),
|
|
|
|
func(ctx context.Context, message []byte) {
|
|
|
|
if endOfLogs.Load() {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
jlMsg := agentsdk.StartupLogsNotifyMessage{}
|
|
|
|
err := json.Unmarshal(message, &jlMsg)
|
|
|
|
if err != nil {
|
|
|
|
logger.Warn(ctx, "invalid startup logs notify message", slog.Error(err))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if jlMsg.CreatedAfter != 0 {
|
2023-03-23 20:02:29 +00:00
|
|
|
logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(dbauthz.As(ctx, actor), database.GetWorkspaceAgentStartupLogsAfterParams{
|
2023-03-23 19:09:13 +00:00
|
|
|
AgentID: workspaceAgent.ID,
|
|
|
|
CreatedAfter: jlMsg.CreatedAfter,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
logger.Warn(ctx, "failed to get workspace agent startup logs after", slog.Error(err))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
sendLogs(logs)
|
|
|
|
}
|
|
|
|
|
|
|
|
if jlMsg.EndOfLogs {
|
|
|
|
endOfLogs.Store(true)
|
2023-03-23 20:02:29 +00:00
|
|
|
logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(dbauthz.As(ctx, actor), database.GetWorkspaceAgentStartupLogsAfterParams{
|
2023-03-23 19:09:13 +00:00
|
|
|
AgentID: workspaceAgent.ID,
|
|
|
|
CreatedAfter: lastSentLogID.Load(),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
logger.Warn(ctx, "get workspace agent startup logs after", slog.Error(err))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
sendLogs(logs)
|
|
|
|
bufferedLogs <- nil
|
|
|
|
}
|
|
|
|
},
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to subscribe to startup logs.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
defer closeSubscribe()
|
|
|
|
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
logger.Debug(context.Background(), "job logs context canceled")
|
|
|
|
return
|
|
|
|
case logs, ok := <-bufferedLogs:
|
|
|
|
// A nil log is sent when complete!
|
|
|
|
if !ok || logs == nil {
|
|
|
|
logger.Debug(context.Background(), "reached the end of published logs")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
err = encoder.Encode(convertWorkspaceAgentStartupLogs(logs))
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Summary Get listening ports for workspace agent
|
|
|
|
// @ID get-listening-ports-for-workspace-agent
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
2023-01-29 21:47:24 +00:00
|
|
|
// @Success 200 {object} codersdk.WorkspaceAgentListeningPortsResponse
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Router /workspaceagents/{workspaceagent}/listening-ports [get]
|
2022-10-06 12:38:22 +00:00
|
|
|
func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := r.Context()
|
|
|
|
workspaceAgent := httpmw.WorkspaceAgentParam(r)
|
|
|
|
|
2023-03-07 21:10:01 +00:00
|
|
|
apiAgent, err := convertWorkspaceAgent(
|
|
|
|
api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout,
|
|
|
|
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
|
|
|
|
)
|
2022-10-06 12:38:22 +00:00
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error reading workspace agent.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
|
2023-01-13 14:30:48 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-10-06 12:38:22 +00:00
|
|
|
Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-03-23 14:54:07 +00:00
|
|
|
agentConn, release, err := api.workspaceAgentCache.Acquire(workspaceAgent.ID)
|
2022-10-06 12:38:22 +00:00
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error dialing workspace agent.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
defer release()
|
|
|
|
|
|
|
|
portsResponse, err := agentConn.ListeningPorts(ctx)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error fetching listening ports.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-10-11 15:10:02 +00:00
|
|
|
// Get a list of ports that are in-use by applications.
|
|
|
|
apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
|
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
|
|
apps = []database.WorkspaceApp{}
|
|
|
|
err = nil
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error fetching workspace apps.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
appPorts := make(map[uint16]struct{}, len(apps))
|
|
|
|
for _, app := range apps {
|
|
|
|
if !app.Url.Valid || app.Url.String == "" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
u, err := url.Parse(app.Url.String)
|
|
|
|
if err != nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
port := u.Port()
|
|
|
|
if port == "" {
|
|
|
|
continue
|
|
|
|
}
|
2022-12-19 19:25:59 +00:00
|
|
|
portNum, err := strconv.ParseUint(port, 10, 16)
|
2022-10-11 15:10:02 +00:00
|
|
|
if err != nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if portNum < 1 || portNum > 65535 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
appPorts[uint16(portNum)] = struct{}{}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Filter out ports that are globally blocked, in-use by applications, or
|
|
|
|
// common non-HTTP ports such as databases, FTP, SSH, etc.
|
2023-01-29 21:47:24 +00:00
|
|
|
filteredPorts := make([]codersdk.WorkspaceAgentListeningPort, 0, len(portsResponse.Ports))
|
2022-10-11 15:10:02 +00:00
|
|
|
for _, port := range portsResponse.Ports {
|
2023-01-29 21:47:24 +00:00
|
|
|
if port.Port < codersdk.WorkspaceAgentMinimumListeningPort {
|
2022-10-11 15:10:02 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
if _, ok := appPorts[port.Port]; ok {
|
|
|
|
continue
|
|
|
|
}
|
2023-01-29 21:47:24 +00:00
|
|
|
if _, ok := codersdk.WorkspaceAgentIgnoredListeningPorts[port.Port]; ok {
|
2022-10-11 15:10:02 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
filteredPorts = append(filteredPorts, port)
|
|
|
|
}
|
|
|
|
|
|
|
|
portsResponse.Ports = filteredPorts
|
2022-10-06 12:38:22 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, portsResponse)
|
|
|
|
}
|
|
|
|
|
2023-03-23 14:54:07 +00:00
|
|
|
func (api *API) dialWorkspaceAgentTailnet(agentID uuid.UUID) (*codersdk.WorkspaceAgentConn, error) {
|
2022-09-01 01:09:44 +00:00
|
|
|
clientConn, serverConn := net.Pipe()
|
|
|
|
conn, err := tailnet.NewConn(&tailnet.Options{
|
|
|
|
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
|
2023-03-14 14:46:47 +00:00
|
|
|
DERPMap: api.DERPMap,
|
2022-10-07 13:05:56 +00:00
|
|
|
Logger: api.Logger.Named("tailnet"),
|
2022-09-01 01:09:44 +00:00
|
|
|
})
|
|
|
|
if err != nil {
|
2023-02-14 14:57:48 +00:00
|
|
|
_ = clientConn.Close()
|
|
|
|
_ = serverConn.Close()
|
2022-09-01 01:09:44 +00:00
|
|
|
return nil, xerrors.Errorf("create tailnet conn: %w", err)
|
|
|
|
}
|
2023-03-23 14:54:07 +00:00
|
|
|
ctx, cancel := context.WithCancel(api.ctx)
|
2023-03-14 14:46:47 +00:00
|
|
|
conn.SetDERPRegionDialer(func(_ context.Context, region *tailcfg.DERPRegion) net.Conn {
|
|
|
|
if !region.EmbeddedRelay {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
left, right := net.Pipe()
|
|
|
|
go func() {
|
|
|
|
defer left.Close()
|
|
|
|
defer right.Close()
|
|
|
|
brw := bufio.NewReadWriter(bufio.NewReader(right), bufio.NewWriter(right))
|
2023-03-23 14:54:07 +00:00
|
|
|
api.DERPServer.Accept(ctx, right, brw, "internal")
|
2023-03-14 14:46:47 +00:00
|
|
|
}()
|
|
|
|
return left
|
|
|
|
})
|
2022-09-01 01:09:44 +00:00
|
|
|
|
|
|
|
sendNodes, _ := tailnet.ServeCoordinator(clientConn, func(node []*tailnet.Node) error {
|
2023-02-24 16:16:29 +00:00
|
|
|
err = conn.UpdateNodes(node, true)
|
2023-01-26 22:23:35 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("update nodes: %w", err)
|
|
|
|
}
|
|
|
|
return nil
|
2022-09-01 01:09:44 +00:00
|
|
|
})
|
|
|
|
conn.SetNodeCallback(sendNodes)
|
2023-02-24 16:16:29 +00:00
|
|
|
agentConn := &codersdk.WorkspaceAgentConn{
|
|
|
|
Conn: conn,
|
|
|
|
CloseFunc: func() {
|
2023-03-23 14:54:07 +00:00
|
|
|
cancel()
|
2023-02-24 16:16:29 +00:00
|
|
|
_ = clientConn.Close()
|
|
|
|
_ = serverConn.Close()
|
|
|
|
},
|
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
go func() {
|
2022-10-17 13:43:30 +00:00
|
|
|
err := (*api.TailnetCoordinator.Load()).ServeClient(serverConn, uuid.New(), agentID)
|
2022-09-01 01:09:44 +00:00
|
|
|
if err != nil {
|
2023-03-28 01:01:25 +00:00
|
|
|
// Sometimes, we get benign closed pipe errors when the server is
|
|
|
|
// shutting down.
|
|
|
|
if api.ctx.Err() == nil {
|
|
|
|
api.Logger.Warn(ctx, "tailnet coordinator client error", slog.Error(err))
|
|
|
|
}
|
2023-02-24 16:16:29 +00:00
|
|
|
_ = agentConn.Close()
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
}()
|
2023-02-24 16:16:29 +00:00
|
|
|
if !agentConn.AwaitReachable(ctx) {
|
|
|
|
_ = agentConn.Close()
|
|
|
|
return nil, xerrors.Errorf("agent not reachable")
|
|
|
|
}
|
|
|
|
return agentConn, nil
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Summary Get connection info for workspace agent
|
|
|
|
// @ID get-connection-info-for-workspace-agent
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
|
|
|
// @Success 200 {object} codersdk.WorkspaceAgentConnectionInfo
|
|
|
|
// @Router /workspaceagents/{workspaceagent}/connection [get]
|
2022-09-01 01:09:44 +00:00
|
|
|
func (api *API) workspaceAgentConnection(rw http.ResponseWriter, r *http.Request) {
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx := r.Context()
|
2023-03-21 14:10:22 +00:00
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentConnectionInfo{
|
2022-09-01 01:09:44 +00:00
|
|
|
DERPMap: api.DERPMap,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Summary Coordinate workspace agent via Tailnet
|
|
|
|
// @Description It accepts a WebSocket connection to an agent that listens to
|
|
|
|
// @Description incoming connections and publishes node updates.
|
2023-01-13 11:27:21 +00:00
|
|
|
// @ID coordinate-workspace-agent-via-tailnet
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Tags Agents
|
|
|
|
// @Success 101
|
|
|
|
// @Router /workspaceagents/me/coordinate [get]
|
2022-09-01 01:09:44 +00:00
|
|
|
func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request) {
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx := r.Context()
|
|
|
|
|
2022-11-16 22:34:06 +00:00
|
|
|
api.WebsocketWaitMutex.Lock()
|
|
|
|
api.WebsocketWaitGroup.Add(1)
|
|
|
|
api.WebsocketWaitMutex.Unlock()
|
|
|
|
defer api.WebsocketWaitGroup.Done()
|
2022-09-01 01:09:44 +00:00
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
2022-09-21 22:07:00 +00:00
|
|
|
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
2022-09-20 00:46:29 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-09-20 00:46:29 +00:00
|
|
|
Message: "Failed to accept websocket.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
2022-09-20 00:46:29 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-09-20 00:46:29 +00:00
|
|
|
Message: "Internal error fetching workspace build job.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-01-25 21:27:36 +00:00
|
|
|
|
|
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Internal error fetching workspace.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-01-26 03:23:14 +00:00
|
|
|
owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Internal error fetching user.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-09-20 00:46:29 +00:00
|
|
|
// Ensure the resource is still valid!
|
|
|
|
// We only accept agents for resources on the latest build.
|
|
|
|
ensureLatestBuild := func() error {
|
2022-09-21 22:07:00 +00:00
|
|
|
latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, build.WorkspaceID)
|
2022-09-20 00:46:29 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if build.ID != latestBuild.ID {
|
|
|
|
return xerrors.New("build is outdated")
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
err = ensureLatestBuild()
|
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
api.Logger.Debug(ctx, "agent tried to connect from non-latest built",
|
2022-09-20 00:46:29 +00:00
|
|
|
slog.F("resource", resource),
|
|
|
|
slog.F("agent", workspaceAgent),
|
|
|
|
)
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
2022-09-20 00:46:29 +00:00
|
|
|
Message: "Agent trying to connect from non-latest build.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
|
|
|
|
conn, err := websocket.Accept(rw, r, nil)
|
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-09-01 01:09:44 +00:00
|
|
|
Message: "Failed to accept websocket.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-10-04 22:10:58 +00:00
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageBinary)
|
2022-09-20 00:46:29 +00:00
|
|
|
defer wsNetConn.Close()
|
|
|
|
|
2023-03-13 09:54:53 +00:00
|
|
|
// We use a custom heartbeat routine here instead of `httpapi.Heartbeat`
|
|
|
|
// because we want to log the agent's last ping time.
|
2023-04-06 01:58:54 +00:00
|
|
|
var lastPing atomic.Pointer[time.Time]
|
|
|
|
lastPing.Store(ptr.Ref(time.Now())) // Since the agent initiated the request, assume it's alive.
|
|
|
|
|
2023-03-13 09:54:53 +00:00
|
|
|
go pprof.Do(ctx, pprof.Labels("agent", workspaceAgent.ID.String()), func(ctx context.Context) {
|
|
|
|
// TODO(mafredri): Is this too frequent? Use separate ping disconnect timeout?
|
|
|
|
t := time.NewTicker(api.AgentConnectionUpdateFrequency)
|
|
|
|
defer t.Stop()
|
|
|
|
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-t.C:
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// We don't need a context that times out here because the ping will
|
|
|
|
// eventually go through. If the context times out, then other
|
|
|
|
// websocket read operations will receive an error, obfuscating the
|
|
|
|
// actual problem.
|
|
|
|
err := conn.Ping(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
2023-04-06 01:58:54 +00:00
|
|
|
lastPing.Store(ptr.Ref(time.Now()))
|
2023-03-13 09:54:53 +00:00
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2022-09-20 00:46:29 +00:00
|
|
|
firstConnectedAt := workspaceAgent.FirstConnectedAt
|
|
|
|
if !firstConnectedAt.Valid {
|
|
|
|
firstConnectedAt = sql.NullTime{
|
|
|
|
Time: database.Now(),
|
|
|
|
Valid: true,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
lastConnectedAt := sql.NullTime{
|
|
|
|
Time: database.Now(),
|
|
|
|
Valid: true,
|
|
|
|
}
|
|
|
|
disconnectedAt := workspaceAgent.DisconnectedAt
|
2023-02-10 18:23:02 +00:00
|
|
|
updateConnectionTimes := func(ctx context.Context) error {
|
2023-04-07 20:21:52 +00:00
|
|
|
//nolint:gocritic // We only update ourself.
|
|
|
|
err = api.Database.UpdateWorkspaceAgentConnectionByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentConnectionByIDParams{
|
2022-09-20 00:46:29 +00:00
|
|
|
ID: workspaceAgent.ID,
|
|
|
|
FirstConnectedAt: firstConnectedAt,
|
|
|
|
LastConnectedAt: lastConnectedAt,
|
|
|
|
DisconnectedAt: disconnectedAt,
|
|
|
|
UpdatedAt: database.Now(),
|
2022-11-06 21:27:09 +00:00
|
|
|
LastConnectedReplicaID: uuid.NullUUID{
|
|
|
|
UUID: api.ID,
|
|
|
|
Valid: true,
|
|
|
|
},
|
2022-09-20 00:46:29 +00:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
defer func() {
|
2023-02-10 18:23:02 +00:00
|
|
|
// If connection closed then context will be canceled, try to
|
|
|
|
// ensure our final update is sent. By waiting at most the agent
|
|
|
|
// inactive disconnect timeout we ensure that we don't block but
|
|
|
|
// also guarantee that the agent will be considered disconnected
|
|
|
|
// by normal status check.
|
2023-02-14 14:27:06 +00:00
|
|
|
//
|
|
|
|
// Use a system context as the agent has disconnected and that token
|
|
|
|
// may no longer be valid.
|
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
ctx, cancel := context.WithTimeout(dbauthz.AsSystemRestricted(api.ctx), api.AgentInactiveDisconnectTimeout)
|
2023-02-10 18:23:02 +00:00
|
|
|
defer cancel()
|
|
|
|
|
2023-03-13 09:54:53 +00:00
|
|
|
// Only update timestamp if the disconnect is new.
|
|
|
|
if !disconnectedAt.Valid {
|
|
|
|
disconnectedAt = sql.NullTime{
|
|
|
|
Time: database.Now(),
|
|
|
|
Valid: true,
|
|
|
|
}
|
2022-09-20 00:46:29 +00:00
|
|
|
}
|
2023-02-14 14:27:06 +00:00
|
|
|
err := updateConnectionTimes(ctx)
|
|
|
|
if err != nil {
|
|
|
|
// This is a bug with unit tests that cancel the app context and
|
|
|
|
// cause this error log to be generated. We should fix the unit tests
|
|
|
|
// as this is a valid log.
|
2023-02-22 22:07:26 +00:00
|
|
|
//
|
|
|
|
// The pq error occurs when the server is shutting down.
|
|
|
|
if !xerrors.Is(err, context.Canceled) && !database.IsQueryCanceledError(err) {
|
2023-02-14 14:27:06 +00:00
|
|
|
api.Logger.Error(ctx, "failed to update agent disconnect time",
|
|
|
|
slog.Error(err),
|
|
|
|
slog.F("workspace", build.WorkspaceID),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
2023-02-10 18:23:02 +00:00
|
|
|
api.publishWorkspaceUpdate(ctx, build.WorkspaceID)
|
2022-09-20 00:46:29 +00:00
|
|
|
}()
|
|
|
|
|
2023-02-10 18:23:02 +00:00
|
|
|
err = updateConnectionTimes(ctx)
|
2022-09-01 01:09:44 +00:00
|
|
|
if err != nil {
|
2022-09-22 18:26:05 +00:00
|
|
|
_ = conn.Close(websocket.StatusGoingAway, err.Error())
|
2022-09-01 01:09:44 +00:00
|
|
|
return
|
|
|
|
}
|
2022-11-07 15:25:18 +00:00
|
|
|
api.publishWorkspaceUpdate(ctx, build.WorkspaceID)
|
2022-09-20 00:46:29 +00:00
|
|
|
|
2022-10-07 13:05:56 +00:00
|
|
|
api.Logger.Info(ctx, "accepting agent", slog.F("agent", workspaceAgent))
|
2022-09-20 00:46:29 +00:00
|
|
|
|
|
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
|
|
|
|
|
|
closeChan := make(chan struct{})
|
|
|
|
go func() {
|
|
|
|
defer close(closeChan)
|
2023-01-26 03:23:14 +00:00
|
|
|
err := (*api.TailnetCoordinator.Load()).ServeAgent(wsNetConn, workspaceAgent.ID,
|
|
|
|
fmt.Sprintf("%s-%s-%s", owner.Username, workspace.Name, workspaceAgent.Name),
|
|
|
|
)
|
2022-09-20 00:46:29 +00:00
|
|
|
if err != nil {
|
2022-10-17 13:43:30 +00:00
|
|
|
api.Logger.Warn(ctx, "tailnet coordinator agent error", slog.Error(err))
|
2022-09-20 00:46:29 +00:00
|
|
|
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
ticker := time.NewTicker(api.AgentConnectionUpdateFrequency)
|
|
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-closeChan:
|
|
|
|
return
|
|
|
|
case <-ticker.C:
|
|
|
|
}
|
2023-03-13 09:54:53 +00:00
|
|
|
|
2023-04-06 01:58:54 +00:00
|
|
|
lastPing := *lastPing.Load()
|
2023-03-13 09:54:53 +00:00
|
|
|
|
|
|
|
var connectionStatusChanged bool
|
|
|
|
if time.Since(lastPing) > api.AgentInactiveDisconnectTimeout {
|
|
|
|
if !disconnectedAt.Valid {
|
|
|
|
connectionStatusChanged = true
|
|
|
|
disconnectedAt = sql.NullTime{
|
|
|
|
Time: database.Now(),
|
|
|
|
Valid: true,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
connectionStatusChanged = disconnectedAt.Valid
|
|
|
|
// TODO(mafredri): Should we update it here or allow lastConnectedAt to shadow it?
|
|
|
|
disconnectedAt = sql.NullTime{}
|
|
|
|
lastConnectedAt = sql.NullTime{
|
|
|
|
Time: database.Now(),
|
|
|
|
Valid: true,
|
|
|
|
}
|
2022-09-20 00:46:29 +00:00
|
|
|
}
|
2023-02-10 18:23:02 +00:00
|
|
|
err = updateConnectionTimes(ctx)
|
2022-09-20 00:46:29 +00:00
|
|
|
if err != nil {
|
2022-09-22 18:26:05 +00:00
|
|
|
_ = conn.Close(websocket.StatusGoingAway, err.Error())
|
2022-09-20 00:46:29 +00:00
|
|
|
return
|
|
|
|
}
|
2023-03-13 09:54:53 +00:00
|
|
|
if connectionStatusChanged {
|
|
|
|
api.publishWorkspaceUpdate(ctx, build.WorkspaceID)
|
|
|
|
}
|
2022-09-20 00:46:29 +00:00
|
|
|
err := ensureLatestBuild()
|
|
|
|
if err != nil {
|
|
|
|
// Disconnect agents that are no longer valid.
|
|
|
|
_ = conn.Close(websocket.StatusGoingAway, "")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// workspaceAgentClientCoordinate accepts a WebSocket that reads node network updates.
|
|
|
|
// After accept a PubSub starts listening for new connection node updates
|
|
|
|
// which are written to the WebSocket.
|
2023-01-13 11:27:21 +00:00
|
|
|
//
|
|
|
|
// @Summary Coordinate workspace agent
|
|
|
|
// @ID coordinate-workspace-agent
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
|
|
|
// @Success 101
|
|
|
|
// @Router /workspaceagents/{workspaceagent}/coordinate [get]
|
2022-09-01 01:09:44 +00:00
|
|
|
func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.Request) {
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx := r.Context()
|
|
|
|
|
2023-04-17 19:57:21 +00:00
|
|
|
// This route accepts user API key auth and workspace proxy auth. The moon actor has
|
|
|
|
// full permissions so should be able to pass this authz check.
|
2022-09-01 01:09:44 +00:00
|
|
|
workspace := httpmw.WorkspaceParam(r)
|
|
|
|
if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) {
|
|
|
|
httpapi.ResourceNotFound(rw)
|
|
|
|
return
|
|
|
|
}
|
2023-04-17 19:57:21 +00:00
|
|
|
|
2022-09-22 15:14:22 +00:00
|
|
|
// This is used by Enterprise code to control the functionality of this route.
|
|
|
|
override := api.WorkspaceClientCoordinateOverride.Load()
|
2022-09-22 16:03:49 +00:00
|
|
|
if override != nil {
|
|
|
|
overrideFunc := *override
|
|
|
|
if overrideFunc != nil && overrideFunc(rw) {
|
|
|
|
return
|
|
|
|
}
|
2022-09-22 15:14:22 +00:00
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
|
2022-11-16 22:34:06 +00:00
|
|
|
api.WebsocketWaitMutex.Lock()
|
|
|
|
api.WebsocketWaitGroup.Add(1)
|
|
|
|
api.WebsocketWaitMutex.Unlock()
|
|
|
|
defer api.WebsocketWaitGroup.Done()
|
2022-09-01 01:09:44 +00:00
|
|
|
workspaceAgent := httpmw.WorkspaceAgentParam(r)
|
|
|
|
|
|
|
|
conn, err := websocket.Accept(rw, r, nil)
|
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-09-01 01:09:44 +00:00
|
|
|
Message: "Failed to accept websocket.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-02-14 14:42:55 +00:00
|
|
|
ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageBinary)
|
|
|
|
defer wsNetConn.Close()
|
|
|
|
|
2022-10-04 22:10:58 +00:00
|
|
|
go httpapi.Heartbeat(ctx, conn)
|
|
|
|
|
2022-09-01 01:09:44 +00:00
|
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
2023-02-14 14:42:55 +00:00
|
|
|
err = (*api.TailnetCoordinator.Load()).ServeClient(wsNetConn, uuid.New(), workspaceAgent.ID)
|
2022-09-01 01:09:44 +00:00
|
|
|
if err != nil {
|
|
|
|
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-04 20:13:37 +00:00
|
|
|
func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp {
|
|
|
|
apps := make([]codersdk.WorkspaceApp, 0)
|
|
|
|
for _, dbApp := range dbApps {
|
|
|
|
apps = append(apps, codersdk.WorkspaceApp{
|
2022-10-14 16:46:38 +00:00
|
|
|
ID: dbApp.ID,
|
2022-12-14 21:54:18 +00:00
|
|
|
URL: dbApp.Url.String,
|
|
|
|
External: dbApp.External,
|
2022-10-28 17:41:31 +00:00
|
|
|
Slug: dbApp.Slug,
|
|
|
|
DisplayName: dbApp.DisplayName,
|
2022-10-14 16:46:38 +00:00
|
|
|
Command: dbApp.Command.String,
|
|
|
|
Icon: dbApp.Icon,
|
|
|
|
Subdomain: dbApp.Subdomain,
|
|
|
|
SharingLevel: codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel),
|
2022-09-23 19:51:04 +00:00
|
|
|
Healthcheck: codersdk.Healthcheck{
|
|
|
|
URL: dbApp.HealthcheckUrl,
|
|
|
|
Interval: dbApp.HealthcheckInterval,
|
|
|
|
Threshold: dbApp.HealthcheckThreshold,
|
|
|
|
},
|
|
|
|
Health: codersdk.WorkspaceAppHealth(dbApp.Health),
|
2022-06-04 20:13:37 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
return apps
|
|
|
|
}
|
|
|
|
|
2023-03-31 20:26:19 +00:00
|
|
|
func convertWorkspaceAgentMetadataDesc(mds []database.WorkspaceAgentMetadatum) []codersdk.WorkspaceAgentMetadataDescription {
|
|
|
|
metadata := make([]codersdk.WorkspaceAgentMetadataDescription, 0)
|
|
|
|
for _, datum := range mds {
|
|
|
|
metadata = append(metadata, codersdk.WorkspaceAgentMetadataDescription{
|
|
|
|
DisplayName: datum.DisplayName,
|
|
|
|
Key: datum.Key,
|
|
|
|
Script: datum.Script,
|
|
|
|
Interval: datum.Interval,
|
|
|
|
Timeout: datum.Timeout,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return metadata
|
|
|
|
}
|
|
|
|
|
2022-11-16 10:53:02 +00:00
|
|
|
func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration, agentFallbackTroubleshootingURL string) (codersdk.WorkspaceAgent, error) {
|
2022-04-11 21:06:15 +00:00
|
|
|
var envs map[string]string
|
|
|
|
if dbAgent.EnvironmentVariables.Valid {
|
|
|
|
err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs)
|
|
|
|
if err != nil {
|
2022-09-01 01:09:44 +00:00
|
|
|
return codersdk.WorkspaceAgent{}, xerrors.Errorf("unmarshal env vars: %w", err)
|
2022-04-11 21:06:15 +00:00
|
|
|
}
|
|
|
|
}
|
2022-11-16 10:53:02 +00:00
|
|
|
troubleshootingURL := agentFallbackTroubleshootingURL
|
|
|
|
if dbAgent.TroubleshootingURL != "" {
|
|
|
|
troubleshootingURL = dbAgent.TroubleshootingURL
|
|
|
|
}
|
2022-04-26 01:03:54 +00:00
|
|
|
workspaceAgent := codersdk.WorkspaceAgent{
|
2023-03-06 19:34:00 +00:00
|
|
|
ID: dbAgent.ID,
|
|
|
|
CreatedAt: dbAgent.CreatedAt,
|
|
|
|
UpdatedAt: dbAgent.UpdatedAt,
|
|
|
|
ResourceID: dbAgent.ResourceID,
|
|
|
|
InstanceID: dbAgent.AuthInstanceID.String,
|
|
|
|
Name: dbAgent.Name,
|
|
|
|
Architecture: dbAgent.Architecture,
|
|
|
|
OperatingSystem: dbAgent.OperatingSystem,
|
|
|
|
StartupScript: dbAgent.StartupScript.String,
|
2023-03-23 19:09:13 +00:00
|
|
|
StartupLogsLength: dbAgent.StartupLogsLength,
|
|
|
|
StartupLogsOverflowed: dbAgent.StartupLogsOverflowed,
|
2023-03-06 19:34:00 +00:00
|
|
|
Version: dbAgent.Version,
|
|
|
|
EnvironmentVariables: envs,
|
|
|
|
Directory: dbAgent.Directory,
|
|
|
|
ExpandedDirectory: dbAgent.ExpandedDirectory,
|
|
|
|
Apps: apps,
|
|
|
|
ConnectionTimeoutSeconds: dbAgent.ConnectionTimeoutSeconds,
|
|
|
|
TroubleshootingURL: troubleshootingURL,
|
|
|
|
LifecycleState: codersdk.WorkspaceAgentLifecycle(dbAgent.LifecycleState),
|
|
|
|
LoginBeforeReady: dbAgent.LoginBeforeReady,
|
|
|
|
StartupScriptTimeoutSeconds: dbAgent.StartupScriptTimeoutSeconds,
|
|
|
|
ShutdownScript: dbAgent.ShutdownScript.String,
|
|
|
|
ShutdownScriptTimeoutSeconds: dbAgent.ShutdownScriptTimeoutSeconds,
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
node := coordinator.Node(dbAgent.ID)
|
|
|
|
if node != nil {
|
|
|
|
workspaceAgent.DERPLatency = map[string]codersdk.DERPRegion{}
|
|
|
|
for rawRegion, latency := range node.DERPLatency {
|
|
|
|
regionParts := strings.SplitN(rawRegion, "-", 2)
|
|
|
|
regionID, err := strconv.Atoi(regionParts[0])
|
|
|
|
if err != nil {
|
|
|
|
return codersdk.WorkspaceAgent{}, xerrors.Errorf("convert derp region id %q: %w", rawRegion, err)
|
|
|
|
}
|
|
|
|
region, found := derpMap.Regions[regionID]
|
|
|
|
if !found {
|
2022-09-01 18:43:52 +00:00
|
|
|
// It's possible that a workspace agent is using an old DERPMap
|
|
|
|
// and reports regions that do not exist. If that's the case,
|
|
|
|
// report the region as unknown!
|
|
|
|
region = &tailcfg.DERPRegion{
|
|
|
|
RegionID: regionID,
|
|
|
|
RegionName: fmt.Sprintf("Unnamed %d", regionID),
|
|
|
|
}
|
2022-09-01 01:09:44 +00:00
|
|
|
}
|
|
|
|
workspaceAgent.DERPLatency[region.RegionName] = codersdk.DERPRegion{
|
|
|
|
Preferred: node.PreferredDERP == regionID,
|
|
|
|
LatencyMilliseconds: latency * 1000,
|
|
|
|
}
|
|
|
|
}
|
2022-06-24 15:25:01 +00:00
|
|
|
}
|
|
|
|
|
2023-03-30 13:24:51 +00:00
|
|
|
status := dbAgent.Status(agentInactiveDisconnectTimeout)
|
|
|
|
workspaceAgent.Status = codersdk.WorkspaceAgentStatus(status.Status)
|
|
|
|
workspaceAgent.FirstConnectedAt = status.FirstConnectedAt
|
|
|
|
workspaceAgent.LastConnectedAt = status.LastConnectedAt
|
|
|
|
workspaceAgent.DisconnectedAt = status.DisconnectedAt
|
2022-04-11 21:06:15 +00:00
|
|
|
|
2022-04-26 01:03:54 +00:00
|
|
|
return workspaceAgent, nil
|
2022-04-11 21:06:15 +00:00
|
|
|
}
|
2022-10-04 22:10:58 +00:00
|
|
|
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Summary Submit workspace agent stats
|
2023-01-13 11:27:21 +00:00
|
|
|
// @ID submit-workspace-agent-stats
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Security CoderSessionToken
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Accept json
|
2023-01-13 15:47:38 +00:00
|
|
|
// @Produce json
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Tags Agents
|
2023-01-29 21:47:24 +00:00
|
|
|
// @Param request body agentsdk.Stats true "Stats request"
|
|
|
|
// @Success 200 {object} agentsdk.StatsResponse
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Router /workspaceagents/me/report-stats [post]
|
2023-04-06 01:58:54 +00:00
|
|
|
// @x-apidocgen {"skip": true}
|
2022-09-01 19:58:23 +00:00
|
|
|
func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Request) {
|
2022-09-21 22:07:00 +00:00
|
|
|
ctx := r.Context()
|
|
|
|
|
2022-09-01 19:58:23 +00:00
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
2022-11-18 22:46:53 +00:00
|
|
|
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
2022-09-01 19:58:23 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-11-18 22:46:53 +00:00
|
|
|
Message: "Failed to get workspace.",
|
2022-09-01 19:58:23 +00:00
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-01-29 21:47:24 +00:00
|
|
|
var req agentsdk.Stats
|
2022-11-18 22:46:53 +00:00
|
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-03-07 14:25:04 +00:00
|
|
|
// An empty stat means it's just looking for the report interval.
|
|
|
|
if req.ConnectionsByProto == nil {
|
2023-01-29 21:47:24 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.StatsResponse{
|
2022-11-18 22:46:53 +00:00
|
|
|
ReportInterval: api.AgentStatsRefreshInterval,
|
2022-09-01 19:58:23 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-12-13 07:03:03 +00:00
|
|
|
api.Logger.Debug(ctx, "read stats report",
|
|
|
|
slog.F("interval", api.AgentStatsRefreshInterval),
|
|
|
|
slog.F("agent", workspaceAgent.ID),
|
|
|
|
slog.F("workspace", workspace.ID),
|
|
|
|
slog.F("payload", req),
|
|
|
|
)
|
|
|
|
|
2023-03-07 14:25:04 +00:00
|
|
|
if req.ConnectionCount > 0 {
|
|
|
|
activityBumpWorkspace(ctx, api.Logger.Named("activity_bump"), api.Database, workspace.ID)
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
|
2023-02-28 19:33:33 +00:00
|
|
|
payload, err := json.Marshal(req.ConnectionsByProto)
|
2022-12-13 07:03:03 +00:00
|
|
|
if err != nil {
|
2023-02-28 19:33:33 +00:00
|
|
|
api.Logger.Error(ctx, "marshal agent connections by proto", slog.F("workspace_agent", workspaceAgent.ID), slog.Error(err))
|
2022-12-13 07:03:03 +00:00
|
|
|
payload = json.RawMessage("{}")
|
|
|
|
}
|
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
now := database.Now()
|
|
|
|
|
2023-04-27 10:34:00 +00:00
|
|
|
var errGroup errgroup.Group
|
|
|
|
errGroup.Go(func() error {
|
|
|
|
_, err = api.Database.InsertWorkspaceAgentStat(ctx, database.InsertWorkspaceAgentStatParams{
|
|
|
|
ID: uuid.New(),
|
|
|
|
CreatedAt: now,
|
|
|
|
AgentID: workspaceAgent.ID,
|
|
|
|
WorkspaceID: workspace.ID,
|
|
|
|
UserID: workspace.OwnerID,
|
|
|
|
TemplateID: workspace.TemplateID,
|
|
|
|
ConnectionsByProto: payload,
|
|
|
|
ConnectionCount: req.ConnectionCount,
|
|
|
|
RxPackets: req.RxPackets,
|
|
|
|
RxBytes: req.RxBytes,
|
|
|
|
TxPackets: req.TxPackets,
|
|
|
|
TxBytes: req.TxBytes,
|
|
|
|
SessionCountVSCode: req.SessionCountVSCode,
|
|
|
|
SessionCountJetBrains: req.SessionCountJetBrains,
|
|
|
|
SessionCountReconnectingPTY: req.SessionCountReconnectingPTY,
|
|
|
|
SessionCountSSH: req.SessionCountSSH,
|
|
|
|
ConnectionMedianLatencyMS: req.ConnectionMedianLatencyMS,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("can't insert workspace agent stat: %w", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
errGroup.Go(func() error {
|
|
|
|
err := api.Database.UpdateWorkspaceLastUsedAt(ctx, database.UpdateWorkspaceLastUsedAtParams{
|
2023-03-07 14:25:04 +00:00
|
|
|
ID: workspace.ID,
|
|
|
|
LastUsedAt: now,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2023-04-27 10:34:00 +00:00
|
|
|
return xerrors.Errorf("can't update workspace LastUsedAt: %w", err)
|
2023-03-07 14:25:04 +00:00
|
|
|
}
|
2023-04-27 10:34:00 +00:00
|
|
|
return nil
|
|
|
|
})
|
|
|
|
if api.Options.UpdateAgentMetrics != nil {
|
|
|
|
errGroup.Go(func() error {
|
|
|
|
user, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("can't get user: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
api.Options.UpdateAgentMetrics(ctx, user.Username, workspace.Name, workspaceAgent.Name, req.Metrics)
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|
|
|
|
err = errGroup.Wait()
|
|
|
|
if err != nil {
|
|
|
|
httpapi.InternalServerError(rw, err)
|
|
|
|
return
|
2022-11-18 22:46:53 +00:00
|
|
|
}
|
|
|
|
|
2023-01-29 21:47:24 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.StatsResponse{
|
2022-11-18 22:46:53 +00:00
|
|
|
ReportInterval: api.AgentStatsRefreshInterval,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-03-31 20:26:19 +00:00
|
|
|
// @Summary Submit workspace agent metadata
|
|
|
|
// @ID submit-workspace-agent-metadata
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Accept json
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param request body agentsdk.PostMetadataRequest true "Workspace agent metadata request"
|
|
|
|
// @Param key path string true "metadata key" format(string)
|
|
|
|
// @Success 204 "Success"
|
|
|
|
// @Router /workspaceagents/me/metadata/{key} [post]
|
|
|
|
// @x-apidocgen {"skip": true}
|
|
|
|
func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
var req agentsdk.PostMetadataRequest
|
|
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
|
|
|
|
|
|
|
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to get workspace.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
key := chi.URLParam(r, "key")
|
|
|
|
|
|
|
|
const (
|
|
|
|
maxValueLen = 32 << 10
|
|
|
|
maxErrorLen = maxValueLen
|
|
|
|
)
|
|
|
|
|
|
|
|
metadataError := req.Error
|
|
|
|
|
|
|
|
// We overwrite the error if the provided payload is too long.
|
|
|
|
if len(req.Value) > maxValueLen {
|
|
|
|
metadataError = fmt.Sprintf("value of %d bytes exceeded %d bytes", len(req.Value), maxValueLen)
|
|
|
|
req.Value = req.Value[:maxValueLen]
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(req.Error) > maxErrorLen {
|
|
|
|
metadataError = fmt.Sprintf("error of %d bytes exceeded %d bytes", len(req.Error), maxErrorLen)
|
|
|
|
req.Error = req.Error[:maxErrorLen]
|
|
|
|
}
|
|
|
|
|
|
|
|
datum := database.UpdateWorkspaceAgentMetadataParams{
|
|
|
|
WorkspaceAgentID: workspaceAgent.ID,
|
|
|
|
// We don't want a misconfigured agent to fill the database.
|
|
|
|
Key: key,
|
|
|
|
Value: req.Value,
|
|
|
|
Error: metadataError,
|
|
|
|
// We ignore the CollectedAt from the agent to avoid bugs caused by
|
|
|
|
// clock skew.
|
|
|
|
CollectedAt: time.Now(),
|
|
|
|
}
|
|
|
|
|
|
|
|
err = api.Database.UpdateWorkspaceAgentMetadata(ctx, datum)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.InternalServerError(rw, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
api.Logger.Debug(
|
|
|
|
ctx, "accepted metadata report",
|
|
|
|
slog.F("agent", workspaceAgent.ID),
|
|
|
|
slog.F("workspace", workspace.ID),
|
|
|
|
slog.F("collected_at", datum.CollectedAt),
|
|
|
|
slog.F("key", datum.Key),
|
|
|
|
)
|
|
|
|
|
|
|
|
err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), []byte(datum.Key))
|
|
|
|
if err != nil {
|
|
|
|
httpapi.InternalServerError(rw, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Watch for workspace agent metadata updates
|
|
|
|
// @ID watch-for-workspace-agent-metadata-updates
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Tags Agents
|
|
|
|
// @Success 200 "Success"
|
|
|
|
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
|
|
|
// @Router /workspaceagents/{workspaceagent}/watch-metadata [get]
|
|
|
|
// @x-apidocgen {"skip": true}
|
|
|
|
func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
var (
|
|
|
|
ctx = r.Context()
|
|
|
|
workspaceAgent = httpmw.WorkspaceAgentParam(r)
|
|
|
|
)
|
|
|
|
|
|
|
|
sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error setting up server-sent events.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// Prevent handler from returning until the sender is closed.
|
|
|
|
defer func() {
|
|
|
|
<-senderClosed
|
|
|
|
}()
|
|
|
|
|
|
|
|
const refreshInterval = time.Second * 5
|
|
|
|
refreshTicker := time.NewTicker(refreshInterval)
|
|
|
|
defer refreshTicker.Stop()
|
|
|
|
|
|
|
|
var (
|
|
|
|
lastDBMetaMu sync.Mutex
|
|
|
|
lastDBMeta []database.WorkspaceAgentMetadatum
|
|
|
|
)
|
|
|
|
|
|
|
|
sendMetadata := func(pull bool) {
|
|
|
|
lastDBMetaMu.Lock()
|
|
|
|
defer lastDBMetaMu.Unlock()
|
|
|
|
|
|
|
|
var err error
|
|
|
|
if pull {
|
|
|
|
// We always use the original Request context because it contains
|
|
|
|
// the RBAC actor.
|
|
|
|
lastDBMeta, err = api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID)
|
|
|
|
if err != nil {
|
|
|
|
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
|
|
|
Type: codersdk.ServerSentEventTypeError,
|
|
|
|
Data: codersdk.Response{
|
|
|
|
Message: "Internal error getting metadata.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
},
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
slices.SortFunc(lastDBMeta, func(i, j database.WorkspaceAgentMetadatum) bool {
|
|
|
|
return i.Key < j.Key
|
|
|
|
})
|
|
|
|
|
|
|
|
// Avoid sending refresh if the client is about to get a
|
|
|
|
// fresh update.
|
|
|
|
refreshTicker.Reset(refreshInterval)
|
|
|
|
}
|
|
|
|
|
|
|
|
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
|
|
|
Type: codersdk.ServerSentEventTypeData,
|
|
|
|
Data: convertWorkspaceAgentMetadata(lastDBMeta),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send initial metadata.
|
|
|
|
sendMetadata(true)
|
|
|
|
|
2023-04-01 21:36:21 +00:00
|
|
|
// We debounce metadata updates to avoid overloading the frontend when
|
|
|
|
// an agent is sending a lot of updates.
|
|
|
|
pubsubDebounce := debounce.New(time.Second)
|
|
|
|
if flag.Lookup("test.v") != nil {
|
|
|
|
pubsubDebounce = debounce.New(time.Millisecond * 100)
|
|
|
|
}
|
|
|
|
|
2023-03-31 20:26:19 +00:00
|
|
|
// Send metadata on updates.
|
|
|
|
cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, _ []byte) {
|
2023-04-01 21:36:21 +00:00
|
|
|
pubsubDebounce(func() {
|
|
|
|
sendMetadata(true)
|
|
|
|
})
|
2023-03-31 20:26:19 +00:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
httpapi.InternalServerError(rw, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
defer cancelSub()
|
|
|
|
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-senderClosed:
|
|
|
|
return
|
|
|
|
case <-refreshTicker.C:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
// Avoid spamming the DB with reads we know there are no updates. We want
|
|
|
|
// to continue sending updates to the frontend so that "Result.Age"
|
|
|
|
// is always accurate. This way, the frontend doesn't need to
|
|
|
|
// sync its own clock with the backend.
|
|
|
|
sendMetadata(false)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []codersdk.WorkspaceAgentMetadata {
|
|
|
|
// An empty array is easier for clients to handle than a null.
|
|
|
|
result := []codersdk.WorkspaceAgentMetadata{}
|
|
|
|
for _, datum := range db {
|
|
|
|
result = append(result, codersdk.WorkspaceAgentMetadata{
|
|
|
|
Result: codersdk.WorkspaceAgentMetadataResult{
|
|
|
|
Value: datum.Value,
|
|
|
|
Error: datum.Error,
|
|
|
|
CollectedAt: datum.CollectedAt,
|
|
|
|
Age: int64(time.Since(datum.CollectedAt).Seconds()),
|
|
|
|
},
|
|
|
|
Description: codersdk.WorkspaceAgentMetadataDescription{
|
|
|
|
DisplayName: datum.DisplayName,
|
|
|
|
Key: datum.Key,
|
|
|
|
Script: datum.Script,
|
|
|
|
Interval: datum.Interval,
|
|
|
|
Timeout: datum.Timeout,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
func watchWorkspaceAgentMetadataChannel(id uuid.UUID) string {
|
|
|
|
return "workspace_agent_metadata:" + id.String()
|
|
|
|
}
|
|
|
|
|
2023-01-24 12:24:27 +00:00
|
|
|
// @Summary Submit workspace agent lifecycle state
|
|
|
|
// @ID submit-workspace-agent-lifecycle-state
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Accept json
|
|
|
|
// @Tags Agents
|
2023-01-29 21:47:24 +00:00
|
|
|
// @Param request body agentsdk.PostLifecycleRequest true "Workspace agent lifecycle request"
|
2023-01-24 12:24:27 +00:00
|
|
|
// @Success 204 "Success"
|
|
|
|
// @Router /workspaceagents/me/report-lifecycle [post]
|
|
|
|
// @x-apidocgen {"skip": true}
|
|
|
|
func (api *API) workspaceAgentReportLifecycle(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
|
|
|
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to get workspace.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-01-29 21:47:24 +00:00
|
|
|
var req agentsdk.PostLifecycleRequest
|
2023-01-24 12:24:27 +00:00
|
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
api.Logger.Debug(ctx, "workspace agent state report",
|
|
|
|
slog.F("agent", workspaceAgent.ID),
|
|
|
|
slog.F("workspace", workspace.ID),
|
|
|
|
slog.F("payload", req),
|
|
|
|
)
|
|
|
|
|
|
|
|
lifecycleState := database.WorkspaceAgentLifecycleState(req.State)
|
|
|
|
if !lifecycleState.Valid() {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Invalid lifecycle state.",
|
|
|
|
Detail: fmt.Sprintf("Invalid lifecycle state %q, must be be one of %q.", req.State, database.AllWorkspaceAgentLifecycleStateValues()),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
err = api.Database.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
|
|
|
|
ID: workspaceAgent.ID,
|
|
|
|
LifecycleState: lifecycleState,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
httpapi.InternalServerError(rw, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
api.publishWorkspaceUpdate(ctx, workspace.ID)
|
|
|
|
|
|
|
|
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
|
|
|
}
|
|
|
|
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Summary Submit workspace agent application health
|
|
|
|
// @ID submit-workspace-agent-application-health
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Security CoderSessionToken
|
2023-01-13 11:27:21 +00:00
|
|
|
// @Accept json
|
2023-01-13 15:47:38 +00:00
|
|
|
// @Produce json
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Tags Agents
|
2023-01-29 21:47:24 +00:00
|
|
|
// @Param request body agentsdk.PostAppHealthsRequest true "Application health request"
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Success 200
|
|
|
|
// @Router /workspaceagents/me/app-health [post]
|
2022-09-23 19:51:04 +00:00
|
|
|
func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request) {
|
2022-11-18 22:46:53 +00:00
|
|
|
ctx := r.Context()
|
2022-09-23 19:51:04 +00:00
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
2023-01-29 21:47:24 +00:00
|
|
|
var req agentsdk.PostAppHealthsRequest
|
2022-11-18 22:46:53 +00:00
|
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
2022-09-23 19:51:04 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if req.Healths == nil || len(req.Healths) == 0 {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-09-23 19:51:04 +00:00
|
|
|
Message: "Health field is empty",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
2022-09-23 19:51:04 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-09-23 19:51:04 +00:00
|
|
|
Message: "Error getting agent apps",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var newApps []database.WorkspaceApp
|
2022-11-07 23:35:01 +00:00
|
|
|
for id, newHealth := range req.Healths {
|
2022-09-23 19:51:04 +00:00
|
|
|
old := func() *database.WorkspaceApp {
|
|
|
|
for _, app := range apps {
|
2022-11-07 23:35:01 +00:00
|
|
|
if app.ID == id {
|
2022-09-23 19:51:04 +00:00
|
|
|
return &app
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}()
|
|
|
|
if old == nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
2022-09-23 19:51:04 +00:00
|
|
|
Message: "Error setting workspace app health",
|
2022-11-07 23:35:01 +00:00
|
|
|
Detail: xerrors.Errorf("workspace app name %s not found", id).Error(),
|
2022-09-23 19:51:04 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if old.HealthcheckUrl == "" {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
2022-09-23 19:51:04 +00:00
|
|
|
Message: "Error setting workspace app health",
|
2022-11-07 23:35:01 +00:00
|
|
|
Detail: xerrors.Errorf("health checking is disabled for workspace app %s", id).Error(),
|
2022-09-23 19:51:04 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
switch newHealth {
|
|
|
|
case codersdk.WorkspaceAppHealthInitializing:
|
|
|
|
case codersdk.WorkspaceAppHealthHealthy:
|
|
|
|
case codersdk.WorkspaceAppHealthUnhealthy:
|
|
|
|
default:
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-09-23 19:51:04 +00:00
|
|
|
Message: "Error setting workspace app health",
|
|
|
|
Detail: xerrors.Errorf("workspace app health %s is not a valid value", newHealth).Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// don't save if the value hasn't changed
|
|
|
|
if old.Health == database.WorkspaceAppHealth(newHealth) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
old.Health = database.WorkspaceAppHealth(newHealth)
|
|
|
|
|
|
|
|
newApps = append(newApps, *old)
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, app := range newApps {
|
2022-11-18 22:46:53 +00:00
|
|
|
err = api.Database.UpdateWorkspaceAppHealthByID(ctx, database.UpdateWorkspaceAppHealthByIDParams{
|
2022-09-23 19:51:04 +00:00
|
|
|
ID: app.ID,
|
|
|
|
Health: app.Health,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-09-23 19:51:04 +00:00
|
|
|
Message: "Error setting workspace app health",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
2022-11-07 15:25:18 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-11-07 15:25:18 +00:00
|
|
|
Message: "Internal error fetching workspace resource.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
job, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
2022-11-07 15:25:18 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-11-07 15:25:18 +00:00
|
|
|
Message: "Internal error fetching workspace build.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, job.WorkspaceID)
|
2022-11-07 15:25:18 +00:00
|
|
|
if err != nil {
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-11-07 15:25:18 +00:00
|
|
|
Message: "Internal error fetching workspace.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-11-18 22:46:53 +00:00
|
|
|
api.publishWorkspaceUpdate(ctx, workspace.ID)
|
2022-11-07 15:25:18 +00:00
|
|
|
|
2022-11-18 22:46:53 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, nil)
|
2022-09-23 19:51:04 +00:00
|
|
|
}
|
|
|
|
|
2023-01-05 14:27:10 +00:00
|
|
|
// workspaceAgentsGitAuth returns a username and password for use
|
2022-10-25 00:46:24 +00:00
|
|
|
// with GIT_ASKPASS.
|
2023-01-05 14:27:10 +00:00
|
|
|
//
|
|
|
|
// @Summary Get workspace agent Git auth
|
|
|
|
// @ID get-workspace-agent-git-auth
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Agents
|
|
|
|
// @Param url query string true "Git URL" format(uri)
|
|
|
|
// @Param listen query bool false "Wait for a new token to be issued"
|
2023-01-29 21:47:24 +00:00
|
|
|
// @Success 200 {object} agentsdk.GitAuthResponse
|
2023-01-05 14:27:10 +00:00
|
|
|
// @Router /workspaceagents/me/gitauth [get]
|
2022-10-25 00:46:24 +00:00
|
|
|
func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := r.Context()
|
|
|
|
gitURL := r.URL.Query().Get("url")
|
|
|
|
if gitURL == "" {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Missing 'url' query parameter!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// listen determines if the request will wait for a
|
|
|
|
// new token to be issued!
|
|
|
|
listen := r.URL.Query().Has("listen")
|
|
|
|
|
|
|
|
var gitAuthConfig *gitauth.Config
|
|
|
|
for _, gitAuth := range api.GitAuthConfigs {
|
|
|
|
matches := gitAuth.Regex.MatchString(gitURL)
|
|
|
|
if !matches {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
gitAuthConfig = gitAuth
|
|
|
|
}
|
|
|
|
if gitAuthConfig == nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
|
|
Message: fmt.Sprintf("No git provider found for URL %q", gitURL),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
workspaceAgent := httpmw.WorkspaceAgent(r)
|
|
|
|
// We must get the workspace to get the owner ID!
|
|
|
|
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to get workspace resource.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to get build.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to get workspace.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if listen {
|
2023-05-01 19:19:41 +00:00
|
|
|
// Since we're ticking frequently and this sign-in operation is rare,
|
|
|
|
// we are OK with polling to avoid the complexity of pubsub.
|
2022-10-25 00:46:24 +00:00
|
|
|
ticker := time.NewTicker(time.Second)
|
|
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
|
|
select {
|
2022-11-18 22:46:53 +00:00
|
|
|
case <-ctx.Done():
|
2022-10-25 00:46:24 +00:00
|
|
|
return
|
|
|
|
case <-ticker.C:
|
|
|
|
}
|
|
|
|
gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
|
|
|
|
ProviderID: gitAuthConfig.ID,
|
|
|
|
UserID: workspace.OwnerID,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2022-12-06 15:06:41 +00:00
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
continue
|
|
|
|
}
|
2022-10-25 00:46:24 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to get git auth link.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-05-01 19:19:41 +00:00
|
|
|
|
|
|
|
// Expiry may be unset if the application doesn't configure tokens
|
|
|
|
// to expire.
|
|
|
|
// See
|
|
|
|
// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app.
|
|
|
|
if gitAuthLink.OAuthExpiry.Before(database.Now()) && !gitAuthLink.OAuthExpiry.IsZero() {
|
2022-10-25 00:46:24 +00:00
|
|
|
continue
|
|
|
|
}
|
2022-11-29 18:08:27 +00:00
|
|
|
if gitAuthConfig.ValidateURL != "" {
|
2023-03-22 19:37:08 +00:00
|
|
|
valid, err := gitAuthConfig.ValidateToken(ctx, gitAuthLink.OAuthAccessToken)
|
2022-11-29 18:08:27 +00:00
|
|
|
if err != nil {
|
|
|
|
api.Logger.Warn(ctx, "failed to validate git auth token",
|
|
|
|
slog.F("workspace_owner_id", workspace.OwnerID.String()),
|
|
|
|
slog.F("validate_url", gitAuthConfig.ValidateURL),
|
|
|
|
slog.Error(err),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
if !valid {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
2022-10-25 00:46:24 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// This is the URL that will redirect the user with a state token.
|
|
|
|
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID))
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to parse access URL.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
gitAuthLink, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
|
|
|
|
ProviderID: gitAuthConfig.ID,
|
|
|
|
UserID: workspace.OwnerID,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to get git auth link.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-01-29 21:47:24 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.GitAuthResponse{
|
2022-10-25 00:46:24 +00:00
|
|
|
URL: redirectURL.String(),
|
2022-11-15 21:06:13 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-03-22 19:37:08 +00:00
|
|
|
gitAuthLink, updated, err := gitAuthConfig.RefreshToken(ctx, api.Database, gitAuthLink)
|
2023-02-27 16:18:19 +00:00
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to refresh git auth token.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if !updated {
|
2023-01-29 21:47:24 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.GitAuthResponse{
|
2022-11-15 21:06:13 +00:00
|
|
|
URL: redirectURL.String(),
|
2022-10-25 00:46:24 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-02-27 16:18:19 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
|
|
|
|
}
|
|
|
|
|
2022-10-25 00:46:24 +00:00
|
|
|
// Provider types have different username/password formats.
|
2023-01-29 21:47:24 +00:00
|
|
|
func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) agentsdk.GitAuthResponse {
|
|
|
|
var resp agentsdk.GitAuthResponse
|
2022-10-25 00:46:24 +00:00
|
|
|
switch typ {
|
|
|
|
case codersdk.GitProviderGitLab:
|
|
|
|
// https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication
|
2023-01-29 21:47:24 +00:00
|
|
|
resp = agentsdk.GitAuthResponse{
|
2022-10-25 00:46:24 +00:00
|
|
|
Username: "oauth2",
|
|
|
|
Password: token,
|
|
|
|
}
|
|
|
|
case codersdk.GitProviderBitBucket:
|
|
|
|
// https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token
|
2023-01-29 21:47:24 +00:00
|
|
|
resp = agentsdk.GitAuthResponse{
|
2022-10-25 00:46:24 +00:00
|
|
|
Username: "x-token-auth",
|
|
|
|
Password: token,
|
|
|
|
}
|
|
|
|
default:
|
2023-01-29 21:47:24 +00:00
|
|
|
resp = agentsdk.GitAuthResponse{
|
2022-10-25 00:46:24 +00:00
|
|
|
Username: token,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return resp
|
|
|
|
}
|
|
|
|
|
|
|
|
func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc {
|
|
|
|
return func(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
var (
|
|
|
|
ctx = r.Context()
|
|
|
|
state = httpmw.OAuth2(r)
|
|
|
|
apiKey = httpmw.APIKey(r)
|
|
|
|
)
|
|
|
|
|
|
|
|
_, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
|
|
|
|
ProviderID: gitAuthConfig.ID,
|
|
|
|
UserID: apiKey.UserID,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to get git auth link.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
|
|
|
|
ProviderID: gitAuthConfig.ID,
|
|
|
|
UserID: apiKey.UserID,
|
|
|
|
CreatedAt: database.Now(),
|
|
|
|
UpdatedAt: database.Now(),
|
|
|
|
OAuthAccessToken: state.Token.AccessToken,
|
|
|
|
OAuthRefreshToken: state.Token.RefreshToken,
|
|
|
|
OAuthExpiry: state.Token.Expiry,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to insert git auth link.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
} else {
|
2023-02-27 16:18:19 +00:00
|
|
|
_, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
|
2022-10-25 00:46:24 +00:00
|
|
|
ProviderID: gitAuthConfig.ID,
|
|
|
|
UserID: apiKey.UserID,
|
|
|
|
UpdatedAt: database.Now(),
|
|
|
|
OAuthAccessToken: state.Token.AccessToken,
|
|
|
|
OAuthRefreshToken: state.Token.RefreshToken,
|
|
|
|
OAuthExpiry: state.Token.Expiry,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to update git auth link.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-27 16:18:19 +00:00
|
|
|
redirect := state.Redirect
|
|
|
|
if redirect == "" {
|
2023-05-01 19:19:41 +00:00
|
|
|
// This is a nicely rendered screen on the frontend
|
2023-02-27 16:18:19 +00:00
|
|
|
redirect = "/gitauth"
|
|
|
|
}
|
|
|
|
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
2022-10-25 00:46:24 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-03 09:50:10 +00:00
|
|
|
// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
|
|
|
|
// is called if a read or write error is encountered.
|
|
|
|
type wsNetConn struct {
|
|
|
|
cancel context.CancelFunc
|
|
|
|
net.Conn
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *wsNetConn) Read(b []byte) (n int, err error) {
|
|
|
|
n, err = c.Conn.Read(b)
|
|
|
|
if err != nil {
|
|
|
|
c.cancel()
|
|
|
|
}
|
|
|
|
return n, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *wsNetConn) Write(b []byte) (n int, err error) {
|
|
|
|
n, err = c.Conn.Write(b)
|
|
|
|
if err != nil {
|
|
|
|
c.cancel()
|
|
|
|
}
|
|
|
|
return n, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *wsNetConn) Close() error {
|
|
|
|
defer c.cancel()
|
|
|
|
return c.Conn.Close()
|
|
|
|
}
|
|
|
|
|
|
|
|
// websocketNetConn wraps websocket.NetConn and returns a context that
|
|
|
|
// is tied to the parent context and the lifetime of the conn. Any error
|
|
|
|
// during read or write will cancel the context, but not close the
|
|
|
|
// conn. Close should be called to release context resources.
|
|
|
|
func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websocket.MessageType) (context.Context, net.Conn) {
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
nc := websocket.NetConn(ctx, conn, msgType)
|
|
|
|
return ctx, &wsNetConn{
|
|
|
|
cancel: cancel,
|
|
|
|
Conn: nc,
|
|
|
|
}
|
|
|
|
}
|
2023-03-23 19:09:13 +00:00
|
|
|
|
|
|
|
func convertWorkspaceAgentStartupLogs(logs []database.WorkspaceAgentStartupLog) []codersdk.WorkspaceAgentStartupLog {
|
|
|
|
sdk := make([]codersdk.WorkspaceAgentStartupLog, 0, len(logs))
|
2023-04-27 10:34:00 +00:00
|
|
|
for _, logEntry := range logs {
|
|
|
|
sdk = append(sdk, convertWorkspaceAgentStartupLog(logEntry))
|
2023-03-23 19:09:13 +00:00
|
|
|
}
|
|
|
|
return sdk
|
|
|
|
}
|
|
|
|
|
2023-04-27 10:34:00 +00:00
|
|
|
func convertWorkspaceAgentStartupLog(logEntry database.WorkspaceAgentStartupLog) codersdk.WorkspaceAgentStartupLog {
|
2023-03-23 19:09:13 +00:00
|
|
|
return codersdk.WorkspaceAgentStartupLog{
|
2023-04-27 10:34:00 +00:00
|
|
|
ID: logEntry.ID,
|
|
|
|
CreatedAt: logEntry.CreatedAt,
|
|
|
|
Output: logEntry.Output,
|
|
|
|
Level: codersdk.LogLevel(logEntry.Level),
|
2023-03-23 19:09:13 +00:00
|
|
|
}
|
|
|
|
}
|