Docs · Glidepath
Notifications
Two independent targets, toggled separately per app in cicd.yaml: Slack and Backstage. Both fire from the same call sites - an app can run either, both, or neither.
Slack notifications
Every stage’s pipeline (build/test/deploy/release) sends one status message per
stage completion, with a failure log excerpt appended when the stage didn’t succeed. The
message logic is the notify-slack StepAction
(charts/glidepath-catalog/templates/stepactions/notify-slack.yaml). The four stage
Pipelines run it as one step of their single debrief finally Task
(tasks/stage-debrief.yaml - one pod for the stage span, both notifications, the stage’s
CDEvent and pipelinerun.finished, replacing six separate finally pods as of 2026-10-08);
release-outcome-notify/release-progress-notify run the same StepAction through the
thin notify-slack Task wrapper. The notification fires unconditionally in both shapes,
and the debrief Task’s step order and onError: continue guarantee a failed span send or
CDEvent can’t suppress it.
sast-scan/image-scan (Phase 3 items 8.4/8.5) additionally send their own, separate
shift-left notification the moment each real scan produces a result, rather than waiting
for the stage-level message above. This is controlled by its own
notifications.slack.scanResults toggle (default true, only takes effect when
notifications.slack.enabled is also true) - the user asked for this to be optional
independently of the general per-stage notifications, since scan results are more
frequent/verbose and an Application might want one without the other:
notifications:
slack:
enabled: true
scanResults: false # turn off just the sast-scan/image-scan pings, keep the rest
The bug this fixes
The plumbing (notify-slack.yaml, reading notifications.slack from cicd.yaml, a
per-app slack-webhook-url Secret) existed since Phase 1, but never actually
worked: the script always read /var/run/secrets/platform/slack-webhook-url, but the
Task never declared a volumes:/volumeMounts: for it at all. Even an Application that fully
enabled notifications.slack and created the Secret exactly as the old header comment
described would still get nothing - the script’s own graceful “no secret mounted” skip
path silently absorbed the missing file, so this looked like an unconfigured-app no-op
rather than a broken feature. Confirmed live before fixing: no Application has ever had this
secret, and kubectl get externalsecret,clustersecretstore -A returns nothing anywhere -
External Secrets Operator is installed but has zero configured backends, so the
architecture doc’s original “populated by ESO at onboarding” plan for this secret was
never actually built. Fixed at the time with a plain secret volume (optional: true,
so Applications without it still start cleanly - not a new failure mode, matches the
script’s existing skip behavior), not a full ESO SecretStore pipeline for one demo
webhook.
That tradeoff was revisited once a second real consumer needed the identical
mechanism - see app-secrets.md. The volume mount is unchanged in
shape (still optional: true, still one file per Secret data key), but now sources
from app-secrets (ESO-synced from the Application’s own backend store) instead of a
hand-created, per-Application Secret.
Onboarding an Application (per app)
-
Enable it in
cicd.yaml, declaring the secret alongside it:secrets: - name: slack-webhook-url notifications: slack: enabled: true channel: "#your-channel" -
Populate the Application’s own backend secret store with a
slack-webhook-urlkey holding a real Slack incoming-webhook URL - see app-secrets.md for where that store is assumed to live.Nothing else to apply -
notify-slack.yaml’s volume mount is already wired into every pipeline via the existing, unconditionalnotifyfinally task.
Message format
- Always:
[<app-namespace>] <stage-name> <status>: <pipeline-run-name>- one line, every stage, every outcome. - On any non-success outcome (
Failed,Cancelled,PipelineRunTimeout, etc. - not narrowed to justFailed, since a short excerpt of whatever the last-running step logged is useful context regardless of the exact failure mode): a second block naming the failed TaskRun and a tail of its pod logs (last 30 lines, capped to ~1500 characters as a readability limit, not Slack’s actualtextsize limit), wrapped in a Slack code block.
No new RBAC was needed for the log-fetching step - pipeline-runner’s existing Role
(charts/glidepath-app/templates/identity/pipeline-runner.yaml) already grants get/list/
watch on taskruns (tekton.dev) and pods/pods/log (core), confirmed by re-reading
that file rather than assumed.
Verification
- Mount fix, in isolation: create a real
slack-webhook-urlSecret, enablenotifications.slackfor a real Application, run a real pipeline, confirm an actual message lands in the real Slack channel - this is the part that had never once worked, so it needs to be seen working, not assumed fixed by re-reading the diff. - Failure-log test: a synthetic PipelineRun with a deliberately bad param (same surgical- break technique used elsewhere in Phase 3), confirming the Slack message includes a real, readable excerpt from the actually-failed step.
- No-secret regression check: an Application without the Secret configured still completes a
normal pipeline run with no error - the
optional: truemount plus the script’s existing skip path, not a new failure mode.
Backstage notifications
Sibling to the Slack path above, sending the same general build/test/deploy/release
pass-fail message (the notify-backstage StepAction, run as the step right after
notify-slack in the same debrief finally Task, or via the notify-backstage Task
wrapper from the release notify pipelines) into Backstage’s own Notifications plugin (@backstage/plugin-notifications-backend,
already installed and wired in the backstage repo - packages/backend/src/index.ts)
instead of a Slack channel. Toggled independently via notifications.backstage.enabled
in cicd.yaml - an app can run Slack, Backstage, both, or neither. Viewable in Tower’s
Notifications tab and via the stock Backstage sidebar bell icon
(NotificationsSidebarItem, already present).
Unlike Slack’s per-Application webhook, there is exactly one Backstage instance
platform-wide (not one per cluster) - every CI/CD Pipeline runs on the dev cluster
(the control plane’s clusterName), but Backstage itself may run on a different cluster. So
the target URL and credential are cluster-level chart values, not per-Application
cicd.yaml/secrets entries, and reaching Backstage at all is a real cross-cluster
call, not an in-cluster one:
- Configure
glidepath-catalog’sbackstageBaseUrl(a cluster-config value, set via theplatform-cicd-catalogApplication’svaluesObjectin the dev cluster’s repo- the chart has no default) to Backstage’s real externally-reachable
Gateway hostname, e.g.
http://backstage.prod.example.internal. Not an in-cluster Service DNS name (backstage.<ns>.svc.cluster.local) - that only resolves on Backstage’s own cluster, not the dev cluster the pipeline actually runs on. Port 80, not 7007 - the prod cluster’s Gateway only has an HTTP listener on port 80 (seegitops-cluster-prod/60-backstage/backstage/httproute.yaml); 7007 is Backstage’s own Service port, never exposed on the node IP directly. Confirmed live:curl -H "Host: backstage.prod.example.internal" http://<node-ip>:80/returns 200,:7007gets connection refused.
- the chart has no default) to Backstage’s real externally-reachable
Gateway hostname, e.g.
- Only if that hostname is not real DNS for a pod, set
backstageHostAliasIPto its current IP on bothglidepath-catalogandglidepath-control-plane, and the samebackstageBaseUrlonglidepath-control-planetoo: the control plane forwards the IP, withbackstageBaseUrl’s host as the hostname, to every app’sglidepath-app(backstageHostAliasIP/backstageHostAliasHostname). Nothing is pinned by default. Every generated PipelineRun then carries a run-widehostAliasesentry, so thenotify-backstagepod resolves the hostname. A lab IP that changes when the cluster restarts has to be updated in both charts when it changes; leave both empty when Backstage has real DNS. - Mint a Backstage static service token scoped to the notifications plugin only
(
backend.auth.externalAccess,type: static,accessRestrictions: [{plugin: notifications}]- see Backstage’s own service-to-service auth docs). Populate it under thebackstage-notify-tokenkey in the same backend secret storeglidepath-control-plane’ssecretStorealready reads from -templates/secretstore/glidepath-backstage-notify- cluster-external-secret.yamldisseminates it into every managed namespace automatically (same mechanism asregistry-credentials, one shared credential platform-wide, not one per Application). - Enable it per app in
cicd.yaml:notifications: backstage: enabled: true
Nothing else to apply - the glidepath-backstage-notify Secret mount is already wired
into every pipeline via the debrief finally Task (and the notify-backstage Task
wrapper for the release notify pipelines).
Message format
- Same event coverage and stage vocabulary as Slack’s (
Build/Test/Deploy/Releasepass/fail, plusrelease-outcome), but mapped onto Backstage’s own notification fields rather than Slack’s block-kit format:title(<app> · <stage> <status>),description(app/repo/commit/image/environment/PR lines, plus a failure-log excerpt on non-success, same as Slack’s),link(the same Tekton Dashboard deep-link, with the same “dead link unless port-forwarded locally” caveat),severity(normalon success,highotherwise - Backstage renders this as its own icon/color, so no⚠text is added the way Slack’s header gets one), andtopic(the stage name, letting a Backstage user filter per-topic in their own per-user notification settings - a native capability Slack’s channel model doesn’t have). - Always broadcast (
recipients: {type: "broadcast"}) - visible to every Backstage user, not targeted at a specific owner/team. No per-app recipient targeting today; a future enhancement could resolve the app’s catalog-info.yamlspec.ownerinstead, if the broadcast-only model turns out too noisy at scale.
Descoped from this pass
sast-scan.yaml/image-scan.yaml’s separate shift-left scan-result Slack messages
(notifications.slack.scanResults) were not given a Backstage equivalent - those
Tasks inline their own Slack-specific curl/formatting logic rather than calling
notify-slack.yaml, so mirroring them means duplicating notify-backstage.yaml’s logic
into both Tasks rather than reusing it. Left as a follow-up if per-scan Backstage
notifications turn out to be wanted; the general per-stage notification above already
covers the common case.
Verification
- Static-token path, in isolation: create the
glidepath-backstage-notifySecret and configurebackstageBaseUrlfor a real cluster, enablenotifications.backstagefor a real Application, run a real pipeline, confirm a real notification lands in Backstage (sidebar bell unread count increments, Tower’s Notifications tab shows it). - No-config regression check: an Application with
notifications.backstage.enabledbut no cluster-levelbackstageBaseUrl/secret configured still completes a normal pipeline run with no error - sameoptional: truemount plus graceful skip as the Slack path. - Both-targets check: an Application with both
notifications.slack.enabledandnotifications.backstage.enabledtrue gets one message in each system per stage completion, independently of each other.