1import * as codeconnections from "@distilled.cloud/aws/codeconnections";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface SyncConfigurationProps {
10 /**
11 * Branch of the repository that Git sync monitors, e.g. `main`.
12 */
13 branch: string;
14 /**
15 * Path to the configuration file (deployment file) in the repository,
16 * e.g. `deployments/stack-deployment.yaml`.
17 */
18 configFile: string;
19 /**
20 * ID of the repository link Git sync monitors (from a `RepositoryLink`).
21 */
22 repositoryLinkId: string;
23 /**
24 * Name of the Amazon Web Services resource kept in sync — for
25 * `CFN_STACK_SYNC` this is the CloudFormation stack name. Together with
26 * `syncType` it identifies the sync configuration; changing it replaces
27 * the configuration.
28 */
29 resourceName: string;
30 /**
31 * ARN of the IAM role that Git sync assumes to sync content to the
32 * resource.
33 */
34 roleArn: string;
35 /**
36 * The sync type. Changing it replaces the configuration.
37 * @default "CFN_STACK_SYNC"
38 */
39 syncType?: "CFN_STACK_SYNC";
40 /**
41 * Whether to publish deployment status (start/end) back to the provider
42 * as commit statuses.
43 * @default "ENABLED"
44 */
45 publishDeploymentStatus?: "ENABLED" | "DISABLED";
46 /**
47 * When to trigger a sync of the resource: on any change to the branch or
48 * only when the tracked config file changes.
49 * @default "ANY_CHANGE"
50 */
51 triggerResourceUpdateOn?: "ANY_CHANGE" | "FILE_CHANGE";
52 /**
53 * Whether Git sync comments sync status on pull requests against the
54 * tracked branch.
55 * @default "ENABLED"
56 */
57 pullRequestComment?: "ENABLED" | "DISABLED";
58}
59
60export interface SyncConfiguration extends Resource<
61 "AWS.CodeConnections.SyncConfiguration",
62 SyncConfigurationProps,
63 {
64 /** Name of the synced Amazon Web Services resource (e.g. stack name). */
65 resourceName: string;
66 /** The sync type. */
67 syncType: string;
68 /** Monitored branch. */
69 branch: string;
70 /** Path to the deployment file in the repository. */
71 configFile: string;
72 /** ID of the monitored repository link. */
73 repositoryLinkId: string;
74 /** Name of the linked repository. */
75 repositoryName: string;
76 /** Owner ID of the linked repository. */
77 ownerId: string;
78 /** The source provider (`GitHub`, `GitLab`, ...). */
79 providerType: string;
80 /** ARN of the IAM role Git sync assumes. */
81 roleArn: string;
82 },
83 never,
84 Providers
85> {}
86
87/**
88 * An AWS CodeConnections sync configuration — connects a repository link's
89 * branch + deployment file to an Amazon Web Services resource so Git sync
90 * keeps the resource updated from the repository (CloudFormation stack
91 * sync).
92 * ### Syncing a CloudFormation Stack
93 * **Example:** Stack Sync from a Repository Link
94 * ```typescript
95 * const sync = yield* CodeConnections.SyncConfiguration("StackSync", {
96 * branch: "main",
97 * configFile: "deployments/stack-deployment.yaml",
98 * repositoryLinkId: link.repositoryLinkId,
99 * resourceName: "my-stack",
100 * roleArn: gitSyncRole.roleArn,
101 * });
102 * ```
103 *
104 * **Example:** Sync Only on Deployment-File Changes
105 * ```typescript
106 * const sync = yield* CodeConnections.SyncConfiguration("StackSync", {
107 * branch: "main",
108 * configFile: "deployments/stack-deployment.yaml",
109 * repositoryLinkId: link.repositoryLinkId,
110 * resourceName: "my-stack",
111 * roleArn: gitSyncRole.roleArn,
112 * triggerResourceUpdateOn: "FILE_CHANGE",
113 * pullRequestComment: "DISABLED",
114 * });
115 * ```
116 *
117 * @resource
118 */
119export const SyncConfiguration = Resource<SyncConfiguration>(
120 "AWS.CodeConnections.SyncConfiguration",
121);
122
123const DEFAULT_SYNC_TYPE = "CFN_STACK_SYNC";
124
125export const SyncConfigurationProvider = () =>
126 Provider.effect(
127 SyncConfiguration,
128 Effect.gen(function* () {
129 /**
130 * Read a sync configuration by its identity (sync type + resource
131 * name); a missing configuration reads as absent.
132 */
133 const getByIdentity = Effect.fn(function* (
134 syncType: string,
135 resourceName: string,
136 ) {
137 const response = yield* codeconnections
138 .getSyncConfiguration({
139 SyncType: syncType,
140 ResourceName: resourceName,
141 })
142 .pipe(
143 Effect.catchTag("ResourceNotFoundException", () =>
144 Effect.succeed(undefined),
145 ),
146 );
147 return response?.SyncConfiguration;
148 });
149
150 const toAttrs = (config: codeconnections.SyncConfiguration) => ({
151 resourceName: config.ResourceName,
152 syncType: config.SyncType,
153 branch: config.Branch,
154 configFile: config.ConfigFile ?? "",
155 repositoryLinkId: config.RepositoryLinkId,
156 repositoryName: config.RepositoryName,
157 ownerId: config.OwnerId,
158 providerType: config.ProviderType ?? "",
159 roleArn: config.RoleArn,
160 });
161
162 return {
163 stables: [
164 "resourceName",
165 "syncType",
166 "repositoryName",
167 "ownerId",
168 "providerType",
169 ],
170
171 diff: Effect.fn(function* ({ olds, news }) {
172 if (!isResolved(news)) return undefined;
173 // (syncType, resourceName) is the configuration's identity —
174 // replace on change. Everything else is mutable via
175 // UpdateSyncConfiguration.
176 if (
177 (news?.resourceName ?? undefined) !==
178 (olds?.resourceName ?? undefined) ||
179 (news?.syncType ?? DEFAULT_SYNC_TYPE) !==
180 (olds?.syncType ?? DEFAULT_SYNC_TYPE)
181 ) {
182 return { action: "replace" } as const;
183 }
184 }),
185
186 // Sync configurations carry no tags — ownership is keyed by the
187 // (syncType, resourceName) identity the caller declares, so `read`
188 // returns the observed state as owned.
189 read: Effect.fn(function* ({ olds, output }) {
190 const syncType =
191 output?.syncType ?? olds?.syncType ?? DEFAULT_SYNC_TYPE;
192 const resourceName = output?.resourceName ?? olds?.resourceName;
193 if (resourceName === undefined) return undefined;
194 const config = yield* getByIdentity(syncType, resourceName);
195 return config === undefined ? undefined : toAttrs(config);
196 }),
197
198 reconcile: Effect.fn(function* ({ news, session }) {
199 const syncType = news.syncType ?? DEFAULT_SYNC_TYPE;
200
201 // 1. Observe — cloud state is authoritative.
202 let observed = yield* getByIdentity(syncType, news.resourceName);
203
204 // 2. Ensure — create if missing; tolerate the AlreadyExists race.
205 if (observed === undefined) {
206 observed = yield* codeconnections
207 .createSyncConfiguration({
208 Branch: news.branch,
209 ConfigFile: news.configFile,
210 RepositoryLinkId: news.repositoryLinkId,
211 ResourceName: news.resourceName,
212 RoleArn: news.roleArn,
213 SyncType: syncType,
214 PublishDeploymentStatus: news.publishDeploymentStatus,
215 TriggerResourceUpdateOn: news.triggerResourceUpdateOn,
216 PullRequestComment: news.pullRequestComment,
217 })
218 .pipe(
219 Effect.map((res) => res.SyncConfiguration),
220 Effect.catchTag("ResourceAlreadyExistsException", (error) =>
221 getByIdentity(syncType, news.resourceName).pipe(
222 Effect.flatMap((config) =>
223 config === undefined
224 ? Effect.fail(error)
225 : Effect.succeed(config),
226 ),
227 ),
228 ),
229 );
230 }
231
232 // 3. Sync — diff every mutable field against OBSERVED cloud state;
233 // skip the API entirely on no-op. Optional prop left undefined
234 // keeps the observed value (service-side default).
235 const drifted =
236 observed.Branch !== news.branch ||
237 (observed.ConfigFile ?? "") !== news.configFile ||
238 observed.RepositoryLinkId !== news.repositoryLinkId ||
239 observed.RoleArn !== news.roleArn ||
240 (news.publishDeploymentStatus !== undefined &&
241 observed.PublishDeploymentStatus !==
242 news.publishDeploymentStatus) ||
243 (news.triggerResourceUpdateOn !== undefined &&
244 observed.TriggerResourceUpdateOn !==
245 news.triggerResourceUpdateOn) ||
246 (news.pullRequestComment !== undefined &&
247 observed.PullRequestComment !== news.pullRequestComment);
248 if (drifted) {
249 const updated = yield* codeconnections.updateSyncConfiguration({
250 ResourceName: news.resourceName,
251 SyncType: syncType,
252 Branch: news.branch,
253 ConfigFile: news.configFile,
254 RepositoryLinkId: news.repositoryLinkId,
255 RoleArn: news.roleArn,
256 PublishDeploymentStatus: news.publishDeploymentStatus,
257 TriggerResourceUpdateOn: news.triggerResourceUpdateOn,
258 PullRequestComment: news.pullRequestComment,
259 });
260 observed = updated.SyncConfiguration;
261 }
262
263 yield* session.note(`${syncType}/${news.resourceName}`);
264 return toAttrs(observed);
265 }),
266
267 // DeleteSyncConfiguration documents no NotFound error — observe
268 // first so a repeat delete (after a state-persistence failure) is a
269 // no-op instead of an InvalidInput failure.
270 delete: Effect.fn(function* ({ output }) {
271 const observed = yield* getByIdentity(
272 output.syncType,
273 output.resourceName,
274 );
275 if (observed !== undefined) {
276 yield* codeconnections.deleteSyncConfiguration({
277 SyncType: output.syncType,
278 ResourceName: output.resourceName,
279 });
280 }
281 }),
282
283 // Enumerate every repository link's sync configurations.
284 list: () =>
285 codeconnections.listRepositoryLinks.pages({}).pipe(
286 Stream.runCollect,
287 Effect.map((chunk) =>
288 Array.from(chunk).flatMap((page) => page.RepositoryLinks ?? []),
289 ),
290 Effect.flatMap((links) =>
291 Effect.forEach(
292 links,
293 (link) =>
294 codeconnections.listSyncConfigurations
295 .pages({
296 RepositoryLinkId: link.RepositoryLinkId,
297 SyncType: DEFAULT_SYNC_TYPE,
298 })
299 .pipe(
300 Stream.runCollect,
301 Effect.map((chunk) =>
302 Array.from(chunk).flatMap(
303 (page) => page.SyncConfigurations ?? [],
304 ),
305 ),
306 ),
307 { concurrency: 4 },
308 ),
309 ),
310 Effect.map((configs) =>
311 configs.flat().map((config) => ({
312 resourceName: config.ResourceName,
313 syncType: config.SyncType,
314 branch: config.Branch,
315 configFile: config.ConfigFile ?? "",
316 repositoryLinkId: config.RepositoryLinkId,
317 repositoryName: config.RepositoryName,
318 ownerId: config.OwnerId,
319 providerType: config.ProviderType ?? "",
320 roleArn: config.RoleArn,
321 })),
322 ),
323 ),
324 };
325 }),
326 );