Merge pull request #72 from hribeiro-jnpr/main #57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: NITA CI | |
| # Run on every push or PR that touches k8s manifests, the installer, or CI | |
| # config itself. Also available for manual dispatch. | |
| on: | |
| push: | |
| pull_request: | |
| workflow_dispatch: | |
| # --------------------------------------------------------------------------- | |
| # JOB 1 — Lint: validate YAML syntax and Kubernetes schemas | |
| # Fast, zero-cluster, runs on every push. | |
| # --------------------------------------------------------------------------- | |
| jobs: | |
| lint: | |
| name: Lint K8s Manifests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - name: Install tools (gettext for envsubst, yamllint, kubeconform) | |
| run: | | |
| sudo apt-get install -y --no-install-recommends gettext-base yamllint | |
| KUBECONFORM_VERSION=v0.6.7 | |
| curl -sLo /tmp/kubeconform.tar.gz \ | |
| "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" | |
| tar -xf /tmp/kubeconform.tar.gz -C /tmp | |
| sudo mv /tmp/kubeconform /usr/local/bin/ | |
| # yamllint catches YAML syntax errors (tabs, duplicate keys, etc.). | |
| # Uses a relaxed profile so kompose-generated metadata doesn't fail. | |
| - name: YAML syntax check | |
| run: | | |
| yamllint \ | |
| -d "{extends: relaxed, rules: {line-length: {max: 250}, truthy: disable}}" \ | |
| k8s/*.yaml | |
| # kubeconform validates against the official Kubernetes OpenAPI schemas. | |
| # envsubst resolves ${CONTAINER_REGISTRY} and ${CSRF_TRUSTED_ORIGINS} | |
| # before validation so all fields are valid strings. | |
| # -ignore-missing-schemas skips calico CRDs. | |
| - name: Kubernetes schema validation | |
| env: | |
| # Resolves to the repo owner's GHCR namespace so forks validate | |
| # against their own registry without any config changes. | |
| CONTAINER_REGISTRY: ghcr.io/${{ github.repository_owner }} | |
| CSRF_TRUSTED_ORIGINS: "https://localhost,http://localhost" | |
| run: | | |
| failed=0 | |
| for f in k8s/*.yaml; do | |
| # calico.yaml is a redirect pointer, not a manifest — skip it | |
| [[ "${f}" == "k8s/calico.yaml" ]] && continue | |
| echo "Validating ${f} ..." | |
| envsubst '${CONTAINER_REGISTRY} ${CSRF_TRUSTED_ORIGINS}' < "${f}" \ | |
| | kubeconform -strict -ignore-missing-schemas -summary - \ | |
| || failed=1 | |
| done | |
| exit $failed | |
| # --------------------------------------------------------------------------- | |
| # JOB 2 — E2E on Kind: deploy NITA on a real (lightweight) Kubernetes cluster | |
| # Runs only after lint passes. Uses the same k8s/ manifests as production. | |
| # --------------------------------------------------------------------------- | |
| e2e-kind: | |
| name: E2E on Kind Cluster | |
| needs: lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 35 | |
| env: | |
| KEYPASS: nita123 | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| # Set CONTAINER_REGISTRY to the repo owner's GHCR namespace (lowercased). | |
| # On the upstream repo this resolves to ghcr.io/aburston; on any fork it | |
| # automatically resolves to ghcr.io/<fork-owner>. All images are public. | |
| - name: Set container registry | |
| run: | | |
| echo "CONTAINER_REGISTRY=ghcr.io/$(echo '${{ github.repository_owner }}' \ | |
| | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" | |
| # gettext-base — provides envsubst (used by apply-k8s.sh) | |
| # openjdk-17-jdk — provides keytool (required by setup-configmaps.sh) | |
| - name: Install runtime dependencies | |
| run: | | |
| sudo apt-get install -y --no-install-recommends \ | |
| gettext-base \ | |
| openjdk-17-jdk | |
| # Create the host-side directories that Kind will bind-mount into the | |
| # node container. These paths match the hostPath values in pv.yaml and | |
| # pv2.yaml. Jenkins also needs /var/nita_project for project file storage. | |
| - name: Create host directories for PersistentVolume hostPaths | |
| run: | | |
| sudo mkdir -p /mnt/data /mydatai2 /var/nita_project | |
| sudo chmod 777 /mnt/data /mydatai2 /var/nita_project | |
| # helm/kind-action creates a Kind cluster using our config file. | |
| # The config removes the control-plane taint (so pods can schedule on the | |
| # single node) and mounts the hostPath directories created above. | |
| - name: Create Kind cluster | |
| uses: helm/kind-action@v1.14.0 | |
| with: | |
| cluster_name: nita | |
| config: .github/kind-config.yaml | |
| # Pre-pull images on the Docker daemon then load them directly into the | |
| # Kind node. This avoids pull timeouts during the rollout wait steps. | |
| - name: Pull and load container images into Kind | |
| run: | | |
| images=( | |
| "mariadb:10.4.12" | |
| "nginx:stable" | |
| "${CONTAINER_REGISTRY}/nita-webapp:latest" | |
| "${CONTAINER_REGISTRY}/nita-jenkins:latest" | |
| ) | |
| for img in "${images[@]}"; do | |
| echo "==> Pulling ${img}" | |
| docker pull "${img}" | |
| kind load docker-image "${img}" --name nita | |
| done | |
| # Build junos-mcp-server locally from upstream source and load it into | |
| # Kind. We build here rather than pulling from GHCR because the image is | |
| # only published to a namespace by the separate build-junos-mcp workflow; | |
| # on the upstream repo (and on first-time forks) that package may not yet | |
| # exist, which would make `docker pull` fail with "denied". Building keeps | |
| # the e2e job self-contained. The tag matches the manifest reference | |
| # (${CONTAINER_REGISTRY}/junos-mcp-server:latest) and the deployment uses | |
| # imagePullPolicy: IfNotPresent, so the loaded image is used as-is. | |
| - name: Build and load junos-mcp-server into Kind | |
| run: | | |
| git clone --depth 1 https://github.com/Juniper/junos-mcp-server.git \ | |
| /tmp/junos-mcp-server | |
| docker build \ | |
| -t "${CONTAINER_REGISTRY}/junos-mcp-server:latest" \ | |
| /tmp/junos-mcp-server | |
| kind load docker-image \ | |
| "${CONTAINER_REGISTRY}/junos-mcp-server:latest" --name nita | |
| # Apply resources with no ConfigMap dependencies first: | |
| # namespace → storage (SC, PVs, PVCs) → RBAC | |
| - name: Apply namespace, storage, and RBAC manifests | |
| working-directory: k8s | |
| run: | | |
| kubectl apply -f nita-namespace.yaml | |
| kubectl apply -f storageClass.yaml | |
| kubectl apply -f pv.yaml | |
| kubectl apply -f pv2.yaml | |
| kubectl apply -f mariadb-persistentvolumeclaim.yaml | |
| kubectl apply -f jenkins-home-persistentvolumeclaim.yaml | |
| kubectl apply -f service-account.yaml | |
| kubectl apply -f cluster-role.yaml | |
| kubectl apply -f role-binding.yaml | |
| # Generate self-signed certs and create the four ConfigMaps that the | |
| # proxy and jenkins deployments mount. Must run before those deployments. | |
| - name: Generate certificates and create ConfigMaps | |
| run: bash .github/scripts/setup-configmaps.sh | |
| # Apply deployments and services. Notes: | |
| # - webapp/jenkins use ${CONTAINER_REGISTRY} → envsubst required | |
| # - webapp also uses ${CSRF_TRUSTED_ORIGINS} → built from hostname/IPs | |
| # - jenkins-service.yaml was exported from a live cluster and contains | |
| # a hardcoded clusterIP, clusterIPs block, resourceVersion and uid. | |
| # sed strips these fields so kubectl apply succeeds on a fresh cluster. | |
| - name: Apply deployments and services | |
| working-directory: k8s | |
| run: | | |
| # Build CSRF_TRUSTED_ORIGINS from the runner's hostname and all IPs | |
| _origins="https://localhost,http://localhost" | |
| _host=$(hostname 2>/dev/null || true) | |
| [[ -n "$_host" ]] && _origins="https://${_host},http://${_host},${_origins}" | |
| for _ip in $(hostname -I 2>/dev/null); do | |
| _origins="https://${_ip},http://${_ip},${_origins}" | |
| done | |
| export CSRF_TRUSTED_ORIGINS="${_origins}" | |
| echo "CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS}" | |
| kubectl apply -f db-service.yaml | |
| envsubst '${CONTAINER_REGISTRY}' < db-deployment.yaml | kubectl apply -f - | |
| kubectl apply -f webapp-service.yaml | |
| envsubst '${CONTAINER_REGISTRY} ${CSRF_TRUSTED_ORIGINS}' < webapp-deployment.yaml | kubectl apply -f - | |
| # Clean live-cluster metadata from jenkins-service before applying | |
| sed \ | |
| -e '/^ clusterIP:/d' \ | |
| -e '/^ clusterIPs:/d' \ | |
| -e '/^ - 10\.[0-9]/d' \ | |
| -e '/ resourceVersion:/d' \ | |
| -e '/ uid:/d' \ | |
| jenkins-service.yaml | kubectl apply -f - | |
| envsubst '${CONTAINER_REGISTRY}' < jenkins-deployment.yaml | kubectl apply -f - | |
| envsubst '${CONTAINER_REGISTRY}' < proxy-deployment.yaml | kubectl apply -f - | |
| # --------------------------------------------------------------------------- | |
| # Wait for each deployment to reach the Running/Ready state | |
| # --------------------------------------------------------------------------- | |
| - name: Wait for db rollout | |
| run: kubectl rollout status deployment/db -n nita --timeout=3m | |
| - name: Wait for webapp rollout (with diagnostics) | |
| run: | | |
| echo "=== Initial webapp pod state ===" | |
| kubectl get pods -n nita -l io.kompose.service=webapp -o wide | |
| for i in $(seq 1 20); do | |
| echo "" | |
| echo "--- Poll ${i}/20 at $(date -u '+%H:%M:%S') ---" | |
| POD=$(kubectl get pods -n nita -l io.kompose.service=webapp \ | |
| -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") | |
| PHASE=$(kubectl get pods -n nita -l io.kompose.service=webapp \ | |
| -o jsonpath='{.items[0].status.phase}' 2>/dev/null || echo "Unknown") | |
| READY=$(kubectl get pods -n nita -l io.kompose.service=webapp \ | |
| -o jsonpath='{.items[0].status.containerStatuses[0].ready}' 2>/dev/null || echo "false") | |
| RESTARTS=$(kubectl get pods -n nita -l io.kompose.service=webapp \ | |
| -o jsonpath='{.items[0].status.containerStatuses[0].restartCount}' 2>/dev/null || echo "0") | |
| STATE=$(kubectl get pods -n nita -l io.kompose.service=webapp \ | |
| -o jsonpath='{.items[0].status.containerStatuses[0].state}' 2>/dev/null || echo "") | |
| echo "Pod: ${POD} | Phase: ${PHASE} | Ready: ${READY} | Restarts: ${RESTARTS}" | |
| echo "ContainerState: ${STATE}" | |
| echo "-- Last 30 log lines --" | |
| kubectl logs -n nita -l io.kompose.service=webapp --tail=30 2>/dev/null \ | |
| || echo "(no logs yet)" | |
| if [[ "${READY}" == "true" ]]; then | |
| echo "Webapp is ready!" | |
| exit 0 | |
| fi | |
| sleep 15 | |
| done | |
| echo "" | |
| echo "=== TIMEOUT: webapp did not become ready after 5 minutes ===" | |
| echo "=== Pod description ===" | |
| kubectl describe pods -n nita -l io.kompose.service=webapp | |
| echo "=== Recent events ===" | |
| kubectl get events -n nita --sort-by=.lastTimestamp \ | |
| --field-selector involvedObject.kind=Pod | tail -20 | |
| exit 1 | |
| - name: Wait for proxy rollout | |
| run: kubectl rollout status deployment/proxy -n nita --timeout=3m | |
| # Jenkins is the slowest to start: keystore import in postStart + plugin loading | |
| - name: Wait for jenkins rollout | |
| run: kubectl rollout status deployment/jenkins -n nita --timeout=10m | |
| # --------------------------------------------------------------------------- | |
| # Smoke tests | |
| # --------------------------------------------------------------------------- | |
| - name: "Test: MariaDB 'Sites' database exists" | |
| run: | | |
| kubectl exec -n nita deployment/db -- \ | |
| mysql -u root -proot -e "SHOW DATABASES;" \ | |
| | grep -q "Sites" | |
| echo "PASS: MariaDB Sites database confirmed" | |
| - name: "Test: Webapp returns HTTP 200/301/302" | |
| run: | | |
| kubectl port-forward svc/webapp 8000:8000 -n nita & | |
| PF_PID=$! | |
| # Allow port-forward to establish | |
| sleep 5 | |
| HTTP=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| --max-time 10 http://localhost:8000/ 2>/dev/null || true) | |
| kill "${PF_PID}" 2>/dev/null || true | |
| echo "Webapp HTTP status: ${HTTP}" | |
| # 000 means no TCP connection at all; anything else means Django is up | |
| [[ "${HTTP}" != "000" ]] \ | |
| || { echo "FAIL: no HTTP response from webapp (connection refused)"; exit 1; } | |
| echo "PASS: Webapp is serving HTTP (status ${HTTP})" | |
| - name: "Test: Jenkins returns HTTP 200/401/403" | |
| run: | | |
| kubectl port-forward svc/jenkins 8080:8080 -n nita & | |
| PF_PID=$! | |
| sleep 5 | |
| HTTP=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| --max-time 10 http://localhost:8080 || echo "000") | |
| kill "${PF_PID}" 2>/dev/null || true | |
| echo "Jenkins HTTP status: ${HTTP}" | |
| [[ "${HTTP}" =~ ^(200|401|403)$ ]] \ | |
| || { echo "FAIL: unexpected status ${HTTP}"; exit 1; } | |
| echo "PASS: Jenkins responded ${HTTP}" | |
| - name: "Test: Jenkins ServiceAccount RBAC — list pods" | |
| run: | | |
| kubectl auth can-i list pods \ | |
| --as=system:serviceaccount:nita:internal-jenknis-pod \ | |
| -n nita | grep -q "yes" | |
| echo "PASS: internal-jenknis-pod can list pods" | |
| - name: "Test: Jenkins ServiceAccount RBAC — create/delete pods" | |
| run: | | |
| kubectl auth can-i create pods \ | |
| --as=system:serviceaccount:nita:internal-jenknis-pod \ | |
| -n nita | grep -q "yes" | |
| kubectl auth can-i delete pods \ | |
| --as=system:serviceaccount:nita:internal-jenknis-pod \ | |
| -n nita | grep -q "yes" | |
| echo "PASS: internal-jenknis-pod can create and delete pods" | |
| # --------------------------------------------------------------------------- | |
| # Always print pod status; print logs only on failure | |
| # --------------------------------------------------------------------------- | |
| - name: Pod and event summary | |
| if: always() | |
| run: | | |
| echo "=== Pods ===" | |
| kubectl get pods -n nita -o wide | |
| echo "" | |
| echo "=== Recent events ===" | |
| kubectl get events -n nita \ | |
| --sort-by=.lastTimestamp | tail -25 | |
| - name: Container logs on failure | |
| if: failure() | |
| run: | | |
| for pod in $(kubectl get pods -n nita -o name 2>/dev/null); do | |
| echo "" | |
| echo "=== Logs: ${pod} ===" | |
| kubectl logs -n nita "${pod}" --tail=60 || true | |
| done | |
| # --------------------------------------------------------------------------- | |
| # API integration tests — run against the deployed stack | |
| # --------------------------------------------------------------------------- | |
| - name: Set up Python for integration tests | |
| uses: actions/setup-python@v6.2.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install integration-test dependencies | |
| run: pip install -r requirements-integration-test.txt | |
| - name: Install Playwright browser | |
| run: playwright install chromium --with-deps | |
| - name: Start port-forward (webapp → localhost:8000) | |
| run: | | |
| kubectl port-forward svc/webapp 8000:8000 -n nita & | |
| echo "PF_PID=$!" >> "$GITHUB_ENV" | |
| for i in $(seq 1 30); do | |
| STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ | |
| -H "Authorization: Token invalid" \ | |
| http://localhost:8000/api/v1/networks/ 2>/dev/null || echo 000) | |
| if [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then | |
| echo "Webapp API is up (HTTP $STATUS)" | |
| exit 0 | |
| fi | |
| echo " attempt $i: HTTP $STATUS, waiting..." | |
| sleep 5 | |
| done | |
| echo "ERROR: webapp API did not respond within 2.5 minutes" | |
| exit 1 | |
| - name: Run pytest integration suite | |
| id: pytest | |
| env: | |
| NITA_BASE_URL: http://localhost:8000 | |
| NITA_USER: vagrant | |
| NITA_PASS: vagrant123 | |
| run: | | |
| set -o pipefail | |
| pytest -c pytest-integration.ini --tb=long -v --junitxml=test-results.xml 2>&1 | tee pytest-output.txt | |
| PYTEST_EXIT=$? | |
| echo '## Pytest Results' >> "$GITHUB_STEP_SUMMARY" | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| tail -200 pytest-output.txt >> "$GITHUB_STEP_SUMMARY" | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| exit $PYTEST_EXIT | |
| - name: Upload test results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: pytest-results | |
| path: | | |
| test-results.xml | |
| pytest-output.txt | |
| if-no-files-found: ignore | |
| - name: Upload screenshots | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: nita-screenshots | |
| path: ci-screenshots/ | |
| if-no-files-found: ignore | |
| - name: Dump webapp logs on failure | |
| if: failure() && steps.pytest.outcome == 'failure' | |
| run: | | |
| echo "=== webapp pod logs (all) ===" | |
| kubectl logs -n nita -l io.kompose.service=webapp --tail=500 2>/dev/null || true | |
| echo "=== jenkins pod logs (last 100 lines) ===" | |
| kubectl logs -n nita -l io.kompose.service=jenkins --tail=100 2>/dev/null || true | |
| - name: Stop port-forward | |
| if: always() | |
| run: | | |
| if [[ -n "${PF_PID:-}" ]]; then | |
| kill "${PF_PID}" 2>/dev/null || true | |
| fi |