1import * as servicemanagement from "@distilled.cloud/gcp/servicemanagement_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 getByName,
12 getLatestConfig,
13 isGeneratedServiceName,
14 listProducerServices,
15 ServiceNotResolved,
16 toServiceName,
17 undeleteService,
18 waitForDeleteOperation,
19 waitForOperation,
20 waitUntilExists,
21 waitUntilGone,
22} from "./internal.ts";
23
24export { ServiceNotResolved };
25
26export type ServiceProps = {
27 /**
28 * DNS service name (for example
29 * `hello.endpoints.my-project.cloud.goog`). If omitted, Alchemy
30 * generates `{alch-…}.endpoints.{project}.cloud.goog` from the stack,
31 * stage, and logical id. Immutable — changing it replaces the service.
32 * After delete the name is reserved for 30 days; reconcile undeletes
33 * instead of recreating.
34 */
35 serviceName?: string;
36 /**
37 * Display title stored on the service config. Changing it writes a new
38 * config version.
39 */
40 title?: string;
41 /**
42 * Producer project id. Defaults to the current stack project.
43 * Immutable — changing it replaces the service.
44 */
45 producerProjectId?: string;
46};
47
48export type Service = Resource<
49 "GCP.ServiceManagement.Service",
50 ServiceProps,
51 {
52 /** DNS service name. */
53 serviceName: string;
54 /** Producer project id. */
55 producerProjectId: string;
56 /** Project id used when the service was reconciled. */
57 project: string;
58 /** Title of the newest service config. */
59 title: string | undefined;
60 },
61 never,
62 Providers
63>;
64
65/**
66 * A Google Service Management managed service (Cloud Endpoints producer
67 * service).
68 *
69 * A managed service is identity-only and immutable. Create and delete
70 * return long-running operations. After delete the name stays reserved
71 * for 30 days — reconcile undeletes rather than recreating. There is no
72 * labels field: `list` / `pnpm nuke:gcp` find services by their generated
73 * `alch-` name, and `read` reports an explicitly named service it has no
74 * state for as unowned. Changing `serviceName` or `producerProjectId`
75 * replaces the service. `title` updates in place by writing a new config
76 * version.
77 *
78 * ### Creating a Service
79 * **Example:** Generated Endpoints name
80 * ```typescript
81 * const api = yield* GCP.ServiceManagement.Service("Hello", {
82 * title: "Hello API",
83 * });
84 * ```
85 *
86 * **Example:** Explicit DNS name
87 * ```typescript
88 * const api = yield* GCP.ServiceManagement.Service("Hello", {
89 * serviceName: "hello.endpoints.my-project.cloud.goog",
90 * title: "Hello API",
91 * });
92 * ```
93 *
94 * ### Updating a Service
95 * **Example:** Change the display title
96 * ```typescript
97 * const api = yield* GCP.ServiceManagement.Service("Hello", {
98 * title: "Hello API v2",
99 * });
100 * ```
101 *
102 * @resource
103 * @category ServiceManagement
104 */
105export const Service = Resource<Service>("GCP.ServiceManagement.Service");
106
107const toAttrs = (
108 service: servicemanagement.ManagedService,
109 project: string,
110 title: string | undefined,
111) => {
112 const serviceName = service.serviceName ?? "";
113 return {
114 serviceName,
115 producerProjectId: service.producerProjectId ?? project,
116 project,
117 title,
118 };
119};
120
121const toAttrsLive = (
122 service: servicemanagement.ManagedService,
123 project: string,
124) =>
125 Effect.gen(function* () {
126 const serviceName = service.serviceName ?? "";
127 const config =
128 serviceName.length > 0 ? yield* getLatestConfig(serviceName) : undefined;
129 return toAttrs(service, project, config?.title);
130 });
131
132const desiredProducer = (
133 news: ServiceProps,
134 project: string,
135 existing?: string,
136) => news.producerProjectId ?? existing ?? project;
137
138export const ServiceProvider = () =>
139 Provider.succeed(Service, {
140 stables: ["serviceName", "producerProjectId", "project"],
141
142 diff: Effect.fn(function* ({ news, olds, output }) {
143 if (!isResolved(news)) return undefined;
144 const previousName = olds?.serviceName ?? output?.serviceName;
145 if (
146 previousName !== undefined &&
147 news.serviceName !== undefined &&
148 news.serviceName !== previousName
149 ) {
150 return { action: "replace" as const, deleteFirst: false };
151 }
152 const previousProducer =
153 olds?.producerProjectId ?? output?.producerProjectId;
154 if (
155 previousProducer !== undefined &&
156 news.producerProjectId !== undefined &&
157 news.producerProjectId !== previousProducer
158 ) {
159 return { action: "replace" as const, deleteFirst: false };
160 }
161 return undefined;
162 }),
163
164 read: Effect.fn(function* ({ id, olds, output }) {
165 const env = yield* GcpEnvironment.current;
166 const serviceName = yield* toServiceName(
167 id,
168 olds?.serviceName,
169 output?.serviceName,
170 env.project,
171 );
172 const existing = yield* getByName(serviceName);
173 if (existing === undefined) return undefined;
174 const attrs = yield* toAttrsLive(existing, env.project);
175 // Managed services have no labels. A generated `alch-` name derives
176 // from this stack, stage and logical id; any other name is only ours
177 // when state already records it.
178 return output !== undefined ||
179 isGeneratedServiceName(serviceName, env.project)
180 ? attrs
181 : Unowned(attrs);
182 }),
183
184 list: () =>
185 Effect.gen(function* () {
186 const env = yield* GcpEnvironment.current;
187 const services = yield* listProducerServices(env.project);
188 return yield* Effect.forEach(
189 services.filter((service) =>
190 isGeneratedServiceName(service.serviceName ?? "", env.project),
191 ),
192 (service) => toAttrsLive(service, env.project),
193 { concurrency: 4 },
194 );
195 }),
196
197 reconcile: Effect.fn(function* ({ id, news, output }) {
198 const env = yield* GcpEnvironment.current;
199 const serviceName = yield* toServiceName(
200 id,
201 news.serviceName,
202 output?.serviceName,
203 env.project,
204 );
205 const producerProjectId = desiredProducer(
206 news,
207 env.project,
208 output?.producerProjectId,
209 );
210
211 let current = yield* getByName(serviceName);
212
213 if (current === undefined) {
214 // A name deleted in the last 30 days is soft-deleted: undelete it.
215 const created = yield* servicemanagement
216 .createServices({
217 body: {
218 serviceName,
219 producerProjectId,
220 },
221 })
222 .pipe(
223 Effect.catchTag(["Conflict", "ServiceSoftDeleted"], () =>
224 Effect.succeed(undefined),
225 ),
226 );
227 if (created !== undefined) {
228 yield* waitForOperation(created);
229 current = yield* waitUntilExists(serviceName);
230 } else {
231 current =
232 (yield* undeleteService(serviceName)) ??
233 (yield* waitUntilExists(serviceName));
234 }
235 }
236
237 const config = yield* getLatestConfig(serviceName);
238 if (news.title !== undefined && (config?.title ?? "") !== news.title) {
239 // A new service is briefly invisible to the config API.
240 yield* servicemanagement
241 .createServicesConfigs({
242 serviceName,
243 body: {
244 name: serviceName,
245 title: news.title,
246 producerProjectId,
247 },
248 })
249 .pipe(
250 Effect.retry({
251 while: (error) =>
252 error._tag === "NotFound" || error._tag === "ServiceNotFound",
253 times: 8,
254 schedule: Schedule.spaced("2 seconds"),
255 }),
256 );
257 }
258
259 return yield* toAttrsLive(current, env.project);
260 }),
261
262 delete: Effect.fn(function* ({ output }) {
263 const operation = yield* servicemanagement
264 .deleteServices({ serviceName: output.serviceName })
265 .pipe(
266 Effect.retry({
267 while: (error) => error._tag === "Conflict",
268 times: 8,
269 schedule: Schedule.spaced("1 second"),
270 }),
271 Effect.catchTag(["NotFound", "ServiceNotFound"], () =>
272 Effect.succeed(undefined),
273 ),
274 );
275 if (operation !== undefined) {
276 yield* waitForDeleteOperation(operation);
277 }
278 yield* waitUntilGone(output.serviceName);
279 }),
280 });