1import * as servicemanagement from "@distilled.cloud/gcp/servicemanagement_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import {
8 waitForOperation as waitForLongRunning,
9 type LongRunningOperation,
10} from "../Operation.ts";
11
12export const MAX_LABEL_LENGTH = 63;
13export const GENERATED_LABEL_LENGTH = 40;
14
15export class ServiceNotResolved extends Data.TaggedError(
16 "GCP.ServiceManagement.ServiceNotResolved",
17)<{
18 serviceName: string;
19}> {}
20
21export class ServiceStillExists extends Data.TaggedError(
22 "GCP.ServiceManagement.ServiceStillExists",
23)<{
24 serviceName: string;
25}> {}
26
27export const endpointsSuffix = (project: string) =>
28 `.endpoints.${project}.cloud.goog`;
29
30export const isGeneratedServiceName = (
31 serviceName: string,
32 project: string,
33) => {
34 const suffix = endpointsSuffix(project);
35 return (
36 serviceName.startsWith("alch-") &&
37 serviceName.endsWith(suffix) &&
38 serviceName.length > suffix.length
39 );
40};
41
42const dnsLabel = (value: string) => {
43 const cleaned = value
44 .toLowerCase()
45 .replace(/[^a-z0-9-]/g, "-")
46 .replace(/^-+/, "")
47 .replace(/-+$/g, "")
48 .slice(0, MAX_LABEL_LENGTH)
49 .replace(/-+$/g, "");
50 const named = /^[a-z]/.test(cleaned) ? cleaned : `a${cleaned}`;
51 return named.slice(0, MAX_LABEL_LENGTH).replace(/-+$/g, "");
52};
53
54export const toServiceName = (
55 id: string,
56 serviceName: string | undefined,
57 existing: string | undefined,
58 project: string,
59) =>
60 Effect.gen(function* () {
61 if (serviceName !== undefined && serviceName.length > 0) {
62 return serviceName;
63 }
64 if (existing !== undefined && existing.length > 0) {
65 return existing;
66 }
67 const generated = yield* createPhysicalName({
68 id,
69 maxLength: GENERATED_LABEL_LENGTH,
70 lowercase: true,
71 });
72 const label = dnsLabel(
73 generated.startsWith("alch-") ? generated : `alch-${generated}`,
74 );
75 return `${label}${endpointsSuffix(project)}`;
76 });
77
78/**
79 * Service Management answers a missing service with HTTP 403 "not found or
80 * permission denied", typed as `ServiceNotFound`.
81 */
82export const getByName = (serviceName: string) =>
83 servicemanagement
84 .getServices({ serviceName })
85 .pipe(
86 Effect.catchTag(["NotFound", "ServiceNotFound"], () =>
87 Effect.succeed(undefined),
88 ),
89 );
90
91/** The newest config version (the list is ordered newest first). */
92export const getLatestConfig = (serviceName: string) =>
93 servicemanagement.listServicesConfigs({ serviceName, pageSize: 1 }).pipe(
94 Effect.map((page) => page.serviceConfigs?.[0]),
95 Effect.catchTag(["NotFound", "ServiceNotFound"], () =>
96 Effect.succeed(undefined),
97 ),
98 );
99
100export const waitUntilExists = (serviceName: string) =>
101 getByName(serviceName).pipe(
102 Effect.flatMap((service) =>
103 service !== undefined
104 ? Effect.succeed(service)
105 : Effect.fail(new ServiceNotResolved({ serviceName })),
106 ),
107 Effect.retry({
108 while: (error) =>
109 error._tag === "GCP.ServiceManagement.ServiceNotResolved",
110 times: 20,
111 schedule: Schedule.spaced("6 seconds"),
112 }),
113 );
114
115export const waitUntilGone = (serviceName: string) =>
116 getByName(serviceName).pipe(
117 Effect.flatMap((service) =>
118 service === undefined
119 ? Effect.void
120 : Effect.fail(new ServiceStillExists({ serviceName })),
121 ),
122 Effect.retry({
123 while: (error) =>
124 error._tag === "GCP.ServiceManagement.ServiceStillExists",
125 times: 10,
126 schedule: Schedule.spaced("4 seconds"),
127 }),
128 );
129
130export const undeleteService = (serviceName: string) =>
131 Effect.gen(function* () {
132 const operation = yield* servicemanagement
133 .undeleteServices({ serviceName })
134 .pipe(
135 Effect.catchTag(
136 ["NotFound", "ServiceNotFound", "ServiceAlreadyActive"],
137 () => Effect.succeed(undefined),
138 ),
139 );
140 if (operation !== undefined) {
141 yield* waitForOperation(operation);
142 }
143 return yield* getByName(serviceName);
144 });
145
146export const listProducerServices = (project: string) =>
147 servicemanagement.listServices
148 .pages({
149 producerProjectId: project,
150 pageSize: 500,
151 })
152 .pipe(
153 Stream.flatMap((page) => Stream.fromIterable(page.services ?? [])),
154 Stream.runCollect,
155 Effect.map((chunk) => Array.from(chunk)),
156 Effect.catchTag("NotFound", () =>
157 Effect.succeed([] as servicemanagement.ManagedService[]),
158 ),
159 );
160
161/** Managed-service creates and deletes routinely run past three minutes. */
162const OPERATION_BUDGET = "10 minutes";
163
164/**
165 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
166 * concurrent create won the race; reconcile observes the resource next.
167 */
168export const waitForOperation = (operation: LongRunningOperation) =>
169 waitForLongRunning(
170 operation,
171 (name) => servicemanagement.getOperations({ name }),
172 {
173 budget: OPERATION_BUDGET,
174 },
175 ).pipe(
176 Effect.catchIf(
177 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
178 () => Effect.succeed(operation),
179 ),
180 );
181
182/**
183 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
184 * means the resource is already gone.
185 */
186export const waitForDeleteOperation = (operation: LongRunningOperation) =>
187 waitForOperation(operation).pipe(
188 Effect.catchIf(
189 (error) =>
190 error._tag === "NotFound" ||
191 (error._tag === "GCP.OperationFailed" && error.code === 5),
192 () => Effect.succeed(operation),
193 ),
194 );