Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: store deployed databases info in Supabase DB #31

Merged
merged 47 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
76db8f0
wip
jgoux Aug 8, 2024
b64d4df
store credentials in the database and fetch them in db-service
jgoux Aug 8, 2024
db62b5f
read-only works
jgoux Aug 8, 2024
dbae56c
set pg-gateway
jgoux Aug 9, 2024
e1a8f95
Merge branch 'feat/upload-db' into feat/deploy-db-to-supabase
jgoux Aug 9, 2024
966af04
group components
jgoux Aug 9, 2024
53609bc
deps
jgoux Aug 9, 2024
5ec5878
bump deps
jgoux Aug 9, 2024
5cc4359
psql connection string is on the frontend
jgoux Aug 9, 2024
902b0cf
make copy somewhat working
jgoux Aug 9, 2024
5daac37
wip
jgoux Aug 12, 2024
f1a9a28
Merge branch 'feat/upload-db' into feat/deploy-db-to-supabase
jgoux Aug 12, 2024
f0190f3
fix a few bugs
jgoux Aug 12, 2024
8c24644
split sidebar into components
jgoux Aug 12, 2024
cc35656
Merge branch 'feat/upload-db' into feat/deploy-db-to-supabase
jgoux Aug 13, 2024
7eff3f1
handle conflicts
jgoux Aug 13, 2024
f646608
fix conflicts
jgoux Aug 13, 2024
50c33c7
dialog doesn't need to be controlled
jgoux Aug 13, 2024
24eb981
rename database -> local database
jgoux Aug 13, 2024
fc9ee58
database -> local database
jgoux Aug 13, 2024
5485d99
fix copy button
jgoux Aug 13, 2024
14455e4
ui tweaks
jgoux Aug 13, 2024
c6f57d5
more tweaks
jgoux Aug 13, 2024
72d6cbc
deployment is working
jgoux Aug 13, 2024
916dd07
deployed database fields
jgoux Aug 13, 2024
2900f8e
encode password
jgoux Aug 13, 2024
7e09415
deployed fields
jgoux Aug 13, 2024
2f472ed
remove log
jgoux Aug 13, 2024
f766a3b
reset password route
jgoux Aug 13, 2024
d97aba9
reset password
jgoux Aug 13, 2024
f3451e9
database deletion
jgoux Aug 13, 2024
1b4887a
delete confirmation dialog
jgoux Aug 14, 2024
2bac2bd
redeploy confirmation
jgoux Aug 14, 2024
b560860
validate dump size client side
jgoux Aug 14, 2024
cbfa62e
validate dump size server-side
jgoux Aug 14, 2024
92735e5
Merge branch 'feat/upload-db' into feat/deploy-db-to-supabase
jgoux Aug 14, 2024
0bc7ee4
isolate database type and harmonize packages
jgoux Aug 14, 2024
64c33ad
add type check to proxy
jgoux Aug 14, 2024
eff29dd
remove server version so it's dynamically generated
jgoux Aug 14, 2024
dbd3591
don't print internal error
jgoux Aug 14, 2024
462f44e
hide port in the database url as it's the default
jgoux Aug 14, 2024
5b925b0
rely on onTlsUpgrade only for TLS validation
jgoux Aug 14, 2024
01080f6
the build is alive
jgoux Aug 14, 2024
161c98f
put supabase config back in the root
jgoux Aug 14, 2024
d8683b4
rename
jgoux Aug 14, 2024
bed20a6
dynamic server version
jgoux Aug 14, 2024
7332000
add PGlite version
jgoux Aug 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
{
"deno.enablePaths": ["supabase/functions"],
"deno.lint": true,
"deno.unstable": true
"deno.unstable": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
jgoux marked this conversation as resolved.
Show resolved Hide resolved
}
5 changes: 3 additions & 2 deletions apps/db-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
"psql": "docker compose run --rm -i psql psql"
},
"dependencies": {
"@electric-sql/pglite": "0.2.0-alpha.9",
"pg-gateway": "^0.2.5-alpha.2",
"@electric-sql/pglite": "0.2.0",
"@supabase/supabase-js": "^2.45.1",
"pg-gateway": "0.3.0-alpha.4",
"tar": "^7.4.3"
},
"devDependencies": {
Expand Down
127 changes: 98 additions & 29 deletions apps/db-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { createReadStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
import { createGunzip } from 'node:zlib'
import { extract } from 'tar'
import { hashMd5Password, PostgresConnection, TlsOptions } from 'pg-gateway'
import { PostgresConnection, ScramSha256Data, TlsOptions } from 'pg-gateway'
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321'
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? ''
const dataMount = process.env.DATA_MOUNT ?? './data'
const s3fsMount = process.env.S3FS_MOUNT ?? './s3'
const wildcardDomain = process.env.WILDCARD_DOMAIN ?? 'db.example.com'
Expand All @@ -32,30 +35,82 @@ function getIdFromServerName(serverName: string) {
return id
}

const PostgresErrorCodes = {
ConnectionException: '08000',
} as const
jgoux marked this conversation as resolved.
Show resolved Hide resolved

function sendFatalError(connection: PostgresConnection, code: string, message: string): never {
connection.sendError({
severity: 'FATAL',
code,
message,
})
connection.socket.end()
throw new Error(message)
}

async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
await access(path)
return true
} catch {
return false;
return false
}
}

const supabase = createClient(supabaseUrl, supabaseKey)
jgoux marked this conversation as resolved.
Show resolved Hide resolved

const server = net.createServer((socket) => {
let db: PGliteInterface

const connection = new PostgresConnection(socket, {
serverVersion: '16.3 (PGlite 0.2.0)',
jgoux marked this conversation as resolved.
Show resolved Hide resolved
authMode: 'md5Password',
tls,
async validateCredentials(credentials) {
if (credentials.authMode === 'md5Password') {
const { hash, salt } = credentials
const expectedHash = await hashMd5Password('postgres', 'postgres', salt)
return hash === expectedHash
}
return false
auth: {
method: 'scram-sha-256',
async getScramSha256Data(_, { tlsInfo }) {
if (!tlsInfo?.sniServerName) {
sendFatalError(
connection,
PostgresErrorCodes.ConnectionException,
'sniServerName required in TLS info'
)
jgoux marked this conversation as resolved.
Show resolved Hide resolved
}

const databaseId = getIdFromServerName(tlsInfo.sniServerName)
const { data, error } = await supabase
.from('deployed_databases')
.select('auth_method, auth_data')
.eq('database_id', databaseId)
.single()

if (error) {
sendFatalError(
connection,
PostgresErrorCodes.ConnectionException,
`Error getting auth data for database ${databaseId}: ${error}`
jgoux marked this conversation as resolved.
Show resolved Hide resolved
)
}

if (data === null) {
sendFatalError(
connection,
PostgresErrorCodes.ConnectionException,
`Database ${databaseId} not found`
)
}

if (data.auth_method !== 'scram-sha-256') {
sendFatalError(
connection,
PostgresErrorCodes.ConnectionException,
`Unsupported auth method for database ${databaseId}: ${data.auth_method}`
)
}

return data.auth_data as ScramSha256Data
jgoux marked this conversation as resolved.
Show resolved Hide resolved
},
},
tls,
async onTlsUpgrade({ tlsInfo }) {
if (!tlsInfo) {
connection.sendError({
Expand Down Expand Up @@ -91,12 +146,12 @@ const server = net.createServer((socket) => {

console.log(`Serving database '${databaseId}'`)

const dbPath = `${dbDir}/${databaseId}`;
const dbPath = `${dbDir}/${databaseId}`

if (!(await fileExists(dbPath))) {
console.log(`Database '${databaseId}' is not cached, downloading...`)

const dumpPath = `${dumpDir}/${databaseId}.tar.gz`;
const dumpPath = `${dumpDir}/${databaseId}.tar.gz`

if (!(await fileExists(dumpPath))) {
connection.sendError({
Expand All @@ -109,33 +164,47 @@ const server = net.createServer((socket) => {
}

// Create a directory for the database
await mkdir(dbPath, { recursive: true });
await mkdir(dbPath, { recursive: true })

try {
// Extract the .tar.gz file
await pipeline(
createReadStream(dumpPath),
createGunzip(),
extract({ cwd: dbPath })
);
await pipeline(createReadStream(dumpPath), createGunzip(), extract({ cwd: dbPath }))
} catch (error) {
console.error(error);
await rm(dbPath, { recursive: true, force: true }); // Clean up the partially created directory
console.error(error)
await rm(dbPath, { recursive: true, force: true }) // Clean up the partially created directory
connection.sendError({
severity: 'FATAL',
code: 'XX000',
message: `Error extracting database: ${(error as Error).message}`,
jgoux marked this conversation as resolved.
Show resolved Hide resolved
});
connection.socket.end();
return;
})
connection.socket.end()
return
}
}

db = new PGlite(dbPath, {
db = new PGlite({
dataDir: dbPath,
extensions: {
vector,
},
})
await db.waitReady
const { rows } = await db.query("SELECT 1 FROM pg_roles WHERE rolname = 'readonly_postgres';")
if (rows.length === 0) {
await db.exec(`
CREATE USER readonly_postgres;
GRANT pg_read_all_data TO readonly_postgres;
`)
}
await db.close()
db = new PGlite({
dataDir: dbPath,
username: 'readonly_postgres',
extensions: {
vector,
},
})
await db.waitReady
jgoux marked this conversation as resolved.
Show resolved Hide resolved
},
async onStartup() {
if (!db) {
Expand Down Expand Up @@ -170,12 +239,12 @@ const server = net.createServer((socket) => {
},
})

socket.on('end', async () => {
socket.on('close', async () => {
console.log('Client disconnected')
await db?.close()
})
})

server.listen(5432, async () => {
console.log('Server listening on port 5432')
})
})
3 changes: 2 additions & 1 deletion apps/postgres-new/.env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
NEXT_PUBLIC_SUPABASE_ANON_KEY="<supabase-anon-key>"
NEXT_PUBLIC_SUPABASE_URL="<supabase-api-url>"
NEXT_PUBLIC_IS_PREVIEW=true
NEXT_PUBLIC_WILDCARD_DOMAIN=db.example.com

OPENAI_API_KEY="<openai-api-key>"
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=test
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
WILDCARD_DOMAIN=db.example.com

71 changes: 71 additions & 0 deletions apps/postgres-new/app/api/databases/[id]/reset-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '~/utils/supabase/server'
import { createScramSha256Data } from 'pg-gateway'
import { generateDatabasePassword } from '~/utils/generate-database-password'

export type DatabaseResetPasswordResponse =
| {
success: true
data: {
password: string
}
}
| {
success: false
error: string
}

export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
): Promise<NextResponse<DatabaseResetPasswordResponse>> {
const supabase = createClient()

const {
data: { user },
} = await supabase.auth.getUser()

if (!user) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
},
{ status: 401 }
)
}

const databaseId = params.id

const { data: existingDeployedDatabase } = await supabase
.from('deployed_databases')
.select('id')
.eq('database_id', databaseId)
.maybeSingle()

if (!existingDeployedDatabase) {
return NextResponse.json(
{
success: false,
error: `Database ${databaseId} was not found`,
},
{ status: 404 }
)
}

const password = generateDatabasePassword()

await supabase
.from('deployed_databases')
.update({
auth_data: createScramSha256Data(password),
})
.eq('database_id', databaseId)

return NextResponse.json({
success: true,
data: {
password,
},
})
}
71 changes: 71 additions & 0 deletions apps/postgres-new/app/api/databases/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '~/utils/supabase/server'

const s3Client = new S3Client({ endpoint: process.env.S3_ENDPOINT, forcePathStyle: true })

export type DatabaseDeleteResponse =
| {
success: true
}
| {
success: false
error: string
}

export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } }
): Promise<NextResponse<DatabaseDeleteResponse>> {
const supabase = createClient()

const {
data: { user },
} = await supabase.auth.getUser()

if (!user) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
},
{ status: 401 }
)
}

const databaseId = params.id

const { data: existingDeployedDatabase } = await supabase
.from('deployed_databases')
.select('id')
.eq('database_id', databaseId)
.maybeSingle()

if (!existingDeployedDatabase) {
return NextResponse.json(
{
success: false,
error: `Database ${databaseId} was not found`,
},
{ status: 404 }
)
}

await supabase.from('deployed_databases').delete().eq('database_id', databaseId)

const key = `dbs/${databaseId}.tar.gz`
try {
await s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
})
)
} catch (error) {
console.error(`Error deleting S3 object ${key}:`, error)
}

return NextResponse.json({
success: true,
})
}
Loading