1import * as cloudidentity from "@distilled.cloud/gcp/cloudidentity_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 encodeOwnershipLine,
11 findOwnedDevice,
12 getDevice,
13 hasOwnershipMarker,
14 lastSegment,
15 listDevices,
16 MAX_ASSET_TAG_LENGTH,
17 normalizeCustomer,
18 ownedByAlchemy,
19 ownershipLabels,
20 parseOwnership,
21 replaceOnIdentity,
22 toPhysicalId,
23} from "./internal.ts";
24import {
25 resourceNameFromOperation,
26 waitForOperation,
27 waitUntilPresent,
28} from "./operations.ts";
29
30export type DeviceProps = {
31 /**
32 * Customer that owns the device (`customers/my_customer` or
33 * `customers/{customer}`). Immutable — changing it replaces the
34 * device.
35 * @default "customers/my_customer"
36 */
37 customer?: string;
38 /**
39 * Serial number. If omitted, a unique value is generated. Immutable
40 * — changing it replaces the device. Only company-owned devices can
41 * be created.
42 */
43 serialNumber?: string;
44 /**
45 * Device type. Immutable — changing it replaces the device.
46 * @default "LINUX"
47 */
48 deviceType?:
49 | cloudidentity.GoogleAppsCloudidentityDevicesV1DeviceDeviceTypeEnum
50 | (string & {});
51 /**
52 * Asset tag. Devices have no labels field, so Alchemy stores
53 * ownership in a `[alchemy …]` prefix and strips it from attributes.
54 */
55 assetTag?: string;
56 /**
57 * Host name.
58 */
59 hostname?: string;
60 /**
61 * WiFi MAC addresses.
62 */
63 wifiMacAddresses?: string[];
64 /**
65 * Partner-specified unique device identifier.
66 */
67 deviceId?: string;
68};
69
70export type Device = Resource<
71 "GCP.CloudIdentity.Device",
72 DeviceProps,
73 {
74 /** Resource name `devices/{device}`. */
75 name: string;
76 /** Device id (last path segment). */
77 deviceResourceId: string;
78 /** Customer used on create. */
79 customer: string;
80 /** Serial number. */
81 serialNumber: string | undefined;
82 /** Device type. */
83 deviceType: string | undefined;
84 /** Asset tag with the Alchemy ownership prefix stripped. */
85 assetTag: string | undefined;
86 /** Host name. */
87 hostname: string | undefined;
88 /** WiFi MAC addresses. */
89 wifiMacAddresses: string[] | undefined;
90 /** Partner device id. */
91 deviceId: string | undefined;
92 /** Owner type (`COMPANY` or `BYOD`). */
93 ownerType: string | undefined;
94 /** Management state. */
95 managementState: string | undefined;
96 /** Compromised state. */
97 compromisedState: string | undefined;
98 /** Encryption state. */
99 encryptionState: string | undefined;
100 /** RFC3339 import timestamp. */
101 createTime: string | undefined;
102 /** Last sync time. */
103 lastSyncTime: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A Cloud Identity company-owned device.
111 *
112 * Device create is limited to company-owned inventory (Enterprise /
113 * Cloud Identity Premium). There is no update API — serial number,
114 * type, and customer are identity. Alchemy stamps ownership into
115 * `assetTag` for `list` / nuke.
116 *
117 * ### Creating a Device
118 * **Example:** Generated serial
119 * ```typescript
120 * const device = yield* GCP.CloudIdentity.Device("Laptop", {
121 * deviceType: "LINUX",
122 * hostname: "eng-laptop",
123 * });
124 * ```
125 *
126 * **Example:** Explicit serial and asset tag
127 * ```typescript
128 * const device = yield* GCP.CloudIdentity.Device("Laptop", {
129 * serialNumber: "HT82V1A01076",
130 * deviceType: "LINUX",
131 * assetTag: "desk-14",
132 * });
133 * ```
134 *
135 * @resource
136 * @category CloudIdentity
137 */
138export const Device = Resource<Device>("GCP.CloudIdentity.Device");
139
140export class DeviceNotResolved extends Data.TaggedError(
141 "GCP.CloudIdentity.DeviceNotResolved",
142)<{
143 name: string;
144}> {}
145
146const DEFAULT_DEVICE_TYPE = "LINUX";
147
148const toAttrs = (
149 device: cloudidentity.GoogleAppsCloudidentityDevicesV1Device,
150 customer: string,
151) => {
152 const name = device.name ?? "";
153 return {
154 name,
155 deviceResourceId: lastSegment(name),
156 customer,
157 serialNumber: device.serialNumber,
158 deviceType: device.deviceType,
159 assetTag: parseOwnership(device.assetTag).text,
160 hostname: device.hostname,
161 wifiMacAddresses: device.wifiMacAddresses,
162 deviceId: device.deviceId,
163 ownerType: device.ownerType,
164 managementState: device.managementState,
165 compromisedState: device.compromisedState,
166 encryptionState: device.encryptionState,
167 createTime: device.createTime,
168 lastSyncTime: device.lastSyncTime,
169 };
170};
171
172const observeDevice = (input: {
173 id: string;
174 name?: string;
175 serialNumber?: string;
176 customer: string;
177}) =>
178 Effect.gen(function* () {
179 const byName = yield* getDevice(input.name ?? "", input.customer);
180 if (byName !== undefined) return byName;
181 return yield* findOwnedDevice(input.id, input.serialNumber, input.customer);
182 });
183
184export const DeviceProvider = () =>
185 Provider.succeed(Device, {
186 stables: [
187 "name",
188 "deviceResourceId",
189 "customer",
190 "serialNumber",
191 "createTime",
192 ],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 const previousCustomer = olds?.customer ?? output?.customer;
197 const nextCustomer =
198 news.customer !== undefined
199 ? normalizeCustomer(news.customer)
200 : previousCustomer;
201 const previousType = olds?.deviceType ?? output?.deviceType;
202 const nextType = news.deviceType ?? DEFAULT_DEVICE_TYPE;
203 return replaceOnIdentity({
204 previousId: olds?.serialNumber ?? output?.serialNumber,
205 nextId: news.serialNumber,
206 previousParent: previousCustomer,
207 nextParent: nextCustomer,
208 extra: previousType !== undefined && previousType !== nextType,
209 });
210 }),
211
212 read: Effect.fn(function* ({ id, olds, output }) {
213 const customer = normalizeCustomer(olds?.customer ?? output?.customer);
214 const existing = yield* observeDevice({
215 id,
216 name: output?.name,
217 serialNumber: olds?.serialNumber ?? output?.serialNumber,
218 customer,
219 });
220 if (existing === undefined) return undefined;
221 const attrs = toAttrs(existing, customer);
222 return (yield* ownedByAlchemy(id, existing.assetTag))
223 ? attrs
224 : Unowned(attrs);
225 }),
226
227 list: () =>
228 Effect.gen(function* () {
229 const devices = yield* listDevices();
230 return devices
231 .filter((device) => hasOwnershipMarker(device.assetTag))
232 .map((device) => toAttrs(device, DEFAULT_CUSTOMER_FALLBACK));
233 }),
234
235 reconcile: Effect.fn(function* ({ id, news, output }) {
236 const customer = normalizeCustomer(news.customer ?? output?.customer);
237 const ownership = yield* ownershipLabels(id);
238 const serialNumber = yield* toPhysicalId(
239 id,
240 news.serialNumber,
241 output?.serialNumber,
242 20,
243 );
244 const assetTag = encodeOwnershipLine(
245 ownership,
246 news.assetTag,
247 MAX_ASSET_TAG_LENGTH,
248 );
249 const deviceType = news.deviceType ?? DEFAULT_DEVICE_TYPE;
250 const desired: cloudidentity.GoogleAppsCloudidentityDevicesV1Device = {
251 serialNumber,
252 deviceType,
253 assetTag,
254 hostname: news.hostname,
255 wifiMacAddresses: news.wifiMacAddresses,
256 deviceId: news.deviceId,
257 };
258
259 let current = yield* observeDevice({
260 id,
261 name: output?.name,
262 serialNumber,
263 customer,
264 });
265
266 if (current === undefined) {
267 const created = yield* cloudidentity
268 .createDevices({
269 customer,
270 body: desired,
271 })
272 .pipe(
273 Effect.catchTag("Conflict", () =>
274 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
275 ),
276 );
277 if (created !== undefined) {
278 yield* waitForOperation(created).pipe(
279 Effect.catchTag(
280 "GCP.CloudIdentity.OperationPending",
281 () => Effect.void,
282 ),
283 );
284 const createdName = resourceNameFromOperation(created);
285 if (createdName !== undefined) {
286 current = yield* getDevice(createdName, customer);
287 }
288 }
289 if (current === undefined) {
290 current = yield* waitUntilPresent(
291 observeDevice({
292 id,
293 name: output?.name,
294 serialNumber,
295 customer,
296 }),
297 serialNumber,
298 ).pipe(
299 Effect.catchTag("GCP.CloudIdentity.OperationPending", () =>
300 observeDevice({ id, serialNumber, customer }),
301 ),
302 );
303 }
304 }
305
306 if (current === undefined) {
307 return yield* new DeviceNotResolved({
308 name: output?.name ?? serialNumber,
309 });
310 }
311
312 return toAttrs(current, customer);
313 }),
314
315 delete: Effect.fn(function* ({ output }) {
316 if (output.name.length === 0) return;
317 const deleted = yield* cloudidentity
318 .deleteDevices({
319 name: output.name,
320 customer: normalizeCustomer(output.customer),
321 })
322 .pipe(
323 Effect.catchTag("NotFound", () =>
324 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
325 ),
326 );
327 if (deleted !== undefined) {
328 yield* waitForOperation(deleted, { notFoundOk: true });
329 }
330 }),
331 });
332
333const DEFAULT_CUSTOMER_FALLBACK = "customers/my_customer";