@cyc-seattle/platform 0.1.0 → 0.2.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 CHANGED
@@ -6,28 +6,59 @@ app owns. The apps that run on it live in their own repos, such as
6
6
 
7
7
  ## Stacks
8
8
 
9
- | Stack | Holds | Applied by |
10
- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
11
- | [`bootstrap`](stacks/bootstrap) | Service accounts and project IAM, the Pulumi state buckets and KMS keys, and the org's GitHub repos and settings | A human with project Owner and `gh` `admin:org`. Never CI. |
12
- | [`cloud`](stacks/cloud) | The VPC, the cluster subnet and its Cloud NAT, the private services peering, the prod and dev Cloud SQL instances, the DNS zones, and Artifact Registry | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
13
- | [`cluster`](stacks/cluster) | The GKE Autopilot cluster, the shared Gateway, its address, certificates, and DNS records, the `dev` namespace with its RBAC, quota, and network policy, and each environment's `platform/outputs.json` | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
14
- | [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions on every PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
9
+ | Stack | Holds | Applied by |
10
+ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
11
+ | [`bootstrap`](stacks/bootstrap) | Service accounts and project IAM, the Pulumi state buckets and KMS keys, and the org's GitHub repos and settings | A human with project Owner and `gh` `admin:org`. Never CI. |
12
+ | [`cloud`](stacks/cloud) | 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, and Artifact Registry | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
13
+ | [`cluster`](stacks/cluster) | 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, and each environment's `platform/outputs.json` | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
14
+ | [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions on every PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
15
15
 
16
16
  ## @cyc-seattle/platform
17
17
 
18
18
  `src/` is the `@cyc-seattle/platform` package, which the stacks here and the apps' stacks import:
19
19
 
20
20
  - `getPlatformOutputs()` reads an environment's `platform/outputs.json` from its state bucket. The
21
- `cluster` stack writes it. Dev stacks can't read prod's backend, so this replaces a
21
+ `cluster` stack writes it. It holds the environment's namespace and domain, the Gateway and its
22
+ address (`gatewayAddress`, for an app's own DNS records), and the Cloud SQL instance's connection
23
+ name and private IP (`sqlHost`). Dev stacks can't read prod's backend, so this replaces a
22
24
  StackReference for platform values (cyc-seattle/isthmia#215).
23
25
  - `clusterProvider(platform)` builds a Kubernetes provider for the cluster. Its kubeconfig runs
24
26
  `gke-gcloud-auth-plugin` with Application Default Credentials, so Pulumi must run under mise,
25
27
  where `mise run install` puts the plugin on `PATH`.
26
- - `WebService` runs an HTTP service behind the environment's Gateway, with secure defaults. Its pods
27
- get no IAM; grant roles to its `member`, a Workload Identity principal.
28
+ - `Workload` runs a long-lived process with no route: a Deployment, a ServiceAccount, and, when it
29
+ has `ports`, a ClusterIP Service that other pods in the namespace reach at `host`.
30
+ - `WebService` is a `Workload` behind the environment's Gateway at `hostname`. Its Service exposes
31
+ `port` as port 80, and its readiness probe must be an HTTP GET, which the load balancer's health
32
+ check reuses.
33
+ - `ScheduledJob` runs a CronJob on `schedule`, in `America/Los_Angeles` unless `timeZone` says
34
+ otherwise. A run never overlaps the one before it, gets an hour, and doesn't retry, by default.
35
+ - All three run non-root with a read-only root filesystem, no capabilities, and no service account
36
+ token, on Spot pods in dev only. Their limits equal their requests. Their pods get no IAM; grant
37
+ roles to `member`, a Workload Identity principal, or set `serviceAccountAnnotations` to run as a
38
+ Google service account.
28
39
  - `enableService` enables a Google Cloud API.
29
40
 
30
- `mise run build` builds it into `dist/`, which is all it publishes.
41
+ `mise run build` builds it into `dist/`, which is all it publishes. `.github/workflows/release.yml`
42
+ releases each merge to `main` with semantic-release, versioned by the conventional commits.
43
+
44
+ ## Environments
45
+
46
+ | Stack | Namespace | Domain | Deploy identity |
47
+ | ------ | --------- | ------------------ | --------------- |
48
+ | `dev` | `dev` | `dev.cycsail.team` | `deploy-dev` |
49
+ | `prod` | `prod` | `cycsail.team` | `deploy-runner` |
50
+
51
+ Each namespace denies ingress from other namespaces except the load balancer's. Dev also admits the
52
+ Kubernetes API's service proxy, which the integration tests use, and denies egress to the mail ports
53
+ (25, 465, 587) outside the cluster. `cluster` writes dev's A records; each prod app writes its own,
54
+ pointing at `gatewayAddress`.
55
+
56
+ `deploy-dev` may administer the `cyc-dev` Cloud SQL instance and read Secret Manager secrets whose IDs
57
+ start with `dev-`. Read them with `gcp.secretmanager.getSecretVersionAccess`, which needs only the
58
+ accessor role.
59
+
60
+ A Job that fails in `prod`, or a CronJob there with no successful Job in 3 hours, emails each address
61
+ in `cluster:alertEmails`.
31
62
 
32
63
  ## Setup
33
64
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { clusterProvider, type ClusterProviderArgs } from "./kubernetes";
2
2
  export { getPlatformOutputs, parsePlatformOutputs, platformOutputsSchema, readPlatformOutputs, stateBucket, type PlatformOutputs, type PlatformOutputsArgs, } from "./outputs";
3
+ export { ScheduledJob, type ScheduledJobArgs } from "./scheduled-job";
3
4
  export { enableService } from "./services";
4
5
  export { WebService, type WebServiceArgs } from "./web-service";
6
+ export { Workload, type PodArgs, type WorkloadArgs, type WorkloadVolume } from "./workload";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WebService = exports.enableService = exports.stateBucket = exports.readPlatformOutputs = exports.platformOutputsSchema = exports.parsePlatformOutputs = exports.getPlatformOutputs = exports.clusterProvider = void 0;
3
+ exports.Workload = exports.WebService = exports.enableService = exports.ScheduledJob = exports.stateBucket = exports.readPlatformOutputs = exports.platformOutputsSchema = exports.parsePlatformOutputs = exports.getPlatformOutputs = exports.clusterProvider = void 0;
4
4
  var kubernetes_1 = require("./kubernetes");
5
5
  Object.defineProperty(exports, "clusterProvider", { enumerable: true, get: function () { return kubernetes_1.clusterProvider; } });
6
6
  var outputs_1 = require("./outputs");
@@ -9,7 +9,11 @@ Object.defineProperty(exports, "parsePlatformOutputs", { enumerable: true, get:
9
9
  Object.defineProperty(exports, "platformOutputsSchema", { enumerable: true, get: function () { return outputs_1.platformOutputsSchema; } });
10
10
  Object.defineProperty(exports, "readPlatformOutputs", { enumerable: true, get: function () { return outputs_1.readPlatformOutputs; } });
11
11
  Object.defineProperty(exports, "stateBucket", { enumerable: true, get: function () { return outputs_1.stateBucket; } });
12
+ var scheduled_job_1 = require("./scheduled-job");
13
+ Object.defineProperty(exports, "ScheduledJob", { enumerable: true, get: function () { return scheduled_job_1.ScheduledJob; } });
12
14
  var services_1 = require("./services");
13
15
  Object.defineProperty(exports, "enableService", { enumerable: true, get: function () { return services_1.enableService; } });
14
16
  var web_service_1 = require("./web-service");
15
17
  Object.defineProperty(exports, "WebService", { enumerable: true, get: function () { return web_service_1.WebService; } });
18
+ var workload_1 = require("./workload");
19
+ Object.defineProperty(exports, "Workload", { enumerable: true, get: function () { return workload_1.Workload; } });
package/dist/outputs.d.ts CHANGED
@@ -13,6 +13,8 @@ export declare const platformOutputsSchema: z.ZodObject<{
13
13
  }, z.core.$strip>;
14
14
  domain: z.ZodString;
15
15
  sqlConnectionName: z.ZodString;
16
+ sqlHost: z.ZodString;
17
+ gatewayAddress: z.ZodString;
16
18
  registry: z.ZodString;
17
19
  }, z.core.$strip>;
18
20
  /** One environment's platform values: where its cluster, Gateway, database, and registry are. */
package/dist/outputs.js CHANGED
@@ -53,6 +53,10 @@ exports.platformOutputsSchema = zod_1.z.object({
53
53
  gateway: zod_1.z.object({ name: zod_1.z.string().min(1), namespace: zod_1.z.string().min(1) }),
54
54
  domain: zod_1.z.string().min(1),
55
55
  sqlConnectionName: zod_1.z.string().min(1),
56
+ /** The environment's Cloud SQL private IP, which pods reach directly. */
57
+ sqlHost: zod_1.z.string().min(1),
58
+ /** The shared Gateway's IPv4 address, for an app's own DNS records. */
59
+ gatewayAddress: zod_1.z.string().min(1),
56
60
  registry: zod_1.z.string().min(1),
57
61
  });
58
62
  exports.outputsObject = "platform/outputs.json";
@@ -0,0 +1,22 @@
1
+ import * as pulumi from "@pulumi/pulumi";
2
+ import { type PodArgs } from "./workload";
3
+ export interface ScheduledJobArgs extends PodArgs {
4
+ /** A cron schedule, such as `0 * * * *`. */
5
+ schedule: string;
6
+ /** The schedule's time zone. Defaults to `America/Los_Angeles`. */
7
+ timeZone?: string;
8
+ /** How long one run may take, in seconds. Defaults to 3600. */
9
+ activeDeadlineSeconds?: number;
10
+ /** How many times a failed run retries. Defaults to 0, so the next scheduled run is the retry. */
11
+ backoffLimit?: number;
12
+ }
13
+ /**
14
+ * A CronJob with the platform's secure defaults, and a Kubernetes ServiceAccount, both named `name`.
15
+ * A run never overlaps the one before it. Its pods get no IAM; grant roles to `member`.
16
+ */
17
+ export declare class ScheduledJob extends pulumi.ComponentResource {
18
+ /** The pods' Workload Identity principal, for IAM grants. */
19
+ readonly member: pulumi.Output<string>;
20
+ readonly serviceAccountName: pulumi.Output<string>;
21
+ constructor(name: string, args: ScheduledJobArgs, opts?: pulumi.ComponentResourceOptions);
22
+ }
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ScheduledJob = void 0;
37
+ const k8s = __importStar(require("@pulumi/kubernetes"));
38
+ const pulumi = __importStar(require("@pulumi/pulumi"));
39
+ const workload_1 = require("./workload");
40
+ /**
41
+ * A CronJob with the platform's secure defaults, and a Kubernetes ServiceAccount, both named `name`.
42
+ * A run never overlaps the one before it. Its pods get no IAM; grant roles to `member`.
43
+ */
44
+ class ScheduledJob extends pulumi.ComponentResource {
45
+ /** The pods' Workload Identity principal, for IAM grants. */
46
+ member;
47
+ serviceAccountName;
48
+ constructor(name, args, opts) {
49
+ super("cyc-seattle:platform:ScheduledJob", name, {}, opts);
50
+ (0, workload_1.checkRequests)("ScheduledJob", name, args.requests);
51
+ const platform = pulumi.output(args.platform);
52
+ const labels = { "app.kubernetes.io/name": name };
53
+ const child = { parent: this, provider: args.provider };
54
+ const account = (0, workload_1.serviceAccount)(name, args, child);
55
+ new k8s.batch.v1.CronJob(name, {
56
+ metadata: { name, namespace: platform.namespace, labels },
57
+ spec: {
58
+ schedule: args.schedule,
59
+ timeZone: args.timeZone ?? "America/Los_Angeles",
60
+ concurrencyPolicy: "Forbid",
61
+ jobTemplate: {
62
+ metadata: { labels },
63
+ spec: {
64
+ activeDeadlineSeconds: args.activeDeadlineSeconds ?? 3600,
65
+ backoffLimit: args.backoffLimit ?? 0,
66
+ template: {
67
+ metadata: { labels },
68
+ spec: (0, workload_1.podSpec)(name, args, account.metadata.name).apply((spec) => ({
69
+ ...spec,
70
+ restartPolicy: "Never",
71
+ })),
72
+ },
73
+ },
74
+ },
75
+ },
76
+ }, child);
77
+ this.serviceAccountName = account.metadata.name;
78
+ this.member = (0, workload_1.workloadMember)(platform, this.serviceAccountName);
79
+ this.registerOutputs({ member: this.member, serviceAccountName: this.serviceAccountName });
80
+ }
81
+ }
82
+ exports.ScheduledJob = ScheduledJob;
@@ -1,40 +1,30 @@
1
1
  import * as k8s from "@pulumi/kubernetes";
2
2
  import * as pulumi from "@pulumi/pulumi";
3
- import type { PlatformOutputs } from "./outputs";
3
+ import { type WorkloadArgs } from "./workload";
4
4
  type Probe = k8s.types.input.core.v1.Probe;
5
- export interface WebServiceArgs {
6
- /** The environment's platform values, from `getPlatformOutputs`. */
7
- platform: pulumi.Input<PlatformOutputs>;
8
- /** The environment's cluster, from `clusterProvider`. Required, so it never uses a local kubeconfig. */
9
- provider: k8s.Provider;
5
+ export interface WebServiceArgs extends WorkloadArgs {
10
6
  /** The public hostname. It must be in the environment's domain. */
11
7
  hostname: string;
12
- image: pulumi.Input<string>;
13
- /** The port the container serves HTTP on. */
8
+ /** The port the container serves HTTP on. The Service exposes it as port 80, named `http`. */
14
9
  port: number;
15
- requests: {
16
- cpu: pulumi.Input<string>;
17
- memory: pulumi.Input<string>;
18
- };
19
10
  /** An HTTP GET probe. The load balancer's health check uses the same path. */
20
11
  readinessProbe: Probe & {
21
12
  httpGet: pulumi.Input<k8s.types.input.core.v1.HTTPGetAction>;
22
13
  };
23
- livenessProbe?: Probe;
24
- env?: pulumi.Input<k8s.types.input.core.v1.EnvVar>[];
25
- replicas?: number;
26
- /** Needed when the image's user is a name, which `runAsNonRoot` can't check. */
27
- runAsUser?: number;
28
14
  }
29
15
  /** Returns `hostname` if it is the environment's domain or a name under it, and throws otherwise. */
30
16
  export declare function checkHostname(hostname: string, domain: string): string;
31
17
  /**
32
- * An HTTP service behind the environment's shared Gateway: a Deployment, a Service, an HTTPRoute, a
33
- * Kubernetes ServiceAccount, and a HealthCheckPolicy. Its pods get no IAM; grant roles to `member`.
18
+ * An HTTP service behind the environment's shared Gateway: a `Workload`'s Deployment, Service, and
19
+ * Kubernetes ServiceAccount, plus an HTTPRoute and a HealthCheckPolicy. Its pods get no IAM; grant
20
+ * roles to `member`.
34
21
  */
35
22
  export declare class WebService extends pulumi.ComponentResource {
36
23
  /** The pods' Workload Identity principal, for IAM grants. */
37
24
  readonly member: pulumi.Output<string>;
25
+ readonly serviceAccountName: pulumi.Output<string>;
26
+ /** The name other pods in the namespace reach its Service by, on port 80. */
27
+ readonly host: string;
38
28
  readonly url: pulumi.Output<string>;
39
29
  constructor(name: string, args: WebServiceArgs, opts?: pulumi.ComponentResourceOptions);
40
30
  }
@@ -37,6 +37,7 @@ exports.WebService = void 0;
37
37
  exports.checkHostname = checkHostname;
38
38
  const k8s = __importStar(require("@pulumi/kubernetes"));
39
39
  const pulumi = __importStar(require("@pulumi/pulumi"));
40
+ const workload_1 = require("./workload");
40
41
  /** Returns `hostname` if it is the environment's domain or a name under it, and throws otherwise. */
41
42
  function checkHostname(hostname, domain) {
42
43
  const host = hostname.toLowerCase();
@@ -47,76 +48,29 @@ function checkHostname(hostname, domain) {
47
48
  return hostname;
48
49
  }
49
50
  /**
50
- * An HTTP service behind the environment's shared Gateway: a Deployment, a Service, an HTTPRoute, a
51
- * Kubernetes ServiceAccount, and a HealthCheckPolicy. Its pods get no IAM; grant roles to `member`.
51
+ * An HTTP service behind the environment's shared Gateway: a `Workload`'s Deployment, Service, and
52
+ * Kubernetes ServiceAccount, plus an HTTPRoute and a HealthCheckPolicy. Its pods get no IAM; grant
53
+ * roles to `member`.
52
54
  */
53
55
  class WebService extends pulumi.ComponentResource {
54
56
  /** The pods' Workload Identity principal, for IAM grants. */
55
57
  member;
58
+ serviceAccountName;
59
+ /** The name other pods in the namespace reach its Service by, on port 80. */
60
+ host;
56
61
  url;
57
62
  constructor(name, args, opts) {
58
63
  super("cyc-seattle:platform:WebService", name, {}, opts);
59
- // The types already require these, but a JavaScript caller or a cast can skip them.
60
- if (!args.requests?.cpu || !args.requests?.memory) {
61
- throw new Error(`WebService "${name}" needs CPU and memory requests.`);
62
- }
64
+ (0, workload_1.checkRequests)("WebService", name, args.requests);
63
65
  if (!args.readinessProbe?.httpGet) {
64
66
  throw new Error(`WebService "${name}" needs an HTTP readiness probe.`);
65
67
  }
66
68
  const platform = pulumi.output(args.platform);
67
- const namespace = platform.namespace;
68
69
  // A bad hostname fails the HTTPRoute's registration, so preview fails before anything applies.
69
70
  const hostname = platform.domain.apply((domain) => checkHostname(args.hostname, domain));
70
- // The namespace is the environment's name.
71
- const spot = namespace.apply((ns) => ns === "dev");
72
- const labels = { "app.kubernetes.io/name": name };
73
- const metadata = { name, namespace, labels };
71
+ const metadata = { name, namespace: platform.namespace, labels: { "app.kubernetes.io/name": name } };
74
72
  const child = { parent: this, provider: args.provider };
75
- const serviceAccount = new k8s.core.v1.ServiceAccount(name, { metadata, automountServiceAccountToken: false }, child);
76
- new k8s.apps.v1.Deployment(name, {
77
- metadata,
78
- spec: {
79
- replicas: args.replicas ?? 1,
80
- selector: { matchLabels: labels },
81
- template: {
82
- metadata: { labels },
83
- spec: {
84
- serviceAccountName: serviceAccount.metadata.name,
85
- automountServiceAccountToken: false,
86
- // Autopilot schedules a pod with this selector on Spot capacity.
87
- nodeSelector: spot.apply((s) => (s ? { "cloud.google.com/gke-spot": "true" } : {})),
88
- securityContext: {
89
- runAsNonRoot: true,
90
- seccompProfile: { type: "RuntimeDefault" },
91
- ...(args.runAsUser !== undefined && { runAsUser: args.runAsUser, runAsGroup: args.runAsUser }),
92
- },
93
- containers: [
94
- {
95
- name,
96
- image: args.image,
97
- ports: [{ name: "http", containerPort: args.port }],
98
- resources: { requests: args.requests },
99
- readinessProbe: args.readinessProbe,
100
- ...(args.livenessProbe && { livenessProbe: args.livenessProbe }),
101
- ...(args.env && { env: args.env }),
102
- securityContext: {
103
- allowPrivilegeEscalation: false,
104
- readOnlyRootFilesystem: true,
105
- capabilities: { drop: ["ALL"] },
106
- },
107
- },
108
- ],
109
- },
110
- },
111
- },
112
- }, child);
113
- const service = new k8s.core.v1.Service(name, {
114
- metadata,
115
- spec: {
116
- selector: labels,
117
- ports: [{ name: "http", port: 80, targetPort: "http" }],
118
- },
119
- }, child);
73
+ const { serviceAccount, service } = (0, workload_1.deployment)(this, name, args, args.port);
120
74
  new k8s.apiextensions.CustomResource(name, {
121
75
  apiVersion: "gateway.networking.k8s.io/v1",
122
76
  kind: "HTTPRoute",
@@ -145,9 +99,16 @@ class WebService extends pulumi.ComponentResource {
145
99
  targetRef: { group: "", kind: "Service", name: service.metadata.name },
146
100
  },
147
101
  }, child);
148
- this.member = pulumi.interpolate `principal://iam.googleapis.com/projects/${platform.projectNumber}/locations/global/workloadIdentityPools/${platform.project}.svc.id.goog/subject/ns/${namespace}/sa/${serviceAccount.metadata.name}`;
102
+ this.serviceAccountName = serviceAccount.metadata.name;
103
+ this.member = (0, workload_1.workloadMember)(platform, this.serviceAccountName);
104
+ this.host = name;
149
105
  this.url = pulumi.interpolate `https://${hostname}/`;
150
- this.registerOutputs({ member: this.member, url: this.url });
106
+ this.registerOutputs({
107
+ member: this.member,
108
+ serviceAccountName: this.serviceAccountName,
109
+ host: this.host,
110
+ url: this.url,
111
+ });
151
112
  }
152
113
  }
153
114
  exports.WebService = WebService;
@@ -0,0 +1,83 @@
1
+ import * as k8s from "@pulumi/kubernetes";
2
+ import * as pulumi from "@pulumi/pulumi";
3
+ import type { PlatformOutputs } from "./outputs";
4
+ type Probe = k8s.types.input.core.v1.Probe;
5
+ type EnvVar = k8s.types.input.core.v1.EnvVar;
6
+ type EnvFromSource = k8s.types.input.core.v1.EnvFromSource;
7
+ /** A volume and where the container mounts it. Give exactly one of `emptyDir`, `configMap`, or `secret`. */
8
+ export interface WorkloadVolume {
9
+ name: string;
10
+ mountPath: string;
11
+ emptyDir?: {
12
+ sizeLimit?: string;
13
+ };
14
+ /** A ConfigMap's name. */
15
+ configMap?: pulumi.Input<string>;
16
+ /** A Secret's name. */
17
+ secret?: pulumi.Input<string>;
18
+ subPath?: string;
19
+ readOnly?: boolean;
20
+ }
21
+ /** What every pod of `Workload`, `WebService`, and `ScheduledJob` takes. */
22
+ export interface PodArgs {
23
+ /** The environment's platform values, from `getPlatformOutputs`. */
24
+ platform: pulumi.Input<PlatformOutputs>;
25
+ /** The environment's cluster, from `clusterProvider`. Required, so it never uses a local kubeconfig. */
26
+ provider: k8s.Provider;
27
+ image: pulumi.Input<string>;
28
+ command?: string[];
29
+ args?: string[];
30
+ /** The container's requests, which are also its limits. */
31
+ requests: {
32
+ cpu: pulumi.Input<string>;
33
+ memory: pulumi.Input<string>;
34
+ };
35
+ env?: pulumi.Input<EnvVar>[];
36
+ envFrom?: pulumi.Input<EnvFromSource>[];
37
+ volumes?: WorkloadVolume[];
38
+ /** Needed when the image's user is a name, which `runAsNonRoot` can't check. Also sets the group. */
39
+ runAsUser?: number;
40
+ /** Annotations on the Kubernetes ServiceAccount, such as `iam.gke.io/gcp-service-account`. */
41
+ serviceAccountAnnotations?: Record<string, string>;
42
+ }
43
+ export interface WorkloadArgs extends PodArgs {
44
+ /** The container's ports. The Service exposes each at the same number. Without any, there's no Service. */
45
+ ports?: {
46
+ name: string;
47
+ port: number;
48
+ }[];
49
+ readinessProbe?: Probe;
50
+ livenessProbe?: Probe;
51
+ startupProbe?: Probe;
52
+ /** Defaults to 1. */
53
+ replicas?: number;
54
+ }
55
+ /** The pods' Workload Identity principal, for IAM grants. */
56
+ export declare function workloadMember(platform: pulumi.Output<PlatformOutputs>, serviceAccountName: pulumi.Input<string>): pulumi.Output<string>;
57
+ export declare function checkRequests(kind: string, name: string, requests: PodArgs["requests"] | undefined): void;
58
+ /** A ServiceAccount named `name` that mounts no token. */
59
+ export declare function serviceAccount(name: string, args: PodArgs, opts: pulumi.CustomResourceOptions): k8s.core.v1.ServiceAccount;
60
+ /** A pod spec with the platform's secure defaults: non-root, read-only, no token, and Spot in dev only. */
61
+ export declare function podSpec(name: string, args: PodArgs, serviceAccountName: pulumi.Input<string>, container?: Partial<k8s.types.input.core.v1.Container>): pulumi.Output<k8s.types.input.core.v1.PodSpec>;
62
+ export interface DeploymentParts {
63
+ serviceAccount: k8s.core.v1.ServiceAccount;
64
+ service?: k8s.core.v1.Service;
65
+ }
66
+ /**
67
+ * The Deployment, ServiceAccount, and Service of a `Workload` or a `WebService`, as children of
68
+ * `parent`. `http` is a `WebService`'s port, which its Service exposes as port 80.
69
+ */
70
+ export declare function deployment(parent: pulumi.Resource, name: string, args: WorkloadArgs, http?: number): DeploymentParts;
71
+ /**
72
+ * A long-running process with no route: a Deployment, a Kubernetes ServiceAccount, and, when it has
73
+ * ports, a ClusterIP Service, all named `name`. Its pods get no IAM; grant roles to `member`.
74
+ */
75
+ export declare class Workload extends pulumi.ComponentResource {
76
+ /** The pods' Workload Identity principal, for IAM grants. */
77
+ readonly member: pulumi.Output<string>;
78
+ readonly serviceAccountName: pulumi.Output<string>;
79
+ /** The name other pods in the namespace reach its Service by. */
80
+ readonly host: string;
81
+ constructor(name: string, args: WorkloadArgs, opts?: pulumi.ComponentResourceOptions);
82
+ }
83
+ export {};
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Workload = void 0;
37
+ exports.workloadMember = workloadMember;
38
+ exports.checkRequests = checkRequests;
39
+ exports.serviceAccount = serviceAccount;
40
+ exports.podSpec = podSpec;
41
+ exports.deployment = deployment;
42
+ const k8s = __importStar(require("@pulumi/kubernetes"));
43
+ const pulumi = __importStar(require("@pulumi/pulumi"));
44
+ /** The pods' Workload Identity principal, for IAM grants. */
45
+ function workloadMember(platform, serviceAccountName) {
46
+ return pulumi.interpolate `principal://iam.googleapis.com/projects/${platform.projectNumber}/locations/global/workloadIdentityPools/${platform.project}.svc.id.goog/subject/ns/${platform.namespace}/sa/${serviceAccountName}`;
47
+ }
48
+ function checkRequests(kind, name, requests) {
49
+ // The types already require them, but a JavaScript caller or a cast can skip them.
50
+ if (!requests?.cpu || !requests?.memory) {
51
+ throw new Error(`${kind} "${name}" needs CPU and memory requests.`);
52
+ }
53
+ }
54
+ /** A ServiceAccount named `name` that mounts no token. */
55
+ function serviceAccount(name, args, opts) {
56
+ const platform = pulumi.output(args.platform);
57
+ return new k8s.core.v1.ServiceAccount(name, {
58
+ metadata: {
59
+ name,
60
+ namespace: platform.namespace,
61
+ labels: { "app.kubernetes.io/name": name },
62
+ ...(args.serviceAccountAnnotations && { annotations: args.serviceAccountAnnotations }),
63
+ },
64
+ automountServiceAccountToken: false,
65
+ }, opts);
66
+ }
67
+ /** A pod spec with the platform's secure defaults: non-root, read-only, no token, and Spot in dev only. */
68
+ function podSpec(name, args, serviceAccountName, container = {}) {
69
+ const volumes = args.volumes ?? [];
70
+ // The namespace is the environment's name.
71
+ return pulumi.output(args.platform).namespace.apply((namespace) => ({
72
+ serviceAccountName,
73
+ automountServiceAccountToken: false,
74
+ // Autopilot schedules a pod with this selector on Spot capacity.
75
+ nodeSelector: namespace === "dev" ? { "cloud.google.com/gke-spot": "true" } : {},
76
+ securityContext: {
77
+ runAsNonRoot: true,
78
+ seccompProfile: { type: "RuntimeDefault" },
79
+ ...(args.runAsUser !== undefined && { runAsUser: args.runAsUser, runAsGroup: args.runAsUser }),
80
+ },
81
+ ...(volumes.length > 0 && {
82
+ volumes: volumes.map((v) => ({
83
+ name: v.name,
84
+ ...(v.emptyDir && { emptyDir: v.emptyDir }),
85
+ ...(v.configMap !== undefined && { configMap: { name: v.configMap } }),
86
+ ...(v.secret !== undefined && { secret: { secretName: v.secret } }),
87
+ })),
88
+ }),
89
+ containers: [
90
+ {
91
+ name,
92
+ image: args.image,
93
+ ...(args.command && { command: args.command }),
94
+ ...(args.args && { args: args.args }),
95
+ ...container,
96
+ // A LimitRange's default limit could otherwise sit below the request, which the API refuses.
97
+ resources: { requests: args.requests, limits: args.requests },
98
+ ...(args.env && { env: args.env }),
99
+ ...(args.envFrom && { envFrom: args.envFrom }),
100
+ ...(volumes.length > 0 && {
101
+ volumeMounts: volumes.map((v) => ({
102
+ name: v.name,
103
+ mountPath: v.mountPath,
104
+ ...(v.subPath !== undefined && { subPath: v.subPath }),
105
+ ...(v.readOnly !== undefined && { readOnly: v.readOnly }),
106
+ })),
107
+ }),
108
+ securityContext: {
109
+ allowPrivilegeEscalation: false,
110
+ readOnlyRootFilesystem: true,
111
+ capabilities: { drop: ["ALL"] },
112
+ },
113
+ },
114
+ ],
115
+ }));
116
+ }
117
+ /**
118
+ * The Deployment, ServiceAccount, and Service of a `Workload` or a `WebService`, as children of
119
+ * `parent`. `http` is a `WebService`'s port, which its Service exposes as port 80.
120
+ */
121
+ function deployment(parent, name, args, http) {
122
+ const platform = pulumi.output(args.platform);
123
+ const labels = { "app.kubernetes.io/name": name };
124
+ const metadata = { name, namespace: platform.namespace, labels };
125
+ const child = { parent, provider: args.provider };
126
+ const account = serviceAccount(name, args, child);
127
+ const ports = [...(http !== undefined ? [{ name: "http", port: http }] : []), ...(args.ports ?? [])];
128
+ new k8s.apps.v1.Deployment(name, {
129
+ metadata,
130
+ spec: {
131
+ replicas: args.replicas ?? 1,
132
+ selector: { matchLabels: labels },
133
+ template: {
134
+ metadata: { labels },
135
+ spec: podSpec(name, args, account.metadata.name, {
136
+ ...(ports.length > 0 && { ports: ports.map((p) => ({ name: p.name, containerPort: p.port })) }),
137
+ ...(args.readinessProbe && { readinessProbe: args.readinessProbe }),
138
+ ...(args.livenessProbe && { livenessProbe: args.livenessProbe }),
139
+ ...(args.startupProbe && { startupProbe: args.startupProbe }),
140
+ }),
141
+ },
142
+ },
143
+ }, child);
144
+ if (ports.length === 0) {
145
+ return { serviceAccount: account };
146
+ }
147
+ const service = new k8s.core.v1.Service(name, {
148
+ metadata,
149
+ spec: {
150
+ selector: labels,
151
+ ports: ports.map((p) => ({
152
+ name: p.name,
153
+ port: p.name === "http" && http !== undefined ? 80 : p.port,
154
+ targetPort: p.name,
155
+ })),
156
+ },
157
+ }, child);
158
+ return { serviceAccount: account, service };
159
+ }
160
+ /**
161
+ * A long-running process with no route: a Deployment, a Kubernetes ServiceAccount, and, when it has
162
+ * ports, a ClusterIP Service, all named `name`. Its pods get no IAM; grant roles to `member`.
163
+ */
164
+ class Workload extends pulumi.ComponentResource {
165
+ /** The pods' Workload Identity principal, for IAM grants. */
166
+ member;
167
+ serviceAccountName;
168
+ /** The name other pods in the namespace reach its Service by. */
169
+ host;
170
+ constructor(name, args, opts) {
171
+ super("cyc-seattle:platform:Workload", name, {}, opts);
172
+ checkRequests("Workload", name, args.requests);
173
+ const { serviceAccount } = deployment(this, name, args);
174
+ this.serviceAccountName = serviceAccount.metadata.name;
175
+ this.member = workloadMember(pulumi.output(args.platform), this.serviceAccountName);
176
+ this.host = name;
177
+ this.registerOutputs({ member: this.member, serviceAccountName: this.serviceAccountName, host: this.host });
178
+ }
179
+ }
180
+ exports.Workload = Workload;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",