@microagi/alchemy-gcp 0.1.2
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/LICENSE +201 -0
- package/README.md +88 -0
- package/lib/Auth/AuthProvider.d.ts +48 -0
- package/lib/Auth/AuthProvider.d.ts.map +1 -0
- package/lib/Auth/AuthProvider.js +84 -0
- package/lib/Auth/AuthProvider.js.map +1 -0
- package/lib/Auth/Credentials.d.ts +28 -0
- package/lib/Auth/Credentials.d.ts.map +1 -0
- package/lib/Auth/Credentials.js +41 -0
- package/lib/Auth/Credentials.js.map +1 -0
- package/lib/CloudResourceManager/Project.d.ts +143 -0
- package/lib/CloudResourceManager/Project.d.ts.map +1 -0
- package/lib/CloudResourceManager/Project.js +272 -0
- package/lib/CloudResourceManager/Project.js.map +1 -0
- package/lib/CloudResourceManager/index.d.ts +3 -0
- package/lib/CloudResourceManager/index.d.ts.map +1 -0
- package/lib/CloudResourceManager/index.js +2 -0
- package/lib/CloudResourceManager/index.js.map +1 -0
- package/lib/Compute/GlobalAddress.d.ts +101 -0
- package/lib/Compute/GlobalAddress.d.ts.map +1 -0
- package/lib/Compute/GlobalAddress.js +134 -0
- package/lib/Compute/GlobalAddress.js.map +1 -0
- package/lib/Compute/Network.d.ts +114 -0
- package/lib/Compute/Network.d.ts.map +1 -0
- package/lib/Compute/Network.js +145 -0
- package/lib/Compute/Network.js.map +1 -0
- package/lib/Compute/Operations.d.ts +34 -0
- package/lib/Compute/Operations.d.ts.map +1 -0
- package/lib/Compute/Operations.js +62 -0
- package/lib/Compute/Operations.js.map +1 -0
- package/lib/Compute/SharedVpcHost.d.ts +44 -0
- package/lib/Compute/SharedVpcHost.d.ts.map +1 -0
- package/lib/Compute/SharedVpcHost.js +71 -0
- package/lib/Compute/SharedVpcHost.js.map +1 -0
- package/lib/Compute/SharedVpcServiceProject.d.ts +46 -0
- package/lib/Compute/SharedVpcServiceProject.d.ts.map +1 -0
- package/lib/Compute/SharedVpcServiceProject.js +96 -0
- package/lib/Compute/SharedVpcServiceProject.js.map +1 -0
- package/lib/Compute/Subnetwork.d.ts +108 -0
- package/lib/Compute/Subnetwork.d.ts.map +1 -0
- package/lib/Compute/Subnetwork.js +201 -0
- package/lib/Compute/Subnetwork.js.map +1 -0
- package/lib/Compute/index.d.ts +6 -0
- package/lib/Compute/index.d.ts.map +1 -0
- package/lib/Compute/index.js +6 -0
- package/lib/Compute/index.js.map +1 -0
- package/lib/Container/Cluster.d.ts +216 -0
- package/lib/Container/Cluster.d.ts.map +1 -0
- package/lib/Container/Cluster.js +326 -0
- package/lib/Container/Cluster.js.map +1 -0
- package/lib/Container/NodePool.d.ts +214 -0
- package/lib/Container/NodePool.d.ts.map +1 -0
- package/lib/Container/NodePool.js +344 -0
- package/lib/Container/NodePool.js.map +1 -0
- package/lib/Container/Operations.d.ts +50 -0
- package/lib/Container/Operations.d.ts.map +1 -0
- package/lib/Container/Operations.js +51 -0
- package/lib/Container/Operations.js.map +1 -0
- package/lib/Container/index.d.ts +5 -0
- package/lib/Container/index.d.ts.map +1 -0
- package/lib/Container/index.js +3 -0
- package/lib/Container/index.js.map +1 -0
- package/lib/Parallelstore/Instance.d.ts +119 -0
- package/lib/Parallelstore/Instance.d.ts.map +1 -0
- package/lib/Parallelstore/Instance.js +180 -0
- package/lib/Parallelstore/Instance.js.map +1 -0
- package/lib/Parallelstore/index.d.ts +2 -0
- package/lib/Parallelstore/index.d.ts.map +1 -0
- package/lib/Parallelstore/index.js +2 -0
- package/lib/Parallelstore/index.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +51 -0
- package/lib/Providers.js.map +1 -0
- package/lib/ServiceNetworking/PsaConnection.d.ts +84 -0
- package/lib/ServiceNetworking/PsaConnection.d.ts.map +1 -0
- package/lib/ServiceNetworking/PsaConnection.js +130 -0
- package/lib/ServiceNetworking/PsaConnection.js.map +1 -0
- package/lib/ServiceNetworking/index.d.ts +2 -0
- package/lib/ServiceNetworking/index.d.ts.map +1 -0
- package/lib/ServiceNetworking/index.js +2 -0
- package/lib/ServiceNetworking/index.js.map +1 -0
- package/lib/ServiceUsage/ApiEnable.d.ts +78 -0
- package/lib/ServiceUsage/ApiEnable.d.ts.map +1 -0
- package/lib/ServiceUsage/ApiEnable.js +132 -0
- package/lib/ServiceUsage/ApiEnable.js.map +1 -0
- package/lib/ServiceUsage/index.d.ts +3 -0
- package/lib/ServiceUsage/index.d.ts.map +1 -0
- package/lib/ServiceUsage/index.js +2 -0
- package/lib/ServiceUsage/index.js.map +1 -0
- package/lib/Tags.d.ts +57 -0
- package/lib/Tags.d.ts.map +1 -0
- package/lib/Tags.js +106 -0
- package/lib/Tags.js.map +1 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +10 -0
- package/lib/index.js.map +1 -0
- package/package.json +70 -0
- package/src/Auth/AuthProvider.ts +180 -0
- package/src/Auth/Credentials.ts +69 -0
- package/src/CloudResourceManager/Project.ts +517 -0
- package/src/CloudResourceManager/index.ts +2 -0
- package/src/Compute/GlobalAddress.ts +283 -0
- package/src/Compute/Network.ts +306 -0
- package/src/Compute/Operations.ts +108 -0
- package/src/Compute/SharedVpcHost.ts +141 -0
- package/src/Compute/SharedVpcServiceProject.ts +185 -0
- package/src/Compute/Subnetwork.ts +375 -0
- package/src/Compute/index.ts +5 -0
- package/src/Container/Cluster.ts +599 -0
- package/src/Container/NodePool.ts +620 -0
- package/src/Container/Operations.ts +88 -0
- package/src/Container/index.ts +8 -0
- package/src/Parallelstore/Instance.ts +371 -0
- package/src/Parallelstore/index.ts +1 -0
- package/src/Providers.ts +84 -0
- package/src/ServiceNetworking/PsaConnection.ts +269 -0
- package/src/ServiceNetworking/index.ts +1 -0
- package/src/ServiceUsage/ApiEnable.ts +267 -0
- package/src/ServiceUsage/index.ts +2 -0
- package/src/Tags.ts +130 -0
- package/src/index.ts +16 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as compute from "@distilled.cloud/gcp/compute-v1";
|
|
2
|
+
import { Resource } from "alchemy";
|
|
3
|
+
import { isResolved } from "alchemy/Diff";
|
|
4
|
+
import * as Provider from "alchemy/Provider";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import type * as GCP from "../Providers.ts";
|
|
7
|
+
import { makeAwaitGlobalOperation } from "./Operations.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mark a GCP project as a Shared VPC **host**. Service projects can
|
|
11
|
+
* then attach to this host via `GCP.SharedVpcServiceProject` and use
|
|
12
|
+
* its VPC for clusters, VMs, etc.
|
|
13
|
+
*
|
|
14
|
+
* Identity: `(project)` — there's at most one host-status flag per
|
|
15
|
+
* project, so the resource is keyed by project ID alone. State is
|
|
16
|
+
* binary (host or not host), so there's no ownership concept; `read`
|
|
17
|
+
* returns plain attrs whenever the project's
|
|
18
|
+
* `xpnProjectStatus === "HOST"`.
|
|
19
|
+
*
|
|
20
|
+
* The `compute.googleapis.com` API must be enabled on the project
|
|
21
|
+
* before this resource succeeds — wire `project` through an
|
|
22
|
+
* `ApiEnable("compute.googleapis.com")` output to make alchemy
|
|
23
|
+
* sequence this resource after the API enable.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const computeApi = yield* GCP.ApiEnable("HostComputeApi", {
|
|
28
|
+
* project: hostProject.projectId,
|
|
29
|
+
* service: "compute.googleapis.com",
|
|
30
|
+
* });
|
|
31
|
+
* const host = yield* GCP.SharedVpcHost("ResearchHost", {
|
|
32
|
+
* project: computeApi.project,
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export type SharedVpcHostProps = {
|
|
37
|
+
/** GCP project ID to mark as a Shared VPC host. Immutable — replace. */
|
|
38
|
+
project: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type SharedVpcHostAttributes = {
|
|
42
|
+
/** Project ID. */
|
|
43
|
+
project: string;
|
|
44
|
+
/** `"HOST"` once the enable completes. */
|
|
45
|
+
xpnProjectStatus: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type SharedVpcHost = Resource<
|
|
49
|
+
"GCP.SharedVpcHost",
|
|
50
|
+
SharedVpcHostProps,
|
|
51
|
+
SharedVpcHostAttributes,
|
|
52
|
+
never,
|
|
53
|
+
GCP.Providers
|
|
54
|
+
>;
|
|
55
|
+
export const SharedVpcHost = Resource<SharedVpcHost>("GCP.SharedVpcHost");
|
|
56
|
+
|
|
57
|
+
export const SharedVpcHostProvider = () =>
|
|
58
|
+
Provider.effect(
|
|
59
|
+
SharedVpcHost,
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
const getProjects = yield* compute.getProjects;
|
|
62
|
+
const enableXpnHostProjects = yield* compute.enableXpnHostProjects;
|
|
63
|
+
const disableXpnHostProjects = yield* compute.disableXpnHostProjects;
|
|
64
|
+
const getGlobalOperations = yield* compute.getGlobalOperations;
|
|
65
|
+
const awaitOp = makeAwaitGlobalOperation(getGlobalOperations);
|
|
66
|
+
|
|
67
|
+
const observe = (project: string) =>
|
|
68
|
+
getProjects({ project }).pipe(
|
|
69
|
+
Effect.catchTag("NotFound", () =>
|
|
70
|
+
Effect.succeed(undefined as compute.Project | undefined),
|
|
71
|
+
),
|
|
72
|
+
Effect.catchTag("Forbidden", () =>
|
|
73
|
+
Effect.succeed(undefined as compute.Project | undefined),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
stables: ["project"],
|
|
79
|
+
diff: Effect.fn(function* ({ news, olds = {} }) {
|
|
80
|
+
if (!isResolved(news)) return undefined;
|
|
81
|
+
if (olds.project !== undefined && olds.project !== news.project) {
|
|
82
|
+
return { action: "replace" } as const;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}),
|
|
86
|
+
reconcile: Effect.fn(function* ({ news, session }) {
|
|
87
|
+
const observed = yield* observe(news.project);
|
|
88
|
+
if (observed?.xpnProjectStatus === "HOST") {
|
|
89
|
+
return { project: news.project, xpnProjectStatus: "HOST" };
|
|
90
|
+
}
|
|
91
|
+
// `enableXpnHost` is idempotent on the GCP side when the
|
|
92
|
+
// project is already a host (returns a no-op operation), but
|
|
93
|
+
// we still tolerate `Conflict` defensively to cover the
|
|
94
|
+
// narrow window where state-persistence races with another
|
|
95
|
+
// reconciler.
|
|
96
|
+
const op = yield* enableXpnHostProjects({
|
|
97
|
+
project: news.project,
|
|
98
|
+
}).pipe(
|
|
99
|
+
Effect.catchTag("Conflict", () =>
|
|
100
|
+
Effect.succeed(undefined as compute.Operation | undefined),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
if (op?.name) yield* awaitOp(news.project, op.name, session);
|
|
104
|
+
return { project: news.project, xpnProjectStatus: "HOST" };
|
|
105
|
+
}),
|
|
106
|
+
delete: Effect.fn(function* ({ output, session }) {
|
|
107
|
+
yield* disableXpnHostProjects({ project: output.project }).pipe(
|
|
108
|
+
Effect.flatMap((op) =>
|
|
109
|
+
op.name
|
|
110
|
+
? awaitOp(output.project, op.name, session)
|
|
111
|
+
: Effect.succeed(op),
|
|
112
|
+
),
|
|
113
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
114
|
+
// `BadRequest` is returned when:
|
|
115
|
+
// 1. The project isn't a host (idempotent disable — fine).
|
|
116
|
+
// 2. Service projects are still attached. The user has
|
|
117
|
+
// to detach them first; surface that error so they
|
|
118
|
+
// can act. Only swallow case 1.
|
|
119
|
+
Effect.catchTag("BadRequest", (e) =>
|
|
120
|
+
/not.*host|not.*xpn|UNSPECIFIED_XPN_PROJECT_STATUS/i.test(
|
|
121
|
+
e.message ?? "",
|
|
122
|
+
)
|
|
123
|
+
? Effect.void
|
|
124
|
+
: Effect.fail(e),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}),
|
|
128
|
+
read: Effect.fn(function* ({ output, olds }) {
|
|
129
|
+
const project = output?.project ?? olds?.project;
|
|
130
|
+
if (!project) return undefined;
|
|
131
|
+
const observed = yield* observe(project);
|
|
132
|
+
if (!observed) return undefined;
|
|
133
|
+
if (observed.xpnProjectStatus !== "HOST") return undefined;
|
|
134
|
+
// No labels-based ownership: host status is project-scoped
|
|
135
|
+
// state. Any host project we can see we treat as adopted —
|
|
136
|
+
// mirrors `ApiEnable`'s stance.
|
|
137
|
+
return { project, xpnProjectStatus: "HOST" };
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as compute from "@distilled.cloud/gcp/compute-v1";
|
|
2
|
+
import { Resource } from "alchemy";
|
|
3
|
+
import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
|
|
4
|
+
import { isResolved } from "alchemy/Diff";
|
|
5
|
+
import * as Provider from "alchemy/Provider";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import type * as GCP from "../Providers.ts";
|
|
8
|
+
import { makeAwaitGlobalOperation } from "./Operations.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attach a service project to a Shared VPC **host**. Once attached,
|
|
12
|
+
* resources in the service project (clusters, VMs) can use VPCs and
|
|
13
|
+
* subnets owned by the host project.
|
|
14
|
+
*
|
|
15
|
+
* Identity: `(hostProject, serviceProject)`. Both are immutable —
|
|
16
|
+
* changing either replaces. Read uses
|
|
17
|
+
* `compute.projects.getXpnHost({ project: serviceProject })`, which
|
|
18
|
+
* returns the host the service project is attached to (or 4xx if
|
|
19
|
+
* unattached). When the returned host name matches `hostProject`, the
|
|
20
|
+
* attachment is present.
|
|
21
|
+
*
|
|
22
|
+
* The host project must already be enabled (`GCP.SharedVpcHost`), and
|
|
23
|
+
* the IAM principal running the deploy needs `compute.xpnAdmin` at the
|
|
24
|
+
* organisation or folder level — that role is what authorises both
|
|
25
|
+
* sides of the attach (the host, owned by infra; the service project,
|
|
26
|
+
* owned by the team consuming the VPC).
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const host = yield* GCP.SharedVpcHost("ResearchHost", { project: hostProject.projectId });
|
|
31
|
+
* yield* GCP.SharedVpcServiceProject("AttachGkeProj", {
|
|
32
|
+
* hostProject: host.project,
|
|
33
|
+
* serviceProject: gkeProject.projectId,
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export type SharedVpcServiceProjectProps = {
|
|
38
|
+
/** Shared VPC host project ID. Immutable — replace. */
|
|
39
|
+
hostProject: string;
|
|
40
|
+
/** Service project ID to attach. Immutable — replace. */
|
|
41
|
+
serviceProject: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SharedVpcServiceProjectAttributes = {
|
|
45
|
+
/** Host project ID. */
|
|
46
|
+
hostProject: string;
|
|
47
|
+
/** Service project ID. */
|
|
48
|
+
serviceProject: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type SharedVpcServiceProject = Resource<
|
|
52
|
+
"GCP.SharedVpcServiceProject",
|
|
53
|
+
SharedVpcServiceProjectProps,
|
|
54
|
+
SharedVpcServiceProjectAttributes,
|
|
55
|
+
never,
|
|
56
|
+
GCP.Providers
|
|
57
|
+
>;
|
|
58
|
+
export const SharedVpcServiceProject = Resource<SharedVpcServiceProject>(
|
|
59
|
+
"GCP.SharedVpcServiceProject",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export const SharedVpcServiceProjectProvider = () =>
|
|
63
|
+
Provider.effect(
|
|
64
|
+
SharedVpcServiceProject,
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const getXpnHostProjects = yield* compute.getXpnHostProjects;
|
|
67
|
+
const enableXpnResource = yield* compute.enableXpnResourceProjects;
|
|
68
|
+
const disableXpnResource = yield* compute.disableXpnResourceProjects;
|
|
69
|
+
const getGlobalOperations = yield* compute.getGlobalOperations;
|
|
70
|
+
const awaitOp = makeAwaitGlobalOperation(getGlobalOperations);
|
|
71
|
+
|
|
72
|
+
// Observed-host name from a service project's perspective.
|
|
73
|
+
// Returns the host project's `name`/`projectId` — or undefined
|
|
74
|
+
// if the service project isn't attached anywhere. GCP returns
|
|
75
|
+
// 404 in that case; some SDK paths return a `Project` with
|
|
76
|
+
// empty fields instead, so we coalesce both.
|
|
77
|
+
const observeHost = (serviceProject: string) =>
|
|
78
|
+
getXpnHostProjects({ project: serviceProject }).pipe(
|
|
79
|
+
Effect.map((p) => p.name ?? p.id ?? undefined),
|
|
80
|
+
Effect.catchTag("NotFound", () =>
|
|
81
|
+
Effect.succeed(undefined as string | undefined),
|
|
82
|
+
),
|
|
83
|
+
Effect.catchTag("Forbidden", () =>
|
|
84
|
+
Effect.succeed(undefined as string | undefined),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const isAttached = Effect.fn(function* (
|
|
89
|
+
hostProject: string,
|
|
90
|
+
serviceProject: string,
|
|
91
|
+
) {
|
|
92
|
+
const observed = yield* observeHost(serviceProject);
|
|
93
|
+
if (!observed) return false;
|
|
94
|
+
// Host references can come back as the bare project ID, the
|
|
95
|
+
// numeric ID, or `projects/{id}` — match suffix to avoid
|
|
96
|
+
// missing on format drift.
|
|
97
|
+
return (
|
|
98
|
+
observed === hostProject ||
|
|
99
|
+
observed.endsWith(`/${hostProject}`) ||
|
|
100
|
+
observed.endsWith(`projects/${hostProject}`)
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const enable = Effect.fn(function* (args: {
|
|
105
|
+
hostProject: string;
|
|
106
|
+
serviceProject: string;
|
|
107
|
+
session: ScopedPlanStatusSession;
|
|
108
|
+
}) {
|
|
109
|
+
const op = yield* enableXpnResource({
|
|
110
|
+
project: args.hostProject,
|
|
111
|
+
body: {
|
|
112
|
+
xpnResource: { type: "PROJECT", id: args.serviceProject },
|
|
113
|
+
},
|
|
114
|
+
}).pipe(
|
|
115
|
+
Effect.catchTag("Conflict", () =>
|
|
116
|
+
Effect.succeed(undefined as compute.Operation | undefined),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
if (op?.name) yield* awaitOp(args.hostProject, op.name, args.session);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
stables: ["hostProject", "serviceProject"],
|
|
124
|
+
diff: Effect.fn(function* ({ news, olds = {} }) {
|
|
125
|
+
if (!isResolved(news)) return undefined;
|
|
126
|
+
if (
|
|
127
|
+
(olds.hostProject !== undefined &&
|
|
128
|
+
olds.hostProject !== news.hostProject) ||
|
|
129
|
+
(olds.serviceProject !== undefined &&
|
|
130
|
+
olds.serviceProject !== news.serviceProject)
|
|
131
|
+
) {
|
|
132
|
+
return { action: "replace" } as const;
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}),
|
|
136
|
+
reconcile: Effect.fn(function* ({ news, session }) {
|
|
137
|
+
const attached = yield* isAttached(news.hostProject, news.serviceProject);
|
|
138
|
+
if (!attached) {
|
|
139
|
+
yield* enable({
|
|
140
|
+
hostProject: news.hostProject,
|
|
141
|
+
serviceProject: news.serviceProject,
|
|
142
|
+
session,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
hostProject: news.hostProject,
|
|
147
|
+
serviceProject: news.serviceProject,
|
|
148
|
+
};
|
|
149
|
+
}),
|
|
150
|
+
delete: Effect.fn(function* ({ output, session }) {
|
|
151
|
+
yield* disableXpnResource({
|
|
152
|
+
project: output.hostProject,
|
|
153
|
+
body: {
|
|
154
|
+
xpnResource: { type: "PROJECT", id: output.serviceProject },
|
|
155
|
+
},
|
|
156
|
+
}).pipe(
|
|
157
|
+
Effect.flatMap((op) =>
|
|
158
|
+
op.name
|
|
159
|
+
? awaitOp(output.hostProject, op.name, session)
|
|
160
|
+
: Effect.succeed(op),
|
|
161
|
+
),
|
|
162
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
163
|
+
// `BadRequest` covers "not attached to this host" (idempotent
|
|
164
|
+
// detach); other 400s (resources still using shared subnets)
|
|
165
|
+
// propagate so the user sees the real precondition.
|
|
166
|
+
Effect.catchTag("BadRequest", (e) =>
|
|
167
|
+
/not.*service.*project|not.*xpn|UNSPECIFIED_XPN_PROJECT_STATUS|not.*attached/i.test(
|
|
168
|
+
e.message ?? "",
|
|
169
|
+
)
|
|
170
|
+
? Effect.void
|
|
171
|
+
: Effect.fail(e),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
}),
|
|
175
|
+
read: Effect.fn(function* ({ output, olds }) {
|
|
176
|
+
const hostProject = output?.hostProject ?? olds?.hostProject;
|
|
177
|
+
const serviceProject = output?.serviceProject ?? olds?.serviceProject;
|
|
178
|
+
if (!hostProject || !serviceProject) return undefined;
|
|
179
|
+
const attached = yield* isAttached(hostProject, serviceProject);
|
|
180
|
+
if (!attached) return undefined;
|
|
181
|
+
return { hostProject, serviceProject };
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import * as compute from "@distilled.cloud/gcp/compute-v1";
|
|
2
|
+
import { Resource } from "alchemy";
|
|
3
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
4
|
+
import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
|
|
5
|
+
import { deepEqual, isResolved, somePropsAreDifferent } from "alchemy/Diff";
|
|
6
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
7
|
+
import * as Provider from "alchemy/Provider";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import {
|
|
10
|
+
descriptionHasAlchemyMarker,
|
|
11
|
+
gcpAlchemyDescription,
|
|
12
|
+
stripAlchemyMarker,
|
|
13
|
+
} from "../Tags.ts";
|
|
14
|
+
import type * as GCP from "../Providers.ts";
|
|
15
|
+
import { makeAwaitRegionOperation } from "./Operations.ts";
|
|
16
|
+
|
|
17
|
+
export type SubnetworkSecondaryRange = {
|
|
18
|
+
/** Name used by alias-IP ranges on VMs in this subnet. */
|
|
19
|
+
rangeName: string;
|
|
20
|
+
/** Secondary CIDR. Must be unique and non-overlapping. */
|
|
21
|
+
ipCidrRange: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A regional VPC subnet. Carries a primary CIDR plus zero or more
|
|
26
|
+
* named secondary ranges (used by GKE VPC-native pods/services).
|
|
27
|
+
*
|
|
28
|
+
* Like Networks, Subnetworks have no `labels` field, so adoption uses
|
|
29
|
+
* the alchemy marker embedded in `description`. Description is set on
|
|
30
|
+
* create and treated as immutable.
|
|
31
|
+
*
|
|
32
|
+
* @section Creating a subnet
|
|
33
|
+
* @example GKE-ready subnet with pods + services secondary ranges
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const sub = yield* GCP.Subnetwork("ResearchSubnet", {
|
|
36
|
+
* project: hostProject.projectId,
|
|
37
|
+
* region: "us-central1",
|
|
38
|
+
* network: vpc.selfLink,
|
|
39
|
+
* ipCidrRange: "10.0.0.0/20",
|
|
40
|
+
* secondaryIpRanges: [
|
|
41
|
+
* { rangeName: "pods", ipCidrRange: "10.4.0.0/14" },
|
|
42
|
+
* { rangeName: "services", ipCidrRange: "10.8.0.0/20" },
|
|
43
|
+
* ],
|
|
44
|
+
* privateIpGoogleAccess: true,
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export type SubnetworkProps = {
|
|
49
|
+
/** GCP project ID hosting the subnet. Immutable — replace if changed. */
|
|
50
|
+
project: string;
|
|
51
|
+
/**
|
|
52
|
+
* GCP region. Subnets are regional — a different region requires
|
|
53
|
+
* replace.
|
|
54
|
+
*/
|
|
55
|
+
region: string;
|
|
56
|
+
/**
|
|
57
|
+
* Subnet name. Defaults to `createPhysicalName({ id, lowercase: true,
|
|
58
|
+
* maxLength: 63 })`. Immutable — replace if changed.
|
|
59
|
+
*/
|
|
60
|
+
name?: string;
|
|
61
|
+
/**
|
|
62
|
+
* URL or short name of the parent VPC. Pass `network.selfLink` from a
|
|
63
|
+
* `GCP.Network` to make alchemy sequence the subnet after the VPC.
|
|
64
|
+
* Immutable — replace.
|
|
65
|
+
*/
|
|
66
|
+
network: string;
|
|
67
|
+
/** User-visible description. Stored after the alchemy marker. Immutable. */
|
|
68
|
+
description?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Primary CIDR for the subnet. Mutable via `expandIpCidrRange` *only*
|
|
71
|
+
* to a strictly-larger range; we surface that as a sync (treats
|
|
72
|
+
* shrinks as "no-op"). For changes that aren't an expansion, this is
|
|
73
|
+
* effectively replace-only.
|
|
74
|
+
*/
|
|
75
|
+
ipCidrRange: string;
|
|
76
|
+
/**
|
|
77
|
+
* Secondary CIDR ranges used by alias IPs (e.g. GKE pods/services
|
|
78
|
+
* ranges). Mutable via `patchSubnetworks`.
|
|
79
|
+
*/
|
|
80
|
+
secondaryIpRanges?: ReadonlyArray<SubnetworkSecondaryRange>;
|
|
81
|
+
/**
|
|
82
|
+
* Whether VMs in this subnet can reach Google services without an
|
|
83
|
+
* external IP. Defaults to `false`. Mutable via `patchSubnetworks`.
|
|
84
|
+
*/
|
|
85
|
+
privateIpGoogleAccess?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Stack type for the subnet. Defaults to `IPV4_ONLY`.
|
|
88
|
+
*/
|
|
89
|
+
stackType?: "IPV4_ONLY" | "IPV4_IPV6" | "IPV6_ONLY";
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type SubnetworkAttributes = {
|
|
93
|
+
/** Subnet name. */
|
|
94
|
+
name: string;
|
|
95
|
+
/** GCP project ID, threaded through from props for delete/read. */
|
|
96
|
+
project: string;
|
|
97
|
+
/** GCP region, threaded through from props for delete/read. */
|
|
98
|
+
region: string;
|
|
99
|
+
/** Server-defined URL. */
|
|
100
|
+
selfLink: string;
|
|
101
|
+
/** Server-assigned numeric id. */
|
|
102
|
+
id: string;
|
|
103
|
+
/** URL of the parent VPC. */
|
|
104
|
+
network: string;
|
|
105
|
+
/** User-visible description (with the alchemy marker stripped). */
|
|
106
|
+
description: string | undefined;
|
|
107
|
+
/** Primary CIDR. */
|
|
108
|
+
ipCidrRange: string;
|
|
109
|
+
/** Secondary ranges currently set. */
|
|
110
|
+
secondaryIpRanges: ReadonlyArray<SubnetworkSecondaryRange>;
|
|
111
|
+
/** Private Google Access setting. */
|
|
112
|
+
privateIpGoogleAccess: boolean;
|
|
113
|
+
/** Stack type. */
|
|
114
|
+
stackType: string | undefined;
|
|
115
|
+
/** Lifecycle state. */
|
|
116
|
+
state: string | undefined;
|
|
117
|
+
/** Default IPv4 gateway in the primary range. */
|
|
118
|
+
gatewayAddress: string | undefined;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type Subnetwork = Resource<
|
|
122
|
+
"GCP.Subnetwork",
|
|
123
|
+
SubnetworkProps,
|
|
124
|
+
SubnetworkAttributes,
|
|
125
|
+
never,
|
|
126
|
+
GCP.Providers
|
|
127
|
+
>;
|
|
128
|
+
export const Subnetwork = Resource<Subnetwork>("GCP.Subnetwork");
|
|
129
|
+
|
|
130
|
+
const toSubnetworkAttributes = (
|
|
131
|
+
s: compute.Subnetwork,
|
|
132
|
+
parent: { project: string; region: string },
|
|
133
|
+
): SubnetworkAttributes => ({
|
|
134
|
+
name: s.name ?? "",
|
|
135
|
+
project: parent.project,
|
|
136
|
+
region: parent.region,
|
|
137
|
+
selfLink: s.selfLink ?? "",
|
|
138
|
+
id: s.id ?? "",
|
|
139
|
+
network: s.network ?? "",
|
|
140
|
+
description: stripAlchemyMarker(s.description),
|
|
141
|
+
ipCidrRange: s.ipCidrRange ?? "",
|
|
142
|
+
secondaryIpRanges: (s.secondaryIpRanges ?? []).map((r) => ({
|
|
143
|
+
rangeName: r.rangeName ?? "",
|
|
144
|
+
ipCidrRange: r.ipCidrRange ?? "",
|
|
145
|
+
})),
|
|
146
|
+
privateIpGoogleAccess: s.privateIpGoogleAccess ?? false,
|
|
147
|
+
stackType: s.stackType,
|
|
148
|
+
state: s.state,
|
|
149
|
+
gatewayAddress: s.gatewayAddress,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const sortedSecondaryRanges = (
|
|
153
|
+
rs: ReadonlyArray<SubnetworkSecondaryRange> | undefined,
|
|
154
|
+
): ReadonlyArray<SubnetworkSecondaryRange> =>
|
|
155
|
+
[...(rs ?? [])].sort((a, b) => a.rangeName.localeCompare(b.rangeName));
|
|
156
|
+
|
|
157
|
+
export const SubnetworkProvider = () =>
|
|
158
|
+
Provider.effect(
|
|
159
|
+
Subnetwork,
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
const getSubnetworks = yield* compute.getSubnetworks;
|
|
162
|
+
const insertSubnetworks = yield* compute.insertSubnetworks;
|
|
163
|
+
const patchSubnetworks = yield* compute.patchSubnetworks;
|
|
164
|
+
const deleteSubnetworks = yield* compute.deleteSubnetworks;
|
|
165
|
+
const expandIpCidrRange = yield* compute.expandIpCidrRangeSubnetworks;
|
|
166
|
+
const getRegionOperations = yield* compute.getRegionOperations;
|
|
167
|
+
const awaitOp = makeAwaitRegionOperation(getRegionOperations);
|
|
168
|
+
|
|
169
|
+
const observe = (project: string, region: string, name: string) =>
|
|
170
|
+
getSubnetworks({ project, region, subnetwork: name }).pipe(
|
|
171
|
+
Effect.catchTag("NotFound", () =>
|
|
172
|
+
Effect.succeed(undefined as compute.Subnetwork | undefined),
|
|
173
|
+
),
|
|
174
|
+
Effect.catchTag("Forbidden", () =>
|
|
175
|
+
Effect.succeed(undefined as compute.Subnetwork | undefined),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// patchSubnetworks requires an up-to-date `fingerprint` for
|
|
180
|
+
// optimistic concurrency control. Re-read just before patching to
|
|
181
|
+
// minimize the chance of `412 conditionNotMet`.
|
|
182
|
+
const patchAspects = Effect.fn(function* (args: {
|
|
183
|
+
project: string;
|
|
184
|
+
region: string;
|
|
185
|
+
name: string;
|
|
186
|
+
observed: compute.Subnetwork;
|
|
187
|
+
news: SubnetworkProps;
|
|
188
|
+
session: ScopedPlanStatusSession;
|
|
189
|
+
}) {
|
|
190
|
+
const body: compute.Subnetwork = {};
|
|
191
|
+
const obsRanges = sortedSecondaryRanges(
|
|
192
|
+
(args.observed.secondaryIpRanges ?? []).map((r) => ({
|
|
193
|
+
rangeName: r.rangeName ?? "",
|
|
194
|
+
ipCidrRange: r.ipCidrRange ?? "",
|
|
195
|
+
})),
|
|
196
|
+
);
|
|
197
|
+
const desiredRanges = sortedSecondaryRanges(args.news.secondaryIpRanges);
|
|
198
|
+
if (!deepEqual(obsRanges, desiredRanges)) {
|
|
199
|
+
body.secondaryIpRanges = desiredRanges as ReadonlyArray<compute.SubnetworkSecondaryRange>;
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
args.news.privateIpGoogleAccess !== undefined &&
|
|
203
|
+
(args.observed.privateIpGoogleAccess ?? false) !==
|
|
204
|
+
args.news.privateIpGoogleAccess
|
|
205
|
+
) {
|
|
206
|
+
body.privateIpGoogleAccess = args.news.privateIpGoogleAccess;
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
args.news.stackType !== undefined &&
|
|
210
|
+
args.observed.stackType !== args.news.stackType
|
|
211
|
+
) {
|
|
212
|
+
body.stackType = args.news.stackType;
|
|
213
|
+
}
|
|
214
|
+
if (Object.keys(body).length === 0) return;
|
|
215
|
+
|
|
216
|
+
body.fingerprint = args.observed.fingerprint;
|
|
217
|
+
const op = yield* patchSubnetworks({
|
|
218
|
+
project: args.project,
|
|
219
|
+
region: args.region,
|
|
220
|
+
subnetwork: args.name,
|
|
221
|
+
body,
|
|
222
|
+
});
|
|
223
|
+
if (op.name) yield* awaitOp(args.project, args.region, op.name, args.session);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Primary CIDR can only be *expanded*. We treat shrinks as no-ops
|
|
227
|
+
// (the diff path doesn't trigger replace on ipCidrRange to keep
|
|
228
|
+
// adoption flexible — if the user passes a smaller range, the
|
|
229
|
+
// observed range stays).
|
|
230
|
+
const expandPrimary = Effect.fn(function* (args: {
|
|
231
|
+
project: string;
|
|
232
|
+
region: string;
|
|
233
|
+
name: string;
|
|
234
|
+
observed: compute.Subnetwork;
|
|
235
|
+
desired: string;
|
|
236
|
+
session: ScopedPlanStatusSession;
|
|
237
|
+
}) {
|
|
238
|
+
if (!args.observed.ipCidrRange) return;
|
|
239
|
+
if (args.observed.ipCidrRange === args.desired) return;
|
|
240
|
+
// Cheap guard: only attempt expand if the desired range is
|
|
241
|
+
// syntactically different. The API will reject shrinks/jumps;
|
|
242
|
+
// surface that error to the user.
|
|
243
|
+
const op = yield* expandIpCidrRange({
|
|
244
|
+
project: args.project,
|
|
245
|
+
region: args.region,
|
|
246
|
+
subnetwork: args.name,
|
|
247
|
+
body: { ipCidrRange: args.desired },
|
|
248
|
+
});
|
|
249
|
+
if (op.name) yield* awaitOp(args.project, args.region, op.name, args.session);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
stables: ["name", "project", "region", "selfLink", "id"],
|
|
254
|
+
diff: Effect.fn(function* ({ news, olds = {} }) {
|
|
255
|
+
if (!isResolved(news)) return undefined;
|
|
256
|
+
if (
|
|
257
|
+
somePropsAreDifferent(olds as SubnetworkProps, news, [
|
|
258
|
+
"project",
|
|
259
|
+
"region",
|
|
260
|
+
"name",
|
|
261
|
+
"network",
|
|
262
|
+
])
|
|
263
|
+
) {
|
|
264
|
+
return { action: "replace" } as const;
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}),
|
|
268
|
+
reconcile: Effect.fn(function* ({ id, news, session }) {
|
|
269
|
+
const desiredName =
|
|
270
|
+
news.name ??
|
|
271
|
+
(yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
|
|
272
|
+
const desiredDescription = yield* gcpAlchemyDescription(
|
|
273
|
+
id,
|
|
274
|
+
news.description,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
let observed = yield* observe(news.project, news.region, desiredName);
|
|
278
|
+
|
|
279
|
+
if (!observed) {
|
|
280
|
+
const body: compute.Subnetwork = {
|
|
281
|
+
name: desiredName,
|
|
282
|
+
description: desiredDescription,
|
|
283
|
+
network: news.network,
|
|
284
|
+
ipCidrRange: news.ipCidrRange,
|
|
285
|
+
...(news.privateIpGoogleAccess !== undefined
|
|
286
|
+
? { privateIpGoogleAccess: news.privateIpGoogleAccess }
|
|
287
|
+
: {}),
|
|
288
|
+
...(news.stackType ? { stackType: news.stackType } : {}),
|
|
289
|
+
...(news.secondaryIpRanges
|
|
290
|
+
? {
|
|
291
|
+
secondaryIpRanges: news.secondaryIpRanges.map((r) => ({
|
|
292
|
+
rangeName: r.rangeName,
|
|
293
|
+
ipCidrRange: r.ipCidrRange,
|
|
294
|
+
})),
|
|
295
|
+
}
|
|
296
|
+
: {}),
|
|
297
|
+
};
|
|
298
|
+
const op = yield* insertSubnetworks({
|
|
299
|
+
project: news.project,
|
|
300
|
+
region: news.region,
|
|
301
|
+
body,
|
|
302
|
+
}).pipe(
|
|
303
|
+
Effect.catchTag("Conflict", () =>
|
|
304
|
+
Effect.succeed(undefined as compute.Operation | undefined),
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
if (op?.name) yield* awaitOp(news.project, news.region, op.name, session);
|
|
308
|
+
observed = yield* getSubnetworks({
|
|
309
|
+
project: news.project,
|
|
310
|
+
region: news.region,
|
|
311
|
+
subnetwork: desiredName,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Sync mutables: secondary ranges, privateIpGoogleAccess,
|
|
316
|
+
// stackType (via patch), then primary range (via expand).
|
|
317
|
+
yield* patchAspects({
|
|
318
|
+
project: news.project,
|
|
319
|
+
region: news.region,
|
|
320
|
+
name: desiredName,
|
|
321
|
+
observed,
|
|
322
|
+
news,
|
|
323
|
+
session,
|
|
324
|
+
});
|
|
325
|
+
yield* expandPrimary({
|
|
326
|
+
project: news.project,
|
|
327
|
+
region: news.region,
|
|
328
|
+
name: desiredName,
|
|
329
|
+
observed,
|
|
330
|
+
desired: news.ipCidrRange,
|
|
331
|
+
session,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const final = yield* getSubnetworks({
|
|
335
|
+
project: news.project,
|
|
336
|
+
region: news.region,
|
|
337
|
+
subnetwork: desiredName,
|
|
338
|
+
});
|
|
339
|
+
return toSubnetworkAttributes(final, {
|
|
340
|
+
project: news.project,
|
|
341
|
+
region: news.region,
|
|
342
|
+
});
|
|
343
|
+
}),
|
|
344
|
+
delete: Effect.fn(function* ({ output, session }) {
|
|
345
|
+
yield* deleteSubnetworks({
|
|
346
|
+
project: output.project,
|
|
347
|
+
region: output.region,
|
|
348
|
+
subnetwork: output.name,
|
|
349
|
+
}).pipe(
|
|
350
|
+
Effect.flatMap((op) =>
|
|
351
|
+
op.name
|
|
352
|
+
? awaitOp(output.project, output.region, op.name, session)
|
|
353
|
+
: Effect.succeed(op),
|
|
354
|
+
),
|
|
355
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
356
|
+
);
|
|
357
|
+
}),
|
|
358
|
+
read: Effect.fn(function* ({ id, output, olds }) {
|
|
359
|
+
const project = output?.project ?? olds?.project;
|
|
360
|
+
const region = output?.region ?? olds?.region;
|
|
361
|
+
if (!project || !region) return undefined;
|
|
362
|
+
const name =
|
|
363
|
+
output?.name ??
|
|
364
|
+
olds?.name ??
|
|
365
|
+
(yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
|
|
366
|
+
const observed = yield* observe(project, region, name);
|
|
367
|
+
if (!observed) return undefined;
|
|
368
|
+
const attrs = toSubnetworkAttributes(observed, { project, region });
|
|
369
|
+
return (yield* descriptionHasAlchemyMarker(id, observed.description))
|
|
370
|
+
? attrs
|
|
371
|
+
: Unowned(attrs);
|
|
372
|
+
}),
|
|
373
|
+
};
|
|
374
|
+
}),
|
|
375
|
+
);
|