Files
cozystack/.github/workflows/pull-requests.yaml
Myasnikov Daniil 9e11998ae9 fix(ci, kubernetes): build container disks concurrently to unblock Build
Every release-1.5 PR Build job dies at the 30-minute timeout, and all of
it is spent in one target. `image-ubuntu-container-disk` builds a disk
per Kubernetes minor -- six of them -- and each spends ~4-5min inside a
libguestfs appliance installing that minor's kubelet/kubeadm into its own
copy of the cloud image. Serially that is ~28min of the 30min budget:

  v1.35 6:00, v1.34 4:21, v1.33 4:27, v1.32 4:39, v1.31 4:42, v1.30 cancelled

None of it is cached, and no warmer will fix that on its own. Only
build-main.yaml sets WRITE_CACHE=1, and main dropped this image from
`make image` in 39cdc8866 six days before release-1.5 was cut, so the
:*-buildcache refs it reads have never been written -- the CI log shows
all six importers failing with "not found". release-1.5 is now the only
branch that builds this image at all.

The six builds share no per-version work, so run them concurrently
instead. A sub-make carries its own -j, keeping the root `make build`
serial, and buildkit dedups the shared guestfish and cloud-image stages
across the concurrent solves. --output-sync=target groups each version's
log under its target name; six interleaved buildx progress streams have
no per-solve identifier and are unreadable. Verified the per-version
buildx/echo/rm command set is byte-identical to the loop it replaces
across PUBLISH_VERSIONED, PUBLISH_FLOATING and WRITE_CACHE combinations.

Moving off the 4cpu/16gb shape because six concurrent libguestfs
appliances want ~9gb of RAM and a core apiece. The comment justifying
the small shape assumed a warm cache, which no longer holds here.

This also stops swallowing failures. The loop separated versions with
`;`, so make saw only the last version's exit status: a failed build for
any other version left its committed .tag file stale and the job green.
Under the sub-make a failure in any version fails the target.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-27 20:46:38 +05:00

459 lines
19 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Pull Request
env:
# TODO: unhardcode this
REGISTRY: iad.ocir.io/idyksih5sir9/cozystack
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel in‑flight runs for the same PR when a new push arrives.
concurrency:
group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
detect-changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- '!docs/**'
build:
name: Build
# Run each Build on its own ephemeral VM instead of the shared self-hosted
# runner. The shared host runs 4 runner agents against one dockerd-embedded
# buildkit; concurrent build jobs serialize on buildkit's single-writer bbolt
# cache lock + exporter mutex and stall to the 30-min timeout (proven via a
# live SIGUSR1 goroutine dump). One VM per job => one buildkit per job => that
# contention is severed by construction. Phase 0.5 of #2937.
#
# 24cpu/96gb rather than the 4cpu/16gb this used to run on: `make build` is
# still serial across images, but this branch is the only one that still
# builds ubuntu-container-disk, and it does so once per Kubernetes minor with
# no shared build cache to warm it (main dropped the image in 39cdc8866, six
# days before release-1.5 was cut, so nothing writes its :*-buildcache refs).
# Those six builds now run concurrently, and each drives its own libguestfs
# appliance -- ~9gb of RAM and a core apiece, which the small shape cannot
# give. The `debug` label still routes to self-hosted for the breakpoint path.
runs-on: ${{ contains(github.event.pull_request.labels.*.name, 'debug') && 'self-hosted' || 'oracle-vm-24cpu-96gb-x86-64' }}
timeout-minutes: 30
permissions:
contents: read
packages: write
needs: ["detect-changes"]
# Never run when the PR carries the "release" label or only docs changed.
if: |
needs.detect-changes.outputs.code == 'true'
&& !contains(github.event.pull_request.labels.*.name, 'release')
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
# Ephemeral runners don't carry two tools the persistent self-hosted
# runner accreted: the helm-unittest plugin (make unit-tests) and the
# flux CLI (the installer's `flux push artifact` publishes the
# cozystack-packages OCI artifact the operator pulls). Install if absent
# (idempotent; the debug/self-hosted path already has them). Durable fix:
# bake both into the oracle-vm runner image, then drop this step.
- name: Set up build toolchain
run: |
helm plugin list 2>/dev/null | grep -q unittest \
|| helm plugin install https://github.com/helm-unittest/helm-unittest
command -v flux >/dev/null \
|| curl -s https://fluxcd.io/install.sh | sudo bash
- name: Run unit tests
run: make unit-tests
- name: Run controller Go tests
run: make test-controllers
- name: Set up Docker config
run: |
if [ -d ~/.docker ]; then
cp -r ~/.docker "${{ runner.temp }}/.docker"
fi
- name: Login to GitHub Container Registry
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: docker/login-action@v3
with:
username: ${{ secrets.OCIR_USER}}
password: ${{ secrets.OCIR_TOKEN }}
registry: iad.ocir.io
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
- name: Build
run: make build
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# Unique per (PR, commit) so concurrent PR builds never push the same
# tag. PUBLISH_VERSIONED/PUBLISH_FLOATING stay off — PR images are
# consumed by digest, and :latest belongs to release builds.
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
PUBLISH_VERSIONED: '0'
PUBLISH_FLOATING: '0'
- name: Build Talos image
run: make -C packages/core/talos talos-nocloud
- name: Save git diff as patch
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
run: git diff HEAD > _out/assets/pr.patch
- name: Upload git diff patch
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
uses: actions/upload-artifact@v4
with:
name: pr-patch
path: _out/assets/pr.patch
- name: Upload Talos image
uses: actions/upload-artifact@v4
with:
name: talos-image
path: _out/assets/nocloud-amd64.raw.xz
resolve_assets:
name: "Resolve assets"
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'release')
outputs:
disk_id: ${{ steps.fetch_assets.outputs.disk_id }}
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
owner: cozystack
- name: Checkout code
if: contains(github.event.pull_request.labels.*.name, 'release')
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Extract tag from PR branch (release PR)
if: contains(github.event.pull_request.labels.*.name, 'release')
id: get_tag
uses: actions/github-script@v7
with:
script: |
const branch = context.payload.pull_request.head.ref;
const m = branch.match(/^release-(\d+\.\d+\.\d+(?:[-\w\.]+)?)$/);
if (!m) {
core.setFailed(`❌ Branch '${branch}' does not match 'release-X.Y.Z[-suffix]'`);
return;
}
core.setOutput('tag', `v${m[1]}`);
- name: Find draft release & asset IDs (release PR)
if: contains(github.event.pull_request.labels.*.name, 'release')
id: fetch_assets
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const tag = '${{ steps.get_tag.outputs.tag }}';
const releases = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
const draft = releases.data.find(r => r.tag_name === tag && r.draft);
if (!draft) {
core.setFailed(`Draft release '${tag}' not found`);
return;
}
const find = (n) => draft.assets.find(a => a.name === n)?.id;
const diskId = find('nocloud-amd64.raw.xz');
if (!diskId) {
core.setFailed('Required assets missing in draft release');
return;
}
core.setOutput('disk_id', diskId);
e2e:
name: "E2E Tests"
runs-on: ${{ contains(github.event.pull_request.labels.*.name, 'debug') && 'self-hosted' || 'oracle-vm-24cpu-96gb-x86-64' }}
#runs-on: [oracle-vm-32cpu-128gb-x86-64]
timeout-minutes: 180
permissions:
contents: read
packages: read
checks: write
needs: ["build", "resolve_assets"]
if: ${{ always() && (needs.build.result == 'success' || needs.resolve_assets.result == 'success') }}
steps:
- name: Generate GitHub App token
if: contains(github.event.pull_request.labels.*.name, 'release')
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
owner: cozystack
# ▸ Checkout and prepare the codebase
# fetch-depth: 0 is required by the "Select E2E tests" step so that
# `git diff origin/${BASE_REF}...HEAD` can resolve the base ref.
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
# ▸ Regular PR path – download artefacts produced by the *build* job
- name: "Download Talos image (regular PR)"
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
uses: actions/download-artifact@v4
with:
name: talos-image
path: _out/assets
- name: Download PR patch
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
uses: actions/download-artifact@v4
with:
name: pr-patch
path: _out/assets
- name: Apply patch
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
run: |
git apply _out/assets/pr.patch
# ▸ Release PR path – fetch artefacts from the corresponding draft release
- name: Download assets from draft release (release PR)
if: contains(github.event.pull_request.labels.*.name, 'release')
run: |
mkdir -p _out/assets
curl -sSL -H "Authorization: token ${APP_TOKEN}" -H "Accept: application/octet-stream" \
-o _out/assets/nocloud-amd64.raw.xz \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/assets/${{ needs.resolve_assets.outputs.disk_id }}"
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Set sandbox ID
run: echo "SANDBOX_NAME=cozy-e2e-sandbox-$(echo "${GITHUB_REPOSITORY}:${GITHUB_WORKFLOW}:${GITHUB_REF}" | sha256sum | cut -c1-10)" >> $GITHUB_ENV
# ▸ Prepare environment
- name: Prepare workspace
run: |
rm -rf /tmp/$SANDBOX_NAME
cp -r ${{ github.workspace }} /tmp/$SANDBOX_NAME
- name: Prepare environment
run: |
cd /tmp/$SANDBOX_NAME
attempt=0
until make SANDBOX_NAME=$SANDBOX_NAME prepare-env; do
attempt=$((attempt + 1))
if [ $attempt -ge 3 ]; then
echo "Attempt $attempt failed, exiting..."
exit 1
fi
echo "Attempt $attempt failed, retrying..."
done
echo "Prepare environment completed after $((attempt + 1)) attempts"
# ▸ Install Cozystack
- name: Install Cozystack into sandbox
run: |
cd /tmp/$SANDBOX_NAME
if ! make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME install-cozystack; then
echo "❌ Install failed (no retry — see diagnostics below)"
echo "::group::Diagnostics: HelmRelease status"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A -o wide 2>&1 | tail -100' || true
echo "::endgroup::"
echo "::group::Diagnostics: not-Ready HelmReleases (describe)"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A --no-headers 2>/dev/null | awk "\$4 != \"True\" {print \$1\" \"\$2}"' \
| while read -r ns name; do
echo "--- $ns/$name ---"
docker exec "$SANDBOX_NAME" sh -c "kubectl describe hr '$name' -n '$ns' 2>&1 | tail -50" || true
done
echo "::endgroup::"
echo "::group::Diagnostics: recent events"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get events -A --sort-by=.lastTimestamp 2>&1 | tail -50' || true
echo "::endgroup::"
exit 1
fi
# Test Impact Analysis (TIA): walk the PackageSource graph for the PR
# diff and select only the bats files affected. Runs by default; opt
# out via the `full-e2e` label to run the full suite.
# `skip=true` means nothing to test (docs-only PR).
- name: Select E2E tests
id: select
if: ${{ !contains(github.event.pull_request.labels.*.name, 'full-e2e') }}
env:
BASE_REF: ${{ github.base_ref }}
run: |
cd /tmp/$SANDBOX_NAME
git diff --name-only "origin/${BASE_REF}...HEAD" > /tmp/changed.txt
apps=$(./hack/select-e2e.sh /tmp/changed.txt)
echo "Selected apps: ${apps:-<none>}"
echo "apps=${apps}" >> $GITHUB_OUTPUT
if [ -z "${apps}" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Run OpenAPI tests
run: |
cd /tmp/$SANDBOX_NAME
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME test-openapi
# Run E2E tests. With `full-e2e` label the selector is skipped, its
# outputs are empty, the != 'true' guard lets this step run, and the
# full bats list is used. Without the label, the selector decides;
# an empty selection (docs-only) skips this step entirely.
- name: Run E2E tests
id: e2e_tests
if: ${{ steps.select.outputs.skip != 'true' }}
env:
SELECTED_APPS: ${{ steps.select.outputs.apps }}
FULL_E2E: ${{ contains(github.event.pull_request.labels.*.name, 'full-e2e') }}
run: |
cd /tmp/$SANDBOX_NAME
if [ "$FULL_E2E" = "true" ] || [ -z "$SELECTED_APPS" ]; then
apps_list=$(ls hack/e2e-apps/*.bats | xargs -n1 basename | cut -d. -f1)
else
apps_list="$SELECTED_APPS"
fi
failed_tests=""
for app in $apps_list; do
echo "::group::Testing $app"
if make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME test-apps-$app; then
echo "::endgroup::"
echo "✅ Test $app completed successfully"
else
echo "::endgroup::"
echo "❌ Test $app failed (no retry — see diagnostics below)"
failed_tests="$failed_tests $app"
echo "::group::Diagnostics for $app"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A -o wide 2>&1 | tail -50' || true
docker exec "$SANDBOX_NAME" sh -c 'kubectl get events -A --sort-by=.lastTimestamp 2>&1 | tail -30' || true
# COSI state at the moment of failure — the CRDs ship no printer
# columns, so pull the readiness fields explicitly (cozyreport
# captures the same at end-of-job, but bucket states move between
# a test failing and the report being taken).
docker exec "$SANDBOX_NAME" sh -c '
kubectl get bucketclaims.objectstorage.k8s.io -A -o custom-columns="NAMESPACE:.metadata.namespace,NAME:.metadata.name,READY:.status.bucketReady,BUCKET:.status.bucketName" 2>&1
kubectl get buckets.objectstorage.k8s.io -o custom-columns="NAME:.metadata.name,READY:.status.bucketReady,ID:.status.bucketID,CLAIMNS:.spec.bucketClaim.namespace" 2>&1
kubectl get bucketaccesses.objectstorage.k8s.io -A -o custom-columns="NAMESPACE:.metadata.namespace,NAME:.metadata.name,GRANTED:.status.accessGranted,CLAIM:.spec.bucketClaimName" 2>&1
' || true
echo "::endgroup::"
fi
done
if [ -n "$failed_tests" ]; then
echo "❌ Failed tests:$failed_tests"
exit 1
fi
echo "✅ All E2E tests passed"
# ▸ Collect debug information (always runs)
- name: Collect report
if: always()
run: |
cd /tmp/$SANDBOX_NAME
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME collect-report || true
- name: Upload cozyreport.tgz
if: always()
uses: actions/upload-artifact@v4
with:
name: cozyreport
path: /tmp/${{ env.SANDBOX_NAME }}/_out/cozyreport.tgz
- name: Collect images list
if: always()
run: |
cd /tmp/$SANDBOX_NAME
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME collect-images || true
- name: Upload image list
if: always()
uses: actions/upload-artifact@v4
with:
name: image-list
path: /tmp/${{ env.SANDBOX_NAME }}/_out/images.txt
# ▸ Open an SSH breakpoint to the failing sandbox so maintainers can attach,
# inspect Talos/Cozystack state and resume with `breakpoint resume`.
#
# Gated by the `debug` or `release` label (release PRs are bot-created,
# so their presence implies maintainer authorship). authorized-users is
# kept as a second defense layer at the breakpoint level.
#
# Uses cozystack/breakpoint-action (fork of namespacelabs/breakpoint-action)
# pinned by SHA. The fork adds: pause-idle mode (initial grace period for
# the first SSH connection, idle-aware exit afterwards), endpoint output
# and ::notice:: annotation, and a dedicated Check Run "Breakpoint Open"
# that carries the SSH endpoint in output.summary while the breakpoint is
# paused (conclusion=failure → standard ✗ in `gh pr checks`; updated to
# conclusion=success when the breakpoint exits).
#
# Configured via the BREAKPOINT_ENDPOINT repository variable — in fork
# PRs repository variables are not exposed, so the step is skipped and
# forks cannot reach the self-hosted rendezvous server.
- name: Breakpoint on E2E failure
if: |
failure() &&
vars.BREAKPOINT_ENDPOINT != '' &&
(contains(github.event.pull_request.labels.*.name, 'debug')
|| contains(github.event.pull_request.labels.*.name, 'release'))
# cozystack/breakpoint-action v2-cozy.1
# mode: pause-idle defaults: grace-period=20m, idle-timeout=10m
uses: cozystack/breakpoint-action@a6f3a6f87be398ad63b6577351e3398e53f578e4
with:
mode: pause-idle
endpoint: ${{ vars.BREAKPOINT_ENDPOINT }}
authorized-users: androndo, Arsolitt, IvanHunters, kvaps, lexfrei, lllamnyp, mattia-eleuteri, matthieu-robin, myasnikovdaniil, sircthulhu, tym83
check-run-name: "Breakpoint Open"
github-token: ${{ github.token }}
check-run-summary-template: |
## 🔴 SSH breakpoint open — paused for debug
```
{endpoint}
```
Enter the e2e sandbox after SSH:
```
docker exec -ti $(docker ps --filter name=cozy-e2e-sandbox -q | head -1) bash
export KUBECONFIG=/workspace/kubeconfig
```
Resume from inside: `breakpoint resume`. Otherwise the breakpoint
exits 10 minutes after the last SSH session disconnects.
# ▸ Tear down environment (always runs)
- name: Tear down sandbox
if: always()
run: make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME delete || true
- name: Remove workspace
if: always()
run: rm -rf /tmp/$SANDBOX_NAME