Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

server-target-store.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

server-target-store.tsBlame338 lines · 1 contributor
783dd46Claude1/**
2 * DB-side helpers for server targets — exists to keep route + hook code
3 * thin and to centralise the audit-log call so every mutation logs.
4 */
5
6import { and, desc, eq } from "drizzle-orm";
7import { db } from "../db";
8import {
9 serverTargetAudit,
10 serverTargetDeployments,
11 serverTargetEnv,
12 serverTargets,
13 type ServerTarget,
14 type NewServerTarget,
15 type ServerTargetEnv,
16} from "../db/schema";
17import { decryptValue, encryptValue, isValidEnvName } from "./server-targets-crypto";
18
19export async function listTargets(): Promise<ServerTarget[]> {
20 return db
21 .select()
22 .from(serverTargets)
23 .orderBy(desc(serverTargets.createdAt));
24}
25
26export async function getTarget(
27 id: string
28): Promise<ServerTarget | null> {
29 const [row] = await db
30 .select()
31 .from(serverTargets)
32 .where(eq(serverTargets.id, id))
33 .limit(1);
34 return row ?? null;
35}
36
37export async function getTargetByName(
38 name: string
39): Promise<ServerTarget | null> {
40 const [row] = await db
41 .select()
42 .from(serverTargets)
43 .where(eq(serverTargets.name, name))
44 .limit(1);
45 return row ?? null;
46}
47
48export interface CreateTargetInput {
49 name: string;
50 host: string;
51 port?: number;
52 sshUser: string;
53 privateKey: string;
54 deployPath?: string;
55 deployScript?: string;
56 watchedRepositoryId?: string | null;
57 watchedBranch?: string | null;
58 createdBy: string;
59}
60
61export async function createTarget(
62 input: CreateTargetInput
63): Promise<{ ok: true; target: ServerTarget } | { ok: false; error: string }> {
64 const enc = encryptValue(input.privateKey);
65 if (!enc.ok) return { ok: false, error: enc.error };
66 const insert: NewServerTarget = {
67 name: input.name,
68 host: input.host,
69 port: input.port ?? 22,
70 sshUser: input.sshUser,
71 encryptedPrivateKey: enc.ciphertext,
72 deployPath: input.deployPath ?? "/var/www/app",
73 deployScript: input.deployScript ?? "bash deploy.sh",
74 watchedRepositoryId: input.watchedRepositoryId ?? null,
75 watchedBranch: input.watchedBranch ?? null,
76 createdBy: input.createdBy,
77 };
78 try {
79 const [row] = await db.insert(serverTargets).values(insert).returning();
80 if (!row) return { ok: false, error: "insert returned no row" };
81 await logAudit({
82 targetId: row.id,
83 actorId: input.createdBy,
84 action: "target.created",
85 detail: `${row.sshUser}@${row.host}:${row.port}`,
86 });
87 return { ok: true, target: row };
88 } catch (err) {
89 return {
90 ok: false,
91 error: err instanceof Error ? err.message : String(err),
92 };
93 }
94}
95
96export async function deleteTarget(
97 id: string,
98 actorId: string
99): Promise<void> {
100 await db.delete(serverTargets).where(eq(serverTargets.id, id));
101 await logAudit({
102 targetId: id,
103 actorId,
104 action: "target.deleted",
105 });
106}
107
108export async function recordPin(
109 targetId: string,
110 fingerprint: string,
111 actorId: string
112): Promise<void> {
113 await db
114 .update(serverTargets)
115 .set({
116 hostFingerprint: fingerprint,
117 status: "verified",
118 lastSeenAt: new Date(),
119 updatedAt: new Date(),
120 })
121 .where(eq(serverTargets.id, targetId));
122 await logAudit({
123 targetId,
124 actorId,
125 action: "target.fingerprint_pinned",
126 detail: fingerprint,
127 });
128}
129
130// --- env vars ---------------------------------------------------------------
131
132export async function listEnv(targetId: string): Promise<ServerTargetEnv[]> {
133 return db
134 .select()
135 .from(serverTargetEnv)
136 .where(eq(serverTargetEnv.targetId, targetId))
137 .orderBy(serverTargetEnv.name);
138}
139
140/**
141 * Decrypt the env vars for a target into a KEY→value map. Skips rows whose
142 * ciphertext fails to decrypt (logged) — a corrupt row should not abort a
143 * deploy, but the missing var becomes obvious in the run log.
144 */
145export async function resolveEnv(
146 targetId: string
147): Promise<Record<string, string>> {
148 const rows = await listEnv(targetId);
149 const out: Record<string, string> = {};
150 for (const row of rows) {
151 const dec = decryptValue(row.encryptedValue);
152 if (dec.ok) out[row.name] = dec.plaintext;
153 else console.warn(`[server-targets] decrypt env ${row.name}: ${dec.error}`);
154 }
155 return out;
156}
157
158export async function upsertEnv(input: {
159 targetId: string;
160 name: string;
161 value: string;
162 isSecret?: boolean;
163 actorId: string;
164}): Promise<{ ok: true } | { ok: false; error: string }> {
165 if (!isValidEnvName(input.name)) {
166 return {
167 ok: false,
168 error: "name must match /^[A-Z_][A-Z0-9_]*$/ (uppercase, digits, _)",
169 };
170 }
171 const enc = encryptValue(input.value);
172 if (!enc.ok) return { ok: false, error: enc.error };
173 const now = new Date();
174 try {
175 const [existing] = await db
176 .select()
177 .from(serverTargetEnv)
178 .where(
179 and(
180 eq(serverTargetEnv.targetId, input.targetId),
181 eq(serverTargetEnv.name, input.name)
182 )
183 )
184 .limit(1);
185 if (existing) {
186 await db
187 .update(serverTargetEnv)
188 .set({
189 encryptedValue: enc.ciphertext,
190 isSecret: input.isSecret ?? existing.isSecret,
191 updatedBy: input.actorId,
192 updatedAt: now,
193 })
194 .where(eq(serverTargetEnv.id, existing.id));
195 } else {
196 await db.insert(serverTargetEnv).values({
197 targetId: input.targetId,
198 name: input.name,
199 encryptedValue: enc.ciphertext,
200 isSecret: input.isSecret ?? true,
201 updatedBy: input.actorId,
202 });
203 }
204 await logAudit({
205 targetId: input.targetId,
206 actorId: input.actorId,
207 action: "env.upserted",
208 detail: input.name,
209 });
210 return { ok: true };
211 } catch (err) {
212 return {
213 ok: false,
214 error: err instanceof Error ? err.message : String(err),
215 };
216 }
217}
218
219export async function deleteEnv(input: {
220 targetId: string;
221 name: string;
222 actorId: string;
223}): Promise<void> {
224 await db
225 .delete(serverTargetEnv)
226 .where(
227 and(
228 eq(serverTargetEnv.targetId, input.targetId),
229 eq(serverTargetEnv.name, input.name)
230 )
231 );
232 await logAudit({
233 targetId: input.targetId,
234 actorId: input.actorId,
235 action: "env.deleted",
236 detail: input.name,
237 });
238}
239
240// --- deployments + audit ----------------------------------------------------
241
242export interface RecordDeployInput {
243 targetId: string;
244 commitSha?: string | null;
245 ref?: string | null;
246 triggeredBy?: string | null;
247 triggerSource: "push" | "manual";
248}
249
250export async function startDeployRow(
251 input: RecordDeployInput
252): Promise<string | null> {
253 try {
254 const [row] = await db
255 .insert(serverTargetDeployments)
256 .values({
257 targetId: input.targetId,
258 commitSha: input.commitSha ?? null,
259 ref: input.ref ?? null,
260 triggeredBy: input.triggeredBy ?? null,
261 triggerSource: input.triggerSource,
262 status: "running",
263 })
264 .returning();
265 return row?.id ?? null;
266 } catch {
267 return null;
268 }
269}
270
271export async function finishDeployRow(input: {
272 id: string;
273 exitCode: number;
274 stdout: string;
275 stderr: string;
276}): Promise<void> {
277 try {
278 await db
279 .update(serverTargetDeployments)
280 .set({
281 status: input.exitCode === 0 ? "success" : "failed",
282 exitCode: input.exitCode,
283 stdout: input.stdout.slice(0, 1_000_000),
284 stderr: input.stderr.slice(0, 1_000_000),
285 finishedAt: new Date(),
286 })
287 .where(eq(serverTargetDeployments.id, input.id));
288 } catch {
289 /* ignore */
290 }
291}
292
293export async function recentDeploys(
294 targetId: string,
295 limit = 20
296): Promise<Array<typeof serverTargetDeployments.$inferSelect>> {
297 return db
298 .select()
299 .from(serverTargetDeployments)
300 .where(eq(serverTargetDeployments.targetId, targetId))
301 .orderBy(desc(serverTargetDeployments.startedAt))
302 .limit(limit);
303}
304
305export async function logAudit(input: {
306 targetId?: string | null;
307 actorId?: string | null;
308 action: string;
309 detail?: string | null;
310 ip?: string | null;
311}): Promise<void> {
312 try {
313 await db.insert(serverTargetAudit).values({
314 targetId: input.targetId ?? null,
315 actorId: input.actorId ?? null,
316 action: input.action,
317 detail: input.detail ?? null,
318 ip: input.ip ?? null,
319 });
320 } catch {
321 /* never fail the caller because of audit */
322 }
323}
324
325export async function findTargetsForPush(input: {
326 repositoryId: string;
327 branch: string;
328}): Promise<ServerTarget[]> {
329 return db
330 .select()
331 .from(serverTargets)
332 .where(
333 and(
334 eq(serverTargets.watchedRepositoryId, input.repositoryId),
335 eq(serverTargets.watchedBranch, input.branch)
336 )
337 );
338}