Skip to content

Commit c675094

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 c675094

File tree

6 files changed

+416
-10
lines changed

6 files changed

+416
-10
lines changed

.github/workflows/ci.yml

Lines changed: 186 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ jobs:
8989
9090
integration:
9191
runs-on: ubuntu-22.04
92+
# Ensure integration tests can resolve a locally-exported dalec frontend image.
93+
# The test harness (withDalecInput) uses FRONTEND_REF when provided.
94+
env:
95+
FRONTEND_REF: localhost:5000/dalec/frontend:ci
9296
strategy:
9397
fail-fast: false
9498
matrix:
@@ -137,6 +141,22 @@ jobs:
137141
username: ${{ github.actor }}
138142
password: ${{ secrets.GITHUB_TOKEN }}
139143

144+
- name: Start local registry (for FRONTEND_REF)
145+
run: |
146+
set -eux
147+
docker rm -f local-registry || true
148+
docker run -d --restart=always --net=host --name local-registry registry:2
149+
150+
- name: Setup buildx builder (host network)
151+
run: |
152+
set -eux
153+
if ! docker buildx inspect dalec-ci >/dev/null 2>&1; then
154+
docker buildx create --name dalec-ci --use --driver docker-container --driver-opt network=host
155+
else
156+
docker buildx use dalec-ci
157+
fi
158+
docker buildx inspect --bootstrap
159+
140160
- name: Setup otel-collector
141161
run: |
142162
set -e
@@ -269,7 +289,15 @@ jobs:
269289
run: |
270290
set -eu
271291
272-
docker buildx bake frontend
292+
# Build instrumented frontend and PUSH it to the local registry so the
293+
# test harness/BuildKit can always resolve it by FRONTEND_REF.
294+
docker buildx bake frontend \
295+
--set frontend.args.DALEC_FRONTEND_COVERAGE=1 \
296+
--set frontend.tags="${FRONTEND_REF}" \
297+
--push
298+
299+
docker buildx imagetools inspect "${FRONTEND_REF}"
300+
273301
if [ "${TEST_SUITE}" = "other" ]; then
274302
exit 0
275303
fi
@@ -280,22 +308,75 @@ jobs:
280308
worker="windowscross"
281309
fi
282310
export WORKER_TARGET=${worker}/worker
283-
docker buildx bake worker
311+
docker buildx bake worker \
312+
--set frontend.args.DALEC_FRONTEND_COVERAGE=1
313+
314+
# Defensive: if building the worker caused any frontend rebuild/tagging,
315+
# re-push instrumented frontend so FRONTEND_REF stays correct.
316+
docker buildx bake frontend \
317+
--set frontend.args.DALEC_FRONTEND_COVERAGE=1 \
318+
--set frontend.tags="${FRONTEND_REF}" \
319+
--push
284320
env:
285321
TEST_SUITE: ${{ matrix.suite }}
286-
- name: Run integration tests
322+
- name: Run integration tests (with coverage tracking)
287323
run: |
288324
set -ex
289-
if [ -n "${TEST_SUITE}" ] && [ ! "${TEST_SUITE}" = "other" ]; then
325+
mkdir -p coverage
326+
327+
# The frontend covdata files (covmeta/covcounters) are written by the test harness
328+
# (writeFrontendCovdata) on the RUNNER filesystem.
329+
export DALEC_FRONTEND_GOCOVERDIR="${GITHUB_WORKSPACE}/coverage/frontend-${TEST_SUITE}"
330+
mkdir -p "${DALEC_FRONTEND_GOCOVERDIR}"
331+
chmod -R a+rwx "${DALEC_FRONTEND_GOCOVERDIR}"
332+
333+
run=""
334+
skip=""
335+
if [ -n "${TEST_SUITE}" ] && [ "${TEST_SUITE}" != "other" ]; then
290336
run="-run=${TEST_SUITE}"
291337
fi
292338
if [ -n "${TEST_SKIP}" ]; then
293339
skip="-skip=${TEST_SKIP}"
294340
fi
295-
go test -timeout=59m -v -json ${run} ${skip} ./test | go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs
341+
342+
go test -timeout=59m -v -json \
343+
-covermode=atomic -coverpkg=./... \
344+
-coverprofile="coverage/integration-${TEST_SUITE}.out" \
345+
${run} ${skip} ./test \
346+
| go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs
347+
348+
# Convert frontend covdata -> legacy coverprofile
349+
if ! ls "${DALEC_FRONTEND_GOCOVERDIR}"/covmeta.* >/dev/null 2>&1; then
350+
echo "::group::frontend coverage debug"
351+
echo "DALEC_FRONTEND_GOCOVERDIR=${DALEC_FRONTEND_GOCOVERDIR}"
352+
echo "Contents:"
353+
ls -la "${DALEC_FRONTEND_GOCOVERDIR}" || true
354+
echo "Searching workspace for covmeta/covcounters..."
355+
find "${GITHUB_WORKSPACE}" \( -name 'covmeta.*' -o -name 'covcounters.*' \) 2>/dev/null | head -n 200 || true
356+
echo "::endgroup::"
357+
echo "::error::No frontend coverage covmeta.* found in ${DALEC_FRONTEND_GOCOVERDIR} (frontend coverage not collected)"
358+
exit 1
359+
fi
360+
361+
go tool covdata textfmt \
362+
-i="${DALEC_FRONTEND_GOCOVERDIR}" \
363+
-o="coverage/frontend-${TEST_SUITE}.out"
296364
env:
297365
TEST_SUITE: ${{ matrix.suite }}
298366
TEST_SKIP: ${{ matrix.skip }}
367+
368+
369+
- name: Upload integration coverage profile
370+
if: always()
371+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
372+
with:
373+
name: coverage-integration-${{ matrix.suite }}
374+
path: |
375+
coverage/integration-${{ matrix.suite }}.out
376+
coverage/frontend-${{ matrix.suite }}.out
377+
if-no-files-found: ignore
378+
retention-days: 7
379+
299380
- name: Get traces
300381
if: always()
301382
run: |
@@ -354,8 +435,27 @@ jobs:
354435
cache: false
355436
- name: download deps
356437
run: go mod download
357-
- name: Run unit tests
358-
run: go test -v --test.short --json ./... | go run ./cmd/test2json2gha
438+
- name: Run unit tests (with coverage tracking)
439+
run: |
440+
set -eux
441+
mkdir -p coverage
442+
443+
pkgs="$(go list ./... | grep -v '/test$' | grep -v '/test/' )"
444+
go test -v --test.short --json \
445+
-covermode=atomic \
446+
-coverprofile="coverage/unit.out" \
447+
${pkgs} \
448+
| go run ./cmd/test2json2gha
449+
- name: Upload unit coverage profile
450+
if: always()
451+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
452+
with:
453+
name: coverage-unit
454+
path: coverage/unit.out
455+
if-no-files-found: ignore
456+
retention-days: 7
457+
458+
359459

360460
e2e:
361461
runs-on: ubuntu-22.04
@@ -443,3 +543,82 @@ jobs:
443543
path: ${{ steps.dump-logs.outputs.DOCKERD_LOG_PATH }}
444544
retention-days: 1
445545

546+
coverage-report:
547+
runs-on: ubuntu-22.04
548+
needs:
549+
- unit
550+
- integration
551+
552+
steps:
553+
- name: Harden Runner
554+
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
555+
with:
556+
egress-policy: audit
557+
558+
- name: Checkout
559+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
560+
561+
- name: Setup Go
562+
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
563+
with:
564+
go-version: "1.25"
565+
cache: false
566+
567+
- name: Download deps
568+
run: go mod download
569+
570+
- name: Download unit coverage artifact
571+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
572+
with:
573+
name: coverage-unit
574+
path: coverage
575+
576+
- name: Download integration coverage artifacts
577+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
578+
with:
579+
path: coverage/_integration
580+
581+
- name: Merge coverage + generate report
582+
run: |
583+
set -eux
584+
go install github.com/wadey/gocovmerge@latest
585+
586+
integration_profiles="$(find coverage/_integration -type f -name 'integration-*.out' | sort | tr '\n' ' ')"
587+
frontend_profiles="$(find coverage/_integration -type f -name 'frontend-*.out' | sort | tr '\n' ' ')"
588+
if [ -z "${integration_profiles}" ]; then
589+
echo "::error::No integration coverage profiles found"
590+
exit 1
591+
fi
592+
593+
if [ -z "${frontend_profiles}" ]; then
594+
echo "::error::No frontend coverage profiles found"
595+
exit 1
596+
fi
597+
598+
if [ ! -f coverage/unit.out ]; then
599+
echo "::error::Unit coverage profile not found (coverage/unit.out)"
600+
exit 1
601+
fi
602+
603+
"$(go env GOPATH)/bin/gocovmerge" coverage/unit.out ${integration_profiles} ${frontend_profiles} > coverage/all.out
604+
605+
go tool cover -func=coverage/all.out | tee coverage/summary.txt
606+
go tool cover -html=coverage/all.out -o coverage/index.html
607+
608+
total="$(tail -n 1 coverage/summary.txt | awk '{print $3}')"
609+
{
610+
echo "## Coverage"
611+
echo
612+
echo "- Total: **${total}**"
613+
echo "- Profiles merged: $(echo "${integration_profiles}" | wc -w) integration + $(echo "${frontend_profiles}" | wc -w) frontend"
614+
} >> "${GITHUB_STEP_SUMMARY}"
615+
616+
- name: Upload merged coverage report
617+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
618+
with:
619+
name: coverage-report
620+
path: |
621+
coverage/all.out
622+
coverage/summary.txt
623+
coverage/index.html
624+
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 -covermode=atomic -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
}

0 commit comments

Comments
 (0)