1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 fromAttributes,
14 toAttributes,
15 userAttributeList,
16 type Attribute,
17} from "./ownership.ts";
18import {
19 lastSegment,
20 orgParent,
21 resolveOrgId,
22 sameJson,
23 toPhysicalId,
24} from "./operations.ts";
25
26const MAX_NAME_LENGTH = 255;
27
28export type AppgroupProps = {
29 /**
30 * Apigee organization id. Defaults to the GCP project id. Immutable.
31 */
32 organizationId?: string;
33 /**
34 * AppGroup name (`A-Z0-9._-$ %`). If omitted, a unique name is
35 * generated. Immutable.
36 */
37 appgroupId?: string;
38 /**
39 * Name shown in the UI.
40 */
41 displayName?: string;
42 /**
43 * Contact email for the AppGroup.
44 */
45 email?: string;
46 /**
47 * Channel identifier of the owner maintaining this grouping.
48 */
49 channelId?: string;
50 /**
51 * Reference to the associated storefront or marketplace.
52 */
53 channelUri?: string;
54 /**
55 * `active` or `inactive`. Status is synced via the update `action`
56 * query parameter.
57 * @default "active"
58 */
59 status?: string;
60 /**
61 * Customer attributes. Alchemy ownership attributes are merged in
62 * automatically so `list` / nuke can find the group.
63 */
64 attributes?: Attribute[];
65};
66
67export type Appgroup = Resource<
68 "GCP.Apigee.Appgroup",
69 AppgroupProps,
70 {
71 /** Full resource name `organizations/{org}/appgroups/{appgroup}`. */
72 name: string;
73 /** AppGroup id. */
74 appgroupId: string;
75 /** Organization id. */
76 organizationId: string;
77 /** Project id. */
78 project: string;
79 /** Display name. */
80 displayName: string | undefined;
81 /** Contact email. */
82 email: string | undefined;
83 /** Channel id. */
84 channelId: string | undefined;
85 /** Channel URI. */
86 channelUri: string | undefined;
87 /** `active` or `inactive`. */
88 status: string | undefined;
89 /** User attributes (Alchemy ownership attributes stripped). */
90 attributes: Attribute[];
91 /** Internal AppGroup id. */
92 appGroupId: string | undefined;
93 /** Creation time in milliseconds since epoch. */
94 createdAt: string | undefined;
95 /** Last modification time in milliseconds since epoch. */
96 lastModifiedAt: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * An Apigee AppGroup — a logical grouping of apps that share credentials.
104 *
105 * AppGroups have no labels field. Alchemy stamps ownership into
106 * attributes so `list` / nuke can find them. The name is immutable.
107 *
108 * ### Creating an AppGroup
109 * **Example:** Generated name
110 * ```typescript
111 * const group = yield* GCP.Apigee.Appgroup("Partners", {
112 * displayName: "Partners",
113 * email: "partners@example.com",
114 * });
115 * ```
116 *
117 * **Example:** Inactive group
118 * ```typescript
119 * const group = yield* GCP.Apigee.Appgroup("Partners", {
120 * appgroupId: "partners",
121 * status: "inactive",
122 * });
123 * ```
124 *
125 * @resource
126 * @category Apigee
127 */
128export const Appgroup = Resource<Appgroup>("GCP.Apigee.Appgroup");
129
130export class AppgroupNotResolved extends Data.TaggedError(
131 "GCP.Apigee.AppgroupNotResolved",
132)<{
133 name: string;
134}> {}
135
136const resourceName = (organizationId: string, appgroupId: string) =>
137 `${orgParent(organizationId)}/appgroups/${appgroupId}`;
138
139const toAttrs = (
140 group: apigee.GoogleCloudApigeeV1AppGroup,
141 project: string,
142 organizationId: string,
143) => {
144 const appgroupId = lastSegment(group.name ?? "");
145 const attributes = userAttributeList(group.attributes);
146 return {
147 name: resourceName(organizationId, appgroupId),
148 appgroupId,
149 organizationId: group.organization ?? organizationId,
150 project,
151 displayName: group.displayName,
152 email: group.email,
153 channelId: group.channelId,
154 channelUri: group.channelUri,
155 status: group.status,
156 attributes,
157 appGroupId: group.appGroupId,
158 createdAt: group.createdAt,
159 lastModifiedAt: group.lastModifiedAt,
160 };
161};
162
163const getByName = (name: string) =>
164 apigee
165 .getOrganizationsAppgroups({ name })
166 .pipe(
167 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
168 Effect.succeed(undefined),
169 ),
170 );
171
172export const AppgroupProvider = () =>
173 Provider.succeed(Appgroup, {
174 stables: [
175 "name",
176 "appgroupId",
177 "organizationId",
178 "project",
179 "appGroupId",
180 "createdAt",
181 ],
182
183 diff: Effect.fn(function* ({ news, olds, output }) {
184 if (!isResolved(news)) return undefined;
185 const previousId = olds?.appgroupId ?? output?.appgroupId;
186 const previousOrg = olds?.organizationId ?? output?.organizationId;
187 if (
188 (previousId !== undefined &&
189 news.appgroupId !== undefined &&
190 news.appgroupId !== previousId) ||
191 (previousOrg !== undefined &&
192 news.organizationId !== undefined &&
193 news.organizationId !== previousOrg)
194 ) {
195 return { action: "replace" as const, deleteFirst: false };
196 }
197 return undefined;
198 }),
199
200 read: Effect.fn(function* ({ id, olds, output }) {
201 const env = yield* GcpEnvironment.current;
202 const organizationId =
203 olds?.organizationId ??
204 output?.organizationId ??
205 (yield* resolveOrgId(env.project));
206 const appgroupId = yield* toPhysicalId(
207 id,
208 olds?.appgroupId,
209 output?.appgroupId,
210 MAX_NAME_LENGTH,
211 );
212 const name = output?.name ?? resourceName(organizationId, appgroupId);
213 const existing = yield* getByName(name);
214 if (existing === undefined) return undefined;
215 const attrs = toAttrs(existing, env.project, organizationId);
216 const { labels } = fromAttributes(existing.attributes);
217 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
218 }),
219
220 list: () =>
221 Effect.gen(function* () {
222 const env = yield* GcpEnvironment.current;
223 const organizationId = yield* resolveOrgId(env.project);
224 return yield* apigee.listOrganizationsAppgroups
225 .pages({
226 parent: orgParent(organizationId),
227 pageSize: 1000,
228 })
229 .pipe(
230 Stream.flatMap((page) => Stream.fromIterable(page.appGroups ?? [])),
231 Stream.filter(
232 (group) =>
233 fromAttributes(group.attributes).labels["alchemy-id"] !==
234 undefined,
235 ),
236 Stream.map((group) => toAttrs(group, env.project, organizationId)),
237 Stream.runCollect,
238 Effect.map((chunk) => Array.from(chunk)),
239 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
240 Effect.succeed([]),
241 ),
242 );
243 }),
244
245 reconcile: Effect.fn(function* ({ id, news, output }) {
246 const env = yield* GcpEnvironment.current;
247 const organizationId =
248 news.organizationId ??
249 output?.organizationId ??
250 (yield* resolveOrgId(env.project));
251 const appgroupId = yield* toPhysicalId(
252 id,
253 news.appgroupId,
254 output?.appgroupId,
255 MAX_NAME_LENGTH,
256 );
257 const name = resourceName(organizationId, appgroupId);
258 const ownership = yield* createInternalLabels(id);
259 const desiredAttributes = toAttributes(ownership, news.attributes);
260 const desiredStatus = news.status ?? "active";
261 const body: apigee.GoogleCloudApigeeV1AppGroup = {
262 name: appgroupId,
263 displayName: news.displayName ?? appgroupId,
264 email: news.email,
265 channelId: news.channelId,
266 channelUri: news.channelUri,
267 attributes: desiredAttributes,
268 };
269
270 let current = yield* getByName(output?.name ?? name);
271
272 if (current === undefined) {
273 const created = yield* apigee
274 .createOrganizationsAppgroups({
275 parent: orgParent(organizationId),
276 body,
277 })
278 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
279 current = created ?? undefined;
280 }
281
282 if (current === undefined) {
283 return yield* new AppgroupNotResolved({ name });
284 }
285
286 const needsUpdate =
287 (current.displayName ?? "") !== (body.displayName ?? "") ||
288 (current.email ?? "") !== (news.email ?? "") ||
289 (current.channelId ?? "") !== (news.channelId ?? "") ||
290 (current.channelUri ?? "") !== (news.channelUri ?? "") ||
291 !sameJson(current.attributes ?? [], desiredAttributes);
292
293 if (needsUpdate) {
294 current = yield* apigee.updateOrganizationsAppgroups({
295 name,
296 body,
297 });
298 }
299
300 if ((current.status ?? "active") !== desiredStatus) {
301 current = yield* apigee.updateOrganizationsAppgroups({
302 name,
303 action: desiredStatus,
304 });
305 }
306
307 return toAttrs(current, env.project, organizationId);
308 }),
309
310 delete: Effect.fn(function* ({ output }) {
311 yield* apigee
312 .deleteOrganizationsAppgroups({ name: output.name })
313 .pipe(
314 Effect.catchTag(
315 ["NotFound", "ApigeeResourceNotFound"],
316 () => Effect.void,
317 ),
318 );
319 }),
320 });