1import * as appconfig from "@distilled.cloud/aws/appconfig";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface DeploymentProps {
11 /**
12 * ID of the application.
13 */
14 applicationId: string;
15 /**
16 * ID of the environment to deploy to.
17 */
18 environmentId: string;
19 /**
20 * ID of the deployment strategy that governs the rollout.
21 */
22 deploymentStrategyId: string;
23 /**
24 * ID of the configuration profile being deployed.
25 */
26 configurationProfileId: string;
27 /**
28 * The configuration version to deploy. For a hosted configuration profile
29 * this is the hosted version number (as a string); for other sources it is
30 * the version identifier defined by that source.
31 */
32 configurationVersion: string;
33 /**
34 * Description of the deployment.
35 */
36 description?: string;
37}
38
39export interface Deployment extends Resource<
40 "AWS.AppConfig.Deployment",
41 DeploymentProps,
42 {
43 applicationId: string;
44 environmentId: string;
45 deploymentNumber: number;
46 configurationProfileId: string;
47 configurationVersion: string;
48 state: string;
49 },
50 never,
51 Providers
52> {}
53
54/**
55 * An AWS AppConfig deployment — releases a configuration version to an
56 * environment following a deployment strategy. Deployments are immutable and
57 * asynchronous: the provider starts the deployment and waits (bounded) for it
58 * to reach a terminal state. Any change to the deployed version, strategy, or
59 * target creates a new deployment (a replacement). Use an all-at-once strategy
60 * (duration 0, bake 0) for a near-instant rollout.
61 *
62 * ### Deploying a Configuration
63 * **Example:** Deploy a Hosted Version
64 * ```typescript
65 * const deployment = yield* AppConfig.Deployment("Rollout", {
66 * applicationId: app.applicationId,
67 * environmentId: env.environmentId,
68 * deploymentStrategyId: strategy.deploymentStrategyId,
69 * configurationProfileId: profile.configurationProfileId,
70 * configurationVersion: String(version.versionNumber),
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const Deployment = Resource<Deployment>("AWS.AppConfig.Deployment");
77
78class DeploymentNotSettled extends Data.TaggedError("DeploymentNotSettled")<{
79 readonly deploymentNumber: number;
80 readonly state: string;
81}> {}
82
83const TERMINAL_STATES = new Set(["COMPLETE", "ROLLED_BACK", "REVERTED"]);
84
85export const DeploymentProvider = () =>
86 Provider.effect(
87 Deployment,
88 Effect.gen(function* () {
89 const readDeployment = Effect.fn(function* (
90 applicationId: string,
91 environmentId: string,
92 deploymentNumber: number,
93 ) {
94 return yield* appconfig
95 .getDeployment({
96 ApplicationId: applicationId,
97 EnvironmentId: environmentId,
98 DeploymentNumber: deploymentNumber,
99 })
100 .pipe(
101 Effect.catchTag("ResourceNotFoundException", () =>
102 Effect.succeed(undefined),
103 ),
104 );
105 });
106
107 // Deployments roll out asynchronously; wait (bounded) for a terminal
108 // state. With an all-at-once strategy this settles in seconds.
109 const waitForSettled = Effect.fn(function* (
110 applicationId: string,
111 environmentId: string,
112 deploymentNumber: number,
113 ) {
114 return yield* readDeployment(
115 applicationId,
116 environmentId,
117 deploymentNumber,
118 ).pipe(
119 Effect.flatMap((deployment) =>
120 deployment !== undefined &&
121 !TERMINAL_STATES.has(deployment.State ?? "")
122 ? Effect.fail(
123 new DeploymentNotSettled({
124 deploymentNumber,
125 state: deployment.State ?? "",
126 }),
127 )
128 : Effect.succeed(deployment),
129 ),
130 Effect.retry({
131 while: (e) => e instanceof DeploymentNotSettled,
132 schedule: Schedule.max([
133 Schedule.fixed("3 seconds"),
134 Schedule.recurs(40),
135 ]),
136 }),
137 );
138 });
139
140 return {
141 stables: ["applicationId", "environmentId", "deploymentNumber"],
142
143 // Deployments are immutable — any change starts a new deployment.
144 diff: Effect.fn(function* ({ olds, news }) {
145 if (!isResolved(news)) return undefined;
146 if (
147 olds !== undefined &&
148 (olds.applicationId !== news.applicationId ||
149 olds.environmentId !== news.environmentId ||
150 olds.deploymentStrategyId !== news.deploymentStrategyId ||
151 olds.configurationProfileId !== news.configurationProfileId ||
152 olds.configurationVersion !== news.configurationVersion)
153 ) {
154 return { action: "replace" } as const;
155 }
156 }),
157
158 read: Effect.fn(function* ({ output }) {
159 if (output?.deploymentNumber === undefined) return undefined;
160 const deployment = yield* readDeployment(
161 output.applicationId,
162 output.environmentId,
163 output.deploymentNumber,
164 );
165 if (deployment?.DeploymentNumber === undefined) return undefined;
166 return {
167 applicationId: output.applicationId,
168 environmentId: output.environmentId,
169 deploymentNumber: deployment.DeploymentNumber,
170 configurationProfileId:
171 deployment.ConfigurationProfileId ??
172 output.configurationProfileId,
173 configurationVersion:
174 deployment.ConfigurationVersion ?? output.configurationVersion,
175 state: deployment.State ?? "",
176 };
177 }),
178
179 reconcile: Effect.fn(function* ({ news, output, session }) {
180 // Observe — an interrupted create may already have started the
181 // deployment.
182 let observed =
183 output?.deploymentNumber !== undefined
184 ? yield* readDeployment(
185 news.applicationId,
186 news.environmentId,
187 output.deploymentNumber,
188 )
189 : undefined;
190
191 // Ensure — start the deployment if it does not exist yet. A
192 // concurrent deployment on the same environment rejects with
193 // ConflictException; retry (bounded) until it clears.
194 if (observed?.DeploymentNumber === undefined) {
195 observed = yield* appconfig
196 .startDeployment({
197 ApplicationId: news.applicationId,
198 EnvironmentId: news.environmentId,
199 DeploymentStrategyId: news.deploymentStrategyId,
200 ConfigurationProfileId: news.configurationProfileId,
201 ConfigurationVersion: news.configurationVersion,
202 Description: news.description,
203 })
204 .pipe(
205 Effect.retry({
206 while: (e) => e._tag === "ConflictException",
207 schedule: Schedule.max([
208 Schedule.fixed("3 seconds"),
209 Schedule.recurs(20),
210 ]),
211 }),
212 );
213 }
214
215 const number = observed.DeploymentNumber!;
216 const settled = yield* waitForSettled(
217 news.applicationId,
218 news.environmentId,
219 number,
220 );
221
222 yield* session.note(`deployment-${number}`);
223 return {
224 applicationId: news.applicationId,
225 environmentId: news.environmentId,
226 deploymentNumber: number,
227 configurationProfileId: news.configurationProfileId,
228 configurationVersion: news.configurationVersion,
229 state: settled?.State ?? observed.State ?? "",
230 };
231 }),
232
233 delete: Effect.fn(function* ({ output }) {
234 // A completed deployment is historical and cannot be deleted;
235 // stopDeployment only affects an in-progress rollout. Treat a
236 // "nothing to stop" rejection as success.
237 yield* appconfig
238 .stopDeployment({
239 ApplicationId: output.applicationId,
240 EnvironmentId: output.environmentId,
241 DeploymentNumber: output.deploymentNumber,
242 })
243 .pipe(
244 Effect.catchTag(
245 ["ResourceNotFoundException", "BadRequestException"],
246 () => Effect.void,
247 ),
248 );
249 }),
250
251 // Deployments are keyed under their parent environment.
252 list: () => Effect.succeed([]),
253 };
254 }),
255 );