From fa128b0d53dfef1aabfd2bc02c5266cb93459f48 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Fri, 4 Sep 2026 16:04:52 +0200 Subject: [PATCH] Introduce CI for this Repo too --- .gitea/workflows/deploy.yml | 109 ++++++++++++++++++++++++++++++++++++ .gitignore | 5 ++ README.md | 31 ++++++++++ ci-deployer.yaml | 74 ++++++++++++++++++++++++ create-ci-kubeconfig.sh | 66 ++++++++++++++++++++++ test.sh | 34 +++++++++++ 6 files changed, 319 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 ci-deployer.yaml create mode 100755 create-ci-kubeconfig.sh create mode 100755 test.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..978932e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,109 @@ +name: Validate and deploy MetalLB configuration + +on: + push: + pull_request: + +env: + KUBECONFORM_VERSION: v0.7.0 + KUBECTL_VERSION: v1.36.3 + METALLB_VERSION: v0.16.0 + METALLB_MANIFEST_SHA256: b0b9be2802f10aa32d45308b4457d06cde0c70544712c8d0cf5511657ffd2b69 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + NAMESPACE: metallb-system + +jobs: + validate: + name: Validate manifests + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Validate scripts and local resources + run: bash test.sh + + - name: Verify the pinned upstream manifest + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/metallb-native.yaml" \ + "https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml" + printf '%s %s\n' "${METALLB_MANIFEST_SHA256}" "${RUNNER_TEMP}/metallb-native.yaml" | sha256sum --check + docker run --rm --interactive \ + "ghcr.io/yannh/kubeconform:${KUBECONFORM_VERSION}@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c" \ + -strict -ignore-missing-schemas -summary < "${RUNNER_TEMP}/metallb-native.yaml" + + deploy: + name: Deploy site configuration + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install kubectl + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check + chmod 0700 "${RUNNER_TEMP}/kubectl" + + - name: Configure Kubernetes access + env: + KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} + run: | + test -n "${KUBE_CONFIG_BASE64}" + printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig" + chmod 0600 "${RUNNER_TEMP}/kubeconfig" + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" config set-cluster cluster \ + --server="${KUBERNETES_API}" \ + --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" + + - name: Apply and verify + run: | + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" apply \ + --dry-run=server \ + --validate=false \ + --filename metallb.yaml + "${RUNNER_TEMP}/kubectl" apply \ + --validate=false \ + --filename metallb.yaml + + for attempt in {1..60}; do + IFS='|' read -r c_generation c_observed c_desired c_updated c_ready c_available c_unavailable <<< "$( + "${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" get deployment/controller \ + --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.readyReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}' + )" + IFS='|' read -r s_generation s_observed s_desired s_updated s_ready s_available s_unavailable <<< "$( + "${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" get daemonset/speaker \ + --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.status.desiredNumberScheduled}|{.status.updatedNumberScheduled}|{.status.numberReady}|{.status.numberAvailable}|{.status.numberUnavailable}' + )" + + echo "Check ${attempt}/60: controller ${c_ready}/${c_desired} ready; speaker ${s_ready}/${s_desired} ready" + if [[ "${c_observed}" == "${c_generation}" \ + && "${c_updated}" == "${c_desired}" \ + && "${c_ready}" == "${c_desired}" \ + && "${c_available}" == "${c_desired}" \ + && ( -z "${c_unavailable}" || "${c_unavailable}" == "0" ) \ + && "${s_observed}" == "${s_generation}" \ + && "${s_updated}" == "${s_desired}" \ + && "${s_ready}" == "${s_desired}" \ + && "${s_available}" == "${s_desired}" \ + && ( -z "${s_unavailable}" || "${s_unavailable}" == "0" ) ]]; then + echo "MetalLB configuration is active and all components are ready" + exit 0 + fi + + sleep 5 + done + + echo "MetalLB did not become ready within five minutes" >&2 + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3253dbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.kubeconfig +kubeconfig +kubeconfig.* +.env +.DS_Store diff --git a/README.md b/README.md index 859e1a5..e0a9e15 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,34 @@ before publishing the repository. The installer verifies the upstream manifest against the committed SHA-256 checksum before applying it, waits for the controller and all speakers, and then applies `metallb.yaml`. + +## Gitea Actions CI/CD + +Every push and pull request validates the scripts, the site-specific resources, +and the downloaded upstream manifest against its pinned checksum. A successful +push to `main` applies `metallb.yaml` and verifies that both the controller and +all speaker pods are ready. + +The upstream MetalLB installation remains an administrator bootstrap. CI owns +only the existing `IPAddressPool/default` and `L2Advertisement/l2`; it cannot +change MetalLB's workloads, CRDs, RBAC, webhook certificate, or Secrets. After +changing `METALLB_VERSION`, run `./install.sh` once as an administrator before +merging the matching CI constants. + +Create the restricted deployment identity once: + +```sh +./create-ci-kubeconfig.sh +``` + +For remote cluster access: + +```sh +KUBECTL_SSH_HOST=arschrock ./create-ci-kubeconfig.sh +``` + +Store only the final output line as the Gitea Actions repository secret +`KUBE_CONFIG_BASE64`. Revoke it by deleting +`metallb-system/metallb-config-deployer-token` and the repository secret. + +Run `./test.sh` for local shell and Kubernetes schema validation. diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..dcb11f5 --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,74 @@ +# Bootstrap once with an administrator identity. CI owns only the existing +# site-specific MetalLB configuration, not the upstream installation or RBAC. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metallb-config-deployer + namespace: metallb-system +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: metallb-config-deployer + namespace: metallb-system +rules: + - apiGroups: + - metallb.io + resources: + - ipaddresspools + resourceNames: + - default + verbs: + - get + - patch + - update + - apiGroups: + - metallb.io + resources: + - l2advertisements + resourceNames: + - l2 + verbs: + - get + - patch + - update + - apiGroups: + - apps + resources: + - deployments + resourceNames: + - controller + verbs: + - get + - apiGroups: + - apps + resources: + - daemonsets + resourceNames: + - speaker + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: metallb-config-deployer + namespace: metallb-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: metallb-config-deployer +subjects: + - kind: ServiceAccount + name: metallb-config-deployer + namespace: metallb-system +--- +apiVersion: v1 +kind: Secret +metadata: + name: metallb-config-deployer-token + namespace: metallb-system + annotations: + kubernetes.io/service-account.name: metallb-config-deployer +type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..fdd9144 --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +namespace=${NAMESPACE:-metallb-system} +service_account=${SERVICE_ACCOUNT:-metallb-config-deployer} +secret=${TOKEN_SECRET:-metallb-config-deployer-token} +ssh_host=${KUBECTL_SSH_HOST:-} +ssh_config=${SSH_CONFIG:-} + +cluster_kubectl() { + if [[ -n "${ssh_host}" ]]; then + if [[ -n "${ssh_config}" ]]; then + ssh -F "${ssh_config}" "${ssh_host}" kubectl "$@" + else + ssh "${ssh_host}" kubectl "$@" + fi + else + kubectl "$@" + fi +} + +if [[ -n "${ssh_host}" ]]; then + cluster_kubectl apply --filename=- < "${project_dir}/ci-deployer.yaml" >&2 +else + cluster_kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2 +fi + +for attempt in {1..30}; do + if cluster_kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' 2>/dev/null | grep -q .; then + break + fi + if [[ "${attempt}" == 30 ]]; then + echo "Timed out waiting for the service-account token" >&2 + exit 1 + fi + sleep 1 +done + +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT + +server=$(cluster_kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}') +cluster_kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.ca\.crt}' | base64 --decode > "${workdir}/ca.crt" +token=$(cluster_kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' | base64 --decode) + +export KUBECONFIG="${workdir}/config" +kubectl config set-cluster cluster \ + --server="${server}" \ + --certificate-authority="${workdir}/ca.crt" \ + --embed-certs=true >/dev/null +kubectl config set-credentials "${service_account}" --token="${token}" >/dev/null +kubectl config set-context metallb \ + --cluster=cluster \ + --user="${service_account}" \ + --namespace="${namespace}" >/dev/null +kubectl config use-context metallb >/dev/null + +echo "Store the following single line as the Gitea Actions secret KUBE_CONFIG_BASE64." >&2 +echo "Treat it as a password; do not commit it." >&2 +base64 --wrap=0 "${KUBECONFIG}" +printf '\n' diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..fcdc820 --- /dev/null +++ b/test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +kubeconform_image=ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c + +sh -n "${project_dir}/install.sh" +bash -n "${project_dir}/create-ci-kubeconfig.sh" +bash -n "${project_dir}/test.sh" + +grep -Fq 'METALLB_VERSION=v0.16.0' "${project_dir}/install.sh" +grep -Fq 'METALLB_MANIFEST_SHA256=b0b9be2802f10aa32d45308b4457d06cde0c70544712c8d0cf5511657ffd2b69' \ + "${project_dir}/install.sh" + +validate_file() { + local manifest=$1 + shift + + if command -v kubeconform >/dev/null 2>&1; then + kubeconform -strict -summary "$@" "${manifest}" + elif command -v docker >/dev/null 2>&1; then + docker run --rm --interactive "${kubeconform_image}" \ + -strict -summary "$@" < "${manifest}" + else + echo "Required command not found: install kubeconform or Docker" >&2 + exit 1 + fi +} + +validate_file "${project_dir}/metallb.yaml" -ignore-missing-schemas +validate_file "${project_dir}/ci-deployer.yaml" + +echo "MetalLB scripts and site-specific resources are valid."