1import * as aoss from "@distilled.cloud/aws/opensearchserverless";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 canonicalizePolicy,
11 retryWhileConflict,
12 stringifyPolicy,
13} from "./internal.ts";
14import type { SecurityPolicyDocument } from "./SecurityPolicy.ts";
15
16/** The kind of lifecycle policy. OpenSearch Serverless only defines `retention`. */
17export type LifecyclePolicyType = "retention";
18
19export interface LifecyclePolicyProps {
20 /**
21 * Name of the lifecycle policy (3-32 characters, lowercase). Changing the
22 * name replaces the policy.
23 * @default a generated physical name
24 */
25 policyName?: string;
26 /**
27 * The policy kind. OpenSearch Serverless currently only supports
28 * `retention` policies. Changing the type replaces the policy.
29 * @default "retention"
30 */
31 type?: LifecyclePolicyType;
32 /**
33 * The lifecycle policy document — a JSON object with `Rules` matching index
34 * patterns to a retention window, e.g.
35 * `{ Rules: [{ ResourceType: "index", Resource: ["index/my-collection/*"], MinIndexRetention: "30d" }] }`.
36 * Use `NoMinIndexRetention: true` on a rule to retain matched indexes
37 * indefinitely. Supply an object or a pre-serialized string.
38 */
39 policy: SecurityPolicyDocument;
40 /**
41 * A human-readable description of the policy.
42 */
43 description?: string;
44}
45
46export interface LifecyclePolicy extends Resource<
47 "AWS.OpenSearchServerless.LifecyclePolicy",
48 LifecyclePolicyProps,
49 {
50 /**
51 * Name of the lifecycle policy.
52 */
53 policyName: string;
54 /**
55 * Policy type (`retention`).
56 */
57 type: string;
58 /**
59 * Version of the policy, used for optimistic-concurrency updates.
60 */
61 policyVersion: string;
62 /**
63 * Description of the lifecycle policy.
64 */
65 description?: string;
66 },
67 {},
68 Providers
69> {}
70
71/**
72 * An Amazon OpenSearch Serverless data lifecycle policy. Retention lifecycle
73 * policies control how long documents are retained in the indexes matched by
74 * the policy's resource patterns — OpenSearch Serverless automatically deletes
75 * documents older than the configured `MinIndexRetention`.
76 *
77 * Lifecycle policies are free, provision instantly, and are matched to
78 * indexes by resource pattern (e.g. `index/my-collection/*`) — the collection
79 * does not need to exist when the policy is created.
80 *
81 * ### Creating Lifecycle Policies
82 * **Example:** Retain Log Indexes for 30 Days
83 * ```typescript
84 * import * as AWS from "alchemy/AWS";
85 *
86 * const retention = yield* AWS.OpenSearchServerless.LifecyclePolicy("Retention", {
87 * policyName: "logs-retention",
88 * policy: {
89 * Rules: [
90 * {
91 * ResourceType: "index",
92 * Resource: ["index/logs/*"],
93 * MinIndexRetention: "30d",
94 * },
95 * ],
96 * },
97 * });
98 * ```
99 *
100 * **Example:** Unlimited Retention for Specific Indexes
101 * ```typescript
102 * const keepForever = yield* AWS.OpenSearchServerless.LifecyclePolicy("KeepForever", {
103 * policyName: "audit-retention",
104 * policy: {
105 * Rules: [
106 * {
107 * ResourceType: "index",
108 * Resource: ["index/audit/*"],
109 * NoMinIndexRetention: true,
110 * },
111 * ],
112 * },
113 * });
114 * ```
115 *
116 * @resource
117 */
118export const LifecyclePolicy = Resource<LifecyclePolicy>(
119 "AWS.OpenSearchServerless.LifecyclePolicy",
120);
121
122const LIFECYCLE_POLICY_TYPE = "retention";
123
124export const LifecyclePolicyProvider = () =>
125 Provider.effect(
126 LifecyclePolicy,
127 Effect.gen(function* () {
128 const createName = Effect.fn(function* (
129 id: string,
130 props: { policyName?: string | undefined },
131 ) {
132 return (
133 props.policyName ??
134 (yield* createPhysicalName({ id, maxLength: 32, lowercase: true }))
135 );
136 });
137
138 const toAttributes = (detail: aoss.LifecyclePolicyDetail) => ({
139 policyName: detail.name!,
140 type: detail.type!,
141 policyVersion: detail.policyVersion!,
142 description: detail.description,
143 });
144
145 // batchGetLifecyclePolicy reports a missing policy in
146 // lifecyclePolicyErrorDetails instead of failing, which observe treats
147 // as "not present".
148 const observe = Effect.fn(function* (type: string, name: string) {
149 const response = yield* aoss.batchGetLifecyclePolicy({
150 identifiers: [{ type, name }],
151 });
152 return response.lifecyclePolicyDetails?.[0];
153 });
154
155 return LifecyclePolicy.Provider.of({
156 stables: ["policyName", "type"],
157
158 list: () =>
159 Effect.gen(function* () {
160 const pages = yield* aoss.listLifecyclePolicies
161 .pages({ type: LIFECYCLE_POLICY_TYPE })
162 .pipe(Stream.runCollect);
163 return Array.from(pages)
164 .flatMap((page) => page.lifecyclePolicySummaries ?? [])
165 .filter(
166 (s) =>
167 s.name !== undefined &&
168 s.type !== undefined &&
169 s.policyVersion !== undefined,
170 )
171 .map((s) => ({
172 policyName: s.name!,
173 type: s.type!,
174 policyVersion: s.policyVersion!,
175 description: s.description,
176 }));
177 }),
178
179 read: Effect.fn(function* ({ id, olds, output }) {
180 const name =
181 output?.policyName ?? (yield* createName(id, olds ?? {}));
182 const detail = yield* observe(LIFECYCLE_POLICY_TYPE, name);
183 if (detail?.name === undefined) {
184 return undefined;
185 }
186 // Lifecycle policies carry no tags, so an existing same-name policy
187 // is adopted.
188 return toAttributes(detail);
189 }),
190
191 diff: Effect.fn(function* ({ id, news, olds }) {
192 if (!isResolved(news)) return undefined;
193 if (
194 (olds.type ?? LIFECYCLE_POLICY_TYPE) !==
195 (news.type ?? LIFECYCLE_POLICY_TYPE)
196 ) {
197 return { action: "replace" } as const;
198 }
199 const oldName = yield* createName(id, olds);
200 const newName = yield* createName(id, news);
201 if (oldName !== newName) {
202 return { action: "replace" } as const;
203 }
204 // description/policy fall through to the default update path
205 }),
206
207 reconcile: Effect.fn(function* ({ id, news, output, session }) {
208 const type = news.type ?? LIFECYCLE_POLICY_TYPE;
209 const name = output?.policyName ?? (yield* createName(id, news));
210 const policy = stringifyPolicy(news.policy);
211
212 // 1. OBSERVE
213 let detail = yield* observe(type, name);
214
215 // 2. ENSURE — create if missing; tolerate a concurrent create race
216 if (detail?.name === undefined) {
217 detail = yield* aoss
218 .createLifecyclePolicy({
219 type,
220 name,
221 policy,
222 description: news.description,
223 })
224 .pipe(
225 Effect.map((r) => r.lifecyclePolicyDetail),
226 Effect.catchTag("ConflictException", () => observe(type, name)),
227 );
228 } else {
229 // 3. SYNC — update policy/description when observed drifts from desired
230 const policyDrift =
231 canonicalizePolicy(detail.policy) !== canonicalizePolicy(policy);
232 const descriptionDrift =
233 news.description !== undefined &&
234 news.description !== detail.description;
235 if (policyDrift || descriptionDrift) {
236 detail = yield* aoss
237 .updateLifecyclePolicy({
238 type,
239 name,
240 policyVersion: detail.policyVersion!,
241 policy: policyDrift ? policy : undefined,
242 description: descriptionDrift ? news.description : undefined,
243 })
244 .pipe(Effect.map((r) => r.lifecyclePolicyDetail));
245 }
246 }
247
248 if (detail?.name === undefined) {
249 return yield* Effect.fail(
250 new aoss.ResourceNotFoundException({
251 message: `lifecycle policy ${type}/${name} not visible after reconcile`,
252 }),
253 );
254 }
255 yield* session.note(`${type}/${name}`);
256 return toAttributes(detail);
257 }),
258
259 delete: Effect.fn(function* ({ output }) {
260 yield* retryWhileConflict(
261 aoss.deleteLifecyclePolicy({
262 type: output.type,
263 name: output.policyName,
264 }),
265 ).pipe(
266 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
267 );
268 }),
269 });
270 }),
271 );