diff --git a/agent/api.go b/agent/api.go index 415b20c95a295..f6c82cca9c7f8 100644 --- a/agent/api.go +++ b/agent/api.go @@ -1,7 +1,12 @@ package agent import ( + "bufio" + "bytes" + "io" "net/http" + "os" + "path/filepath" "sync" "time" @@ -11,7 +16,7 @@ import ( "github.com/coder/coder/codersdk" ) -func (*agent) apiHandler() http.Handler { +func (a *agent) apiHandler() http.Handler { r := chi.NewRouter() r.Get("/", func(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{ @@ -22,6 +27,26 @@ func (*agent) apiHandler() http.Handler { lp := &listeningPortsHandler{} r.Get("/api/v0/listening-ports", lp.handler) + logs := &logsHandler{ + logFiles: []*logFile{ + { + name: codersdk.WorkspaceAgentLogAgent, + path: filepath.Join(a.logDir, string(codersdk.WorkspaceAgentLogAgent)), + }, + { + name: codersdk.WorkspaceAgentLogStartupScript, + path: filepath.Join(a.logDir, string(codersdk.WorkspaceAgentLogStartupScript)), + }, + }, + } + r.Route("/api/v0/logs", func(r chi.Router) { + r.Get("/", logs.list) + r.Route("/{log}", func(r chi.Router) { + r.Get("/", logs.info) + r.Get("/tail", logs.tail) + }) + }) + return r } @@ -47,3 +72,216 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request Ports: ports, }) } + +type logFile struct { + name codersdk.WorkspaceAgentLog + path string + + mu sync.Mutex // Protects following. + lines int + offset int64 +} + +type logsHandler struct { + logFiles []*logFile +} + +func (lh *logsHandler) list(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logs, ok := logFileInfo(w, r, lh.logFiles...) + if !ok { + return + } + + httpapi.Write(ctx, w, http.StatusOK, logs) +} + +func (lh *logsHandler) info(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + logName := codersdk.WorkspaceAgentLog(chi.URLParam(r, "log")) + if logName == "" { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Missing log URL parameter.", + }) + return + } + + for _, f := range lh.logFiles { + if f.name == logName { + logs, ok := logFileInfo(w, r, f) + if !ok { + return + } + + httpapi.Write(ctx, w, http.StatusOK, logs[0]) + return + } + } + + httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{ + Message: "Log file not found.", + }) +} + +func (lh *logsHandler) tail(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + logName := codersdk.WorkspaceAgentLog(chi.URLParam(r, "log")) + if logName == "" { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Missing log URL parameter.", + }) + return + } + + qp := r.URL.Query() + parser := httpapi.NewQueryParamParser() + offset := parser.Int(qp, 0, "offset") + limit := parser.Int(qp, 0, "limit") + if len(parser.Errors) > 0 { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: parser.Errors, + }) + return + } + + var lf *logFile + for _, f := range lh.logFiles { + if f.name == logName { + lf = f + break + } + } + if lf == nil { + httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{ + Message: "Log file not found.", + }) + return + } + + f, err := os.Open(lf.path) + if err != nil { + if os.IsNotExist(err) { + httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{ + Message: "Log file not found.", + }) + return + } + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Could not open log file.", + Detail: err.Error(), + }) + return + } + defer f.Close() + + var lines []string + fr := bufio.NewReader(f) + n := -1 + for { + b, err := fr.ReadBytes('\n') + if err != nil { + // Note, we skip incomplete lines with no newline. + if err == io.EOF { + break + } + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Could not read log file.", + Detail: err.Error(), + }) + return + } + n++ + if n < offset { + continue + } + b = bytes.TrimRight(b, "\r\n") + lines = append(lines, string(b)) + + if limit > 0 && len(lines) >= limit { + break + } + } + + httpapi.Write(ctx, w, http.StatusOK, codersdk.WorkspaceAgentLogTailResponse{ + Offset: offset, + Count: len(lines), + Lines: lines, + }) +} + +func logFileInfo(w http.ResponseWriter, r *http.Request, lf ...*logFile) ([]codersdk.WorkspaceAgentLogInfo, bool) { + ctx := r.Context() + + var logs []codersdk.WorkspaceAgentLogInfo + for _, f := range lf { + size, lines, modified, exists, err := f.fileInfo() + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Could not gather log file info.", + Detail: err.Error(), + }) + return nil, false + } + + logs = append(logs, codersdk.WorkspaceAgentLogInfo{ + Name: f.name, + Path: f.path, + Size: size, + Lines: lines, + Exists: exists, + Modified: modified, + }) + } + + return logs, true +} + +// fileInfo counts the number of lines in the log file and caches +// the logFile's line count and offset. +func (lf *logFile) fileInfo() (size int64, lines int, modified time.Time, exists bool, err error) { + lf.mu.Lock() + defer lf.mu.Unlock() + + f, err := os.Open(lf.path) + if err != nil { + if os.IsNotExist(err) { + return 0, 0, time.Time{}, false, nil + } + return 0, 0, time.Time{}, false, err + } + defer f.Close() + + // Note, modified time will not be entirely accurate, but we rather + // give an old timestamp than one that is newer than when we counted + // the lines. + info, err := f.Stat() + if err != nil { + return 0, 0, time.Time{}, false, err + } + + _, err = f.Seek(lf.offset, io.SeekStart) + if err != nil { + return 0, 0, time.Time{}, false, err + } + + r := bufio.NewReader(f) + for { + b, err := r.ReadBytes('\n') + if err != nil { + // Note, we skip incomplete lines with no newline. + if err == io.EOF { + break + } + return 0, 0, time.Time{}, false, err + } + size += int64(len(b)) + lines++ + } + lf.offset += size + lf.lines += lines + + return lf.offset, lf.lines, info.ModTime(), true, nil +} diff --git a/cli/ssh.go b/cli/ssh.go index 5adeba63bbae6..8989699e78146 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -98,6 +98,9 @@ func ssh() *cobra.Command { return cliui.Canceled } if xerrors.Is(err, cliui.AgentStartError) { + // Best-effort show log tail. + _ = showStartupScriptLogTail(ctx, cmd.ErrOrStderr(), client, workspaceAgent.ID, nil) + return xerrors.New("Agent startup script exited with non-zero status, use --no-wait to login anyway.") } return xerrors.Errorf("await agent: %w", err) @@ -108,7 +111,16 @@ func ssh() *cobra.Command { return err } defer conn.Close() - conn.AwaitReachable(ctx) + + if !conn.AwaitReachable(ctx) { + return ctx.Err() + } + + err = showStartupScriptLogTail(ctx, cmd.ErrOrStderr(), client, workspaceAgent.ID, conn) + if err != nil { + return err + } + stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace) defer stopPolling() @@ -337,6 +349,64 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder return workspace, workspaceAgent, nil } +// showStartupScriptLogTail shows the tail of the starutp script log, if no conn +// is provided a new connection to the agent will be established and closed +// after done. +func showStartupScriptLogTail(ctx context.Context, dest io.Writer, client *codersdk.Client, workspaceAgentID uuid.UUID, conn *codersdk.WorkspaceAgentConn) (err error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + if conn == nil { + conn, err = client.DialWorkspaceAgent(ctx, workspaceAgentID, &codersdk.DialWorkspaceAgentOptions{}) + if err != nil { + return err + } + defer conn.Close() + + if !conn.AwaitReachable(ctx) { + return ctx.Err() + } + } + + info, err := conn.LogInfo(ctx, codersdk.WorkspaceAgentLogStartupScript) + if err != nil { + return err + } + + if !info.Exists { + return nil + } + + curLine := info.Lines + if curLine < 10 { + curLine = 0 + } else { + curLine -= 10 + } + + tail, err := conn.LogTail(ctx, codersdk.WorkspaceAgentLogStartupScript, codersdk.WorkspaceAgentLogTailRequest{ + Offset: curLine, + }) + if err != nil { + return err + } + lines := tail.Lines + if len(lines) > 0 { + // More than 10 lines could've been returned if there were + // new lines after info. + if len(lines) > 10 { + lines = lines[len(lines)-10:] + } + + _, _ = fmt.Fprintf(dest, "Showing up to the last 10 lines from startup_script:\n\n") + for _, line := range lines { + _, _ = fmt.Fprintf(dest, "[startup_script] %s\n", line) + } + _, _ = fmt.Fprintf(dest, "\n") + } + return nil +} + // Attempt to poll workspace autostop. We write a per-workspace lockfile to // avoid spamming the user with notifications in case of multiple instances // of the CLI running simultaneously. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2559e1e0ed619..84e32896b8e4b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4277,6 +4277,148 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get workspace agent logs", + "operationId": "get-workspace-agent-logs", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogInfo" + } + } + } + } + } + }, + "/workspaceagents/{workspaceagent}/logs/{log}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get workspace agent log file information.", + "operationId": "get-workspace-agent-log-file-information", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "enum": [ + "coder-agent.log", + "coder-startup-script.log" + ], + "type": "string", + "description": "Workspace log file name", + "name": "log", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogInfo" + } + } + } + } + }, + "/workspaceagents/{workspaceagent}/logs/{log}/tail": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get workspace agent log contents.", + "operationId": "get-workspace-agent-log-contents", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "enum": [ + "coder-agent.log", + "coder-startup-script.log" + ], + "type": "string", + "description": "Workspace log file name", + "name": "log", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Line offset to start reading from", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of lines to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogTailResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/pty": { "get": { "security": [ @@ -8068,6 +8210,63 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceAgentLog": { + "type": "string", + "enum": [ + "coder-agent.log", + "coder-startup-script.log" + ], + "x-enum-varnames": [ + "WorkspaceAgentLogAgent", + "WorkspaceAgentLogStartupScript" + ] + }, + "codersdk.WorkspaceAgentLogInfo": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + }, + "lines": { + "type": "integer", + "example": 100 + }, + "modified": { + "type": "string", + "format": "date-time" + }, + "name": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLog" + }, + "path": { + "type": "string", + "example": "/tmp/coder-agent.log" + }, + "size": { + "type": "integer", + "example": 2048 + } + } + }, + "codersdk.WorkspaceAgentLogTailResponse": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "string" + } + }, + "count": { + "description": "Number of lines returned.", + "type": "integer" + }, + "start": { + "description": "Line offset, 0-based.", + "type": "integer" + } + } + }, "codersdk.WorkspaceAgentStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 97acc0d3e3f36..68581354fd8ba 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3759,6 +3759,130 @@ } } }, + "/workspaceagents/{workspaceagent}/logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get workspace agent logs", + "operationId": "get-workspace-agent-logs", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogInfo" + } + } + } + } + } + }, + "/workspaceagents/{workspaceagent}/logs/{log}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get workspace agent log file information.", + "operationId": "get-workspace-agent-log-file-information", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "enum": ["coder-agent.log", "coder-startup-script.log"], + "type": "string", + "description": "Workspace log file name", + "name": "log", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogInfo" + } + } + } + } + }, + "/workspaceagents/{workspaceagent}/logs/{log}/tail": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get workspace agent log contents.", + "operationId": "get-workspace-agent-log-contents", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "enum": ["coder-agent.log", "coder-startup-script.log"], + "type": "string", + "description": "Workspace log file name", + "name": "log", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Line offset to start reading from", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of lines to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogTailResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/pty": { "get": { "security": [ @@ -7267,6 +7391,60 @@ } } }, + "codersdk.WorkspaceAgentLog": { + "type": "string", + "enum": ["coder-agent.log", "coder-startup-script.log"], + "x-enum-varnames": [ + "WorkspaceAgentLogAgent", + "WorkspaceAgentLogStartupScript" + ] + }, + "codersdk.WorkspaceAgentLogInfo": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + }, + "lines": { + "type": "integer", + "example": 100 + }, + "modified": { + "type": "string", + "format": "date-time" + }, + "name": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLog" + }, + "path": { + "type": "string", + "example": "/tmp/coder-agent.log" + }, + "size": { + "type": "integer", + "example": 2048 + } + } + }, + "codersdk.WorkspaceAgentLogTailResponse": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "string" + } + }, + "count": { + "description": "Number of lines returned.", + "type": "integer" + }, + "start": { + "description": "Line offset, 0-based.", + "type": "integer" + } + } + }, "codersdk.WorkspaceAgentStatus": { "type": "string", "enum": ["connecting", "connected", "disconnected", "timeout"], diff --git a/coderd/coderd.go b/coderd/coderd.go index bfce5a5fb1a88..84a88a37045d4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -572,6 +572,13 @@ func New(options *Options) *API { r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/coordinate", api.workspaceAgentClientCoordinate) + r.Route("/logs", func(r chi.Router) { + r.Get("/", api.workspaceAgentLogs) + r.Route("/{log}", func(r chi.Router) { + r.Get("/", api.workspaceAgentLogInfo) + r.Get("/tail", api.workspaceAgentLogTail) + }) + }) }) }) r.Route("/workspaces", func(r chi.Router) { diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 3fcbb63cb632c..37e6ef732173f 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -146,6 +146,18 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { AssertAction: rbac.ActionCreate, AssertObject: workspaceExecObj, }, + "GET:/api/v2/workspaceagents/{workspaceagent}/logs": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/logs/{log}": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/logs/{log}/tail": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, "POST:/api/v2/organizations/{organization}/templates": { AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceTemplate.InOrg(a.Organization.ID), @@ -695,6 +707,7 @@ func (s *PreparedRecorder) Authorize(ctx context.Context, object rbac.Object) er } return s.prepped.Authorize(ctx, object) } + func (s *PreparedRecorder) CompileToSQL(ctx context.Context, cfg regosql.ConvertConfig) (string, error) { s.rw.Lock() defer s.rw.Unlock() diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 5c14f5ea217a5..c256f79949948 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "go.opentelemetry.io/otel/trace" "golang.org/x/mod/semver" @@ -858,6 +859,227 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin return workspaceAgent, nil } +// workspaceAgentLogs returns a list of logs that are available on the agent. +// +// @Summary Get workspace agent logs +// @ID get-workspace-agent-logs +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Success 200 {array} codersdk.WorkspaceAgentLogInfo +// @Router /workspaceagents/{workspaceagent}/logs [get] +func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + workspaceAgent := httpmw.WorkspaceAgentParam(r) + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, rbac.ActionRead, workspace) { + httpapi.ResourceNotFound(rw) + return + } + + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error reading workspace agent.", + Detail: err.Error(), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected), + }) + return + } + + agentConn, release, err := api.workspaceAgentCache.Acquire(r, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + agentLogs, err := agentConn.Logs(ctx) + if err != nil { + var e *codersdk.Error + if xerrors.As(err, &e) { + httpapi.Write(ctx, rw, e.StatusCode(), codersdk.Response{ + Message: e.Message, + Detail: e.Detail, + Validations: e.Validations, + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error getting logs.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, agentLogs) +} + +// workspaceAgentLogInfo returns information about the log file. +// +// @Summary Get workspace agent log file information. +// @ID get-workspace-agent-log-file-information +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Param log path codersdk.WorkspaceAgentLog true "Workspace log file name" +// @Success 200 {object} codersdk.WorkspaceAgentLogInfo +// @Router /workspaceagents/{workspaceagent}/logs/{log} [get] +func (api *API) workspaceAgentLogInfo(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + workspaceAgent := httpmw.WorkspaceAgentParam(r) + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, rbac.ActionRead, workspace) { + httpapi.ResourceNotFound(rw) + return + } + + logName := codersdk.WorkspaceAgentLog(chi.URLParam(r, "log")) + + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error reading workspace agent.", + Detail: err.Error(), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected), + }) + return + } + + agentConn, release, err := api.workspaceAgentCache.Acquire(r, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + logInfo, err := agentConn.LogInfo(ctx, logName) + if err != nil { + var e *codersdk.Error + if xerrors.As(err, &e) { + httpapi.Write(ctx, rw, e.StatusCode(), codersdk.Response{ + Message: e.Message, + Detail: e.Detail, + Validations: e.Validations, + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error getting log info.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, logInfo) +} + +// workspaceAgentLogTail issues a log tail request to the workspace agent. +// +// @Summary Get workspace agent log contents. +// @ID get-workspace-agent-log-contents +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Param log path codersdk.WorkspaceAgentLog true "Workspace log file name" +// @Param offset query int false "Line offset to start reading from" +// @Param limit query int false "Maximum number of lines to return" +// @Success 200 {object} codersdk.WorkspaceAgentLogTailResponse +// @Router /workspaceagents/{workspaceagent}/logs/{log}/tail [get] +func (api *API) workspaceAgentLogTail(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + workspaceAgent := httpmw.WorkspaceAgentParam(r) + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, rbac.ActionRead, workspace) { + httpapi.ResourceNotFound(rw) + return + } + + logName := codersdk.WorkspaceAgentLog(chi.URLParam(r, "log")) + + qp := r.URL.Query() + parser := httpapi.NewQueryParamParser() + offset := parser.Int(qp, 0, "offset") + limit := parser.Int(qp, 0, "limit") + if len(parser.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: parser.Errors, + }) + return + } + + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error reading workspace agent.", + Detail: err.Error(), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected), + }) + return + } + + agentConn, release, err := api.workspaceAgentCache.Acquire(r, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + tail, err := agentConn.LogTail(ctx, logName, codersdk.WorkspaceAgentLogTailRequest{ + Limit: limit, + Offset: offset, + }) + if err != nil { + var e *codersdk.Error + if xerrors.As(err, &e) { + httpapi.Write(ctx, rw, e.StatusCode(), codersdk.Response{ + Message: e.Message, + Detail: e.Detail, + Validations: e.Validations, + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error tailing workspace agent log.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, tail) +} + // @Summary Submit workspace agent stats // @ID submit-workspace-agent-stats // @Security CoderSessionToken diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 3fe824f0403f9..59475d295734d 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -8,6 +8,8 @@ import ( "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "regexp" "runtime" "strconv" @@ -710,6 +712,161 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { }) } +func TestWorkspaceAgentLogs(t *testing.T) { + t.Parallel() + + setup := func(t *testing.T, logDir string, startupScript string) (*codersdk.Client, uuid.UUID) { + 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, + }, + LoginBeforeReady: false, + StartupScript: startupScript, + }}, + }}, + }, + }, + }}, + }) + 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) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + agentCloser := agent.New(agent.Options{ + Client: agentClient, + LogDir: logDir, + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelInfo), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + return client, resources[0].Agents[0].ID + } + + t.Run("coder-agent.log info", func(t *testing.T) { + t.Parallel() + + logDir := t.TempDir() + client, agentID := setup(t, logDir, "") + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + f, err := os.Create(filepath.Join(logDir, "coder-agent.log")) + require.NoError(t, err) + defer f.Close() + + // Part 1, test that the log file is created and has the correct info. + missing := "end-no-newline" + content := "hello world\n\ntest\n\n" + missing + _, err = f.WriteString(content) + require.NoError(t, err) + err = f.Sync() + require.NoError(t, err) + + info, err := client.WorkspaceAgentLogInfo(ctx, agentID, codersdk.WorkspaceAgentLogAgent) + require.NoError(t, err) + + assert.Equal(t, true, info.Exists) + assert.Equal(t, int64(len(content)-len(missing)), info.Size) + assert.Equal(t, 4, info.Lines) + assert.Contains(t, info.Path, logDir) + + // Part 2, test that the log file is updated and has the correct info. + content2 := "\nmore\nlines\n" + _, err = f.WriteString(content2) + require.NoError(t, err) + err = f.Sync() + require.NoError(t, err) + + info, err = client.WorkspaceAgentLogInfo(ctx, agentID, codersdk.WorkspaceAgentLogAgent) + require.NoError(t, err) + + assert.Equal(t, int64(len(content)+len(content2)), info.Size) + assert.Equal(t, 7, info.Lines) + }) + + t.Run("coder-startup-script.log tail", func(t *testing.T) { + t.Parallel() + + logDir := t.TempDir() + client, agentID := setup(t, logDir, "echo skip first line\necho hello world\necho test\n") + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { + workspaceAgent, err := client.WorkspaceAgent(ctx, agentID) + if !assert.NoError(t, err) { + return false + } + return workspaceAgent.LifecycleState == codersdk.WorkspaceAgentLifecycleReady + }, testutil.IntervalMedium, "agent status timeout") + + info, err := client.WorkspaceAgentLogInfo(ctx, agentID, codersdk.WorkspaceAgentLogStartupScript) + require.NoError(t, err) + + assert.Equal(t, true, info.Exists) + assert.Equal(t, 3, info.Lines) + + tail, err := client.WorkspaceAgentLogTail(ctx, agentID, codersdk.WorkspaceAgentLogStartupScript, codersdk.WorkspaceAgentLogTailRequest{ + Offset: info.Lines - 2, + }) + require.NoError(t, err) + + assert.Equal(t, 2, tail.Count) + assert.Equal(t, []string{"hello world", "test"}, tail.Lines) + }) + + t.Run("coder-startup-script.log not exists", func(t *testing.T) { + t.Parallel() + + logDir := t.TempDir() + client, agentID := setup(t, logDir, "") + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { + workspaceAgent, err := client.WorkspaceAgent(ctx, agentID) + if !assert.NoError(t, err) { + return false + } + return workspaceAgent.LifecycleState == codersdk.WorkspaceAgentLifecycleReady + }, testutil.IntervalMedium, "agent status timeout") + + info, err := client.WorkspaceAgentLogInfo(ctx, agentID, codersdk.WorkspaceAgentLogStartupScript) + require.NoError(t, err) + + require.Equal(t, false, info.Exists) + + _, err = client.WorkspaceAgentLogTail(ctx, agentID, codersdk.WorkspaceAgentLogStartupScript, codersdk.WorkspaceAgentLogTailRequest{ + Offset: info.Lines - 2, + }) + require.Error(t, err) + }) +} + func TestWorkspaceAgentAppHealth(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ diff --git a/codersdk/workspaceagentconn.go b/codersdk/workspaceagentconn.go index d6a4feffa37d5..8bbadddc7302f 100644 --- a/codersdk/workspaceagentconn.go +++ b/codersdk/workspaceagentconn.go @@ -293,6 +293,91 @@ func (c *WorkspaceAgentConn) ListeningPorts(ctx context.Context) (WorkspaceAgent return resp, json.NewDecoder(res.Body).Decode(&resp) } +type WorkspaceAgentLogInfo struct { + Name WorkspaceAgentLog `json:"name"` + Path string `json:"path" example:"/tmp/coder-agent.log"` + Size int64 `json:"size" example:"2048"` + Lines int `json:"lines" example:"100"` + Exists bool `json:"exists"` + Modified time.Time `json:"modified" format:"date-time"` +} + +// Logs returns a list of logs that are available on the agent. +func (c *WorkspaceAgentConn) Logs(ctx context.Context) ([]WorkspaceAgentLogInfo, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/logs", nil) + if err != nil { + return nil, xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var resp []WorkspaceAgentLogInfo + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// LogInfo returns information about the log file. +func (c *WorkspaceAgentConn) LogInfo(ctx context.Context, name WorkspaceAgentLog) (WorkspaceAgentLogInfo, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + res, err := c.apiRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v0/logs/%s", name), nil) + if err != nil { + return WorkspaceAgentLogInfo{}, xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgentLogInfo{}, ReadBodyAsError(res) + } + + var resp WorkspaceAgentLogInfo + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// @typescript-ignore WorkspaceAgentLogTailRequest +type WorkspaceAgentLogTailRequest struct { + Offset int `json:"offset"` // Line offset, 0-based. + Limit int `json:"limit"` // Number of lines to return. +} + +type WorkspaceAgentLogTailResponse struct { + Offset int `json:"start"` // Line offset, 0-based. + Count int `json:"count"` // Number of lines returned. + Lines []string `json:"content"` +} + +// LogTail issues a log tail request to the workspace agent. +func (c *WorkspaceAgentConn) LogTail(ctx context.Context, name WorkspaceAgentLog, req WorkspaceAgentLogTailRequest) (WorkspaceAgentLogTailResponse, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + url := fmt.Sprintf("/api/v0/logs/%s/tail", name) + var params []string + if req.Offset > 0 { + params = append(params, fmt.Sprintf("offset=%d", req.Offset)) + } + if req.Limit > 0 { + params = append(params, fmt.Sprintf("limit=%d", req.Limit)) + } + if len(params) > 0 { + url += "?" + strings.Join(params, "&") + } + + res, err := c.apiRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return WorkspaceAgentLogTailResponse{}, xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgentLogTailResponse{}, ReadBodyAsError(res) + } + + var resp WorkspaceAgentLogTailResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // apiRequest makes a request to the workspace agent's HTTP API server. func (c *WorkspaceAgentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { ctx, span := tracing.StartSpan(ctx) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index b60a6906e7ff6..42dabcf2b8caf 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -10,6 +10,7 @@ import ( "net/http/cookiejar" "net/netip" "strconv" + "strings" "time" "github.com/google/uuid" @@ -51,6 +52,15 @@ const ( WorkspaceAgentLifecycleReady WorkspaceAgentLifecycle = "ready" ) +// WorkspaceAgentLog represents the name of a workspace agent log. +type WorkspaceAgentLog string + +// WorkspaceAgentLogs enums. +const ( + WorkspaceAgentLogAgent WorkspaceAgentLog = "coder-agent.log" + WorkspaceAgentLogStartupScript WorkspaceAgentLog = "coder-startup-script.log" +) + type WorkspaceAgent struct { ID uuid.UUID `json:"id" format:"uuid"` CreatedAt time.Time `json:"created_at" format:"date-time"` @@ -277,6 +287,60 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid. return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) } +// WorkspaceAgentLogs returns a list of logs that are available on the agent. +func (c *Client) WorkspaceAgentLogs(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentLogInfo, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/logs", agentID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var resp []WorkspaceAgentLogInfo + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// WorkspaceAgentLogInfo returns information about the log file. +func (c *Client) WorkspaceAgentLogInfo(ctx context.Context, agentID uuid.UUID, name WorkspaceAgentLog) (WorkspaceAgentLogInfo, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/logs/%s", agentID, name), nil) + if err != nil { + return WorkspaceAgentLogInfo{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgentLogInfo{}, ReadBodyAsError(res) + } + var resp WorkspaceAgentLogInfo + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// WorkspaceAgentLogTail issues a log tail request to the workspace agent. +func (c *Client) WorkspaceAgentLogTail(ctx context.Context, agentID uuid.UUID, name WorkspaceAgentLog, req WorkspaceAgentLogTailRequest) (WorkspaceAgentLogTailResponse, error) { + url := fmt.Sprintf("/api/v2/workspaceagents/%s/logs/%s/tail", agentID, name) + var params []string + if req.Offset > 0 { + params = append(params, fmt.Sprintf("offset=%d", req.Offset)) + } + if req.Limit > 0 { + params = append(params, fmt.Sprintf("limit=%d", req.Limit)) + } + if len(params) > 0 { + url += "?" + strings.Join(params, "&") + } + + res, err := c.Request(ctx, http.MethodGet, url, req) + if err != nil { + return WorkspaceAgentLogTailResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgentLogTailResponse{}, ReadBodyAsError(res) + } + var resp WorkspaceAgentLogTailResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // GitProvider is a constant that represents the // type of providers that are supported within Coder. // @typescript-ignore GitProvider diff --git a/docs/api/agents.md b/docs/api/agents.md index ffea8e40065f3..0fdbc7fad3b72 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -683,6 +683,170 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/lis To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace agent logs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/logs \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceagents/{workspaceagent}/logs` + +### Parameters + +| Name | In | Type | Required | Description | +| ---------------- | ---- | ------------ | -------- | ------------------ | +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | + +### Example responses + +> 200 Response + +```json +[ + { + "exists": true, + "lines": 100, + "modified": "2019-08-24T14:15:22Z", + "name": "coder-agent.log", + "path": "/tmp/coder-agent.log", + "size": 2048 + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.WorkspaceAgentLogInfo](schemas.md#codersdkworkspaceagentloginfo) | + +