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";
16
17const MAX_NAME_LENGTH = 255;
18
19export type KeyvaluemapsEntryProps = {
20 /**
21 * Apigee organization id. Defaults to the current GCP project id.
22 * Immutable — changing it replaces the entry.
23 */
24 organization?: string;
25 /**
26 * Parent key value map id or full name
27 * (`organizations/{org}/keyvaluemaps/{keyvaluemap}`). Immutable —
28 * changing it replaces the entry.
29 */
30 map: string;
31 /**
32 * Entry key (the `{entry}` segment of
33 * `organizations/{org}/keyvaluemaps/{keyvaluemap}/entries/{entry}`).
34 * If omitted, a unique name is generated from the stack, stage, and
35 * logical id. Immutable — changing it replaces the entry. Must not be
36 * `__alchemy` (reserved by {@link Keyvaluemap} for ownership).
37 */
38 entryId?: string;
39 /**
40 * Payload associated with the key, stored verbatim.
41 */
42 value: string;
43};
44
45export type KeyvaluemapsEntry = Resource<
46 "GCP.Apigee.KeyvaluemapsEntry",
47 KeyvaluemapsEntryProps,
48 {
49 /** Full resource name `organizations/{org}/keyvaluemaps/{map}/entries/{entry}`. */
50 name: string;
51 /** Entry key (last path segment). */
52 entryId: string;
53 /** Parent map id. */
54 mapId: string;
55 /** Apigee organization id. */
56 organization: string;
57 /** Payload associated with the key. */
58 value: string;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * An organization-scoped Apigee key value map entry.
66 *
67 * Entries have no labels field and the value is read by proxies, so
68 * Alchemy stores it verbatim and identifies its entries by the generated
69 * key: `read` reports an entry with an explicit `entryId` as unowned
70 * (adopt it with `--adopt`). Organization, map, and key are identity —
71 * changing them replaces the entry. `value` updates in place.
72 *
73 * ### Creating an Entry
74 * **Example:** Generated key on a map
75 * ```typescript
76 * const kv = yield* GCP.Apigee.Keyvaluemap("Config", {});
77 * const entry = yield* GCP.Apigee.KeyvaluemapsEntry("ApiKey", {
78 * map: kv.name,
79 * value: "secret-value",
80 * });
81 * ```
82 *
83 * **Example:** Explicit key
84 * ```typescript
85 * const entry = yield* GCP.Apigee.KeyvaluemapsEntry("ApiKey", {
86 * map: kv.name,
87 * entryId: "api-key",
88 * value: "secret-value",
89 * });
90 * ```
91 *
92 * @resource
93 * @category Apigee
94 */
95export const KeyvaluemapsEntry = Resource<KeyvaluemapsEntry>(
96 "GCP.Apigee.KeyvaluemapsEntry",
97);
98
99export class KeyvaluemapsEntryNotResolved extends Data.TaggedError(
100 "GCP.Apigee.KeyvaluemapsEntryNotResolved",
101)<{
102 name: string;
103}> {}
104
105const mapIdOf = (map: string) => lastSegment(map);
106
107const mapName = (organization: string, map: string) =>
108 map.includes("/") ? map : `${orgParent(organization)}/keyvaluemaps/${map}`;
109
110const resourceName = (organization: string, map: string, entryId: string) =>
111 `${mapName(organization, map)}/entries/${entryId}`;
112
113const toAttrs = (
114 entry: apigee.GoogleCloudApigeeV1KeyValueEntry,
115 organization: string,
116 mapId: string,
117) => {
118 const raw = entry.name ?? "";
119 const name = raw.includes("/") ? raw : resourceName(organization, mapId, raw);
120 return {
121 name,
122 entryId: lastSegment(name),
123 mapId,
124 organization: organizationFromName(name) ?? organization,
125 value: entry.value ?? "",
126 };
127};
128
129const getByName = (name: string) =>
130 apigee
131 .getOrganizationsKeyvaluemapsEntries({ name })
132 .pipe(
133 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
134 Effect.succeed(undefined),
135 ),
136 );
137
138export const KeyvaluemapsEntryProvider = () =>
139 Provider.succeed(KeyvaluemapsEntry, {
140 stables: ["name", "entryId", "mapId", "organization"],
141
142 diff: Effect.fn(function* ({ news, olds, output }) {
143 if (!isResolved(news)) return undefined;
144 const previousId = olds?.entryId ?? output?.entryId;
145 const previousMap = olds?.map ?? output?.mapId;
146 const previousOrg = olds?.organization ?? output?.organization;
147 if (
148 (previousId !== undefined &&
149 news.entryId !== undefined &&
150 news.entryId !== previousId) ||
151 (previousMap !== undefined &&
152 mapIdOf(news.map) !== mapIdOf(previousMap)) ||
153 (previousOrg !== undefined &&
154 news.organization !== undefined &&
155 news.organization !== previousOrg)
156 ) {
157 return { action: "replace" as const, deleteFirst: true };
158 }
159 return undefined;
160 }),
161
162 read: Effect.fn(function* ({ id, olds, output }) {
163 const env = yield* GcpEnvironment.current;
164 const organization =
165 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
166 const mapId = mapIdOf(olds?.map ?? output?.mapId ?? "");
167 if (mapId.length === 0) return undefined;
168 const entryId = yield* toResourceId(
169 id,
170 olds?.entryId,
171 output?.entryId,
172 MAX_NAME_LENGTH,
173 );
174 const name = output?.name ?? resourceName(organization, mapId, entryId);
175 const existing = yield* getByName(name);
176 if (existing === undefined) return undefined;
177 const attrs = toAttrs(existing, organization, mapId);
178 const generated = yield* toResourceId(
179 id,
180 undefined,
181 undefined,
182 MAX_NAME_LENGTH,
183 );
184 return attrs.entryId === generated ? attrs : Unowned(attrs);
185 }),
186
187 reconcile: Effect.fn(function* ({ id, news, output }) {
188 const env = yield* GcpEnvironment.current;
189 const organization =
190 news.organization ?? output?.organization ?? env.project;
191 const mapId = mapIdOf(news.map);
192 const entryId = yield* toResourceId(
193 id,
194 news.entryId,
195 output?.entryId,
196 MAX_NAME_LENGTH,
197 );
198 const parent = mapName(organization, news.map);
199 const name = resourceName(organization, mapId, entryId);
200 const desiredValue = news.value;
201
202 let current = yield* getByName(output?.name ?? name);
203
204 if (current === undefined) {
205 const created = yield* apigee
206 .createOrganizationsKeyvaluemapsEntries({
207 parent,
208 body: { name: entryId, value: desiredValue },
209 })
210 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
211 current = created ?? undefined;
212 }
213
214 if (current === undefined) {
215 return yield* new KeyvaluemapsEntryNotResolved({ name });
216 }
217
218 if ((current.value ?? "") !== desiredValue) {
219 current = yield* apigee.updateOrganizationsKeyvaluemapsEntries({
220 name: current.name?.includes("/") ? current.name : name,
221 body: { name: entryId, value: desiredValue },
222 });
223 }
224
225 return toAttrs(current, organization, mapId);
226 }),
227
228 delete: Effect.fn(function* ({ output }) {
229 yield* apigee
230 .deleteOrganizationsKeyvaluemapsEntries({ name: output.name })
231 .pipe(
232 Effect.catchTag(
233 ["NotFound", "ApigeeResourceNotFound"],
234 () => Effect.void,
235 ),
236 );
237 }),
238 });