Skip to content

Commit 5c7c344

Browse files
Merge pull request #5 from PurdueRCAC/db-abstraction
Move database operations to a separate module
2 parents 5d608ed + b2d74c4 commit 5c7c344

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+2847
-2549
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
Warnings:
3+
4+
- The values [STOPPED] on the enum `DeploymentStatus` will be removed. If these variants are still used in the database, this will fail.
5+
- The values [SYSTEM] on the enum `LogType` will be removed. If these variants are still used in the database, this will fail.
6+
- You are about to drop the column `builderJobId` on the `Deployment` table. All the data in the column will be lost.
7+
8+
*/
9+
10+
-- Set deployments with a status of STOPPED to COMPLETE
11+
UPDATE "Deployment" SET "status" = 'COMPLETE' WHERE "status" = 'STOPPED';
12+
13+
-- AlterEnum
14+
BEGIN;
15+
CREATE TYPE "DeploymentStatus_new" AS ENUM ('QUEUED', 'PENDING', 'BUILDING', 'DEPLOYING', 'COMPLETE', 'ERROR', 'CANCELLED');
16+
ALTER TABLE "Deployment" ALTER COLUMN "status" DROP DEFAULT;
17+
ALTER TABLE "Deployment" ALTER COLUMN "status" TYPE "DeploymentStatus_new" USING ("status"::text::"DeploymentStatus_new");
18+
ALTER TYPE "DeploymentStatus" RENAME TO "DeploymentStatus_old";
19+
ALTER TYPE "DeploymentStatus_new" RENAME TO "DeploymentStatus";
20+
DROP TYPE "DeploymentStatus_old";
21+
ALTER TABLE "Deployment" ALTER COLUMN "status" SET DEFAULT 'QUEUED';
22+
COMMIT;
23+
24+
-- Set logs with a status of SYSTEM to BUILD
25+
UPDATE "Log" SET "type" = 'BUILD' WHERE "type" = 'SYSTEM';
26+
27+
-- AlterEnum
28+
BEGIN;
29+
CREATE TYPE "LogType_new" AS ENUM ('BUILD', 'RUNTIME');
30+
ALTER TABLE "Log" ALTER COLUMN "type" TYPE "LogType_new" USING ("type"::text::"LogType_new");
31+
ALTER TYPE "LogType" RENAME TO "LogType_old";
32+
ALTER TYPE "LogType_new" RENAME TO "LogType";
33+
DROP TYPE "LogType_old";
34+
COMMIT;
35+
36+
-- AlterTable
37+
ALTER TABLE "Deployment" DROP COLUMN "builderJobId",
38+
ALTER COLUMN "status" SET DEFAULT 'QUEUED';

backend/prisma/schema.prisma

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,11 @@ model Deployment {
186186
createdAt DateTime @default(now())
187187
updatedAt DateTime @updatedAt
188188
commitMessage String?
189-
builderJobId String?
190189
checkRunId Int?
191190
192191
// Used to update a deployment when the correct workflow run finishes
193192
workflowRunId Int? @unique
194-
status DeploymentStatus @default(PENDING)
193+
status DeploymentStatus @default(QUEUED)
195194
// A random value used by the builder (in place of the deployment ID) to make changes to this Deployment.
196195
// API clients should only be able to update deployments if they have the deployment's secret or some other form of authorization.
197196
secret String? @unique
@@ -290,8 +289,6 @@ enum LogStream {
290289
}
291290

292291
enum LogType {
293-
// System logs created by the AnvilOps backend
294-
SYSTEM
295292
// Logs from users' image builds
296293
BUILD
297294
// Logs from users' pods
@@ -311,8 +308,8 @@ enum DeploymentStatus {
311308
COMPLETE
312309
// There was a problem somewhere during the process
313310
ERROR
314-
// Deployment is no longer active
315-
STOPPED
311+
// The deployment was building or deploying, but it was cancelled in favor of another deployment
312+
CANCELLED
316313
}
317314

318315
model Session {

backend/prisma/types.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
import { Prisma } from "../src/generated/prisma/client.ts";
2-
import { db } from "../src/lib/db.ts";
3-
4-
const Resources = ["cpu", "memory"] as const;
51
type BaseResources = {
62
cpu?: string;
73
memory?: string;
84
};
5+
96
declare global {
107
namespace PrismaJson {
118
type EnvVar = {
@@ -25,11 +22,4 @@ declare global {
2522
isPreviewing: boolean;
2623
};
2724
}
28-
type ExtendedDeploymentConfig = Prisma.Result<
29-
typeof db.deploymentConfig,
30-
{},
31-
"findFirst"
32-
>;
3325
}
34-
35-
export {};

backend/src/db/crypto.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import crypto, { createCipheriv, createDecipheriv } from "node:crypto";
2+
import { env } from "../lib/env.ts";
3+
4+
const masterKey = Buffer.from(env.FIELD_ENCRYPTION_KEY, "base64");
5+
const separator = "|";
6+
7+
const unwrapKey = (wrapped: string): Buffer => {
8+
const iv = Buffer.alloc(8, 0xa6); // Recommended default initial value
9+
const decipher = createDecipheriv("aes256-wrap", masterKey, iv);
10+
return Buffer.concat([decipher.update(wrapped, "base64"), decipher.final()]);
11+
};
12+
13+
const wrapKey = (key: Buffer): string => {
14+
const iv = Buffer.alloc(8, 0xa6);
15+
const cipher = createCipheriv("aes256-wrap", masterKey, iv);
16+
return cipher.update(key, undefined, "base64") + cipher.final("base64");
17+
};
18+
19+
const encrypt = (secret: string, key: Buffer): string => {
20+
const iv = crypto.randomBytes(12);
21+
const cipher = createCipheriv("aes-256-gcm", key, iv);
22+
const res = Buffer.concat([cipher.update(secret), cipher.final()]);
23+
const authTag = cipher.getAuthTag();
24+
return [authTag, iv, res]
25+
.map((buf) => buf.toString("base64"))
26+
.join(separator);
27+
};
28+
29+
const decrypt = (ctxtFull: string, key: Buffer): string => {
30+
const [authTagEncoded, ivEncoded, ctxtEncoded] = ctxtFull.split(separator);
31+
const iv = Buffer.from(ivEncoded, "base64");
32+
const ctxt = Buffer.from(ctxtEncoded, "base64");
33+
const authTag = Buffer.from(authTagEncoded, "base64");
34+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
35+
decipher.setAuthTag(authTag);
36+
37+
return decipher.update(ctxt, undefined, "utf8") + decipher.final("utf8");
38+
};
39+
40+
const genKey = (): Buffer => {
41+
return crypto.randomBytes(32);
42+
};
43+
44+
export const encryptEnv = (plaintext: PrismaJson.EnvVar[], key: string) => {
45+
const unwrapped = unwrapKey(key);
46+
return plaintext.map((envVar) => ({
47+
...envVar,
48+
value: encrypt(envVar.value, unwrapped),
49+
}));
50+
};
51+
52+
export const decryptEnv = (ciphertext: PrismaJson.EnvVar[], key: string) => {
53+
const unwrapped = unwrapKey(key);
54+
return ciphertext.map((envVar) => ({
55+
...envVar,
56+
value: decrypt(envVar.value, unwrapped),
57+
}));
58+
};
59+
60+
export const generateKey = () => wrapKey(genKey());

backend/src/db/index.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { PrismaPg } from "@prisma/adapter-pg";
2+
import type { DefaultArgs } from "@prisma/client/runtime/client";
3+
import connectPgSimple from "connect-pg-simple";
4+
import session from "express-session";
5+
import { Pool, type Notification } from "pg";
6+
import "../../prisma/types.ts";
7+
import { PrismaClient } from "../generated/prisma/client.ts";
8+
import { env } from "../lib/env.ts";
9+
import { AppRepo } from "./repo/app.ts";
10+
import { AppGroupRepo } from "./repo/appGroup.ts";
11+
import { CacheRepo } from "./repo/cache.ts";
12+
import { DeploymentRepo } from "./repo/deployment.ts";
13+
import { InvitationRepo } from "./repo/invitation.ts";
14+
import { OrganizationRepo } from "./repo/organization.ts";
15+
import { RepoImportStateRepo } from "./repo/repoImportState.ts";
16+
import { UserRepo } from "./repo/user.ts";
17+
18+
export class NotFoundError extends Error {}
19+
export class ConflictError extends Error {}
20+
21+
export type PrismaClientType = PrismaClient<
22+
{ adapter: PrismaPg; omit: { deployment: { secret: true } } },
23+
never,
24+
DefaultArgs
25+
>;
26+
27+
/**
28+
* A Postgres database implementation
29+
*/
30+
export class Database {
31+
private DATABASE_URL =
32+
env.DATABASE_URL ??
33+
`postgresql://${env.POSTGRES_USER}:${env.POSTGRES_PASSWORD}@${env.POSTGRES_HOSTNAME}/${env.POSTGRES_DB}`;
34+
35+
private pool = new Pool({
36+
connectionString: this.DATABASE_URL,
37+
connectionTimeoutMillis: 5000,
38+
});
39+
40+
private prismaPostgresAdapter = new PrismaPg({
41+
connectionString: this.DATABASE_URL,
42+
});
43+
44+
private client: PrismaClientType = new PrismaClient({
45+
adapter: this.prismaPostgresAdapter,
46+
omit: {
47+
deployment: {
48+
secret: true,
49+
},
50+
},
51+
});
52+
53+
app = new AppRepo(this.client);
54+
55+
appGroup = new AppGroupRepo(this.client);
56+
57+
cache = new CacheRepo(this.client);
58+
59+
deployment = new DeploymentRepo(this.client, this.publish.bind(this));
60+
61+
invitation = new InvitationRepo(this.client);
62+
63+
org = new OrganizationRepo(this.client);
64+
65+
repoImportState = new RepoImportStateRepo(this.client);
66+
67+
user = new UserRepo(this.client);
68+
69+
sessionStore = new (connectPgSimple(session))({
70+
conString: this.DATABASE_URL,
71+
});
72+
73+
/**
74+
* Subscribes to the given channel and runs the callback when a message is received on that channel.
75+
*
76+
* @returns A cleanup function to remove the listener
77+
*/
78+
async subscribe(channel: string, callback: (msg: Notification) => void) {
79+
const conn = await this.pool.connect();
80+
if (!channel.match(/^[a-zA-Z0-9_]+$/g)) {
81+
// Sanitize against potential SQL injection. Postgres unfortunately doesn't provide a way to parameterize the
82+
// channel name for LISTEN and UNLISTEN, so we validate that the channel name is a valid SQL identifier here.
83+
throw new Error(
84+
"Invalid channel name: '" +
85+
channel +
86+
"'. Expected only letters, numbers, and underscores.",
87+
);
88+
}
89+
90+
const listener = (msg: Notification) => {
91+
if (msg.channel === channel) {
92+
callback(msg);
93+
}
94+
};
95+
conn.on("notification", listener);
96+
97+
await conn.query(`LISTEN "${channel}"`);
98+
99+
return async () => {
100+
await conn.query(`UNLISTEN "${channel}"`);
101+
conn.off("notification", listener);
102+
conn.release();
103+
};
104+
}
105+
106+
/**
107+
* Publishes a message on the given channel.
108+
* @see {subscribe}
109+
* @param channel The channel to publish on
110+
* @param payload The message to publish
111+
*/
112+
async publish(channel: string, payload: string) {
113+
await this.pool.query("SELECT pg_notify($1, $2);", [channel, payload]);
114+
}
115+
}
116+
117+
export const db = new Database();

0 commit comments

Comments
 (0)