1import * as amp from "@distilled.cloud/aws/amp";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 decodeDefinition,
12 encodeDefinition,
13 syncAmpTags,
14 toTagRecord,
15} from "./internal.ts";
16
17export interface RuleGroupsNamespaceProps {
18 /**
19 * Id of the AMP workspace this rule groups namespace belongs to. Changing
20 * the workspace replaces the namespace.
21 */
22 workspaceId: string;
23 /**
24 * Name of the rule groups namespace. Changing the name replaces the
25 * namespace.
26 */
27 name: string;
28 /**
29 * The rules definition as a Prometheus-format YAML document (the same
30 * shape as a `prometheus.yml` `groups:` file). Updated in place.
31 */
32 definition: string;
33 /**
34 * User-defined tags for the namespace.
35 */
36 tags?: Record<string, string>;
37}
38
39export interface RuleGroupsNamespace extends Resource<
40 "AWS.AMP.RuleGroupsNamespace",
41 RuleGroupsNamespaceProps,
42 {
43 workspaceId: string;
44 name: string;
45 ruleGroupsNamespaceArn: string;
46 status: string;
47 },
48 never,
49 Providers
50> {}
51
52/**
53 * A rule groups namespace inside an Amazon Managed Service for Prometheus
54 * workspace — a container of Prometheus recording and alerting rules,
55 * supplied as a YAML definition.
56 *
57 * ### Creating a Rule Groups Namespace
58 * **Example:** Basic Recording Rule
59 * ```typescript
60 * const workspace = yield* AMP.Workspace("Metrics", {});
61 * const rules = yield* AMP.RuleGroupsNamespace("Rules", {
62 * workspaceId: workspace.workspaceId,
63 * name: "default",
64 * definition: `groups:
65 * - name: example
66 * rules:
67 * - record: metric:requests:rate5m
68 * expr: rate(http_requests_total[5m])`,
69 * });
70 * ```
71 *
72 * @resource
73 */
74export const RuleGroupsNamespace = Resource<RuleGroupsNamespace>(
75 "AWS.AMP.RuleGroupsNamespace",
76);
77
78export const RuleGroupsNamespaceProvider = () =>
79 Provider.effect(
80 RuleGroupsNamespace,
81 Effect.gen(function* () {
82 const toAttrs = (ns: amp.RuleGroupsNamespaceDescription) => ({
83 workspaceId: "",
84 name: ns.name,
85 ruleGroupsNamespaceArn: ns.arn,
86 status: ns.status.statusCode,
87 });
88
89 /** Describe the namespace; typed not-found → undefined. */
90 const describe = Effect.fn(function* (workspaceId: string, name: string) {
91 const response = yield* amp
92 .describeRuleGroupsNamespace({ workspaceId, name })
93 .pipe(
94 Effect.catchTag("ResourceNotFoundException", () =>
95 Effect.succeed(undefined),
96 ),
97 );
98 return response?.ruleGroupsNamespace;
99 });
100
101 /**
102 * Poll until the namespace reaches ACTIVE. A namespace in CREATING /
103 * UPDATING omits its `data` blob from the description, so callers that
104 * need the definition must wait for ACTIVE first. Activation is
105 * usually seconds but has been observed to remain CREATING beyond 90s
106 * under account load. Poll less frequently for a bounded ~2 minute
107 * window so deployments tolerate that documented asynchronous
108 * lifecycle without creating an unbounded wait.
109 */
110 const waitActive = Effect.fn(function* (
111 workspaceId: string,
112 name: string,
113 ) {
114 const ns = yield* amp
115 .describeRuleGroupsNamespace({ workspaceId, name })
116 .pipe(
117 Effect.map((r) => r.ruleGroupsNamespace),
118 Effect.repeat({
119 schedule: Schedule.max([
120 Schedule.fixed("12 seconds"),
121 Schedule.recurs(10),
122 ]),
123 until: (n) => n.status.statusCode === "ACTIVE",
124 }),
125 );
126 if (ns.status.statusCode !== "ACTIVE") {
127 return yield* Effect.fail(
128 new Error(
129 `AMP rule groups namespace ${name} did not become ACTIVE (status: ${ns.status.statusCode})`,
130 ),
131 );
132 }
133 return ns;
134 });
135
136 return {
137 stables: ["workspaceId", "name", "ruleGroupsNamespaceArn"],
138
139 diff: Effect.fn(function* ({ olds, news }) {
140 if (!isResolved(news)) return undefined;
141 if (
142 olds?.workspaceId !== news.workspaceId ||
143 olds?.name !== news.name
144 ) {
145 return { action: "replace" } as const;
146 }
147 }),
148
149 read: Effect.fn(function* ({ id, olds, output }) {
150 const workspaceId = output?.workspaceId ?? olds?.workspaceId;
151 const name = output?.name ?? olds?.name;
152 if (!workspaceId || !name) return undefined;
153 const ns = yield* describe(workspaceId, name);
154 if (ns === undefined) return undefined;
155 const attrs = { ...toAttrs(ns), workspaceId };
156 const tags = toTagRecord(ns.tags);
157 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
158 }),
159
160 reconcile: Effect.fn(function* ({ id, news, session }) {
161 const workspaceId = news!.workspaceId;
162 const name = news!.name;
163 const internalTags = yield* createInternalTags(id);
164 const desiredTags = { ...internalTags, ...news!.tags };
165 const desiredData = yield* encodeDefinition(news!.definition);
166
167 // 1. Observe — the live namespace is authoritative.
168 const ns = yield* describe(workspaceId, name);
169
170 // 2. Ensure — create if missing.
171 if (ns === undefined) {
172 yield* amp.createRuleGroupsNamespace({
173 workspaceId,
174 name,
175 data: desiredData,
176 tags: desiredTags,
177 });
178 } else {
179 // 3. Sync definition — put only when the YAML drifts. `data` is
180 // present once the namespace is ACTIVE (omitted while CREATING).
181 const currentDefinition =
182 ns.data !== undefined
183 ? yield* decodeDefinition(ns.data)
184 : undefined;
185 if (currentDefinition !== news!.definition) {
186 yield* amp.putRuleGroupsNamespace({
187 workspaceId,
188 name,
189 data: desiredData,
190 });
191 }
192 }
193
194 // Wait for ACTIVE so the ARN, status, and data blob are settled.
195 const fresh = yield* waitActive(workspaceId, name);
196
197 // 3b. Sync tags — diff against OBSERVED cloud tags.
198 yield* syncAmpTags(fresh.arn, desiredTags);
199
200 yield* session.note(`${workspaceId}/${name}`);
201 return { ...toAttrs(fresh), workspaceId };
202 }),
203
204 delete: Effect.fn(function* ({ output }) {
205 yield* amp
206 .deleteRuleGroupsNamespace({
207 workspaceId: output.workspaceId,
208 name: output.name,
209 })
210 .pipe(
211 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
212 Effect.retry({
213 while: (e) => e._tag === "ConflictException",
214 schedule: Schedule.max([
215 Schedule.fixed("3 seconds"),
216 Schedule.recurs(20),
217 ]),
218 }),
219 );
220 }),
221
222 // Sub-resource keyed by its parent workspace — not independently
223 // enumerable across the account.
224 list: () => Effect.succeed([]),
225 };
226 }),
227 );