v0.8.1 b8e893f

actions_log.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"net/http"
11	"strconv"
12	"strings"
13	"time"
14
15	"bnema.dev/ermite/internal/clientlimit"
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// LogLineView is a line of a job log, as shown.
21type LogLineView struct {
22	N    int64
23	Step int32
24	Text string
25	Time string
26	// Group marks "##[group]" and "##[endgroup]" lines and Error the
27	// "##[error]" ones; the text is shown without the marker.
28	Group, End, Error bool
29	// Continued marks a piece of a line whose beginning is in an earlier batch
30	// of the live feed: the page appends its text to the line before it.
31	Continued bool
32}
33
34// JobPage is one job with its steps and its log.
35type JobPage struct {
36	JobRow
37	RunHref string
38	RunName string
39	Run     string
40	Steps   []StepView
41	Lines   []LogLineView
42	// From is the first line shown; Next is the link to the lines after the
43	// last shown, "" when it is the end; Follow says the log is still being
44	// written, so the page follows it live.
45	From    int64
46	Next    string
47	Prev    string
48	Total   int64
49	Follow  bool
50	RawHref string
51	// StreamHref is the live feed of the lines (logStream).
52	StreamHref string
53	Note       string
54}
55
56// appendLogLine adds the line n of a job log to the lines shown. The markers
57// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
58// are not shown as text. A piece that continues a line too long to send whole
59// is joined to the line before it; one that opens the list (its line is in an
60// earlier batch) keeps Continued, and the page appends it to that line.
61func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
62	text := l.Text
63	v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
64	switch {
65	case strings.HasPrefix(text, "##[group]"):
66		v.Group, text = true, strings.TrimPrefix(text, "##[group]")
67	case strings.HasPrefix(text, "##[endgroup]"):
68		v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
69	case strings.HasPrefix(text, "##[error]"):
70		v.Error, text = true, strings.TrimPrefix(text, "##[error]")
71	}
72	v.Text = visibleText(text)
73	if l.Continued && len(out) > 0 {
74		out[len(out)-1].Text += v.Text
75		return out
76	}
77	return append(out, v)
78}
79
80// logClient is the key of the visitor for the limit of log streams per client.
81func (s *Server) logClient(r *http.Request) string {
82	return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
83}
84
85// readLog reads up to max lines from a line number, through the log stream of
86// the use case, and returns the line after the last read.
87func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
88	if from > j.Lines || j.Lines == 0 {
89		return nil, from, nil
90	}
91	var out []LogLineView
92	next := from
93	stop := errors.New("enough")
94	err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
95		for i, l := range b.Lines {
96			if len(out) >= max && !l.Continued {
97				return stop // the rest of a cut line still belongs to this page
98			}
99			out = appendLogLine(out, b.Offset+int64(i), l)
100			next = b.Offset + int64(i) + 1
101		}
102		return nil
103	})
104	if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
105		return nil, from, err
106	}
107	return out, next, nil
108}
109
110// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
111// one line per line with its time, joined where the runner cut a long line.
112func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
113	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
114	defer cancel()
115	d, err := s.loadRepo(ctx, r.PathValue("repo"))
116	if err != nil {
117		s.fail(ctx, w, r, err)
118		return
119	}
120	id, err := parseRunPath(r)
121	jobID := r.PathValue("job")
122	if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
123		s.fail(ctx, w, r, errNotFound)
124		return
125	}
126	var attempt int32
127	if a := r.URL.Query().Get("attempt"); a != "" {
128		n, perr := strconv.ParseInt(a, 10, 32)
129		if perr != nil || n < 1 {
130			s.fail(ctx, w, r, errNotFound)
131			return
132		}
133		attempt = int32(n)
134	}
135	// The job is read first: a job that is not there, or not readable, is a 404
136	// before any header of the log is sent.
137	if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
138		s.fail(ctx, w, r, err)
139		return
140	}
141	h := w.Header()
142	h.Set("Content-Type", "text/plain; charset=utf-8")
143	h.Set("X-Content-Type-Options", "nosniff")
144	h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
145	h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
146	cacheHeaders(ctx, h, "no-cache", sessionCache)
147	written := 0
148	open := false
149	err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt, Client: s.logClient(r)}, func(b ports.LogBatch) error {
150		for _, l := range b.Lines {
151			if written > rawLogMaxBytes {
152				return errors.New("log too large")
153			}
154			var line string
155			switch {
156			case l.Continued && open:
157				line = l.Text
158			case open:
159				line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
160			default:
161				line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
162			}
163			open = true
164			n, werr := fmt.Fprint(w, line)
165			written += n
166			if werr != nil {
167				return werr
168			}
169		}
170		return nil
171	})
172	if open {
173		_, _ = fmt.Fprintln(w)
174	}
175	if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
176		w.WriteHeader(http.StatusNotFound)
177	}
178}
179
180// logClass is the class of a line of the log.
181func logClass(ln LogLineView) string {
182	switch {
183	case ln.Group:
184		return "lg group"
185	case ln.End:
186		return "lg endgroup"
187	case ln.Error:
188		return "lg error"
189	}
190	return "lg"
191}
192
193// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
194// lines of a job log from line N as Server-Sent Events, following the job while
195// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
196// the number of the next line as its id, so that a reconnection (Last-Event-ID)
197// goes on where it stopped. "ci-end" tells the job is over and every line was
198// sent. The job events of the repository stream carry no line (see events.go).
199func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
200	ctx, cancel := context.WithCancel(r.Context())
201	defer cancel()
202	d, err := s.loadRepo(ctx, r.PathValue("repo"))
203	if err != nil {
204		s.denyEvents(ctx, w, err)
205		return
206	}
207	id, err := parseRunPath(r)
208	jobID := r.PathValue("job")
209	if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
210		s.denyEvents(ctx, w, errNotFound)
211		return
212	}
213	from := int64(0)
214	if v := r.Header.Get("Last-Event-ID"); v != "" {
215		from, err = strconv.ParseInt(v, 10, 64)
216	} else if v := r.URL.Query().Get("from"); v != "" {
217		from, err = strconv.ParseInt(v, 10, 64)
218	}
219	if err != nil || from < 0 {
220		s.denyEvents(ctx, w, errNotFound)
221		return
222	}
223	if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
224		s.denyEvents(ctx, w, err)
225		return
226	}
227
228	rc := http.NewResponseController(w)
229	hdr := w.Header()
230	hdr.Set("Content-Type", "text/event-stream")
231	cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
232	hdr.Set("X-Accel-Buffering", "no")
233	w.WriteHeader(http.StatusOK)
234	out := &sseWriter{w: w, rc: rc}
235	if out.raw(": connected\n\n") != nil {
236		return
237	}
238	n := from
239	err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
240		JobID: jobID, Offset: from, Follow: true,
241		Client: s.logClient(r),
242	}, func(b ports.LogBatch) error {
243		var views []LogLineView
244		for i, l := range b.Lines {
245			views = appendLogLine(views, b.Offset+int64(i), l)
246		}
247		var html strings.Builder
248		for _, v := range views {
249			var b2 strings.Builder
250			if err := LogLine(v).Render(ctx, &b2); err != nil {
251				return err
252			}
253			html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
254		}
255		n = b.Offset + int64(len(b.Lines))
256		_ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
257		_, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
258		if werr != nil {
259			return werr
260		}
261		return rc.Flush()
262	})
263	if err == nil || errors.Is(err, domain.ErrNotFound) {
264		_ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
265	}
266}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close