SysEngineering

Daily license alerts

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?”).

What it posts

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.

Where the numbers come from, and the lag

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.

Behaviour worth knowing

Configuration

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.

Deploy (from an up-to-date 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.

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

Tests

.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.