1import * as transfer from "@distilled.cloud/aws/transfer";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface ServerProps {
13 /**
14 * File-transfer protocols the server exposes.
15 * @default ["SFTP"]
16 */
17 protocols?: transfer.Protocol[];
18 /**
19 * Storage domain the server serves files from. Changing it replaces the
20 * server.
21 * @default "S3"
22 */
23 domain?: transfer.Domain;
24 /**
25 * Where the server endpoint is hosted. `PUBLIC` is internet-facing;
26 * `VPC` places it inside a VPC (requires `endpointDetails`).
27 * @default "PUBLIC"
28 */
29 endpointType?: transfer.EndpointType;
30 /**
31 * VPC endpoint configuration (subnets, security groups, address
32 * allocations). Required when `endpointType` is `VPC`.
33 */
34 endpointDetails?: transfer.EndpointDetails;
35 /**
36 * How users authenticate. `SERVICE_MANAGED` stores SSH keys in Transfer
37 * Family; `API_GATEWAY`/`AWS_LAMBDA`/`AWS_DIRECTORY_SERVICE` delegate to a
38 * custom identity provider. Changing it replaces the server.
39 * @default "SERVICE_MANAGED"
40 */
41 identityProviderType?: transfer.IdentityProviderType;
42 /**
43 * Configuration for a custom identity provider (required unless
44 * `identityProviderType` is `SERVICE_MANAGED`).
45 */
46 identityProviderDetails?: transfer.IdentityProviderDetails;
47 /**
48 * IAM role ARN Transfer Family assumes to publish CloudWatch logs.
49 */
50 loggingRole?: string;
51 /**
52 * Name of the security policy (cipher/algorithm set) attached to the server.
53 */
54 securityPolicyName?: string;
55 /**
56 * Banner shown to clients before authentication (SFTP/FTPS only).
57 */
58 preAuthenticationLoginBanner?: string;
59 /**
60 * Banner shown to clients after authentication.
61 */
62 postAuthenticationLoginBanner?: string;
63 /**
64 * Protocol-specific settings (passive IP, TLS session resumption, etc.).
65 */
66 protocolDetails?: transfer.ProtocolDetails;
67 /**
68 * S3 storage options such as directory-listing optimization.
69 */
70 s3StorageOptions?: transfer.S3StorageOptions;
71 /**
72 * Workflows triggered on file upload.
73 */
74 workflowDetails?: transfer.WorkflowDetails;
75 /**
76 * CloudWatch Logs log-group ARNs the server streams structured logs to.
77 */
78 structuredLogDestinations?: string[];
79 /**
80 * IP address type of the endpoint.
81 * @default "IPV4"
82 */
83 ipAddressType?: transfer.IpAddressType;
84 /**
85 * User-defined tags for the server.
86 */
87 tags?: Record<string, string>;
88}
89
90export interface Server extends Resource<
91 "AWS.Transfer.Server",
92 ServerProps,
93 {
94 /**
95 * AWS-assigned server ID (e.g. `s-0123456789abcdef0`). Clients connect to
96 * `{serverId}.server.transfer.{region}.amazonaws.com`.
97 */
98 serverId: string;
99 /**
100 * ARN of the server.
101 */
102 arn: string;
103 /**
104 * Where the server endpoint is hosted (`PUBLIC` or `VPC`).
105 */
106 endpointType: string;
107 /**
108 * Storage domain the server serves files from (`S3` or `EFS`).
109 */
110 domain: string;
111 /**
112 * How users authenticate.
113 */
114 identityProviderType: string;
115 /**
116 * File-transfer protocols the server exposes.
117 */
118 protocols: string[];
119 /**
120 * Current lifecycle state (e.g. `ONLINE`, `STARTING`).
121 */
122 state: string | undefined;
123 /**
124 * Current tags reported for the server.
125 */
126 tags: Record<string, string>;
127 },
128 never,
129 Providers
130> {}
131
132/**
133 * An AWS Transfer Family server — a managed SFTP/FTPS/FTP/AS2 endpoint in
134 * front of S3 or EFS storage. A running server is billed hourly (plus data
135 * transfer), so create it only when needed and destroy it promptly.
136 * ### Creating a Server
137 * **Example:** Public SFTP Server (Service-Managed Users)
138 * ```typescript
139 * const server = yield* Server("Sftp", {
140 * protocols: ["SFTP"],
141 * domain: "S3",
142 * endpointType: "PUBLIC",
143 * identityProviderType: "SERVICE_MANAGED",
144 * });
145 * ```
146 *
147 * ### Adding Users
148 * **Example:** SFTP Server with a Service-Managed User
149 * ```typescript
150 * const server = yield* Server("Sftp", {
151 * protocols: ["SFTP"],
152 * identityProviderType: "SERVICE_MANAGED",
153 * });
154 *
155 * // Role Transfer Family assumes to access the S3 storage backend
156 * const role = yield* AWS.IAM.Role("TransferUserRole", {
157 * assumeRolePolicyDocument: {
158 * Version: "2012-10-17",
159 * Statement: [
160 * {
161 * Effect: "Allow",
162 * Principal: { Service: "transfer.amazonaws.com" },
163 * Action: ["sts:AssumeRole"],
164 * },
165 * ],
166 * },
167 * inlinePolicies: {
168 * s3: {
169 * Version: "2012-10-17",
170 * Statement: [
171 * {
172 * Effect: "Allow",
173 * Action: ["s3:ListBucket", "s3:GetObject", "s3:PutObject"],
174 * Resource: [bucket.bucketArn, Output.interpolate`${bucket.bucketArn}/*`],
175 * },
176 * ],
177 * },
178 * },
179 * });
180 *
181 * const user = yield* User("Alice", {
182 * serverId: server.serverId,
183 * userName: "alice",
184 * role: role.roleArn,
185 * homeDirectory: Output.interpolate`/${bucket.bucketName}/alice`,
186 * sshPublicKeyBody: "ssh-ed25519 AAAA...",
187 * });
188 * ```
189 *
190 * @resource
191 */
192export const Server = Resource<Server>("AWS.Transfer.Server");
193
194const toTagRecord = (
195 tags: Array<{ Key?: string; Value?: string }> | undefined,
196): Record<string, string> =>
197 Object.fromEntries(
198 (tags ?? [])
199 .filter(
200 (tag): tag is { Key: string; Value: string } =>
201 typeof tag.Key === "string" && typeof tag.Value === "string",
202 )
203 .map((tag) => [tag.Key, tag.Value]),
204 );
205
206export const ServerProvider = () =>
207 Provider.effect(
208 Server,
209 Effect.gen(function* () {
210 const describe = Effect.fn(function* (serverId: string) {
211 const response = yield* transfer
212 .describeServer({ ServerId: serverId })
213 .pipe(
214 Effect.catchTag("ResourceNotFoundException", () =>
215 Effect.succeed(undefined),
216 ),
217 );
218 return response?.Server;
219 });
220
221 const readTags = Effect.fn(function* (arn: string) {
222 const response = yield* transfer
223 .listTagsForResource({ Arn: arn })
224 .pipe(Effect.catch(() => Effect.succeed(undefined)));
225 return toTagRecord(response?.Tags);
226 });
227
228 // Servers transition through STARTING before ONLINE; updateServer and
229 // deleteServer require a settled state. Budget ~7 minutes (10s x 42).
230 const waitUntilSettled = Effect.fn(function* (serverId: string) {
231 const settlePolicy = Schedule.max([
232 Schedule.fixed("10 seconds"),
233 Schedule.recurs(42),
234 ]);
235 return yield* describe(serverId).pipe(
236 Effect.flatMap((server) => {
237 if (
238 server !== undefined &&
239 (server.State === "STARTING" || server.State === "STOPPING")
240 ) {
241 return Effect.fail(
242 new Error(
243 `Transfer server '${serverId}' still settling (state: ${server.State})`,
244 ),
245 );
246 }
247 return Effect.succeed(server);
248 }),
249 Effect.retry({ schedule: settlePolicy }),
250 );
251 });
252
253 const toAttrs = Effect.fn(function* (server: transfer.DescribedServer) {
254 if (!server.ServerId) {
255 return yield* Effect.fail(
256 new Error("Transfer server is missing its ServerId"),
257 );
258 }
259 return {
260 serverId: server.ServerId,
261 arn: server.Arn,
262 endpointType: server.EndpointType ?? "PUBLIC",
263 domain: server.Domain ?? "S3",
264 identityProviderType:
265 server.IdentityProviderType ?? "SERVICE_MANAGED",
266 protocols: [...(server.Protocols ?? [])],
267 state: server.State,
268 tags: yield* readTags(server.Arn),
269 };
270 });
271
272 return {
273 stables: ["serverId", "arn"],
274
275 diff: Effect.fn(function* ({ olds, news }) {
276 if (!isResolved(news)) return undefined;
277 // Domain and identity-provider type are create-only.
278 if (
279 (news.domain ?? "S3") !== (olds?.domain ?? "S3") ||
280 (news.identityProviderType ?? "SERVICE_MANAGED") !==
281 (olds?.identityProviderType ?? "SERVICE_MANAGED")
282 ) {
283 return { action: "replace" } as const;
284 }
285 }),
286
287 read: Effect.fn(function* ({ id, output }) {
288 // ServerId is AWS-assigned, so a server can only be found via a
289 // previously-persisted output (like an auto-id VPC).
290 if (!output?.serverId) return undefined;
291 const server = yield* describe(output.serverId);
292 if (!server?.ServerId) return undefined;
293 const attrs = yield* toAttrs(server);
294 return (yield* hasAlchemyTags(id, attrs.tags))
295 ? attrs
296 : Unowned(attrs);
297 }),
298
299 reconcile: Effect.fn(function* ({ id, news, output, session }) {
300 const internalTags = yield* createInternalTags(id);
301 const desiredTags = { ...internalTags, ...news.tags };
302
303 // 1. Observe — cloud state is authoritative (via the id cache).
304 let serverId = output?.serverId;
305 let observed = serverId ? yield* describe(serverId) : undefined;
306
307 // 2. Ensure — create if missing.
308 if (observed === undefined) {
309 const created = yield* transfer.createServer({
310 Protocols: news.protocols ?? ["SFTP"],
311 Domain: news.domain,
312 EndpointType: news.endpointType,
313 EndpointDetails: news.endpointDetails,
314 IdentityProviderType: news.identityProviderType,
315 IdentityProviderDetails: news.identityProviderDetails,
316 LoggingRole: news.loggingRole,
317 SecurityPolicyName: news.securityPolicyName,
318 PreAuthenticationLoginBanner: news.preAuthenticationLoginBanner,
319 PostAuthenticationLoginBanner: news.postAuthenticationLoginBanner,
320 ProtocolDetails: news.protocolDetails,
321 S3StorageOptions: news.s3StorageOptions,
322 WorkflowDetails: news.workflowDetails,
323 StructuredLogDestinations: news.structuredLogDestinations,
324 IpAddressType: news.ipAddressType,
325 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
326 Key,
327 Value,
328 })),
329 });
330 serverId = created.ServerId;
331 } else {
332 // 3. Sync — push mutable configuration. updateServer requires a
333 // settled server state.
334 yield* waitUntilSettled(serverId!);
335 yield* transfer.updateServer({
336 ServerId: serverId!,
337 Protocols: news.protocols,
338 EndpointType: news.endpointType,
339 EndpointDetails: news.endpointDetails,
340 IdentityProviderDetails: news.identityProviderDetails,
341 LoggingRole: news.loggingRole,
342 SecurityPolicyName: news.securityPolicyName,
343 PreAuthenticationLoginBanner: news.preAuthenticationLoginBanner,
344 PostAuthenticationLoginBanner: news.postAuthenticationLoginBanner,
345 ProtocolDetails: news.protocolDetails,
346 S3StorageOptions: news.s3StorageOptions,
347 WorkflowDetails: news.workflowDetails,
348 StructuredLogDestinations: news.structuredLogDestinations,
349 IpAddressType: news.ipAddressType,
350 });
351 }
352
353 // Wait for the server to settle so downstream User creation and the
354 // returned state are stable.
355 observed = yield* waitUntilSettled(serverId!);
356 if (!observed?.ServerId || !observed.Arn) {
357 return yield* Effect.fail(
358 new Error(
359 `Transfer server '${serverId}' not found after reconcile`,
360 ),
361 );
362 }
363
364 // 3b. Sync tags — diff against OBSERVED cloud tags.
365 const arn = observed.Arn;
366 const observedTags = yield* readTags(arn);
367 const { upsert, removed } = diffTags(observedTags, desiredTags);
368 if (upsert.length > 0) {
369 yield* transfer.tagResource({ Arn: arn, Tags: upsert });
370 }
371 if (removed.length > 0) {
372 yield* transfer.untagResource({ Arn: arn, TagKeys: removed });
373 }
374
375 yield* session.note(observed.ServerId);
376 return yield* toAttrs(observed);
377 }),
378
379 delete: Effect.fn(function* ({ output }) {
380 yield* transfer
381 .deleteServer({ ServerId: output.serverId })
382 .pipe(
383 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
384 );
385 }),
386
387 list: () =>
388 transfer.listServers.pages({}).pipe(
389 Stream.runCollect,
390 Effect.map((chunk) =>
391 Array.from(chunk).flatMap((page) =>
392 (page.Servers ?? []).filter(
393 (server) => server.ServerId !== undefined,
394 ),
395 ),
396 ),
397 Effect.flatMap(
398 Effect.forEach(
399 (server) =>
400 toAttrs({
401 Arn: server.Arn,
402 ServerId: server.ServerId,
403 Domain: server.Domain,
404 EndpointType: server.EndpointType,
405 IdentityProviderType: server.IdentityProviderType,
406 State: server.State,
407 LoggingRole: server.LoggingRole,
408 }),
409 { concurrency: 4 },
410 ),
411 ),
412 ),
413 };
414 }),
415 );