feat(mac): canonical Mac bring-up + configurator/mcp/digit-ui from-source fold-ins — consolidates #14/#40/#46/#47/#48/#50 on nairobi - #52
Conversation
Fixes from a from-scratch deploy of the notification stack on bomet
(`ke.bomet`, WhatsApp via Twilio production sender). Three buckets:
## digit-config-service env defaults
Added MDMS-v2 reachability so `_create` validation works:
MDMS_V2_HOST: http://egov-mdms-service:8094
MDMS_V2_SCHEMA_SEARCH_PATH: /mdms-v2/schema/v1/_search
STATE_LEVEL_TENANT_ID: ${STATE_LEVEL_TENANT_ID:-pg}
Without these the service defaults to `application.properties`
literals (`http://localhost:8083`) which is itself, and every
ConfigData create returns `MDMS_VALIDATION_FAILED: Could not fetch
config schema from MDMS v2: I/O error on POST request`.
## novu-bridge env path correction
NOVU_BRIDGE_CONFIG_RESOLVE_PATH: /config-service/config/v1/_resolve
NOVU_BRIDGE_CONFIG_SEARCH_PATH: /config-service/config/v1/_search
Was: `/config-service/v1/_resolve` (missing `/config/` segment).
config-service mounts its controller at `/config/v1` AND has
SERVER_SERVLET_CONTEXT_PATH `/config-service`. The earlier value got
Spring's "No static resource v1/_resolve" 500 because the controller
mapping wasn't matched.
## novu-bridge channel + preference defaults
NOVU_BRIDGE_CHANNEL: ${NOVU_BRIDGE_CHANNEL:-whatsapp}
NOVU_BRIDGE_PREFERENCE_ENABLED:${NOVU_BRIDGE_PREFERENCE_ENABLED:-false}
- Channel is now case-significant lowercase to match the TemplateBinding
`channel` field convention (`whatsapp` / `sms`). Upstream was
`WHATSAPP` and our seed records have lowercase — the lookup criteria
didn't match.
- Preference check defaults to disabled. The `digit-user-preferences-service`
on the preview image 404s `/user-preference/v1/_check` (controller
path mismatch). Enabling it without the controller mapping wired
breaks every dispatch. Flip to `true` once preferences are properly
configured for a tenant.
## nginx template additions
Added Novu workarounds and Vite-base-/ bug mitigations:
- `/novu-ws/` proxy to port 14003 (websocket) so the dashboard can do
same-origin WS via /novu-ws/ instead of port-14003 cross-origin.
- `/assets/`, `/static/`, `/images/`, `/manifest.json`,
`/favicon-gradient.svg`, `/favicon.ico` proxies to novu-dashboard.
⚠ The /assets/, /images/ etc. blocks are WORKAROUNDS for an upstream
Vite-build bug in `ghcr.io/novuhq/novu/dashboard:2.3.0` — the image
is built with base=/ so its HTML references absolute root paths,
ignoring runtime VITE_BASE_PATH. Without these blocks the dashboard
404s on every asset load. PROPER FIX (out of scope here): rebuild
the dashboard image with VITE_BASE_PATH=/novu baked in at build
time, then drop these blocks.
- `/auth/` proxy to dashboard for React Router internal routes
(e.g. /auth/sign-up). MUST come before any Kong /auth route or
hard-refreshes at /auth/* go to Kong's keycloak upstream which
500s with DNS resolve failure when keycloak isn't deployed.
⚠ Same Vite-build-bug story. PROPER FIX: rebuild image so internal
routes are emitted as /novu/auth/* and shadowed by /novu/.
## digit.env.j2: single-origin WS
NOVU_WS_PUBLIC_URL now points at `{scheme}://{domain}/novu-ws`
(routed via the new nginx block) instead of `{scheme}://{domain}:14003`.
Single-origin keeps cookies and CORS coherent; the port-direct version
also broke with TLS termination at nginx because the upstream WS isn't
TLS-served on 14003.
## Out-of-scope (worth tracking separately)
1. **Bridge `whatsapp:` prefix bug.** `novu-bridge`'s
`DispatchPipelineService` prepends `whatsapp:` to the recipient
`to.phone` regardless of `NOVU_BRIDGE_CHANNEL`. With channel=sms
this produces Twilio error 21910 ("Invalid From and To pair"). The
fix is in the Java source — outside the docker-compose / Ansible
scope of this PR. Tracked: needs a follow-up PR against the bridge
image source to gate the prefix on `equalsIgnoreCase("whatsapp")`.
2. **Kong `keycloak-route` shadowing /auth.** Unrelated to notif but
surfaced via the dashboard /auth/sign-up flow. Either gate the
route on an `enable_keycloak` flag in kong.yml, or have ansible
strip it when the deployment doesn't deploy keycloak. For now the
nginx /auth/ block above takes precedence so functionally fine.
## Verified end-to-end
After these fixes plus ChakshuGautam#37 seed corrections, the chain dispatches on
ke.bomet to a real Indian WhatsApp number via Twilio production:
/user-otp/_send → Kafka → bridge → config-service → Novu →
novu-worker → bridge-endpoint → Twilio → recipient (status: read).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… events Seeds the TemplateBinding and ProviderDetail records that wire the PGR complaint-lifecycle events and OTP.SEND to the Novu workflows, so the novu-bridge can resolve a workflow + provider per (tenant, event, channel, locale). Folds the original seed (was WIP ChakshuGautam#37) together with the corrections that aligned it to live config-service behavior (was ChakshuGautam#41), as one coherent change on nairobi: - Data records POST to config-service /config/v1/_create/<schema>, not mdms-v2 — config-service has its own eg_config_data table and is not a pass-through; records seeded via mdms-v2 were invisible to the bridge. Schemas are still registered in mdms-v2 (config-service validates creates against them). - seed.sh captures the full /oauth/token response and includes userInfo in every RequestInfo (config-service NullCheckException without it); deterministic uniqueIdentifier per schema; auto-detects Kong /config-service routing with CONFIG_SERVICE_URL fallback. - TemplateBinding schema: locale moved to required (MDMS rejected it as x-unique-only with UNIQUE_ATTRIBUTE_LIST_ERR). - template-bindings.json: empty contentSid keys dropped (schema pattern ^HX[a-fA-F0-9]{32}$ rejects empty string). - README documents the config-service architecture + Content Template flow for production WhatsApp. Supersedes ChakshuGautam#37 and ChakshuGautam#38 (WIP plans). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bridge had `whatsapp` hardcoded in three places, so even with
`channel=sms` in TemplateBinding/ProviderDetail the dispatch path
silently coerced everything into Twilio's WhatsApp pipeline:
1. NovuBridgeConfiguration / application.properties / compose all
defaulted to NOVU_BRIDGE_CHANNEL=WHATSAPP. Flipped to SMS — works
out of the box with any Twilio SMS sender; WhatsApp requires a
pre-approved Programmable WhatsApp sender that we don't ship.
2. TwilioProviderStrategy unconditionally prepended `whatsapp:` to
the sender number. Now keys off ResolvedProvider.channel:
whatsapp → adds prefix, sms/other → strips any stray prefix that
leaked in from MDMS data.
3. DispatchPipelineService.formatWhatsappPhone() unconditionally
prepended `whatsapp:` to the *recipient* number too. Renamed to
formatRecipientPhone(), now takes channel and only prefixes for
whatsapp. This was the load-bearing bug — Twilio rejected SMS
trigger requests because the To address looked like a WhatsApp
address.
4. PreferenceServiceClient.isWhatsAppAllowed() hard-coded
consent.get("WHATSAPP"). Renamed to isChannelAllowed(channel)
and looks up consent.get(channel.toUpperCase()). Without this,
a user with SMS=GRANTED but no WHATSAPP entry would be denied.
Verified end-to-end SMS delivery via Kafka pipeline on subhashini
(NOVU_BRIDGE_CHANNEL=SMS, Twilio account in trial mode, recipient
verified in Twilio console).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ved Content SIDs
The 5 lifecycle TemplateBindings (APPLY/ASSIGN/RESOLVE/REJECT/REOPEN)
now carry the real, Meta-approved Twilio WhatsApp Content SIDs that
already exist on the eGov account (EN, *_message_new family), instead
of leaving contentSid absent.
- channel sms -> whatsapp (these SIDs are WhatsApp-approved; the bridge
resolves the whatsapp channel).
- paramOrder rewritten to map ComplaintsDomainEvent.data keys
(serviceName/complaintNo/submittedDate/assigneeName/
assigneeDesignation/departmentName/comment) onto each template's
{{1}},{{2}},... positions, verified per-SID via the Twilio Content
API rather than inferred from names.
- templateId unchanged — all 5 Novu workflow ids already exist in
novu-bridge-endpoint/workflows.js.
OTP.SEND intentionally left on the hardcoded path (no contentSid,
channel sms, not routed): no twilio/authentication template exists on
the account and SMS-OTP needs CCRS ChakshuGautam#43 + an SMS-capable sender first.
README updated: lifecycle WhatsApp is now zero-touch on deploy; Hindi
SIDs exist but stay a follow-up (HI {{n}} order unverified); serviceName
ships as the PGR serviceCode (display-name resolution tracked
separately).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes complainant lifecycle coverage (5 -> 7). Both templates are already Meta-approved on the eGov Twilio account; paramOrder verified against the live Content API bodies: - REASSIGN -> HX7dc390... (serviceName,complaintNo,submittedDate, assigneeName,assigneeDesignation,departmentName) — same shape as ASSIGN - RATE -> HXa0ad0e... (serviceName,complaintNo,submittedDate) Adds matching complaints-workflow-reassign / -rate Novu workflows in novu-bridge-endpoint (the trigger needs the workflow to exist even though WhatsApp content is rendered from the Twilio contentSid passthrough). Requires a novu-bridge-endpoint redeploy + bridge re-sync to go live; seed rows land on next deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rect live tenants) A create-only seed cannot fix an already-deployed tenant: config-service rejects a repeat _create for the same x-unique tuple with DUPLICATE_RECORD, so a stale row (wrong channel / missing contentSid / placeholder Twilio token) silently survives a re-run. Surfaced on Bomet 2026-05-16 — lifecycle bindings AND the Twilio ProviderDetail were stale and the reseed was a no-op until corrected by hand. - On DUPLICATE: _search the existing record, match on the x-unique DATA tuple (eventName,channel,locale / providerName,channel) NOT the uniqueIdentifier string (legacy rows carry a stale '.sms.' id whose data was later flipped to whatsapp), then _update by its id (which config-service _update requires). - Probe for a base that can _search; Kong routes _create/_resolve but not _search/_update, so the upsert needs config-service directly. Falls back to create-only with a loud WARNING when only Kong is reachable (re-run in-cluster to apply corrections). - Explicit per-record status: created / updated / SKIPPED / FAILED. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nant scoping Two corrections surfaced deploying the producer to Bomet (2026-05-16): - provider-details.json had channel=sms. The bridge dispatches on NOVU_BRIDGE_CHANNEL (default whatsapp, ChakshuGautam#42) and the lifecycle bindings are whatsapp, so an sms provider is never resolved (NB_PROVIDERS_NOT_FOUND) — WhatsApp lifecycle silently never sends. Set channel=whatsapp to match the bindings + bridge default. - README: config-service resolves TemplateBinding/ProviderDetail by EXACT tenantId (no hierarchy fallback). On a single-city deploy the citizen UI files complaints under the root tenant (ke), not the city tenant (ke.bomet); seeding only ke.bomet makes every real complaint fail CONFIG_NOT_RESOLVED. Documented: seed at the tenant the PGR events actually carry. Verified end-to-end on Bomet: real CCRS pgr-services emits COMPLAINTS.WORKFLOW.APPLY (tenant ke) -> bridge resolves -> Twilio WhatsApp delivered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bridge resolves the citizen phone country-code prefix from MDMS
(ValidationConfigs.mobileNumberValidation) in formatRecipientPhone.
Its application.properties default is
`mdms.host=${MDMS_HOST:-http://mdms-v2.egov:8080}` — that hostname does
not resolve on the egov docker network. Earlier iterations set only
MDMS_SEARCH_PATH, leaving MDMS_HOST at the broken default, so every
complaint died with NB_MDMS_PHONE_PREFIX_UNAVAILABLE before reaching
Novu/Twilio.
Set MDMS_HOST=http://egov-mdms-service:8094 (same host the rest of the
stack uses). Verified end-to-end on Bomet: prefix now resolves (+254),
bridge -> Novu (201) -> Twilio; only delivery to a non-existent number
fails (Twilio 63024), as expected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…upstream pg/mz defaults
The compose file ships with `STATE_LEVEL_TENANT_ID: pg` (+ `pg.citya`
on the two city-tier services + a stray `mz` on inbox) from upstream
egovernments/CCRS sample data. The playbook copies the compose verbatim
and only rewrites the Grafana domain. Net result: every DIGIT service
comes up using `pg` for state-tier env vars on any tenant deployed
through this playbook, including naipepea and (when redeployed) bomet.
Adds an inventory field `state_root` (the actual country/state tenant
— `ke` for Nairobi/Bomet, distinct from the misleadingly-named
`state_tenant_id` which is the city tenant the SPA pins to) and four
`replace:` tasks that rewrite the upstream defaults:
STATE_LEVEL_TENANT_ID: pg -> {{ state_root }}
EGOV_STATE_LEVEL_TENANT_ID: pg -> {{ state_root }}
STATE_LEVEL_TENANT_ID: pg.citya -> {{ tenant_id }} (city tier)
STATE_LEVEL_TENANT_ID: mz -> {{ state_root }}
Three `pg` defaults stay unrewritten because they match what the working
naipepea backup had pre-regression too — `EGOV_STATELEVEL_TENANTID` on
egov-indexer, `EGOV_STATELEVEL_TENANT` on egov-hrms, and
`PGR_STATELEVEL_TENANTID` on pgr-services. These appear to be unread
or differently-named env vars and were `pg` even on a working stack;
leaving them alone keeps the diff scoped to what actually breaks auth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a first-class macOS (Darwin) target to the canonical deploy without
disturbing the Linux/Debian path (all branches gated; Linux renders and
behaves byte-identically — verified via --syntax-check + diff review).
Preflight: now permits Darwin (was: fail on non-Debian); message tells
Mac operators to set ansible_connection: local in host_vars.
Skipped on Darwin (Docker Desktop/OrbStack already provides these):
- Docker host install + /etc/docker + docker.service (one guarded block)
- apt-installed Node/Python/Playwright-deps (feature-flagged extras)
Containerized nginx on Darwin (the one real port): host-nginx tasks
wrapped in a Linux-only block; a sibling Darwin block renders the SAME
template with nginx_upstream_host=host.docker.internal (so the container
reaches host-published Kong/UI/MCP/etc ports) and runs nginx:1.27-alpine
with the configurator dist + globalConfigs.js bind-mounted. Template
upstreams parametrized: {{ nginx_upstream_host | default('127.0.0.1') }}
— Linux default unchanged.
OS-aware defaults (group_vars/all.yml; Linux values unchanged, host_vars
still overrides): digit_dir (~/digit on Mac, no /opt sudo),
configurator_www_dir, nginx_host_port. Play-level DOCKER_DEFAULT_PLATFORM
=linux/amd64 on Darwin (omit on Linux). Rosetta JVM stability flags
(prev commit) gate via MAC_JVM_OPTS, empty on Linux.
Most of the playbook-deploy.yml line delta is mechanical reindent from
wrapping two contiguous task clusters in `block:`; net new logic is the
guards + the Mac nginx block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Play-level become:true + ansible_connection:local on macOS forces sudo prompts and root-owns ~/digit. No privileged host task runs on Darwin (all gated off), so become=false there. Linux unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full-path ./deploy.sh validation on Mac caught this: the MCP health wait (curl :13101/healthz, 12x10s) had no enable_mcp guard, unlike the mcp-bootstrap tasks. On Mac enable_mcp is false (digit-mcp image is VPC-registry-only), so the playbook hung 2 min then FAILED on a service it was never asked to run — blocking everything after (OpenBao, Mac-nginx/configurator, validation). Add when: enable_mcp | default(false), consistent with the sibling tasks. Linux/VPC (enable_mcp true) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last full-path gap: fullpath7 passed EVERYTHING (converge x2, OpenBao seal+re-unseal, Mac-nginx serving /configurator/ + /digit-ui/ at 200, auth probe, MDMS StateInfo, INFRA VALIDATION) and failed only on the trailing debug task 'Show MCP health status', which reads mcp_health.rc — but mcp_health is registered only by the enable_mcp-gated wait task (skipped on Mac), so it's undefined. Gate this status line the same way, consistent with fix #2 and the mcp-bootstrap tasks. Linux/VPC (enable_mcp true) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ci_tests Full-path validation: the deployment itself passed end-to-end (converge x2, OpenBao seal+re-unseal, Mac-nginx serving /configurator/ + /digit-ui/ @200, INFRA VALIDATION green), then failed in the OPTIONAL post-deploy CI tail. 8 tasks (L1348-1450: Install Newman, Postman digit-core-validation, Postman complaints-demo, install pytest+dataloader deps, CRSLoader E2E + results, CRSLoader v2 regression + results) had NO when: — they ran regardless of run_ci_tests, right above the 'CI tests — skipped' message. Wrong on Linux too (silently slow); on Mac the pip task hard-fails (homebrew python lacks 'packaging'). Gate all 8 on run_ci_tests | default(false), consistent with the rest of the CI block. Deployment path unchanged. 7th and final issue the faithful ./deploy.sh validation caught — all masked by the earlier manual-converge shortcut. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CI aggregator was the one ungated task left: its when: only checked the rc vars, each *_result.rc|default(-1). With run_ci_tests:false every CI sub-test skips → those vars are undefined → rc=-1 → the aggregator fired and failed the play even though all CI tasks correctly skipped (skipped=74). Add run_ci_tests gate to its when:, consistent with the entire rest of the CI block. Deployment path unchanged; Linux with run_ci_tests:true judges real results as before. 8th and final CI-gating issue the faithful ./deploy.sh validation surfaced. With this, every CI/extras task is gated → run_ci_tests:false cleanly skips the whole tail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bare up-retry was insufficient: under Rosetta the first up -d
aborts on slow depends_on health, leaving leaked network endpoints
that make every subsequent up collide ('endpoint <svc> already
exists'). New files/mac-stack-up.sh converges by disconnecting ONLY
non-running containers' leaked endpoints (running ones untouched, so
accumulated JVM warmth is preserved — no down) then re-issuing up -d,
looping until rc=0.
Start task split: Linux unchanged (plain up -d, when != Darwin);
Darwin runs the helper (when == Darwin). Confirmed-needed pattern —
same leaked-endpoint dance hit the personal-install rehearsal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full-path validation caught this: after OpenBao seeds secrets into .env, 'Recreate services with the new env' does a second plain docker compose up -d. On Mac that cold-restarts the JVM containers (env changed) and hits the same Rosetta depends_on timeout as the initial start (mdms-backend exited 1 → dependency failed to start), failing the playbook AFTER converge+OpenBao. Only the first start task was converge-wrapped. Split this task the same way: Linux plain up -d (unchanged); Darwin reuses mac-stack-up.sh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full-path validation got all the way to the final INFRA-VALIDATION gate then failed: 'OpenBao /v1/sys/health unsealed' -> 503 sealed/standby. Cause: the post-OpenBao recreate (converge#2, mac-stack-up down+up) RECREATES the openbao container, which returns SEALED; the earlier unseal ran before that recreate. Add a Darwin-only re-unseal (wait for seal-status API, unseal with the persisted bao_unseal_key) right after converge#2. Linux unaffected — its post-OpenBao recreate is a plain up -d that doesn't recreate openbao. 5th issue the faithful full-path test caught (all masked by the earlier manual-converge shortcut). Everything before this now passes: converge#1, MCP-skip, OpenBao, converge#2, Mac-nginx serving /configurator/ + /digit-ui/ (200), auth probe, MDMS StateInfo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tency, leaked-endpoint fail-fast Squash of mac-port-thin commits 7daa3fc + dd91350 + cfbb420. mac-stack-up.sh final shape: 14×40s retry loop, MAC_STACK_UP_SKIP_DOWN=1 for converge#2 (preserves infra + openbao state, halves deploy time), healthy-skip short-circuit (idempotent re-entry), fail-fast on leaked endpoints with explicit remedy, live progress to /tmp/mac-stack-up.progress. Playbook + deploy.sh changes: SKIP_DOWN=1 on converge#2 invocation; deploy.sh on Darwin gains a banner pointing at the progress sink so the operator can tail it during the 10-40min Rosetta converge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple-Silicon Macs run the amd64 JVM images under Rosetta, where the C2
JIT compiler SIGBUSes (CompileBroker::collect_statistics) and CDS mmap'd
archives also SIGBUS — egov-localization and friends crash ~1 min after
start. Bomet/naipepea never saw this because they're Linux.
Append `${MAC_JVM_OPTS:-}` to all 13 JAVA_TOOL_OPTIONS in
docker-compose.egov-digit.yaml (consistent with the file's existing
${VAR:-default} interpolation style; .env is auto-loaded from digit_dir).
digit.env.j2 sets MAC_JVM_OPTS to `-XX:TieredStopAtLevel=1 -Xshare:off`
only when ansible_system == 'Darwin', empty string otherwise.
Net: Linux targets get an empty suffix -> byte-identical JVM behavior,
zero production impact. Darwin targets get C1-only JIT + CDS off, which
eliminates the deterministic Rosetta SIGBUS (verified on the fork as
KDwevedi/CCRS#5; this is the canonical-safe, conditional form).
First commit of the thin Mac path (Approach A).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nginx-site.conf.j2 only had `static` and `else`(HMR). maputo.yml sets digit_ui_mode: container, which fell into the HMR else → `location /` (incl. /user/oauth/token) proxied to :upstream_esbuild_port (the digit-ui container, no API there) → 504 on every login, and globalConfigs.js was never served so digit-ui rendered as the source tenant (pg). Add a proper `container` branch: /digit-ui/* → the digit-ui container, /digit-ui/globalConfigs.js → the per-tenant host file, filestore rewrite, and ALL other paths (the API) → Kong. HMR/static branches unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… PATH deploy.sh's final step is `ansible-playbook ...`. If ansible isn't on PATH the script dies with a cryptic shell "ansible-playbook: command not found" at that line and the operator has no idea why. Very common macOS trap: `pip3 install --user ansible` installs ansible-playbook into the Python user-base bin, which macOS does NOT add to PATH — pip "succeeds" yet the binary is invisible. Add an early `command -v ansible-playbook` guard that exits 127 with the exact install/PATH fixes (brew / pipx / pip+PATH) and the verify command. Hit live during a macOS onboarding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
From the 10-iteration faithful ./deploy.sh validation (PR ChakshuGautam/CCRS#46) and the Feliciano Maputo dump. digit-ansible-onboard: - macOS is now a first-class target (thin Mac-port): added the Mac host_vars recipe (ansible_connection:local, deploy_become:false, docker_default_platform, digit_ui_mode:container, enable_mcp:false, configurator via vite v8.0.13 building client environment for production... �[2K transforming...✓ 1 modules transformed.), Rosetta deploy time, Mac probe. Removed the obsolete 'Mac → Ubuntu VM' framing. - IRON LAW exception + Q9 + Step-4: db_fast_path pins THREE secrets (elasticsearch_master_password, postgres_password=egov123, minio_root_*=minioadmin), not just elasticsearch — a custom value is the #1 cause of a stack that comes up then fails the post-OpenBao recreate (cost the whole validation to pin down). - Step-6: 'MCP SKIPPED (disabled)' is expected when enable_mcp:false, not a failure. digit-xlsx-onboard: - Step-2: pre-clean the recurring partner-dump defects (boundary dedupe+whitespace, Designation.department list, ServiceDefs.keywords string, Phase-1 local logo path). - Step-5b: explicit conditional new-root tenant_bootstrap BEFORE the wizard (brand-new state root => zero schemas => Phase 3-4 fail). - Step-6: prefer the forthcoming onboard_city composite; this skill becomes the narrative around it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Encodes the 5-hr live-spiral lessons: preflight-first, confirm runtime (OrbStack vs Docker Desktop), never SKIP_DOWN for recovery + leaked- endpoint engine-restart, the mdms-v2 _create platform defect (CCRS#49, canonical MCP confirms) → SQL keystone is mandatory, nginx container branch (CCRS#46), ansible-buffering progress file, citizen OTP handshake, employeeCode-as-username (egovernments#67), Phase-2 reconcile (egovernments#68). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ed green Validated 2026-05-20 on Apple-Silicon Mac / OrbStack: ./deploy.sh maputo → ok=63 changed=14 failed=0 skipped=84 /egov-mdms-service/health 200 /user/oauth/token (ADMIN/pg) 200 + real access_token /configurator/ 200 /digit-ui/ 200 /status/ 200 MAC-QUICKSTART.md is the blank-dir Codex-CLI target: prerequisites, clone-and-deploy in 6 commands, verification curls, troubleshooting. Says nothing about kit scripts, sibling repos, or post-deploy patches. host_vars/maputo.yml.example is committed (other host_vars/*.yml are gitignored per canonical convention). Caller copies it to maputo.yml, edits the marked fields, runs ./deploy.sh maputo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validated green on Mac/OrbStack — 2026-05-20 21:15
Probes (all 200)
Iteration log (6 attempts, 5 real bugs caught + fixed)
Files added in this round
Known scope limits (out for this PR)
Net for #52The 16+5=21 commit branch reaches the MAC-QUICKSTART.md user experience: clone → copy host_vars example → 🤖 Generated with Claude Code |
…t in this PR) Blank-dir Mac deploy from a wiped state surfaced: ~/digit/configurator/ is empty after the playbook completes (no task in this PR builds the dist), so /configurator/ returns 403 and the validate task fails. digit-configurator is vendored separately in PR ChakshuGautam#50 (kept-separate per the fold/supersede audit; 58k-line snapshot would drown ChakshuGautam#52's review surface). Until ChakshuGautam#50 lands, operators have two paths: (a) leave configurator:false (default in the example now) — the rest of the stack still validates 200 across mdms/ui/status/auth (b) build digit-configurator dist externally, drop into ~/digit/configurator/, then flip nginx_features.configurator:true and re-deploy MAC-QUICKSTART.md updated with the same guidance + an adjusted verify curl sequence (4 probes instead of 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Blank-dir test confirmed: from wiped volumes + empty ~/digit/ + fresh clone to working stack, converge took 2m44s (21:13:19 → 21:16:03). Updated MAC-QUICKSTART.md with the accurate timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🎉 Blank-dir scenario validated — MAC-QUICKSTART.md works end-to-endJust ran the true blank-dir test the consolidated PR was scoped around: # Setup: wiped all state
docker compose down -v --remove-orphans # all containers + volumes gone
rm -rf ~/digit # working dir wiped
docker volume rm $(docker volume ls -q | grep digit)
docker rm -f $(docker ps -aq) # any orphans
# Test: blank dir, fresh clone, follow QUICKSTART verbatim
mkdir -p ~/work/ccrs-blanktest && cd ~/work/ccrs-blanktest
git clone -b feat/canonical-mac-bringup https://github.com/KDwevedi/Citizen-Complaint-Resolution-System.git ccrs
cd ccrs/local-setup/ansible
cp inventory/host_vars/maputo.yml.example inventory/host_vars/maputo.yml
./deploy.sh maputoResult: One more bug caught + fixed in this final passConfigurator returned 403 because Net deliverable on this PRA fresh operator on a clean Mac:
No kit scripts, no sibling repos, no post-deploy patches. This is the consolidated path. |
🤖 Codex CLI blank-dir test — passed end-to-endThe ultimate validation: prompted Codex CLI (gpt-5.4,
That's it. No memory, no prior context, no hand-holding. Codex:
Result: Codex's own closing summary:
What this means: the QUICKSTART is now sufficient for an AI agent with zero prior context to bring up a working DIGIT stack on a Mac in one shot. PR #52 is ready for review. |
The mcp-bootstrap city task hardcoded the target as {{ state_tenant_id }}.citya,
which only fits the root=X / city=X.citya naming. Maputo is root=mz / city=mz.maputo.
Add city_tenant (default {{ state_tenant_id }}.citya — Linux/naipepea unchanged).
Lets a multi-tenant demo box keep state_root=pg (enc-service boots clean against
the dump) while bootstrapping mz + mz.maputo as layered-on tenants via MCP.
Per the bootstrap-order analysis: enc-service stays pinned to pg; mz's encryption
keys + DataSecurity are generated/read lazily at request time, not boot time, so
state_root and the MCP onboard-target must be separable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Threads core_mobile_configs.mobileNumberPattern + mobileNumberLength from host_vars into the mcp-bootstrap body so MCP synthesizes the per-tenant common-masters.UserValidation 'mobile' rule (egov-user ValidationData shape). Without it new tenants fall back to the hardcoded 10-digit regex and reject 9-digit MZ / Kenya mobiles. Validated: mz.maputo 9-digit register passes, 7-digit rejected, employee login 200. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…country = config, no code) The mcp-bootstrap body now passes user_validation (default omit) from host_vars. When set, MCP creates one common-masters.UserValidation record per field rule; when omitted it auto-builds a single mobile rule from core_mobile_configs. maputo.yml.example documents the pattern: a fourth country just edits core_mobile_configs (two lines) — or, to tune userName/email/name rules, declares a user_validation list. Zero MCP/ansible code change either way. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…or free) Adds files/configurator-build.sh (clone digit-configurator + npm + vite build --base=/configurator/, prints dist path) and a playbook task that runs it when build_configurator: true, capturing the dist into configurator_build for the existing sync task. Operators who pre-build elsewhere still just set configurator_build: <path> and leave build_configurator off. Validated on Mac/OrbStack: built dist served at /configurator/ = 200 (deploy ok=71 failed=0). Generic recipe (node+vite) — same on Linux. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nfigurator)
When `build_mcp: true`, the deploy clones DIGIT-MCP and `docker build`s the
image itself (files/mcp-build.sh), tags it digit-mcp:local, and runs that —
no GHCR/VPC pull, no multi-arch CI fix needed. Mirrors the configurator
fold-in (files/configurator-build.sh).
- files/mcp-build.sh: clone (repo/ref configurable) + docker build + tag;
prints the image tag on the last line for the playbook to capture.
- playbook: "Resolve MCP image tag" sets mcp_image (digit-mcp:local when
build_mcp, else {{ docker_registry }}/digit-mcp:latest) before .env render;
"Build digit-mcp image locally" runs the script before pull, gated on
enable_mcp AND build_mcp (both default false → no Debian behavior change).
- digit.env.j2: MCP_IMAGE={{ mcp_image }} (was hardcoded registry/latest).
- maputo.yml.example: document build_mcp / mcp_repo_url / mcp_ref / mcp_image
and drop the stale "arm64-only, upstream-fix territory" note.
Validated on macOS/OrbStack via canonical ./deploy.sh maputo: PLAY RECAP
failed=0; build task ran (changed); .env+compose resolve digit-mcp:local;
container runs the local amd64 build (gap-fix branch), healthy, /healthz 200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update the ansible docs for the MCP + configurator fold-ins: - dependency table: digit-configurator and DIGIT-MCP are now built from source at deploy (build_configurator / build_mcp), not pre-built/pulled. - "digit-mcp image" section: local build (build_mcp → digit-mcp:local) is the recommended path; VPC registry push demoted to legacy. Note the mac converge won't auto-recreate on a tag-only change (--force-recreate). - host_vars README: replace the stale "enable_mcp only works in the VPC" warning — build_mcp:true builds locally anywhere, no registry needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The citizen-flow phone-input prefix comes from LOCALE_REGION (ISO code), not coreMobileConfigs.mobilePrefix. Default IN→+91; set per country (MZ→+258). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third fold-in after configurator + mcp. When `build_digit_ui: true`, the deploy clones digit-ui-esbuild + `npm run build`s the SPA (files/digit-ui-build.sh) and mounts the bundle over the digit-ui container web root via the docker-compose.digit-ui-build.yml overlay — serving a from-source UI instead of the pinned (often stale) registry image. No registry dependency. - files/digit-ui-build.sh: clone (repo/ref configurable) + npm install + `npm run build` (NOT bare esbuild.build.js — that skips the prebuild vendored-CSS step and ships a broken bundle). Prints build/ path. - docker-compose.digit-ui-build.yml: mounts ./src/digit-ui-esbuild/build over /var/web/digit-ui; base globalConfigs.js / silent-check-sso.html / conf file mounts nest on top (runtime per-tenant config preserved). - playbook: copy overlay, add it to compose_files when build_digit_ui, build task before up -d. All gated `build_digit_ui | default(false)` → no Linux (static/host-nginx) behaviour change. - maputo.yml.example documents build_digit_ui / digit_ui_esbuild_repo|branch. Validated on macOS via canonical ./deploy.sh maputo: PLAY RECAP failed=0, build task ran, digit-ui serves the from-source bundle (index.js sha match), healthy; globalConfigs LOCALE_REGION=MZ still applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The docker-compose.digit-ui-build.yml overlay mounted the whole build/ dir over /var/web/digit-ui. On OrbStack a file-mount nested inside a dir-mount fails intermittently, so the base globalConfigs.js / silent-check-sso.html file mounts 404'd — the SPA couldn't load its runtime config. (Worked on a manual recreate, broke after the converge — classic flaky nested mount.) Replace the overlay with a post-up `tar` of build/ into the running container (excluding globalConfigs.js + silent-check-sso.html, which stay as file mounts). No nested mounts → globalConfigs.js serves; re-applied each deploy. - remove docker-compose.digit-ui-build.yml + its copy task + compose_files entry - add "Lay digit-ui from-source build into the container" task after up -d Validated: ./deploy.sh maputo failed=0; build + tar tasks ran; digit-ui serves the from-source bundle (index.js sha match) and globalConfigs.js is HTTP 200. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior commit only captured the overlay file deletion; this carries the actual playbook change: drop the docker-compose.digit-ui-build.yml copy task + compose_files entry, and add the post-up "Lay digit-ui from-source build into the container" tar task. Validated via ./deploy.sh maputo (failed=0; build + tar tasks ran; digit-ui serves the from-source bundle, globalConfigs.js 200). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pled) Earlier doc was wrong: the phone-input prefix does NOT come from LOCALE_REGION. In the from-source digit-ui (build_digit_ui), the citizen login reads CORE_MOBILE_CONFIGS.mobilePrefix for the prefix; LOCALE_REGION only sets the i18n locale (en_<REGION>). So keep locale_region: IN (en_IN is always seeded → localization works with zero seeding) and set mobilePrefix per country for the prefix. The stale pinned image lacked this decoupling and fell back to +91. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…into feat/canonical-mac-bringup
…nto feat/canonical-mac-bringup
…eat/canonical-mac-bringup # Conflicts: # local-setup/ansible/templates/digit.env.j2 # local-setup/docker-compose.egov-digit.yaml
What this PR is
One consolidated bring-up PR that absorbs PR #14, #40, #46, #47, #48 (close on merge) and lets a Mac operator (or Linux operator) onboard a new tenant by editing
host_vars/<tenant>.ymland running./deploy.sh <tenant>— no script-on-top.Cherry-picked from the 6 source PRs into 16 ordered commits off current
nairobiHEAD (b4b0d99). Linux behavior is byte-identical — every Mac branch is eitherwhen: ansible_system == 'Darwin'ordefault(omit).Commit map
fix(ansible): render STATE_LEVEL_TENANT_ID from inventoryPARENT_LEVEL_TENANT_ID,DIGIT_TENANT) — same shape, found during auditmac-port: thin macOS path — skip host-provisioning, containerize nginxc2a8aae. Includes the preflight flip: extendsa80b11b'sfail when os_family != 'Debian'tofail when os_family != 'Debian' AND ansible_system != 'Darwin', and updates the message to point Mac operators atansible_connection: localinstead of "spin up a VM". Empirical headless OrbStack stack works fine without the VM layer (verified this session).mac-port: OS-conditional become602f749mac-port: gate Wait-MCP-health / Show-MCP-status / Newman / Fail-if-CI-failed9791d7e,7a50652,0d4771b,1d0844bmac-port: robust Rosetta stack-up via mac-stack-up.sh converge loop397f4dc(creates the converge engine)mac-port: route post-OpenBao recreate through the Darwin converge too8d715eamac-port: re-unseal OpenBao after converge#2 (Darwin)92d52dbmac-port: mac-stack-up.sh — live converge progress, SKIP_DOWN, idempotency, leaked-endpoint fail-fast7daa3fc+dd91350+cfbb420(iterative improvements to the converge script). Also fixes a converge#2 task signature gap (missing NET arg) and a duplicatewhen:clause from cherry-pick merging.mac-port: gate JVM Rosetta-stability flags on Darwin0d66f93mac-port(nginx): add digit_ui_mode=="container" branch (fixes login 504)383d026chore(deploy.sh): fail fast if ansible-playbook not on PATH9cf48a6(resolved against banner: precheck FIRST, then banner)skills: encode validated Mac-port + onboarding gotchas(×2)ab102f7+a274131Empirical grounding
Before cutting this branch I ran
tenant_bootstrap pg → exptagainst the local Mac/OrbStack db_fast_path stack (same image both branches pull:egovio/mdms-v2:v2.9.2-4a60f20):The cast bug we feared (
JSONObject cannot be cast to JSONArray) did not fire on the bootstrap path. MCP'stenant_bootstrapis already 99.7% functional on db_fast_path. The remaining gaps (UserValidation synthesis, ACCESSCONTROL-ACTIONS bridge, StateInfo rebrand, citymodule data, root locales) are tracked in a follow-up PR againstChakshuGautam/DIGIT-MCP— out of scope here.That empirical result is what made it possible to ship a Mac-onboarding PR without rewriting the bootstrap. We just need ansible to call MCP (which this PR enables on Mac by dropping the false
enable_mcp: falsepremise's underlying reason).What this PR does NOT do (deferred)
tenant_bootstrap5-gap fixes (UserValidation synth, ACCESSCONTROL-ACTIONS bridge, StateInfo branding rewrite, citymodule data copy, root-tenant locale seeding) — separate PR againstChakshuGautam/DIGIT-MCP. Spec'd at ~245 LOC + 2 vendored localization JSONs from CRS Loader. Will close upstream issue DataLoader v2 regression: tenant_bootstrap doesn't copy StateInfo / PGR workflow / DataSecurity records on new root #22.Closes
feat/personal-install(supersede — parallel laptop-bring-up; the canonicallocal-setup/ansible/Mac path here replaces it)fix/state-level-tenant-from-inventory(absorbed, commit 1)mac-port-thin(absorbed, commits 2-13)skills/validated-mac-onboard(absorbed, commits 15-16)chore/deploy-ansible-precheck(absorbed, commit 14)Stays open
mdms-v2 _createdefect (upstream Java fix inDigit-Core; no longer urgent — empirically not on bootstrap path)How to verify Mac
Linux deploys via Tailscale/Hetzner are unchanged — every Mac branch is Darwin-gated or
default(omit).Update — also folds in configurator, MCP, and digit-ui (build-from-source)
Beyond the original Mac-port consolidation, this branch now makes the deploy build all three external repos from source at deploy (clone-at-deploy; no vendored snapshots, no pinned/stale images), via one consistent pattern:
build_configurator: true→files/configurator-build.sh(clone digit-configurator +vite build). Supersedes feat(configurator): import digit-configurator as configurator/ snapshot #50 (the configurator-snapshot import) — same goal, build-from-source instead of vendoring.build_mcp: true→files/mcp-build.sh(clone DIGIT-MCP +docker build→digit-mcp:local). No GHCR / multi-arch dependency.build_digit_ui: true→files/digit-ui-build.sh(clone digit-ui-esbuild +npm run build), laid into the container viatar(OrbStack-safe; bind-mounting the build dir breaks the nested globalConfigs.js file-mount).Plus deploy-hardening validated on macOS/OrbStack against Maputo (
mz/mz.maputo):locale_region: IN(en_IN localization, zero seeding); phone prefix fromcore_mobile_configs.mobilePrefix(decoupled in the from-source digit-ui).Closes #50 (configurator snapshot — superseded by build-from-source).
Update 2 — pulls in the notification fixes
Folds the notif line into this PR (conflict pre-checked; resolved inter-PR overlaps):
config.getChannel()— SMS and whatsapp). Chosen over fix(novu-bridge): channel-aware whatsapp: prefix — unblock SMS delivery #43 (whatsapp-hardcoded duplicate of the same fix) — closes fix(novu-bridge): channel-aware whatsapp: prefix — unblock SMS delivery #43./novu-api,/novu-wsworkarounds + NOVU_* public URLs (digit.env.j2). Resolved keep-both with our MAC_JVM_OPTS block;NOVU_BRIDGE_CHANNELdefaults to lowercasewhatsapp(case-sensitive vs seed; SMS paused).Closes #41, #42, #43, #44.
Deferred: #39 (otp-publisher service) — its notif seed diverged from #41 (seed.sh −168, template-bindings −43); reconciling the unified seed is a notif-domain follow-up. WIP #37/#38 held.
🤖 Generated with Claude Code