1import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 ResourceNotResolved,
12 encodeOwnership,
13 expandRepository,
14 fieldMask,
15 hasOwnershipMarker,
16 listAlchemyRepositories,
17 listChildResources,
18 listRules,
19 locationFromRepository,
20 missingGet,
21 normalizeLocation,
22 parseName,
23 parseOwnership,
24 replaceOnIdentity,
25 sameJson,
26 sameText,
27 toPhysicalId,
28} from "./internal.ts";
29
30const DEFAULT_OPERATION = "DOWNLOAD";
31const OWNERSHIP_TRUE = "true";
32
33export type RuleCondition = {
34 /** CEL expression that must match for the rule to apply. */
35 expression?: string;
36 /** Short title for the expression. */
37 title?: string;
38 /** Longer description of the expression. */
39 description?: string;
40 /** Source location used in error reporting. */
41 location?: string;
42};
43
44export type RepositoriesRuleProps = {
45 /**
46 * Parent repository. Full name
47 * `projects/{project}/locations/{location}/repositories/{repository}`
48 * or the repository id (combined with `location`). Immutable —
49 * changing it replaces the rule.
50 */
51 repository: string;
52 /**
53 * Region used when `repository` is a bare id.
54 * @default the repository's location, else the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
55 */
56 location?: string;
57 /**
58 * Rule id (the `{rule}` segment of `.../repositories/{repository}/rules/{rule}`).
59 * If omitted, a unique name is generated. Max 256 characters:
60 * alphanumeric plus `-._~:@+^`. Immutable — changing it replaces the
61 * rule.
62 */
63 ruleId?: string;
64 /**
65 * Action applied to matching downloads.
66 */
67 action:
68 | artifactregistry.GoogleDevtoolsArtifactregistryV1RuleActionEnum
69 | (string & {});
70 /**
71 * Operation the rule applies to.
72 * @default "DOWNLOAD"
73 */
74 operation?:
75 | artifactregistry.GoogleDevtoolsArtifactregistryV1RuleOperationEnum
76 | (string & {});
77 /**
78 * Package id this rule applies to. Empty or omitted applies to every
79 * package in the repository. Each repository may have one
80 * repository-level rule and one rule per package.
81 */
82 packageId?: string;
83 /**
84 * Optional CEL condition. Rules have no labels field, so Alchemy stamps
85 * ownership into `condition.title`.
86 */
87 condition?: RuleCondition;
88};
89
90export type RepositoriesRule = Resource<
91 "GCP.ArtifactRegistry.RepositoriesRule",
92 RepositoriesRuleProps,
93 {
94 /** Full resource name `projects/{project}/locations/{location}/repositories/{repository}/rules/{rule}`. */
95 name: string;
96 /** Rule id (last path segment). */
97 ruleId: string;
98 /** Parent repository resource name. */
99 repository: string;
100 /** Project id. */
101 project: string;
102 /** Location id (`us-central1`). */
103 location: string;
104 /** Action (`ALLOW` or `DENY`). */
105 action: string | undefined;
106 /** Operation (`DOWNLOAD`). */
107 operation: string | undefined;
108 /** Package id, if the rule is package-scoped. */
109 packageId: string | undefined;
110 /** User CEL condition with Alchemy ownership stripped from `title`. */
111 condition: RuleCondition | undefined;
112 },
113 never,
114 Providers
115>;
116
117/**
118 * An Artifact Registry download rule on a repository.
119 *
120 * Download rules are a Preview API. Each repository may have one
121 * repository-level rule and one rule per package. Changing `repository`,
122 * `location`, or `ruleId` replaces the rule. `action`, `operation`,
123 * `packageId`, and `condition` update in place.
124 *
125 * Rules have no labels field. Alchemy stamps ownership into
126 * `condition.title` so `list` / `pnpm nuke:gcp` can find them.
127 *
128 * ### Creating a RepositoriesRule
129 * **Example:** Deny all downloads
130 * ```typescript
131 * const rule = yield* GCP.ArtifactRegistry.RepositoriesRule("Deny", {
132 * repository: images.name,
133 * action: "DENY",
134 * });
135 * ```
136 *
137 * **Example:** Allow a package except old versions
138 * ```typescript
139 * const rule = yield* GCP.ArtifactRegistry.RepositoriesRule("AllowApp", {
140 * repository: images.name,
141 * action: "ALLOW",
142 * packageId: "app",
143 * condition: { expression: "pkg.version.id >= '1.0.0'" },
144 * });
145 * ```
146 *
147 * @resource
148 * @category ArtifactRegistry
149 */
150export const RepositoriesRule = Resource<RepositoriesRule>(
151 "GCP.ArtifactRegistry.RepositoriesRule",
152);
153
154const normalizeOperation = (operation: string | undefined) => {
155 const value = (operation ?? DEFAULT_OPERATION).toUpperCase();
156 return value === "OPERATION_UNSPECIFIED" ? DEFAULT_OPERATION : value;
157};
158
159const normalizeAction = (action: string) => action.toUpperCase();
160
161const resourceNameOf = (repository: string, ruleId: string) =>
162 `${repository}/rules/${ruleId}`;
163
164const desiredCondition = (
165 news: RepositoriesRuleProps,
166 ownership: Record<string, string>,
167): artifactregistry.Expr => {
168 const expression =
169 news.condition?.expression && news.condition.expression.length > 0
170 ? news.condition.expression
171 : OWNERSHIP_TRUE;
172 return {
173 expression,
174 title: encodeOwnership(ownership, news.condition?.title),
175 description: news.condition?.description,
176 location: news.condition?.location,
177 };
178};
179
180const toUserCondition = (
181 condition: artifactregistry.Expr | undefined,
182): RuleCondition | undefined => {
183 if (condition === undefined) return undefined;
184 const ownership = parseOwnership(condition.title);
185 const title = ownership.text;
186 const expression = condition.expression;
187 const description = condition.description;
188 const location = condition.location;
189 const ownershipOnly =
190 expression === OWNERSHIP_TRUE &&
191 title === undefined &&
192 (description === undefined || description.length === 0) &&
193 (location === undefined || location.length === 0);
194 if (ownershipOnly) return undefined;
195 return {
196 expression,
197 title,
198 description,
199 location,
200 };
201};
202
203const toAttrs = (
204 rule: artifactregistry.GoogleDevtoolsArtifactregistryV1Rule,
205 project: string,
206 region: string,
207) => {
208 const name = rule.name ?? "";
209 const parsed = parseName(name, "rules", region);
210 return {
211 name,
212 ruleId: parsed.id,
213 repository: parsed.parent,
214 project: parsed.project || project,
215 location: parsed.location,
216 action: rule.action,
217 operation: normalizeOperation(rule.operation),
218 packageId:
219 rule.packageId && rule.packageId.length > 0 ? rule.packageId : undefined,
220 condition: toUserCondition(rule.condition),
221 };
222};
223
224const getByName = missingGet(
225 artifactregistry.getProjectsLocationsRepositoriesRules,
226);
227
228export const RepositoriesRuleProvider = () =>
229 Provider.succeed(RepositoriesRule, {
230 stables: ["name", "ruleId", "repository", "project", "location"],
231
232 diff: Effect.fn(function* ({ news, olds, output }) {
233 if (!isResolved(news)) return undefined;
234 const env = yield* GcpEnvironment.current;
235 const previousId = olds?.ruleId ?? output?.ruleId;
236 const nextId = news.ruleId ?? previousId;
237 const previousLocation = normalizeLocation(
238 olds?.location ?? output?.location,
239 env.region,
240 );
241 const nextLocation = normalizeLocation(
242 news.location ??
243 locationFromRepository(news.repository, previousLocation),
244 env.region,
245 );
246 return replaceOnIdentity({
247 previousId,
248 nextId,
249 previousLocation,
250 nextLocation,
251 previousParent: olds?.repository ?? output?.repository,
252 nextParent: news.repository,
253 });
254 }),
255
256 read: Effect.fn(function* ({ id, olds, output }) {
257 const env = yield* GcpEnvironment.current;
258 const location = normalizeLocation(
259 olds?.location ??
260 output?.location ??
261 locationFromRepository(
262 olds?.repository ?? output?.repository,
263 env.region,
264 ),
265 env.region,
266 );
267 const repository = expandRepository(
268 olds?.repository ?? output?.repository ?? "",
269 env.project,
270 location,
271 );
272 const ruleId = yield* toPhysicalId(
273 id,
274 olds?.ruleId,
275 output?.ruleId,
276 "rule",
277 );
278 const name = output?.name ?? resourceNameOf(repository, ruleId);
279 const existing = yield* getByName(name);
280 if (existing === undefined) return undefined;
281 const attrs = toAttrs(existing, env.project, env.region);
282 const { labels } = parseOwnership(existing.condition?.title);
283 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
284 }),
285
286 list: () =>
287 Effect.gen(function* () {
288 const env = yield* GcpEnvironment.current;
289 const repos = yield* listAlchemyRepositories(env.project);
290 const rules = yield* listChildResources(repos, listRules);
291 return rules
292 .filter((rule) => hasOwnershipMarker(rule.condition?.title))
293 .map((rule) => toAttrs(rule, env.project, env.region));
294 }),
295
296 reconcile: Effect.fn(function* ({ id, news, output }) {
297 const env = yield* GcpEnvironment.current;
298 const location = normalizeLocation(
299 news.location ??
300 output?.location ??
301 locationFromRepository(news.repository, env.region),
302 env.region,
303 );
304 const repository = expandRepository(
305 news.repository,
306 env.project,
307 location,
308 );
309 const ruleId = yield* toPhysicalId(
310 id,
311 news.ruleId,
312 output?.ruleId,
313 "rule",
314 );
315 const name = resourceNameOf(repository, ruleId);
316 const ownership = yield* createInternalLabels(id);
317 const action = normalizeAction(news.action);
318 const operation = normalizeOperation(news.operation);
319 const packageId = news.packageId;
320 const condition = desiredCondition(news, ownership);
321
322 let current = yield* getByName(name);
323
324 if (current === undefined) {
325 const created = yield* artifactregistry
326 .createProjectsLocationsRepositoriesRules({
327 parent: repository,
328 ruleId,
329 body: {
330 action,
331 operation,
332 packageId,
333 condition,
334 },
335 })
336 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
337 current = created ?? undefined;
338 }
339
340 if (current === undefined) {
341 return yield* new ResourceNotResolved({ name });
342 }
343
344 const observed = current.name ?? name;
345 const actionChanged = normalizeAction(current.action ?? "") !== action;
346 const operationChanged =
347 normalizeOperation(current.operation) !== operation;
348 const packageChanged = !sameText(current.packageId, packageId);
349 const conditionChanged = !sameJson(current.condition, condition);
350 const updateMask = fieldMask([
351 actionChanged && "action",
352 operationChanged && "operation",
353 packageChanged && "packageId",
354 conditionChanged && "condition",
355 ]);
356
357 if (updateMask.length > 0) {
358 current =
359 yield* artifactregistry.patchProjectsLocationsRepositoriesRules({
360 name: observed,
361 updateMask,
362 body: {
363 name: observed,
364 action,
365 operation,
366 packageId,
367 condition,
368 },
369 });
370 }
371
372 return toAttrs(current, env.project, env.region);
373 }),
374
375 delete: Effect.fn(function* ({ output }) {
376 yield* artifactregistry
377 .deleteProjectsLocationsRepositoriesRules({ name: output.name })
378 .pipe(Effect.catchTag("NotFound", () => Effect.void));
379 }),
380 });