Skip to content

Commit 0b45ef3

Browse files
committed
feat: Add agent logs in API, show startup_script tail on error (SSH)
Refs: #2957
1 parent f6da0a6 commit 0b45ef3

File tree

13 files changed

+1421
-2
lines changed

13 files changed

+1421
-2
lines changed

agent/api.go

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package agent
22

33
import (
4+
"bufio"
5+
"bytes"
6+
"io"
47
"net/http"
8+
"os"
9+
"path/filepath"
510
"sync"
611
"time"
712

@@ -11,7 +16,7 @@ import (
1116
"github.com/coder/coder/codersdk"
1217
)
1318

14-
func (*agent) apiHandler() http.Handler {
19+
func (a *agent) apiHandler() http.Handler {
1520
r := chi.NewRouter()
1621
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
1722
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
@@ -22,6 +27,26 @@ func (*agent) apiHandler() http.Handler {
2227
lp := &listeningPortsHandler{}
2328
r.Get("/api/v0/listening-ports", lp.handler)
2429

30+
logs := &logsHandler{
31+
logFiles: []*logFile{
32+
{
33+
name: codersdk.WorkspaceAgentLogAgent,
34+
path: filepath.Join(a.logDir, string(codersdk.WorkspaceAgentLogAgent)),
35+
},
36+
{
37+
name: codersdk.WorkspaceAgentLogStartupScript,
38+
path: filepath.Join(a.logDir, string(codersdk.WorkspaceAgentLogStartupScript)),
39+
},
40+
},
41+
}
42+
r.Route("/api/v0/logs", func(r chi.Router) {
43+
r.Get("/", logs.list)
44+
r.Route("/{log}", func(r chi.Router) {
45+
r.Get("/", logs.info)
46+
r.Get("/tail", logs.tail)
47+
})
48+
})
49+
2550
return r
2651
}
2752

@@ -47,3 +72,206 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request
4772
Ports: ports,
4873
})
4974
}
75+
76+
type logFile struct {
77+
name codersdk.WorkspaceAgentLog
78+
path string
79+
80+
mu sync.Mutex // Protects following.
81+
lines int
82+
offset int64
83+
}
84+
85+
type logsHandler struct {
86+
logFiles []*logFile
87+
}
88+
89+
func (lh *logsHandler) list(w http.ResponseWriter, r *http.Request) {
90+
ctx := r.Context()
91+
logs, ok := logFileInfo(w, r, lh.logFiles...)
92+
if !ok {
93+
return
94+
}
95+
96+
httpapi.Write(ctx, w, http.StatusOK, logs)
97+
}
98+
99+
func (lh *logsHandler) info(w http.ResponseWriter, r *http.Request) {
100+
ctx := r.Context()
101+
102+
logName := codersdk.WorkspaceAgentLog(chi.URLParam(r, "log"))
103+
if logName == "" {
104+
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
105+
Message: "Missing log URL parameter.",
106+
})
107+
return
108+
}
109+
110+
for _, f := range lh.logFiles {
111+
if f.name == logName {
112+
logs, ok := logFileInfo(w, r, f)
113+
if !ok {
114+
return
115+
}
116+
117+
httpapi.Write(ctx, w, http.StatusOK, logs[0])
118+
return
119+
}
120+
}
121+
122+
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
123+
Message: "Log not found.",
124+
})
125+
}
126+
127+
func (lh *logsHandler) tail(w http.ResponseWriter, r *http.Request) {
128+
ctx := r.Context()
129+
130+
logName := codersdk.WorkspaceAgentLog(chi.URLParam(r, "log"))
131+
if logName == "" {
132+
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
133+
Message: "Missing log URL parameter.",
134+
})
135+
return
136+
}
137+
138+
qp := r.URL.Query()
139+
parser := httpapi.NewQueryParamParser()
140+
offset := parser.Int(qp, 0, "offset")
141+
limit := parser.Int(qp, 0, "limit")
142+
if len(parser.Errors) > 0 {
143+
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
144+
Message: "Query parameters have invalid values.",
145+
Validations: parser.Errors,
146+
})
147+
return
148+
}
149+
150+
var lf *logFile
151+
for _, f := range lh.logFiles {
152+
if f.name == logName {
153+
lf = f
154+
break
155+
}
156+
}
157+
if lf == nil {
158+
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
159+
Message: "Log not found.",
160+
})
161+
return
162+
}
163+
164+
f, err := os.Open(lf.path)
165+
if err != nil {
166+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
167+
Message: "Could not open log file.",
168+
Detail: err.Error(),
169+
})
170+
return
171+
}
172+
defer f.Close()
173+
174+
var lines []string
175+
fr := bufio.NewReader(f)
176+
n := -1
177+
for {
178+
b, err := fr.ReadBytes('\n')
179+
if err != nil {
180+
// Note, we skip incomplete lines with no newline.
181+
if err == io.EOF {
182+
break
183+
}
184+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
185+
Message: "Could not read log file.",
186+
Detail: err.Error(),
187+
})
188+
return
189+
}
190+
n++
191+
if n < int(offset) {
192+
continue
193+
}
194+
b = bytes.TrimRight(b, "\r\n")
195+
lines = append(lines, string(b))
196+
197+
if limit > 0 && len(lines) >= int(limit) {
198+
break
199+
}
200+
}
201+
202+
httpapi.Write(ctx, w, http.StatusOK, codersdk.WorkspaceAgentLogTailResponse{
203+
Offset: int(offset),
204+
Count: len(lines),
205+
Lines: lines,
206+
})
207+
}
208+
209+
func logFileInfo(w http.ResponseWriter, r *http.Request, lf ...*logFile) ([]codersdk.WorkspaceAgentLogInfo, bool) {
210+
ctx := r.Context()
211+
212+
var logs []codersdk.WorkspaceAgentLogInfo
213+
for _, f := range lf {
214+
size, lines, modified, err := f.fileInfo()
215+
if err != nil {
216+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
217+
Message: "Could not gather log file info.",
218+
Detail: err.Error(),
219+
})
220+
return nil, false
221+
}
222+
223+
logs = append(logs, codersdk.WorkspaceAgentLogInfo{
224+
Name: f.name,
225+
Path: f.path,
226+
Size: size,
227+
Lines: lines,
228+
Modified: modified,
229+
})
230+
}
231+
232+
return logs, true
233+
}
234+
235+
// fileInfo counts the number of lines in the log file and caches
236+
// the logFile's line count and offset.
237+
func (lf *logFile) fileInfo() (size int64, lines int, modified time.Time, err error) {
238+
lf.mu.Lock()
239+
defer lf.mu.Unlock()
240+
241+
f, err := os.Open(lf.path)
242+
if err != nil {
243+
return 0, 0, time.Time{}, err
244+
}
245+
defer f.Close()
246+
247+
// Note, modified time will not be entirely accurate, but we rather
248+
// give an old timestamp than one that is newer than when we counted
249+
// the lines.
250+
info, err := f.Stat()
251+
if err != nil {
252+
return 0, 0, time.Time{}, err
253+
}
254+
255+
_, err = f.Seek(lf.offset, io.SeekStart)
256+
if err != nil {
257+
return 0, 0, time.Time{}, err
258+
}
259+
260+
r := bufio.NewReader(f)
261+
for {
262+
b, err := r.ReadBytes('\n')
263+
if err != nil {
264+
// Note, we skip incomplete lines with no newline.
265+
if err == io.EOF {
266+
break
267+
}
268+
return 0, 0, time.Time{}, err
269+
}
270+
size += int64(len(b))
271+
lines++
272+
}
273+
lf.offset += size
274+
lf.lines += lines
275+
276+
return lf.offset, lf.lines, info.ModTime(), nil
277+
}

cli/ssh.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ func ssh() *cobra.Command {
9898
return cliui.Canceled
9999
}
100100
if xerrors.Is(err, cliui.AgentStartError) {
101+
// Best-effort show log tail.
102+
_ = showStartupScriptLogTail(ctx, cmd.ErrOrStderr(), client, workspaceAgent.ID, nil)
103+
101104
return xerrors.New("Agent startup script exited with non-zero status, use --no-wait to login anyway.")
102105
}
103106
return xerrors.Errorf("await agent: %w", err)
@@ -108,7 +111,16 @@ func ssh() *cobra.Command {
108111
return err
109112
}
110113
defer conn.Close()
111-
conn.AwaitReachable(ctx)
114+
115+
if !conn.AwaitReachable(ctx) {
116+
return ctx.Err()
117+
}
118+
119+
err = showStartupScriptLogTail(ctx, cmd.ErrOrStderr(), client, workspaceAgent.ID, conn)
120+
if err != nil {
121+
return err
122+
}
123+
112124
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
113125
defer stopPolling()
114126

@@ -337,6 +349,60 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder
337349
return workspace, workspaceAgent, nil
338350
}
339351

352+
// showStartupScriptLogTail shows the tail of the starutp script log, if no conn
353+
// is provided a new connection to the agent will be established and closed
354+
// after done.
355+
func showStartupScriptLogTail(ctx context.Context, dest io.Writer, client *codersdk.Client, workspaceAgentID uuid.UUID, conn *codersdk.WorkspaceAgentConn) (err error) {
356+
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
357+
defer cancel()
358+
359+
if conn == nil {
360+
conn, err = client.DialWorkspaceAgent(ctx, workspaceAgentID, &codersdk.DialWorkspaceAgentOptions{})
361+
if err != nil {
362+
return err
363+
}
364+
defer conn.Close()
365+
366+
if !conn.AwaitReachable(ctx) {
367+
return ctx.Err()
368+
}
369+
}
370+
371+
info, err := conn.LogInfo(ctx, codersdk.WorkspaceAgentLogStartupScript)
372+
if err != nil {
373+
return err
374+
}
375+
376+
curLine := info.Lines
377+
if curLine < 10 {
378+
curLine = 0
379+
} else {
380+
curLine -= 10
381+
}
382+
383+
tail, err := conn.LogTail(ctx, codersdk.WorkspaceAgentLogStartupScript, codersdk.WorkspaceAgentLogTailRequest{
384+
Offset: curLine,
385+
})
386+
if err != nil {
387+
return err
388+
}
389+
lines := tail.Lines
390+
if len(lines) > 0 {
391+
// More than 10 lines could've been returned if there were
392+
// new lines after info.
393+
if len(lines) > 10 {
394+
lines = lines[len(lines)-10:]
395+
}
396+
397+
_, _ = fmt.Fprintf(dest, "Showing up to the last 10 lines from startup_script:\n\n")
398+
for _, line := range lines {
399+
_, _ = fmt.Fprintf(dest, "[startup_script] %s\n", line)
400+
}
401+
_, _ = fmt.Fprintf(dest, "\n")
402+
}
403+
return nil
404+
}
405+
340406
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
341407
// avoid spamming the user with notifications in case of multiple instances
342408
// of the CLI running simultaneously.

0 commit comments

Comments
 (0)