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}