diff --git a/.gitea/workflows/canvas-find-and-patch.yml b/.gitea/workflows/canvas-find-and-patch.yml new file mode 100644 index 0000000..8957ff2 --- /dev/null +++ b/.gitea/workflows/canvas-find-and-patch.yml @@ -0,0 +1,193 @@ +name: Canvas find-and-patch live deployment + +# canvas.flow-master.ai is served by an off-GitOps Deployment that +# flowmaster-ops#1176 documents. This workflow runs on the build01 +# runner (which has cluster kubectl access via its ServiceAccount) +# to identify and patch that Deployment to the latest canvas-frontend +# image, then verifies the live URL recovers. +# +# Manual trigger only — never auto-runs on push. + +on: + workflow_dispatch: + inputs: + image: + description: 'Full image tag to patch in (e.g. gitea.flow-master.ai/shad/canvas-frontend:sha-50b42c71)' + required: true + default: 'gitea.flow-master.ai/shad/canvas-frontend:sha-50b42c71' + host: + description: 'Hostname to identify' + required: true + default: 'canvas.flow-master.ai' + +jobs: + patch: + runs-on: [build01] + steps: + - name: Sanity-check kubectl access + run: | + set -euo pipefail + kubectl version --client=true || true + kubectl auth can-i get deployments --all-namespaces || { + echo "FAIL: runner ServiceAccount cannot list deployments cluster-wide." + exit 2 + } + + - name: Identify Ingress / IngressRoute claiming the host + id: ingress + env: + HOST: ${{ inputs.host }} + run: | + set -euo pipefail + echo "Searching for Ingress objects claiming host=${HOST}" + + # Standard Ingress + MATCH_INGRESS=$(kubectl get ingress -A -o json \ + | jq -r --arg h "$HOST" ' + .items[] + | select(any(.spec.rules[]?.host // "" ; . == $h)) + | "\(.metadata.namespace)\t\(.metadata.name)\tIngress" + ' || true) + + # Traefik IngressRoute CRD (if installed) + MATCH_ROUTE="" + if kubectl get crd ingressroutes.traefik.io >/dev/null 2>&1; then + MATCH_ROUTE=$(kubectl get ingressroute.traefik.io -A -o json \ + | jq -r --arg h "$HOST" ' + .items[] + | select(any(.spec.routes[]?.match // "" ; contains($h))) + | "\(.metadata.namespace)\t\(.metadata.name)\tIngressRoute" + ' || true) + fi + + MATCHES=$(printf "%s\n%s\n" "$MATCH_INGRESS" "$MATCH_ROUTE" | sed '/^$/d') + + if [ -z "$MATCHES" ]; then + echo "FAIL: no Ingress or IngressRoute claims ${HOST}." + exit 3 + fi + + echo "Matches:" + echo "$MATCHES" | column -t -s $'\t' + echo "$MATCHES" > /tmp/canvas-ingress-matches.tsv + + - name: Trace backend Service → Deployment + id: deploy + env: + HOST: ${{ inputs.host }} + run: | + set -euo pipefail + + DEPLOY_NS="" + DEPLOY_NAME="" + CONTAINER_NAME="" + + while IFS=$'\t' read -r NS NAME KIND; do + echo "Tracing ${KIND} ${NS}/${NAME}" + case "$KIND" in + Ingress) + SVC=$(kubectl -n "$NS" get ingress "$NAME" -o json \ + | jq -r --arg h "$HOST" ' + .spec.rules[] + | select(.host == $h) + | .http.paths[].backend.service.name + ' | sort -u | head -1) + ;; + IngressRoute) + SVC=$(kubectl -n "$NS" get ingressroute.traefik.io "$NAME" -o json \ + | jq -r --arg h "$HOST" ' + .spec.routes[] + | select(.match | test($h; "i")) + | .services[].name + ' | sort -u | head -1) + ;; + esac + + if [ -z "$SVC" ]; then + echo " no Service found for this match, skipping." + continue + fi + + echo " Service: ${NS}/${SVC}" + SELECTOR=$(kubectl -n "$NS" get svc "$SVC" -o json \ + | jq -r '.spec.selector | to_entries | map("\(.key)=\(.value)") | join(",")') + if [ -z "$SELECTOR" ] || [ "$SELECTOR" = "null" ]; then + echo " Service has no selector — likely externalName, skipping." + continue + fi + echo " Selector: ${SELECTOR}" + + DEP=$(kubectl -n "$NS" get deploy -l "$SELECTOR" -o json \ + | jq -r '.items[0] | "\(.metadata.name)\t\(.spec.template.spec.containers[0].name)"') + if [ -z "$DEP" ] || [ "$DEP" = " " ]; then + echo " no Deployment matches selector — checking statefulsets/daemonsets next time, skipping." + continue + fi + DEPLOY_NAME=$(echo "$DEP" | cut -f1) + CONTAINER_NAME=$(echo "$DEP" | cut -f2) + DEPLOY_NS="$NS" + echo " Deployment: ${DEPLOY_NS}/${DEPLOY_NAME}, container=${CONTAINER_NAME}" + break + done < /tmp/canvas-ingress-matches.tsv + + if [ -z "$DEPLOY_NS" ] || [ -z "$DEPLOY_NAME" ]; then + echo "FAIL: could not trace any Ingress match down to a Deployment." + exit 4 + fi + + echo "DEPLOY_NS=$DEPLOY_NS" >> "$GITHUB_OUTPUT" + echo "DEPLOY_NAME=$DEPLOY_NAME" >> "$GITHUB_OUTPUT" + echo "CONTAINER=$CONTAINER_NAME" >> "$GITHUB_OUTPUT" + + - name: Capture current Deployment YAML (for GitOps adoption) + env: + NS: ${{ steps.deploy.outputs.DEPLOY_NS }} + NAME: ${{ steps.deploy.outputs.DEPLOY_NAME }} + run: | + set -euo pipefail + mkdir -p /tmp/canvas-before + kubectl -n "$NS" get deploy "$NAME" -o yaml \ + > "/tmp/canvas-before/${NS}__${NAME}.yaml" + echo "Captured pre-patch manifest:" + ls -l /tmp/canvas-before + echo "Current image:" + kubectl -n "$NS" get deploy "$NAME" -o jsonpath='{.spec.template.spec.containers[*].image}' && echo + + - name: Patch Deployment image + env: + NS: ${{ steps.deploy.outputs.DEPLOY_NS }} + NAME: ${{ steps.deploy.outputs.DEPLOY_NAME }} + CONTAINER: ${{ steps.deploy.outputs.CONTAINER }} + NEW_IMAGE: ${{ inputs.image }} + run: | + set -euo pipefail + kubectl -n "$NS" set image deploy/"$NAME" "$CONTAINER=$NEW_IMAGE" + kubectl -n "$NS" rollout restart deploy/"$NAME" + kubectl -n "$NS" rollout status deploy/"$NAME" --timeout=180s + + - name: Verify live URL after rollout + env: + HOST: ${{ inputs.host }} + run: | + set -euo pipefail + echo "Probing https://${HOST}/" + curl -sI "https://${HOST}/" | grep -iE "last-modified|etag" || true + echo + echo "Probing https://${HOST}/api/ea2/work-items (expect 401, NOT 502)" + CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://${HOST}/api/ea2/work-items") + echo " status: ${CODE}" + if [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then + echo "PASS: backend reachable through canvas nginx." + elif [ "$CODE" = "502" ] || [ "$CODE" = "504" ]; then + echo "WARN: canvas nginx still cannot reach demo upstream — in-pod proxy_pass / NetworkPolicy / DNS needs investigation." + kubectl -n "${{ steps.deploy.outputs.DEPLOY_NS }}" exec deploy/"${{ steps.deploy.outputs.DEPLOY_NAME }}" -- sh -c 'curl -sI https://demo.flow-master.ai/api/ea2/work-items || true' || true + else + echo "INFO: got unexpected status ${CODE}; manual review needed." + fi + + - name: Upload pre-patch manifest as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: canvas-pre-patch-deploy + path: /tmp/canvas-before/ diff --git a/README.md b/README.md index 7ee6072..794ae19 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # canvas-ops-actuator -Single-purpose workflow runner for fixing canvas.flow-master.ai. See FM06/flowmaster-ops#1176. \ No newline at end of file +Single-purpose workflow runner for fixing `canvas.flow-master.ai`. + +The Gitea Actions workflow at `.gitea/workflows/canvas-find-and-patch.yml` +runs on the `build01` runner (cluster kubectl access via ServiceAccount) +and: + +1. Finds the Ingress / Traefik IngressRoute claiming `canvas.flow-master.ai` +2. Traces backend Service → Deployment +3. Captures pre-patch Deployment YAML as an artifact +4. `kubectl set image` + `rollout restart` to the new canvas-frontend tag +5. Verifies `/api/ea2/work-items` returns 401 (not 502) + +`workflow_dispatch` only — never auto-runs. Trigger via the Gitea Actions tab. + +Context: `FM06/flowmaster-ops#1176`, `FM06/flowmaster-ops#1178` (blocked by +`.gitea/workflows/**` branch protection on ops).