1import * as mq from "@distilled.cloud/aws/mq";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.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 { syncMqTags, toTagRecord } from "./internal.ts";
11
12export interface ConfigurationProps {
13 /**
14 * Name of the configuration (1-150 chars, `a-z A-Z 0-9 . _ ~ -`). If
15 * omitted a deterministic physical name is generated. The name is
16 * immutable — changing it replaces the configuration.
17 */
18 configurationName?: string;
19 /**
20 * Broker engine the configuration targets. Immutable — changing it
21 * replaces the configuration.
22 */
23 engineType: "ACTIVEMQ" | "RABBITMQ";
24 /**
25 * Broker engine version the configuration targets (e.g. `"5.18"` for
26 * ActiveMQ, `"3.13"` for RabbitMQ). Immutable — changing it replaces the
27 * configuration.
28 */
29 engineVersion?: string;
30 /**
31 * Authentication strategy for the associated broker. Immutable —
32 * changing it replaces the configuration.
33 * @default "SIMPLE"
34 */
35 authenticationStrategy?: "SIMPLE" | "LDAP";
36 /**
37 * The broker configuration document as plain text (ActiveMQ XML or
38 * RabbitMQ Cuttlefish). Alchemy base64-encodes it for the API. Supplying
39 * (or changing) `data` publishes a new configuration revision. Omitting
40 * it keeps the engine's default revision.
41 */
42 data?: string;
43 /**
44 * Description recorded on the published revision. Only applied when `data`
45 * is also supplied (the API updates data + description together).
46 */
47 description?: string;
48 /**
49 * User-defined tags for the configuration.
50 */
51 tags?: Record<string, string>;
52}
53
54export interface Configuration extends Resource<
55 "AWS.MQ.Configuration",
56 ConfigurationProps,
57 {
58 /** Server-assigned unique id of the configuration (e.g. `c-1234...`). */
59 configurationId: string;
60 /** ARN of the configuration. */
61 configurationArn: string;
62 /** Name of the configuration. */
63 configurationName: string;
64 /** Latest published revision number. */
65 configurationRevision: number;
66 /** Broker engine the configuration targets (`ACTIVEMQ` or `RABBITMQ`). */
67 engineType: string;
68 /** Engine version the configuration targets. */
69 engineVersion: string | undefined;
70 },
71 never,
72 Providers
73> {}
74
75/**
76 * An Amazon MQ broker configuration — a versioned document (ActiveMQ XML or
77 * RabbitMQ Cuttlefish) that a {@link Broker} can reference to control
78 * engine-level settings. Each edit to `data` publishes a new immutable
79 * revision; a broker pins a specific `{ id, revision }` pair.
80 *
81 * ### Creating a Configuration
82 * **Example:** Default ActiveMQ Configuration
83 * ```typescript
84 * const config = yield* MQ.Configuration("BrokerConfig", {
85 * engineType: "ACTIVEMQ",
86 * engineVersion: "5.18",
87 * });
88 * ```
89 *
90 * **Example:** Custom ActiveMQ Configuration Document
91 * ```typescript
92 * const config = yield* MQ.Configuration("BrokerConfig", {
93 * engineType: "ACTIVEMQ",
94 * engineVersion: "5.18",
95 * description: "Enable statistics plugin",
96 * data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
97 * <broker xmlns="http://activemq.apache.org/schema/core">
98 * <plugins>
99 * <statisticsBrokerPlugin/>
100 * </plugins>
101 * </broker>`,
102 * });
103 * // config.configurationRevision -> 2 (the published revision)
104 * ```
105 *
106 * ### Attaching to a Broker
107 * **Example:** Reference a Configuration from a Broker
108 * ```typescript
109 * const broker = yield* MQ.Broker("Orders", {
110 * engineType: "ACTIVEMQ",
111 * engineVersion: "5.18",
112 * hostInstanceType: "mq.t3.micro",
113 * users: [{ username: "admin", password: Redacted.make("SuperSecretPassw0rd") }],
114 * configuration: {
115 * id: config.configurationId,
116 * revision: config.configurationRevision,
117 * },
118 * });
119 * ```
120 *
121 * @resource
122 */
123export const Configuration = Resource<Configuration>("AWS.MQ.Configuration");
124
125const encodeData = (data: string): Effect.Effect<string> =>
126 Effect.sync(() => Buffer.from(data, "utf8").toString("base64"));
127
128const decodeData = (data: string | undefined): Effect.Effect<string> =>
129 Effect.sync(() =>
130 data === undefined ? "" : Buffer.from(data, "base64").toString("utf8"),
131 );
132
133export const ConfigurationProvider = () =>
134 Provider.effect(
135 Configuration,
136 Effect.gen(function* () {
137 const toName = (id: string, props: Partial<ConfigurationProps>) =>
138 props.configurationName
139 ? Effect.succeed(props.configurationName)
140 : createPhysicalName({ id, maxLength: 150 });
141
142 /** Read a configuration by id; a missing configuration reads as absent. */
143 const readConfiguration = Effect.fn(function* (configurationId: string) {
144 return yield* mq
145 .describeConfiguration({ ConfigurationId: configurationId })
146 .pipe(
147 Effect.catchTag("NotFoundException", () =>
148 Effect.succeed(undefined),
149 ),
150 );
151 });
152
153 /** Enumerate all configurations (listConfigurations is not paginated). */
154 const listAllConfigurations = Effect.fn(function* () {
155 const configs: mq.Configuration[] = [];
156 let nextToken: string | undefined;
157 // Bounded page walk — MQ terminates on an absent NextToken.
158 for (let page = 0; page < 20; page++) {
159 const response = yield* mq.listConfigurations({
160 MaxResults: 100,
161 NextToken: nextToken,
162 });
163 configs.push(...(response.Configurations ?? []));
164 nextToken = response.NextToken;
165 if (!nextToken) break;
166 }
167 return configs;
168 });
169
170 /** Find a live configuration by name. */
171 const findByName = Effect.fn(function* (name: string) {
172 const configs = yield* listAllConfigurations();
173 const summary = configs.find((c) => c.Name === name && c.Id);
174 if (!summary?.Id) return undefined;
175 return yield* readConfiguration(summary.Id);
176 });
177
178 const toAttrs = (config: mq.DescribeConfigurationResponse) => ({
179 configurationId: config.Id!,
180 configurationArn: config.Arn!,
181 configurationName: config.Name!,
182 configurationRevision: config.LatestRevision?.Revision ?? 1,
183 engineType: config.EngineType!,
184 engineVersion: config.EngineVersion,
185 });
186
187 return {
188 stables: ["configurationId", "configurationArn", "configurationName"],
189
190 diff: Effect.fn(function* ({ id, olds, news }) {
191 if (!isResolved(news)) return undefined;
192 if (
193 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
194 ) {
195 return { action: "replace" } as const;
196 }
197 // Engine type, version, and auth strategy are fixed at creation.
198 if (
199 (news.engineType ?? undefined) !== (olds?.engineType ?? undefined)
200 ) {
201 return { action: "replace" } as const;
202 }
203 if (
204 (news.engineVersion ?? undefined) !==
205 (olds?.engineVersion ?? undefined)
206 ) {
207 return { action: "replace" } as const;
208 }
209 if (
210 (news.authenticationStrategy ?? undefined) !==
211 (olds?.authenticationStrategy ?? undefined)
212 ) {
213 return { action: "replace" } as const;
214 }
215 }),
216
217 read: Effect.fn(function* ({ id, olds, output }) {
218 const config = output?.configurationId
219 ? yield* readConfiguration(output.configurationId)
220 : yield* findByName(yield* toName(id, olds ?? {}));
221 if (config === undefined) return undefined;
222 const attrs = toAttrs(config);
223 const tags = toTagRecord(config.Tags);
224 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
225 }),
226
227 reconcile: Effect.fn(function* ({ id, news, output, session }) {
228 const name = output?.configurationName ?? (yield* toName(id, news));
229 const internalTags = yield* createInternalTags(id);
230 const desiredTags = { ...internalTags, ...news.tags };
231
232 // 1. Observe — cloud state is authoritative.
233 let observed = output?.configurationId
234 ? yield* readConfiguration(output.configurationId)
235 : undefined;
236 if (observed === undefined) {
237 observed = yield* findByName(name);
238 }
239
240 // 2. Ensure — create if missing. createConfiguration seeds an
241 // initial default revision; `data` (if any) is published below.
242 if (observed === undefined) {
243 const created = yield* mq.createConfiguration({
244 Name: name,
245 EngineType: news.engineType,
246 EngineVersion: news.engineVersion,
247 AuthenticationStrategy: news.authenticationStrategy,
248 Tags: desiredTags,
249 });
250 observed = yield* readConfiguration(created.Id!);
251 if (observed === undefined) {
252 return yield* Effect.fail(
253 new Error(
254 `MQ configuration '${name}' disappeared immediately after create`,
255 ),
256 );
257 }
258 }
259
260 const configurationId = observed.Id!;
261
262 // 3. Sync configuration document — publish a new revision only when
263 // the desired `data` differs from the current latest revision.
264 if (news.data !== undefined) {
265 const revision = observed.LatestRevision?.Revision;
266 const current =
267 revision === undefined
268 ? undefined
269 : yield* mq.describeConfigurationRevision({
270 ConfigurationId: configurationId,
271 ConfigurationRevision: String(revision),
272 });
273 const currentData = yield* decodeData(current?.Data);
274 if (currentData !== news.data) {
275 yield* mq.updateConfiguration({
276 ConfigurationId: configurationId,
277 Data: yield* encodeData(news.data),
278 Description: news.description,
279 });
280 }
281 }
282
283 // 3b. Sync tags — diff against OBSERVED cloud tags.
284 yield* syncMqTags(
285 observed.Arn!,
286 toTagRecord(observed.Tags),
287 desiredTags,
288 );
289
290 // 4. Re-read for the fresh latest revision + tags.
291 const final = yield* readConfiguration(configurationId);
292 if (final === undefined) {
293 return yield* Effect.fail(
294 new Error(
295 `MQ configuration '${name}' disappeared while reconciling`,
296 ),
297 );
298 }
299 yield* session.note(name);
300 return toAttrs(final);
301 }),
302
303 delete: Effect.fn(function* ({ output }) {
304 yield* mq
305 .deleteConfiguration({ ConfigurationId: output.configurationId })
306 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
307 }),
308
309 list: () =>
310 Effect.gen(function* () {
311 const configs = yield* listAllConfigurations();
312 return configs.flatMap((c) =>
313 c.Id !== undefined && c.Arn !== undefined && c.Name !== undefined
314 ? [
315 {
316 configurationId: c.Id,
317 configurationArn: c.Arn,
318 configurationName: c.Name,
319 configurationRevision: c.LatestRevision?.Revision ?? 1,
320 engineType: c.EngineType!,
321 engineVersion: c.EngineVersion,
322 },
323 ]
324 : [],
325 );
326 }),
327 };
328 }),
329 );