A Cloud Run Job that checks every connected Google Workspace organization’s
seats each weekday morning and posts to #greg-weekly-digest only when a
workspace is running out. Asked for by Debashis Saha on 2026-10-29 (“can Greg
send an alert when we run out of licenses on any of our Google Workspace?”).
Nothing repeats. A workspace sitting at 1 free seat is announced once, on the day it got there; a day with no drop and no recovery posts nothing. On 2026-10-29, 23 of 29 workspaces were already at 3 or fewer (6 at 0), which is why it alerts on change rather than on level.
:rotating_light: Google Workspace seats running low @Anthony @Debashis
Out of licenses (1)
- AGOG: 0 free (88/88 assigned), was 1
3 or fewer free (1)
- Schmidt Entities: 1 free (319/320 assigned), was 2
Google's license numbers as of 2026-10-27. They run 2-4 days behind, so the
Admin console may already show fewer free seats.
The customer usage report (Reports API, admin.reports.usage.readonly, already
in every tenant’s DWD row) carries accounts:<sku>_total_licenses (purchased)
and accounts:<sku>_used_licenses (assigned). It is the same read the weekly
digest makes. No new scopes, no Admin console work, no billing access: the
Reseller and billing APIs only cover organizations bought through a reseller.
Google publishes it once a day, 2-4 days behind. Verified 2026-10-29:
Schmidt Entities read 319/320 here while its console showed 0 available, 320
assigned. Nothing on our side shortens that. Live counts would need the
Enterprise License Manager API, whose only scope (apps.licensing) is not
read-only, and Greg never holds a write scope. So the threshold of 3 is also
the buffer.
Each SKU is judged on its own. The digest sums SKUs for its fleet line;
an alert must not, or one SKU’s spare seats would hide another that is out. In
practice every tenant reports only gsuite_enterprise (Enterprise Plus):
Archived User and Cloud Identity Free seats are not in the report (checked
across all 30 tenants on 2026-10-29). The SKU is named in the message only for
a workspace with more than one.
Plans. On a Flexible plan, purchased seats follow assigned seats, so “0 free” means nothing. The Enterprise Plus subscriptions checked (Schmidt Entities, Blue City Holdings) are Annual, with a real cap, as Adam expects of nearly all of ours. If one turns out to be Flexible, it will read “0 free” and alert once; exclude it then.
--max-retries 0): the post comes before the save, so a
retry after Slack accepted the message would post it twice. A failed run is
simply caught by the next weekday’s.license_alert.env.yaml (committed; no secrets). The only secret is
SLACK_BOT_TOKEN, Greg’s production bot token.
| Variable | Purpose |
|---|---|
LICENSE_ALERT_SLACK_CHANNEL |
C0BUFQ46YMD, #greg-weekly-digest. Greg is already a member. |
LICENSE_ALERT_MENTION_USER_IDS |
Anthony Xu U069VHKV44S, Debashis Saha U03GQAXLQKE. |
LICENSE_ALERT_STATE_BUCKET / _OBJECT |
gw-admin-assistant-digest-state, gw-license-alerts/latest.json (its own object next to the digest’s). |
LICENSE_ALERT_WARN_FREE |
Default 3. |
LICENSE_ALERT_DRY_RUN |
Print instead of posting; saves nothing. |
main only)Same image and runtime account as the digest; that account already has
storage.objectAdmin on the bucket.
The commands below use bash line continuations (\). In PowerShell, swap them for
backticks (`), as DIGEST.md does, or put each command on one line.
gcloud run jobs deploy gw-license-alerts \
--project gw-admin-assistant --region us-central1 \
--source . \
--command python --args license_alert_job.py \
--task-timeout 15m --max-retries 0 \
--service-account 1039144753775-compute@developer.gserviceaccount.com \
--set-secrets SLACK_BOT_TOKEN=gw-assistant-prod-slack-bot-token:latest \
--env-vars-file license_alert.env.yaml
Dry run first (prints, posts nothing, saves nothing), then read it from the logs:
gcloud run jobs execute gw-license-alerts --project gw-admin-assistant \
--region us-central1 --wait --update-env-vars LICENSE_ALERT_DRY_RUN=true
--update-env-vars on execute applies to that execution only. Then a real run
(the baseline post), then the schedule.
Weekdays 9:00 Pacific. A drop over the weekend is caught by Monday’s run, since each run compares with the last saved one.
The scheduler calls the job as the compute default account, which needs
roles/run.invoker on the job itself (the digest job has the same binding). Without
it the scheduler’s call is refused and nothing runs.
gcloud run jobs add-iam-policy-binding gw-license-alerts \
--project gw-admin-assistant --region us-central1 \
--member serviceAccount:1039144753775-compute@developer.gserviceaccount.com \
--role roles/run.invoker
gcloud scheduler jobs create http gw-license-alerts-weekdays \
--project gw-admin-assistant --location us-central1 \
--schedule "0 9 * * 1-5" --time-zone "America/Los_Angeles" \
--uri "https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/gw-admin-assistant/jobs/gw-license-alerts:run" \
--http-method POST \
--oauth-service-account-email 1039144753775-compute@developer.gserviceaccount.com
.venv/bin/python -m unittest test_license_alert_job -v
Per-SKU parsing, baseline vs drop vs recovery vs quiet, a new tenant, a failed tenant keeping its entry, mentions only on drops, escaping, and that the snapshot advances only after a successful post.