1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts";
5import { isResolved } from "../Diff.ts";
6import * as Provider from "../Provider.ts";
7import { Resource } from "../Resource.ts";
8import { validateOrganizationVPCEndpoint } from "./OrganizationVPCEndpoint.ts";
9import type { Providers } from "./Providers.ts";
10
11export interface ProjectVPCEndpointProps {
12 /** Project to restrict. Changing the project replaces the association, cleaning up the old scope first. */
13 project: {
14 /** Neon project identity. */
15 projectId: string;
16 };
17 /** Already configured organization VPC endpoint in the project's organization and AWS region. */
18 endpoint: {
19 /** Existing Neon organization identity. */
20 orgId: string;
21 /** Neon AWS region identity. */
22 regionId: string;
23 /** Existing registered AWS VPC endpoint identity. */
24 vpcEndpointId: string;
25 };
26 /** Descriptive label for this project restriction. */
27 label: string;
28}
29
30export interface ProjectVPCEndpointAttributes {
31 /** Restricted project identity. */
32 projectId: string;
33 /** Organization containing the project and registered endpoint. */
34 orgId: string;
35 /** AWS Neon region shared by the project and registered endpoint. */
36 regionId: string;
37 /** Registered AWS VPC endpoint identity. */
38 vpcEndpointId: string;
39 /** Observed restriction label, or null when absent. */
40 label: string | null;
41 /** Original adopted restriction label; null means Alchemy created the restriction. */
42 initialLabel: string | null;
43 /** Last managed label, retained across refreshes for drift-safe cleanup. */
44 managedLabel: string;
45}
46
47export interface ProjectVPCEndpoint extends Resource<
48 "Neon.ProjectVPCEndpoint",
49 ProjectVPCEndpointProps,
50 ProjectVPCEndpointAttributes,
51 never,
52 Providers
53> {}
54
55/**
56 * Manage one project's restriction to an already registered organization VPC
57 * endpoint. Requires an organization admin entitled to AWS Private Networking.
58 * The project, endpoint, and client must share an AWS region and the endpoint
59 * must belong to the project's organization. Azure is unsupported.
60 * This does not change the AWS endpoint, its organization registration, or
61 * the project's separate block_public_connections setting. Other restrictions
62 * are left untouched. Removing the last restriction can broaden connectivity.
63 *
64 * Existing associations require explicit resource-scoped adoption. Their
65 * original label is durably captured before mutation and restored on destroy;
66 * an adopted restriction is never silently removed. New restrictions are
67 * removed on destroy only when their label still matches the managed value.
68 * Scope changes replace with old-scope cleanup first, avoiding stale managed
69 * grants. Preserve state and serialize writers; Neon has no conditional writes.
70 *
71 * ### Restrict a project
72 * **Example:** Reference an organization registration
73 * ```typescript
74 * const restriction = yield* Neon.ProjectVPCEndpoint("PrivateAccess", {
75 * project,
76 * endpoint: network,
77 * label: "Application access",
78 * });
79 * ```
80 *
81 * @resource
82 * @product Project
83 */
84export const ProjectVPCEndpoint = Resource<ProjectVPCEndpoint>(
85 "Neon.ProjectVPCEndpoint",
86);
87
88export class InvalidProjectVPCEndpoint extends Data.TaggedError(
89 "InvalidProjectVPCEndpoint",
90)<{
91 /** Invalid scope or unsafe mutation condition. */
92 message: string;
93}> {}
94
95/** @internal */
96export const validateProjectVPCEndpoint = Effect.fn(function* (
97 props: ProjectVPCEndpointProps,
98) {
99 if (!props.project.projectId.trim()) {
100 return yield* new InvalidProjectVPCEndpoint({
101 message: "A project ID is required",
102 });
103 }
104 yield* validateOrganizationVPCEndpoint({
105 ...props.endpoint,
106 label: props.label,
107 });
108});
109
110const scopeOf = (props: ProjectVPCEndpointProps) => ({
111 projectId: props.project.projectId,
112 orgId: props.endpoint.orgId,
113 regionId: props.endpoint.regionId,
114 vpcEndpointId: props.endpoint.vpcEndpointId,
115});
116
117const request = (scope: { projectId: string; vpcEndpointId: string }) => ({
118 project_id: scope.projectId,
119 vpc_endpoint_id: scope.vpcEndpointId,
120});
121
122const observe = (scope: { projectId: string; vpcEndpointId: string }) =>
123 Neon.listProjectVPCEndpoints({ project_id: scope.projectId }).pipe(
124 Effect.map((result) =>
125 result.endpoints.find(
126 (endpoint) => endpoint.vpc_endpoint_id === scope.vpcEndpointId,
127 ),
128 ),
129 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
130 );
131
132const validateCloudScope = Effect.fn(function* (scope: {
133 projectId: string;
134 orgId: string;
135 regionId: string;
136 vpcEndpointId: string;
137}) {
138 const { project } = yield* Neon.getProject({ project_id: scope.projectId });
139 if (project.org_id !== scope.orgId || project.region_id !== scope.regionId) {
140 return yield* new InvalidProjectVPCEndpoint({
141 message:
142 "Project and registered endpoint must share an organization and AWS region",
143 });
144 }
145 yield* Neon.getOrganizationVPCEndpointDetails({
146 org_id: scope.orgId,
147 region_id: scope.regionId,
148 vpc_endpoint_id: scope.vpcEndpointId,
149 });
150});
151
152export const ProjectVPCEndpointProvider = () =>
153 Provider.succeed(ProjectVPCEndpoint, {
154 stables: [
155 "projectId",
156 "orgId",
157 "regionId",
158 "vpcEndpointId",
159 "initialLabel",
160 ],
161 diff: Effect.fn(function* ({ olds, news, output }) {
162 const previous = output ?? scopeOf(olds);
163 if (
164 !("project" in news) ||
165 !("endpoint" in news) ||
166 !isResolved(news.project) ||
167 !isResolved(news.endpoint) ||
168 news.project.projectId !== previous.projectId ||
169 news.endpoint.orgId !== previous.orgId ||
170 news.endpoint.regionId !== previous.regionId ||
171 news.endpoint.vpcEndpointId !== previous.vpcEndpointId
172 )
173 return { action: "replace", deleteFirst: true } as const;
174 if (!isResolved<ProjectVPCEndpointProps>(news)) return;
175 yield* validateProjectVPCEndpoint(news);
176 const scope = scopeOf(news);
177 yield* validateCloudScope(scope);
178 if ((yield* observe(scope))?.label !== news.label) {
179 return { action: "update" } as const;
180 }
181 }),
182 read: Effect.fn(function* ({ olds, output }) {
183 if (
184 !output &&
185 (!olds?.project?.projectId ||
186 !olds.endpoint?.orgId ||
187 !olds.endpoint.regionId ||
188 !olds.endpoint.vpcEndpointId)
189 )
190 return;
191 const scope = output ?? scopeOf(olds);
192 const observed = yield* observe(scope);
193 if (output) return { ...output, label: observed?.label ?? null };
194 if (!observed) return;
195 yield* validateCloudScope(scope);
196 return Unowned({
197 ...scope,
198 label: observed.label,
199 initialLabel: observed.label,
200 managedLabel: observed.label,
201 });
202 }),
203 reconcile: Effect.fn(function* ({ news, output, olds }) {
204 yield* validateProjectVPCEndpoint(news);
205 const scope = scopeOf(news);
206 if (
207 output &&
208 (output.projectId !== scope.projectId ||
209 output.orgId !== scope.orgId ||
210 output.regionId !== scope.regionId ||
211 output.vpcEndpointId !== scope.vpcEndpointId)
212 )
213 return yield* new InvalidProjectVPCEndpoint({
214 message: "Restriction identity changed without replacement",
215 });
216 yield* validateCloudScope(scope);
217 const observed = yield* observe(scope);
218 if (observed && !output)
219 return yield* new OwnedBySomeoneElse({
220 message:
221 "Existing project VPC restriction requires scoped adoption; an equal endpoint ID is not ownership",
222 resourceType: "Neon.ProjectVPCEndpoint",
223 });
224 if (
225 output &&
226 !olds &&
227 observed?.label !== output.managedLabel &&
228 observed?.label !== news.label
229 ) {
230 return yield* new InvalidProjectVPCEndpoint({
231 message:
232 "Project restriction changed since its adoption baseline was captured",
233 });
234 }
235 if (observed?.label !== news.label) {
236 yield* Neon.assignProjectVPCEndpoint({
237 ...request(scope),
238 label: news.label,
239 }).pipe(
240 Effect.catchTag(
241 "Conflict",
242 Effect.fn(function* (error) {
243 const raced = yield* observe(scope);
244 if (!raced || raced.label !== news.label) return yield* error;
245 if (!output)
246 return yield* new OwnedBySomeoneElse({
247 message:
248 "A project restriction appeared during creation; explicit adoption is required",
249 resourceType: "Neon.ProjectVPCEndpoint",
250 });
251 }),
252 ),
253 );
254 }
255 const current = yield* observe(scope);
256 if (!current || current.label !== news.label)
257 return yield* new InvalidProjectVPCEndpoint({
258 message: "Project restriction changed during reconciliation",
259 });
260 return {
261 ...scope,
262 label: current.label,
263 initialLabel: output?.initialLabel ?? null,
264 managedLabel: news.label,
265 };
266 }),
267 delete: Effect.fn(function* ({ output, olds }) {
268 const observed = yield* observe(output);
269 if (!observed) return;
270 if (
271 output.initialLabel !== null &&
272 observed.label === output.initialLabel
273 )
274 return;
275 if (
276 observed.label !== output.managedLabel &&
277 observed.label !== olds.label
278 ) {
279 return yield* new InvalidProjectVPCEndpoint({
280 message:
281 "Refusing cleanup of an externally relabeled project restriction",
282 });
283 }
284 // A transferred project must not be mutated under its previous organization.
285 const exists = yield* validateCloudScope(output).pipe(
286 Effect.as(true),
287 Effect.catchTag(
288 "NotFound",
289 Effect.fn(function* (error) {
290 if (yield* observe(output)) return yield* error;
291 return false;
292 }),
293 ),
294 );
295 if (!exists) return;
296 if (output.initialLabel !== null) {
297 yield* Neon.assignProjectVPCEndpoint({
298 ...request(output),
299 label: output.initialLabel,
300 }).pipe(
301 Effect.catchTag(
302 "NotFound",
303 Effect.fn(function* (error) {
304 if (yield* observe(output)) return yield* error;
305 }),
306 ),
307 );
308 } else {
309 yield* Neon.deleteProjectVPCEndpoint(request(output)).pipe(
310 Effect.catchTag("NotFound", () => Effect.void),
311 );
312 }
313 }),
314 });