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 * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import { readBackupSearchTags, syncBackupSearchTags } from "./internal.ts";
11
12export interface S3ExportSpecification {
13 /** Name of the destination S3 bucket that receives the results `.csv`. */
14 destinationBucket: string;
15 /** Key prefix under which the results are written. */
16 destinationPrefix?: string;
17}
18
19export interface ExportSpecification {
20 /** Export the search results to an S3 bucket. */
21 s3ExportSpecification: S3ExportSpecification;
22}
23
24export interface ExportJobProps {
25 /**
26 * Identifier of the search job whose results are exported. Immutable —
27 * changing it replaces the export job.
28 */
29 searchJobIdentifier: string;
30 /**
31 * Where the results are exported to. Immutable — changing it replaces the
32 * export job.
33 */
34 exportSpecification: ExportSpecification;
35 /**
36 * ARN of the IAM role BackupSearch assumes to write the export to the
37 * destination bucket. Immutable — changing it replaces the export job.
38 */
39 roleArn?: string;
40 /**
41 * User-defined tags for the export job.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface ExportJob extends Resource<
47 "AWS.BackupSearch.ExportJob",
48 ExportJobProps,
49 {
50 /**
51 * Service-assigned unique ID of the export job.
52 */
53 exportJobIdentifier: string;
54 /**
55 * ARN of the export job.
56 */
57 exportJobArn: string;
58 /**
59 * ARN of the search job whose results are exported.
60 */
61 searchJobArn: string | undefined;
62 /**
63 * Current status of the export job (e.g. `RUNNING`, `COMPLETED`).
64 */
65 status: string;
66 },
67 never,
68 Providers
69> {}
70
71/**
72 * An AWS Backup Search export job — transmits the results of a completed
73 * search job to a designated S3 bucket as a `.csv` file, retaining them
74 * beyond the search job's seven-day retention. An export job is immutable
75 * once started and cannot be stopped or deleted; its record ages out
76 * server-side.
77 *
78 * ### Exporting Search Results
79 * **Example:** Export to an S3 Bucket
80 * ```typescript
81 * const exportJob = yield* BackupSearch.ExportJob("Results", {
82 * searchJobIdentifier: search.searchJobIdentifier,
83 * exportSpecification: {
84 * s3ExportSpecification: {
85 * destinationBucket: bucket.bucketName,
86 * destinationPrefix: "backup-search-results/",
87 * },
88 * },
89 * roleArn: role.roleArn,
90 * });
91 * ```
92 *
93 * @resource
94 */
95export const ExportJob = Resource<ExportJob>("AWS.BackupSearch.ExportJob");
96
97export const ExportJobProvider = () =>
98 Provider.effect(
99 ExportJob,
100 Effect.gen(function* () {
101 const get = Effect.fn(function* (exportJobIdentifier: string) {
102 return yield* backupsearch
103 .getSearchResultExportJob({
104 ExportJobIdentifier: exportJobIdentifier,
105 })
106 .pipe(
107 Effect.catchTag("ResourceNotFoundException", () =>
108 Effect.succeed(undefined),
109 ),
110 );
111 });
112
113 const toAttrs = (
114 job:
115 | backupsearch.GetSearchResultExportJobOutput
116 | backupsearch.ExportJobSummary,
117 ) => ({
118 exportJobIdentifier: job.ExportJobIdentifier,
119 exportJobArn: job.ExportJobArn!,
120 searchJobArn: job.SearchJobArn,
121 status: (job.Status ?? "UNKNOWN") as string,
122 });
123
124 return {
125 stables: ["exportJobIdentifier", "exportJobArn"],
126
127 diff: Effect.fn(function* ({ olds, news }) {
128 if (!isResolved(news)) return undefined;
129 if (
130 (olds.searchJobIdentifier ?? undefined) !==
131 (news.searchJobIdentifier ?? undefined) ||
132 (olds.roleArn ?? undefined) !== (news.roleArn ?? undefined) ||
133 JSON.stringify(olds.exportSpecification ?? null) !==
134 JSON.stringify(news.exportSpecification ?? null)
135 ) {
136 return { action: "replace" } as const;
137 }
138 }),
139
140 read: Effect.fn(function* ({ id, output }) {
141 if (!output?.exportJobIdentifier) return undefined;
142 const job = yield* get(output.exportJobIdentifier);
143 if (job === undefined) return undefined;
144 const attrs = toAttrs(job);
145 const tags =
146 job.ExportJobArn !== undefined
147 ? yield* readBackupSearchTags(job.ExportJobArn)
148 : {};
149 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
150 }),
151
152 reconcile: Effect.fn(function* ({ id, news, output, session }) {
153 const internalTags = yield* createInternalTags(id);
154 const desiredTags = { ...internalTags, ...news.tags };
155
156 // OBSERVE — output is only an identifier cache; the job may be gone.
157 let job =
158 output?.exportJobIdentifier !== undefined
159 ? yield* get(output.exportJobIdentifier)
160 : undefined;
161
162 // ENSURE — an export job is immutable once started; start it if
163 // missing. Any change to its inputs is a replacement (diff).
164 if (job === undefined) {
165 const started = yield* backupsearch.startSearchResultExportJob({
166 SearchJobIdentifier: news.searchJobIdentifier,
167 ExportSpecification: {
168 s3ExportSpecification: {
169 DestinationBucket:
170 news.exportSpecification.s3ExportSpecification
171 .destinationBucket,
172 DestinationPrefix:
173 news.exportSpecification.s3ExportSpecification
174 .destinationPrefix,
175 },
176 },
177 RoleArn: news.roleArn,
178 Tags: desiredTags,
179 });
180 job = yield* backupsearch.getSearchResultExportJob({
181 ExportJobIdentifier: started.ExportJobIdentifier,
182 });
183 } else if (job.ExportJobArn !== undefined) {
184 // SYNC — tags are the only mutable aspect; diff against OBSERVED.
185 yield* syncBackupSearchTags(job.ExportJobArn, desiredTags);
186 }
187
188 yield* session.note(job.ExportJobIdentifier);
189 return toAttrs(job);
190 }),
191
192 // An export job cannot be stopped or deleted — its record ages out
193 // server-side and the exported .csv lives in the destination bucket.
194 delete: () => Effect.void,
195
196 list: () =>
197 backupsearch.listSearchResultExportJobs.pages({}).pipe(
198 Stream.runCollect,
199 Effect.map((chunk) =>
200 Array.from(chunk)
201 .flatMap((page) => page.ExportJobs ?? [])
202 .map(toAttrs),
203 ),
204 ),
205 };
206 }),
207 );