@cyc-seattle/platform 1.55.0 → 1.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,9 +13,9 @@ platform is built, its data model, and the planning engine.
13
13
  | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
14
14
  | [`bootstrap`](stacks/bootstrap) | Service accounts and project IAM, the Pulumi state buckets and KMS keys, the org's GitHub repos and settings, and Firebase on the project | A human with project Owner and `gh` `admin:org`. Never CI. |
15
15
  | [`infra`](stacks/infra) | The VPC, the cluster subnet and its Cloud NAT on a static address, the private services peering, the prod and dev Cloud SQL instances, the DNS zones, Artifact Registry and its cleanup policy, the GKE Autopilot cluster, the shared Gateway, its address, certificates, and dev's DNS records, the `dev` and `prod` namespaces with their quotas and network policies, dev's RBAC, the prod Job alerts, the log exclusions, the public website's Firebase Hosting site, and each environment's `platform/outputs.json` | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
16
- | [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions for each queued PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
17
- | [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync and website build machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev`, from Actions for each queued PR, and `deploy-runner` on a push to `main`, after `infra`. |
18
- | [`authentik`](stacks/authentik) | Authentik, the sign-in at `login.<domain>`: its server and worker, database, secrets, and A record, dev's Mailpit, and its own configuration: flows, stages, policies, the Google source, the `staff` group, the `Admins` superuser group, and the Directus OIDC and portal proxy applications | `deploy-dev`, from Actions for each queued PR, and `deploy-runner` on a push to `main`, after `backend`. |
16
+ | [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions for each merged PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
17
+ | [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync and website build machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev` and then `deploy-runner`, from Actions on a push to `main`, after `infra`. |
18
+ | [`authentik`](stacks/authentik) | Authentik, the sign-in at `login.<domain>`: its server and worker, database, secrets, and A record, dev's Mailpit, and its own configuration: flows, stages, policies, the Google source, the `staff` group, the `Admins` superuser group, and the Directus OIDC and portal proxy applications | `deploy-dev` and then `deploy-runner`, from Actions on a push to `main`, after `backend`. |
19
19
 
20
20
  ## @cyc-seattle/platform
21
21
 
@@ -104,19 +104,18 @@ deployer needs first.
104
104
  authenticates with Workload Identity Federation and stores no credentials. Each job runs in a
105
105
  GitHub environment that holds its identity and state bucket:
106
106
 
107
- | Event | Jobs, in order |
108
- | ------------------------ | ----------------------------------------------------------------------- |
109
- | Pull request | `ci` |
110
- | Merge queue | `ci`, then `dev`: `mise run deploy` as `deploy-dev`; `dev-passed` |
111
- | Push to `main` | `prod-preview`, then `prod`, which waits for the `maintainers` approval |
112
- | Run by hand, from `main` | `prod-preview`, then `prod` |
113
-
114
- Dev is shared, so only the merge queue deploys to it (#69, #80). PRs queued together deploy as
115
- one batch: each queued PR's run skips dev when a newer PR is queued behind it, and the newest
116
- deploys them all. The queue requires `dev-passed`, so a batch merges only once its dev deploy passes, and `main` gets the exact commit the queue tested, in one push. If the
117
- newest PR fails, the queue drops it and the rest of the batch deploys without it. A queued PR of only docs or CI skips `dev`. The queue
118
- doesn't run the integration tests (#161); a run by hand does. Dev's environment takes only `main`
119
- (#280), so a branch can't deploy to it.
107
+ | Event | Jobs, in order |
108
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
109
+ | Pull request | `ci` |
110
+ | Push to `main` | `dev`: `mise run deploy` as `deploy-dev`, then `integration` and `prod-preview`, then `prod`, which waits for the `maintainers` approval |
111
+ | Run by hand, from `main` | The same as a push to `main` |
112
+
113
+ Dev is shared and takes only `main` (#280), so a PR's push runs only CI, and each push to `main`
114
+ deploys dev before prod. `integration` starts `integration.yml`, which runs the integration tests
115
+ against dev in a run of its own, one at a time. A newer run replaces one that's waiting, and a
116
+ waiting deploy goes ahead of it, so it tests whatever is newest on dev. A failure opens or comments
117
+ on the `Integration tests failed on dev` issue, and a green run closes it. `nightly.yml` still
118
+ deploys and tests `main` each night, with its own issue.
120
119
 
121
120
  `prod-preview` runs `mise run preview` as `deploy-runner` and writes the diff to the job summary.
122
121
  `prod` runs `mise run deploy` on the same commit once approved. It runs only when the preview shows
@@ -46,13 +46,6 @@ export declare class Staffing {
46
46
  * Boats count the ones sailors bring.
47
47
  */
48
48
  private checkCapacity;
49
- /**
50
- * The coaches a block pays for its expected sailors: Σ (each program's sailors ÷ its sailors per
51
- * coach), rounded up, but never more than it's given. Salaried coaches on salary are always paid, so
52
- * only the others are cut, each staff row by the same share. When a program has no sailors per
53
- * coach, the block pays all it's given.
54
- */
55
- private coachesExpected;
56
49
  /** Books a cost on a block, split among its activities by their offerings' sailors. */
57
50
  private book;
58
51
  /**
@@ -80,8 +73,8 @@ export declare class Staffing {
80
73
  holds: Map<Id, number>;
81
74
  };
82
75
  /**
83
- * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the ones its
84
- * expected sailors need on the block of its busiest travel day.
76
+ * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
77
+ * block of its busiest travel day.
85
78
  */
86
79
  private bookTrips;
87
80
  }
@@ -6,8 +6,6 @@ export function enrollmentOf(offering) {
6
6
  export function maxEnrollmentOf(offering) {
7
7
  return offering.capacity ?? enrollmentOf(offering);
8
8
  }
9
- /** `n` rounded up, so a share like 5.0000001 boats stays 5. */
10
- const ceil = (n) => Math.ceil(n - 1e-9);
11
9
  function sum(values) {
12
10
  let total = 0;
13
11
  for (const v of values)
@@ -97,7 +95,6 @@ export class Staffing {
97
95
  const byOffering = groupBy(list, (a) => a.offering.id);
98
96
  const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
99
97
  const share = (a) => (total > 0 ? enrollment(a) / total : 1 / byOffering.size) / byOffering.get(a.offering.id).length;
100
- const most = sum([...byOffering.keys()].map((o) => this.ctx.maxEnrollment.get(o) ?? 0));
101
98
  result.set(id, {
102
99
  row,
103
100
  members: list,
@@ -109,7 +106,6 @@ export class Staffing {
109
106
  staff: staff.get(id) ?? [],
110
107
  boats: boats.get(id) ?? [],
111
108
  shares: new Map(list.map((a) => [a, share(a)])),
112
- fill: most > 0 ? Math.min(1, total / most) : 1,
113
109
  });
114
110
  }
115
111
  return result;
@@ -206,26 +202,6 @@ export class Staffing {
206
202
  holds.set(o.id, Math.min(holds.get(o.id) ?? Infinity, Math.max(0, n)));
207
203
  }
208
204
  }
209
- /**
210
- * The coaches a block pays for its expected sailors: Σ (each program's sailors ÷ its sailors per
211
- * coach), rounded up, but never more than it's given. Salaried coaches on salary are always paid, so
212
- * only the others are cut, each staff row by the same share. When a program has no sailors per
213
- * coach, the block pays all it's given.
214
- */
215
- coachesExpected(block, programs) {
216
- const given = sum(block.staff.map((s) => s.count));
217
- const salaried = sum(block.staff.filter((s) => s.position_key != null && s.pay !== "hourly").map((s) => s.count));
218
- let load = 0;
219
- for (const o of new Map(block.members.map((a) => [a.offering.id, a.offering])).values()) {
220
- const perCoach = programs.get(o.product_id)?.sailors_per_coach;
221
- if (perCoach == null || perCoach <= 0)
222
- return { coaches: given, share: 1 };
223
- load += (this.ctx.enrollment.get(o.id) ?? 0) / perCoach;
224
- }
225
- const others = given - salaried;
226
- const kept = Math.min(others, Math.max(0, ceil(load) - salaried));
227
- return { coaches: salaried + kept, share: others > 0 ? kept / others : 1 };
228
- }
229
205
  /** Books a cost on a block, split among its activities by their offerings' sailors. */
230
206
  book(block, account, amount, line, name, explanation, mtdcShare = 1, wages = false) {
231
207
  if (amount === 0 || account === undefined)
@@ -255,11 +231,7 @@ export class Staffing {
255
231
  const rates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
256
232
  const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
257
233
  for (const list of groupBy(blocks, (b) => b.row.date).values()) {
258
- const chartered = list.flatMap((block) => block.boats.flatMap((b) => {
259
- // The boats the expected sailors use, as for its own boats.
260
- const n = ceil((b.chartered ?? 0) * block.fill);
261
- return n > 0 ? [{ block, id: b.boat_class_id, n }] : [];
262
- }));
234
+ const chartered = list.flatMap((block) => block.boats.flatMap((b) => ((b.chartered ?? 0) > 0 ? [{ block, id: b.boat_class_id, n: b.chartered }] : [])));
263
235
  for (const [id, uses] of groupBy(chartered, (c) => c.id)) {
264
236
  const name = classes.get(id)?.name ?? id;
265
237
  const rate = rates.get(id)?.charter_per_day_cents;
@@ -366,13 +338,10 @@ export class Staffing {
366
338
  const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
367
339
  const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
368
340
  const given = new Map();
369
- const tripCoaches = new Map();
370
341
  for (const block of byDate(blocks)) {
371
342
  const scope = { plan_id: block.row.plan_id, date: block.row.date };
372
343
  const paidHours = block.paid_minutes / 60;
373
344
  const waterHours = block.on_water_minutes / 60;
374
- const expected = this.coachesExpected(block, programs);
375
- tripCoaches.set(block.row.id, expected.coaches);
376
345
  const book = (type, amount, line, name, explanation) => this.book(block, ctx.costAccount(type), amount, line, name, explanation, 1, type === "coach_wages");
377
346
  let wages = 0;
378
347
  for (const s of block.staff) {
@@ -381,7 +350,7 @@ export class Staffing {
381
350
  if (s.position_key != null) {
382
351
  const position = positions.get(s.position_key);
383
352
  if (s.pay === "hourly") {
384
- const hours = s.count * expected.share * paidHours;
353
+ const hours = s.count * paidHours;
385
354
  const rate = position?.hourly_coaching_rate_cents;
386
355
  if (position !== undefined && rate == null) {
387
356
  this.warnOnce("no_hourly_rate", `${position.name} is paid by the hour to coach but has no own hourly coaching rate, so those hours cost nothing`, position.key, scope);
@@ -417,18 +386,16 @@ export class Staffing {
417
386
  continue;
418
387
  }
419
388
  if (level.pay_type !== "volunteer") {
420
- wages += s.count * expected.share * paidHours * (level.hourly_rate_cents ?? 0);
389
+ wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
421
390
  }
422
391
  }
423
- book("coach_wages", wages, "coach_wages", "Coach wages", "Coaches the expected sailors need × paid hours × each one's hourly rate");
392
+ book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
424
393
  for (const b of block.boats) {
425
394
  const rate = boatRates.get(b.boat_class_id);
426
395
  if (rate === undefined || b.count <= 0 || waterHours <= 0)
427
396
  continue;
428
397
  const boatClass = boatClasses.get(b.boat_class_id);
429
- this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"), ceil(b.count * block.fill) *
430
- waterHours *
431
- (rate.maintenance_per_boat_hour_cents + (rate.fuel_per_hour_cents ?? 0)), `boats:${b.boat_class_id}`, `Boats: ${boatClass?.name ?? "boats"}`, "The organization's boats the expected sailors use × on-water hours × the class's fuel and upkeep");
398
+ this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"), b.count * waterHours * (rate.maintenance_per_boat_hour_cents + (rate.fuel_per_hour_cents ?? 0)), `boats:${b.boat_class_id}`, `Boats: ${boatClass?.name ?? "boats"}`, "The organization's boats × on-water hours × the class's fuel and upkeep");
432
399
  }
433
400
  const coaches = sum(block.staff.map((s) => s.count));
434
401
  const boats = sum(block.boats.map((b) => b.count + (b.chartered ?? 0)));
@@ -437,7 +404,7 @@ export class Staffing {
437
404
  }
438
405
  }
439
406
  this.bookCharters(byDate(blocks));
440
- this.bookTrips(blocks, scheduled, tripCoaches);
407
+ this.bookTrips(blocks, scheduled);
441
408
  return {
442
409
  coaching,
443
410
  doubleBooked,
@@ -459,10 +426,10 @@ export class Staffing {
459
426
  };
460
427
  }
461
428
  /**
462
- * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the ones its
463
- * expected sailors need on the block of its busiest travel day.
429
+ * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
430
+ * block of its busiest travel day.
464
431
  */
465
- bookTrips(blocks, scheduled, coachesOf) {
432
+ bookTrips(blocks, scheduled) {
466
433
  const { ctx } = this;
467
434
  for (const trip of ctx.data.plan_trips) {
468
435
  const offering = ctx.offeringsById.get(trip.offering_id);
@@ -470,9 +437,8 @@ export class Staffing {
470
437
  continue;
471
438
  const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
472
439
  const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
473
- const coaches = Math.max(0, ...staffed.map((b) => coachesOf.get(b.row.id) ?? 0));
474
- const given = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
475
- if (given === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
440
+ const coaches = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
441
+ if (coaches === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
476
442
  const first = scheduled
477
443
  .filter(travel)
478
444
  .map((a) => a.date)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.55.0",
3
+ "version": "1.55.1",
4
4
  "description": "Pulumi components for CYC's shared Google Cloud platform: WebService, the platform outputs reader, and the stacks that build the platform",
5
5
  "author": "CYC Community Sailing Center",
6
6
  "license": "Apache-2.0",