1import * as shield from "@distilled.cloud/aws/shield";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7
8/** Whether the Shield Advanced subscription renews automatically. */
9export type SubscriptionAutoRenew = "ENABLED" | "DISABLED";
10
11export interface SubscriptionProps {
12 /**
13 * Whether the subscription renews automatically at the end of the 1-year
14 * commitment. Mutable — but AWS only allows changing it in the 30 days
15 * before the current period ends.
16 * @default "ENABLED"
17 */
18 autoRenew?: SubscriptionAutoRenew;
19}
20
21export interface Subscription extends Resource<
22 "AWS.Shield.Subscription",
23 SubscriptionProps,
24 {
25 /** ARN of the subscription. */
26 subscriptionArn: string | undefined;
27 /** Start of the current subscription period (ISO timestamp). */
28 startTime: string | undefined;
29 /** End of the current subscription period (ISO timestamp). */
30 endTime: string | undefined;
31 /** Length of the commitment, in seconds (1 year). */
32 timeCommitmentInSeconds: number | undefined;
33 /** Whether the subscription auto-renews. */
34 autoRenew: string | undefined;
35 /** Whether proactive engagement by the Shield Response Team is enabled. */
36 proactiveEngagementStatus: string | undefined;
37 },
38 never,
39 Providers
40> {}
41
42/**
43 * The account-level AWS Shield Advanced subscription.
44 *
45 * :::caution
46 * Creating this resource subscribes the account to Shield Advanced at
47 * **$3,000/month with a mandatory 1-year commitment** (billed to the
48 * Organizations payer account). AWS refuses `DeleteSubscription` until the
49 * commitment ends, so destroying this resource before then leaves the
50 * subscription in place (a warning is logged); after the commitment it is
51 * cancelled for real.
52 * :::
53 *
54 * ### Subscribing to Shield Advanced
55 * **Example:** Subscribe with Auto-Renew
56 * ```typescript
57 * const subscription = yield* Shield.Subscription("Shield", {});
58 * ```
59 *
60 * **Example:** Subscribe and Disable Auto-Renew
61 * ```typescript
62 * const subscription = yield* Shield.Subscription("Shield", {
63 * autoRenew: "DISABLED",
64 * });
65 * ```
66 */
67const SubscriptionResource = Resource<Subscription>("AWS.Shield.Subscription");
68
69export { SubscriptionResource as Subscription };
70
71// `describeSubscription` fails with the (patched) typed `SubscriptionNotFound`
72// when the account is not subscribed — collapse it to `undefined`.
73const observeSubscription = shield.describeSubscription({}).pipe(
74 Effect.map((r) => r.Subscription),
75 Effect.catchTag("SubscriptionNotFound", () => Effect.succeed(undefined)),
76);
77
78const buildAttrs = (subscription: shield.Subscription) => ({
79 subscriptionArn: subscription.SubscriptionArn,
80 startTime: subscription.StartTime?.toISOString(),
81 endTime: subscription.EndTime?.toISOString(),
82 timeCommitmentInSeconds: subscription.TimeCommitmentInSeconds,
83 autoRenew: subscription.AutoRenew,
84 proactiveEngagementStatus: subscription.ProactiveEngagementStatus,
85});
86
87export const SubscriptionProvider = () =>
88 Provider.effect(
89 SubscriptionResource,
90 Effect.gen(function* () {
91 return {
92 stables: ["subscriptionArn"],
93
94 read: Effect.fn(function* ({ output }) {
95 const subscription = yield* observeSubscription;
96 if (!subscription) return undefined;
97 const attrs = buildAttrs(subscription);
98 // Shield subscriptions carry no tags, so ownership cannot be
99 // verified from cloud state alone. Treat it as ours only when we
100 // have prior state; otherwise gate adoption behind --adopt.
101 return output ? attrs : Unowned(attrs);
102 }),
103
104 // Account-level singleton — report the single subscription, if any.
105 list: () =>
106 observeSubscription.pipe(
107 Effect.map((subscription) =>
108 subscription ? [buildAttrs(subscription)] : [],
109 ),
110 ),
111
112 reconcile: Effect.fn(function* ({ news, session }) {
113 // 1. OBSERVE — cloud state is authoritative.
114 let subscription = yield* observeSubscription;
115
116 // 2. ENSURE — subscribe if not already; tolerate the AlreadyExists
117 // race.
118 if (!subscription) {
119 yield* shield
120 .createSubscription({})
121 .pipe(
122 Effect.catchTag(
123 "ResourceAlreadyExistsException",
124 () => Effect.void,
125 ),
126 );
127 subscription = yield* shield
128 .describeSubscription({})
129 .pipe(Effect.map((r) => r.Subscription));
130 if (!subscription) {
131 return yield* Effect.fail(
132 new Error(
133 "Failed to create or read the Shield Advanced subscription",
134 ),
135 );
136 }
137 }
138
139 // 3. SYNC auto-renew — observed ↔ desired; AWS only accepts the
140 // change within 30 days of the period end, so skip the call on
141 // a no-op.
142 const desiredAutoRenew = news.autoRenew ?? "ENABLED";
143 if (subscription.AutoRenew !== desiredAutoRenew) {
144 yield* shield.updateSubscription({ AutoRenew: desiredAutoRenew });
145 const refreshed = yield* shield
146 .describeSubscription({})
147 .pipe(Effect.map((r) => r.Subscription));
148 subscription = refreshed ?? subscription;
149 }
150
151 // 4. RETURN fresh attributes.
152 if (subscription.SubscriptionArn !== undefined) {
153 yield* session.note(subscription.SubscriptionArn);
154 }
155 return buildAttrs(subscription);
156 }),
157
158 delete: Effect.fn(function* () {
159 // AWS refuses to cancel a Shield Advanced subscription before the
160 // 1-year commitment ends (LockedSubscriptionException). Warn and
161 // leave it in place rather than wedging the destroy.
162 yield* shield.deleteSubscription({}).pipe(
163 Effect.catchTag(
164 ["SubscriptionNotFound", "ResourceNotFoundException"],
165 () => Effect.void,
166 ),
167 Effect.catchTag("LockedSubscriptionException", (error) =>
168 Effect.logWarning(
169 "Shield Advanced subscription is locked for its 1-year commitment and cannot be cancelled yet; leaving it in place.",
170 error,
171 ),
172 ),
173 );
174 }),
175 };
176 }),
177 );