diff --git a/agent/agent.go b/agent/agent.go index 59485e330c325..e614a28c8905c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -41,6 +41,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/agent/usershell" "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -88,6 +89,7 @@ type Client interface { PostLifecycle(ctx context.Context, state agentsdk.PostLifecycleRequest) error PostAppHealth(ctx context.Context, req agentsdk.PostAppHealthsRequest) error PostStartup(ctx context.Context, req agentsdk.PostStartupRequest) error + PatchStartupLogs(ctx context.Context, req agentsdk.PatchStartupLogs) error } func New(options Options) io.Closer { @@ -642,13 +644,32 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { } a.logger.Info(ctx, "running script", slog.F("lifecycle", lifecycle), slog.F("script", script)) - writer, err := a.filesystem.OpenFile(filepath.Join(a.logDir, fmt.Sprintf("coder-%s-script.log", lifecycle)), os.O_CREATE|os.O_RDWR, 0o600) + fileWriter, err := a.filesystem.OpenFile(filepath.Join(a.logDir, fmt.Sprintf("coder-%s-script.log", lifecycle)), os.O_CREATE|os.O_RDWR, 0o600) if err != nil { return xerrors.Errorf("open %s script log file: %w", lifecycle, err) } defer func() { - _ = writer.Close() + _ = fileWriter.Close() }() + + var writer io.Writer = fileWriter + if lifecycle == "startup" { + // Create pipes for startup logs reader and writer + logsReader, logsWriter := io.Pipe() + defer func() { + _ = logsReader.Close() + }() + writer = io.MultiWriter(fileWriter, logsWriter) + flushedLogs, err := a.trackScriptLogs(ctx, logsReader) + if err != nil { + return xerrors.Errorf("track script logs: %w", err) + } + defer func() { + _ = logsWriter.Close() + <-flushedLogs + }() + } + cmd, err := a.createCommand(ctx, script, nil) if err != nil { return xerrors.Errorf("create command: %w", err) @@ -664,10 +685,124 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { return xerrors.Errorf("run: %w", err) } - return nil } +func (a *agent) trackScriptLogs(ctx context.Context, reader io.Reader) (chan struct{}, error) { + // Initialize variables for log management + queuedLogs := make([]agentsdk.StartupLog, 0) + var flushLogsTimer *time.Timer + var logMutex sync.Mutex + logsFlushed := sync.NewCond(&sync.Mutex{}) + var logsSending bool + defer func() { + logMutex.Lock() + if flushLogsTimer != nil { + flushLogsTimer.Stop() + } + logMutex.Unlock() + }() + + // sendLogs function uploads the queued logs to the server + sendLogs := func() { + // Lock logMutex and check if logs are already being sent + logMutex.Lock() + if logsSending { + logMutex.Unlock() + return + } + if flushLogsTimer != nil { + flushLogsTimer.Stop() + } + if len(queuedLogs) == 0 { + logMutex.Unlock() + return + } + // Move the current queued logs to logsToSend and clear the queue + logsToSend := queuedLogs + logsSending = true + queuedLogs = make([]agentsdk.StartupLog, 0) + logMutex.Unlock() + + // Retry uploading logs until successful or a specific error occurs + for r := retry.New(time.Second, 5*time.Second); r.Wait(ctx); { + err := a.client.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ + Logs: logsToSend, + }) + if err == nil { + break + } + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) { + if sdkErr.StatusCode() == http.StatusRequestEntityTooLarge { + a.logger.Warn(ctx, "startup logs too large, dropping logs") + break + } + } + a.logger.Error(ctx, "upload startup logs", slog.Error(err), slog.F("to_send", logsToSend)) + } + // Reset logsSending flag + logMutex.Lock() + logsSending = false + flushLogsTimer.Reset(100 * time.Millisecond) + logMutex.Unlock() + logsFlushed.Broadcast() + } + // queueLog function appends a log to the queue and triggers sendLogs if necessary + queueLog := func(log agentsdk.StartupLog) { + logMutex.Lock() + defer logMutex.Unlock() + + // Append log to the queue + queuedLogs = append(queuedLogs, log) + + // If there are more than 100 logs, send them immediately + if len(queuedLogs) > 100 { + // Don't early return after this, because we still want + // to reset the timer just in case logs come in while + // we're sending. + go sendLogs() + } + // Reset or set the flushLogsTimer to trigger sendLogs after 100 milliseconds + if flushLogsTimer != nil { + flushLogsTimer.Reset(100 * time.Millisecond) + return + } + flushLogsTimer = time.AfterFunc(100*time.Millisecond, sendLogs) + } + + // It's important that we either flush or drop all logs before returning + // because the startup state is reported after flush. + // + // It'd be weird for the startup state to be ready, but logs are still + // coming in. + logsFinished := make(chan struct{}) + err := a.trackConnGoroutine(func() { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + queueLog(agentsdk.StartupLog{ + CreatedAt: database.Now(), + Output: scanner.Text(), + }) + } + defer close(logsFinished) + logsFlushed.L.Lock() + for { + logMutex.Lock() + if len(queuedLogs) == 0 { + logMutex.Unlock() + break + } + logMutex.Unlock() + logsFlushed.Wait() + } + }) + if err != nil { + return nil, xerrors.Errorf("track conn goroutine: %w", err) + } + return logsFinished, nil +} + func (a *agent) init(ctx context.Context) { // Clients' should ignore the host key when connecting. // The agent needs to authenticate with coderd to SSH, diff --git a/agent/agent_test.go b/agent/agent_test.go index 9e2ff33d2d5c2..d163d7bf60dd2 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "net" + "net/http" + "net/http/httptest" "net/netip" "os" "os/exec" @@ -31,8 +33,6 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" "golang.org/x/crypto/ssh" - "golang.org/x/text/encoding/unicode" - "golang.org/x/text/transform" "golang.org/x/xerrors" "tailscale.com/net/speedtest" "tailscale.com/tailcfg" @@ -40,6 +40,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/pty/ptytest" @@ -739,37 +740,78 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) { func TestAgent_StartupScript(t *testing.T) { t.Parallel() + output := "something" + command := "sh -c 'echo " + output + "'" if runtime.GOOS == "windows" { - t.Skip("This test doesn't work on Windows for some reason...") + command = "cmd.exe /c echo " + output } - content := "output" - //nolint:dogsled - _, _, _, fs, _ := setupAgent(t, agentsdk.Metadata{ - StartupScript: "echo " + content, - }, 0) - var gotContent string - require.Eventually(t, func() bool { - outputPath := filepath.Join(os.TempDir(), "coder-startup-script.log") - content, err := afero.ReadFile(fs, outputPath) - if err != nil { - t.Logf("read file %q: %s", outputPath, err) - return false - } - if len(content) == 0 { - t.Logf("no content in %q", outputPath) - return false + t.Run("Success", func(t *testing.T) { + t.Parallel() + client := &client{ + t: t, + agentID: uuid.New(), + metadata: agentsdk.Metadata{ + StartupScript: command, + DERPMap: &tailcfg.DERPMap{}, + }, + statsChan: make(chan *agentsdk.Stats), + coordinator: tailnet.NewCoordinator(), } - if runtime.GOOS == "windows" { - // Windows uses UTF16! 🪟🪟🪟 - content, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), content) - if !assert.NoError(t, err) { - return false - } + closer := agent.New(agent.Options{ + Client: client, + Filesystem: afero.NewMemMapFs(), + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + ReconnectingPTYTimeout: 0, + }) + t.Cleanup(func() { + _ = closer.Close() + }) + assert.Eventually(t, func() bool { + got := client.getLifecycleStates() + return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady + }, testutil.WaitShort, testutil.IntervalMedium) + + require.Len(t, client.getStartupLogs(), 1) + require.Equal(t, output, client.getStartupLogs()[0].Output) + }) + // This ensures that even when coderd sends back that the startup + // script has written too many lines it will still succeed! + t.Run("OverflowsAndSkips", func(t *testing.T) { + t.Parallel() + client := &client{ + t: t, + agentID: uuid.New(), + metadata: agentsdk.Metadata{ + StartupScript: command, + DERPMap: &tailcfg.DERPMap{}, + }, + patchWorkspaceLogs: func() error { + resp := httptest.NewRecorder() + httpapi.Write(context.Background(), resp, http.StatusRequestEntityTooLarge, codersdk.Response{ + Message: "Too many lines!", + }) + res := resp.Result() + defer res.Body.Close() + return codersdk.ReadBodyAsError(res) + }, + statsChan: make(chan *agentsdk.Stats), + coordinator: tailnet.NewCoordinator(), } - gotContent = string(content) - return true - }, testutil.WaitShort, testutil.IntervalMedium) - require.Equal(t, content, strings.TrimSpace(gotContent)) + closer := agent.New(agent.Options{ + Client: client, + Filesystem: afero.NewMemMapFs(), + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + ReconnectingPTYTimeout: 0, + }) + t.Cleanup(func() { + _ = closer.Close() + }) + assert.Eventually(t, func() bool { + got := client.getLifecycleStates() + return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady + }, testutil.WaitShort, testutil.IntervalMedium) + require.Len(t, client.getStartupLogs(), 0) + }) } func TestAgent_Lifecycle(t *testing.T) { @@ -1495,10 +1537,12 @@ type client struct { statsChan chan *agentsdk.Stats coordinator tailnet.Coordinator lastWorkspaceAgent func() + patchWorkspaceLogs func() error mu sync.Mutex // Protects following. lifecycleStates []codersdk.WorkspaceAgentLifecycle startup agentsdk.PostStartupRequest + logs []agentsdk.StartupLog } func (c *client) Metadata(_ context.Context) (agentsdk.Metadata, error) { @@ -1583,6 +1627,22 @@ func (c *client) PostStartup(_ context.Context, startup agentsdk.PostStartupRequ return nil } +func (c *client) getStartupLogs() []agentsdk.StartupLog { + c.mu.Lock() + defer c.mu.Unlock() + return c.logs +} + +func (c *client) PatchStartupLogs(_ context.Context, logs agentsdk.PatchStartupLogs) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.patchWorkspaceLogs != nil { + return c.patchWorkspaceLogs() + } + c.logs = append(c.logs, logs.Logs...) + return nil +} + // tempDirUnixSocket returns a temporary directory that can safely hold unix // sockets (probably). // diff --git a/cli/agent.go b/cli/agent.go index c81fdb0b57bc1..b3086815b2bad 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -118,7 +118,10 @@ func workspaceAgent() *cobra.Command { client := agentsdk.New(coderURL) client.SDK.Logger = logger // Set a reasonable timeout so requests can't hang forever! - client.SDK.HTTPClient.Timeout = 10 * time.Second + // The timeout needs to be reasonably long, because requests + // with large payloads can take a bit. e.g. startup scripts + // may take a while to insert. + client.SDK.HTTPClient.Timeout = 30 * time.Second // Enable pprof handler // This prevents the pprof import from being accidentally deleted. diff --git a/cli/server.go b/cli/server.go index cef611dd566e6..bb53b4218e290 100644 --- a/cli/server.go +++ b/cli/server.go @@ -65,6 +65,7 @@ import ( "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbfake" + "github.com/coder/coder/coderd/database/dbpurge" "github.com/coder/coder/coderd/database/migrations" "github.com/coder/coder/coderd/devtunnel" "github.com/coder/coder/coderd/gitauth" @@ -993,6 +994,10 @@ flags, and YAML configuration. The precedence is as follows: shutdownConnsCtx, shutdownConns := context.WithCancel(ctx) defer shutdownConns() + // Ensures that old database entries are cleaned up over time! + purger := dbpurge.New(ctx, logger, options.Database) + defer purger.Close() + // Wrap the server in middleware that redirects to the access URL if // the request is not to a local IP. var handler http.Handler = coderAPI.RootHandler diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e0f64e1804540..9435f1bec2a37 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2644,13 +2644,13 @@ const docTemplate = `{ }, { "type": "integer", - "description": "Before Unix timestamp", + "description": "Before log id", "name": "before", "in": "query" }, { "type": "integer", - "description": "After Unix timestamp", + "description": "After log id", "name": "after", "in": "query" }, @@ -4402,6 +4402,48 @@ const docTemplate = `{ } } }, + "/workspaceagents/me/startup-logs": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Patch workspace agent startup logs", + "operationId": "patch-workspace-agent-startup-logs", + "parameters": [ + { + "description": "Startup logs", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PatchStartupLogs" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceagents/{workspaceagent}": { "get": { "security": [ @@ -4565,6 +4607,62 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/startup-logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get startup logs by workspace agent", + "operationId": "get-startup-logs-by-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Before log id", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "description": "After log id", + "name": "after", + "in": "query" + }, + { + "type": "boolean", + "description": "Follow log stream", + "name": "follow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentStartupLog" + } + } + } + } + } + }, "/workspacebuilds/{workspacebuild}": { "get": { "security": [ @@ -5344,6 +5442,17 @@ const docTemplate = `{ } } }, + "agentsdk.PatchStartupLogs": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/agentsdk.StartupLog" + } + } + } + }, "agentsdk.PostAppHealthsRequest": { "type": "object", "properties": { @@ -5375,6 +5484,17 @@ const docTemplate = `{ } } }, + "agentsdk.StartupLog": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "output": { + "type": "string" + } + } + }, "agentsdk.Stats": { "type": "object", "properties": { @@ -8680,6 +8800,12 @@ const docTemplate = `{ "shutdown_script_timeout_seconds": { "type": "integer" }, + "startup_logs_length": { + "type": "integer" + }, + "startup_logs_overflowed": { + "type": "boolean" + }, "startup_script": { "type": "string" }, @@ -8763,6 +8889,21 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceAgentStartupLog": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "output": { + "type": "string" + } + } + }, "codersdk.WorkspaceAgentStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f2461cf940f92..7a0f6e16d341f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2320,13 +2320,13 @@ }, { "type": "integer", - "description": "Before Unix timestamp", + "description": "Before log id", "name": "before", "in": "query" }, { "type": "integer", - "description": "After Unix timestamp", + "description": "After log id", "name": "after", "in": "query" }, @@ -3866,6 +3866,42 @@ } } }, + "/workspaceagents/me/startup-logs": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Patch workspace agent startup logs", + "operationId": "patch-workspace-agent-startup-logs", + "parameters": [ + { + "description": "Startup logs", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PatchStartupLogs" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceagents/{workspaceagent}": { "get": { "security": [ @@ -4013,6 +4049,58 @@ } } }, + "/workspaceagents/{workspaceagent}/startup-logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get startup logs by workspace agent", + "operationId": "get-startup-logs-by-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Before log id", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "description": "After log id", + "name": "after", + "in": "query" + }, + { + "type": "boolean", + "description": "Follow log stream", + "name": "follow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentStartupLog" + } + } + } + } + } + }, "/workspacebuilds/{workspacebuild}": { "get": { "security": [ @@ -4715,6 +4803,17 @@ } } }, + "agentsdk.PatchStartupLogs": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/agentsdk.StartupLog" + } + } + } + }, "agentsdk.PostAppHealthsRequest": { "type": "object", "properties": { @@ -4746,6 +4845,17 @@ } } }, + "agentsdk.StartupLog": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "output": { + "type": "string" + } + } + }, "agentsdk.Stats": { "type": "object", "properties": { @@ -7824,6 +7934,12 @@ "shutdown_script_timeout_seconds": { "type": "integer" }, + "startup_logs_length": { + "type": "integer" + }, + "startup_logs_overflowed": { + "type": "boolean" + }, "startup_script": { "type": "string" }, @@ -7907,6 +8023,21 @@ } } }, + "codersdk.WorkspaceAgentStartupLog": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "output": { + "type": "string" + } + } + }, "codersdk.WorkspaceAgentStatus": { "type": "string", "enum": ["connecting", "connected", "disconnected", "timeout"], diff --git a/coderd/coderd.go b/coderd/coderd.go index fdbe226cfd980..e17ce255b34f4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -604,6 +604,7 @@ func New(options *Options) *API { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Get("/metadata", api.workspaceAgentMetadata) r.Post("/startup", api.postWorkspaceAgentStartup) + r.Patch("/startup-logs", api.patchWorkspaceAgentStartupLogs) r.Post("/app-health", api.postWorkspaceAppHealth) r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/gitsshkey", api.agentGitSSHKey) @@ -619,6 +620,7 @@ func New(options *Options) *API { ) r.Get("/", api.workspaceAgent) r.Get("/pty", api.workspaceAgentPTY) + r.Get("/startup-logs", api.workspaceAgentStartupLogs) r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/coordinate", api.workspaceAgentClientCoordinate) diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index 8abf78314930a..dda80d2f40800 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -345,6 +345,7 @@ func assertProduce(t *testing.T, comment SwaggerComment) { } else { if (comment.router == "/workspaceagents/me/app-health" && comment.method == "post") || (comment.router == "/workspaceagents/me/startup" && comment.method == "post") || + (comment.router == "/workspaceagents/me/startup/logs" && comment.method == "patch") || (comment.router == "/licenses/{id}" && comment.method == "delete") || (comment.router == "/debug/coordinator" && comment.method == "get") { return // Exception: HTTP 200 is returned without response entity diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 42b4cb87e81d6..2ee0579281038 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -263,13 +263,21 @@ func (q *querier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (data return job, nil } -func (q *querier) GetProvisionerLogsByIDBetween(ctx context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) { +func (q *querier) GetProvisionerLogsAfterID(ctx context.Context, arg database.GetProvisionerLogsAfterIDParams) ([]database.ProvisionerJobLog, error) { // Authorized read on job lets the actor also read the logs. _, err := q.GetProvisionerJobByID(ctx, arg.JobID) if err != nil { return nil, err } - return q.db.GetProvisionerLogsByIDBetween(ctx, arg) + return q.db.GetProvisionerLogsAfterID(ctx, arg) +} + +func (q *querier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsAfterParams) ([]database.WorkspaceAgentStartupLog, error) { + _, err := q.GetWorkspaceAgentByID(ctx, arg.AgentID) + if err != nil { + return nil, err + } + return q.db.GetWorkspaceAgentStartupLogsAfter(ctx, arg) } func (q *querier) GetLicenses(ctx context.Context) ([]database.License, error) { @@ -1245,6 +1253,24 @@ func (q *querier) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, ar return q.db.UpdateWorkspaceAgentLifecycleStateByID(ctx, arg) } +func (q *querier) UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg database.UpdateWorkspaceAgentStartupLogOverflowByIDParams) error { + agent, err := q.db.GetWorkspaceAgentByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByAgentID(ctx, agent.ID) + if err != nil { + return err + } + + if err := q.authorizeContext(ctx, rbac.ActionUpdate, workspace); err != nil { + return err + } + + return q.db.UpdateWorkspaceAgentStartupLogOverflowByID(ctx, arg) +} + func (q *querier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg database.UpdateWorkspaceAgentStartupByIDParams) error { agent, err := q.db.GetWorkspaceAgentByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index bde5d4bb42d01..533bf3621b232 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -282,13 +282,18 @@ func (s *MethodTestSuite) TestProvsionerJob() { check.Args(database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID}). Asserts(v.RBACObject(tpl), []rbac.Action{rbac.ActionRead, rbac.ActionUpdate}).Returns() })) - s.Run("GetProvisionerLogsByIDBetween", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetProvisionerJobsByIDs", s.Subtest(func(db database.Store, check *expects) { + a := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{}) + b := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{}) + check.Args([]uuid.UUID{a.ID, b.ID}).Asserts().Returns(slice.New(a, b)) + })) + s.Run("GetProvisionerLogsAfterID", s.Subtest(func(db database.Store, check *expects) { w := dbgen.Workspace(s.T(), db, database.Workspace{}) j := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{ Type: database.ProvisionerJobTypeWorkspaceBuild, }) _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: w.ID}) - check.Args(database.GetProvisionerLogsByIDBetweenParams{ + check.Args(database.GetProvisionerLogsAfterIDParams{ JobID: j.ID, }).Asserts(w, rbac.ActionRead).Returns([]database.ProvisionerJobLog{}) })) @@ -978,6 +983,16 @@ func (s *MethodTestSuite) TestWorkspace() { LifecycleState: database.WorkspaceAgentLifecycleStateCreated, }).Asserts(ws, rbac.ActionUpdate).Returns() })) + s.Run("UpdateWorkspaceAgentStartupLogOverflowByID", s.Subtest(func(db database.Store, check *expects) { + ws := dbgen.Workspace(s.T(), db, database.Workspace{}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) + agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + check.Args(database.UpdateWorkspaceAgentStartupLogOverflowByIDParams{ + ID: agt.ID, + StartupLogsOverflowed: true, + }).Asserts(ws, rbac.ActionUpdate).Returns() + })) s.Run("UpdateWorkspaceAgentStartupByID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) @@ -987,6 +1002,15 @@ func (s *MethodTestSuite) TestWorkspace() { ID: agt.ID, }).Asserts(ws, rbac.ActionUpdate).Returns() })) + s.Run("GetWorkspaceAgentStartupLogsAfter", s.Subtest(func(db database.Store, check *expects) { + ws := dbgen.Workspace(s.T(), db, database.Workspace{}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) + agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + check.Args(database.GetWorkspaceAgentStartupLogsAfterParams{ + AgentID: agt.ID, + }).Asserts(ws, rbac.ActionRead).Returns([]database.WorkspaceAgentStartupLog{}) + })) s.Run("GetWorkspaceAppByAgentIDAndSlug", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) diff --git a/coderd/database/dbauthz/system.go b/coderd/database/dbauthz/system.go index d46aff267dc75..d27ff55ea0afe 100644 --- a/coderd/database/dbauthz/system.go +++ b/coderd/database/dbauthz/system.go @@ -280,6 +280,13 @@ func (q *querier) DeleteOldWorkspaceAgentStats(ctx context.Context) error { return q.db.DeleteOldWorkspaceAgentStats(ctx) } +func (q *querier) DeleteOldWorkspaceAgentStartupLogs(ctx context.Context) error { + if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceSystem); err != nil { + return err + } + return q.db.DeleteOldWorkspaceAgentStartupLogs(ctx) +} + func (q *querier) GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAfter time.Time) (database.GetDeploymentWorkspaceAgentStatsRow, error) { return q.db.GetDeploymentWorkspaceAgentStats(ctx, createdAfter) } @@ -370,6 +377,10 @@ func (q *querier) InsertProvisionerJobLogs(ctx context.Context, arg database.Ins return q.db.InsertProvisionerJobLogs(ctx, arg) } +func (q *querier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg database.InsertWorkspaceAgentStartupLogsParams) ([]database.WorkspaceAgentStartupLog, error) { + return q.db.InsertWorkspaceAgentStartupLogs(ctx, arg) +} + // TODO: We need to create a ProvisionerDaemon resource type func (q *querier) InsertProvisionerDaemon(ctx context.Context, arg database.InsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) { // if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index cfe67a729076f..a38b9baeacad7 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -60,6 +60,7 @@ func New() database.Store { templateVersions: make([]database.TemplateVersion, 0), templates: make([]database.Template, 0), workspaceAgentStats: make([]database.WorkspaceAgentStat, 0), + workspaceAgentLogs: make([]database.WorkspaceAgentStartupLog, 0), workspaceBuilds: make([]database.WorkspaceBuild, 0), workspaceApps: make([]database.WorkspaceApp, 0), workspaces: make([]database.Workspace, 0), @@ -123,6 +124,7 @@ type data struct { templateVersionVariables []database.TemplateVersionVariable templates []database.Template workspaceAgents []database.WorkspaceAgent + workspaceAgentLogs []database.WorkspaceAgentStartupLog workspaceApps []database.WorkspaceApp workspaceBuilds []database.WorkspaceBuild workspaceBuildParameters []database.WorkspaceBuildParameter @@ -2614,7 +2616,7 @@ func (q *fakeQuerier) GetProvisionerJobsCreatedAfter(_ context.Context, after ti return jobs, nil } -func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) { +func (q *fakeQuerier) GetProvisionerLogsAfterID(_ context.Context, arg database.GetProvisionerLogsAfterIDParams) ([]database.ProvisionerJobLog, error) { if err := validateDatabaseType(arg); err != nil { return nil, err } @@ -2627,9 +2629,6 @@ func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg datab if jobLog.JobID != arg.JobID { continue } - if arg.CreatedBefore != 0 && jobLog.ID > arg.CreatedBefore { - continue - } if arg.CreatedAfter != 0 && jobLog.ID < arg.CreatedAfter { continue } @@ -3517,6 +3516,70 @@ func (q *fakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspaceAgentStartupLogsAfter(_ context.Context, arg database.GetWorkspaceAgentStartupLogsAfterParams) ([]database.WorkspaceAgentStartupLog, error) { + if err := validateDatabaseType(arg); err != nil { + return nil, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + logs := []database.WorkspaceAgentStartupLog{} + for _, log := range q.workspaceAgentLogs { + if log.AgentID != arg.AgentID { + continue + } + if arg.CreatedAfter != 0 && log.ID < arg.CreatedAfter { + continue + } + logs = append(logs, log) + } + return logs, nil +} + +func (q *fakeQuerier) InsertWorkspaceAgentStartupLogs(_ context.Context, arg database.InsertWorkspaceAgentStartupLogsParams) ([]database.WorkspaceAgentStartupLog, error) { + if err := validateDatabaseType(arg); err != nil { + return nil, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + logs := []database.WorkspaceAgentStartupLog{} + id := int64(1) + if len(q.workspaceAgentLogs) > 0 { + id = q.workspaceAgentLogs[len(q.workspaceAgentLogs)-1].ID + } + outputLength := int32(0) + for index, output := range arg.Output { + id++ + logs = append(logs, database.WorkspaceAgentStartupLog{ + ID: id, + AgentID: arg.AgentID, + CreatedAt: arg.CreatedAt[index], + Output: output, + }) + outputLength += int32(len(output)) + } + for index, agent := range q.workspaceAgents { + if agent.ID != arg.AgentID { + continue + } + // Greater than 1MB, same as the PostgreSQL constraint! + if agent.StartupLogsLength+outputLength > (1 << 20) { + return nil, &pq.Error{ + Constraint: "max_startup_logs_length", + Table: "workspace_agents", + } + } + agent.StartupLogsLength += outputLength + q.workspaceAgents[index] = agent + break + } + q.workspaceAgentLogs = append(q.workspaceAgentLogs, logs...) + return logs, nil +} + func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -4325,6 +4388,11 @@ func (q *fakeQuerier) DeleteLicense(_ context.Context, id int32) (int32, error) return 0, sql.ErrNoRows } +func (*fakeQuerier) DeleteOldWorkspaceAgentStartupLogs(_ context.Context) error { + // noop + return nil +} + func (q *fakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4735,3 +4803,20 @@ func (q *fakeQuerier) UpdateWorkspaceAgentLifecycleStateByID(_ context.Context, } return sql.ErrNoRows } + +func (q *fakeQuerier) UpdateWorkspaceAgentStartupLogOverflowByID(_ context.Context, arg database.UpdateWorkspaceAgentStartupLogOverflowByIDParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + for i, agent := range q.workspaceAgents { + if agent.ID == arg.ID { + agent.StartupLogsOverflowed = arg.StartupLogsOverflowed + q.workspaceAgents[i] = agent + return nil + } + } + return sql.ErrNoRows +} diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go new file mode 100644 index 0000000000000..e7d3876669e52 --- /dev/null +++ b/coderd/database/dbpurge/dbpurge.go @@ -0,0 +1,64 @@ +package dbpurge + +import ( + "context" + "errors" + "io" + "time" + + "golang.org/x/sync/errgroup" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/database" +) + +// New creates a new periodically purging database instance. +// It is the caller's responsibility to call Close on the returned instance. +// +// This is for cleaning up old, unused resources from the database that take up space. +func New(ctx context.Context, logger slog.Logger, db database.Store) io.Closer { + closed := make(chan struct{}) + ctx, cancelFunc := context.WithCancel(ctx) + go func() { + defer close(closed) + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + var eg errgroup.Group + eg.Go(func() error { + return db.DeleteOldWorkspaceAgentStartupLogs(ctx) + }) + eg.Go(func() error { + return db.DeleteOldWorkspaceAgentStats(ctx) + }) + err := eg.Wait() + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + logger.Error(ctx, "failed to purge old database entries", slog.Error(err)) + } + } + }() + return &instance{ + cancel: cancelFunc, + closed: closed, + } +} + +type instance struct { + cancel context.CancelFunc + closed chan struct{} +} + +func (i *instance) Close() error { + i.cancel() + <-i.closed + return nil +} diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go new file mode 100644 index 0000000000000..bc51f15b451da --- /dev/null +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -0,0 +1,26 @@ +package dbpurge_test + +import ( + "context" + "testing" + + "go.uber.org/goleak" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/coderd/database/dbfake" + "github.com/coder/coder/coderd/database/dbpurge" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +// Ensures no goroutines leak. +func TestPurge(t *testing.T) { + t.Parallel() + purger := dbpurge.New(context.Background(), slogtest.Make(t, nil), dbfake.New()) + err := purger.Close() + require.NoError(t, err) +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 402ef5a43ca1b..2f99cf2a073e3 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -475,6 +475,22 @@ CREATE TABLE users ( last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL ); +CREATE TABLE workspace_agent_startup_logs ( + agent_id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + output character varying(1024) NOT NULL, + id bigint NOT NULL +); + +CREATE SEQUENCE workspace_agent_startup_logs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE workspace_agent_startup_logs_id_seq OWNED BY workspace_agent_startup_logs.id; + CREATE TABLE workspace_agent_stats ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -523,7 +539,10 @@ CREATE TABLE workspace_agents ( startup_script_timeout_seconds integer DEFAULT 0 NOT NULL, expanded_directory character varying(4096) DEFAULT ''::character varying NOT NULL, shutdown_script character varying(65534), - shutdown_script_timeout_seconds integer DEFAULT 0 NOT NULL + shutdown_script_timeout_seconds integer DEFAULT 0 NOT NULL, + startup_logs_length integer DEFAULT 0 NOT NULL, + startup_logs_overflowed boolean DEFAULT false NOT NULL, + CONSTRAINT max_startup_logs_length CHECK ((startup_logs_length <= 1048576)) ); COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; @@ -546,6 +565,10 @@ COMMENT ON COLUMN workspace_agents.shutdown_script IS 'Script that is executed b COMMENT ON COLUMN workspace_agents.shutdown_script_timeout_seconds IS 'The number of seconds to wait for the shutdown script to complete. If the script does not complete within this time, the agent lifecycle will be marked as shutdown_timeout.'; +COMMENT ON COLUMN workspace_agents.startup_logs_length IS 'Total length of startup logs'; + +COMMENT ON COLUMN workspace_agents.startup_logs_overflowed IS 'Whether the startup logs overflowed in length'; + CREATE TABLE workspace_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -639,6 +662,8 @@ ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq': ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); +ALTER TABLE ONLY workspace_agent_startup_logs ALTER COLUMN id SET DEFAULT nextval('workspace_agent_startup_logs_id_seq'::regclass); + ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval('workspace_resource_metadata_id_seq'::regclass); ALTER TABLE ONLY workspace_agent_stats @@ -731,6 +756,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +ALTER TABLE ONLY workspace_agent_startup_logs + ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); + ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); @@ -802,6 +830,8 @@ CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WH CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); +CREATE INDEX workspace_agent_startup_logs_id_agent_id_idx ON workspace_agent_startup_logs USING btree (agent_id, id); + CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (auth_token); CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id); @@ -864,6 +894,9 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspace_agent_startup_logs + ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; diff --git a/coderd/database/errors.go b/coderd/database/errors.go index 8de29d7d9092f..90a4ecf42e2c6 100644 --- a/coderd/database/errors.go +++ b/coderd/database/errors.go @@ -45,3 +45,12 @@ func IsQueryCanceledError(err error) bool { return false } + +func IsStartupLogsLimitError(err error) bool { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + return pqErr.Constraint == "max_startup_logs_length" && pqErr.Table == "workspace_agents" + } + + return false +} diff --git a/coderd/database/migrations/000110_add_startup_logs.down.sql b/coderd/database/migrations/000110_add_startup_logs.down.sql new file mode 100644 index 0000000000000..8cbdd59359f21 --- /dev/null +++ b/coderd/database/migrations/000110_add_startup_logs.down.sql @@ -0,0 +1,4 @@ +DROP TABLE workspace_agent_startup_logs; +ALTER TABLE ONLY workspace_agents + DROP COLUMN startup_logs_length, + DROP COLUMN startup_logs_overflowed; diff --git a/coderd/database/migrations/000110_add_startup_logs.up.sql b/coderd/database/migrations/000110_add_startup_logs.up.sql new file mode 100644 index 0000000000000..f74c014dd55bc --- /dev/null +++ b/coderd/database/migrations/000110_add_startup_logs.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS workspace_agent_startup_logs ( + agent_id uuid NOT NULL REFERENCES workspace_agents (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL, + output varchar(1024) NOT NULL, + id BIGSERIAL PRIMARY KEY +); +CREATE INDEX workspace_agent_startup_logs_id_agent_id_idx ON workspace_agent_startup_logs USING btree (agent_id, id ASC); + +-- The maximum length of startup logs is 1MB per workspace agent. +ALTER TABLE workspace_agents ADD COLUMN startup_logs_length integer NOT NULL DEFAULT 0 CONSTRAINT max_startup_logs_length CHECK (startup_logs_length <= 1048576); +ALTER TABLE workspace_agents ADD COLUMN startup_logs_overflowed boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN workspace_agents.startup_logs_length IS 'Total length of startup logs'; +COMMENT ON COLUMN workspace_agents.startup_logs_overflowed IS 'Whether the startup logs overflowed in length'; + +COMMIT; diff --git a/coderd/database/migrations/testdata/fixtures/000110_workspace_agent_startup_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000110_workspace_agent_startup_logs.up.sql new file mode 100644 index 0000000000000..98788795f1025 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000110_workspace_agent_startup_logs.up.sql @@ -0,0 +1,9 @@ +INSERT INTO workspace_agent_startup_logs ( + agent_id, + created_at, + output +) VALUES ( + '45e89705-e09d-4850-bcec-f9a937f5d78d', + NOW(), + 'output' +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 3b8cbc2a97126..82ade2f95c96e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1569,6 +1569,17 @@ type WorkspaceAgent struct { ShutdownScript sql.NullString `db:"shutdown_script" json:"shutdown_script"` // The number of seconds to wait for the shutdown script to complete. If the script does not complete within this time, the agent lifecycle will be marked as shutdown_timeout. ShutdownScriptTimeoutSeconds int32 `db:"shutdown_script_timeout_seconds" json:"shutdown_script_timeout_seconds"` + // Total length of startup logs + StartupLogsLength int32 `db:"startup_logs_length" json:"startup_logs_length"` + // Whether the startup logs overflowed in length + StartupLogsOverflowed bool `db:"startup_logs_overflowed" json:"startup_logs_overflowed"` +} + +type WorkspaceAgentStartupLog struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Output string `db:"output" json:"output"` + ID int64 `db:"id" json:"id"` } type WorkspaceAgentStat struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 77ce2395d8424..99cb9f7624301 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -33,6 +33,9 @@ type sqlcQuerier interface { DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error DeleteGroupMembersByOrgAndUser(ctx context.Context, arg DeleteGroupMembersByOrgAndUserParams) error DeleteLicense(ctx context.Context, id int32) (int32, error) + // If an agent hasn't connected in the last 7 days, we purge it's logs. + // Logs can take up a lot of space, so it's important we clean up frequently. + DeleteOldWorkspaceAgentStartupLogs(ctx context.Context) error DeleteOldWorkspaceAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error @@ -87,7 +90,7 @@ type sqlcQuerier interface { GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) - GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) + GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) @@ -121,6 +124,7 @@ type sqlcQuerier interface { GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) + GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg GetWorkspaceAgentStartupLogsAfterParams) ([]WorkspaceAgentStartupLog, error) GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) @@ -181,6 +185,7 @@ type sqlcQuerier interface { InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error) InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) + InsertWorkspaceAgentStartupLogs(ctx context.Context, arg InsertWorkspaceAgentStartupLogsParams) ([]WorkspaceAgentStartupLog, error) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) (WorkspaceBuild, error) @@ -225,6 +230,7 @@ type sqlcQuerier interface { UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error + UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentStartupLogOverflowByIDParams) error UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) (WorkspaceBuild, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5e8def562c16d..823079b7c6be6 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -86,3 +86,42 @@ func TestGetDeploymentWorkspaceAgentStats(t *testing.T) { require.Equal(t, int64(1), stats.SessionCountVSCode) }) } + +func TestInsertWorkspaceAgentStartupLogs(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + sqlDB := testSQLDB(t) + ctx := context.Background() + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + org := dbgen.Organization(t, db, database.Organization{}) + job := dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + }) + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + logs, err := db.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ + AgentID: agent.ID, + CreatedAt: []time.Time{database.Now()}, + Output: []string{"first"}, + // 1 MB is the max + OutputLength: 1 << 20, + }) + require.NoError(t, err) + require.Equal(t, int64(1), logs[0].ID) + + _, err = db.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ + AgentID: agent.ID, + CreatedAt: []time.Time{database.Now()}, + Output: []string{"second"}, + OutputLength: 1, + }) + require.True(t, database.IsStartupLogsLimitError(err)) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1ff1cb7c7c6cc..dd9fe69dd22ba 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2286,7 +2286,7 @@ func (q *sqlQuerier) InsertProvisionerDaemon(ctx context.Context, arg InsertProv return i, err } -const getProvisionerLogsByIDBetween = `-- name: GetProvisionerLogsByIDBetween :many +const getProvisionerLogsAfterID = `-- name: GetProvisionerLogsAfterID :many SELECT job_id, created_at, source, level, stage, output, id FROM @@ -2295,18 +2295,16 @@ WHERE job_id = $1 AND ( id > $2 - OR id < $3 ) ORDER BY id ASC ` -type GetProvisionerLogsByIDBetweenParams struct { - JobID uuid.UUID `db:"job_id" json:"job_id"` - CreatedAfter int64 `db:"created_after" json:"created_after"` - CreatedBefore int64 `db:"created_before" json:"created_before"` +type GetProvisionerLogsAfterIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + CreatedAfter int64 `db:"created_after" json:"created_after"` } -func (q *sqlQuerier) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) { - rows, err := q.db.QueryContext(ctx, getProvisionerLogsByIDBetween, arg.JobID, arg.CreatedAfter, arg.CreatedBefore) +func (q *sqlQuerier) GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) { + rows, err := q.db.QueryContext(ctx, getProvisionerLogsAfterID, arg.JobID, arg.CreatedAfter) if err != nil { return nil, err } @@ -5072,9 +5070,22 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP return i, err } +const deleteOldWorkspaceAgentStartupLogs = `-- name: DeleteOldWorkspaceAgentStartupLogs :exec +DELETE FROM workspace_agent_startup_logs WHERE agent_id IN + (SELECT id FROM workspace_agents WHERE last_connected_at IS NOT NULL + AND last_connected_at < NOW() - INTERVAL '7 day') +` + +// If an agent hasn't connected in the last 7 days, we purge it's logs. +// Logs can take up a lot of space, so it's important we clean up frequently. +func (q *sqlQuerier) DeleteOldWorkspaceAgentStartupLogs(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteOldWorkspaceAgentStartupLogs) + return err +} + const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5115,13 +5126,15 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5160,13 +5173,15 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5207,13 +5222,60 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } +const getWorkspaceAgentStartupLogsAfter = `-- name: GetWorkspaceAgentStartupLogsAfter :many +SELECT + agent_id, created_at, output, id +FROM + workspace_agent_startup_logs +WHERE + agent_id = $1 + AND ( + id > $2 + ) ORDER BY id ASC +` + +type GetWorkspaceAgentStartupLogsAfterParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + CreatedAfter int64 `db:"created_after" json:"created_after"` +} + +func (q *sqlQuerier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg GetWorkspaceAgentStartupLogsAfterParams) ([]WorkspaceAgentStartupLog, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentStartupLogsAfter, arg.AgentID, arg.CreatedAfter) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentStartupLog + for rows.Next() { + var i WorkspaceAgentStartupLog + if err := rows.Scan( + &i.AgentID, + &i.CreatedAt, + &i.Output, + &i.ID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5258,6 +5320,8 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, + &i.StartupLogsOverflowed, ); err != nil { return nil, err } @@ -5273,7 +5337,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] } const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many -SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds FROM workspace_agents WHERE created_at > $1 +SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) { @@ -5314,6 +5378,8 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, + &i.StartupLogsOverflowed, ); err != nil { return nil, err } @@ -5354,7 +5420,7 @@ INSERT INTO shutdown_script_timeout_seconds ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed ` type InsertWorkspaceAgentParams struct { @@ -5435,10 +5501,66 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } +const insertWorkspaceAgentStartupLogs = `-- name: InsertWorkspaceAgentStartupLogs :many +WITH new_length AS ( + UPDATE workspace_agents SET + startup_logs_length = startup_logs_length + $4 WHERE workspace_agents.id = $1 +) +INSERT INTO + workspace_agent_startup_logs + SELECT + $1 :: uuid AS agent_id, + unnest($2 :: timestamptz [ ]) AS created_at, + unnest($3 :: VARCHAR(1024) [ ]) AS output + RETURNING workspace_agent_startup_logs.agent_id, workspace_agent_startup_logs.created_at, workspace_agent_startup_logs.output, workspace_agent_startup_logs.id +` + +type InsertWorkspaceAgentStartupLogsParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + CreatedAt []time.Time `db:"created_at" json:"created_at"` + Output []string `db:"output" json:"output"` + OutputLength int32 `db:"output_length" json:"output_length"` +} + +func (q *sqlQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg InsertWorkspaceAgentStartupLogsParams) ([]WorkspaceAgentStartupLog, error) { + rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentStartupLogs, + arg.AgentID, + pq.Array(arg.CreatedAt), + pq.Array(arg.Output), + arg.OutputLength, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentStartupLog + for rows.Next() { + var i WorkspaceAgentStartupLog + if err := rows.Scan( + &i.AgentID, + &i.CreatedAt, + &i.Output, + &i.ID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateWorkspaceAgentConnectionByID = `-- name: UpdateWorkspaceAgentConnectionByID :exec UPDATE workspace_agents @@ -5513,6 +5635,25 @@ func (q *sqlQuerier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg Up return err } +const updateWorkspaceAgentStartupLogOverflowByID = `-- name: UpdateWorkspaceAgentStartupLogOverflowByID :exec +UPDATE + workspace_agents +SET + startup_logs_overflowed = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAgentStartupLogOverflowByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + StartupLogsOverflowed bool `db:"startup_logs_overflowed" json:"startup_logs_overflowed"` +} + +func (q *sqlQuerier) UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentStartupLogOverflowByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAgentStartupLogOverflowByID, arg.ID, arg.StartupLogsOverflowed) + return err +} + const deleteOldWorkspaceAgentStats = `-- name: DeleteOldWorkspaceAgentStats :exec DELETE FROM workspace_agent_stats WHERE created_at < NOW() - INTERVAL '30 days' ` diff --git a/coderd/database/queries/provisionerjoblogs.sql b/coderd/database/queries/provisionerjoblogs.sql index 7d6fece0d8201..b98cf471f0d1a 100644 --- a/coderd/database/queries/provisionerjoblogs.sql +++ b/coderd/database/queries/provisionerjoblogs.sql @@ -1,4 +1,4 @@ --- name: GetProvisionerLogsByIDBetween :many +-- name: GetProvisionerLogsAfterID :many SELECT * FROM @@ -7,7 +7,6 @@ WHERE job_id = @job_id AND ( id > @created_after - OR id < @created_before ) ORDER BY id ASC; -- name: InsertProvisionerJobLogs :many diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 2bc30faf21095..3d7438252ffb8 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -93,3 +93,42 @@ SET lifecycle_state = $2 WHERE id = $1; + +-- name: UpdateWorkspaceAgentStartupLogOverflowByID :exec +UPDATE + workspace_agents +SET + startup_logs_overflowed = $2 +WHERE + id = $1; + +-- name: GetWorkspaceAgentStartupLogsAfter :many +SELECT + * +FROM + workspace_agent_startup_logs +WHERE + agent_id = $1 + AND ( + id > @created_after + ) ORDER BY id ASC; + +-- name: InsertWorkspaceAgentStartupLogs :many +WITH new_length AS ( + UPDATE workspace_agents SET + startup_logs_length = startup_logs_length + @output_length WHERE workspace_agents.id = @agent_id +) +INSERT INTO + workspace_agent_startup_logs + SELECT + @agent_id :: uuid AS agent_id, + unnest(@created_at :: timestamptz [ ]) AS created_at, + unnest(@output :: VARCHAR(1024) [ ]) AS output + RETURNING workspace_agent_startup_logs.*; + +-- If an agent hasn't connected in the last 7 days, we purge it's logs. +-- Logs can take up a lot of space, so it's important we clean up frequently. +-- name: DeleteOldWorkspaceAgentStartupLogs :exec +DELETE FROM workspace_agent_startup_logs WHERE agent_id IN + (SELECT id FROM workspace_agents WHERE last_connected_at IS NOT NULL + AND last_connected_at < NOW() - INTERVAL '7 day'); diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index f65d82dd62a02..7d8ce52ffd452 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -9,7 +9,6 @@ import ( "golang.org/x/exp/maps" "golang.org/x/exp/slices" - "golang.org/x/xerrors" "github.com/google/uuid" @@ -168,10 +167,6 @@ func countUniqueUsers(rows []database.GetTemplateDAUsRow) int { func (c *Cache) refreshTemplateDAUs(ctx context.Context) error { //nolint:gocritic // This is a system service. ctx = dbauthz.AsSystemRestricted(ctx) - err := c.database.DeleteOldWorkspaceAgentStats(ctx) - if err != nil { - return xerrors.Errorf("delete old stats: %w", err) - } templates, err := c.database.GetTemplates(ctx) if err != nil { diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index d324e9551f690..50915e1fe4042 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -25,57 +25,18 @@ import ( // Returns provisioner logs based on query parameters. // The intended usage for a client to stream all logs (with JS API): -// const timestamp = new Date().getTime(); -// 1. GET /logs?before= -// 2. GET /logs?after=&follow +// GET /logs +// GET /logs?after=&follow // The combination of these responses should provide all current logs // to the consumer, and future logs are streamed in the follow request. func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job database.ProvisionerJob) { var ( - ctx = r.Context() - actor, _ = dbauthz.ActorFromContext(ctx) - logger = api.Logger.With(slog.F("job_id", job.ID)) - follow = r.URL.Query().Has("follow") - afterRaw = r.URL.Query().Get("after") - beforeRaw = r.URL.Query().Get("before") + ctx = r.Context() + actor, _ = dbauthz.ActorFromContext(ctx) + logger = api.Logger.With(slog.F("job_id", job.ID)) + follow = r.URL.Query().Has("follow") + afterRaw = r.URL.Query().Get("after") ) - if beforeRaw != "" && follow { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Query param \"before\" cannot be used with \"follow\".", - }) - return - } - - // if we are following logs, start the subscription before we query the database, so that we don't miss any logs - // between the end of our query and the start of the subscription. We might get duplicates, so we'll keep track - // of processed IDs. - var bufferedLogs <-chan *database.ProvisionerJobLog - if follow { - bl, closeFollow, err := api.followLogs(actor, job.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error watching provisioner logs.", - Detail: err.Error(), - }) - return - } - defer closeFollow() - bufferedLogs = bl - - // Next query the job itself to see if it is complete. If so, the historical query to the database will return - // the full set of logs. It's a little sad to have to query the job again, given that our caller definitely - // has, but we need to query it *after* we start following the pubsub to avoid a race condition where the job - // completes between the prior query and the start of following the pubsub. A more substantial refactor could - // avoid this, but not worth it for one fewer query at this point. - job, err = api.Database.GetProvisionerJobByID(ctx, job.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error querying job.", - Detail: err.Error(), - }) - return - } - } var after int64 // Only fetch logs created after the time provided. @@ -92,26 +53,10 @@ func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job return } } - var before int64 - // Only fetch logs created before the time provided. - if beforeRaw != "" { - var err error - before, err = strconv.ParseInt(beforeRaw, 10, 64) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Query param \"before\" must be an integer.", - Validations: []codersdk.ValidationError{ - {Field: "before", Detail: "Must be an integer"}, - }, - }) - return - } - } - logs, err := api.Database.GetProvisionerLogsByIDBetween(ctx, database.GetProvisionerLogsByIDBetweenParams{ - JobID: job.ID, - CreatedAfter: after, - CreatedBefore: before, + logs, err := api.Database.GetProvisionerLogsAfterID(ctx, database.GetProvisionerLogsAfterIDParams{ + JobID: job.ID, + CreatedAfter: after, }) if errors.Is(err, sql.ErrNoRows) { err = nil @@ -162,11 +107,27 @@ func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job } } if job.CompletedAt.Valid { - // job was complete before we queried the database for historical logs, meaning we got everything. No need - // to stream anything from the bufferedLogs. + // job was complete before we queried the database for historical logs return } + // if we are following logs, start the subscription before we query the database, so that we don't miss any logs + // between the end of our query and the start of the subscription. We might get duplicates, so we'll keep track + // of processed IDs. + var bufferedLogs <-chan *database.ProvisionerJobLog + if follow { + bl, closeFollow, err := api.followProvisionerJobLogs(actor, job.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error watching provisioner logs.", + Detail: err.Error(), + }) + return + } + defer closeFollow() + bufferedLogs = bl + } + for { select { case <-ctx.Done(): @@ -382,7 +343,7 @@ type provisionerJobLogsMessage struct { EndOfLogs bool `json:"end_of_logs,omitempty"` } -func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *database.ProvisionerJobLog, func(), error) { +func (api *API) followProvisionerJobLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *database.ProvisionerJobLog, func(), error) { logger := api.Logger.With(slog.F("job_id", jobID)) var ( @@ -419,7 +380,7 @@ func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *databas // CreatedAfter is sent when logs are streaming! if jlMsg.CreatedAfter != 0 { - logs, err := api.Database.GetProvisionerLogsByIDBetween(dbauthz.As(ctx, actor), database.GetProvisionerLogsByIDBetweenParams{ + logs, err := api.Database.GetProvisionerLogsAfterID(dbauthz.As(ctx, actor), database.GetProvisionerLogsAfterIDParams{ JobID: jobID, CreatedAfter: jlMsg.CreatedAfter, }) @@ -443,7 +404,7 @@ func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *databas // so we fetch logs after the last ID we've seen and send them! if jlMsg.EndOfLogs { endOfLogs.Store(true) - logs, err := api.Database.GetProvisionerLogsByIDBetween(dbauthz.As(ctx, actor), database.GetProvisionerLogsByIDBetweenParams{ + logs, err := api.Database.GetProvisionerLogsAfterID(dbauthz.As(ctx, actor), database.GetProvisionerLogsAfterIDParams{ JobID: jobID, CreatedAfter: lastSentLogID.Load(), }) @@ -458,8 +419,6 @@ func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *databas logger.Debug(ctx, "got End of Logs") bufferedLogs <- nil } - - lastSentLogID.Store(jlMsg.CreatedAfter) }, ) if err != nil { diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index 1e0b327d355e6..505031d50c949 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -89,36 +89,4 @@ func TestProvisionerJobLogs(t *testing.T) { } } }) - - t.Run("List", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ - Log: &proto.Log{ - Level: proto.LogLevel_INFO, - Output: "log-output", - }, - }, - }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - logs, err := client.WorkspaceBuildLogsBefore(ctx, workspace.LatestBuild.ID, 0) - require.NoError(t, err) - require.Greater(t, len(logs), 1) - }) } diff --git a/coderd/templateversions.go b/coderd/templateversions.go index bfa52a3eab4b9..ef2df44d5f07b 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1482,8 +1482,8 @@ func (api *API) templateVersionResources(rw http.ResponseWriter, r *http.Request // @Produce json // @Tags Templates // @Param templateversion path string true "Template version ID" format(uuid) -// @Param before query int false "Before Unix timestamp" -// @Param after query int false "After Unix timestamp" +// @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.ProvisionerJobLog // @Router /templateversions/{templateversion}/logs [get] diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index a83bba5d9d31e..808ba4f1915a0 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -216,6 +217,318 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques httpapi.Write(ctx, rw, http.StatusOK, nil) } +// @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) + outputLength := 0 + for _, log := range req.Logs { + createdAt = append(createdAt, log.CreatedAt) + output = append(output, log.Output) + outputLength += len(log.Output) + } + logs, err := api.Database.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ + AgentID: workspaceAgent.ID, + CreatedAt: createdAt, + Output: output, + 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() + workspaceAgent = httpmw.WorkspaceAgentParam(r) + workspace = httpmw.WorkspaceParam(r) + logger = api.Logger.With(slog.F("workspace_agent_id", workspaceAgent.ID)) + follow = r.URL.Query().Has("follow") + afterRaw = r.URL.Query().Get("after") + ) + if !api.Authorize(r, rbac.ActionRead, workspace) { + httpapi.ResourceNotFound(rw) + return + } + + 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 { + logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ + 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) + logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ + 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 + } + } + } +} + // workspaceAgentPTY spawns a PTY and pipes it over a WebSocket. // This is used for the web terminal. // @@ -851,6 +1164,8 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin Architecture: dbAgent.Architecture, OperatingSystem: dbAgent.OperatingSystem, StartupScript: dbAgent.StartupScript.String, + StartupLogsLength: dbAgent.StartupLogsLength, + StartupLogsOverflowed: dbAgent.StartupLogsOverflowed, Version: dbAgent.Version, EnvironmentVariables: envs, Directory: dbAgent.Directory, @@ -1525,3 +1840,19 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock Conn: nc, } } + +func convertWorkspaceAgentStartupLogs(logs []database.WorkspaceAgentStartupLog) []codersdk.WorkspaceAgentStartupLog { + sdk := make([]codersdk.WorkspaceAgentStartupLog, 0, len(logs)) + for _, log := range logs { + sdk = append(sdk, convertWorkspaceAgentStartupLog(log)) + } + return sdk +} + +func convertWorkspaceAgentStartupLog(log database.WorkspaceAgentStartupLog) codersdk.WorkspaceAgentStartupLog { + return codersdk.WorkspaceAgentStartupLog{ + ID: log.ID, + CreatedAt: log.CreatedAt, + Output: log.Output, + } +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 8e7546cf236b6..b17eb7cdbc371 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -175,6 +175,128 @@ func TestWorkspaceAgent(t *testing.T) { }) } +func TestWorkspaceAgentStartupLogs(t *testing.T) { + t.Parallel() + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := testutil.Context(t) + defer cancelFunc() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + err := agentClient.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ + Logs: []agentsdk.StartupLog{{ + CreatedAt: database.Now(), + Output: "testing", + }}, + }) + require.NoError(t, err) + + logs, closer, err := client.WorkspaceAgentStartupLogsAfter(ctx, build.Resources[0].Agents[0].ID, -500) + require.NoError(t, err) + defer func() { + _ = closer.Close() + }() + var logChunk []codersdk.WorkspaceAgentStartupLog + select { + case <-ctx.Done(): + case logChunk = <-logs: + } + require.NoError(t, ctx.Err()) + require.Len(t, logChunk, 1) + require.Equal(t, "testing", logChunk[0].Output) + cancelFunc() + }) + t.Run("PublishesOnOverflow", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := testutil.Context(t) + defer cancelFunc() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + updates, err := client.WatchWorkspace(ctx, workspace.ID) + require.NoError(t, err) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + err = agentClient.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ + Logs: []agentsdk.StartupLog{{ + CreatedAt: database.Now(), + Output: strings.Repeat("a", (1<<20)+1), + }}, + }) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusRequestEntityTooLarge, apiError.StatusCode()) + + var update codersdk.Workspace + select { + case <-ctx.Done(): + t.FailNow() + case update = <-updates: + } + // Ensure that the UI gets an update when the logs overflow! + require.True(t, update.LatestBuild.Resources[0].Agents[0].StartupLogsOverflowed) + cancelFunc() + }) +} + func TestWorkspaceAgentListen(t *testing.T) { t.Parallel() diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index e217172c6d776..da5df152f06a0 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -249,3 +249,7 @@ func (*client) PostAppHealth(_ context.Context, _ agentsdk.PostAppHealthsRequest func (*client) PostStartup(_ context.Context, _ agentsdk.PostStartupRequest) error { return nil } + +func (*client) PatchStartupLogs(_ context.Context, _ agentsdk.PatchStartupLogs) error { + return nil +} diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 7035fbefcdbd9..6ebec9a42f26a 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -516,6 +516,29 @@ func (c *Client) PostStartup(ctx context.Context, req PostStartupRequest) error return nil } +type StartupLog struct { + CreatedAt time.Time `json:"created_at"` + Output string `json:"output"` +} + +type PatchStartupLogs struct { + Logs []StartupLog `json:"logs"` +} + +// PatchStartupLogs writes log messages to the agent startup script. +// Log messages are limited to 1MB in total. +func (c *Client) PatchStartupLogs(ctx context.Context, req PatchStartupLogs) error { + res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/startup-logs", req) + 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"` @@ -589,3 +612,14 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock Conn: nc, } } + +// StartupLogsNotifyChannel returns the channel name responsible for notifying +// of new startup logs. +func StartupLogsNotifyChannel(agentID uuid.UUID) string { + return fmt.Sprintf("startup-logs:%s", agentID) +} + +type StartupLogsNotifyMessage struct { + CreatedAfter int64 `json:"created_after"` + EndOfLogs bool `json:"end_of_logs"` +} diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 0a751169bf0d3..0479d05ae5221 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -9,8 +9,6 @@ import ( "net" "net/http" "net/http/cookiejar" - "net/url" - "strconv" "time" "github.com/google/uuid" @@ -100,27 +98,6 @@ type ProvisionerJobLog struct { Output string `json:"output"` } -// provisionerJobLogsBefore provides log output that occurred before a time. -// This is abstracted from a specific job type to provide consistency between -// APIs. Logs is the only shared route between jobs. -func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, before int64) ([]ProvisionerJobLog, error) { - values := url.Values{} - if before != 0 { - values["before"] = []string{strconv.FormatInt(before, 10)} - } - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("%s?%s", path, values.Encode()), nil) - if err != nil { - return nil, err - } - if res.StatusCode != http.StatusOK { - defer res.Body.Close() - return nil, ReadBodyAsError(res) - } - - var logs []ProvisionerJobLog - return logs, json.NewDecoder(res.Body).Decode(&logs) -} - // provisionerJobLogsAfter streams logs that occurred after a specific time. func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { afterQuery := "" diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index d82f86a2c38b2..c823a79282899 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -191,11 +191,6 @@ func (c *Client) TemplateVersionVariables(ctx context.Context, version uuid.UUID return variables, json.NewDecoder(res.Body).Decode(&variables) } -// TemplateVersionLogsBefore returns logs that occurred before a specific log ID. -func (c *Client) TemplateVersionLogsBefore(ctx context.Context, version uuid.UUID, before int64) ([]ProvisionerJobLog, error) { - return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/templateversions/%s/logs", version), before) -} - // TemplateVersionLogsAfter streams logs for a template version that occurred after a specific log ID. func (c *Client) TemplateVersionLogsAfter(ctx context.Context, version uuid.UUID, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/templateversions/%s/logs", version), after) @@ -258,12 +253,6 @@ func (c *Client) TemplateVersionDryRunResources(ctx context.Context, version, jo return resources, json.NewDecoder(res.Body).Decode(&resources) } -// TemplateVersionDryRunLogsBefore returns logs for a template version dry-run -// that occurred before a specific log ID. -func (c *Client) TemplateVersionDryRunLogsBefore(ctx context.Context, version, job uuid.UUID, before int64) ([]ProvisionerJobLog, error) { - return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/templateversions/%s/dry-run/%s/logs", version, job), before) -} - // TemplateVersionDryRunLogsAfter streams logs for a template version dry-run // that occurred after a specific log ID. func (c *Client) TemplateVersionDryRunLogsAfter(ctx context.Context, version, job uuid.UUID, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 5c52061c37ec8..0504c33d2569a 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/cookiejar" @@ -74,25 +75,27 @@ var WorkspaceAgentLifecycleOrder = []WorkspaceAgentLifecycle{ } type WorkspaceAgent struct { - ID uuid.UUID `json:"id" format:"uuid"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` - FirstConnectedAt *time.Time `json:"first_connected_at,omitempty" format:"date-time"` - LastConnectedAt *time.Time `json:"last_connected_at,omitempty" format:"date-time"` - DisconnectedAt *time.Time `json:"disconnected_at,omitempty" format:"date-time"` - Status WorkspaceAgentStatus `json:"status"` - LifecycleState WorkspaceAgentLifecycle `json:"lifecycle_state"` - Name string `json:"name"` - ResourceID uuid.UUID `json:"resource_id" format:"uuid"` - InstanceID string `json:"instance_id,omitempty"` - Architecture string `json:"architecture"` - EnvironmentVariables map[string]string `json:"environment_variables"` - OperatingSystem string `json:"operating_system"` - StartupScript string `json:"startup_script,omitempty"` - Directory string `json:"directory,omitempty"` - ExpandedDirectory string `json:"expanded_directory,omitempty"` - Version string `json:"version"` - Apps []WorkspaceApp `json:"apps"` + ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + FirstConnectedAt *time.Time `json:"first_connected_at,omitempty" format:"date-time"` + LastConnectedAt *time.Time `json:"last_connected_at,omitempty" format:"date-time"` + DisconnectedAt *time.Time `json:"disconnected_at,omitempty" format:"date-time"` + Status WorkspaceAgentStatus `json:"status"` + LifecycleState WorkspaceAgentLifecycle `json:"lifecycle_state"` + Name string `json:"name"` + ResourceID uuid.UUID `json:"resource_id" format:"uuid"` + InstanceID string `json:"instance_id,omitempty"` + Architecture string `json:"architecture"` + EnvironmentVariables map[string]string `json:"environment_variables"` + OperatingSystem string `json:"operating_system"` + StartupScript string `json:"startup_script,omitempty"` + StartupLogsLength int32 `json:"startup_logs_length"` + StartupLogsOverflowed bool `json:"startup_logs_overflowed"` + Directory string `json:"directory,omitempty"` + ExpandedDirectory string `json:"expanded_directory,omitempty"` + Version string `json:"version"` + Apps []WorkspaceApp `json:"apps"` // DERPLatency is mapped by region name (e.g. "New York City", "Seattle"). DERPLatency map[string]DERPRegion `json:"latency,omitempty"` ConnectionTimeoutSeconds int32 `json:"connection_timeout_seconds"` @@ -322,6 +325,65 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid. return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) } +func (c *Client) WorkspaceAgentStartupLogsAfter(ctx context.Context, agentID uuid.UUID, after int64) (<-chan []WorkspaceAgentStartupLog, io.Closer, error) { + afterQuery := "" + if after != 0 { + afterQuery = fmt.Sprintf("&after=%d", after) + } + followURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/startup-logs?follow%s", agentID, afterQuery)) + if err != nil { + return nil, nil, err + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(followURL, []*http.Cookie{{ + Name: SessionTokenCookie, + Value: c.SessionToken(), + }}) + httpClient := &http.Client{ + Jar: jar, + Transport: c.HTTPClient.Transport, + } + conn, res, err := websocket.Dial(ctx, followURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return nil, nil, err + } + return nil, nil, ReadBodyAsError(res) + } + logChunks := make(chan []WorkspaceAgentStartupLog) + closed := make(chan struct{}) + ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageText) + decoder := json.NewDecoder(wsNetConn) + go func() { + defer close(closed) + defer close(logChunks) + defer conn.Close(websocket.StatusGoingAway, "") + var logs []WorkspaceAgentStartupLog + for { + err = decoder.Decode(&logs) + if err != nil { + return + } + select { + case <-ctx.Done(): + return + case logChunks <- logs: + } + } + }() + return logChunks, closeFunc(func() error { + _ = wsNetConn.Close() + <-closed + return nil + }), nil +} + // GitProvider is a constant that represents the // type of providers that are supported within Coder. type GitProvider string @@ -347,3 +409,9 @@ const ( GitProviderGitLab GitProvider = "gitlab" GitProviderBitBucket GitProvider = "bitbucket" ) + +type WorkspaceAgentStartupLog struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + Output string `json:"output"` +} diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 2d8a70724d1d5..c7bdf022d238f 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -130,11 +130,6 @@ func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error { return nil } -// WorkspaceBuildLogsBefore returns logs that occurred before a specific log ID. -func (c *Client) WorkspaceBuildLogsBefore(ctx context.Context, build uuid.UUID, before int64) ([]ProvisionerJobLog, error) { - return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), before) -} - // WorkspaceBuildLogsAfter streams logs for a workspace build that occurred after a specific log ID. func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, build uuid.UUID, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), after) diff --git a/docs/api/agents.md b/docs/api/agents.md index 6031e8698815b..7757a89c89aeb 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -519,6 +519,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -717,3 +719,58 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/pty | 101 | [Switching Protocols](https://tools.ietf.org/html/rfc7231#section-6.2.2) | Switching Protocols | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get startup logs by workspace agent + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/startup-logs \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceagents/{workspaceagent}/startup-logs` + +### Parameters + +| Name | In | Type | Required | Description | +| ---------------- | ----- | ------------ | -------- | ------------------ | +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | +| `before` | query | integer | false | Before log id | +| `after` | query | integer | false | After log id | +| `follow` | query | boolean | false | Follow log stream | + +### Example responses + +> 200 Response + +```json +[ + { + "created_at": "2019-08-24T14:15:22Z", + "id": 0, + "output": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.WorkspaceAgentStartupLog](schemas.md#codersdkworkspaceagentstartuplog) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ----------------- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» id` | integer | false | | | +| `» output` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/builds.md b/docs/api/builds.md index d46abaada436d..ddb1539773506 100644 --- a/docs/api/builds.md +++ b/docs/api/builds.md @@ -106,6 +106,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -256,6 +258,8 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -547,6 +551,8 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -627,6 +633,8 @@ Status Code **200** | `»» resource_id` | string(uuid) | false | | | | `»» shutdown_script` | string | false | | | | `»» shutdown_script_timeout_seconds` | integer | false | | | +| `»» startup_logs_length` | integer | false | | | +| `»» startup_logs_overflowed` | boolean | false | | | | `»» startup_script` | string | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | @@ -781,6 +789,8 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -936,6 +946,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -1050,6 +1062,8 @@ Status Code **200** | `»»» resource_id` | string(uuid) | false | | | | `»»» shutdown_script` | string | false | | | | `»»» shutdown_script_timeout_seconds` | integer | false | | | +| `»»» startup_logs_length` | integer | false | | | +| `»»» startup_logs_overflowed` | boolean | false | | | | `»»» startup_script` | string | false | | | | `»»» startup_script_timeout_seconds` | integer | false | | »»startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | @@ -1266,6 +1280,8 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 5901610907217..9a92f20f1ca3b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -200,6 +200,25 @@ | `startup_script_timeout` | integer | false | | | | `vscode_port_proxy_uri` | string | false | | | +## agentsdk.PatchStartupLogs + +```json +{ + "logs": [ + { + "created_at": "string", + "output": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------ | --------------------------------------------------- | -------- | ------------ | ----------- | +| `logs` | array of [agentsdk.StartupLog](#agentsdkstartuplog) | false | | | + ## agentsdk.PostAppHealthsRequest ```json @@ -248,6 +267,22 @@ | `expanded_directory` | string | false | | | | `version` | string | false | | | +## agentsdk.StartupLog + +```json +{ + "created_at": "string", + "output": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------ | ------ | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `output` | string | false | | | + ## agentsdk.Stats ```json @@ -4321,6 +4356,8 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -4448,6 +4485,8 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -4483,6 +4522,8 @@ Parameter represents a set value for the scope. | `resource_id` | string | false | | | | `shutdown_script` | string | false | | | | `shutdown_script_timeout_seconds` | integer | false | | | +| `startup_logs_length` | integer | false | | | +| `startup_logs_overflowed` | boolean | false | | | | `startup_script` | string | false | | | | `startup_script_timeout_seconds` | integer | false | | Startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `status` | [codersdk.WorkspaceAgentStatus](#codersdkworkspaceagentstatus) | false | | | @@ -4614,6 +4655,24 @@ Parameter represents a set value for the scope. | ------- | ------------------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ports` | array of [codersdk.WorkspaceAgentListeningPort](#codersdkworkspaceagentlisteningport) | false | | 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. | +## codersdk.WorkspaceAgentStartupLog + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "id": 0, + "output": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------ | ------- | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `id` | integer | false | | | +| `output` | string | false | | | + ## codersdk.WorkspaceAgentStatus ```json @@ -4793,6 +4852,8 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -5012,6 +5073,8 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -5207,6 +5270,8 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", diff --git a/docs/api/templates.md b/docs/api/templates.md index 7953da485e25a..b083d7b35c81c 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1709,6 +1709,8 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -1789,6 +1791,8 @@ Status Code **200** | `»» resource_id` | string(uuid) | false | | | | `»» shutdown_script` | string | false | | | | `»» shutdown_script_timeout_seconds` | integer | false | | | +| `»» startup_logs_length` | integer | false | | | +| `»» startup_logs_overflowed` | boolean | false | | | | `»» startup_script` | string | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | @@ -1917,12 +1921,12 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/l ### Parameters -| Name | In | Type | Required | Description | -| ----------------- | ----- | ------------ | -------- | --------------------- | -| `templateversion` | path | string(uuid) | true | Template version ID | -| `before` | query | integer | false | Before Unix timestamp | -| `after` | query | integer | false | After Unix timestamp | -| `follow` | query | boolean | false | Follow log stream | +| Name | In | Type | Required | Description | +| ----------------- | ----- | ------------ | -------- | ------------------- | +| `templateversion` | path | string(uuid) | true | Template version ID | +| `before` | query | integer | false | Before log id | +| `after` | query | integer | false | After log id | +| `follow` | query | boolean | false | Follow log stream | ### Example responses @@ -2134,6 +2138,8 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -2214,6 +2220,8 @@ Status Code **200** | `»» resource_id` | string(uuid) | false | | | | `»» shutdown_script` | string | false | | | | `»» shutdown_script_timeout_seconds` | integer | false | | | +| `»» startup_logs_length` | integer | false | | | +| `»» startup_logs_overflowed` | boolean | false | | | | `»» startup_script` | string | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index e37c0ff7cd9ca..ba896f5afc8f2 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -138,6 +138,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -308,6 +310,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -497,6 +501,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -668,6 +674,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_length": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", diff --git a/go.mod b/go.mod index de972aceef747..eba1883eca55a 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.5 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/adrg/xdg v0.4.0 + github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd github.com/andybalholm/brotli v1.0.4 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible diff --git a/go.sum b/go.sum index f2979a3e5b6ae..58d3cd6a3db8b 100644 --- a/go.sum +++ b/go.sum @@ -193,6 +193,8 @@ github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1L github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd h1:WOzjyD34+0vVw3wzE7js8Yvzo08ljzvK1jG6wL8elVU= +github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd/go.mod h1:VM1c/0Tl3O26UkHMbU32VFqLwLvi2FA40b6s5vPOpoo= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= diff --git a/site/package.json b/site/package.json index 24cf7c8ac7f0f..d279561ee0353 100644 --- a/site/package.json +++ b/site/package.json @@ -73,6 +73,8 @@ "react-markdown": "8.0.3", "react-router-dom": "6.4.1", "react-syntax-highlighter": "15.5.0", + "react-virtualized-auto-sizer": "^1.0.7", + "react-window": "^1.8.8", "remark-gfm": "3.0.1", "rollup-plugin-visualizer": "5.9.0", "sourcemapped-stacktrace": "1.1.11", @@ -104,6 +106,8 @@ "@types/react-dom": "18.0.6", "@types/react-helmet": "6.1.5", "@types/react-syntax-highlighter": "15.5.5", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", "@types/semver": "7.3.12", "@types/ua-parser-js": "0.7.36", "@types/uuid": "8.3.4", diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c5c1debd5dc4d..73a133f27e02d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -694,6 +694,15 @@ export const getWorkspaceBuildLogs = async ( return response.data } +export const getWorkspaceAgentStartupLogs = async ( + agentID: string, +): Promise => { + const response = await axios.get( + `/api/v2/workspaceagents/${agentID}/startup-logs`, + ) + return response.data +} + export const putWorkspaceExtension = async ( workspaceId: string, newDeadline: dayjs.Dayjs, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b7a3e6f2d3fed..34a0cd02dc2b2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1042,6 +1042,8 @@ export interface WorkspaceAgent { readonly environment_variables: Record readonly operating_system: string readonly startup_script?: string + readonly startup_logs_length: number + readonly startup_logs_overflowed: boolean readonly directory?: string readonly expanded_directory?: string readonly version: string @@ -1067,6 +1069,13 @@ export interface WorkspaceAgentListeningPortsResponse { readonly ports: WorkspaceAgentListeningPort[] } +// From codersdk/workspaceagents.go +export interface WorkspaceAgentStartupLog { + readonly id: number + readonly created_at: string + readonly output: string +} + // From codersdk/workspaceapps.go export interface WorkspaceApp { readonly id: string diff --git a/site/src/components/Logs/Logs.stories.tsx b/site/src/components/Logs/Logs.stories.tsx index f5d56590f24d4..7f211cc9381e1 100644 --- a/site/src/components/Logs/Logs.stories.tsx +++ b/site/src/components/Logs/Logs.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, Story } from "@storybook/react" +import { LogLevel } from "api/typesGenerated" import { MockWorkspaceBuildLogs } from "../../testHelpers/entities" import { Logs, LogsProps } from "./Logs" @@ -12,8 +13,15 @@ const Template: Story = (args) => const lines = MockWorkspaceBuildLogs.map((log) => ({ time: log.created_at, output: log.output, + level: "info" as LogLevel, })) export const Example = Template.bind({}) Example.args = { lines, } + +export const WithLineNumbers = Template.bind({}) +WithLineNumbers.args = { + lines, + lineNumbers: true, +} diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index b1a101ecb5890..685be552c24b2 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -1,11 +1,11 @@ -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, Theme } from "@material-ui/core/styles" import { LogLevel } from "api/typesGenerated" import dayjs from "dayjs" import { FC } from "react" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { combineClasses } from "../../util/combineClasses" -interface Line { +export interface Line { time: string output: string level: LogLevel @@ -14,15 +14,19 @@ interface Line { export interface LogsProps { lines: Line[] hideTimestamps?: boolean + lineNumbers?: boolean className?: string } export const Logs: FC> = ({ hideTimestamps, lines, + lineNumbers, className = "", }) => { - const styles = useStyles() + const styles = useStyles({ + lineNumbers: Boolean(lineNumbers), + }) return (
@@ -32,7 +36,9 @@ export const Logs: FC> = ({ {!hideTimestamps && ( <> - {dayjs(line.time).format(`HH:mm:ss.SSS`)} + {lineNumbers + ? idx + 1 + : dayjs(line.time).format(`HH:mm:ss.SSS`)}      @@ -45,22 +51,55 @@ export const Logs: FC> = ({ ) } -const useStyles = makeStyles((theme) => ({ +export const logLineHeight = 20 + +export const LogLine: FC<{ + line: Line + hideTimestamp?: boolean + number?: number + style?: React.CSSProperties +}> = ({ line, hideTimestamp, number, style }) => { + const styles = useStyles({ + lineNumbers: Boolean(number), + }) + + return ( +
+ {!hideTimestamp && ( + <> + + {number ? number : dayjs(line.time).format(`HH:mm:ss.SSS`)} + +      + + )} + {line.output} +
+ ) +} + +const useStyles = makeStyles< + Theme, + { + lineNumbers: boolean + } +>((theme) => ({ root: { minHeight: 156, - background: theme.palette.background.default, - color: theme.palette.text.primary, - fontFamily: MONOSPACE_FONT_FAMILY, fontSize: 13, - wordBreak: "break-all", padding: theme.spacing(2, 0), borderRadius: theme.shape.borderRadius, overflowX: "auto", + background: theme.palette.background.default, }, scrollWrapper: { width: "fit-content", }, line: { + wordBreak: "break-all", + color: theme.palette.text.primary, + fontFamily: MONOSPACE_FONT_FAMILY, + height: ({ lineNumbers }) => (lineNumbers ? logLineHeight : "auto"), // Whitespace is significant in terminal output for alignment whiteSpace: "pre", padding: theme.spacing(0, 3), @@ -78,7 +117,8 @@ const useStyles = makeStyles((theme) => ({ }, time: { userSelect: "none", - width: theme.spacing(12.5), + width: ({ lineNumbers }) => theme.spacing(lineNumbers ? 3.5 : 12.5), + whiteSpace: "pre", display: "inline-block", color: theme.palette.text.secondary, }, diff --git a/site/src/components/Resources/AgentRow.stories.tsx b/site/src/components/Resources/AgentRow.stories.tsx index d994ec77faa1d..823c662ab70e0 100644 --- a/site/src/components/Resources/AgentRow.stories.tsx +++ b/site/src/components/Resources/AgentRow.stories.tsx @@ -5,6 +5,7 @@ import { MockWorkspaceAgentConnecting, MockWorkspaceAgentOff, MockWorkspaceAgentOutdated, + MockWorkspaceAgentReady, MockWorkspaceAgentShutdownError, MockWorkspaceAgentShutdownTimeout, MockWorkspaceAgentShuttingDown, @@ -100,6 +101,41 @@ Starting.args = { workspace: MockWorkspace, applicationsHost: "", showApps: true, + + storybookStartupLogs: [ + "Cloning Git repository...", + "Starting Docker Daemon...", + "Adding some 🧙magic🧙...", + "Starting VS Code...", + ].map((line, index) => ({ + id: index, + level: "info", + output: line, + time: "", + })), +} + +export const Started = Template.bind({}) +Started.args = { + agent: { + ...MockWorkspaceAgentReady, + startup_logs_length: 1, + }, + workspace: MockWorkspace, + applicationsHost: "", + showApps: true, + + storybookStartupLogs: [ + "Cloning Git repository...", + "Starting Docker Daemon...", + "Adding some 🧙magic🧙...", + "Starting VS Code...", + ].map((line, index) => ({ + id: index, + level: "info", + output: line, + time: "", + })), } export const StartTimeout = Template.bind({}) diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index d72a14b21ebbf..fd9dd4c146baa 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -1,19 +1,42 @@ -import { makeStyles } from "@material-ui/core/styles" +import Link from "@material-ui/core/Link" +import Popover from "@material-ui/core/Popover" +import { makeStyles, useTheme } from "@material-ui/core/styles" +import PlayCircleOutlined from "@material-ui/icons/PlayCircleFilledOutlined" +import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined" +import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" import { Skeleton } from "@material-ui/lab" +import { useMachine } from "@xstate/react" +import { AppLinkSkeleton } from "components/AppLink/AppLinkSkeleton" +import { Maybe } from "components/Conditionals/Maybe" +import { LogLine, logLineHeight } from "components/Logs/Logs" import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" -import { FC } from "react" +import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" +import { + FC, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" +import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism" +import AutoSizer from "react-virtualized-auto-sizer" +import { FixedSizeList as List, ListOnScrollProps } from "react-window" +import { + LineWithID, + workspaceAgentLogsMachine, +} from "xServices/workspaceAgentLogs/workspaceAgentLogsXService" import { Workspace, WorkspaceAgent } from "../../api/typesGenerated" import { AppLink } from "../AppLink/AppLink" import { SSHButton } from "../SSHButton/SSHButton" import { Stack } from "../Stack/Stack" import { TerminalLink } from "../TerminalLink/TerminalLink" import { AgentLatency } from "./AgentLatency" -import { AgentVersion } from "./AgentVersion" -import { Maybe } from "components/Conditionals/Maybe" import { AgentStatus } from "./AgentStatus" -import { AppLinkSkeleton } from "components/AppLink/AppLinkSkeleton" -import { useTranslation } from "react-i18next" -import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" +import { AgentVersion } from "./AgentVersion" export interface AgentRowProps { agent: WorkspaceAgent @@ -24,6 +47,8 @@ export interface AgentRowProps { hideVSCodeDesktopButton?: boolean serverVersion: string onUpdateAgent: () => void + + storybookStartupLogs?: LineWithID[] } export const AgentRow: FC = ({ @@ -35,126 +60,355 @@ export const AgentRow: FC = ({ hideVSCodeDesktopButton, serverVersion, onUpdateAgent, + storybookStartupLogs, }) => { const styles = useStyles() const { t } = useTranslation("agent") - return ( - - -
- -
-
-
{agent.name}
- - {agent.operating_system} - - - - + const [logsMachine, sendLogsEvent] = useMachine(workspaceAgentLogsMachine, { + context: { agentID: agent.id }, + services: process.env.STORYBOOK + ? { + getStartupLogs: async () => { + return storybookStartupLogs || [] + }, + streamStartupLogs: () => async () => { + // noop + }, + } + : undefined, + }) + const theme = useTheme() + const startupScriptAnchorRef = useRef(null) + const [startupScriptOpen, setStartupScriptOpen] = useState(false) + + const hasStartupFeatures = + Boolean(agent.startup_logs_length) || + Boolean(logsMachine.context.startupLogs?.length) - + const [showStartupLogs, setShowStartupLogs] = useState( + agent.lifecycle_state !== "ready" && hasStartupFeatures, + ) + useEffect(() => { + setShowStartupLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures) + }, [agent.lifecycle_state, hasStartupFeatures]) + // External applications can provide startup logs for an agent during it's spawn. + // These could be Kubernetes logs, or other logs that are useful to the user. + // For this reason, we want to fetch these logs when the agent is starting. + useEffect(() => { + if (agent.lifecycle_state === "starting") { + sendLogsEvent("FETCH_STARTUP_LOGS") + } + }, [sendLogsEvent, agent.lifecycle_state]) + useEffect(() => { + // We only want to fetch logs when they are actually shown, + // otherwise we can make a lot of requests that aren't necessary. + if (showStartupLogs) { + sendLogsEvent("FETCH_STARTUP_LOGS") + } + }, [sendLogsEvent, showStartupLogs]) + const logListRef = useRef(null) + const logListDivRef = useRef(null) + const startupLogs = useMemo(() => { + const allLogs = logsMachine.context.startupLogs || [] - - - - + const logs = [...allLogs] + if (agent.startup_logs_overflowed) { + logs.push({ + id: -1, + level: "error", + output: "Startup logs exceeded the max size of 1MB!", + time: new Date().toISOString(), + }) + } + return logs + }, [logsMachine.context.startupLogs, agent.startup_logs_overflowed]) + const [bottomOfLogs, setBottomOfLogs] = useState(true) + // This is a layout effect to remove flicker when we're scrolling to the bottom. + useLayoutEffect(() => { + // If we're currently watching the bottom, we always want to stay at the bottom. + if (bottomOfLogs && logListRef.current) { + logListRef.current.scrollToItem(startupLogs.length - 1, "end") + } + }, [showStartupLogs, startupLogs, logListRef, bottomOfLogs]) - - {t("unableToConnect")} - - -
-
+ // This is a bit of a hack on the react-window API to get the scroll position. + // If we're scrolled to the bottom, we want to keep the list scrolled to the bottom. + // This makes it feel similar to a terminal that auto-scrolls downwards! + const handleLogScroll = useCallback( + (props: ListOnScrollProps) => { + if ( + props.scrollOffset === 0 || + props.scrollUpdateWasRequested || + !logListDivRef.current + ) { + return + } + // The parent holds the height of the list! + const parent = logListDivRef.current.parentElement + if (!parent) { + return + } + const distanceFromBottom = + logListDivRef.current.scrollHeight - + (props.scrollOffset + parent.clientHeight) + setBottomOfLogs(distanceFromBottom < logLineHeight) + }, + [logListDivRef], + ) + return ( + - {showApps && agent.status === "connected" && ( - <> - {agent.apps.map((app) => ( - - ))} - - - {!hideSSHButton && ( - - )} - {!hideVSCodeDesktopButton && ( - + +
+ +
+
+
{agent.name}
+ + {agent.operating_system} + + + + + + + + + + + + + + {t("unableToConnect")} + + + + {hasStartupFeatures && ( + + { + setShowStartupLogs(!showStartupLogs) + }} + > + {showStartupLogs ? ( + + ) : ( + + )} + {showStartupLogs ? "Hide" : "Show"} Startup Logs + + + {agent.startup_script && ( + { + setStartupScriptOpen(!startupScriptOpen) + }} + > + + View Startup Script + + )} + + setStartupScriptOpen(false)} + anchorEl={startupScriptAnchorRef.current} + anchorOrigin={{ + vertical: "bottom", + horizontal: "left", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + > +
+ + {agent.startup_script || ""} + +
+
+
)} - {applicationsHost !== undefined && applicationsHost !== "" && ( - + + + + {showApps && agent.status === "connected" && ( + <> + {agent.apps.map((app) => ( + + ))} + + - )} - - )} - {showApps && agent.status === "connecting" && ( - <> - - - - )} + {!hideSSHButton && ( + + )} + {!hideVSCodeDesktopButton && ( + + )} + {applicationsHost !== undefined && applicationsHost !== "" && ( + + )} + + )} + {showApps && agent.status === "connecting" && ( + <> + + + + )} + + + {showStartupLogs && ( + + {({ width }) => ( + + {({ index, style }) => ( + + )} + + )} + + )} ) } const useStyles = makeStyles((theme) => ({ + agentWrapper: { + "&:not(:last-child)": { + borderBottom: `1px solid ${theme.palette.divider}`, + }, + }, + agentRow: { padding: theme.spacing(3, 4), backgroundColor: theme.palette.background.paperLight, fontSize: 16, + }, - "&:not(:last-child)": { - borderBottom: `1px solid ${theme.palette.divider}`, + startupLinks: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + marginTop: theme.spacing(0.5), + }, + + startupLink: { + cursor: "pointer", + display: "flex", + gap: 4, + alignItems: "center", + userSelect: "none", + whiteSpace: "nowrap", + + "& svg": { + width: 12, + height: 12, }, }, + startupLogs: { + maxHeight: 256, + background: theme.palette.background.default, + }, + + startupScriptPopover: { + backgroundColor: theme.palette.background.default, + }, + agentStatusWrapper: { width: theme.spacing(4.5), display: "flex", @@ -174,4 +428,10 @@ const useStyles = makeStyles((theme) => ({ color: theme.palette.text.secondary, marginTop: theme.spacing(0.5), }, + + agentStartupLogs: { + maxHeight: 200, + display: "flex", + flexDirection: "column-reverse", + }, })) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index ff4cdf7364f96..d2161fd17cb6a 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,8 +1,15 @@ import { makeStyles } from "@material-ui/core/styles" +import { Avatar } from "components/Avatar/Avatar" +import { AgentRow } from "components/Resources/AgentRow" +import { + ActiveTransition, + WorkspaceBuildProgress, +} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" import { FC } from "react" import { useNavigate } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" +import { AlertBanner } from "../AlertBanner/AlertBanner" import { BuildsTable } from "../BuildsTable/BuildsTable" import { Margins } from "../Margins/Margins" import { @@ -16,13 +23,6 @@ import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions" import { WorkspaceDeletedBanner } from "../WorkspaceDeletedBanner/WorkspaceDeletedBanner" import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" -import { AlertBanner } from "../AlertBanner/AlertBanner" -import { - ActiveTransition, - WorkspaceBuildProgress, -} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" -import { AgentRow } from "components/Resources/AgentRow" -import { Avatar } from "components/Avatar/Avatar" export enum WorkspaceErrors { GET_BUILDS_ERROR = "getBuildsError", @@ -249,5 +249,8 @@ export const useStyles = makeStyles((theme) => { timelineContents: { margin: 0, }, + logs: { + border: `1px solid ${theme.palette.divider}`, + }, } }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 1fae46c62c795..c805e33c02cef 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -2,13 +2,13 @@ import { makeStyles } from "@material-ui/core/styles" import { useMachine } from "@xstate/react" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { Loader } from "components/Loader/Loader" import { FC, useEffect } from "react" import { useParams } from "react-router-dom" -import { Loader } from "components/Loader/Loader" import { firstOrItem } from "util/array" +import { quotaMachine } from "xServices/quotas/quotasXService" import { workspaceMachine } from "xServices/workspace/workspaceXService" import { WorkspaceReadyPage } from "./WorkspaceReadyPage" -import { quotaMachine } from "xServices/quotas/quotasXService" export const WorkspacePage: FC = () => { const { username: usernameQueryParam, workspace: workspaceQueryParam } = diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index a1fb78c6e6636..aea797d1f262f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -405,6 +405,8 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { troubleshooting_url: "https://coder.com/troubleshoot", lifecycle_state: "starting", login_before_ready: false, + startup_logs_length: 0, + startup_logs_overflowed: false, startup_script_timeout_seconds: 120, shutdown_script_timeout_seconds: 120, } @@ -470,6 +472,13 @@ export const MockWorkspaceAgentStarting: TypesGen.WorkspaceAgent = { lifecycle_state: "starting", } +export const MockWorkspaceAgentReady: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: "test-workspace-agent-ready", + name: "a-ready-workspace-agent", + lifecycle_state: "ready", +} + export const MockWorkspaceAgentStartTimeout: TypesGen.WorkspaceAgent = { ...MockWorkspaceAgent, id: "test-workspace-agent-start-timeout", @@ -655,6 +664,7 @@ export const MockWorkspace: TypesGen.Workspace = { MockTemplate.allow_user_cancel_workspace_jobs, outdated: false, owner_id: MockUser.id, + organization_id: MockOrganization.id, owner_name: MockUser.username, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 68ae122a150a9..1fc1c543fd78e 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -699,6 +699,7 @@ export const workspaceMachine = createMachine( checkRefresh: true, data: JSON.parse(event.data), }) + // refresh }) // handle any error events returned by our sse diff --git a/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts b/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts new file mode 100644 index 0000000000000..69340c014be9b --- /dev/null +++ b/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts @@ -0,0 +1,127 @@ +import * as API from "api/api" +import { createMachine, assign } from "xstate" +import * as TypesGen from "api/typesGenerated" +import { Line } from "components/Logs/Logs" + +// Logs are stored as the Line interface to make rendering +// much more efficient. Instead of mapping objects each time, we're +// able to just pass the array of logs to the component. +export interface LineWithID extends Line { + id: number +} + +export const workspaceAgentLogsMachine = createMachine( + { + predictableActionArguments: true, + id: "workspaceAgentLogsMachine", + schema: { + events: {} as + | { + type: "ADD_STARTUP_LOGS" + logs: LineWithID[] + } + | { + type: "FETCH_STARTUP_LOGS" + }, + context: {} as { + agentID: string + startupLogs?: LineWithID[] + }, + services: {} as { + getStartupLogs: { + data: LineWithID[] + } + }, + }, + tsTypes: {} as import("./workspaceAgentLogsXService.typegen").Typegen0, + initial: "waiting", + states: { + waiting: { + on: { + FETCH_STARTUP_LOGS: "loading", + }, + }, + loading: { + invoke: { + src: "getStartupLogs", + onDone: { + target: "watchStartupLogs", + actions: ["assignStartupLogs"], + }, + }, + }, + watchStartupLogs: { + id: "watchingStartupLogs", + invoke: { + id: "streamStartupLogs", + src: "streamStartupLogs", + }, + }, + loaded: { + type: "final", + }, + }, + on: { + ADD_STARTUP_LOGS: { + actions: "addStartupLogs", + }, + }, + }, + { + services: { + getStartupLogs: (ctx) => + API.getWorkspaceAgentStartupLogs(ctx.agentID).then((data) => + data.map((log) => ({ + id: log.id, + level: "info" as TypesGen.LogLevel, + output: log.output, + time: log.created_at, + })), + ), + streamStartupLogs: (ctx) => async (callback) => { + return new Promise((resolve, reject) => { + const proto = location.protocol === "https:" ? "wss:" : "ws:" + let after = 0 + if (ctx.startupLogs && ctx.startupLogs.length > 0) { + after = ctx.startupLogs[ctx.startupLogs.length - 1].id + } + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/workspaceagents/${ctx.agentID}/startup-logs?follow&after=${after}`, + ) + socket.binaryType = "blob" + socket.addEventListener("message", (event) => { + const logs = JSON.parse( + event.data, + ) as TypesGen.WorkspaceAgentStartupLog[] + callback({ + type: "ADD_STARTUP_LOGS", + logs: logs.map((log) => ({ + id: log.id, + level: "info" as TypesGen.LogLevel, + output: log.output, + time: log.created_at, + })), + }) + }) + socket.addEventListener("error", () => { + reject(new Error("socket errored")) + }) + socket.addEventListener("open", () => { + resolve() + }) + }) + }, + }, + actions: { + assignStartupLogs: assign({ + startupLogs: (_, { data }) => data, + }), + addStartupLogs: assign({ + startupLogs: (context, event) => { + const previousLogs = context.startupLogs ?? [] + return [...previousLogs, ...event.logs] + }, + }), + }, + }, +) diff --git a/site/yarn.lock b/site/yarn.lock index 2f7c153725919..852fb9d0e8f34 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -3398,6 +3398,20 @@ dependencies: "@types/react" "*" +"@types/react-virtualized-auto-sizer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4" + integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong== + dependencies: + "@types/react" "*" + +"@types/react-window@^1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1" + integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw== + dependencies: + "@types/react" "*" + "@types/react@*": version "18.0.28" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.28.tgz#accaeb8b86f4908057ad629a26635fe641480065" @@ -10434,6 +10448,11 @@ memfs@^3.1.2: dependencies: fs-monkey "^1.0.3" +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memoizerific@^1.11.3: version "1.11.3" resolved "https://registry.yarnpkg.com/memoizerific/-/memoizerific-1.11.3.tgz#7c87a4646444c32d75438570905f2dbd1b1a805a" @@ -12493,6 +12512,19 @@ react-transition-group@^4.4.0: loose-envify "^1.4.0" prop-types "^15.6.2" +react-virtualized-auto-sizer@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz#bfb8414698ad1597912473de3e2e5f82180c1195" + integrity sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA== + +react-window@^1.8.8: + version "1.8.8" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.8.tgz#1b52919f009ddf91970cbdb2050a6c7be44df243" + integrity sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"