1import type * as NFW from "@distilled.cloud/aws/network-firewall";
2import * as nfw from "@distilled.cloud/aws/network-firewall";
3import * as Effect from "effect/Effect";
4import { deepEqual, isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface LoggingConfigurationProps {
10 /**
11 * ARN of the {@link Firewall} to attach the logging configuration to.
12 * Changing the firewall replaces the configuration.
13 */
14 firewallArn: string;
15 /**
16 * The log destinations, one per log type (`ALERT`, `FLOW`, `TLS`). Uses
17 * raw Network Firewall API structures. The provider converges the live
18 * configuration one change at a time, as the API requires.
19 */
20 logDestinationConfigs: NFW.LogDestinationConfig[];
21}
22
23export interface LoggingConfiguration extends Resource<
24 "AWS.NetworkFirewall.LoggingConfiguration",
25 LoggingConfigurationProps,
26 {
27 /** ARN of the firewall the logging configuration applies to. */
28 firewallArn: string;
29 },
30 never,
31 Providers
32> {}
33
34/**
35 * The logging configuration of an AWS Network Firewall {@link Firewall} —
36 * routes the firewall's `ALERT`, `FLOW`, and `TLS` logs to S3, CloudWatch
37 * Logs, or Kinesis Data Firehose destinations.
38 *
39 * A firewall has exactly one logging configuration; deleting this resource
40 * resets it to no logging.
41 * ### Configuring Logging
42 * **Example:** Flow logs to CloudWatch Logs
43 * ```typescript
44 * import * as Logs from "alchemy/AWS/Logs";
45 * import * as NetworkFirewall from "alchemy/AWS/NetworkFirewall";
46 *
47 * const logGroup = yield* Logs.LogGroup("FirewallLogs");
48 *
49 * yield* NetworkFirewall.LoggingConfiguration("Logging", {
50 * firewallArn: firewall.firewallArn,
51 * logDestinationConfigs: [
52 * {
53 * LogType: "FLOW",
54 * LogDestinationType: "CloudWatchLogs",
55 * LogDestination: { logGroup: logGroup.logGroupName },
56 * },
57 * ],
58 * });
59 * ```
60 *
61 * **Example:** Alert logs to S3
62 * ```typescript
63 * yield* NetworkFirewall.LoggingConfiguration("Logging", {
64 * firewallArn: firewall.firewallArn,
65 * logDestinationConfigs: [
66 * {
67 * LogType: "ALERT",
68 * LogDestinationType: "S3",
69 * LogDestination: { bucketName: bucket.bucketName, prefix: "alerts" },
70 * },
71 * ],
72 * });
73 * ```
74 *
75 * @resource
76 */
77export const LoggingConfiguration = Resource<LoggingConfiguration>(
78 "AWS.NetworkFirewall.LoggingConfiguration",
79);
80
81export const LoggingConfigurationProvider = () =>
82 Provider.effect(
83 LoggingConfiguration,
84 Effect.gen(function* () {
85 const readConfigs = Effect.fn(function* (firewallArn: string) {
86 const response = yield* nfw.describeLoggingConfiguration({
87 FirewallArn: firewallArn,
88 });
89 return [
90 ...(response.LoggingConfiguration?.LogDestinationConfigs ?? []),
91 ];
92 });
93
94 const applyConfigs = Effect.fn(function* (
95 firewallArn: string,
96 configs: NFW.LogDestinationConfig[],
97 ) {
98 yield* nfw.updateLoggingConfiguration({
99 FirewallArn: firewallArn,
100 LoggingConfiguration: { LogDestinationConfigs: configs },
101 });
102 });
103
104 // Converge the live logging configuration to `desired`, one change per
105 // UpdateLoggingConfiguration call as the API requires (a single call
106 // may only add one config, remove one config, or change one existing
107 // destination).
108 const converge = Effect.fn(function* (
109 firewallArn: string,
110 desired: NFW.LogDestinationConfig[],
111 ) {
112 let current = yield* readConfigs(firewallArn);
113 const desiredByType = new Map(desired.map((d) => [d.LogType, d]));
114
115 // Remove log types no longer desired.
116 for (const config of [...current]) {
117 if (!desiredByType.has(config.LogType)) {
118 current = current.filter((c) => c.LogType !== config.LogType);
119 yield* applyConfigs(firewallArn, current);
120 }
121 }
122 // Change destinations of existing log types.
123 for (const config of desired) {
124 const existing = current.find((c) => c.LogType === config.LogType);
125 if (existing !== undefined && !deepEqual(existing, config)) {
126 current = current.map((c) =>
127 c.LogType === config.LogType ? config : c,
128 );
129 yield* applyConfigs(firewallArn, current);
130 }
131 }
132 // Add missing log types.
133 for (const config of desired) {
134 if (!current.some((c) => c.LogType === config.LogType)) {
135 current = [...current, config];
136 yield* applyConfigs(firewallArn, current);
137 }
138 }
139 });
140
141 return LoggingConfiguration.Provider.of({
142 stables: ["firewallArn"],
143
144 // Keyed by its parent firewall — not independently enumerable in a
145 // useful way (it is a singleton setting, not a discrete resource).
146 list: () => Effect.succeed([]),
147
148 read: Effect.fn(function* ({ olds, output }) {
149 const firewallArn = output?.firewallArn ?? olds?.firewallArn;
150 if (firewallArn === undefined) return undefined;
151 const response = yield* nfw
152 .describeLoggingConfiguration({ FirewallArn: firewallArn })
153 .pipe(
154 Effect.catchTag("ResourceNotFoundException", () =>
155 Effect.succeed(undefined),
156 ),
157 );
158 const configs =
159 response?.LoggingConfiguration?.LogDestinationConfigs ?? [];
160 if (configs.length === 0) return undefined;
161 return { firewallArn };
162 }),
163
164 diff: Effect.fn(function* ({ news, olds }) {
165 if (!isResolved(news)) return undefined;
166 if (olds.firewallArn !== news.firewallArn) {
167 return { action: "replace" } as const;
168 }
169 }),
170
171 reconcile: Effect.fn(function* ({ news, session }) {
172 yield* converge(news.firewallArn, news.logDestinationConfigs);
173 yield* session.note(news.firewallArn);
174 return { firewallArn: news.firewallArn };
175 }),
176
177 delete: Effect.fn(function* ({ output }) {
178 // Reset to no logging, removing one destination per call. The
179 // firewall itself may already be gone — that is success.
180 yield* converge(output.firewallArn, []).pipe(
181 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
182 );
183 }),
184 });
185 }),
186 );