| 1 | import * as firebaserules from "@distilled.cloud/gcp/firebaserules_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { GcpEnvironment } from "../Environment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { |
| 11 | expandRulesetName, |
| 12 | getRelease, |
| 13 | isGeneratedReleaseId, |
| 14 | listReleases, |
| 15 | projectParent, |
| 16 | releaseIdOf, |
| 17 | releaseResourceName, |
| 18 | sameText, |
| 19 | toReleaseId, |
| 20 | } from "./internal.ts"; |
| 21 | |
| 22 | export type ReleaseProps = { |
| 23 | /** |
| 24 | * Release id (the `{release_id}` segment of |
| 25 | * `projects/{project}/releases/{release_id}`). May contain `/` (e.g. |
| 26 | * `cloud.firestore` or `prod/v23`). If omitted, a unique `alc-` |
| 27 | * prefixed name is generated from the stack, stage, and logical id. |
| 28 | * Firebase Rules releases have no labels field, so generated ids carry |
| 29 | * the `alc-` prefix for `list` / nuke. Immutable — changing it |
| 30 | * replaces the release. |
| 31 | */ |
| 32 | releaseId?: string; |
| 33 | /** |
| 34 | * Ruleset this release points at |
| 35 | * (`projects/{project}/rulesets/{rulesetId}` or a bare ruleset id). |
| 36 | * The ruleset must exist. Updating this patches the release in place. |
| 37 | */ |
| 38 | rulesetName: string; |
| 39 | }; |
| 40 | |
| 41 | export type Release = Resource< |
| 42 | "GCP.FirebaseRules.Release", |
| 43 | ReleaseProps, |
| 44 | { |
| 45 | /** Full resource name `projects/{project}/releases/{releaseId}`. */ |
| 46 | name: string; |
| 47 | /** Release id (path after `/releases/`). */ |
| 48 | releaseId: string; |
| 49 | /** Project id. */ |
| 50 | project: string; |
| 51 | /** Full ruleset resource name this release refers to. */ |
| 52 | rulesetName: string; |
| 53 | /** RFC3339 creation timestamp. */ |
| 54 | createTime: string | undefined; |
| 55 | /** RFC3339 last-update timestamp. */ |
| 56 | updateTime: string | undefined; |
| 57 | }, |
| 58 | never, |
| 59 | Providers |
| 60 | >; |
| 61 | |
| 62 | /** |
| 63 | * A named Firebase Security Rules release pointing at a `Ruleset`. |
| 64 | * |
| 65 | * Releases have no labels field. Generated release ids are prefixed |
| 66 | * `alc-` so `list` / nuke can find them. `releaseId` is identity — |
| 67 | * changing it replaces the release. `rulesetName` updates in place. |
| 68 | * |
| 69 | * ### Creating a Release |
| 70 | * **Example:** Generated id |
| 71 | * ```typescript |
| 72 | * const ruleset = yield* GCP.FirebaseRules.Ruleset("Firestore", { |
| 73 | * source: { |
| 74 | * files: [ |
| 75 | * { |
| 76 | * name: "firestore.rules", |
| 77 | * content: "rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if false; } } }", |
| 78 | * }, |
| 79 | * ], |
| 80 | * }, |
| 81 | * }); |
| 82 | * const release = yield* GCP.FirebaseRules.Release("Live", { |
| 83 | * rulesetName: ruleset.name, |
| 84 | * }); |
| 85 | * ``` |
| 86 | * |
| 87 | * **Example:** Named release |
| 88 | * ```typescript |
| 89 | * const release = yield* GCP.FirebaseRules.Release("Live", { |
| 90 | * releaseId: "prod", |
| 91 | * rulesetName: ruleset.name, |
| 92 | * }); |
| 93 | * ``` |
| 94 | * |
| 95 | * ### Updating a Release |
| 96 | * **Example:** Point at a new ruleset |
| 97 | * ```typescript |
| 98 | * const release = yield* GCP.FirebaseRules.Release("Live", { |
| 99 | * rulesetName: nextRuleset.name, |
| 100 | * }); |
| 101 | * ``` |
| 102 | * |
| 103 | * @resource |
| 104 | * @category FirebaseRules |
| 105 | */ |
| 106 | export const Release = Resource<Release>("GCP.FirebaseRules.Release"); |
| 107 | |
| 108 | export class ReleaseNotResolved extends Data.TaggedError( |
| 109 | "GCP.FirebaseRules.ReleaseNotResolved", |
| 110 | )<{ |
| 111 | name: string; |
| 112 | }> {} |
| 113 | |
| 114 | const toAttrs = (release: firebaserules.Release, project: string) => { |
| 115 | const name = release.name ?? ""; |
| 116 | return { |
| 117 | name, |
| 118 | releaseId: releaseIdOf(name), |
| 119 | project, |
| 120 | rulesetName: release.rulesetName ?? "", |
| 121 | createTime: release.createTime, |
| 122 | updateTime: release.updateTime, |
| 123 | }; |
| 124 | }; |
| 125 | |
| 126 | export const ReleaseProvider = () => |
| 127 | Provider.succeed(Release, { |
| 128 | stables: ["name", "releaseId", "project", "createTime"], |
| 129 | |
| 130 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 131 | if (!isResolved(news)) return undefined; |
| 132 | const previous = olds?.releaseId ?? output?.releaseId; |
| 133 | if ( |
| 134 | news.releaseId !== undefined && |
| 135 | previous !== undefined && |
| 136 | news.releaseId !== previous |
| 137 | ) { |
| 138 | return { action: "replace" as const, deleteFirst: false }; |
| 139 | } |
| 140 | return undefined; |
| 141 | }), |
| 142 | |
| 143 | read: Effect.fn(function* ({ id, olds, output }) { |
| 144 | const env = yield* GcpEnvironment.current; |
| 145 | const releaseId = yield* toReleaseId( |
| 146 | id, |
| 147 | olds?.releaseId, |
| 148 | output?.releaseId, |
| 149 | ); |
| 150 | const name = output?.name ?? releaseResourceName(env.project, releaseId); |
| 151 | const existing = yield* getRelease(name); |
| 152 | if (existing === undefined) return undefined; |
| 153 | const attrs = toAttrs(existing, env.project); |
| 154 | if ( |
| 155 | isGeneratedReleaseId(attrs.releaseId) || |
| 156 | output?.name === attrs.name |
| 157 | ) { |
| 158 | return attrs; |
| 159 | } |
| 160 | return Unowned(attrs); |
| 161 | }), |
| 162 | |
| 163 | list: () => |
| 164 | Effect.gen(function* () { |
| 165 | const env = yield* GcpEnvironment.current; |
| 166 | const releases = yield* listReleases(env.project); |
| 167 | return releases |
| 168 | .filter((release) => |
| 169 | isGeneratedReleaseId(releaseIdOf(release.name ?? "")), |
| 170 | ) |
| 171 | .map((release) => toAttrs(release, env.project)); |
| 172 | }), |
| 173 | |
| 174 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 175 | const env = yield* GcpEnvironment.current; |
| 176 | const parent = projectParent(env.project); |
| 177 | const releaseId = yield* toReleaseId( |
| 178 | id, |
| 179 | news.releaseId, |
| 180 | output?.releaseId, |
| 181 | ); |
| 182 | const name = releaseResourceName(env.project, releaseId); |
| 183 | const desiredRuleset = expandRulesetName(env.project, news.rulesetName); |
| 184 | |
| 185 | let current = yield* getRelease(output?.name ?? name); |
| 186 | |
| 187 | if (current === undefined) { |
| 188 | const created = yield* firebaserules |
| 189 | .createProjectsReleases({ |
| 190 | name: parent, |
| 191 | body: { |
| 192 | name, |
| 193 | rulesetName: desiredRuleset, |
| 194 | }, |
| 195 | }) |
| 196 | .pipe(Effect.catchTag("Conflict", () => getRelease(name))); |
| 197 | current = created ?? undefined; |
| 198 | } |
| 199 | |
| 200 | if (current === undefined) { |
| 201 | return yield* new ReleaseNotResolved({ name }); |
| 202 | } |
| 203 | |
| 204 | if (!sameText(current.rulesetName, desiredRuleset)) { |
| 205 | current = yield* firebaserules.patchProjectsReleases({ |
| 206 | name: current.name ?? name, |
| 207 | body: { |
| 208 | release: { |
| 209 | name: current.name ?? name, |
| 210 | rulesetName: desiredRuleset, |
| 211 | }, |
| 212 | updateMask: "rulesetName", |
| 213 | }, |
| 214 | }); |
| 215 | } |
| 216 | |
| 217 | return toAttrs(current, env.project); |
| 218 | }), |
| 219 | |
| 220 | delete: Effect.fn(function* ({ output }) { |
| 221 | if (!output.name) return; |
| 222 | yield* firebaserules |
| 223 | .deleteProjectsReleases({ name: output.name }) |
| 224 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 225 | }), |
| 226 | }); |