-
Notifications
You must be signed in to change notification settings - Fork 0
399 lines (385 loc) · 18.7 KB
/
Copy pathci.yml
File metadata and controls
399 lines (385 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
---
name: CI
on:
push:
branches: [main]
pull_request:
# Least privilege by default; jobs widen only what they need.
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Third-party actions are pinned to a full commit SHA (a re-pointed tag can ship
# malicious code — cf. the March 2026 KICS action compromise). The trailing
# comment records the human-readable version; .github/dependabot.yml bumps them.
jobs:
# Detect whether ansible/, the Helm chart, compose/ or cloud-init/ changed so heavy
# jobs skip unrelated PRs.
changes:
runs-on: ubuntu-latest
timeout-minutes: 5 # ~5s in practice
permissions:
contents: read
pull-requests: read
outputs:
ansible: ${{ steps.filter.outputs.ansible }}
helm: ${{ steps.filter.outputs.helm }}
compose: ${{ steps.filter.outputs.compose }}
cloud-init: ${{ steps.filter.outputs.cloud-init }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
# ci.yml is listed because the ansible jobs' cache wiring lives here, so a
# change to it must re-run them — same reason the helm filter claims it.
ansible:
- 'ansible/**'
- '.github/workflows/ci.yml'
# The chart shares the schema-key inventory and checker with molecule.
helm:
- 'charts/**'
- 'ansible/molecule/schema/files/**'
- 'Makefile'
- '.github/workflows/ci.yml'
# lint-compose and security-compose live in the root Makefile.
compose:
- 'compose/**'
- 'Makefile'
- '.github/workflows/ci.yml'
# lint-cloud-init lives in the root Makefile and reads
# ansible/requirements.yml (the collection lock must cover it).
cloud-init:
- 'cloud-init/**'
- 'ansible/requirements.yml'
- 'Makefile'
- '.github/workflows/ci.yml'
# Ansible style + best-practice + the production-profile SECURITY rules,
# plus a syntax-check of every playbook. Runs only when ansible/ changed.
ansible-lint:
needs: changes
if: needs.changes.outputs.ansible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # ~1m15s in practice
defaults:
run:
working-directory: ansible
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# `cache: pip` needs a file to key on and this repo ships no pip manifest, so
# the workflow is the dependency declaration — it is where the package list
# literally lives. The installs stay unpinned `--upgrade`, so pip still queries
# the PyPI index to resolve; the cache saves the download, not the round trip.
# That is the right trade: PyPI is not the flaky dependency here, Galaxy is
# (see the collections cache below). setup-python derives its own key and takes
# no custom suffix, so this job and galaxy-build share one pip cache; their
# package sets overlap heavily (ansible-core, ansible-lint) so whichever
# populates it first covers most of both. To split them, drop `cache:` and use
# actions/cache on ~/.cache/pip with explicit per-job keys.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/workflows/ci.yml
- name: Install Ansible tooling
run: python -m pip install --upgrade ansible ansible-lint yamllint
# A warm collections tree removes the Galaxy round trip outright: with the
# requirements already satisfied on disk, `ansible-galaxy collection install`
# prints "Nothing to do" and never contacts a server. So a hit is IMMUNE to the
# transient `Connection reset by peer` that failed #50 mid-resolve on the
# transitive community.crypto — not merely faster than it.
#
# restore/save are split rather than the all-in-one action on purpose: the
# failure being defended against is Galaxy dying part way through an install,
# and the combined action's post step would persist that half-written tree.
# Saving only after `make deps` returns 0 keeps a poisoned cache out.
#
# No restore-keys — an older cache still satisfies the `>=` ranges in
# requirements.yml, so `make deps` would find nothing to do and the cache-epoch
# bump documented in that file would silently be a no-op.
#
# `path:` and `hashFiles()` resolve against the workspace root, NOT this job's
# `working-directory: ansible`. The key is shared with galaxy-build and with
# molecule.yml; when they miss together they race to save it and the losers log
# a harmless "Cache already exists".
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: galaxy-cache
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Install Galaxy collections
run: make deps # must precede lint/syntax-check
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: steps.galaxy-cache.outputs.cache-hit != 'true'
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Lint (yamllint + ansible-lint)
run: make lint
- name: Syntax-check playbooks
# Dummy inventory: no real hosts.yml is committed upstream (only hosts.yml.example),
# and --syntax-check only parses, it never connects. Every playbook, so a new
# one (backup, decommission, ...) is covered without editing this step.
run: |
for pb in playbooks/*.yml; do
ansible-playbook "$pb" --syntax-check -i localhost,
done
# Build the public `decdn.node` collection and run galaxy-importer's checks —
# the same validation Galaxy runs on upload (metadata, license, README, embedded
# ansible-lint). This is a readiness GATE only: it never publishes and needs no
# token. Runs only when ansible/ changed.
galaxy-build:
needs: changes
if: needs.changes.outputs.ansible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # ~1m45s in practice
defaults:
run:
working-directory: ansible
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shares the pip cache with ansible-lint — see that job for why the key is
# the workflow file.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/workflows/ci.yml
- name: Install build + import tooling
run: python -m pip install --upgrade ansible-core ansible-lint galaxy-importer
# Same cache as ansible-lint (same key, same rationale — see that job).
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: galaxy-cache
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Vendor collection dependencies
# So galaxy-importer's embedded ansible-lint can resolve the roles' FQCNs
# (devsec.hardening, ansible.posix). Installed under ansible/collections.
run: make deps
env:
ANSIBLE_COLLECTIONS_PATH: collections
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: steps.galaxy-cache.outputs.cache-hit != 'true'
with:
path: ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('ansible/requirements.yml') }}
- name: Build + validate the decdn.node collection
run: make galaxy-check
env:
ANSIBLE_COLLECTIONS_PATH: collections
- name: Upload collection artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: decdn-node-collection
path: ansible/build/decdn-node-*.tar.gz
if-no-files-found: ignore
# Helm chart: `helm lint --strict`, positive + negative render tests, kubeconform
# (digest-pinned image) and the upstream schema-key check shared with molecule —
# all via `make lint-helm`, the same command developers run locally.
helm:
needs: changes
if: needs.changes.outputs.helm == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # pulls the kubeconform image; ~1m in practice
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Helm version is pinned here, in the kics job below and twice in release.yml;
# bump all four.
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v4.3.0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Chart lint + render tests
# yq (mikefarah) and Docker ship on ubuntu-latest. No decdn binary here, so
# the real `decdn config validate` step reports SKIPPED; run it locally with
# DECDN_CLI=... before bumping the decdn version.
run: make lint-helm
# cloud-init path: `cloud-init schema` plus the invariants cloud-init/README.md
# promises (no secrets, release install, host-generated wallet, localhost in
# decdn_nodes, a keyed admin account, shellcheck-clean stage 1, a collection lock
# covering ansible/requirements.yml). The boot itself is the molecule `cloud-init`
# scenario (molecule.yml). Runs only when cloud-init/, ansible/requirements.yml, the
# root Makefile or ci.yml changed.
cloud-init:
needs: changes
if: needs.changes.outputs.cloud-init == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10 # an apt install at most; seconds otherwise
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# yq and shellcheck ship on ubuntu-latest; cloud-init may not.
- name: Install cloud-init
run: command -v cloud-init || { sudo apt-get update -qq && sudo apt-get install -y -qq cloud-init; }
- name: cloud-init invariants
run: make lint-cloud-init
# Docker Compose path: render compose/compose.yaml with its example env files and
# assert the invariants its README promises (host network, no published ports,
# digest-pinned image, read-only rootfs, no capabilities, graceful stop). Runs
# only when compose/ or the root Makefile changed.
compose:
needs: changes
if: needs.changes.outputs.compose == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5 # docker compose + jq ship on ubuntu-latest; seconds in practice
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Compose invariants
run: make lint-compose
# Render roles/grafana_alloy's templates and validate them with the REAL pinned
# Grafana Alloy binary. The molecule `grafana-cloud` scenario deliberately runs
# against a stub that exits 0 for every subcommand, so it proves plumbing but
# would happily ship a config Alloy cannot load (an unknown component, a block
# in the wrong parent, a CLI flag that does not exist). This job is that gate.
# Runs only when ansible/ changed.
alloy-config:
needs: changes
if: needs.changes.outputs.ansible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15 # ~1m in practice, most of it the first .deb fetch
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/workflows/ci.yml
- name: Install Ansible
run: python -m pip install --upgrade ansible
# Keyed on the role defaults, which is where the version + digest pin
# lives: bumping grafana_alloy_version invalidates the cache by
# construction, so a bump is always validated against the new binary.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ansible/.cache/alloy
key: alloy-${{ runner.os }}-${{ hashFiles('ansible/roles/grafana_alloy/defaults/main.yml') }}
- name: Validate the rendered Alloy configuration
run: make lint-alloy
# Dedicated IaC security scan of the Ansible tree, the rendered Helm chart and
# compose/, driven straight from the digest-pinned KICS *engine* image by
# `make security` — the exact command
# developers run locally, so CI and local results cannot drift. KICS severities
# are CRITICAL/HIGH/MEDIUM/LOW/INFO; the engine's own `--fail-on high` exit
# code is the gate.
#
# SUPPLY-CHAIN NOTE: we deliberately do NOT use Checkmarx/kics-github-action.
# Its git tags were hijacked in the March 2026 TeamPCP attack (CISA KEV), and
# beyond that history its entrypoint `apk add`s nodejs/npm at *run* time inside
# its digest-pinned base image and then executes the result — an unpinned fetch
# that defeats the pinning it advertises. That fetch also breaks the action
# outright today: Chainguard's current nodejs wants a newer glibc than the
# pinned base ships, so `node dist/index.js` dies and the step exits non-zero
# no matter what the scan found. Driving the engine image ourselves drops the
# Node layer entirely and leaves one pinned artifact — `KICS_IMAGE` in the
# Makefile, pinned by Docker Hub digest (a different artifact from the
# hijacked action). See CONTRIBUTING.md.
kics:
needs: changes
if: >-
needs.changes.outputs.ansible == 'true' || needs.changes.outputs.helm == 'true'
|| needs.changes.outputs.compose == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20 # pulls the KICS engine image; ~30s in practice
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v4.3.0
- name: KICS security scan of ansible/, the rendered chart and compose/ (fail on HIGH)
run: make security
# Replaces the action's `enable_jobs_summary`.
- name: Summarise KICS findings
if: always()
run: |
for scan in ansible helm compose; do
results=kics-results/results.json
[ "$scan" != ansible ] && results="kics-results/$scan/results.json"
{
echo "### KICS IaC scan ($scan)"
echo
if [ -f "$results" ]; then
jq -r '.severity_counters
| "| CRITICAL | HIGH | MEDIUM | LOW | INFO |",
"|---|---|---|---|---|",
"| \(.CRITICAL) | \(.HIGH) | \(.MEDIUM) | \(.LOW) | \(.INFO) |"' "$results"
echo
jq -r 'if (.total_counter // 0) == 0 then "No findings."
else (.queries[] | .query_name as $q | .severity as $s
| .files[] | "- **\($s)** \($q) — `\(.file_name):\(.line)`")
end' "$results"
else
echo "No results file — the scan did not complete."
fi
echo
} >> "$GITHUB_STEP_SUMMARY"
done
- name: Upload KICS results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: kics-results
path: kics-results/
if-no-files-found: ignore
# To surface KICS findings in the repo's Security tab, enable GitHub code
# scanning (free on public repos) and un-comment the block below (add
# `security-events: write` to this job's permissions):
# - name: Upload SARIF to code scanning
# if: always()
# uses: github/codeql-action/upload-sarif@d77b13a0df3134d64a457ea9003f600b09fa1c8a # v3.36.1
# with:
# sarif_file: kics-results/results.sarif
# Every pre-commit hook on every file: hygiene, shellcheck, markdownlint and
# yamllint. The same gate `make lint` runs locally, so contributors who never
# installed the hook still meet it. The manual-stage ansible-lint hook is skipped
# (the ansible-lint job above covers it). Always runs: it is cheap and covers
# every path.
pre-commit:
runs-on: ubuntu-latest
timeout-minutes: 10 # hook environments are cached; ~1m cold
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install pre-commit
run: python -m pip install --upgrade pre-commit
# Hook environments (node for markdownlint, the shellcheck image, yamllint's
# virtualenv) keyed on the config that pins them.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run every hook on every file
run: pre-commit run --all-files --show-diff-on-failure
# The repo's own guard rails: ansible/Makefile's scoping guards (dry runs), the
# release gate, and the lint-compose and lint-cloud-init negative cases
# (tests/scripts-test.sh). Always runs: seconds, and it covers files every path
# filter above would miss.
scripts:
runs-on: ubuntu-latest
timeout-minutes: 10 # an apt install at most; seconds otherwise
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Without cloud-init on PATH the harness SKIPS the lint-cloud-init cases.
- name: Install cloud-init
run: command -v cloud-init || { sudo apt-get update -qq && sudo apt-get install -y -qq cloud-init; }
- name: Script and guard tests
run: make test-scripts
# Lint the workflow files themselves.
actionlint:
runs-on: ubuntu-latest
timeout-minutes: 10 # ~15s in practice
permissions:
contents: read
checks: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: reviewdog/action-actionlint@320fcdd9c860767cf17fab3b20e22e739d5d02b8 # v1.76.0
with:
reporter: github-check
fail_on_error: true