diff --git a/controlplane/provisioning/analytics_events_test.go b/controlplane/provisioning/analytics_events_test.go index 13658c9e..14d55652 100644 --- a/controlplane/provisioning/analytics_events_test.go +++ b/controlplane/provisioning/analytics_events_test.go @@ -55,7 +55,7 @@ func TestProvisionEmitsAnalyticsEvent(t *testing.T) { store := newFakeStore() router := newTestRouter(store) - body := []byte(`{"database_name": "acme-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`) + body := []byte(`{"database_name": "acme-db", "default_team_id": "1", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/acme/provision", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() diff --git a/controlplane/provisioning/api.go b/controlplane/provisioning/api.go index b99f605b..25e01254 100644 --- a/controlplane/provisioning/api.go +++ b/controlplane/provisioning/api.go @@ -136,9 +136,11 @@ type connectionDetails struct { type provisionRequest struct { DatabaseName string `json:"database_name"` - // DefaultTeamID optionally links the org to its default PostHog team id. - // Optional and non-breaking: absent/empty ⇒ the org's default_team_id is - // left NULL (no error). Prerequisite for pull-based compute billing. + // DefaultTeamID links the org to its default PostHog team id. REQUIRED + // when the provision creates a NEW org (400 otherwise — every org carries + // its team id from birth; pull-based compute billing keys usage buckets by + // it). Optional on re-provision of an existing org: absent/empty keeps the + // stored value, never wipes it. DefaultTeamID string `json:"default_team_id,omitempty"` MetadataStore *provisionMetadataReq `json:"metadata_store,omitempty"` DataStore *provisionDataStoreReq `json:"data_store,omitempty"` @@ -327,6 +329,13 @@ func (h *handler) provisionWarehouse(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } + // Creating a NEW org requires default_team_id — a caller input + // problem, not a server failure. Decided in the store (only it knows + // whether the org exists), surfaced here as 400. + if errors.Is(err, ErrDefaultTeamIDRequired) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if isUniqueViolation(err) { // Most likely: database_name already in use by another // org. Map to 409 with a clear message; the underlying diff --git a/controlplane/provisioning/api_test.go b/controlplane/provisioning/api_test.go index e27f217f..725185a3 100644 --- a/controlplane/provisioning/api_test.go +++ b/controlplane/provisioning/api_test.go @@ -108,12 +108,15 @@ func (s *fakeStore) Provision(req ProvisionRequest) error { shadowWarehouse := s.warehouses[req.OrgID] shadowUserHash, hadUser := s.users[configstore.OrgUserKey{OrgID: req.OrgID, Username: "root"}] - // 1. Warehouse + Org + // 1. Warehouse + Org. Mirrors createPendingWarehouseTx: a NEW org + // requires default_team_id (rejected before any write); an existing org + // keeps its stored value when the field is omitted (set-only, never wipe). if _, ok := s.orgs[req.OrgID]; !ok { + if req.DefaultTeamID == "" { + return ErrDefaultTeamIDRequired + } s.orgs[req.OrgID] = &configstore.Org{Name: req.OrgID, DatabaseName: req.DatabaseName} } - // default_team_id is optional/non-breaking: set it only when supplied, - // mirroring createPendingWarehouseTx (empty ⇒ leave NULL, never wipe). if req.DefaultTeamID != "" { teamID := req.DefaultTeamID s.orgs[req.OrgID].DefaultTeamID = &teamID @@ -212,7 +215,7 @@ func TestProvisionAutoCreatesOrg(t *testing.T) { store := newFakeStore() router := newTestRouter(store) - body := []byte(`{"database_name": "test-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`) + body := []byte(`{"database_name": "test-db", "default_team_id": "1", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/new-org/provision", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -229,7 +232,7 @@ func TestProvisionAutoCreatesOrg(t *testing.T) { } } -// TestProvisionPersistsDefaultTeamID checks the optional default_team_id in the +// TestProvisionPersistsDefaultTeamID checks the default_team_id in the // provision body is threaded through to the org record. func TestProvisionPersistsDefaultTeamID(t *testing.T) { store := newFakeStore() @@ -256,9 +259,11 @@ func TestProvisionPersistsDefaultTeamID(t *testing.T) { } } -// TestProvisionWithoutDefaultTeamIDLeavesNull checks default_team_id is optional: -// an absent field leaves the org's default_team_id NULL with no error. -func TestProvisionWithoutDefaultTeamIDLeavesNull(t *testing.T) { +// TestProvisionNewOrgRequiresDefaultTeamID locks in the mandatory contract: +// provisioning a NEW org without default_team_id is rejected with 400 and +// creates nothing (no org, no warehouse) — every org carries its default team +// id from birth. +func TestProvisionNewOrgRequiresDefaultTeamID(t *testing.T) { store := newFakeStore() router := newTestRouter(store) @@ -268,15 +273,41 @@ func TestProvisionWithoutDefaultTeamIDLeavesNull(t *testing.T) { rec := httptest.NewRecorder() router.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "default_team_id") { + t.Fatalf("error body should name default_team_id: %s", rec.Body.String()) + } + if _, ok := store.orgs["noteam-org"]; ok { + t.Fatal("org must not be created when default_team_id is missing") + } + if store.warehouses["noteam-org"] != nil { + t.Fatal("warehouse must not be created when default_team_id is missing") + } +} + +// TestReprovisionExistingOrgKeepsDefaultTeamID: re-provisioning an EXISTING org +// without default_team_id stays valid (not the new-org 400) and keeps the +// stored value — the field is set-only, never wiped by omission. +func TestReprovisionExistingOrgKeepsDefaultTeamID(t *testing.T) { + store := newFakeStore() + teamID := "777" + store.orgs["backfilled"] = &configstore.Org{Name: "backfilled", DefaultTeamID: &teamID} + router := newTestRouter(store) + + body := []byte(`{"database_name": "backfilled-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/backfilled/provision", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String()) } - org, ok := store.orgs["noteam-org"] - if !ok { - t.Fatal("expected org to be auto-created") - } - if org.DefaultTeamID != nil { - t.Fatalf("expected default_team_id to be nil (NULL), got %q", *org.DefaultTeamID) + org := store.orgs["backfilled"] + if org.DefaultTeamID == nil || *org.DefaultTeamID != teamID { + t.Fatalf("default_team_id must survive re-provision without the field, got %v", org.DefaultTeamID) } } @@ -495,7 +526,7 @@ func TestProvisionTransactionRollsBackOnUserFailure(t *testing.T) { store.setProvisionUserFailHook(errors.New("simulated DB write failure")) router := newTestRouter(store) - body := []byte(`{"database_name": "team-7-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`) + body := []byte(`{"database_name": "team-7-db", "default_team_id": "7", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/7/provision", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -617,6 +648,7 @@ func TestProvisionDuckLakeExternal(t *testing.T) { body := []byte(`{ "database_name": "extdl-db", + "default_team_id": "1", "metadata_store": {"type": "external", "external": { "endpoint": "rds.example.us-east-1.rds.amazonaws.com", "password_aws_secret": "duckling-example-rds-password", @@ -667,6 +699,7 @@ func TestProvisionComputesS3BucketName(t *testing.T) { wantBucket := "posthog-duckling-0194d6405db400006cde48d6114c0f99-mw-prod-us" body := []byte(`{ "database_name": "db", + "default_team_id": "1", "metadata_store": {"type": "cnpg-shard"}, "data_store": {"type": "s3bucket"}, "ducklake": {"enabled": true} @@ -705,6 +738,7 @@ func TestProvisionNoBucketSuffixLeavesNameEmpty(t *testing.T) { body := []byte(`{ "database_name": "db", + "default_team_id": "1", "metadata_store": {"type": "cnpg-shard"}, "data_store": {"type": "s3bucket"}, "ducklake": {"enabled": true} @@ -828,7 +862,7 @@ func TestProvisionRejectsOverlongSlugOrgID(t *testing.T) { func TestProvisionAcceptsHyphenatedOrgID(t *testing.T) { store := newFakeStore() router := newTestRouter(store) - body := []byte(`{"database_name":"d","metadata_store":{"type":"cnpg-shard"},"ducklake":{"enabled":true}}`) + body := []byte(`{"database_name":"d","default_team_id":"1","metadata_store":{"type":"cnpg-shard"},"ducklake":{"enabled":true}}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/my-org-cnpg/provision", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() diff --git a/controlplane/provisioning/store.go b/controlplane/provisioning/store.go index 5ea2b57a..31a5f89d 100644 --- a/controlplane/provisioning/store.go +++ b/controlplane/provisioning/store.go @@ -17,6 +17,14 @@ import ( // Deleted, then retry /provision. var ErrWarehouseNonTerminal = errors.New("warehouse already exists in non-terminal state") +// ErrDefaultTeamIDRequired is returned by Provision when the request would +// create a NEW org without a default_team_id. Every org must carry its default +// PostHog team id from birth (pull-based compute billing keys usage buckets by +// it); all pre-existing orgs have been backfilled. Re-provisioning an EXISTING +// org without the field stays valid — the stored value is kept, never wiped. +// HTTP handlers map this to 400. +var ErrDefaultTeamIDRequired = errors.New("default_team_id is required when creating a new org") + // ProvisionRequest is the all-or-nothing input the Provision endpoint // dispatches into a single configstore transaction. Warehouse + root // user are always written. @@ -29,9 +37,11 @@ var ErrWarehouseNonTerminal = errors.New("warehouse already exists in non-termin type ProvisionRequest struct { OrgID string DatabaseName string - // DefaultTeamID optionally links the org to its default PostHog team id. - // Optional/non-breaking: empty ⇒ the org's default_team_id is left NULL - // (unset), never an error. Prerequisite for pull-based compute billing. + // DefaultTeamID links the org to its default PostHog team id. REQUIRED + // when the org does not exist yet (Provision returns + // ErrDefaultTeamIDRequired otherwise); optional on re-provision of an + // existing org, where empty keeps the stored value (never a wipe). + // Prerequisite for pull-based compute billing. DefaultTeamID string Warehouse *configstore.ManagedWarehouse // RootUserHash is the bcrypt hash of the freshly-generated root @@ -76,7 +86,9 @@ func (s *gormStore) GetManagedWarehouse(orgID string) (*configstore.ManagedWareh func (s *gormStore) CreatePendingWarehouse(orgID, databaseName string, warehouse *configstore.ManagedWarehouse) error { return s.cs.DB().Transaction(func(tx *gorm.DB) error { - // No default_team_id on this standalone path — leave the column as-is. + // No default_team_id on this standalone path — an existing org keeps + // its column as-is; creating a NEW org through here now fails with + // ErrDefaultTeamIDRequired (same invariant as Provision). return createPendingWarehouseTx(tx, orgID, databaseName, "", warehouse) }) } @@ -91,15 +103,23 @@ func (s *gormStore) CreatePendingWarehouse(orgID, databaseName string, warehouse // exists in non-terminal state") so HTTP handlers can map to 409 // without an extra error type. func createPendingWarehouseTx(tx *gorm.DB, orgID, databaseName, defaultTeamID string, warehouse *configstore.ManagedWarehouse) error { - // Auto-create org if it doesn't exist (PostHog calls provision, duckgres creates everything) - org := configstore.Org{Name: orgID, DatabaseName: databaseName} - if defaultTeamID != "" { - // Set default_team_id on create. Optional/non-breaking: an empty value - // leaves the column NULL (unset), and re-provisioning without it does - // NOT wipe an existing value (see the explicit update below). - org.DefaultTeamID = &defaultTeamID - } - if err := tx.Where("name = ?", orgID).FirstOrCreate(&org).Error; err != nil { + // Auto-create org if it doesn't exist (PostHog calls provision, duckgres + // creates everything). A NEW org MUST carry default_team_id (pull-based + // compute billing keys usage buckets by it; all pre-existing orgs are + // backfilled) — creating one without it is rejected. Re-provisioning an + // existing org without it keeps the stored value (never a wipe). + var org configstore.Org + err := tx.Where("name = ?", orgID).First(&org).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if defaultTeamID == "" { + return ErrDefaultTeamIDRequired + } + org = configstore.Org{Name: orgID, DatabaseName: databaseName, DefaultTeamID: &defaultTeamID} + if err := tx.Create(&org).Error; err != nil { + return err + } + case err != nil: return err } // Update database name if org already existed with a different one @@ -109,8 +129,8 @@ func createPendingWarehouseTx(tx *gorm.DB, orgID, databaseName, defaultTeamID st } } // If a default_team_id was supplied, persist it even when the org already - // existed (FirstOrCreate only sets fields on insert). Only ever set, never - // cleared here, so an omitted value is a no-op rather than a wipe. + // existed (the create branch above only runs on insert). Only ever set, + // never cleared here, so an omitted value is a no-op rather than a wipe. if defaultTeamID != "" { if err := tx.Model(&org).Update("default_team_id", defaultTeamID).Error; err != nil { return err @@ -119,7 +139,7 @@ func createPendingWarehouseTx(tx *gorm.DB, orgID, databaseName, defaultTeamID st // Check for existing warehouse in non-terminal state var existing configstore.ManagedWarehouse - err := tx.First(&existing, "org_id = ?", orgID).Error + err = tx.First(&existing, "org_id = ?", orgID).Error if err == nil { if existing.State != configstore.ManagedWarehouseStateFailed && existing.State != configstore.ManagedWarehouseStateDeleted { diff --git a/tests/e2e-mw-dev/harness.sh b/tests/e2e-mw-dev/harness.sh index 181ac1a7..0f249c4d 100755 --- a/tests/e2e-mw-dev/harness.sh +++ b/tests/e2e-mw-dev/harness.sh @@ -1029,39 +1029,55 @@ org_default_profile() { # org password catalog log "org default OK: audit shows org.update with field-change detail on $org" } -# ---- org default_team_id (optional, non-breaking) --------------------------- -# default_team_id links an org to its default PostHog team id (a nullable string -# config-store column, prereq for pull-based compute billing where usage buckets -# are keyed by team_id). It is OPTIONAL everywhere: NULL is a valid state. -# Asserts both halves of the contract on real orgs against the real config store: -# 1. Set path: the CNPG org was provisioned WITH default_team_id in its body -# (see CNPG_BODY); GET /orgs/:id must round-trip exactly that value. -# 2. Unset path: the EXT org was provisioned WITHOUT it; GET /orgs/:id must -# report null (not an error, not a stray value) — proving NULL is tolerated. -# 3. Mutate path: PUT /orgs/:id can set and clear it (""=NULL) on the EXT org, -# round-tripping on GET, so the admin edit path is covered too. +# ---- org default_team_id (mandatory on new orgs) ---------------------------- +# default_team_id links an org to its default PostHog team id (config-store +# column, prereq for pull-based compute billing where usage buckets are keyed by +# team_id). Contract: MANDATORY when a provision creates a NEW org (400, nothing +# created); optional on re-provision of an existing org, where omission keeps +# the stored value (set-only, never a wipe — the keep path is covered by +# TestReprovisionExistingOrgKeepsDefaultTeamID, since same-id re-provision +# in-run is off-limits here, see the lifecycle NOTE below). Asserts on real orgs +# against the real config store: +# 1. Set path: CNPG + EXT orgs were provisioned WITH default_team_id in their +# bodies (mandatory now); GET /orgs/:id must round-trip exactly each value. +# 2. Reject path: provisioning a brand-new org WITHOUT default_team_id must be +# 400 naming the field, and must create nothing (org GET stays 404). +# 3. Mutate path: PUT /orgs/:id can set and clear it (""=NULL) on the EXT org +# (admin escape hatch), round-tripping on GET; the provisioned value is +# restored afterwards so the org stays contract-conformant. # get_org_default_team_id prints the raw JSON value ("null" when NULL) so the # assertions can distinguish an unset column from an empty string. get_org_default_team_id() { # org -> prints default_team_id (jq raw; "null" when unset) curl -fsS -H "$H" "$API/api/v1/orgs/$1" | jq -r '.default_team_id' } -default_team_id_optional() { # cnpg_org ext_org +default_team_id_mandatory() { # cnpg_org ext_org cnpg_org="$1"; ext_org="$2" - log "default_team_id: set-at-provision round-trip on $cnpg_org, NULL-tolerated on $ext_org" + log "default_team_id: provision round-trips on $cnpg_org/$ext_org, new-org-without rejected" - # 1. Set path: CNPG org provisioned WITH default_team_id must read it back. + # 1. Set path: both orgs provisioned WITH default_team_id must read it back. got="$(get_org_default_team_id "$cnpg_org")" [ "$got" = "$CNPG_DEFAULT_TEAM_ID" ] \ || fail "default_team_id: GET /orgs/$cnpg_org = '$got' want '$CNPG_DEFAULT_TEAM_ID' (provision default_team_id did not persist)" - log "default_team_id OK: $cnpg_org round-tripped '$got' from provision" - - # 2. Unset path: EXT org provisioned WITHOUT it must read back null (no error). got="$(get_org_default_team_id "$ext_org")" - [ "$got" = "null" ] \ - || fail "default_team_id: GET /orgs/$ext_org = '$got' want 'null' (unset must be NULL, not an error/value)" - log "default_team_id OK: $ext_org reports NULL (optional/non-breaking honored)" - - # 3. Mutate path: PUT can set then clear ("" -> NULL) on the ext org. + [ "$got" = "$EXT_DEFAULT_TEAM_ID" ] \ + || fail "default_team_id: GET /orgs/$ext_org = '$got' want '$EXT_DEFAULT_TEAM_ID' (provision default_team_id did not persist)" + log "default_team_id OK: $cnpg_org/$ext_org round-tripped from provision" + + # 2. Reject path: a NEW org without default_team_id must 400 and create nothing. + noteam="e2e-noteam" + code="$(curl -s -o /tmp/noteam_out -w '%{http_code}' -X POST -H "$H" -H 'Content-Type: application/json' \ + -d '{"database_name":"e2enoteamdb","metadata_store":{"type":"cnpg-shard"},"ducklake":{"enabled":true}}' \ + "$API/api/v1/orgs/$noteam/provision")" + [ "$code" = "400" ] \ + || fail "default_team_id: new-org provision without it -> HTTP $code want 400: $(cat /tmp/noteam_out)" + grep -q "default_team_id" /tmp/noteam_out \ + || fail "default_team_id: rejection error should name the field: $(cat /tmp/noteam_out)" + code="$(curl -s -o /dev/null -w '%{http_code}' -H "$H" "$API/api/v1/orgs/$noteam")" + [ "$code" = "404" ] \ + || fail "default_team_id: rejected provision must create nothing, GET /orgs/$noteam -> HTTP $code want 404" + log "default_team_id OK: new org without it rejected with 400, nothing created" + + # 3. Mutate path: PUT can set, clear ("" -> NULL), then restore on the ext org. code="$(put_org "$ext_org" '{"default_team_id":"424242"}')" [ "$code" = "200" ] || fail "default_team_id: PUT set -> HTTP $code: $(cat /tmp/put_org_out)" got="$(get_org_default_team_id "$ext_org")" @@ -1070,7 +1086,9 @@ default_team_id_optional() { # cnpg_org ext_org [ "$code" = "200" ] || fail "default_team_id: PUT clear -> HTTP $code: $(cat /tmp/put_org_out)" got="$(get_org_default_team_id "$ext_org")" [ "$got" = "null" ] || fail "default_team_id: after PUT clear, GET = '$got' want 'null' (empty must clear to NULL)" - log "default_team_id OK: PUT set/clear round-trips on $ext_org (NULL restored)" + code="$(put_org "$ext_org" "{\"default_team_id\":\"$EXT_DEFAULT_TEAM_ID\"}")" + [ "$code" = "200" ] || fail "default_team_id: PUT restore -> HTTP $code: $(cat /tmp/put_org_out)" + log "default_team_id OK: PUT set/clear/restore round-trips on $ext_org" } # ---- user persistent secrets ------------------------------------------------ @@ -2094,17 +2112,19 @@ lifecycle_teardown_cnpg() { # org } # ---- cnpg duckling: cnpg-shard metadata + DuckLake ------------------------ -# Carries an optional default_team_id at provision time (prereq for pull-based -# compute billing: usage buckets keyed by team_id = the org's default team). The -# EXT org below deliberately omits it, so default_team_id_optional asserts both -# the set path (round-trips) and the unset path (NULL, no error). +# default_team_id is MANDATORY at provision time for new orgs (prereq for +# pull-based compute billing: usage buckets keyed by team_id = the org's default +# team) — every provision body below carries one; default_team_id_mandatory +# asserts the round-trips and that omitting it on a new org is rejected. CNPG_DEFAULT_TEAM_ID='90210' CNPG_BODY='{"database_name":"'"$CNPG"'","metadata_store":{"type":"cnpg-shard"}, "default_team_id":"'"$CNPG_DEFAULT_TEAM_ID"'", "data_store":{"type":"s3bucket"},"ducklake":{"enabled":true}}' # ---- ext duckling: external RDS metadata + DuckLake ----------------------- +EXT_DEFAULT_TEAM_ID='31337' EXT_BODY='{"database_name":"'"$EXT"'", + "default_team_id":"'"$EXT_DEFAULT_TEAM_ID"'", "metadata_store":{"type":"external","external":{ "endpoint":"'"$EXT_RDS_ENDPOINT"'","password_aws_secret":"'"$EXT_RDS_SECRET"'", "user":"ducklingexample","database":"ducklingexample"}}, @@ -2115,7 +2135,7 @@ EXT_BODY='{"database_name":"'"$EXT"'", # DuckLake-only: these orgs exist purely to host the worker-churn-heavy # resilience lanes, so keep their provision footprint small. res_body() { # org - printf '{"database_name":"%s","metadata_store":{"type":"cnpg-shard"},"data_store":{"type":"s3bucket"},"ducklake":{"enabled":true}}' "$1" + printf '{"database_name":"%s","default_team_id":"1","metadata_store":{"type":"cnpg-shard"},"data_store":{"type":"s3bucket"},"ducklake":{"enabled":true}}' "$1" } # ---- per-user kill switch --------------------------------------------------- @@ -2351,10 +2371,11 @@ lane_ext() { # external-RDS metadata backend + org default profile # Org default profile on ext: no client-sized assertions run on this org, so # the 2-CPU shape is unambiguously the org default's. org_default_profile "$EXT" "$ext_pw" ducklake - # default_team_id contract: CNPG provisioned WITH one (round-trips), EXT - # WITHOUT one (NULL, no error) + PUT set/clear on EXT. Runs here because both - # orgs are provisioned by now; it only touches the admin API, no worker/DB. - default_team_id_optional "$CNPG" "$EXT" + # default_team_id contract: both orgs provisioned WITH one (round-trips), a + # NEW org WITHOUT one is rejected (400, nothing created) + PUT set/clear/ + # restore on EXT. Runs here because both orgs are provisioned by now; it only + # touches the provisioning + admin APIs, no worker/DB. + default_team_id_mandatory "$CNPG" "$EXT" } main() {