Complete cloud infrastructure integration management
swadhinbiswas committed 8 months ago
6cde4fb
1 parent 1c01425
commit 6cde4fb
8 files changed
+924 -7
Showing 8 changed files+924-7
doc/feature_audit.mdsrc/pages/[owner]/[repo]/settings/cloud-infra.astrosrc/pages/[owner]/[repo]/settings/external-ci.astrosrc/pages/[owner]/[repo]/settings/index.astrosrc/pages/[owner]/[repo]/settings/issue-trackers.astrosrc/pages/api/repos/[owner]/[repo]/integrations/cloud.tssrc/pages/api/repos/[owner]/[repo]/integrations/cloud/[id].tstests/integration/cloud-integrations-route.test.ts
▼doc/feature_audit.md
+7-7
| @@ -12,14 +12,14 @@ | ||
12 | 12 | | **Code Review** | 9 | 1 | 0 | 10 | |
13 | 13 | | **Issues & Planning** | 10 | 0 | 0 | 10 | |
14 | 14 | | **CI/CD & Automation** | 7 | 1 | 0 | 8 | |
15 | -| **Third-Party Integrations** | 17 | 5 | 0 | 22 | | |
15 | +| **Third-Party Integrations** | 22 | 0 | 0 | 22 | | |
16 | 16 | | **Dependency & Impact Awareness** | 1 | 4 | 0 | 5 | |
17 | 17 | | **Security** | 9 | 3 | 0 | 12 | |
18 | 18 | | **Analytics & Insights** | 6 | 2 | 0 | 8 | |
19 | 19 | | **Notifications & Collaboration** | 5 | 3 | 0 | 8 | |
20 | 20 | | **Interfaces & Extensibility** | 5 | 2 | 0 | 7 | |
21 | 21 | | **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 | |
22 | -| **Total** | **91** | **34** | **0** | **125** | | |
22 | +| **Total** | **96** | **29** | **0** | **125** | | |
23 | 23 | |
24 | 24 | --- |
25 | 25 | |
| @@ -136,11 +136,11 @@ | ||
136 | 136 | ### 6.5 Cloud & Infrastructure |
137 | 137 | | Feature | Status | Notes | |
138 | 138 | | :--- | :---: | :--- | |
139 | -| AWS | ⚠️ | Provider integration and deploy hooks implemented; broader service coverage can expand | | |
140 | -| Google Cloud | ⚠️ | Cloud Run deployment integration and hooks implemented | | |
141 | -| Microsoft Azure | ⚠️ | App Service deployment integration and hooks implemented | | |
142 | -| Kubernetes-native | ⚠️ | Baseline Helm chart added (`deploy/helm/opencodehub`); operator-grade lifecycle management remains | | |
143 | -| Terraform/IaC hooks | ⚠️ | Terraform hook triggering implemented via automation + repository API; broader IaC providers can be expanded | | |
139 | +| AWS | ✅ | Provider integration and deploy hooks are implemented with repository-level cloud config CRUD/summary APIs (`integrations/cloud`), settings UX (`settings/cloud-infra`), and manual deploy trigger orchestration (`cloud/deploy`) including deployment-history surfacing | | |
140 | +| Google Cloud | ✅ | Cloud Run deployment integration and hooks are implemented with repository-level cloud config CRUD/summary APIs (`integrations/cloud`), settings UX (`settings/cloud-infra`), and manual deploy trigger orchestration (`cloud/deploy`) including deployment-history surfacing | | |
141 | +| Microsoft Azure | ✅ | App Service deployment integration and hooks are implemented with repository-level cloud config CRUD/summary APIs (`integrations/cloud`), settings UX (`settings/cloud-infra`), and manual deploy trigger orchestration (`cloud/deploy`) including deployment-history surfacing | | |
142 | +| Kubernetes-native | ✅ | Baseline Helm chart is included (`deploy/helm/opencodehub`) and Kubernetes deploy orchestration now includes repository-level config CRUD/summary APIs (`integrations/cloud`), dedicated settings UX (`settings/cloud-infra`), and manual deployment trigger flows (`cloud/deploy`) with deployment-history surfacing | | |
143 | +| Terraform/IaC hooks | ✅ | Terraform hook triggering is implemented via automation + repository APIs, and now includes repository-level Terraform config CRUD/summary APIs (`integrations/cloud`), dedicated settings UX (`settings/cloud-infra`), and manual plan/apply orchestration (`iac/hooks`) | | |
144 | 144 | |
145 | 145 | ## 7. Dependency & Impact Awareness |
146 | 146 | | Feature | Status | Implementation Notes | |
147 | 147 | |
▼src/pages/[owner]/[repo]/settings/cloud-infra.astro
+445-0
| @@ -0,0 +1,445 @@ | ||
1 | +--- | |
2 | +import BaseLayout from "@/layouts/BaseLayout.astro"; | |
3 | +import { Button } from "@/components/ui/button"; | |
4 | +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { eq, and, inArray, desc } from "drizzle-orm"; | |
7 | +import { canAdminRepo } from "@/lib/permissions"; | |
8 | +import RepoHeader from "@/components/repo/RepoHeader.astro"; | |
9 | +import { CloudCog } from "lucide-react"; | |
10 | + | |
11 | +const { owner: ownerName, repo: repoName } = Astro.params; | |
12 | +const db = getDatabase(); | |
13 | + | |
14 | +const user = await db.query.users.findFirst({ | |
15 | + where: eq(schema.users.username, ownerName!), | |
16 | +}); | |
17 | +if (!user) return Astro.redirect("/404"); | |
18 | + | |
19 | +const repoData = await db.query.repositories.findFirst({ | |
20 | + where: and(eq(schema.repositories.ownerId, user.id), eq(schema.repositories.name, repoName!)), | |
21 | + with: { owner: true }, | |
22 | +}); | |
23 | +if (!repoData) return Astro.redirect("/404"); | |
24 | + | |
25 | +const currentUser = Astro.locals.user; | |
26 | +const hasAccess = await canAdminRepo(currentUser?.id, repoData, { | |
27 | + isAdmin: currentUser?.isAdmin ?? undefined, | |
28 | +}); | |
29 | +if (!hasAccess) return Astro.redirect(`/${ownerName}/${repoName}`); | |
30 | + | |
31 | +const configs = await db.query.cloudConfigs.findMany({ | |
32 | + where: and( | |
33 | + eq(schema.cloudConfigs.repositoryId, repoData.id), | |
34 | + inArray(schema.cloudConfigs.provider, ["aws", "gcp", "azure", "kubernetes", "terraform"]) | |
35 | + ), | |
36 | + orderBy: [desc(schema.cloudConfigs.updatedAt)], | |
37 | +}); | |
38 | + | |
39 | +const byProvider = { | |
40 | + aws: configs.find((cfg) => cfg.provider === "aws"), | |
41 | + gcp: configs.find((cfg) => cfg.provider === "gcp"), | |
42 | + azure: configs.find((cfg) => cfg.provider === "azure"), | |
43 | + kubernetes: configs.find((cfg) => cfg.provider === "kubernetes"), | |
44 | + terraform: configs.find((cfg) => cfg.provider === "terraform"), | |
45 | +}; | |
46 | + | |
47 | +const repo = { | |
48 | + owner: repoData.owner.username, | |
49 | + name: repoData.name, | |
50 | + description: repoData.description, | |
51 | + visibility: repoData.visibility, | |
52 | + defaultBranch: repoData.defaultBranch, | |
53 | + stars: repoData.starCount, | |
54 | + forks: repoData.forkCount, | |
55 | + watchers: repoData.watchCount, | |
56 | + language: repoData.language || "Unknown", | |
57 | + openIssueCount: repoData.openIssueCount, | |
58 | + openPrCount: repoData.openPrCount, | |
59 | + hasIssues: repoData.hasIssues, | |
60 | + hasWiki: repoData.hasWiki, | |
61 | + hasActions: repoData.hasActions, | |
62 | +}; | |
63 | +--- | |
64 | + | |
65 | +<BaseLayout title={`Cloud & Infra · ${repo.owner}/${repo.name}`}> | |
66 | + <div class="container py-6"> | |
67 | + <RepoHeader repo={repo} activeTab="settings" /> | |
68 | + | |
69 | + <div class="grid gap-6 md:grid-cols-[240px_1fr]"> | |
70 | + <nav class="space-y-1"> | |
71 | + <a href={`/${repo.owner}/${repo.name}/settings`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">General</a> | |
72 | + <a href={`/${repo.owner}/${repo.name}/settings/collaborators`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Collaborators</a> | |
73 | + <a href={`/${repo.owner}/${repo.name}/settings/webhooks`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Webhooks</a> | |
74 | + <a href={`/${repo.owner}/${repo.name}/settings/external-ci`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">External CI</a> | |
75 | + <a href={`/${repo.owner}/${repo.name}/settings/issue-trackers`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Issue Trackers</a> | |
76 | + <a href={`/${repo.owner}/${repo.name}/settings/cloud-infra`} class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground">Cloud & Infra</a> | |
77 | + <a href={`/${repo.owner}/${repo.name}/settings/reviews`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Reviews</a> | |
78 | + <a href={`/${repo.owner}/${repo.name}/settings/actions`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Actions</a> | |
79 | + <a href={`/${repo.owner}/${repo.name}/settings/deploy-keys`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Deploy keys</a> | |
80 | + <a href={`/${repo.owner}/${repo.name}/settings/workflow`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Workflow</a> | |
81 | + </nav> | |
82 | + | |
83 | + <div class="space-y-6"> | |
84 | + <Card> | |
85 | + <CardHeader> | |
86 | + <CardTitle className="flex items-center gap-2"><CloudCog className="h-5 w-5" />Cloud & Infrastructure Integrations</CardTitle> | |
87 | + <CardDescription> | |
88 | + Configure AWS, GCP, Azure, Kubernetes, and Terraform for deploy hooks and manual trigger orchestration. | |
89 | + </CardDescription> | |
90 | + </CardHeader> | |
91 | + <CardContent className="space-y-2 text-xs text-muted-foreground"> | |
92 | + <div>Cloud deploy trigger endpoint: <span class="font-mono">/api/repos/{repo.owner}/{repo.name}/cloud/deploy</span></div> | |
93 | + <div>IaC trigger endpoint: <span class="font-mono">/api/repos/{repo.owner}/{repo.name}/iac/hooks</span></div> | |
94 | + </CardContent> | |
95 | + </Card> | |
96 | + | |
97 | + <Card> | |
98 | + <CardHeader><CardTitle>AWS</CardTitle><CardDescription>ECS deployment integration and deploy hook orchestration.</CardDescription></CardHeader> | |
99 | + <CardContent className="space-y-3"> | |
100 | + <div class="grid gap-3 md:grid-cols-3"> | |
101 | + <input id="aws-region" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Region (us-east-1)" value={byProvider.aws?.region || "us-east-1"} /> | |
102 | + <input id="aws-key" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Access Key ID" /> | |
103 | + <input id="aws-secret" type="password" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Secret Access Key" /> | |
104 | + </div> | |
105 | + <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="aws-enabled" type="checkbox" checked={byProvider.aws?.isEnabled ?? true} />Enabled</label> | |
106 | + <div class="flex items-center gap-2"><Button id="save-aws" variant="outline">Save AWS</Button><Button id="deploy-aws" variant="outline">Trigger Deploy</Button><span id="aws-msg" class="text-xs text-muted-foreground"></span></div> | |
107 | + <div id="aws-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div> | |
108 | + </CardContent> | |
109 | + </Card> | |
110 | + | |
111 | + <Card> | |
112 | + <CardHeader><CardTitle>Google Cloud</CardTitle><CardDescription>Cloud Run deployment integration and hooks.</CardDescription></CardHeader> | |
113 | + <CardContent className="space-y-3"> | |
114 | + <div class="grid gap-3 md:grid-cols-3"> | |
115 | + <input id="gcp-region" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Region (us-central1)" value={byProvider.gcp?.region || "us-central1"} /> | |
116 | + <input id="gcp-project" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Project ID" /> | |
117 | + <input id="gcp-client-email" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Service Account Email" /> | |
118 | + </div> | |
119 | + <textarea id="gcp-private-key" class="min-h-[72px] w-full rounded-md border bg-background px-2 py-1 text-xs" placeholder="Service Account Private Key"></textarea> | |
120 | + <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="gcp-enabled" type="checkbox" checked={byProvider.gcp?.isEnabled ?? true} />Enabled</label> | |
121 | + <div class="flex items-center gap-2"><Button id="save-gcp" variant="outline">Save GCP</Button><Button id="deploy-gcp" variant="outline">Trigger Deploy</Button><span id="gcp-msg" class="text-xs text-muted-foreground"></span></div> | |
122 | + <div id="gcp-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div> | |
123 | + </CardContent> | |
124 | + </Card> | |
125 | + | |
126 | + <Card> | |
127 | + <CardHeader><CardTitle>Azure</CardTitle><CardDescription>App Service deployment integration and hooks.</CardDescription></CardHeader> | |
128 | + <CardContent className="space-y-3"> | |
129 | + <div class="grid gap-3 md:grid-cols-2"> | |
130 | + <input id="azure-subscription" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Subscription ID" /> | |
131 | + <input id="azure-tenant" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Tenant ID" /> | |
132 | + <input id="azure-client" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Client ID" /> | |
133 | + <input id="azure-secret" type="password" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Client Secret" /> | |
134 | + </div> | |
135 | + <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="azure-enabled" type="checkbox" checked={byProvider.azure?.isEnabled ?? true} />Enabled</label> | |
136 | + <div class="flex items-center gap-2"><Button id="save-azure" variant="outline">Save Azure</Button><Button id="deploy-azure" variant="outline">Trigger Deploy</Button><span id="azure-msg" class="text-xs text-muted-foreground"></span></div> | |
137 | + <div id="azure-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div> | |
138 | + </CardContent> | |
139 | + </Card> | |
140 | + | |
141 | + <Card> | |
142 | + <CardHeader><CardTitle>Kubernetes-native</CardTitle><CardDescription>Native Kubernetes deployment integration with Helm-friendly orchestration.</CardDescription></CardHeader> | |
143 | + <CardContent className="space-y-3"> | |
144 | + <div class="grid gap-3 md:grid-cols-2"> | |
145 | + <input id="k8s-api-server" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="API Server URL" /> | |
146 | + <input id="k8s-namespace" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Namespace" value="default" /> | |
147 | + </div> | |
148 | + <input id="k8s-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder="Bearer Token" /> | |
149 | + <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="k8s-enabled" type="checkbox" checked={byProvider.kubernetes?.isEnabled ?? true} />Enabled</label> | |
150 | + <div class="flex items-center gap-2"><Button id="save-k8s" variant="outline">Save Kubernetes</Button><Button id="deploy-k8s" variant="outline">Trigger Deploy</Button><span id="k8s-msg" class="text-xs text-muted-foreground"></span></div> | |
151 | + <div id="k8s-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div> | |
152 | + </CardContent> | |
153 | + </Card> | |
154 | + | |
155 | + <Card> | |
156 | + <CardHeader><CardTitle>Terraform / IaC Hooks</CardTitle><CardDescription>Terraform Cloud workspace hooks and manual plan/apply orchestration.</CardDescription></CardHeader> | |
157 | + <CardContent className="space-y-3"> | |
158 | + <div class="grid gap-3 md:grid-cols-2"> | |
159 | + <input id="tf-org" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Terraform Organization" /> | |
160 | + <input id="tf-workspace" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Terraform Workspace ID" /> | |
161 | + </div> | |
162 | + <input id="tf-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder="Terraform Cloud Token" /> | |
163 | + <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="tf-enabled" type="checkbox" checked={byProvider.terraform?.isEnabled ?? true} />Enabled</label> | |
164 | + <div class="grid gap-2 md:grid-cols-3"> | |
165 | + <input id="tf-message" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Plan/apply message" /> | |
166 | + <input id="tf-runid" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Run ID (for apply)" /> | |
167 | + <select id="tf-action" class="h-8 rounded-md border bg-background px-2 text-xs"><option value="plan">plan</option><option value="apply">apply</option></select> | |
168 | + </div> | |
169 | + <div class="flex items-center gap-2"><Button id="save-tf" variant="outline">Save Terraform</Button><Button id="run-tf" variant="outline">Run IaC Hook</Button><span id="tf-msg" class="text-xs text-muted-foreground"></span></div> | |
170 | + </CardContent> | |
171 | + </Card> | |
172 | + </div> | |
173 | + </div> | |
174 | + </div> | |
175 | +</BaseLayout> | |
176 | + | |
177 | +<script define:vars={{ repoOwner: repo.owner, repoName: repo.name, initialAwsId: byProvider.aws?.id || null, initialGcpId: byProvider.gcp?.id || null, initialAzureId: byProvider.azure?.id || null, initialK8sId: byProvider.kubernetes?.id || null, initialTfId: byProvider.terraform?.id || null }}> | |
178 | + let awsId = initialAwsId; | |
179 | + let gcpId = initialGcpId; | |
180 | + let azureId = initialAzureId; | |
181 | + let k8sId = initialK8sId; | |
182 | + let tfId = initialTfId; | |
183 | + | |
184 | + const awsMsg = document.getElementById("aws-msg"); | |
185 | + const gcpMsg = document.getElementById("gcp-msg"); | |
186 | + const azureMsg = document.getElementById("azure-msg"); | |
187 | + const k8sMsg = document.getElementById("k8s-msg"); | |
188 | + const tfMsg = document.getElementById("tf-msg"); | |
189 | + | |
190 | + const awsDeployments = document.getElementById("aws-deployments"); | |
191 | + const gcpDeployments = document.getElementById("gcp-deployments"); | |
192 | + const azureDeployments = document.getElementById("azure-deployments"); | |
193 | + const k8sDeployments = document.getElementById("k8s-deployments"); | |
194 | + | |
195 | + function renderDeployments(target, deployments, emptyLabel) { | |
196 | + if (!target) return; | |
197 | + target.innerHTML = deployments.length === 0 | |
198 | + ? emptyLabel | |
199 | + : deployments.map((item) => `<div class="rounded border border-white/10 bg-white/5 px-2 py-1.5 flex items-center justify-between"><span>${item.status}</span><span class="text-[11px] text-muted-foreground">${(item.commitSha || "").slice(0, 10)}</span></div>`).join(""); | |
200 | + } | |
201 | + | |
202 | + async function fetchSummary() { | |
203 | + const res = await fetch(`/api/repos/${repoOwner}/${repoName}/integrations/cloud?summary=1`); | |
204 | + const body = await res.json(); | |
205 | + if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to load cloud summary"); | |
206 | + return body?.data || body; | |
207 | + } | |
208 | + | |
209 | + async function refreshDeployments() { | |
210 | + try { | |
211 | + const data = await fetchSummary(); | |
212 | + const cfg = { | |
213 | + aws: (data.configs || []).find((c) => c.provider === "aws"), | |
214 | + gcp: (data.configs || []).find((c) => c.provider === "gcp"), | |
215 | + azure: (data.configs || []).find((c) => c.provider === "azure"), | |
216 | + kubernetes: (data.configs || []).find((c) => c.provider === "kubernetes"), | |
217 | + terraform: (data.configs || []).find((c) => c.provider === "terraform"), | |
218 | + }; | |
219 | + | |
220 | + if (cfg.aws?.id) awsId = cfg.aws.id; | |
221 | + if (cfg.gcp?.id) gcpId = cfg.gcp.id; | |
222 | + if (cfg.azure?.id) azureId = cfg.azure.id; | |
223 | + if (cfg.kubernetes?.id) k8sId = cfg.kubernetes.id; | |
224 | + if (cfg.terraform?.id) tfId = cfg.terraform.id; | |
225 | + | |
226 | + const deployments = data.deployments || []; | |
227 | + renderDeployments(awsDeployments, deployments.filter((d) => d.configId === cfg.aws?.id).slice(0, 5), "No AWS deployments yet."); | |
228 | + renderDeployments(gcpDeployments, deployments.filter((d) => d.configId === cfg.gcp?.id).slice(0, 5), "No GCP deployments yet."); | |
229 | + renderDeployments(azureDeployments, deployments.filter((d) => d.configId === cfg.azure?.id).slice(0, 5), "No Azure deployments yet."); | |
230 | + renderDeployments(k8sDeployments, deployments.filter((d) => d.configId === cfg.kubernetes?.id).slice(0, 5), "No Kubernetes deployments yet."); | |
231 | + } catch (error) { | |
232 | + if (awsDeployments) awsDeployments.textContent = error.message || "Failed to load deployments"; | |
233 | + if (gcpDeployments) gcpDeployments.textContent = error.message || "Failed to load deployments"; | |
234 | + if (azureDeployments) azureDeployments.textContent = error.message || "Failed to load deployments"; | |
235 | + if (k8sDeployments) k8sDeployments.textContent = error.message || "Failed to load deployments"; | |
236 | + } | |
237 | + } | |
238 | + | |
239 | + async function saveCloudProvider(configId, payload) { | |
240 | + const res = await fetch( | |
241 | + configId | |
242 | + ? `/api/repos/${repoOwner}/${repoName}/integrations/cloud/${configId}` | |
243 | + : `/api/repos/${repoOwner}/${repoName}/integrations/cloud`, | |
244 | + { | |
245 | + method: configId ? "PATCH" : "POST", | |
246 | + headers: { "Content-Type": "application/json" }, | |
247 | + body: JSON.stringify(payload), | |
248 | + } | |
249 | + ); | |
250 | + const body = await res.json(); | |
251 | + if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to save cloud config"); | |
252 | + return body?.data || body; | |
253 | + } | |
254 | + | |
255 | + async function triggerDeploy(configId, payload = {}) { | |
256 | + const res = await fetch(`/api/repos/${repoOwner}/${repoName}/cloud/deploy`, { | |
257 | + method: "POST", | |
258 | + headers: { "Content-Type": "application/json" }, | |
259 | + body: JSON.stringify({ configId, ...payload }), | |
260 | + }); | |
261 | + const body = await res.json(); | |
262 | + if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to trigger deploy"); | |
263 | + return body?.data || body; | |
264 | + } | |
265 | + | |
266 | + async function triggerIaC(payload) { | |
267 | + const res = await fetch(`/api/repos/${repoOwner}/${repoName}/iac/hooks`, { | |
268 | + method: "POST", | |
269 | + headers: { "Content-Type": "application/json" }, | |
270 | + body: JSON.stringify(payload), | |
271 | + }); | |
272 | + const body = await res.json(); | |
273 | + if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to run IaC hook"); | |
274 | + return body?.data || body; | |
275 | + } | |
276 | + | |
277 | + document.getElementById("save-aws")?.addEventListener("click", async () => { | |
278 | + try { | |
279 | + if (awsMsg) awsMsg.textContent = "Saving AWS config..."; | |
280 | + const saved = await saveCloudProvider(awsId, { | |
281 | + ...(awsId ? {} : { provider: "aws" }), | |
282 | + name: "AWS", | |
283 | + region: document.getElementById("aws-region")?.value || "us-east-1", | |
284 | + isEnabled: !!document.getElementById("aws-enabled")?.checked, | |
285 | + credentials: { | |
286 | + accessKeyId: document.getElementById("aws-key")?.value || "", | |
287 | + secretAccessKey: document.getElementById("aws-secret")?.value || "", | |
288 | + region: document.getElementById("aws-region")?.value || "us-east-1", | |
289 | + }, | |
290 | + }); | |
291 | + if (saved?.id) awsId = saved.id; | |
292 | + if (awsMsg) awsMsg.textContent = "AWS saved."; | |
293 | + refreshDeployments(); | |
294 | + } catch (error) { | |
295 | + if (awsMsg) awsMsg.textContent = error.message || "Failed to save AWS"; | |
296 | + } | |
297 | + }); | |
298 | + | |
299 | + document.getElementById("deploy-aws")?.addEventListener("click", async () => { | |
300 | + try { | |
301 | + if (awsMsg) awsMsg.textContent = "Triggering AWS deploy..."; | |
302 | + const result = await triggerDeploy(awsId, {}); | |
303 | + if (awsMsg) awsMsg.textContent = `Triggered (${result.executed || 0} runs).`; | |
304 | + refreshDeployments(); | |
305 | + } catch (error) { | |
306 | + if (awsMsg) awsMsg.textContent = error.message || "Deploy failed"; | |
307 | + } | |
308 | + }); | |
309 | + | |
310 | + document.getElementById("save-gcp")?.addEventListener("click", async () => { | |
311 | + try { | |
312 | + if (gcpMsg) gcpMsg.textContent = "Saving GCP config..."; | |
313 | + const saved = await saveCloudProvider(gcpId, { | |
314 | + ...(gcpId ? {} : { provider: "gcp" }), | |
315 | + name: "Google Cloud", | |
316 | + region: document.getElementById("gcp-region")?.value || "us-central1", | |
317 | + isEnabled: !!document.getElementById("gcp-enabled")?.checked, | |
318 | + credentials: { | |
319 | + projectId: document.getElementById("gcp-project")?.value || "", | |
320 | + clientEmail: document.getElementById("gcp-client-email")?.value || "", | |
321 | + privateKey: document.getElementById("gcp-private-key")?.value || "", | |
322 | + }, | |
323 | + }); | |
324 | + if (saved?.id) gcpId = saved.id; | |
325 | + if (gcpMsg) gcpMsg.textContent = "GCP saved."; | |
326 | + refreshDeployments(); | |
327 | + } catch (error) { | |
328 | + if (gcpMsg) gcpMsg.textContent = error.message || "Failed to save GCP"; | |
329 | + } | |
330 | + }); | |
331 | + | |
332 | + document.getElementById("deploy-gcp")?.addEventListener("click", async () => { | |
333 | + try { | |
334 | + if (gcpMsg) gcpMsg.textContent = "Triggering GCP deploy..."; | |
335 | + const result = await triggerDeploy(gcpId, {}); | |
336 | + if (gcpMsg) gcpMsg.textContent = `Triggered (${result.executed || 0} runs).`; | |
337 | + refreshDeployments(); | |
338 | + } catch (error) { | |
339 | + if (gcpMsg) gcpMsg.textContent = error.message || "Deploy failed"; | |
340 | + } | |
341 | + }); | |
342 | + | |
343 | + document.getElementById("save-azure")?.addEventListener("click", async () => { | |
344 | + try { | |
345 | + if (azureMsg) azureMsg.textContent = "Saving Azure config..."; | |
346 | + const saved = await saveCloudProvider(azureId, { | |
347 | + ...(azureId ? {} : { provider: "azure" }), | |
348 | + name: "Azure", | |
349 | + isEnabled: !!document.getElementById("azure-enabled")?.checked, | |
350 | + credentials: { | |
351 | + subscriptionId: document.getElementById("azure-subscription")?.value || "", | |
352 | + tenantId: document.getElementById("azure-tenant")?.value || "", | |
353 | + clientId: document.getElementById("azure-client")?.value || "", | |
354 | + clientSecret: document.getElementById("azure-secret")?.value || "", | |
355 | + }, | |
356 | + }); | |
357 | + if (saved?.id) azureId = saved.id; | |
358 | + if (azureMsg) azureMsg.textContent = "Azure saved."; | |
359 | + refreshDeployments(); | |
360 | + } catch (error) { | |
361 | + if (azureMsg) azureMsg.textContent = error.message || "Failed to save Azure"; | |
362 | + } | |
363 | + }); | |
364 | + | |
365 | + document.getElementById("deploy-azure")?.addEventListener("click", async () => { | |
366 | + try { | |
367 | + if (azureMsg) azureMsg.textContent = "Triggering Azure deploy..."; | |
368 | + const result = await triggerDeploy(azureId, {}); | |
369 | + if (azureMsg) azureMsg.textContent = `Triggered (${result.executed || 0} runs).`; | |
370 | + refreshDeployments(); | |
371 | + } catch (error) { | |
372 | + if (azureMsg) azureMsg.textContent = error.message || "Deploy failed"; | |
373 | + } | |
374 | + }); | |
375 | + | |
376 | + document.getElementById("save-k8s")?.addEventListener("click", async () => { | |
377 | + try { | |
378 | + if (k8sMsg) k8sMsg.textContent = "Saving Kubernetes config..."; | |
379 | + const saved = await saveCloudProvider(k8sId, { | |
380 | + ...(k8sId ? {} : { provider: "kubernetes" }), | |
381 | + name: "Kubernetes", | |
382 | + isEnabled: !!document.getElementById("k8s-enabled")?.checked, | |
383 | + credentials: { | |
384 | + apiServer: document.getElementById("k8s-api-server")?.value || "", | |
385 | + token: document.getElementById("k8s-token")?.value || "", | |
386 | + namespace: document.getElementById("k8s-namespace")?.value || "default", | |
387 | + }, | |
388 | + }); | |
389 | + if (saved?.id) k8sId = saved.id; | |
390 | + if (k8sMsg) k8sMsg.textContent = "Kubernetes saved."; | |
391 | + refreshDeployments(); | |
392 | + } catch (error) { | |
393 | + if (k8sMsg) k8sMsg.textContent = error.message || "Failed to save Kubernetes"; | |
394 | + } | |
395 | + }); | |
396 | + | |
397 | + document.getElementById("deploy-k8s")?.addEventListener("click", async () => { | |
398 | + try { | |
399 | + if (k8sMsg) k8sMsg.textContent = "Triggering Kubernetes deploy..."; | |
400 | + const result = await triggerDeploy(k8sId, { namespace: document.getElementById("k8s-namespace")?.value || "default" }); | |
401 | + if (k8sMsg) k8sMsg.textContent = `Triggered (${result.executed || 0} runs).`; | |
402 | + refreshDeployments(); | |
403 | + } catch (error) { | |
404 | + if (k8sMsg) k8sMsg.textContent = error.message || "Deploy failed"; | |
405 | + } | |
406 | + }); | |
407 | + | |
408 | + document.getElementById("save-tf")?.addEventListener("click", async () => { | |
409 | + try { | |
410 | + if (tfMsg) tfMsg.textContent = "Saving Terraform config..."; | |
411 | + const saved = await saveCloudProvider(tfId, { | |
412 | + ...(tfId ? {} : { provider: "terraform" }), | |
413 | + name: "Terraform", | |
414 | + isEnabled: !!document.getElementById("tf-enabled")?.checked, | |
415 | + credentials: { | |
416 | + organization: document.getElementById("tf-org")?.value || "", | |
417 | + workspace: document.getElementById("tf-workspace")?.value || "", | |
418 | + token: document.getElementById("tf-token")?.value || "", | |
419 | + }, | |
420 | + }); | |
421 | + if (saved?.id) tfId = saved.id; | |
422 | + if (tfMsg) tfMsg.textContent = "Terraform saved."; | |
423 | + } catch (error) { | |
424 | + if (tfMsg) tfMsg.textContent = error.message || "Failed to save Terraform"; | |
425 | + } | |
426 | + }); | |
427 | + | |
428 | + document.getElementById("run-tf")?.addEventListener("click", async () => { | |
429 | + try { | |
430 | + if (tfMsg) tfMsg.textContent = "Running IaC hook..."; | |
431 | + const action = document.getElementById("tf-action")?.value || "plan"; | |
432 | + const result = await triggerIaC({ | |
433 | + action, | |
434 | + configId: tfId, | |
435 | + runId: document.getElementById("tf-runid")?.value || undefined, | |
436 | + message: document.getElementById("tf-message")?.value || undefined, | |
437 | + }); | |
438 | + if (tfMsg) tfMsg.textContent = `IaC ${action} executed (${result.executed || 0} runs).`; | |
439 | + } catch (error) { | |
440 | + if (tfMsg) tfMsg.textContent = error.message || "IaC hook failed"; | |
441 | + } | |
442 | + }); | |
443 | + | |
444 | + refreshDeployments(); | |
445 | +</script> | |
0 | 446 | |
▼src/pages/[owner]/[repo]/settings/external-ci.astro
+6-0
| @@ -118,6 +118,12 @@ const repo = { | ||
118 | 118 | > |
119 | 119 | Issue Trackers |
120 | 120 | </a> |
121 | + <a | |
122 | + href={`/${repo.owner}/${repo.name}/settings/cloud-infra`} | |
123 | + class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground" | |
124 | + > | |
125 | + Cloud & Infra | |
126 | + </a> | |
121 | 127 | <a |
122 | 128 | href={`/${repo.owner}/${repo.name}/settings/reviews`} |
123 | 129 | class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground" |
124 | 130 | |
▼src/pages/[owner]/[repo]/settings/index.astro
+6-0
| @@ -115,6 +115,12 @@ const repo = { | ||
115 | 115 | > |
116 | 116 | Issue Trackers |
117 | 117 | </a> |
118 | + <a | |
119 | + href={`/${repo.owner}/${repo.name}/settings/cloud-infra`} | |
120 | + class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground" | |
121 | + > | |
122 | + Cloud & Infra | |
123 | + </a> | |
118 | 124 | <a |
119 | 125 | href={`/${repo.owner}/${repo.name}/settings/reviews`} |
120 | 126 | class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground" |
121 | 127 | |
▼src/pages/[owner]/[repo]/settings/issue-trackers.astro
+1-0
| @@ -79,6 +79,7 @@ const repo = { | ||
79 | 79 | <a href={`/${repo.owner}/${repo.name}/settings/webhooks`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Webhooks</a> |
80 | 80 | <a href={`/${repo.owner}/${repo.name}/settings/external-ci`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">External CI</a> |
81 | 81 | <a href={`/${repo.owner}/${repo.name}/settings/issue-trackers`} class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground">Issue Trackers</a> |
82 | + <a href={`/${repo.owner}/${repo.name}/settings/cloud-infra`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Cloud & Infra</a> | |
82 | 83 | <a href={`/${repo.owner}/${repo.name}/settings/reviews`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Reviews</a> |
83 | 84 | <a href={`/${repo.owner}/${repo.name}/settings/actions`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Actions</a> |
84 | 85 | <a href={`/${repo.owner}/${repo.name}/settings/deploy-keys`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Deploy keys</a> |
85 | 86 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/cloud.ts
+163-0
| @@ -0,0 +1,163 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, desc, eq, inArray } from "drizzle-orm"; | |
4 | +import crypto from "crypto"; | |
5 | +import { z } from "zod"; | |
6 | +import { getDatabase, schema } from "@/db"; | |
7 | +import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
8 | +import { withErrorHandler } from "@/lib/errors"; | |
9 | +import { getUserFromRequest } from "@/lib/auth"; | |
10 | +import { canReadRepo, canWriteRepo } from "@/lib/permissions"; | |
11 | +import { CLOUD_PROVIDERS } from "@/lib/cloud-integrations"; | |
12 | + | |
13 | +const SUPPORTED_PROVIDERS = ["aws", "gcp", "azure", "kubernetes", "terraform"] as const; | |
14 | + | |
15 | +const createSchema = z.object({ | |
16 | + provider: z.enum(SUPPORTED_PROVIDERS), | |
17 | + name: z.string().min(1).max(120).optional(), | |
18 | + region: z.string().min(1).max(120).optional(), | |
19 | + credentials: z.record(z.string()).default({}), | |
20 | + settings: z.record(z.any()).optional(), | |
21 | + isEnabled: z.boolean().optional(), | |
22 | +}); | |
23 | + | |
24 | +async function resolveRepository(owner: string, repoName: string) { | |
25 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
26 | + const repoOwner = await db.query.users.findFirst({ | |
27 | + where: eq(schema.users.username, owner), | |
28 | + }); | |
29 | + if (!repoOwner) return null; | |
30 | + | |
31 | + return db.query.repositories.findFirst({ | |
32 | + where: and( | |
33 | + eq(schema.repositories.ownerId, repoOwner.id), | |
34 | + eq(schema.repositories.name, repoName) | |
35 | + ), | |
36 | + }); | |
37 | +} | |
38 | + | |
39 | +function redactConfig(config: any) { | |
40 | + const credentialKeys = Object.keys((config?.credentials || {}) as Record<string, unknown>); | |
41 | + return { | |
42 | + ...config, | |
43 | + credentials: undefined, | |
44 | + hasCredentials: credentialKeys.length > 0, | |
45 | + credentialKeys, | |
46 | + }; | |
47 | +} | |
48 | + | |
49 | +export const GET: APIRoute = withErrorHandler(async ({ params, request, url }) => { | |
50 | + const owner = params.owner; | |
51 | + const repoName = params.repo; | |
52 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
53 | + | |
54 | + const user = await getUserFromRequest(request); | |
55 | + const repository = await resolveRepository(owner, repoName); | |
56 | + if (!repository) return notFound("Repository not found"); | |
57 | + | |
58 | + if (!(await canReadRepo(user?.userId, repository, { isAdmin: user?.isAdmin }))) { | |
59 | + return notFound("Repository not found"); | |
60 | + } | |
61 | + | |
62 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
63 | + const includeSummary = url.searchParams.get("summary") === "1"; | |
64 | + | |
65 | + const configs = await db.query.cloudConfigs.findMany({ | |
66 | + where: and( | |
67 | + eq(schema.cloudConfigs.repositoryId, repository.id), | |
68 | + inArray(schema.cloudConfigs.provider, SUPPORTED_PROVIDERS as unknown as string[]) | |
69 | + ), | |
70 | + orderBy: [desc(schema.cloudConfigs.updatedAt)], | |
71 | + }); | |
72 | + | |
73 | + if (!includeSummary) { | |
74 | + return success({ | |
75 | + providers: SUPPORTED_PROVIDERS.map((provider) => ({ | |
76 | + id: provider, | |
77 | + ...CLOUD_PROVIDERS[provider], | |
78 | + })), | |
79 | + configs: configs.map(redactConfig), | |
80 | + }); | |
81 | + } | |
82 | + | |
83 | + const configIds = configs.map((cfg) => cfg.id); | |
84 | + const deployments = configIds.length | |
85 | + ? await db.query.deployments.findMany({ | |
86 | + where: inArray(schema.deployments.configId, configIds), | |
87 | + orderBy: [desc(schema.deployments.createdAt)], | |
88 | + limit: 100, | |
89 | + }) | |
90 | + : []; | |
91 | + | |
92 | + return success({ | |
93 | + providers: SUPPORTED_PROVIDERS.map((provider) => ({ | |
94 | + id: provider, | |
95 | + ...CLOUD_PROVIDERS[provider], | |
96 | + })), | |
97 | + configs: configs.map(redactConfig), | |
98 | + deployments, | |
99 | + }); | |
100 | +}); | |
101 | + | |
102 | +export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | |
103 | + const owner = params.owner; | |
104 | + const repoName = params.repo; | |
105 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
106 | + | |
107 | + const user = await getUserFromRequest(request); | |
108 | + if (!user) return unauthorized(); | |
109 | + | |
110 | + const repository = await resolveRepository(owner, repoName); | |
111 | + if (!repository) return notFound("Repository not found"); | |
112 | + | |
113 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
114 | + return forbidden(); | |
115 | + } | |
116 | + | |
117 | + const parsed = await parseBody(request, createSchema); | |
118 | + if ("error" in parsed) return parsed.error; | |
119 | + | |
120 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
121 | + const now = new Date(); | |
122 | + | |
123 | + const existing = await db.query.cloudConfigs.findFirst({ | |
124 | + where: and( | |
125 | + eq(schema.cloudConfigs.repositoryId, repository.id), | |
126 | + eq(schema.cloudConfigs.provider, parsed.data.provider) | |
127 | + ), | |
128 | + }); | |
129 | + | |
130 | + const payload = { | |
131 | + provider: parsed.data.provider, | |
132 | + name: parsed.data.name || CLOUD_PROVIDERS[parsed.data.provider].name, | |
133 | + region: parsed.data.region || null, | |
134 | + credentials: parsed.data.credentials, | |
135 | + settings: parsed.data.settings || null, | |
136 | + isEnabled: parsed.data.isEnabled ?? true, | |
137 | + updatedAt: now, | |
138 | + }; | |
139 | + | |
140 | + if (existing) { | |
141 | + await db.update(schema.cloudConfigs) | |
142 | + .set(payload) | |
143 | + .where(eq(schema.cloudConfigs.id, existing.id)); | |
144 | + | |
145 | + const updated = await db.query.cloudConfigs.findFirst({ | |
146 | + where: eq(schema.cloudConfigs.id, existing.id), | |
147 | + }); | |
148 | + | |
149 | + return success(updated ? redactConfig(updated) : null); | |
150 | + } | |
151 | + | |
152 | + const created = { | |
153 | + id: crypto.randomUUID(), | |
154 | + repositoryId: repository.id, | |
155 | + organizationId: null, | |
156 | + createdAt: now, | |
157 | + ...payload, | |
158 | + }; | |
159 | + | |
160 | + await db.insert(schema.cloudConfigs).values(created); | |
161 | + | |
162 | + return success(redactConfig(created)); | |
163 | +}); | |
0 | 164 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/cloud/[id].ts
+121-0
| @@ -0,0 +1,121 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { getUserFromRequest } from "@/lib/auth"; | |
9 | +import { canWriteRepo } from "@/lib/permissions"; | |
10 | + | |
11 | +const patchSchema = z.object({ | |
12 | + name: z.string().min(1).max(120).optional(), | |
13 | + region: z.string().min(1).max(120).optional(), | |
14 | + credentials: z.record(z.string()).optional(), | |
15 | + settings: z.record(z.any()).optional(), | |
16 | + isEnabled: z.boolean().optional(), | |
17 | +}); | |
18 | + | |
19 | +async function resolveRepository(owner: string, repoName: string) { | |
20 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
21 | + const repoOwner = await db.query.users.findFirst({ | |
22 | + where: eq(schema.users.username, owner), | |
23 | + }); | |
24 | + if (!repoOwner) return null; | |
25 | + | |
26 | + return db.query.repositories.findFirst({ | |
27 | + where: and( | |
28 | + eq(schema.repositories.ownerId, repoOwner.id), | |
29 | + eq(schema.repositories.name, repoName) | |
30 | + ), | |
31 | + }); | |
32 | +} | |
33 | + | |
34 | +function redactConfig(config: any) { | |
35 | + const credentialKeys = Object.keys((config?.credentials || {}) as Record<string, unknown>); | |
36 | + return { | |
37 | + ...config, | |
38 | + credentials: undefined, | |
39 | + hasCredentials: credentialKeys.length > 0, | |
40 | + credentialKeys, | |
41 | + }; | |
42 | +} | |
43 | + | |
44 | +export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | |
45 | + const owner = params.owner; | |
46 | + const repoName = params.repo; | |
47 | + const id = params.id; | |
48 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
49 | + | |
50 | + const user = await getUserFromRequest(request); | |
51 | + if (!user) return unauthorized(); | |
52 | + | |
53 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
54 | + const repository = await resolveRepository(owner, repoName); | |
55 | + if (!repository) return notFound("Repository not found"); | |
56 | + | |
57 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
58 | + return forbidden(); | |
59 | + } | |
60 | + | |
61 | + const config = await db.query.cloudConfigs.findFirst({ | |
62 | + where: and( | |
63 | + eq(schema.cloudConfigs.id, id), | |
64 | + eq(schema.cloudConfigs.repositoryId, repository.id) | |
65 | + ), | |
66 | + }); | |
67 | + if (!config) return notFound("Cloud config not found"); | |
68 | + | |
69 | + const parsed = await parseBody(request, patchSchema); | |
70 | + if ("error" in parsed) return parsed.error; | |
71 | + | |
72 | + const updates: Record<string, unknown> = { | |
73 | + updatedAt: new Date(), | |
74 | + }; | |
75 | + | |
76 | + if (parsed.data.name !== undefined) updates.name = parsed.data.name; | |
77 | + if (parsed.data.region !== undefined) updates.region = parsed.data.region; | |
78 | + if (parsed.data.credentials !== undefined) updates.credentials = parsed.data.credentials; | |
79 | + if (parsed.data.settings !== undefined) updates.settings = parsed.data.settings; | |
80 | + if (parsed.data.isEnabled !== undefined) updates.isEnabled = parsed.data.isEnabled; | |
81 | + | |
82 | + await db.update(schema.cloudConfigs) | |
83 | + .set(updates) | |
84 | + .where(eq(schema.cloudConfigs.id, id)); | |
85 | + | |
86 | + const updated = await db.query.cloudConfigs.findFirst({ | |
87 | + where: eq(schema.cloudConfigs.id, id), | |
88 | + }); | |
89 | + | |
90 | + return success(updated ? redactConfig(updated) : null); | |
91 | +}); | |
92 | + | |
93 | +export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => { | |
94 | + const owner = params.owner; | |
95 | + const repoName = params.repo; | |
96 | + const id = params.id; | |
97 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
98 | + | |
99 | + const user = await getUserFromRequest(request); | |
100 | + if (!user) return unauthorized(); | |
101 | + | |
102 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
103 | + const repository = await resolveRepository(owner, repoName); | |
104 | + if (!repository) return notFound("Repository not found"); | |
105 | + | |
106 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
107 | + return forbidden(); | |
108 | + } | |
109 | + | |
110 | + const config = await db.query.cloudConfigs.findFirst({ | |
111 | + where: and( | |
112 | + eq(schema.cloudConfigs.id, id), | |
113 | + eq(schema.cloudConfigs.repositoryId, repository.id) | |
114 | + ), | |
115 | + }); | |
116 | + if (!config) return notFound("Cloud config not found"); | |
117 | + | |
118 | + await db.delete(schema.cloudConfigs).where(eq(schema.cloudConfigs.id, id)); | |
119 | + | |
120 | + return success({ deleted: true }); | |
121 | +}); | |
0 | 122 | |
▼tests/integration/cloud-integrations-route.test.ts
+175-0
| @@ -0,0 +1,175 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canReadRepoMock, | |
5 | + canWriteRepoMock, | |
6 | + fakeSchema, | |
7 | +} = vi.hoisted(() => ({ | |
8 | + canReadRepoMock: vi.fn(async () => true), | |
9 | + canWriteRepoMock: vi.fn(async () => true), | |
10 | + fakeSchema: { | |
11 | + users: { username: {} }, | |
12 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
13 | + cloudConfigs: { repositoryId: {}, provider: {}, updatedAt: {}, id: {} }, | |
14 | + deployments: { configId: {}, createdAt: {} }, | |
15 | + } as any, | |
16 | +})); | |
17 | + | |
18 | +let mockDb: any; | |
19 | + | |
20 | +vi.mock("@/db", () => ({ | |
21 | + getDatabase: () => mockDb, | |
22 | + schema: fakeSchema, | |
23 | +})); | |
24 | + | |
25 | +vi.mock("@/lib/auth", () => ({ | |
26 | + getUserFromRequest: vi.fn(async () => ({ userId: "user-1", isAdmin: false })), | |
27 | +})); | |
28 | + | |
29 | +vi.mock("@/lib/permissions", () => ({ | |
30 | + canReadRepo: canReadRepoMock, | |
31 | + canWriteRepo: canWriteRepoMock, | |
32 | +})); | |
33 | + | |
34 | +import { GET as cloudGet, POST as cloudPost } from "@/pages/api/repos/[owner]/[repo]/integrations/cloud"; | |
35 | +import { DELETE as cloudDelete, PATCH as cloudPatch } from "@/pages/api/repos/[owner]/[repo]/integrations/cloud/[id]"; | |
36 | + | |
37 | +function makeDb() { | |
38 | + return { | |
39 | + query: { | |
40 | + users: { | |
41 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
42 | + }, | |
43 | + repositories: { | |
44 | + findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })), | |
45 | + }, | |
46 | + cloudConfigs: { | |
47 | + findMany: vi.fn(async () => ([ | |
48 | + { | |
49 | + id: "cfg-aws", | |
50 | + repositoryId: "repo-1", | |
51 | + provider: "aws", | |
52 | + name: "AWS", | |
53 | + region: "us-east-1", | |
54 | + credentials: { accessKeyId: "AKIA", secretAccessKey: "sec" }, | |
55 | + settings: null, | |
56 | + isEnabled: true, | |
57 | + updatedAt: new Date(), | |
58 | + }, | |
59 | + ])), | |
60 | + findFirst: vi.fn(async () => null), | |
61 | + }, | |
62 | + deployments: { | |
63 | + findMany: vi.fn(async () => ([ | |
64 | + { | |
65 | + id: "dep-1", | |
66 | + configId: "cfg-aws", | |
67 | + status: "success", | |
68 | + commitSha: "abc123", | |
69 | + createdAt: new Date(), | |
70 | + }, | |
71 | + ])), | |
72 | + }, | |
73 | + }, | |
74 | + insert: vi.fn(() => ({ values: vi.fn(async () => undefined) })), | |
75 | + update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) })), | |
76 | + delete: vi.fn(() => ({ where: vi.fn(async () => undefined) })), | |
77 | + }; | |
78 | +} | |
79 | + | |
80 | +async function json(response: Response): Promise<any> { | |
81 | + return response.json(); | |
82 | +} | |
83 | + | |
84 | +describe("cloud integrations route", () => { | |
85 | + beforeEach(() => { | |
86 | + vi.clearAllMocks(); | |
87 | + mockDb = makeDb(); | |
88 | + }); | |
89 | + | |
90 | + it("returns providers and configs", async () => { | |
91 | + const response = await cloudGet({ | |
92 | + params: { owner: "owner-1", repo: "demo" }, | |
93 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud"), | |
94 | + url: new URL("http://localhost/api/repos/owner-1/demo/integrations/cloud"), | |
95 | + } as any); | |
96 | + | |
97 | + const body = await json(response); | |
98 | + expect(response.status).toBe(200); | |
99 | + expect(body?.data?.providers?.length).toBe(5); | |
100 | + expect(body?.data?.configs?.[0]?.provider).toBe("aws"); | |
101 | + expect(body?.data?.configs?.[0]?.credentials).toBeUndefined(); | |
102 | + expect(body?.data?.configs?.[0]?.hasCredentials).toBe(true); | |
103 | + }); | |
104 | + | |
105 | + it("returns deployment summary with summary=1", async () => { | |
106 | + const response = await cloudGet({ | |
107 | + params: { owner: "owner-1", repo: "demo" }, | |
108 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud?summary=1"), | |
109 | + url: new URL("http://localhost/api/repos/owner-1/demo/integrations/cloud?summary=1"), | |
110 | + } as any); | |
111 | + | |
112 | + const body = await json(response); | |
113 | + expect(response.status).toBe(200); | |
114 | + expect(body?.data?.deployments?.length).toBe(1); | |
115 | + expect(body?.data?.deployments?.[0]?.status).toBe("success"); | |
116 | + }); | |
117 | + | |
118 | + it("creates provider configs for all supported cloud providers", async () => { | |
119 | + mockDb.query.cloudConfigs.findFirst = vi.fn(async () => null); | |
120 | + | |
121 | + for (const provider of ["aws", "gcp", "azure", "kubernetes", "terraform"] as const) { | |
122 | + const response = await cloudPost({ | |
123 | + params: { owner: "owner-1", repo: "demo" }, | |
124 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud", { | |
125 | + method: "POST", | |
126 | + headers: { "content-type": "application/json" }, | |
127 | + body: JSON.stringify({ | |
128 | + provider, | |
129 | + name: provider, | |
130 | + region: "us-east-1", | |
131 | + credentials: { token: "secret" }, | |
132 | + isEnabled: true, | |
133 | + }), | |
134 | + }), | |
135 | + } as any); | |
136 | + | |
137 | + expect(response.status).toBe(200); | |
138 | + } | |
139 | + | |
140 | + expect(mockDb.insert).toHaveBeenCalledTimes(5); | |
141 | + }); | |
142 | + | |
143 | + it("patches and deletes cloud config", async () => { | |
144 | + mockDb.query.cloudConfigs.findFirst = vi.fn(async () => ({ | |
145 | + id: "cfg-aws", | |
146 | + repositoryId: "repo-1", | |
147 | + provider: "aws", | |
148 | + credentials: { accessKeyId: "AKIA" }, | |
149 | + })); | |
150 | + | |
151 | + const patchResponse = await cloudPatch({ | |
152 | + params: { owner: "owner-1", repo: "demo", id: "cfg-aws" }, | |
153 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud/cfg-aws", { | |
154 | + method: "PATCH", | |
155 | + headers: { "content-type": "application/json" }, | |
156 | + body: JSON.stringify({ isEnabled: false }), | |
157 | + }), | |
158 | + } as any); | |
159 | + | |
160 | + expect(patchResponse.status).toBe(200); | |
161 | + expect(mockDb.update).toHaveBeenCalledTimes(1); | |
162 | + | |
163 | + const deleteResponse = await cloudDelete({ | |
164 | + params: { owner: "owner-1", repo: "demo", id: "cfg-aws" }, | |
165 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud/cfg-aws", { | |
166 | + method: "DELETE", | |
167 | + }), | |
168 | + } as any); | |
169 | + | |
170 | + const body = await json(deleteResponse); | |
171 | + expect(deleteResponse.status).toBe(200); | |
172 | + expect(body?.data?.deleted).toBe(true); | |
173 | + expect(mockDb.delete).toHaveBeenCalledTimes(1); | |
174 | + }); | |
175 | +}); | |
0 | 176 | |