v0.14.0 815fb98

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

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