feat: add startup script logs to the ui (#6558)

* Add startup script logs to the database

* Add coderd endpoints for startup script logs

* Push startup script logs from agent

* Pull startup script logs on frontend

* Rename queries

* Add constraint

* Start creating log sending loop

* Add log sending to the agent

* Add tests for streaming logs

* Shorten notify channel name

* Add FE

* Improve bulk log performance

* Finish UI display

* Fix startup log visibility

* Add warning for overflow

* Fix agent queue logs overflow

* Display staartup logs in a virtual DOM for performance

* Fix agent queue with loads of logs

* Fix authorize test

* Remove faulty test

* Fix startup and shutdown reporting error

* Fix gen

* Fix comments

* Periodically purge old database entries

* Add test fixture for migration

* Add Storybook

* Check if there are logs when displaying features

* Fix startup component overflow gap

* Fix startup log wrapping

---------

Co-authored-by: Asher <ash@coder.com>
This commit is contained in:
Kyle Carberry 2023-03-23 14:09:13 -05:00 committed by GitHub
parent a6fa8cac58
commit cb7375450b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2513 additions and 353 deletions

View File

@ -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,

View File

@ -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
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 len(content) == 0 {
t.Logf("no content in %q", outputPath)
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(),
}
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
}
}
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).
//

View File

@ -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.

View File

@ -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

145
coderd/apidoc/docs.go generated
View File

@ -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": [

View File

@ -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"],

View File

@ -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)

View File

@ -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

View File

@ -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 {

View File

@ -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()})

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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;

View File

@ -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
}

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,9 @@
INSERT INTO workspace_agent_startup_logs (
agent_id,
created_at,
output
) VALUES (
'45e89705-e09d-4850-bcec-f9a937f5d78d',
NOW(),
'output'
);

View File

@ -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 {

View File

@ -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)

View File

@ -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))
}

View File

@ -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'
`

View File

@ -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

View File

@ -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');

View File

@ -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 {

View File

@ -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=<id>
// 2. GET /logs?after=<id>&follow
// GET /logs
// GET /logs?after=<id>&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 {

View File

@ -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)
})
}

View File

@ -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]

View File

@ -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,
}
}

View File

@ -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()

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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 := ""

View File

@ -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) {

View File

@ -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"`
}

View File

@ -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)

View File

@ -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) |
<h3 id="get-startup-logs-by-workspace-agent-responseschema">Response Schema</h3>
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).

View File

@ -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",

View File

@ -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",

View File

@ -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 | | |

View File

@ -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",

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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",

View File

@ -694,6 +694,15 @@ export const getWorkspaceBuildLogs = async (
return response.data
}
export const getWorkspaceAgentStartupLogs = async (
agentID: string,
): Promise<TypesGen.WorkspaceAgentStartupLog[]> => {
const response = await axios.get<TypesGen.WorkspaceAgentStartupLog[]>(
`/api/v2/workspaceagents/${agentID}/startup-logs`,
)
return response.data
}
export const putWorkspaceExtension = async (
workspaceId: string,
newDeadline: dayjs.Dayjs,

View File

@ -1042,6 +1042,8 @@ export interface WorkspaceAgent {
readonly environment_variables: Record<string, string>
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

View File

@ -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<LogsProps> = (args) => <Logs {...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,
}

View File

@ -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<React.PropsWithChildren<LogsProps>> = ({
hideTimestamps,
lines,
lineNumbers,
className = "",
}) => {
const styles = useStyles()
const styles = useStyles({
lineNumbers: Boolean(lineNumbers),
})
return (
<div className={combineClasses([className, styles.root])}>
@ -32,7 +36,9 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
{!hideTimestamps && (
<>
<span className={styles.time}>
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
{lineNumbers
? idx + 1
: dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
<span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span>
</>
@ -45,22 +51,55 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
)
}
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 (
<div className={combineClasses([styles.line, line.level])} style={style}>
{!hideTimestamp && (
<>
<span className={styles.time}>
{number ? number : dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
<span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span>
</>
)}
<span>{line.output}</span>
</div>
)
}
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,
},

View File

@ -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({})

View File

@ -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<AgentRowProps> = ({
@ -35,126 +60,355 @@ export const AgentRow: FC<AgentRowProps> = ({
hideVSCodeDesktopButton,
serverVersion,
onUpdateAgent,
storybookStartupLogs,
}) => {
const styles = useStyles()
const { t } = useTranslation("agent")
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<HTMLLinkElement>(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<List>(null)
const logListDivRef = useRef<HTMLDivElement>(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])
// 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 (
<Stack
direction="column"
key={agent.id}
direction="row"
alignItems="center"
justifyContent="space-between"
className={styles.agentRow}
spacing={4}
spacing={0}
className={styles.agentWrapper}
>
<Stack direction="row" alignItems="baseline">
<div className={styles.agentStatusWrapper}>
<AgentStatus agent={agent} />
</div>
<div>
<div className={styles.agentName}>{agent.name}</div>
<Stack
direction="row"
alignItems="baseline"
className={styles.agentData}
spacing={1}
>
<span className={styles.agentOS}>{agent.operating_system}</span>
<Maybe condition={agent.status === "connected"}>
<AgentVersion
agent={agent}
serverVersion={serverVersion}
onUpdate={onUpdateAgent}
/>
</Maybe>
<AgentLatency agent={agent} />
<Maybe condition={agent.status === "connecting"}>
<Skeleton width={160} variant="text" />
<Skeleton width={36} variant="text" />
</Maybe>
<Maybe condition={agent.status === "timeout"}>
{t("unableToConnect")}
</Maybe>
</Stack>
</div>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
wrap="wrap"
maxWidth="750px"
justifyContent="space-between"
className={styles.agentRow}
spacing={4}
>
{showApps && agent.status === "connected" && (
<>
{agent.apps.map((app) => (
<AppLink
key={app.slug}
appsHost={applicationsHost}
app={app}
agent={agent}
workspace={workspace}
/>
))}
<Stack direction="row" alignItems="baseline">
<div className={styles.agentStatusWrapper}>
<AgentStatus agent={agent} />
</div>
<div>
<div className={styles.agentName}>{agent.name}</div>
<Stack
direction="row"
alignItems="baseline"
className={styles.agentData}
spacing={1}
>
<span className={styles.agentOS}>{agent.operating_system}</span>
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
{!hideSSHButton && (
<SSHButton
<Maybe condition={agent.status === "connected"}>
<AgentVersion
agent={agent}
serverVersion={serverVersion}
onUpdate={onUpdateAgent}
/>
</Maybe>
<AgentLatency agent={agent} />
<Maybe condition={agent.status === "connecting"}>
<Skeleton width={160} variant="text" />
<Skeleton width={36} variant="text" />
</Maybe>
<Maybe condition={agent.status === "timeout"}>
{t("unableToConnect")}
</Maybe>
</Stack>
{hasStartupFeatures && (
<Stack
direction="row"
alignItems="baseline"
spacing={1}
className={styles.startupLinks}
>
<Link
className={styles.startupLink}
variant="body2"
onClick={() => {
setShowStartupLogs(!showStartupLogs)
}}
>
{showStartupLogs ? (
<VisibilityOffOutlined />
) : (
<VisibilityOutlined />
)}
{showStartupLogs ? "Hide" : "Show"} Startup Logs
</Link>
{agent.startup_script && (
<Link
className={styles.startupLink}
variant="body2"
ref={startupScriptAnchorRef}
onClick={() => {
setStartupScriptOpen(!startupScriptOpen)
}}
>
<PlayCircleOutlined />
View Startup Script
</Link>
)}
<Popover
classes={{
paper: styles.startupScriptPopover,
}}
open={startupScriptOpen}
onClose={() => setStartupScriptOpen(false)}
anchorEl={startupScriptAnchorRef.current}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<div>
<SyntaxHighlighter
style={darcula}
language="shell"
showLineNumbers
// Use inline styles does not work correctly
// https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/329
codeTagProps={{ style: {} }}
customStyle={{
background: theme.palette.background.default,
maxWidth: 600,
margin: 0,
}}
>
{agent.startup_script || ""}
</SyntaxHighlighter>
</div>
</Popover>
</Stack>
)}
</div>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
wrap="wrap"
maxWidth="750px"
>
{showApps && agent.status === "connected" && (
<>
{agent.apps.map((app) => (
<AppLink
key={app.slug}
appsHost={applicationsHost}
app={app}
agent={agent}
workspace={workspace}
/>
))}
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
/>
)}
{!hideVSCodeDesktopButton && (
<VSCodeDesktopButton
userName={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
folderPath={agent.expanded_directory}
/>
)}
{applicationsHost !== undefined && applicationsHost !== "" && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
</>
)}
{showApps && agent.status === "connecting" && (
<>
<AppLinkSkeleton width={84} />
<AppLinkSkeleton width={112} />
</>
)}
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
/>
)}
{!hideVSCodeDesktopButton && (
<VSCodeDesktopButton
userName={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
folderPath={agent.expanded_directory}
/>
)}
{applicationsHost !== undefined && applicationsHost !== "" && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
</>
)}
{showApps && agent.status === "connecting" && (
<>
<AppLinkSkeleton width={84} />
<AppLinkSkeleton width={112} />
</>
)}
</Stack>
</Stack>
{showStartupLogs && (
<AutoSizer disableHeight>
{({ width }) => (
<List
ref={logListRef}
innerRef={logListDivRef}
height={256}
itemCount={startupLogs.length}
itemSize={logLineHeight}
width={width}
className={styles.startupLogs}
onScroll={handleLogScroll}
>
{({ index, style }) => (
<LogLine
line={startupLogs[index]}
number={index + 1}
style={style}
/>
)}
</List>
)}
</AutoSizer>
)}
</Stack>
)
}
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",
},
}))

View File

@ -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}`,
},
}
})

View File

@ -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 } =

View File

@ -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

View File

@ -699,6 +699,7 @@ export const workspaceMachine = createMachine(
checkRefresh: true,
data: JSON.parse(event.data),
})
// refresh
})
// handle any error events returned by our sse

View File

@ -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<void>((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]
},
}),
},
},
)

View File

@ -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"