1import * as macie2 from "@distilled.cloud/aws/macie2";
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 {
9 createInternalTags,
10 diffTags,
11 hasAlchemyTags,
12 tagRecord,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import { retryThroughEnablement } from "./common.ts";
16
17/**
18 * Whether the classification job runs once (`ONE_TIME`) or on a recurring
19 * schedule (`SCHEDULED`).
20 */
21export type JobType = "ONE_TIME" | "SCHEDULED";
22
23/**
24 * How Macie selects managed data identifiers for the job.
25 */
26export type ManagedDataIdentifierSelector =
27 | "ALL"
28 | "EXCLUDE"
29 | "INCLUDE"
30 | "NONE"
31 | "RECOMMENDED";
32
33/**
34 * A set of S3 buckets (within one account) that the job analyzes.
35 */
36export interface BucketDefinition {
37 /** The account that owns the buckets. */
38 accountId: string;
39 /** The names of the buckets to analyze. */
40 buckets: string[];
41}
42
43export interface ClassificationJobProps {
44 /**
45 * Custom name for the job. Must be unique per account. If omitted, a unique
46 * name is generated from the app/stage/logical ID.
47 */
48 name?: string;
49
50 /**
51 * Whether the job runs once (`ONE_TIME`) or on a recurring schedule
52 * (`SCHEDULED`).
53 * @default "ONE_TIME"
54 */
55 jobType?: JobType;
56
57 /**
58 * The S3 buckets to analyze, grouped by owning account. Changing this
59 * replaces the job — classification jobs are immutable once created.
60 */
61 bucketDefinitions: BucketDefinition[];
62
63 /**
64 * Optional description of the job.
65 */
66 description?: string;
67
68 /**
69 * The sampling depth, as a percentage, applied to objects in the buckets.
70 * @default 100
71 */
72 samplingPercentage?: number;
73
74 /**
75 * For a scheduled job, whether Macie analyzes all existing eligible objects
76 * immediately on the first run.
77 */
78 initialRun?: boolean;
79
80 /**
81 * How Macie selects managed data identifiers for the job.
82 */
83 managedDataIdentifierSelector?: ManagedDataIdentifierSelector;
84
85 /**
86 * Tags applied to the job. Alchemy ownership tags are merged in automatically.
87 */
88 tags?: Record<string, string>;
89}
90
91/** @resource */
92export interface ClassificationJob extends Resource<
93 "AWS.Macie2.ClassificationJob",
94 ClassificationJobProps,
95 {
96 /** Generated job ID. */
97 jobId: string;
98 /** ARN of the classification job. */
99 jobArn: string;
100 /** The resolved job name. */
101 name: string;
102 /** Current job status (`RUNNING` / `COMPLETE` / `CANCELLED` / ...). */
103 jobStatus: string | undefined;
104 },
105 never,
106 Providers
107> {}
108
109/**
110 * An Amazon Macie classification job — scans one or more S3 buckets for
111 * sensitive data. Requires Macie to be enabled for the account (see
112 * `Macie2.Session`). Jobs are immutable once created; changing the job type,
113 * name, or bucket set replaces the job. Destroy cancels the job.
114 *
115 * ### Creating a classification job
116 * **Example:** One-time job over a bucket
117 * ```typescript
118 * const job = yield* Macie2.ClassificationJob("Scan", {
119 * jobType: "ONE_TIME",
120 * bucketDefinitions: [{ accountId, buckets: ["my-bucket"] }],
121 * });
122 * ```
123 *
124 * **Example:** Sampled scan with a description
125 * ```typescript
126 * const job = yield* Macie2.ClassificationJob("Scan", {
127 * jobType: "ONE_TIME",
128 * bucketDefinitions: [{ accountId, buckets: ["my-bucket"] }],
129 * samplingPercentage: 20,
130 * description: "PII sweep",
131 * });
132 * ```
133 */
134const ClassificationJobResource = Resource<ClassificationJob>(
135 "AWS.Macie2.ClassificationJob",
136);
137
138export { ClassificationJobResource as ClassificationJob };
139
140const createName = (id: string, props: Partial<ClassificationJobProps>) =>
141 props.name
142 ? Effect.succeed(props.name)
143 : createPhysicalName({ id, maxLength: 200 });
144
145// A stable fingerprint of the immutable definition — any change replaces.
146const fingerprint = (props: Partial<ClassificationJobProps>) =>
147 JSON.stringify({
148 jobType: props.jobType ?? "ONE_TIME",
149 bucketDefinitions: (props.bucketDefinitions ?? [])
150 .map((b) => ({ accountId: b.accountId, buckets: [...b.buckets].sort() }))
151 .sort((a, b) => a.accountId.localeCompare(b.accountId)),
152 });
153
154const buildAttrs = (
155 jobId: string,
156 d: macie2.DescribeClassificationJobResponse,
157) => ({
158 jobId,
159 jobArn: d.jobArn!,
160 name: d.name!,
161 jobStatus: d.jobStatus,
162});
163
164export const ClassificationJobProvider = () =>
165 Provider.effect(
166 ClassificationJobResource,
167 Effect.gen(function* () {
168 const describe = (jobId: string) =>
169 macie2
170 .describeClassificationJob({ jobId })
171 .pipe(
172 Effect.catchTag("ResourceNotFoundException", () =>
173 Effect.succeed(undefined),
174 ),
175 );
176
177 return {
178 stables: ["jobId", "jobArn", "name"],
179
180 read: Effect.fn(function* ({ id, output }) {
181 if (!output?.jobId) return undefined;
182 const d = yield* describe(output.jobId);
183 if (!d) return undefined;
184 const attrs = buildAttrs(output.jobId, d);
185 return (yield* hasAlchemyTags(id, d.tags)) ? attrs : Unowned(attrs);
186 }),
187
188 // Classification jobs are enumerable, but they are per-job resources
189 // (not a singleton); the engine keys them by logical ID, so return the
190 // empty set here and rely on `read` for state refresh.
191 list: () => Effect.succeed([]),
192
193 diff: Effect.fn(function* ({ id, news, olds = {} }) {
194 if (!isResolved(news)) return undefined;
195 const oldName = yield* createName(id, olds);
196 const newName = yield* createName(id, news);
197 if (oldName !== newName) return { action: "replace" } as const;
198 if (fingerprint(olds) !== fingerprint(news)) {
199 return { action: "replace" } as const;
200 }
201 // Jobs are immutable apart from status; nothing else to update.
202 return undefined;
203 }),
204
205 reconcile: Effect.fn(function* ({ id, news, output, session }) {
206 const internalTags = yield* createInternalTags(id);
207 const desiredTags = { ...news.tags, ...internalTags };
208 const name = output?.name ?? (yield* createName(id, news));
209
210 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
211 let jobId = output?.jobId;
212 let live = jobId ? yield* describe(jobId) : undefined;
213
214 // 2. ENSURE — create the job if it does not exist.
215 if (jobId === undefined || live === undefined) {
216 const created = yield* retryThroughEnablement(
217 macie2.createClassificationJob({
218 name,
219 jobType: news.jobType ?? "ONE_TIME",
220 s3JobDefinition: {
221 bucketDefinitions: news.bucketDefinitions.map((b) => ({
222 accountId: b.accountId,
223 buckets: b.buckets,
224 })),
225 },
226 description: news.description,
227 samplingPercentage: news.samplingPercentage,
228 initialRun: news.initialRun,
229 managedDataIdentifierSelector:
230 news.managedDataIdentifierSelector,
231 tags: desiredTags,
232 }),
233 );
234 jobId = created.jobId!;
235 live = yield* macie2.describeClassificationJob({ jobId });
236 } else {
237 // 3. SYNC tags — the definition is immutable; only tags mutate.
238 const { upsert, removed } = diffTags(
239 tagRecord(live.tags),
240 desiredTags,
241 );
242 if (upsert.length > 0) {
243 yield* macie2.tagResource({
244 resourceArn: live.jobArn!,
245 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
246 });
247 }
248 if (removed.length > 0) {
249 yield* macie2.untagResource({
250 resourceArn: live.jobArn!,
251 tagKeys: removed,
252 });
253 }
254 }
255
256 // 4. RETURN fresh attributes.
257 const final = yield* macie2.describeClassificationJob({ jobId });
258 yield* session.note(jobId);
259 return buildAttrs(jobId, final);
260 }),
261
262 delete: Effect.fn(function* ({ output }) {
263 // Classification jobs cannot be deleted — only cancelled. Cancelling
264 // an already-terminal job raises ConflictException; treat as done.
265 yield* macie2
266 .updateClassificationJob({
267 jobId: output.jobId,
268 jobStatus: "CANCELLED",
269 })
270 .pipe(
271 Effect.catchTag("ConflictException", () => Effect.void),
272 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
273 );
274 }),
275 };
276 }),
277 );