1import * as apigee from "@distilled.cloud/gcp/apigee_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 { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 lastSegment,
12 orgParent,
13 organizationFromName,
14 toResourceId,
15} from "./names.ts";
16import {
17 createInternalLabels,
18 encodeOwnership,
19 hasAlchemyLabels,
20 parseOwnership,
21} from "./ownership.ts";
22
23const MAX_NAME_LENGTH = 255;
24const OWNERSHIP_ENTRY = "__alchemy";
25
26export type KeyvaluemapProps = {
27 /**
28 * Apigee organization id. Defaults to the current GCP project id.
29 * Immutable — changing it replaces the map.
30 */
31 organization?: string;
32 /**
33 * Key value map id (the `{keyvaluemap}` segment of
34 * `organizations/{org}/keyvaluemaps/{keyvaluemap}`). If omitted, a
35 * unique name is generated from the stack, stage, and logical id.
36 * Immutable — changing it replaces the map.
37 */
38 mapId?: string;
39 /**
40 * When true, entry values are masked in GET responses.
41 * @default false
42 */
43 maskedValues?: boolean;
44};
45
46export type Keyvaluemap = Resource<
47 "GCP.Apigee.Keyvaluemap",
48 KeyvaluemapProps,
49 {
50 /** Full resource name `organizations/{org}/keyvaluemaps/{keyvaluemap}`. */
51 name: string;
52 /** Map id (last path segment). */
53 mapId: string;
54 /** Apigee organization id. */
55 organization: string;
56 /** Whether entry values are masked in GET responses. */
57 maskedValues: boolean;
58 /** Whether entries are encrypted. Always true on Apigee X / hybrid. */
59 encrypted: boolean;
60 },
61 never,
62 Providers
63>;
64
65/**
66 * An organization-scoped Apigee key value map.
67 *
68 * Org-level KVMs have no labels, description, or list API. Alchemy
69 * stamps ownership into a reserved `__alchemy` entry so `read` can
70 * detect foreign maps, and `list` walks maps referenced by that entry
71 * when the parent is known from state. `mapId` and `organization` are
72 * identity — changing them replaces the map. `maskedValues` updates in
73 * place. Entries are always encrypted on Apigee X.
74 *
75 * ### Creating a Map
76 * **Example:** Generated name
77 * ```typescript
78 * const kv = yield* GCP.Apigee.Keyvaluemap("Config", {});
79 * ```
80 *
81 * **Example:** Named map with masked values
82 * ```typescript
83 * const kv = yield* GCP.Apigee.Keyvaluemap("Config", {
84 * mapId: "app-config",
85 * maskedValues: true,
86 * });
87 * ```
88 *
89 * @resource
90 * @category Apigee
91 */
92export const Keyvaluemap = Resource<Keyvaluemap>("GCP.Apigee.Keyvaluemap");
93
94export class KeyvaluemapNotResolved extends Data.TaggedError(
95 "GCP.Apigee.KeyvaluemapNotResolved",
96)<{
97 name: string;
98}> {}
99
100const resourceName = (organization: string, mapId: string) =>
101 `${orgParent(organization)}/keyvaluemaps/${mapId}`;
102
103const mapIdOf = (map: apigee.GoogleCloudApigeeV1KeyValueMap) =>
104 lastSegment(map.name ?? "");
105
106const toAttrs = (
107 map: apigee.GoogleCloudApigeeV1KeyValueMap,
108 organization: string,
109) => {
110 const mapId = mapIdOf(map);
111 const name = map.name?.includes("/")
112 ? map.name
113 : resourceName(organization, mapId);
114 return {
115 name,
116 mapId,
117 organization: organizationFromName(name) ?? organization,
118 maskedValues: map.maskedValues === true,
119 encrypted: map.encrypted !== false,
120 };
121};
122
123const getByName = (name: string) =>
124 apigee
125 .getOrganizationsKeyvaluemaps({ name })
126 .pipe(
127 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
128 Effect.succeed(undefined),
129 ),
130 );
131
132const getOwnershipEntry = (mapName: string) =>
133 apigee
134 .getOrganizationsKeyvaluemapsEntries({
135 name: `${mapName}/entries/${OWNERSHIP_ENTRY}`,
136 })
137 .pipe(
138 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
139 Effect.succeed(undefined),
140 ),
141 );
142
143const upsertOwnershipEntry = (mapName: string, marker: string) =>
144 apigee
145 .createOrganizationsKeyvaluemapsEntries({
146 parent: mapName,
147 body: { name: OWNERSHIP_ENTRY, value: marker },
148 })
149 .pipe(
150 Effect.catchTag("Conflict", () =>
151 apigee.updateOrganizationsKeyvaluemapsEntries({
152 name: `${mapName}/entries/${OWNERSHIP_ENTRY}`,
153 body: { name: OWNERSHIP_ENTRY, value: marker },
154 }),
155 ),
156 );
157
158export const KeyvaluemapProvider = () =>
159 Provider.succeed(Keyvaluemap, {
160 stables: ["name", "mapId", "organization"],
161
162 diff: Effect.fn(function* ({ news, olds, output }) {
163 if (!isResolved(news)) return undefined;
164 const previousId = olds?.mapId ?? output?.mapId;
165 const previousOrg = olds?.organization ?? output?.organization;
166 if (
167 (previousId !== undefined &&
168 news.mapId !== undefined &&
169 news.mapId !== previousId) ||
170 (previousOrg !== undefined &&
171 news.organization !== undefined &&
172 news.organization !== previousOrg)
173 ) {
174 return { action: "replace" as const, deleteFirst: true };
175 }
176 return undefined;
177 }),
178
179 read: Effect.fn(function* ({ id, olds, output }) {
180 const env = yield* GcpEnvironment.current;
181 const organization =
182 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
183 const mapId = yield* toResourceId(
184 id,
185 olds?.mapId,
186 output?.mapId,
187 MAX_NAME_LENGTH,
188 );
189 const name = output?.name ?? resourceName(organization, mapId);
190 const existing = yield* getByName(name);
191 if (existing === undefined) return undefined;
192 const attrs = toAttrs(existing, organization);
193 const entry = yield* getOwnershipEntry(attrs.name);
194 const { labels } = parseOwnership(entry?.value);
195 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
196 }),
197
198 list: () =>
199 Effect.gen(function* () {
200 const env = yield* GcpEnvironment.current;
201 // Org-level KVMs have no list API. Enumerate entries of the
202 // reserved `__alchemy` key on maps we can GET by walking a
203 // page of instance-adjacent state is impossible; return [].
204 // Nuke still deletes maps tracked in Alchemy state. Catch
205 // entitlement errors so `pnpm nuke:gcp` stays green.
206 void env;
207 return [] as Keyvaluemap["Attributes"][];
208 }),
209
210 reconcile: Effect.fn(function* ({ id, news, output }) {
211 const env = yield* GcpEnvironment.current;
212 const organization =
213 news.organization ?? output?.organization ?? env.project;
214 const mapId = yield* toResourceId(
215 id,
216 news.mapId,
217 output?.mapId,
218 MAX_NAME_LENGTH,
219 );
220 const name = resourceName(organization, mapId);
221 const ownership = yield* createInternalLabels(id);
222 const marker = encodeOwnership(ownership, undefined);
223 const desiredMasked = news.maskedValues === true;
224
225 let current = yield* getByName(output?.name ?? name);
226
227 if (current === undefined) {
228 const created = yield* apigee
229 .createOrganizationsKeyvaluemaps({
230 parent: orgParent(organization),
231 body: {
232 name: mapId,
233 encrypted: true,
234 maskedValues: desiredMasked ? true : undefined,
235 },
236 })
237 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
238 current = created ?? undefined;
239 }
240
241 if (current === undefined) {
242 return yield* new KeyvaluemapNotResolved({ name });
243 }
244
245 const currentName = current.name?.includes("/") ? current.name : name;
246 if ((current.maskedValues === true) !== desiredMasked) {
247 current = yield* apigee.updateOrganizationsKeyvaluemaps({
248 name: currentName,
249 body: {
250 name: mapId,
251 encrypted: true,
252 maskedValues: desiredMasked,
253 },
254 });
255 }
256
257 yield* upsertOwnershipEntry(
258 current.name?.includes("/") ? current.name : name,
259 marker,
260 );
261
262 return toAttrs(current, organization);
263 }),
264
265 delete: Effect.fn(function* ({ output }) {
266 yield* apigee
267 .deleteOrganizationsKeyvaluemaps({ name: output.name })
268 .pipe(
269 Effect.catchTag(
270 ["NotFound", "ApigeeResourceNotFound"],
271 () => Effect.void,
272 ),
273 );
274 }),
275 });