diff --git a/.changeset/private-networking-dequeue.md b/.changeset/private-networking-dequeue.md new file mode 100644 index 00000000000..4a5bdba6a67 --- /dev/null +++ b/.changeset/private-networking-dequeue.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add optional `hasPrivateLink` field to the dequeue message organization object for private networking support diff --git a/.server-changes/private-networking.md b/.server-changes/private-networking.md new file mode 100644 index 00000000000..b9e0006af0f --- /dev/null +++ b/.server-changes/private-networking.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add private networking support via AWS PrivateLink. Includes BillingClient methods for managing private connections, org settings UI pages for connection management, and supervisor changes to apply `privatelink` pod labels for CiliumNetworkPolicy matching. diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index bcd68318246..1de788b1e46 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -268,6 +268,7 @@ class ManagedSupervisor { snapshotFriendlyId: message.snapshot.friendlyId, placementTags: message.placementTags, annotations: message.run.annotations, + hasPrivateLink: message.organization.hasPrivateLink, }); // Disabled for now diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 0aa5b170126..b2e737383ab 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -340,7 +340,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { } #getSharedLabels(opts: WorkloadManagerCreateOptions): Record { - return { + const labels: Record = { env: opts.envId, envtype: this.#envTypeToLabelValue(opts.envType), org: opts.orgId, @@ -352,6 +352,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { // and pool-level scheduling decisions; finer-grained source breakdowns live in run annotations. scheduled: String(this.#isScheduledRun(opts)), }; + + // Add privatelink label for CiliumNetworkPolicy matching + if (opts.hasPrivateLink) { + labels.privatelink = opts.orgId; + } + + return labels; } #getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities { diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index fca27b249a2..0ab47f0fffa 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -36,4 +36,6 @@ export interface WorkloadManagerCreateOptions { snapshotId: string; snapshotFriendlyId: string; annotations?: RunAnnotations; + // private networking + hasPrivateLink?: boolean; } diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index e0e414b189e..7f67236a587 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -3,6 +3,7 @@ import { ChartBarIcon, Cog8ToothIcon, CreditCardIcon, + LockClosedIcon, UserGroupIcon, } from "@heroicons/react/20/solid"; import { ArrowLeftIcon } from "@heroicons/react/24/solid"; @@ -19,6 +20,7 @@ import { rootPath, v3BillingAlertsPath, v3BillingPath, + v3PrivateConnectionsPath, v3UsagePath, } from "~/utils/pathBuilder"; import { LinkButton } from "../primitives/Buttons"; @@ -46,7 +48,7 @@ export function OrganizationSettingsSideMenu({ organization: MatchedOrganization; buildInfo: BuildInfo; }) { - const { isManagedCloud } = useFeatures(); + const { isManagedCloud, hasPrivateConnections } = useFeatures(); const currentPlan = useCurrentPlan(); const isAdmin = useHasAdminAccess(); const showBuildInfo = isAdmin || !isManagedCloud; @@ -103,6 +105,15 @@ export function OrganizationSettingsSideMenu({ /> )} + {hasPrivateConnections && ( + + )} ("root"); - return routeMatch?.features ?? { isManagedCloud: false }; + return routeMatch?.features ?? { isManagedCloud: false, hasPrivateConnections: false }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx new file mode 100644 index 00000000000..12aed5641ec --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx @@ -0,0 +1,307 @@ +import { LinkButton } from "~/components/primitives/Buttons"; +import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { prisma } from "~/db.server"; +import { featuresForRequest } from "~/features.server"; +import { logger } from "~/services/logger.server"; +import { getPrivateLinks } from "~/services/platform.v3.server"; +import { requireUserId } from "~/services/session.server"; +import { + OrganizationParamsSchema, + organizationPath, + v3PrivateConnectionsPath, +} from "~/utils/pathBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import type { PrivateLinkConnectionStatus } from "@trigger.dev/platform"; +import { Button } from "~/components/primitives/Buttons"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { deletePrivateLink } from "~/services/platform.v3.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { + ClipboardDocumentIcon, + PlusIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; +import { useMemo, useState } from "react"; +import { useInterval } from "~/hooks/useInterval"; + +export const meta: MetaFunction = () => { + return [{ title: `Private Connections | Trigger.dev` }]; +}; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const { hasPrivateConnections } = featuresForRequest(request); + if (!hasPrivateConnections) { + return redirect(organizationPath({ slug: organizationSlug })); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } + + const [error, connections] = await tryCatch(getPrivateLinks(organization.id)); + if (error) { + logger.error("Error loading private link connections", { error, organizationId: organization.id }); + } + + return typedjson({ + connections: connections?.connections ?? [], + organizationId: organization.id, + }); +} + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + if (request.method !== "DELETE" && request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const formData = await request.formData(); + const connectionId = formData.get("connectionId"); + const intent = formData.get("intent"); + + if (intent !== "delete" || typeof connectionId !== "string") { + return json({ error: "Invalid request" }, { status: 400 }); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + "Organization not found" + ); + } + + const [error] = await tryCatch(deletePrivateLink(organization.id, connectionId)); + if (error) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + `Failed to delete connection: ${error.message}` + ); + } + + return redirectWithSuccessMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + "Connection deletion initiated" + ); +}; + +const STATUS_COLORS: Record = { + PENDING: "bg-amber-500/20 text-amber-400", + PROVISIONING: "bg-blue-500/20 text-blue-400", + ACTIVE: "bg-emerald-500/20 text-emerald-400", + ERROR: "bg-rose-500/20 text-rose-400", + DELETING: "bg-charcoal-500/20 text-charcoal-400", +}; + +function StatusBadge({ status }: { status: PrivateLinkConnectionStatus }) { + return ( + + {status} + + ); +} + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +const TERMINAL_STATUSES: PrivateLinkConnectionStatus[] = ["ACTIVE", "ERROR"]; + +export default function Page() { + const { connections } = useTypedLoaderData(); + const plan = useCurrentPlan(); + const revalidator = useRevalidator(); + + const hasInProgressConnections = useMemo( + () => connections.some((c) => !TERMINAL_STATUSES.includes(c.status)), + [connections] + ); + + useInterval({ + interval: 3_000, + onLoad: false, + callback: () => { + if (revalidator.state === "idle") { + revalidator.revalidate(); + } + }, + disabled: !hasInProgressConnections, + }); + + const hasPrivateNetworking = true; + const limit = plan?.v3Subscription?.plan?.limits?.privateLinkConnectionLimit ?? 2; + const canAdd = connections.filter((c) => c.status !== "DELETING").length < limit; + + return ( + + + + + {hasPrivateNetworking && canAdd && ( + + Add Connection + + )} + + + + +
+
+ Private Connections + + Connect your AWS resources (databases, caches, APIs) to your Trigger.dev tasks via + AWS PrivateLink. Connections are organization-wide and work across all projects and + environments. + +
+ + {!hasPrivateNetworking ? ( +
+ + Private Connections require upgrading to Pro or an Enterprise plan. + +
+ ) : connections.length === 0 ? ( +
+ + No private connections yet. Add your first connection to securely reach your AWS + resources from task pods. + + + Add Connection + +
+ ) : ( +
+ {connections.map((connection) => ( +
+
+
+ + {connection.name} + + + {connection.status !== "DELETING" && ( +
+ + + +
+ )} +
+
+
+ Service: + + {connection.endpointServiceName} + + +
+
+ Region: + {connection.targetRegion} +
+ {connection.endpointDnsName && ( +
+ DNS: + + {connection.endpointDnsName} + + +
+ )} + {connection.statusMessage && ( +
+ Error: + {connection.statusMessage} +
+ )} +
+ Created: + + {new Date(connection.createdAt).toLocaleDateString()} + +
+
+
+
+ ))} + + {!canAdd && ( + + Connection limit reached ({limit}). Delete an existing connection to add a new + one. + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx new file mode 100644 index 00000000000..730ab2290e2 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx @@ -0,0 +1,769 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData, useParams, type MetaFunction } from "@remix-run/react"; +import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { useState } from "react"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { featuresForRequest } from "~/features.server"; +import { + redirectWithErrorMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import type { CreatePrivateLinkConnectionBody } from "@trigger.dev/platform"; +import { + createPrivateLink, + getPrivateLinkRegions, +} from "~/services/platform.v3.server"; +import { requireUserId } from "~/services/session.server"; +import { + OrganizationParamsSchema, + organizationPath, + v3PrivateConnectionsPath, +} from "~/utils/pathBuilder"; +import { + CommandLineIcon, + DocumentTextIcon, + PencilSquareIcon, + SparklesIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; + +export const meta: MetaFunction = () => { + return [{ title: `Add Private Connection | Trigger.dev` }]; +}; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const { hasPrivateConnections } = featuresForRequest(request); + if (!hasPrivateConnections) { + return redirect(organizationPath({ slug: organizationSlug })); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } + + const [error, regions] = await tryCatch(getPrivateLinkRegions(organization.id)); + + const awsAccountIds = env.PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS?.split(",").filter(Boolean) ?? []; + + return typedjson({ + availableRegions: regions?.availableRegions ?? ["us-east-1", "eu-central-1"], + activeRegions: regions?.activeRegions ?? [], + awsAccountIds, + }); +} + +const schema = z.object({ + name: z.string().min(1, "Name is required").max(100, "Name must be 100 characters or less"), + endpointServiceName: z + .string() + .min(1, "VPC Endpoint Service name is required") + .regex( + /^com\.amazonaws\.vpce\..+\.vpce-svc-.+$/, + "Must be a valid VPC Endpoint Service name (com.amazonaws.vpce..vpce-svc-*)" + ), + targetRegion: z.string().min(1, "Region is required"), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + "Organization not found" + ); + } + + // Fetch available regions dynamically (same call the loader makes) + const [, fetchedRegions] = await tryCatch(getPrivateLinkRegions(organization.id)); + const availableRegions = fetchedRegions?.availableRegions ?? ["us-east-1", "eu-central-1"]; + + const { targetRegion: selectedRegion, ...rest } = submission.value; + + if (!availableRegions.includes(selectedRegion)) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + `Invalid region: ${selectedRegion}` + ); + } + + const [error] = await tryCatch( + createPrivateLink(organization.id, { + ...rest, + targetRegion: selectedRegion as CreatePrivateLinkConnectionBody["targetRegion"], + }) + ); + + if (error) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + error.message + ); + } + + const message = "Connection created! Provisioning will begin shortly."; + + return redirectWithSuccessMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + message + ); +}; + + +type SetupMethod = "manual" | "ai" | "terraform" | "docs"; + +type PortEntry = { port: string; protocol: "TCP" | "UDP" }; + +const AWS_REGIONS = [ + { value: "us-east-1", label: "US East (N. Virginia)" }, + { value: "us-east-2", label: "US East (Ohio)" }, + { value: "us-west-1", label: "US West (N. California)" }, + { value: "us-west-2", label: "US West (Oregon)" }, + { value: "af-south-1", label: "Africa (Cape Town)" }, + { value: "ap-east-1", label: "Asia Pacific (Hong Kong)" }, + { value: "ap-south-1", label: "Asia Pacific (Mumbai)" }, + { value: "ap-south-2", label: "Asia Pacific (Hyderabad)" }, + { value: "ap-southeast-1", label: "Asia Pacific (Singapore)" }, + { value: "ap-southeast-2", label: "Asia Pacific (Sydney)" }, + { value: "ap-southeast-3", label: "Asia Pacific (Jakarta)" }, + { value: "ap-southeast-4", label: "Asia Pacific (Melbourne)" }, + { value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" }, + { value: "ap-northeast-2", label: "Asia Pacific (Seoul)" }, + { value: "ap-northeast-3", label: "Asia Pacific (Osaka)" }, + { value: "ca-central-1", label: "Canada (Central)" }, + { value: "ca-west-1", label: "Canada West (Calgary)" }, + { value: "eu-central-1", label: "Europe (Frankfurt)" }, + { value: "eu-central-2", label: "Europe (Zurich)" }, + { value: "eu-west-1", label: "Europe (Ireland)" }, + { value: "eu-west-2", label: "Europe (London)" }, + { value: "eu-west-3", label: "Europe (Paris)" }, + { value: "eu-south-1", label: "Europe (Milan)" }, + { value: "eu-south-2", label: "Europe (Spain)" }, + { value: "eu-north-1", label: "Europe (Stockholm)" }, + { value: "il-central-1", label: "Israel (Tel Aviv)" }, + { value: "me-south-1", label: "Middle East (Bahrain)" }, + { value: "me-central-1", label: "Middle East (UAE)" }, + { value: "sa-east-1", label: "South America (São Paulo)" }, +]; + +function TerraformWizard({ awsAccountIds }: { awsAccountIds: string[] }) { + const [hostname, setHostname] = useState(""); + const [ports, setPorts] = useState([{ port: "5432", protocol: "TCP" }]); + const [region, setRegion] = useState("us-east-1"); + + const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]); + const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index)); + const updatePort = (index: number, field: keyof PortEntry, value: string) => + setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + + const validPorts = ports.filter((p) => p.port !== ""); + + const terraformScript = `# Trigger.dev Private Networking - Terraform Configuration +# Creates an NLB and VPC Endpoint Service for your resource + +variable "vpc_id" { + description = "Your VPC ID" + type = string +} + +variable "subnet_ids" { + description = "Private subnet IDs in your VPC" + type = list(string) +} + +variable "target_ip" { + description = "IP address of the target resource" + type = string${hostname ? `\n default = "${hostname}"` : ""} +} + +# Network Load Balancer +resource "aws_lb" "trigger_privatelink" { + name = "trigger-privatelink" + internal = true + load_balancer_type = "network" + subnets = var.subnet_ids +} +${validPorts + .map( + (p, i) => ` +resource "aws_lb_target_group" "port_${p.port}" { + name = "trigger-pl-${p.port}" + port = ${p.port} + protocol = "${p.protocol}" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + protocol = "TCP" + port = ${p.port} + } +} + +resource "aws_lb_target_group_attachment" "port_${p.port}" { + target_group_arn = aws_lb_target_group.port_${p.port}.arn + target_id = var.target_ip + port = ${p.port} +} + +resource "aws_lb_listener" "port_${p.port}" { + load_balancer_arn = aws_lb.trigger_privatelink.arn + port = ${p.port} + protocol = "${p.protocol}" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.port_${p.port}.arn + } +}` + ) + .join("\n")} + +# VPC Endpoint Service +resource "aws_vpc_endpoint_service" "trigger_privatelink" { + acceptance_required = false + network_load_balancer_arns = [aws_lb.trigger_privatelink.arn] + supported_regions = ["us-east-1", "eu-central-1"] + + allowed_principals = [ +${awsAccountIds.map((id) => ` "arn:aws:iam::${id}:root",`).join("\n")} + ] +} + +output "endpoint_service_name" { + description = "Paste this into the Trigger.dev dashboard" + value = aws_vpc_endpoint_service.trigger_privatelink.service_name +} +`; + + return ( +
+ + + setHostname(e.target.value)} + placeholder="my-database.abc123.us-east-1.rds.amazonaws.com" + fullWidth + /> + + +
+ +
+ {ports.map((entry, index) => ( +
+ updatePort(index, "port", e.target.value)} + placeholder="Port" + className="w-24" + /> + + {ports.length > 1 && ( + + )} +
+ ))} + +
+
+ + + + + + +
+
+ main.tf + +
+
+          {terraformScript}
+        
+
+
+ ); +} + +function AIPromptWizard({ awsAccountIds }: { awsAccountIds: string[] }) { + const [hostname, setHostname] = useState(""); + const [ports, setPorts] = useState([{ port: "5432", protocol: "TCP" }]); + const [region, setRegion] = useState("us-east-1"); + + const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]); + const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index)); + const updatePort = (index: number, field: keyof PortEntry, value: string) => + setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + + const validPorts = ports.filter((p) => p.port !== ""); + const regionLabel = AWS_REGIONS.find((r) => r.value === region)?.label ?? region; + + const portsDescription = validPorts.length > 0 + ? validPorts.map((p) => `${p.port} (${p.protocol})`).join(", ") + : "5432 (TCP)"; + + const prompt = `I need to set up AWS PrivateLink so that Trigger.dev can connect to my resource. Please create the following in my AWS account in the ${region} (${regionLabel}) region: + +1. A Network Load Balancer (NLB): + - Name: trigger-privatelink + - Internal: yes + - Type: network + - Place it in my private subnets + +2. For each of the following ports, create a target group, target group attachment, and listener: +${validPorts.length > 0 ? validPorts.map((p) => ` - Port ${p.port} (${p.protocol})`).join("\n") : " - Port 5432 (TCP)"} + + Each target group should: + - Target type: ip + - Target IP: ${hostname || ""} + - Have a TCP health check on the same port + +3. A VPC Endpoint Service: + - Acceptance required: no + - Attach the NLB created above + - Supported regions: us-east-1, eu-central-1 + - Allowed principals: +${awsAccountIds.map((id) => ` - arn:aws:iam::${id}:root`).join("\n") || " - "} + +After creating everything, give me the VPC Endpoint Service name (it looks like com.amazonaws.vpce..vpce-svc-*) so I can paste it into the Trigger.dev dashboard.`; + + return ( +
+ + + setHostname(e.target.value)} + placeholder="my-database.abc123.us-east-1.rds.amazonaws.com" + fullWidth + /> + + +
+ +
+ {ports.map((entry, index) => ( +
+ updatePort(index, "port", e.target.value)} + placeholder="Port" + className="w-24" + /> + + {ports.length > 1 && ( + + )} +
+ ))} + +
+
+ + + + + + +
+
+ AI Prompt + +
+
+          {prompt}
+        
+
+
+ ); +} + +export default function Page() { + const { availableRegions, activeRegions, awsAccountIds } = useTypedLoaderData(); + const { organizationSlug } = useParams(); + const lastSubmission = useActionData(); + const [setupMethod, setSetupMethod] = useState(null); + + const defaultRegion = "us-east-1"; + + const [form, { name, endpointServiceName, targetRegion }] = useForm({ + id: "create-private-connection", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + }); + + return ( + + + + + + +
+
+ Add Private Connection + + Connect your AWS resources to Trigger.dev task pods via AWS PrivateLink. You'll need + to create a VPC Endpoint Service on your AWS account first. + +
+ + {/* Setup method cards */} +
+ + + + +
+ + {/* AI prompt wizard */} + {setupMethod === "ai" && ( +
+ AI-Assisted Setup + + Fill in your resource details below and we'll generate a prompt you can paste into + Claude, ChatGPT, or any AI assistant with AWS access. After it creates the + resources, paste the VPC Endpoint Service name below. + + +
+ )} + + {/* Terraform wizard (expandable) */} + {setupMethod === "terraform" && ( +
+ Terraform Configuration + + Fill in your resource details below and we'll generate a Terraform script. Run{" "} + + terraform apply + {" "} + to create the VPC Endpoint Service, then paste the output service name below. + + +
+ )} + + {/* Docs iframe */} + {setupMethod === "docs" && ( +
+ Setup Guide + {awsAccountIds.length > 0 && ( + <> + + When adding allowed principals to your VPC Endpoint Service, use the following + AWS account ID(s): + +
+ {awsAccountIds.map((id) => ( + + {id} + + ))} +
+ + )} +