1import * as apphub from "@distilled.cloud/gcp/apphub_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 GLOBAL_LOCATION,
13 locationParent,
14 parseName,
15 projectIdOf,
16 projectNameOf,
17 replaceOnIdentity,
18 ResourceNotResolved,
19 waitForOperation,
20 waitUntilExists,
21 waitUntilGone,
22 waitUntilReady,
23} from "./internal.ts";
24
25export type ServiceProjectAttachmentProps = {
26 /**
27 * Service project to attach, as `projects/{project}` or a bare project
28 * id. Immutable — changing it replaces the attachment. The attachment
29 * id must equal this project's id. Defaults to the stack project.
30 */
31 serviceProject?: string;
32 /**
33 * Attachment id. Must equal the project id of `serviceProject`. If
34 * omitted, that project id is used. Immutable — changing it replaces
35 * the attachment.
36 */
37 serviceProjectAttachmentId?: string;
38 /**
39 * Location of the host project. App Hub only supports `global`.
40 * @default "global"
41 */
42 location?: string;
43};
44
45export type ServiceProjectAttachment = Resource<
46 "GCP.AppHub.ServiceProjectAttachment",
47 ServiceProjectAttachmentProps,
48 {
49 /** Full resource name. */
50 name: string;
51 /** Attachment id (last path segment, equal to the service project id). */
52 serviceProjectAttachmentId: string;
53 /** Host project id. */
54 project: string;
55 /** Location id (`global`). */
56 location: string;
57 /** Service project resource name (`projects/{number}` on output). */
58 serviceProject: string | undefined;
59 /** Server-reported state. */
60 state: string | undefined;
61 /** Server-generated resource uid. */
62 uid: string | undefined;
63 /** RFC3339 creation timestamp. */
64 createTime: string | undefined;
65 },
66 never,
67 Providers
68>;
69
70/**
71 * An App Hub service project attachment. Service projects hold the
72 * underlying cloud resources and expose them to a host project so App
73 * Hub can provide an aggregated view.
74 *
75 * Attachments have no labels, description, or display name, and the id
76 * is forced to the service project id, so `list` cannot stamp ownership.
77 * Location is always `global`. The resource is existence-only: service
78 * project is identity, and there is nothing mutable to sync.
79 *
80 * ### Creating a Service Project Attachment
81 * **Example:** Attach the stack project
82 * ```typescript
83 * const attachment = yield* GCP.AppHub.ServiceProjectAttachment(
84 * "Host",
85 * {},
86 * );
87 * ```
88 *
89 * **Example:** Attach a specific service project
90 * ```typescript
91 * const attachment = yield* GCP.AppHub.ServiceProjectAttachment(
92 * "Payments",
93 * { serviceProject: "projects/my-service" },
94 * );
95 * ```
96 *
97 * @resource
98 * @category AppHub
99 */
100export const ServiceProjectAttachment = Resource<ServiceProjectAttachment>(
101 "GCP.AppHub.ServiceProjectAttachment",
102);
103
104const resourceName = (
105 project: string,
106 location: string,
107 attachmentId: string,
108) =>
109 `${locationParent(project, location)}/serviceProjectAttachments/${attachmentId}`;
110
111const toAttrs = (item: apphub.ServiceProjectAttachment, project: string) => {
112 const name = item.name ?? "";
113 const parsed = parseName(name, "serviceProjectAttachments", GLOBAL_LOCATION);
114 return {
115 name,
116 serviceProjectAttachmentId: parsed.id,
117 project: parsed.project || project,
118 location: parsed.location || GLOBAL_LOCATION,
119 serviceProject: item.serviceProject,
120 state: item.state,
121 uid: item.uid,
122 createTime: item.createTime,
123 };
124};
125
126const getByName = (name: string) =>
127 name.length === 0
128 ? Effect.succeed(undefined)
129 : apphub
130 .getProjectsLocationsServiceProjectAttachments({ name })
131 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
132
133export const ServiceProjectAttachmentProvider = () =>
134 Provider.succeed(ServiceProjectAttachment, {
135 stables: [
136 "name",
137 "serviceProjectAttachmentId",
138 "project",
139 "location",
140 "serviceProject",
141 "uid",
142 "createTime",
143 ],
144
145 diff: Effect.fn(function* ({ news, olds, output }) {
146 if (!isResolved(news)) return undefined;
147 const previousProject = projectIdOf(
148 olds?.serviceProject ?? output?.serviceProject,
149 "",
150 );
151 const nextProject = projectIdOf(
152 news.serviceProject ?? olds?.serviceProject ?? output?.serviceProject,
153 previousProject,
154 );
155 return replaceOnIdentity({
156 previousId:
157 olds?.serviceProjectAttachmentId ??
158 output?.serviceProjectAttachmentId,
159 nextId:
160 news.serviceProjectAttachmentId ??
161 olds?.serviceProjectAttachmentId ??
162 output?.serviceProjectAttachmentId,
163 previousLocation: GLOBAL_LOCATION,
164 nextLocation: GLOBAL_LOCATION,
165 extra:
166 previousProject.length > 0 &&
167 nextProject.length > 0 &&
168 previousProject !== nextProject,
169 });
170 }),
171
172 read: Effect.fn(function* ({ olds, output }) {
173 const env = yield* GcpEnvironment.current;
174 const serviceProjectId = projectIdOf(
175 olds?.serviceProject ?? output?.serviceProject,
176 env.project,
177 );
178 const attachmentId =
179 olds?.serviceProjectAttachmentId ??
180 output?.serviceProjectAttachmentId ??
181 serviceProjectId;
182 const name =
183 output?.name ??
184 resourceName(env.project, GLOBAL_LOCATION, attachmentId);
185 const existing = yield* getByName(name);
186 if (existing === undefined) return undefined;
187 const attrs = toAttrs(existing, env.project);
188 return attrs.serviceProjectAttachmentId === attachmentId
189 ? attrs
190 : Unowned(attrs);
191 }),
192
193 list: () =>
194 Effect.gen(function* () {
195 const env = yield* GcpEnvironment.current;
196 return yield* apphub.listProjectsLocationsServiceProjectAttachments
197 .pages({
198 parent: locationParent(env.project, GLOBAL_LOCATION),
199 pageSize: 1000,
200 })
201 .pipe(
202 Stream.flatMap((page) =>
203 Stream.fromIterable(page.serviceProjectAttachments ?? []),
204 ),
205 Stream.filter(
206 (item) =>
207 projectIdOf(item.serviceProject, "") === env.project ||
208 parseName(
209 item.name ?? "",
210 "serviceProjectAttachments",
211 GLOBAL_LOCATION,
212 ).id === env.project,
213 ),
214 Stream.map((item) => toAttrs(item, env.project)),
215 Stream.runCollect,
216 Effect.map((chunk) => Array.from(chunk)),
217 // No global location yet: no attachments.
218 Effect.catchTag("NotFound", () => Effect.succeed([])),
219 );
220 }),
221
222 reconcile: Effect.fn(function* ({ news, output }) {
223 const env = yield* GcpEnvironment.current;
224 const serviceProject = projectNameOf(news.serviceProject, env.project);
225 const attachmentId =
226 news.serviceProjectAttachmentId ??
227 output?.serviceProjectAttachmentId ??
228 projectIdOf(serviceProject, env.project);
229 const name = resourceName(env.project, GLOBAL_LOCATION, attachmentId);
230 const parent = locationParent(env.project, GLOBAL_LOCATION);
231
232 let current = yield* getByName(output?.name ?? name);
233
234 if (current === undefined) {
235 const created = yield* apphub
236 .createProjectsLocationsServiceProjectAttachments({
237 parent,
238 serviceProjectAttachmentId: attachmentId,
239 body: {
240 serviceProject,
241 },
242 })
243 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
244 if (created !== undefined) {
245 yield* waitForOperation(created);
246 }
247 current = yield* waitUntilExists(getByName(name), name);
248 }
249
250 if (current === undefined) {
251 return yield* new ResourceNotResolved({ name });
252 }
253
254 current = yield* waitUntilReady(
255 getByName(current.name ?? name),
256 current.name ?? name,
257 (item) => item.state,
258 );
259
260 return toAttrs(current, env.project);
261 }),
262
263 delete: Effect.fn(function* ({ output }) {
264 const operation = yield* apphub
265 .deleteProjectsLocationsServiceProjectAttachments({
266 name: output.name,
267 })
268 .pipe(
269 Effect.retry({
270 while: (error) => error._tag === "Conflict",
271 times: 8,
272 schedule: Schedule.spaced("2 seconds"),
273 }),
274 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
275 );
276 if (operation !== undefined) {
277 yield* waitForOperation(operation, { notFoundOk: true });
278 }
279 yield* waitUntilGone(getByName(output.name), output.name);
280 }),
281 });