Skip to content

Commit ea8a61a

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

File tree

13 files changed

+1480
-2
lines changed

13 files changed

+1480
-2
lines changed

agent/api.go

Lines changed: 239 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,216 @@ 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 file 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 file not found.",
160+
})
161+
return
162+
}
163+
164+
f, err := os.Open(lf.path)
165+
if err != nil {
166+
if os.IsNotExist(err) {
167+
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
168+
Message: "Log file not found.",
169+
})
170+
return
171+
}
172+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
173+
Message: "Could not open log file.",
174+
Detail: err.Error(),
175+
})
176+
return
177+
}
178+
defer f.Close()
179+
180+
var lines []string
181+
fr := bufio.NewReader(f)
182+
n := -1
183+
for {
184+
b, err := fr.ReadBytes('\n')
185+
if err != nil {
186+
// Note, we skip incomplete lines with no newline.
187+
if err == io.EOF {
188+
break
189+
}
190+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
191+
Message: "Could not read log file.",
192+
Detail: err.Error(),
193+
})
194+
return
195+
}
196+
n++
197+
if n < offset {
198+
continue
199+
}
200+
b = bytes.TrimRight(b, "\r\n")
201+
lines = append(lines, string(b))
202+
203+
if limit > 0 && len(lines) >= limit {
204+
break
205+
}
206+
}
207+
208+
httpapi.Write(ctx, w, http.StatusOK, codersdk.WorkspaceAgentLogTailResponse{
209+
Offset: offset,
210+
Count: len(lines),
211+
Lines: lines,
212+
})
213+
}
214+
215+
func logFileInfo(w http.ResponseWriter, r *http.Request, lf ...*logFile) ([]codersdk.WorkspaceAgentLogInfo, bool) {
216+
ctx := r.Context()
217+
218+
var logs []codersdk.WorkspaceAgentLogInfo
219+
for _, f := range lf {
220+
size, lines, modified, exists, err := f.fileInfo()
221+
if err != nil {
222+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
223+
Message: "Could not gather log file info.",
224+
Detail: err.Error(),
225+
})
226+
return nil, false
227+
}
228+
229+
logs = append(logs, codersdk.WorkspaceAgentLogInfo{
230+
Name: f.name,
231+
Path: f.path,
232+
Size: size,
233+
Lines: lines,
234+
Exists: exists,
235+
Modified: modified,
236+
})
237+
}
238+
239+
return logs, true
240+
}
241+
242+
// fileInfo counts the number of lines in the log file and caches
243+
// the logFile's line count and offset.
244+
func (lf *logFile) fileInfo() (size int64, lines int, modified time.Time, exists bool, err error) {
245+
lf.mu.Lock()
246+
defer lf.mu.Unlock()
247+
248+
f, err := os.Open(lf.path)
249+
if err != nil {
250+
if os.IsNotExist(err) {
251+
return 0, 0, time.Time{}, false, nil
252+
}
253+
return 0, 0, time.Time{}, false, err
254+
}
255+
defer f.Close()
256+
257+
// Note, modified time will not be entirely accurate, but we rather
258+
// give an old timestamp than one that is newer than when we counted
259+
// the lines.
260+
info, err := f.Stat()
261+
if err != nil {
262+
return 0, 0, time.Time{}, false, err
263+
}
264+
265+
_, err = f.Seek(lf.offset, io.SeekStart)
266+
if err != nil {
267+
return 0, 0, time.Time{}, false, err
268+
}
269+
270+
r := bufio.NewReader(f)
271+
for {
272+
b, err := r.ReadBytes('\n')
273+
if err != nil {
274+
// Note, we skip incomplete lines with no newline.
275+
if err == io.EOF {
276+
break
277+
}
278+
return 0, 0, time.Time{}, false, err
279+
}
280+
size += int64(len(b))
281+
lines++
282+
}
283+
lf.offset += size
284+
lf.lines += lines
285+
286+
return lf.offset, lf.lines, info.ModTime(), true, nil
287+
}

cli/ssh.go

Lines changed: 71 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,64 @@ 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+
if !info.Exists {
377+
return nil
378+
}
379+
380+
curLine := info.Lines
381+
if curLine < 10 {
382+
curLine = 0
383+
} else {
384+
curLine -= 10
385+
}
386+
387+
tail, err := conn.LogTail(ctx, codersdk.WorkspaceAgentLogStartupScript, codersdk.WorkspaceAgentLogTailRequest{
388+
Offset: curLine,
389+
})
390+
if err != nil {
391+
return err
392+
}
393+
lines := tail.Lines
394+
if len(lines) > 0 {
395+
// More than 10 lines could've been returned if there were
396+
// new lines after info.
397+
if len(lines) > 10 {
398+
lines = lines[len(lines)-10:]
399+
}
400+
401+
_, _ = fmt.Fprintf(dest, "Showing up to the last 10 lines from startup_script:\n\n")
402+
for _, line := range lines {
403+
_, _ = fmt.Fprintf(dest, "[startup_script] %s\n", line)
404+
}
405+
_, _ = fmt.Fprintf(dest, "\n")
406+
}
407+
return nil
408+
}
409+
340410
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
341411
// avoid spamming the user with notifications in case of multiple instances
342412
// of the CLI running simultaneously.

0 commit comments

Comments
 (0)