1import * as servicecatalog from "@distilled.cloud/aws/service-catalog";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 createTagsList,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16import { idempotencyToken, retryWhileResourceInUse } from "./internal.ts";
17
18export interface PortfolioProps {
19 /**
20 * Display name of the portfolio (1-100 characters). Portfolio display
21 * names are not unique in an account; the portfolio's identity is its
22 * auto-generated ID. If omitted, a unique name is generated from the app,
23 * stage, and logical ID. Display name changes are applied in place.
24 */
25 displayName?: string;
26 /**
27 * Name of the person or organization that owns the portfolio
28 * (for example a team name). Updatable in place.
29 */
30 providerName: string;
31 /**
32 * Description of the portfolio. Updatable in place.
33 */
34 description?: string;
35 /**
36 * Tags to apply to the portfolio. Merged with internal Alchemy tags.
37 */
38 tags?: Record<string, string>;
39}
40
41export interface Portfolio extends Resource<
42 "AWS.ServiceCatalog.Portfolio",
43 PortfolioProps,
44 {
45 /** The auto-generated portfolio ID (e.g. `port-abc123`). */
46 portfolioId: string;
47 /** The ARN of the portfolio. */
48 portfolioArn: string;
49 /** The display name of the portfolio. */
50 portfolioName: string;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * An AWS Service Catalog portfolio — a container that organizes products
58 * and grants access to them for a set of principals.
59 *
60 * ### Creating a Portfolio
61 * **Example:** Basic Portfolio
62 * ```typescript
63 * import * as ServiceCatalog from "alchemy/AWS/ServiceCatalog";
64 *
65 * const portfolio = yield* ServiceCatalog.Portfolio("Tools", {
66 * providerName: "platform-team",
67 * });
68 * ```
69 *
70 * **Example:** Portfolio with Description and Tags
71 * ```typescript
72 * const portfolio = yield* ServiceCatalog.Portfolio("Tools", {
73 * displayName: "engineering-tools",
74 * providerName: "platform-team",
75 * description: "Self-service infrastructure for engineering",
76 * tags: { team: "platform" },
77 * });
78 * ```
79 *
80 * ### Granting Access
81 * **Example:** Associate a principal (IAM role)
82 * ```typescript
83 * yield* ServiceCatalog.PrincipalPortfolioAssociation("DevAccess", {
84 * portfolioId: portfolio.portfolioId,
85 * principalArn: role.roleArn,
86 * });
87 * ```
88 *
89 * ### Adding Products
90 * **Example:** Associate a product
91 * ```typescript
92 * yield* ServiceCatalog.PortfolioProductAssociation("ToolsVpcProduct", {
93 * portfolioId: portfolio.portfolioId,
94 * productId: product.productId,
95 * });
96 * ```
97 *
98 * @resource
99 */
100export const Portfolio = Resource<Portfolio>("AWS.ServiceCatalog.Portfolio");
101
102export const PortfolioProvider = () =>
103 Provider.effect(
104 Portfolio,
105 Effect.gen(function* () {
106 const createName = Effect.fn(function* (
107 id: string,
108 props: Pick<PortfolioProps, "displayName">,
109 ) {
110 return (
111 props.displayName ??
112 (yield* createPhysicalName({ id, maxLength: 100 }))
113 );
114 });
115
116 // Portfolio display names are not unique, so the fallback lookup
117 // (state lost, no cached ID) scans for the deterministic name we
118 // would have created and takes the first match.
119 const findByDisplayName = Effect.fn(function* (displayName: string) {
120 return yield* servicecatalog.listPortfolios.pages({}).pipe(
121 Stream.runCollect,
122 Effect.map((chunk) =>
123 Array.from(chunk)
124 .flatMap((page) => page.PortfolioDetails ?? [])
125 .find((p) => p.DisplayName === displayName),
126 ),
127 );
128 });
129
130 const observe = Effect.fn(function* (portfolioId: string) {
131 return yield* servicecatalog
132 .describePortfolio({ Id: portfolioId })
133 .pipe(
134 Effect.catchTag("ResourceNotFoundException", () =>
135 Effect.succeed(undefined),
136 ),
137 );
138 });
139
140 return Portfolio.Provider.of({
141 stables: ["portfolioId", "portfolioArn"],
142 list: () =>
143 servicecatalog.listPortfolios.pages({}).pipe(
144 Stream.runCollect,
145 Effect.map((chunk) =>
146 Array.from(chunk)
147 .flatMap((page) => page.PortfolioDetails ?? [])
148 .filter(
149 (d) => d.Id != null && d.ARN != null && d.DisplayName != null,
150 )
151 .map((d) => ({
152 portfolioId: d.Id!,
153 portfolioArn: d.ARN!,
154 portfolioName: d.DisplayName!,
155 })),
156 ),
157 ),
158 read: Effect.fn(function* ({ id, olds, output }) {
159 let portfolioId = output?.portfolioId;
160 if (portfolioId === undefined) {
161 const displayName = yield* createName(id, olds ?? {});
162 const found = yield* findByDisplayName(displayName);
163 portfolioId = found?.Id;
164 }
165 if (portfolioId === undefined) return undefined;
166 const described = yield* observe(portfolioId);
167 const detail = described?.PortfolioDetail;
168 if (!detail?.Id) return undefined;
169 const attrs = {
170 portfolioId: detail.Id,
171 portfolioArn: detail.ARN!,
172 portfolioName: detail.DisplayName!,
173 };
174 return (yield* hasAlchemyTags(id, tagRecord(described?.Tags)))
175 ? attrs
176 : Unowned(attrs);
177 }),
178 // Display name, provider name, description, and tags are all
179 // updatable in place — no diff needed (identity is the generated ID).
180 reconcile: Effect.fn(function* ({
181 id,
182 news,
183 output,
184 session,
185 instanceId,
186 }) {
187 const displayName = yield* createName(id, news);
188 const internalTags = yield* createInternalTags(id);
189 const desiredTags: Record<string, string> = {
190 ...news.tags,
191 ...internalTags,
192 };
193
194 // 1. OBSERVE — cloud is authoritative; output caches the ID only.
195 let described = output?.portfolioId
196 ? yield* observe(output.portfolioId)
197 : undefined;
198 if (!described?.PortfolioDetail?.Id) {
199 const found = yield* findByDisplayName(displayName);
200 described = found?.Id ? yield* observe(found.Id) : undefined;
201 }
202
203 // 2. ENSURE — create when missing. The idempotency token (derived
204 // from the instance ID) makes a retried create converge on the
205 // same portfolio instead of duplicating it.
206 if (!described?.PortfolioDetail?.Id) {
207 const created = yield* servicecatalog.createPortfolio({
208 DisplayName: displayName,
209 ProviderName: news.providerName,
210 Description: news.description,
211 Tags: createTagsList(desiredTags),
212 IdempotencyToken: idempotencyToken(instanceId),
213 });
214 described = yield* observe(created.PortfolioDetail!.Id!);
215 }
216
217 const detail = described!.PortfolioDetail!;
218 const portfolioId = detail.Id!;
219
220 // 3. SYNC — diff observed details + tags against desired and apply
221 // only the delta in a single updatePortfolio call.
222 const observedTags = tagRecord(described!.Tags);
223 const { upsert, removed } = diffTags(observedTags, desiredTags);
224 const changes: {
225 DisplayName?: string;
226 ProviderName?: string;
227 Description?: string;
228 } = {};
229 if (detail.DisplayName !== displayName) {
230 changes.DisplayName = displayName;
231 }
232 if (detail.ProviderName !== news.providerName) {
233 changes.ProviderName = news.providerName;
234 }
235 if (
236 news.description !== undefined &&
237 detail.Description !== news.description
238 ) {
239 changes.Description = news.description;
240 }
241 if (
242 Object.keys(changes).length > 0 ||
243 upsert.length > 0 ||
244 removed.length > 0
245 ) {
246 yield* servicecatalog.updatePortfolio({
247 Id: portfolioId,
248 ...changes,
249 AddTags: upsert.length > 0 ? upsert : undefined,
250 RemoveTags: removed.length > 0 ? removed : undefined,
251 });
252 }
253
254 yield* session.note(portfolioId);
255 return {
256 portfolioId,
257 portfolioArn: detail.ARN!,
258 portfolioName: displayName,
259 };
260 }),
261 delete: Effect.fn(function* ({ output }) {
262 // Disassociations of products/principals settle asynchronously, so
263 // a portfolio delete right after teardown of its associations can
264 // transiently report ResourceInUseException.
265 yield* retryWhileResourceInUse(
266 servicecatalog.deletePortfolio({ Id: output.portfolioId }),
267 ).pipe(
268 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
269 );
270 }),
271 });
272 }),
273 );