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/