1import * as backupsearch from "@distilled.cloud/aws/backupsearch";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import { readBackupSearchTags, syncBackupSearchTags } from "./internal.ts";
12
13export interface BackupCreationTimeFilter {
14 /** Only include recovery points created after this ISO-8601 timestamp. */
15 createdAfter?: string;
16 /** Only include recovery points created before this ISO-8601 timestamp. */
17 createdBefore?: string;
18}
19
20export interface SearchScope {
21 /**
22 * Resource types of the recovery points to include in the search:
23 * `S3` and/or `EBS`.
24 */
25 backupResourceTypes: string[];
26 /** Filter recovery points by their backup creation time. */
27 backupResourceCreationTime?: BackupCreationTimeFilter;
28 /** Only search backups of these source resources (ARNs). */
29 sourceResourceArns?: string[];
30 /** Only search these recovery points (recovery point ARNs). */
31 backupResourceArns?: string[];
32 /** Only search recovery points carrying these tags. */
33 backupResourceTags?: Record<string, string>;
34}
35
36export interface StringCondition {
37 /** The string value to compare against. */
38 value: string;
39 /**
40 * Comparison operator: `EQUALS_TO`, `NOT_EQUALS_TO`, `CONTAINS`,
41 * `DOES_NOT_CONTAIN`, `BEGINS_WITH`, `ENDS_WITH`, `DOES_NOT_BEGIN_WITH`,
42 * or `DOES_NOT_END_WITH`.
43 * @default EQUALS_TO
44 */
45 operator?: string;
46}
47
48export interface LongCondition {
49 /** The numeric value to compare against. */
50 value: number;
51 /**
52 * Comparison operator: `EQUALS_TO`, `NOT_EQUALS_TO`,
53 * `LESS_THAN_EQUAL_TO`, or `GREATER_THAN_EQUAL_TO`.
54 * @default EQUALS_TO
55 */
56 operator?: string;
57}
58
59export interface TimeCondition {
60 /** The ISO-8601 timestamp to compare against. */
61 value: string;
62 /**
63 * Comparison operator: `EQUALS_TO`, `NOT_EQUALS_TO`,
64 * `LESS_THAN_EQUAL_TO`, or `GREATER_THAN_EQUAL_TO`.
65 * @default EQUALS_TO
66 */
67 operator?: string;
68}
69
70export interface S3ItemFilter {
71 /** Match S3 objects by key. */
72 objectKeys?: StringCondition[];
73 /** Match S3 objects by size in bytes. */
74 sizes?: LongCondition[];
75 /** Match S3 objects by creation time. */
76 creationTimes?: TimeCondition[];
77 /** Match S3 objects by version id. */
78 versionIds?: StringCondition[];
79 /** Match S3 objects by ETag. */
80 etags?: StringCondition[];
81}
82
83export interface EBSItemFilter {
84 /** Match EBS files by path. */
85 filePaths?: StringCondition[];
86 /** Match EBS files by size in bytes. */
87 sizes?: LongCondition[];
88 /** Match EBS files by creation time. */
89 creationTimes?: TimeCondition[];
90 /** Match EBS files by last-modification time. */
91 lastModificationTimes?: TimeCondition[];
92}
93
94export interface ItemFilters {
95 /** Filters applied to items inside S3 recovery points. */
96 s3ItemFilters?: S3ItemFilter[];
97 /** Filters applied to items inside EBS recovery points. */
98 ebsItemFilters?: EBSItemFilter[];
99}
100
101export interface SearchJobProps {
102 /**
103 * Display name of the search job. If omitted, a unique name is generated
104 * from the app, stage, and logical ID. Changing the name replaces the job.
105 */
106 name?: string;
107 /**
108 * ARN of the KMS key used to encrypt the search results. Immutable —
109 * changing it replaces the job.
110 */
111 encryptionKeyArn?: string;
112 /**
113 * The recovery points to search. Immutable — changing it replaces the job.
114 */
115 searchScope: SearchScope;
116 /**
117 * Filters applied to the items inside the recovery points. Immutable —
118 * changing them replaces the job.
119 */
120 itemFilters?: ItemFilters;
121 /**
122 * User-defined tags for the search job.
123 */
124 tags?: Record<string, string>;
125}
126
127export interface SearchJob extends Resource<
128 "AWS.BackupSearch.SearchJob",
129 SearchJobProps,
130 {
131 /**
132 * Service-assigned unique ID of the search job.
133 */
134 searchJobIdentifier: string;
135 /**
136 * ARN of the search job.
137 */
138 searchJobArn: string;
139 /**
140 * Name of the search job.
141 */
142 name: string | undefined;
143 /**
144 * Current status of the search job (e.g. `RUNNING`, `COMPLETED`).
145 */
146 status: string;
147 },
148 never,
149 Providers
150> {}
151
152/**
153 * An AWS Backup Search job — a data-plane search over AWS Backup recovery
154 * points (S3 and EBS) whose backup indexes are active. A search job is
155 * immutable once started: it runs to completion, retains its results for
156 * seven days, and can only be stopped while `RUNNING` (destroying the
157 * resource stops a running job; completed jobs age out server-side).
158 *
159 * ### Creating a Search Job
160 * **Example:** Search All S3 Backups
161 * ```typescript
162 * const search = yield* BackupSearch.SearchJob("FindReports", {
163 * searchScope: { backupResourceTypes: ["S3"] },
164 * itemFilters: {
165 * s3ItemFilters: [
166 * { objectKeys: [{ value: "reports/", operator: "BEGINS_WITH" }] },
167 * ],
168 * },
169 * });
170 * ```
171 *
172 * **Example:** Search Specific Recovery Points
173 * ```typescript
174 * const search = yield* BackupSearch.SearchJob("AuditSearch", {
175 * searchScope: {
176 * backupResourceTypes: ["EBS"],
177 * backupResourceArns: [recoveryPointArn],
178 * backupResourceCreationTime: { createdAfter: "2026-01-01T00:00:00Z" },
179 * },
180 * });
181 * ```
182 *
183 * @resource
184 */
185export const SearchJob = Resource<SearchJob>("AWS.BackupSearch.SearchJob");
186
187const encodeTimeCondition = (
188 condition: TimeCondition,
189): backupsearch.TimeCondition => ({
190 Value: new Date(condition.value),
191 Operator: condition.operator,
192});
193
194const encodeStringCondition = (
195 condition: StringCondition,
196): backupsearch.StringCondition => ({
197 Value: condition.value,
198 Operator: condition.operator,
199});
200
201const encodeLongCondition = (
202 condition: LongCondition,
203): backupsearch.LongCondition => ({
204 Value: condition.value,
205 Operator: condition.operator,
206});
207
208const encodeSearchScope = (scope: SearchScope): backupsearch.SearchScope => ({
209 BackupResourceTypes: scope.backupResourceTypes,
210 BackupResourceCreationTime: scope.backupResourceCreationTime
211 ? {
212 CreatedAfter: scope.backupResourceCreationTime.createdAfter
213 ? new Date(scope.backupResourceCreationTime.createdAfter)
214 : undefined,
215 CreatedBefore: scope.backupResourceCreationTime.createdBefore
216 ? new Date(scope.backupResourceCreationTime.createdBefore)
217 : undefined,
218 }
219 : undefined,
220 SourceResourceArns: scope.sourceResourceArns,
221 BackupResourceArns: scope.backupResourceArns,
222 BackupResourceTags: scope.backupResourceTags,
223});
224
225const encodeItemFilters = (filters: ItemFilters): backupsearch.ItemFilters => ({
226 S3ItemFilters: filters.s3ItemFilters?.map((filter) => ({
227 ObjectKeys: filter.objectKeys?.map(encodeStringCondition),
228 Sizes: filter.sizes?.map(encodeLongCondition),
229 CreationTimes: filter.creationTimes?.map(encodeTimeCondition),
230 VersionIds: filter.versionIds?.map(encodeStringCondition),
231 ETags: filter.etags?.map(encodeStringCondition),
232 })),
233 EBSItemFilters: filters.ebsItemFilters?.map((filter) => ({
234 FilePaths: filter.filePaths?.map(encodeStringCondition),
235 Sizes: filter.sizes?.map(encodeLongCondition),
236 CreationTimes: filter.creationTimes?.map(encodeTimeCondition),
237 LastModificationTimes:
238 filter.lastModificationTimes?.map(encodeTimeCondition),
239 })),
240});
241
242export const SearchJobProvider = () =>
243 Provider.effect(
244 SearchJob,
245 Effect.gen(function* () {
246 const createName = Effect.fn(function* (
247 id: string,
248 props: SearchJobProps,
249 ) {
250 return props.name ?? (yield* createPhysicalName({ id, maxLength: 60 }));
251 });
252
253 const get = Effect.fn(function* (searchJobIdentifier: string) {
254 return yield* backupsearch
255 .getSearchJob({ SearchJobIdentifier: searchJobIdentifier })
256 .pipe(
257 Effect.catchTag("ResourceNotFoundException", () =>
258 Effect.succeed(undefined),
259 ),
260 );
261 });
262
263 const toAttrs = (job: backupsearch.GetSearchJobOutput) => ({
264 searchJobIdentifier: job.SearchJobIdentifier,
265 searchJobArn: job.SearchJobArn,
266 name: job.Name,
267 status: job.Status as string,
268 });
269
270 return {
271 stables: ["searchJobIdentifier", "searchJobArn", "name"],
272
273 diff: Effect.fn(function* ({ id, olds, news }) {
274 if (!isResolved(news)) return undefined;
275 const oldName = yield* createName(id, olds);
276 const newName = yield* createName(id, news);
277 if (
278 oldName !== newName ||
279 (olds.encryptionKeyArn ?? undefined) !==
280 (news.encryptionKeyArn ?? undefined) ||
281 JSON.stringify(olds.searchScope ?? null) !==
282 JSON.stringify(news.searchScope ?? null) ||
283 JSON.stringify(olds.itemFilters ?? null) !==
284 JSON.stringify(news.itemFilters ?? null)
285 ) {
286 return { action: "replace" } as const;
287 }
288 }),
289
290 read: Effect.fn(function* ({ id, output }) {
291 if (!output?.searchJobIdentifier) return undefined;
292 const job = yield* get(output.searchJobIdentifier);
293 if (job === undefined) return undefined;
294 const attrs = toAttrs(job);
295 const tags = yield* readBackupSearchTags(job.SearchJobArn);
296 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
297 }),
298
299 reconcile: Effect.fn(function* ({ id, news, output, session }) {
300 const name = yield* createName(id, news);
301 const internalTags = yield* createInternalTags(id);
302 const desiredTags = { ...internalTags, ...news.tags };
303
304 // OBSERVE — output is only an identifier cache; the job may be gone.
305 let job =
306 output?.searchJobIdentifier !== undefined
307 ? yield* get(output.searchJobIdentifier)
308 : undefined;
309
310 // ENSURE — a search job is immutable once started; start it if
311 // missing. Any change to the scope/filters is a replacement (diff).
312 if (job === undefined) {
313 const started = yield* backupsearch.startSearchJob({
314 Name: name,
315 EncryptionKeyArn: news.encryptionKeyArn,
316 SearchScope: encodeSearchScope(news.searchScope),
317 ItemFilters: news.itemFilters
318 ? encodeItemFilters(news.itemFilters)
319 : undefined,
320 Tags: desiredTags,
321 });
322 job = yield* backupsearch.getSearchJob({
323 SearchJobIdentifier: started.SearchJobIdentifier!,
324 });
325 } else {
326 // SYNC — tags are the only mutable aspect; diff against OBSERVED.
327 yield* syncBackupSearchTags(job.SearchJobArn, desiredTags);
328 }
329
330 yield* session.note(job.SearchJobIdentifier);
331 return toAttrs(job);
332 }),
333
334 delete: Effect.fn(function* ({ output }) {
335 // A search job cannot be deleted — results age out after 7 days.
336 // Stop it if it is still RUNNING; stopping a job in any other
337 // state is a ConflictException and means there is nothing to do.
338 yield* backupsearch
339 .stopSearchJob({
340 SearchJobIdentifier: output.searchJobIdentifier,
341 })
342 .pipe(
343 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
344 Effect.catchTag("ConflictException", () => Effect.void),
345 );
346 }),
347
348 list: () =>
349 backupsearch.listSearchJobs.pages({}).pipe(
350 Stream.runCollect,
351 Effect.map((chunk) =>
352 Array.from(chunk)
353 .flatMap((page) => page.SearchJobs ?? [])
354 .map((summary) => ({
355 searchJobIdentifier: summary.SearchJobIdentifier!,
356 searchJobArn: summary.SearchJobArn!,
357 name: summary.Name,
358 status: (summary.Status ?? "UNKNOWN") as string,
359 })),
360 ),
361 ),
362 };
363 }),
364 );