Skip to content

Commit edde69b

Browse files
committed
Add code coverage
This PR adds end-to-end Go code coverage reporting to the existing CI pipeline. Fixes: #889 Signed-off-by: Kartik Joshi <karikjoshi21@gmail.com>
1 parent 30ce18e commit edde69b

File tree

6 files changed

+365
-10
lines changed

6 files changed

+365
-10
lines changed

.github/workflows/ci.yml

Lines changed: 136 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ jobs:
269269
run: |
270270
set -eu
271271
272-
docker buildx bake frontend
272+
docker buildx bake frontend --set frontend.args.DALEC_FRONTEND_COVERAGE=1
273273
if [ "${TEST_SUITE}" = "other" ]; then
274274
exit 0
275275
fi
@@ -280,22 +280,53 @@ jobs:
280280
worker="windowscross"
281281
fi
282282
export WORKER_TARGET=${worker}/worker
283-
docker buildx bake worker
283+
docker buildx bake worker --set frontend.args.DALEC_FRONTEND_COVERAGE=1
284284
env:
285285
TEST_SUITE: ${{ matrix.suite }}
286-
- name: Run integration tests
286+
- name: Run integration tests (with coverage tracking)
287287
run: |
288288
set -ex
289-
if [ -n "${TEST_SUITE}" ] && [ ! "${TEST_SUITE}" = "other" ]; then
289+
mkdir -p coverage
290+
export DALEC_FRONTEND_GOCOVERDIR="${GITHUB_WORKSPACE}/coverage/frontend-${TEST_SUITE}"
291+
292+
run=""
293+
skip=""
294+
if [ -n "${TEST_SUITE}" ] && [ "${TEST_SUITE}" != "other" ]; then
290295
run="-run=${TEST_SUITE}"
291296
fi
292297
if [ -n "${TEST_SKIP}" ]; then
293298
skip="-skip=${TEST_SKIP}"
294299
fi
295-
go test -timeout=59m -v -json ${run} ${skip} ./test | go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs
300+
301+
go test -timeout=59m -v -json \
302+
-covermode=set -coverpkg=./... \
303+
-coverprofile="coverage/integration-${TEST_SUITE}.out" \
304+
${run} ${skip} ./test \
305+
| go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs
306+
307+
# Convert frontend covdata -> legacy coverprofile (mode: set)
308+
if ! ls "${DALEC_FRONTEND_GOCOVERDIR}"/covmeta.* >/dev/null 2>&1; then
309+
echo "::error::No frontend coverage covmeta.* found in ${DALEC_FRONTEND_GOCOVERDIR} (frontend coverage not collected)"
310+
exit 1
311+
fi
312+
go tool covdata textfmt \
313+
-i="${DALEC_FRONTEND_GOCOVERDIR}" \
314+
-o="coverage/frontend-${TEST_SUITE}.out"
296315
env:
297316
TEST_SUITE: ${{ matrix.suite }}
298317
TEST_SKIP: ${{ matrix.skip }}
318+
319+
- name: Upload integration coverage profile
320+
if: always()
321+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
322+
with:
323+
name: coverage-integration-${{ matrix.suite }}
324+
path: |
325+
coverage/integration-${{ matrix.suite }}.out
326+
coverage/frontend-${{ matrix.suite }}.out
327+
if-no-files-found: ignore
328+
retention-days: 7
329+
299330
- name: Get traces
300331
if: always()
301332
run: |
@@ -354,8 +385,27 @@ jobs:
354385
cache: false
355386
- name: download deps
356387
run: go mod download
357-
- name: Run unit tests
358-
run: go test -v --test.short --json ./... | go run ./cmd/test2json2gha
388+
- name: Run unit tests (with coverage tracking)
389+
run: |
390+
set -eux
391+
mkdir -p coverage
392+
393+
pkgs="$(go list ./... | grep -v '/test$' | grep -v '/test/' )"
394+
go test -v --test.short --json \
395+
-covermode=set \
396+
-coverprofile="coverage/unit.out" \
397+
${pkgs} \
398+
| go run ./cmd/test2json2gha
399+
- name: Upload unit coverage profile
400+
if: always()
401+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
402+
with:
403+
name: coverage-unit
404+
path: coverage/unit.out
405+
if-no-files-found: ignore
406+
retention-days: 7
407+
408+
359409

360410
e2e:
361411
runs-on: ubuntu-22.04
@@ -443,3 +493,82 @@ jobs:
443493
path: ${{ steps.dump-logs.outputs.DOCKERD_LOG_PATH }}
444494
retention-days: 1
445495

496+
coverage-report:
497+
runs-on: ubuntu-22.04
498+
needs:
499+
- unit
500+
- integration
501+
502+
steps:
503+
- name: Harden Runner
504+
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
505+
with:
506+
egress-policy: audit
507+
508+
- name: Checkout
509+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
510+
511+
- name: Setup Go
512+
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
513+
with:
514+
go-version: "1.25"
515+
cache: false
516+
517+
- name: Download deps
518+
run: go mod download
519+
520+
- name: Download unit coverage artifact
521+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
522+
with:
523+
name: coverage-unit
524+
path: coverage
525+
526+
- name: Download integration coverage artifacts
527+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
528+
with:
529+
path: coverage/_integration
530+
531+
- name: Merge coverage + generate report
532+
run: |
533+
set -eux
534+
go install github.com/wadey/gocovmerge@latest
535+
536+
integration_profiles="$(find coverage/_integration -type f -name 'integration-*.out' | sort | tr '\n' ' ')"
537+
frontend_profiles="$(find coverage/_integration -type f -name 'frontend-*.out' | sort | tr '\n' ' ')"
538+
if [ -z "${integration_profiles}" ]; then
539+
echo "::error::No integration coverage profiles found"
540+
exit 1
541+
fi
542+
543+
if [ -z "${frontend_profiles}" ]; then
544+
echo "::error::No frontend coverage profiles found"
545+
exit 1
546+
fi
547+
548+
if [ ! -f coverage/unit.out ]; then
549+
echo "::error::Unit coverage profile not found (coverage/unit.out)"
550+
exit 1
551+
fi
552+
553+
"$(go env GOPATH)/bin/gocovmerge" coverage/unit.out ${integration_profiles} ${frontend_profiles} > coverage/all.out
554+
555+
go tool cover -func=coverage/all.out | tee coverage/summary.txt
556+
go tool cover -html=coverage/all.out -o coverage/index.html
557+
558+
total="$(tail -n 1 coverage/summary.txt | awk '{print $3}')"
559+
{
560+
echo "## Coverage"
561+
echo
562+
echo "- Total: **${total}**"
563+
echo "- Profiles merged: $(echo "${integration_profiles}" | wc -w) integration + $(echo "${frontend_profiles}" | wc -w) frontend"
564+
} >> "${GITHUB_STEP_SUMMARY}"
565+
566+
- name: Upload merged coverage report
567+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
568+
with:
569+
name: coverage-report
570+
path: |
571+
coverage/all.out
572+
coverage/summary.txt
573+
coverage/index.html
574+
retention-days: 14

Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ WORKDIR /build
55
COPY . .
66
ENV CGO_ENABLED=0
77
ARG TARGETARCH TARGETOS GOFLAGS=-trimpath
8+
ARG DALEC_FRONTEND_COVERAGE=0
89
ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOFLAGS=${GOFLAGS}
910
RUN \
1011
--mount=type=cache,target=/go/pkg/mod \
1112
--mount=type=cache,target=/root/.cache/go-build \
12-
go build -o /frontend ./cmd/frontend
13+
if [ "${DALEC_FRONTEND_COVERAGE}" = "1" ]; then \
14+
go build -cover -coverpkg=./... -o /frontend ./cmd/frontend ; \
15+
else \
16+
go build -o /frontend ./cmd/frontend ; \
17+
fi
1318

1419
FROM scratch AS frontend
1520
COPY --from=frontend-build /frontend /frontend

cmd/frontend/coverage.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// cmd/frontend/coverage.go
2+
package main
3+
4+
import (
5+
"bytes"
6+
"compress/gzip"
7+
"context"
8+
"strings"
9+
10+
"runtime/coverage"
11+
12+
gwclient "github.com/moby/buildkit/frontend/gateway/client"
13+
)
14+
15+
const (
16+
frontendCoverageOptKey = "dalec.coverage"
17+
frontendCovMetaKey = "dalec.coverage.frontend.meta.gz"
18+
frontendCovCountersKey = "dalec.coverage.frontend.counters.gz"
19+
)
20+
21+
func isNoMetaErr(err error) bool {
22+
if err == nil {
23+
return false
24+
}
25+
// runtime/coverage: "no meta-data available (binary not built with -cover?)"
26+
return strings.Contains(strings.ToLower(err.Error()), "no meta-data available")
27+
}
28+
29+
// Enabled per solve via SolveRequest.FrontendOpt["dalec.coverage"]="1"
30+
func wantFrontendCoverage(c gwclient.Client) bool {
31+
v, ok := c.BuildOpts().Opts[frontendCoverageOptKey]
32+
if !ok {
33+
return false
34+
}
35+
v = strings.ToLower(strings.TrimSpace(v))
36+
return v == "1" || v == "true" || v == "yes" || v == "on"
37+
}
38+
39+
func gzipBytes(in []byte) ([]byte, error) {
40+
var buf bytes.Buffer
41+
zw := gzip.NewWriter(&buf)
42+
if _, err := zw.Write(in); err != nil {
43+
_ = zw.Close()
44+
return nil, err
45+
}
46+
if err := zw.Close(); err != nil {
47+
return nil, err
48+
}
49+
return buf.Bytes(), nil
50+
}
51+
52+
func attachFrontendCoverage(c gwclient.Client, res *gwclient.Result) error {
53+
if res == nil || !wantFrontendCoverage(c) {
54+
return nil
55+
}
56+
if res.Metadata == nil {
57+
res.Metadata = map[string][]byte{}
58+
}
59+
60+
var metaBuf, ctrBuf bytes.Buffer
61+
62+
if err := coverage.WriteMeta(&metaBuf); err != nil {
63+
if isNoMetaErr(err) {
64+
return nil
65+
}
66+
return err
67+
}
68+
if err := coverage.WriteCounters(&ctrBuf); err != nil {
69+
if isNoMetaErr(err) {
70+
return nil
71+
}
72+
return err
73+
}
74+
75+
metaGz, err := gzipBytes(metaBuf.Bytes())
76+
if err != nil {
77+
return err
78+
}
79+
ctrGz, err := gzipBytes(ctrBuf.Bytes())
80+
if err != nil {
81+
return err
82+
}
83+
84+
res.Metadata[frontendCovMetaKey] = metaGz
85+
res.Metadata[frontendCovCountersKey] = ctrGz
86+
87+
// Avoid cross-solve accumulation if the frontend process is reused.
88+
// Only works for binaries built with -cover (and typically atomic counters).
89+
_ = coverage.ClearCounters()
90+
91+
return nil
92+
}
93+
94+
func wrapWithCoverage(next gwclient.BuildFunc) gwclient.BuildFunc {
95+
return func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
96+
res, err := next(ctx, c)
97+
if err != nil {
98+
return nil, err
99+
}
100+
if err := attachFrontendCoverage(c, res); err != nil {
101+
return nil, err
102+
}
103+
return res, nil
104+
}
105+
}

cmd/frontend/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ func dalecMain() {
6969
if err != nil {
7070
bklog.L.WithError(err).Fatal("error creating frontend router")
7171
}
72+
handler := mux.Handler(frontend.WithTargetForwardingHandler)
73+
handler = wrapWithCoverage(handler)
7274

73-
if err := grpcclient.RunFromEnvironment(ctx, mux.Handler(frontend.WithTargetForwardingHandler)); err != nil {
75+
if err := grpcclient.RunFromEnvironment(ctx, handler); err != nil {
7476
bklog.L.WithError(err).Fatal("error running frontend")
7577
os.Exit(70) // 70 is EX_SOFTWARE, meaning internal software error occurred
7678
}

test/testenv/buildx.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"path/filepath"
1112
"net"
1213
"os"
1314
"os/exec"
@@ -399,14 +400,35 @@ type clientForceDalecWithInput struct {
399400
}
400401

401402
func (c *clientForceDalecWithInput) Solve(ctx context.Context, req gwclient.SolveRequest) (*gwclient.Result, error) {
403+
covRoot := os.Getenv("DALEC_FRONTEND_GOCOVERDIR")
404+
405+
if covRoot != "" {
406+
if req.FrontendOpt == nil {
407+
req.FrontendOpt = map[string]string{}
408+
}
409+
// Frontend-only toggle (NOT a dalec build arg)
410+
req.FrontendOpt["dalec.coverage"] = "1"
411+
}
402412
if req.Definition == nil {
403413
// Only inject the frontend when there is no "definition" set.
404414
// If a definition is set, it is intended for this to go directly to the buildkit solver.
405415
if err := withDalecInput(ctx, c.Client, &req); err != nil {
406416
return nil, err
407417
}
408418
}
409-
return c.Client.Solve(ctx, req)
419+
res, err := c.Client.Solve(ctx, req)
420+
if err != nil {
421+
return nil, err
422+
}
423+
424+
if covRoot != "" {
425+
// Make it suite-safe by letting CI set DALEC_FRONTEND_GOCOVERDIR per-suite.
426+
// If they set a plain directory, we write directly there.
427+
if err := writeFrontendCovdata(filepath.Clean(covRoot), res); err != nil {
428+
return nil, err
429+
}
430+
}
431+
return res, nil
410432
}
411433

412434
// gwClientInputInject is a gwclient.Client that injects the result of a build func into the solve request as an input named by the id.

0 commit comments

Comments
 (0)