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}