1import * as grafana from "@distilled.cloud/aws/grafana";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { syncGrafanaTags, toTagRecord, unredact } from "./internal.ts";
13
14export interface WorkspaceProps {
15 /**
16 * A name for the workspace. If omitted, a deterministic physical name is
17 * generated.
18 */
19 name?: string;
20 /**
21 * A description for the workspace.
22 */
23 description?: string;
24 /**
25 * Whether the workspace can access AWS resources in only the current
26 * account (`CURRENT_ACCOUNT`) or throughout an organization
27 * (`ORGANIZATION`). Changing this replaces the workspace.
28 * @default "CURRENT_ACCOUNT"
29 */
30 accountAccessType?: "CURRENT_ACCOUNT" | "ORGANIZATION";
31 /**
32 * The user authentication providers for the workspace. `AWS_SSO` requires
33 * IAM Identity Center to be enabled in the account; `SAML` configures an
34 * external IdP after creation. Changing this replaces the workspace.
35 */
36 authenticationProviders: Array<"AWS_SSO" | "SAML">;
37 /**
38 * Whether AWS manages the workspace's IAM role and permissions
39 * (`SERVICE_MANAGED`) or you supply `workspaceRoleArn`
40 * (`CUSTOMER_MANAGED`). Changing this replaces the workspace.
41 * @default "SERVICE_MANAGED"
42 */
43 permissionType?: "SERVICE_MANAGED" | "CUSTOMER_MANAGED";
44 /**
45 * ARN of an IAM role the workspace uses to access AWS data sources and
46 * notification channels. Required when `permissionType` is
47 * `CUSTOMER_MANAGED`.
48 */
49 workspaceRoleArn?: string;
50 /**
51 * AWS services the workspace can query as data sources (e.g.
52 * `PROMETHEUS`, `CLOUDWATCH`, `XRAY`).
53 */
54 dataSources?: string[];
55 /**
56 * The Grafana version for the workspace (e.g. `"10.4"`). If omitted, AWS
57 * chooses the current default.
58 */
59 grafanaVersion?: string;
60 /**
61 * User-defined tags for the workspace.
62 */
63 tags?: Record<string, string>;
64}
65
66export interface Workspace extends Resource<
67 "AWS.Grafana.Workspace",
68 WorkspaceProps,
69 {
70 /** The unique ID of the workspace. */
71 workspaceId: string;
72 /** The ARN of the workspace. */
73 workspaceArn: string;
74 /** The URL of the workspace's Grafana console. */
75 endpoint: string;
76 /** The Grafana version the workspace runs. */
77 grafanaVersion: string;
78 /** The current status of the workspace (`ACTIVE`, `CREATING`, ...). */
79 status: string;
80 },
81 never,
82 Providers
83> {}
84
85/**
86 * An Amazon Managed Grafana workspace — a fully-managed Grafana server for
87 * visualizing operational metrics, logs, and traces.
88 *
89 * Grafana workspaces require IAM Identity Center (AWS SSO) to be enabled in
90 * the account when `authenticationProviders` includes `AWS_SSO`. Workspace
91 * provisioning is asynchronous and can take a few minutes.
92 *
93 * ### Creating a Workspace
94 * **Example:** SSO-Authenticated Workspace
95 * ```typescript
96 * const workspace = yield* Grafana.Workspace("Dashboards", {
97 * accountAccessType: "CURRENT_ACCOUNT",
98 * authenticationProviders: ["AWS_SSO"],
99 * permissionType: "SERVICE_MANAGED",
100 * dataSources: ["PROMETHEUS", "CLOUDWATCH"],
101 * });
102 * ```
103 *
104 * @resource
105 */
106export const Workspace = Resource<Workspace>("AWS.Grafana.Workspace");
107
108export const WorkspaceProvider = () =>
109 Provider.effect(
110 Workspace,
111 Effect.gen(function* () {
112 const arnFor = (accountId: string, region: string, id: string) =>
113 `arn:aws:grafana:${region}:${accountId}:/workspaces/${id}`;
114
115 const toAttrs = (
116 accountId: string,
117 region: string,
118 ws: grafana.WorkspaceDescription,
119 ) => ({
120 workspaceId: ws.id,
121 workspaceArn: arnFor(accountId, region, ws.id),
122 endpoint: ws.endpoint,
123 grafanaVersion: ws.grafanaVersion,
124 status: ws.status,
125 });
126
127 /** Describe a workspace by id; typed not-found → undefined. */
128 const describe = Effect.fn(function* (id: string) {
129 const response = yield* grafana
130 .describeWorkspace({ workspaceId: id })
131 .pipe(
132 Effect.catchTag("ResourceNotFoundException", () =>
133 Effect.succeed(undefined),
134 ),
135 );
136 return response?.workspace;
137 });
138
139 /** Poll until the workspace reaches ACTIVE; fail fast on *FAILED*. */
140 const waitActive = Effect.fn(function* (id: string) {
141 const ws = yield* grafana.describeWorkspace({ workspaceId: id }).pipe(
142 Effect.map((r) => r.workspace),
143 Effect.repeat({
144 schedule: Schedule.max([
145 Schedule.fixed("5 seconds"),
146 Schedule.recurs(36),
147 ]),
148 until: (w) => w.status === "ACTIVE" || w.status.endsWith("FAILED"),
149 }),
150 );
151 if (ws.status !== "ACTIVE") {
152 return yield* Effect.fail(
153 new Error(
154 `Grafana workspace ${id} did not become ACTIVE (status: ${ws.status})`,
155 ),
156 );
157 }
158 return ws;
159 });
160
161 return {
162 stables: ["workspaceId", "workspaceArn", "endpoint"],
163
164 diff: Effect.fn(function* ({ olds, news }) {
165 if (!isResolved(news)) return undefined;
166 const authChanged =
167 JSON.stringify(
168 [...(olds?.authenticationProviders ?? [])].sort(),
169 ) !== JSON.stringify([...news.authenticationProviders].sort());
170 if (
171 authChanged ||
172 (olds?.accountAccessType ?? "CURRENT_ACCOUNT") !==
173 (news.accountAccessType ?? "CURRENT_ACCOUNT") ||
174 (olds?.permissionType ?? "SERVICE_MANAGED") !==
175 (news.permissionType ?? "SERVICE_MANAGED")
176 ) {
177 return { action: "replace" } as const;
178 }
179 }),
180
181 read: Effect.fn(function* ({ id, output }) {
182 if (!output?.workspaceId) return undefined;
183 const { accountId, region } = yield* AWSEnvironment.current;
184 const ws = yield* describe(output.workspaceId);
185 if (ws === undefined) return undefined;
186 const attrs = toAttrs(accountId, region, ws);
187 const tags = toTagRecord(ws.tags);
188 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
189 }),
190
191 reconcile: Effect.fn(function* ({ id, news, output, session }) {
192 const { accountId, region } = yield* AWSEnvironment.current;
193 const internalTags = yield* createInternalTags(id);
194 const desiredTags = { ...internalTags, ...news!.tags };
195
196 // 1. Observe — cloud state is authoritative; output is an id cache.
197 let ws =
198 output?.workspaceId !== undefined
199 ? yield* describe(output.workspaceId)
200 : undefined;
201
202 // 2. Ensure — create if missing, then wait for ACTIVE.
203 if (ws === undefined) {
204 const created = yield* grafana.createWorkspace({
205 accountAccessType: news!.accountAccessType ?? "CURRENT_ACCOUNT",
206 permissionType: news!.permissionType ?? "SERVICE_MANAGED",
207 authenticationProviders: news!.authenticationProviders,
208 workspaceName: news!.name,
209 workspaceDescription: news!.description,
210 workspaceRoleArn: news!.workspaceRoleArn,
211 workspaceDataSources: news!.dataSources,
212 grafanaVersion: news!.grafanaVersion,
213 tags: desiredTags,
214 });
215 ws = yield* waitActive(created.workspace.id);
216 } else {
217 // 3. Sync mutable settings via updateWorkspace when drifted.
218 const drifted =
219 (news!.name ?? undefined) !== unredact(ws.name) ||
220 (news!.description ?? undefined) !== unredact(ws.description) ||
221 JSON.stringify([...(news!.dataSources ?? [])].sort()) !==
222 JSON.stringify([...(ws.dataSources ?? [])].sort());
223 if (drifted) {
224 yield* grafana.updateWorkspace({
225 workspaceId: ws.id,
226 workspaceName: news!.name,
227 workspaceDescription: news!.description,
228 workspaceDataSources: news!.dataSources,
229 workspaceRoleArn: news!.workspaceRoleArn,
230 });
231 ws = yield* waitActive(ws.id);
232 }
233 }
234
235 // 3b. Sync tags — diff against OBSERVED cloud tags.
236 const arn = arnFor(accountId, region, ws.id);
237 yield* syncGrafanaTags(arn, desiredTags);
238
239 yield* session.note(ws.id);
240 return toAttrs(accountId, region, ws);
241 }),
242
243 delete: Effect.fn(function* ({ output }) {
244 yield* grafana
245 .deleteWorkspace({ workspaceId: output.workspaceId })
246 .pipe(
247 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
248 Effect.retry({
249 while: (e) => e._tag === "ConflictException",
250 schedule: Schedule.max([
251 Schedule.fixed("5 seconds"),
252 Schedule.recurs(24),
253 ]),
254 }),
255 );
256 }),
257
258 list: () =>
259 grafana.listWorkspaces.pages({}).pipe(
260 Stream.runCollect,
261 Effect.flatMap((chunk) =>
262 Effect.gen(function* () {
263 const { accountId, region } = yield* AWSEnvironment.current;
264 return Array.from(chunk).flatMap((page) =>
265 page.workspaces.map((summary) => ({
266 workspaceId: summary.id,
267 workspaceArn: arnFor(accountId, region, summary.id),
268 endpoint: summary.endpoint,
269 grafanaVersion: summary.grafanaVersion,
270 status: summary.status,
271 })),
272 );
273 }),
274 ),
275 ),
276 };
277 }),
278 );