1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 createInternalLabels,
14 encodeOwnership,
15 hasAlchemyLabels,
16 hasOwnershipMarker,
17 lastSegment,
18 orgIdOf,
19 orgParent,
20 parseOwnership,
21} from "./ownership.ts";
22
23const MAX_ID_LENGTH = 63;
24
25export type SpaceProps = {
26 /**
27 * Space id (the `{space}` segment of `organizations/{org}/spaces/{space}`).
28 * If omitted, a unique name is generated from the stack, stage, and
29 * logical id. Must be 1-63 characters, start with a letter, and match
30 * `[a-z][a-z0-9-]*`. Immutable — changing it replaces the space.
31 */
32 spaceId?: string;
33 /**
34 * Apigee organization id. Defaults to the stack GCP project. Immutable —
35 * changing it replaces the space.
36 */
37 organization?: string;
38 /**
39 * Human-readable display name. Spaces have no labels field, so Alchemy
40 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
41 * in a `[alchemy …]` prefix for `list` / nuke.
42 */
43 displayName?: string;
44};
45
46export type Space = Resource<
47 "GCP.Apigee.Space",
48 SpaceProps,
49 {
50 /** Full resource name `organizations/{org}/spaces/{space}`. */
51 name: string;
52 /** Space id (last path segment). */
53 spaceId: string;
54 /** Apigee organization id. */
55 organization: string;
56 /** User display name with the Alchemy ownership prefix stripped. */
57 displayName: string | undefined;
58 /** RFC3339 creation timestamp. */
59 createTime: string | undefined;
60 /** RFC3339 last-update timestamp. */
61 updateTime: string | undefined;
62 },
63 never,
64 Providers
65>;
66
67/**
68 * An Apigee organization space used to group API proxies, shared flows,
69 * and products for IAM.
70 *
71 * Spaces have no labels field, so Alchemy stamps ownership into
72 * `displayName` for `list` / nuke. `spaceId` and `organization` are
73 * identity — changing either replaces the space. `displayName` updates
74 * in place.
75 *
76 * ### Creating a Space
77 * **Example:** Generated id
78 * ```typescript
79 * const space = yield* GCP.Apigee.Space("Payments", {
80 * displayName: "payments team",
81 * });
82 * ```
83 *
84 * **Example:** Explicit id
85 * ```typescript
86 * const space = yield* GCP.Apigee.Space("Payments", {
87 * spaceId: "payments",
88 * displayName: "payments team",
89 * });
90 * ```
91 *
92 * ### Updating a Space
93 * **Example:** Change the display name
94 * ```typescript
95 * const space = yield* GCP.Apigee.Space("Payments", {
96 * displayName: "payments and billing",
97 * });
98 * ```
99 *
100 * @resource
101 * @category Apigee
102 */
103export const Space = Resource<Space>("GCP.Apigee.Space");
104
105export class SpaceNotResolved extends Data.TaggedError(
106 "GCP.Apigee.SpaceNotResolved",
107)<{
108 name: string;
109}> {}
110
111const rfc1035 = (name: string): string => {
112 let next = name
113 .toLowerCase()
114 .replace(/[^a-z0-9-]/g, "-")
115 .replace(/-+/g, "-")
116 .replace(/^-+/, "")
117 .replace(/-+$/, "");
118 if (!/^[a-z]/.test(next)) {
119 next = `s${next}`;
120 }
121 next = next.slice(0, MAX_ID_LENGTH).replace(/-+$/, "");
122 return next.length > 0 ? next : "space";
123};
124
125const resourceName = (org: string, spaceId: string) =>
126 `${orgParent(org)}/spaces/${spaceId}`;
127
128const spaceIdOf = (space: apigee.GoogleCloudApigeeV1Space) => {
129 const raw = space.name ?? "";
130 return raw.includes("/") ? lastSegment(raw) : raw;
131};
132
133const toId = (id: string, spaceId: string | undefined, existing?: string) =>
134 Effect.gen(function* () {
135 if (spaceId !== undefined) return rfc1035(spaceId);
136 if (existing !== undefined) return existing;
137 return rfc1035(
138 yield* createPhysicalName({
139 id,
140 maxLength: MAX_ID_LENGTH,
141 lowercase: true,
142 }),
143 );
144 });
145
146const toAttrs = (space: apigee.GoogleCloudApigeeV1Space, org: string) => {
147 const spaceId = spaceIdOf(space);
148 const parsed = parseOwnership(space.displayName);
149 const name = space.name ?? (spaceId ? resourceName(org, spaceId) : "");
150 return {
151 name,
152 spaceId,
153 organization: orgIdOf(name.startsWith("organizations/") ? name : org, org),
154 displayName: parsed.text,
155 createTime: space.createTime,
156 updateTime: space.updateTime,
157 };
158};
159
160const getByName = (name: string) =>
161 apigee
162 .getOrganizationsSpaces({ name })
163 .pipe(
164 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
165 Effect.succeed(undefined),
166 ),
167 );
168
169export const SpaceProvider = () =>
170 Provider.succeed(Space, {
171 stables: ["name", "spaceId", "organization", "createTime"],
172
173 diff: Effect.fn(function* ({ news, olds, output }) {
174 if (!isResolved(news)) return undefined;
175 const previousId = olds?.spaceId ?? output?.spaceId;
176 const idChanged =
177 previousId !== undefined &&
178 news.spaceId !== undefined &&
179 rfc1035(news.spaceId) !== previousId;
180 const previousOrg = olds?.organization ?? output?.organization;
181 const orgChanged =
182 previousOrg !== undefined &&
183 news.organization !== undefined &&
184 orgIdOf(news.organization, previousOrg) !== previousOrg;
185 if (idChanged || orgChanged) {
186 return { action: "replace" as const, deleteFirst: false };
187 }
188 return undefined;
189 }),
190
191 read: Effect.fn(function* ({ id, olds, output }) {
192 const env = yield* GcpEnvironment.current;
193 const org = orgIdOf(
194 olds?.organization ?? output?.organization,
195 env.project,
196 );
197 const spaceId = yield* toId(id, olds?.spaceId, output?.spaceId);
198 const name = output?.name ?? resourceName(org, spaceId);
199 const existing = yield* getByName(name);
200 if (existing === undefined) return undefined;
201 const attrs = toAttrs(existing, org);
202 const { labels } = parseOwnership(existing.displayName);
203 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
204 }),
205
206 list: () =>
207 Effect.gen(function* () {
208 const env = yield* GcpEnvironment.current;
209 const org = env.project;
210 return yield* apigee.listOrganizationsSpaces
211 .pages({
212 parent: orgParent(org),
213 pageSize: 1000,
214 })
215 .pipe(
216 Stream.flatMap((page) => Stream.fromIterable(page.spaces ?? [])),
217 Stream.filter((space) => hasOwnershipMarker(space.displayName)),
218 Stream.map((space) => toAttrs(space, org)),
219 Stream.runCollect,
220 Effect.map((chunk) => Array.from(chunk)),
221 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
222 Effect.succeed([]),
223 ),
224 );
225 }),
226
227 reconcile: Effect.fn(function* ({ id, news, output }) {
228 const env = yield* GcpEnvironment.current;
229 const org = orgIdOf(
230 news.organization ?? output?.organization,
231 env.project,
232 );
233 const spaceId = yield* toId(id, news.spaceId, output?.spaceId);
234 const name = resourceName(org, spaceId);
235 const ownership = yield* createInternalLabels(id);
236 const desiredDisplayName = encodeOwnership(ownership, news.displayName);
237
238 let current = yield* getByName(output?.name ?? name);
239
240 if (current === undefined) {
241 const created = yield* apigee
242 .createOrganizationsSpaces({
243 parent: orgParent(org),
244 spaceId,
245 body: {
246 displayName: desiredDisplayName,
247 },
248 })
249 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
250 current = created ?? undefined;
251 }
252
253 if (current === undefined) {
254 return yield* new SpaceNotResolved({ name });
255 }
256
257 if ((current.displayName ?? "") !== desiredDisplayName) {
258 current = yield* apigee.patchOrganizationsSpaces({
259 name: current.name ?? name,
260 updateMask: "display_name",
261 body: {
262 displayName: desiredDisplayName,
263 },
264 });
265 }
266
267 return toAttrs(current, org);
268 }),
269
270 delete: Effect.fn(function* ({ output }) {
271 yield* apigee
272 .deleteOrganizationsSpaces({ name: output.name })
273 .pipe(
274 Effect.catchTag(
275 ["NotFound", "ApigeeResourceNotFound"],
276 () => Effect.void,
277 ),
278 );
279 }),
280 });