1import * as androiddeviceprovisioning from "@distilled.cloud/gcp/androiddeviceprovisioning_v1";
2import * as Config from "effect/Config";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12
13export const CUSTOMER_PREFIX = "customers/";
14export const MAX_CONFIGURATION_NAME_LENGTH = 100;
15export const DEFAULT_IS_DEFAULT = false;
16export const PROBE_CUSTOMER = "customers/0";
17export const PROBE_CONFIGURATION = `${PROBE_CUSTOMER}/configurations/0`;
18export const ANDROID_DEVICE_POLICY_PACKAGE =
19 "com.google.android.apps.work.clouddpc";
20
21export const lastSegment = (value: string) => {
22 const trimmed = value.replace(/\/+$/, "");
23 const parts = trimmed.split("/");
24 return parts[parts.length - 1] || trimmed;
25};
26
27export const parentOf = (name: string) => {
28 const parts = name.split("/").filter((part) => part.length > 0);
29 return parts.slice(0, -2).join("/");
30};
31
32export const normalizeName = (value: string) =>
33 value.replace(/\/+$/, "").trim();
34
35export const toCustomerName = (value: string) => {
36 const trimmed = normalizeName(value);
37 if (trimmed.length === 0) return "";
38 const customersIdx = trimmed.lastIndexOf("/customers/");
39 if (customersIdx >= 0) {
40 const rest = trimmed.slice(customersIdx + "/customers/".length);
41 const id = rest.split("/")[0] ?? "";
42 return id.length > 0 ? `${CUSTOMER_PREFIX}${id}` : "";
43 }
44 if (trimmed.startsWith(CUSTOMER_PREFIX)) {
45 const id = trimmed.slice(CUSTOMER_PREFIX.length).split("/")[0] ?? "";
46 return id.length > 0 ? `${CUSTOMER_PREFIX}${id}` : trimmed;
47 }
48 return `${CUSTOMER_PREFIX}${lastSegment(trimmed)}`;
49};
50
51export const toConfigurationName = (
52 parent: string,
53 configurationId?: string,
54) => {
55 if (
56 configurationId !== undefined &&
57 configurationId.includes("/configurations/")
58 ) {
59 return normalizeName(configurationId);
60 }
61 const customer = toCustomerName(parent);
62 if (
63 configurationId !== undefined &&
64 configurationId.length > 0 &&
65 customer.length > 0
66 ) {
67 return `${customer}/configurations/${lastSegment(configurationId)}`;
68 }
69 return "";
70};
71
72export const toDpcName = (parent: string, value: string) => {
73 const trimmed = normalizeName(value);
74 if (trimmed.includes("/dpcs/")) return trimmed;
75 const customer = toCustomerName(parent);
76 if (customer.length === 0 || trimmed.length === 0) return trimmed;
77 return `${customer}/dpcs/${lastSegment(trimmed)}`;
78};
79
80export const sameText = (left: string | undefined, right: string | undefined) =>
81 (left ?? "") === (right ?? "");
82
83export const sameBoolean = (
84 left: boolean | undefined,
85 right: boolean | undefined,
86) => left === right;
87
88export const updateMaskOf = (...fields: Array<string | undefined>) =>
89 fields.filter((field): field is string => field !== undefined).join(",");
90
91export const replaceOnIdentity = (input: {
92 previousParent?: string;
93 nextParent: string;
94 previousId?: string;
95 nextId?: string;
96}) => {
97 if (
98 input.previousParent !== undefined &&
99 input.previousParent.length > 0 &&
100 toCustomerName(input.previousParent) !== toCustomerName(input.nextParent)
101 ) {
102 return { action: "replace" as const, deleteFirst: false };
103 }
104 if (
105 input.previousId !== undefined &&
106 input.nextId !== undefined &&
107 lastSegment(input.previousId) !== lastSegment(input.nextId)
108 ) {
109 return { action: "replace" as const, deleteFirst: false };
110 }
111 return undefined;
112};
113
114const markerOf = (stack: string, stage: string, id: string) =>
115 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
116
117const fitMarker = (labels: Record<string, string>, maxLength: number) => {
118 let stack = labels[alchemyLabelKeys.stack] ?? "x";
119 let stage = labels[alchemyLabelKeys.stage] ?? "x";
120 let id = labels[alchemyLabelKeys.id] ?? "x";
121 let marker = markerOf(stack, stage, id);
122 while (
123 marker.length > maxLength &&
124 (stack.length > 1 || stage.length > 1 || id.length > 1)
125 ) {
126 if (stack.length >= stage.length && stack.length >= id.length) {
127 stack = stack.slice(0, -1);
128 } else if (stage.length >= id.length) {
129 stage = stage.slice(0, -1);
130 } else {
131 id = id.slice(0, -1);
132 }
133 marker = markerOf(stack, stage, id);
134 }
135 return marker.slice(0, maxLength);
136};
137
138export const encodeOwnershipLine = (
139 labels: Record<string, string>,
140 text: string | undefined,
141 maxLength = MAX_CONFIGURATION_NAME_LENGTH,
142): string => {
143 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
144 if (!trimmed) return fitMarker(labels, maxLength);
145 const minMarker = 24;
146 const reserved = Math.min(
147 trimmed.length + 1,
148 Math.max(0, maxLength - minMarker),
149 );
150 const marker = fitMarker(labels, maxLength - reserved);
151 return `${marker} ${trimmed}`.slice(0, maxLength);
152};
153
154export const parseOwnership = (
155 text: string | undefined,
156): {
157 labels: Record<string, string>;
158 text: string | undefined;
159} => {
160 if (!text?.startsWith("[alchemy ")) {
161 return { labels: {}, text };
162 }
163 const end = text.indexOf("]");
164 if (end < 0) return { labels: {}, text };
165 const labels: Record<string, string> = {};
166 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
167 const eq = part.indexOf("=");
168 if (eq > 0) {
169 labels[part.slice(0, eq)] = part.slice(eq + 1);
170 }
171 }
172 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
173 return { labels, text: rest.length > 0 ? rest : undefined };
174};
175
176export const hasOwnershipMarker = (text: string | undefined) =>
177 Object.keys(parseOwnership(text).labels).some((key) =>
178 key.startsWith("alchemy-"),
179 );
180
181const prefixMatch = (expected: string, observed: string) =>
182 expected === observed ||
183 expected.startsWith(observed) ||
184 observed.startsWith(expected);
185
186export const ownedByAlchemy = (id: string, text: string | undefined) =>
187 Effect.gen(function* () {
188 const expected = yield* createInternalLabels(id);
189 const { labels } = parseOwnership(text);
190 if (!hasOwnershipMarker(text)) return false;
191 const exact = yield* hasAlchemyLabels(id, labels);
192 if (exact) return true;
193 return (
194 prefixMatch(
195 expected[alchemyLabelKeys.stack] ?? "",
196 labels[alchemyLabelKeys.stack] ?? "",
197 ) &&
198 prefixMatch(
199 expected[alchemyLabelKeys.stage] ?? "",
200 labels[alchemyLabelKeys.stage] ?? "",
201 ) &&
202 prefixMatch(
203 expected[alchemyLabelKeys.id] ?? "",
204 labels[alchemyLabelKeys.id] ?? "",
205 )
206 );
207 });
208
209export const ownershipLabels = (id: string) => createInternalLabels(id);
210
211export const toDisplayName = (
212 id: string,
213 requested: string | undefined,
214 existing: string | undefined,
215 maxLength = MAX_CONFIGURATION_NAME_LENGTH,
216) =>
217 Effect.gen(function* () {
218 if (requested !== undefined && requested.length > 0) {
219 return requested.slice(0, maxLength);
220 }
221 if (existing !== undefined && existing.length > 0) {
222 return existing.slice(0, maxLength);
223 }
224 return yield* createPhysicalName({
225 id,
226 maxLength: Math.min(40, maxLength),
227 lowercase: true,
228 });
229 });
230
231const emptyList = <A>() => Effect.succeed([] as A[]);
232
233export const collectPages = <Page, A, E, R>(
234 pages: Stream.Stream<Page, E, R>,
235 items: (page: Page) => readonly A[] | undefined,
236) =>
237 pages.pipe(
238 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
239 Stream.runCollect,
240 Effect.map((chunk) => Array.from(chunk)),
241 );
242
243export const getConfiguration = (name: string) =>
244 name.length === 0
245 ? Effect.succeed(undefined)
246 : androiddeviceprovisioning
247 .getCustomersConfigurations({ name })
248 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
249
250export const listConfigurationsAt = (parent: string) =>
251 parent.length === 0
252 ? emptyList<androiddeviceprovisioning.Configuration>()
253 : androiddeviceprovisioning
254 .listCustomersConfigurations({ parent: toCustomerName(parent) })
255 .pipe(
256 Effect.map((page) => page.configurations ?? []),
257 Effect.catchTag("NotFound", () =>
258 emptyList<androiddeviceprovisioning.Configuration>(),
259 ),
260 );
261
262export const listDpcsAt = (parent: string) =>
263 parent.length === 0
264 ? emptyList<androiddeviceprovisioning.Dpc>()
265 : androiddeviceprovisioning
266 .listCustomersDpcs({ parent: toCustomerName(parent) })
267 .pipe(
268 Effect.map((page) => page.dpcs ?? []),
269 Effect.catchTag("NotFound", () =>
270 emptyList<androiddeviceprovisioning.Dpc>(),
271 ),
272 );
273
274export const pickDpcName = (parent: string) =>
275 Effect.gen(function* () {
276 const dpcs = yield* listDpcsAt(parent);
277 const preferred = dpcs.find(
278 (dpc) => dpc.packageName === ANDROID_DEVICE_POLICY_PACKAGE && dpc.name,
279 );
280 return preferred?.name ?? dpcs.find((dpc) => dpc.name)?.name;
281 });
282
283/** Whitespace/comma-separated list setting from the ConfigProvider (env by default). */
284const listSetting = (keys: readonly string[]) =>
285 Effect.gen(function* () {
286 const values: string[] = [];
287 for (const key of keys) {
288 const raw = Option.getOrUndefined(
289 yield* Config.option(Config.String(key)),
290 )?.trim();
291 if (!raw) continue;
292 for (const part of raw.split(/[,\s]+/)) {
293 if (part.length > 0) values.push(part);
294 }
295 }
296 return values;
297 });
298
299/** Extra customers for `list` (nuke), e.g. `GCP_ANDROIDDEVICEPROVISIONING_CUSTOMERS`. */
300export const configuredCustomers = () =>
301 listSetting([
302 "GCP_ANDROIDDEVICEPROVISIONING_CUSTOMER",
303 "GCP_ANDROIDDEVICEPROVISIONING_CUSTOMER_ID",
304 "GCP_ANDROIDDEVICEPROVISIONING_CUSTOMERS",
305 ]).pipe(
306 Effect.map((values) => [
307 ...new Set(values.map(toCustomerName).filter((name) => name.length > 0)),
308 ]),
309 );
310
311export const listCustomersAt = () =>
312 collectPages(
313 androiddeviceprovisioning.listCustomers.pages({ pageSize: 100 }),
314 (page) => page.customers,
315 ).pipe(
316 Effect.catchTag("NotFound", () =>
317 emptyList<androiddeviceprovisioning.Company>(),
318 ),
319 );
320
321export const listCustomerNames = () =>
322 Effect.gen(function* () {
323 const configured = yield* configuredCustomers();
324 const names = new Set(configured);
325 const listed = yield* listCustomersAt();
326 for (const company of listed) {
327 const fromName = company.name ? toCustomerName(company.name) : "";
328 const fromId = company.companyId ? toCustomerName(company.companyId) : "";
329 if (fromName.length > 0) names.add(fromName);
330 else if (fromId.length > 0) names.add(fromId);
331 }
332 return [...names];
333 });
334
335export const findOwnedConfiguration = (id: string, parent: string) =>
336 Effect.gen(function* () {
337 const rows = yield* listConfigurationsAt(parent);
338 for (const row of rows) {
339 if (yield* ownedByAlchemy(id, row.configurationName)) {
340 return row;
341 }
342 }
343 return undefined;
344 });
345
346export const findConfigurationByName = (
347 configurationName: string,
348 parent: string,
349) =>
350 Effect.gen(function* () {
351 const rows = yield* listConfigurationsAt(parent);
352 return rows.find((row) => row.configurationName === configurationName);
353 });
354
355export const listOwnedConfigurations = () =>
356 Effect.gen(function* () {
357 const parents = yield* listCustomerNames();
358 const pages = yield* Effect.forEach(
359 parents,
360 (parent) => listConfigurationsAt(parent),
361 { concurrency: 4 },
362 );
363 return pages
364 .flat()
365 .filter((row) => hasOwnershipMarker(row.configurationName));
366 });