@cyc-seattle/platform 1.39.3 → 1.40.0
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 +4 -2
- package/dist/planning/staffing.d.ts +3 -7
- package/dist/planning/staffing.js +20 -45
- package/dist/schema/index.d.ts +9 -0
- package/dist/workload.d.ts +2 -0
- package/dist/workload.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,11 +31,13 @@ app owns. The apps that run on it live in their own repos, such as
|
|
|
31
31
|
one replica, `spreadAcrossZones` spreads them, and `minAvailable` adds a PodDisruptionBudget.
|
|
32
32
|
`podAnnotations` sets annotations such as `cluster-autoscaler.kubernetes.io/safe-to-evict`. A
|
|
33
33
|
pod with `safe-to-evict: "false"` gets at least 500m CPU and 512Mi from Autopilot, so ask for that
|
|
34
|
-
much, or every preview shows a change.
|
|
34
|
+
much, or every preview shows a change. `progressDeadlineSeconds` gives a slow rollout longer than
|
|
35
|
+
Kubernetes' 600 seconds before the deploy fails.
|
|
35
36
|
- `WebService` is a `Workload` behind the environment's Gateway at `hostname`. Its Service exposes
|
|
36
37
|
`port` as port 80, and its readiness probe must be an HTTP GET, which the load balancer's health
|
|
37
38
|
check reuses unless `healthCheck.path` names a cheaper path. With `paths`, it claims only those path prefixes on `hostname`, and another
|
|
38
|
-
`WebService` can serve the rest of it.
|
|
39
|
+
`WebService` can serve the rest of it. A stopping pod keeps serving for 10 seconds, while the load
|
|
40
|
+
balancer stops sending to it, then has 30 seconds to exit.
|
|
39
41
|
- `ScheduledJob` runs a CronJob on `schedule`, in `America/Los_Angeles` unless `timeZone` says
|
|
40
42
|
otherwise. A run never overlaps the one before it, gets an hour, and doesn't retry, by default.
|
|
41
43
|
- All three run non-root with a read-only root filesystem, no capabilities, and no service account
|
|
@@ -22,13 +22,10 @@ export interface DoubleBooked {
|
|
|
22
22
|
export declare function enrollmentOf(offering: PlanOffering): number;
|
|
23
23
|
export declare class Staffing {
|
|
24
24
|
private readonly ctx;
|
|
25
|
-
private readonly covers;
|
|
26
25
|
private readonly warned;
|
|
27
26
|
constructor(ctx: Context);
|
|
28
27
|
/** Warns once for each row and plan copy, about the first date it's seen on. */
|
|
29
28
|
private warnOnce;
|
|
30
|
-
/** Whether holding `held` qualifies for `required`, through `qualification_covers`. */
|
|
31
|
-
qualifies(held: Id[], required: Id): boolean;
|
|
32
29
|
/**
|
|
33
30
|
* Gathers each block's activities, times and allocations. A block's paid time runs from its first
|
|
34
31
|
* activity's start to its last one's end, plus the longest setup and put-away. Its time on the
|
|
@@ -39,12 +36,11 @@ export declare class Staffing {
|
|
|
39
36
|
private checkUnallocated;
|
|
40
37
|
/** Each program's settings, by its product. */
|
|
41
38
|
private programs;
|
|
42
|
-
/** Whether a staff member holding `held`, at level `levelKey` if any, may coach a program coached at `required`. */
|
|
43
|
-
private qualifiedFor;
|
|
44
39
|
/**
|
|
45
40
|
* Warns when a block's sailors are more than its staff or its boats can safely take. Each program's
|
|
46
|
-
* sailors count against its own sailors per coach, and
|
|
47
|
-
*
|
|
41
|
+
* sailors count against its own sailors per coach, and need coaches at its own staff level. A
|
|
42
|
+
* salaried position on the block counts as a coach for any of its programs, since the plan trusts
|
|
43
|
+
* the planner's allocation (#208). Boats count the ones sailors bring.
|
|
48
44
|
*/
|
|
49
45
|
private checkCapacity;
|
|
50
46
|
/**
|
|
@@ -41,13 +41,9 @@ function groupBy(rows, key) {
|
|
|
41
41
|
}
|
|
42
42
|
export class Staffing {
|
|
43
43
|
ctx;
|
|
44
|
-
covers = new Map();
|
|
45
44
|
warned = new Set();
|
|
46
45
|
constructor(ctx) {
|
|
47
46
|
this.ctx = ctx;
|
|
48
|
-
for (const c of ctx.data.qualification_covers) {
|
|
49
|
-
this.covers.set(c.qualification_id, new Set([...(this.covers.get(c.qualification_id) ?? []), c.covers_id]));
|
|
50
|
-
}
|
|
51
47
|
}
|
|
52
48
|
/** Warns once for each row and plan copy, about the first date it's seen on. */
|
|
53
49
|
warnOnce(code, message, key, scope = {}) {
|
|
@@ -57,21 +53,6 @@ export class Staffing {
|
|
|
57
53
|
this.warned.add(id);
|
|
58
54
|
this.ctx.warn(code, message, key, scope);
|
|
59
55
|
}
|
|
60
|
-
/** Whether holding `held` qualifies for `required`, through `qualification_covers`. */
|
|
61
|
-
qualifies(held, required) {
|
|
62
|
-
const seen = new Set();
|
|
63
|
-
const stack = [...held];
|
|
64
|
-
while (stack.length > 0) {
|
|
65
|
-
const q = stack.pop();
|
|
66
|
-
if (q === required)
|
|
67
|
-
return true;
|
|
68
|
-
if (seen.has(q))
|
|
69
|
-
continue;
|
|
70
|
-
seen.add(q);
|
|
71
|
-
stack.push(...(this.covers.get(q) ?? []));
|
|
72
|
-
}
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
56
|
/**
|
|
76
57
|
* Gathers each block's activities, times and allocations. A block's paid time runs from its first
|
|
77
58
|
* activity's start to its last one's end, plus the longest setup and put-away. Its time on the
|
|
@@ -147,18 +128,11 @@ export class Staffing {
|
|
|
147
128
|
programs() {
|
|
148
129
|
return new Map(this.ctx.data.plan_programs.map((p) => [p.product_id, p]));
|
|
149
130
|
}
|
|
150
|
-
/** Whether a staff member holding `held`, at level `levelKey` if any, may coach a program coached at `required`. */
|
|
151
|
-
qualifiedFor(held, levelKey, required) {
|
|
152
|
-
if (levelKey === required)
|
|
153
|
-
return true;
|
|
154
|
-
const level = this.ctx.data.plan_staff_levels.find((l) => l.key === required);
|
|
155
|
-
const needed = level?.qualifications ?? [];
|
|
156
|
-
return needed.length > 0 && needed.every((q) => this.qualifies(held, q));
|
|
157
|
-
}
|
|
158
131
|
/**
|
|
159
132
|
* Warns when a block's sailors are more than its staff or its boats can safely take. Each program's
|
|
160
|
-
* sailors count against its own sailors per coach, and
|
|
161
|
-
*
|
|
133
|
+
* sailors count against its own sailors per coach, and need coaches at its own staff level. A
|
|
134
|
+
* salaried position on the block counts as a coach for any of its programs, since the plan trusts
|
|
135
|
+
* the planner's allocation (#208). Boats count the ones sailors bring.
|
|
162
136
|
*/
|
|
163
137
|
checkCapacity(block, programs) {
|
|
164
138
|
const { ctx } = this;
|
|
@@ -171,9 +145,9 @@ export class Staffing {
|
|
|
171
145
|
const scope = { version_id: block.row.version_id, date: block.row.date };
|
|
172
146
|
// The block's offerings, with the verb that agrees with them: "Guppies Week 2 has".
|
|
173
147
|
const has = `${offeringsOf(block)} ${new Set(block.members.map((a) => a.offering.id)).size > 1 ? "have" : "has"}`;
|
|
174
|
-
|
|
148
|
+
// Coaches each level needs, from the sailors of the programs coached at it.
|
|
149
|
+
const needed = new Map();
|
|
175
150
|
let counted = 0;
|
|
176
|
-
const required = [];
|
|
177
151
|
for (const o of offerings) {
|
|
178
152
|
const settings = programs.get(o.product_id);
|
|
179
153
|
if (settings?.staff_level_key == null || settings.sailors_per_coach == null || settings.sailors_per_coach <= 0) {
|
|
@@ -181,24 +155,25 @@ export class Staffing {
|
|
|
181
155
|
continue;
|
|
182
156
|
}
|
|
183
157
|
const n = ctx.enrollment.get(o.id) ?? 0;
|
|
184
|
-
|
|
158
|
+
needed.set(settings.staff_level_key, (needed.get(settings.staff_level_key) ?? 0) + n / settings.sailors_per_coach);
|
|
185
159
|
counted += n;
|
|
186
|
-
required.push(settings.staff_level_key);
|
|
187
160
|
}
|
|
188
161
|
if (counted > 0) {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
162
|
+
const load = sum(needed.values());
|
|
163
|
+
// Each level's staff cover only its own programs; salaried positions cover what's left.
|
|
164
|
+
let short = 0;
|
|
165
|
+
let covered = 0;
|
|
166
|
+
for (const [level, need] of needed) {
|
|
167
|
+
const have = sum(block.staff.filter((s) => s.position_key == null && s.staff_level_key === level).map((s) => s.count));
|
|
168
|
+
covered += Math.min(need, have);
|
|
169
|
+
short += Math.max(0, need - have);
|
|
170
|
+
}
|
|
171
|
+
const salaried = sum(block.staff.filter((s) => s.position_key != null).map((s) => s.count));
|
|
172
|
+
covered += Math.min(short, salaried);
|
|
173
|
+
if (load > covered + 1e-9) {
|
|
174
|
+
ctx.warn("over_capacity", covered === 0
|
|
200
175
|
? `${has} ${countOf(counted, "sailor")} and no coach`
|
|
201
|
-
: `${has} ${countOf(counted, "sailor")}, but enough coaches for only ${Math.floor((
|
|
176
|
+
: `${has} ${countOf(counted, "sailor")}, but enough coaches for only ${Math.floor((covered * counted) / load + 1e-9)}`, block.row.key, scope);
|
|
202
177
|
}
|
|
203
178
|
}
|
|
204
179
|
if (!block.members.some((a) => a.uses_boats))
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface Schema {
|
|
|
68
68
|
registrations: Registrations[];
|
|
69
69
|
resource_kinds: ResourceKinds[];
|
|
70
70
|
schools: Schools[];
|
|
71
|
+
sync_leases: SyncLeases[];
|
|
71
72
|
sync_runs: SyncRuns[];
|
|
72
73
|
sync_tasks: SyncTasks[];
|
|
73
74
|
version_set_members: VersionSetMembers[];
|
|
@@ -1058,6 +1059,14 @@ export interface Schools {
|
|
|
1058
1059
|
/** Other ways families write this school on their Clubspot forms. An answer matching the name or one of these, ignoring capitals and punctuation and counting a run of spaces as one, is this school. Anything else is kept as their own words. */
|
|
1059
1060
|
aliases: string[] | null;
|
|
1060
1061
|
}
|
|
1062
|
+
/** Which syncs are running now. A sync holds its queue while it runs, so a second run waits its turn. Written by the syncs. */
|
|
1063
|
+
export interface SyncLeases {
|
|
1064
|
+
id: string;
|
|
1065
|
+
/** Which sync's queue is held. A queue has one lease at a time. */
|
|
1066
|
+
queue: string;
|
|
1067
|
+
/** The running sync renews this every minute. Once it passes, the sync has stopped, and the next run takes the queue. */
|
|
1068
|
+
expires_at: string;
|
|
1069
|
+
}
|
|
1061
1070
|
/** One row each time a sync runs. */
|
|
1062
1071
|
export interface SyncRuns {
|
|
1063
1072
|
id: string;
|
package/dist/workload.d.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface WorkloadArgs extends PodArgs {
|
|
|
65
65
|
spreadAcrossZones?: boolean;
|
|
66
66
|
/** Adds a PodDisruptionBudget that keeps this many pods up through drains. Must be below `replicas`. */
|
|
67
67
|
minAvailable?: number;
|
|
68
|
+
/** How long a rollout may take before the Deployment, and so the deploy, fails. Defaults to 600. */
|
|
69
|
+
progressDeadlineSeconds?: number;
|
|
68
70
|
}
|
|
69
71
|
/** The pods' Workload Identity principal, for IAM grants. */
|
|
70
72
|
export declare function workloadMember(platform: pulumi.Output<PlatformOutputs>, serviceAccountName: pulumi.Input<string>): pulumi.Output<string>;
|
package/dist/workload.js
CHANGED
|
@@ -79,6 +79,7 @@ export function podSpec(name, args, serviceAccountName, container = {}) {
|
|
|
79
79
|
],
|
|
80
80
|
}));
|
|
81
81
|
}
|
|
82
|
+
const preStopSeconds = 10;
|
|
82
83
|
/**
|
|
83
84
|
* The Deployment, ServiceAccount, and Service of a `Workload` or a `WebService`, as children of
|
|
84
85
|
* `parent`. `http` is a `WebService`'s port, which its Service exposes as port 80.
|
|
@@ -99,10 +100,16 @@ export function deployment(parent, name, args, http) {
|
|
|
99
100
|
throw new Error(`${name}: "minAvailable" (${args.minAvailable}) must be below "replicas" (${replicas}).`);
|
|
100
101
|
}
|
|
101
102
|
const ports = [...(http !== undefined ? [{ name: "http", port: http }] : []), ...(args.ports ?? [])];
|
|
103
|
+
// The load balancer keeps sending to a stopping pod for a few seconds (cyc-seattle/platform#153), so
|
|
104
|
+
// a WebService's pod keeps serving that long before it gets SIGTERM, then has the default 30s to exit.
|
|
105
|
+
const drain = http !== undefined && {
|
|
106
|
+
lifecycle: { preStop: { sleep: { seconds: preStopSeconds } } },
|
|
107
|
+
};
|
|
102
108
|
new k8s.apps.v1.Deployment(name, {
|
|
103
109
|
metadata,
|
|
104
110
|
spec: {
|
|
105
111
|
replicas,
|
|
112
|
+
...(args.progressDeadlineSeconds !== undefined && { progressDeadlineSeconds: args.progressDeadlineSeconds }),
|
|
106
113
|
selector: { matchLabels: labels },
|
|
107
114
|
template: {
|
|
108
115
|
metadata: { labels, ...(args.podAnnotations && { annotations: args.podAnnotations }) },
|
|
@@ -111,8 +118,10 @@ export function deployment(parent, name, args, http) {
|
|
|
111
118
|
...(args.readinessProbe && { readinessProbe: args.readinessProbe }),
|
|
112
119
|
...(args.livenessProbe && { livenessProbe: args.livenessProbe }),
|
|
113
120
|
...(args.startupProbe && { startupProbe: args.startupProbe }),
|
|
121
|
+
...drain,
|
|
114
122
|
}).apply((spec) => ({
|
|
115
123
|
...spec,
|
|
124
|
+
...(drain && { terminationGracePeriodSeconds: preStopSeconds + 30 }),
|
|
116
125
|
...(args.spreadAcrossZones && {
|
|
117
126
|
topologySpreadConstraints: [
|
|
118
127
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyc-seattle/platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
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",
|