Skip to content

Commit b4898e1

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

File tree

12 files changed

+1296
-2
lines changed

12 files changed

+1296
-2
lines changed

agent/api.go

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

cli/ssh.go

+67-1
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+
Start: 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 tail.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)