Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

**Key Features**:
- **Flexible Project Creation**: Import from GitHub repositories or create new projects from scratch
- **Optional Database**: Add PostgreSQL database on-demand when needed
- **AI Agent Ecosystem**: AI agents handle development, testing, deployment, and infrastructure management
- **Automated Operations**: Deployment, scaling, and infrastructure management happen automatically in the background
- **Full-Stack Development**: Complete environment with database, terminal, and file management
- **Full-Stack Development**: Complete environment with optional database, terminal, and file management
- **Zero Infrastructure Knowledge Required**: Users don't need to understand Kubernetes, networking, or DevOps

**Architecture**: The platform uses an **asynchronous reconciliation pattern** where API endpoints return immediately and background jobs sync desired state (database) with actual state (Kubernetes) every 3 seconds.
Expand Down Expand Up @@ -158,6 +159,10 @@ npx prisma db push # Push schema to database
- `lib/k8s/k8s-service-helper.ts` - User-specific K8s service
- `lib/events/sandbox/sandboxListener.ts` - Sandbox lifecycle handlers
- `lib/jobs/sandbox/sandboxReconcile.ts` - Sandbox reconciliation job
- `lib/events/database/databaseListener.ts` - Database lifecycle handlers
- `lib/jobs/database/databaseReconcile.ts` - Database reconciliation job
- `lib/actions/project.ts` - Project creation (creates Sandbox only)
- `lib/actions/database.ts` - Database creation/deletion (on-demand)
- `prisma/schema.prisma` - Database schema
- `instrumentation.ts` - Application startup

Expand All @@ -181,10 +186,11 @@ npx prisma db push # Push schema to database

## Important Notes

- **Project Resources**: Each project includes a Sandbox (required) and can optionally have a Database (PostgreSQL). Database can be added on-demand after project creation.
- **Reconciliation Delay**: Status updates may take up to 3 seconds
- **User-Specific Namespaces**: Each user operates in their own K8s namespace
- **Frontend Polling**: Client components poll every 3 seconds for status updates
- **Database Wait Time**: PostgreSQL cluster takes 2-3 minutes to reach "Running"
- **Database Wait Time**: PostgreSQL cluster takes 2-3 minutes to reach "Running" (when added)
- **Idempotent Operations**: All K8s methods can be called multiple times safely
- **Lock Duration**: Optimistic locks held for 30 seconds
- **Deployment Domain**: Main app listens on `0.0.0.0:3000` (not localhost) for Sealos
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client'

import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'

import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { createDatabase } from '@/lib/actions/database'

interface AddDatabaseCardProps {
projectId: string
projectName: string
}

export function AddDatabaseCard({ projectId }: AddDatabaseCardProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()

const handleCreateDatabase = () => {
startTransition(async () => {
const result = await createDatabase(projectId)

if (!result.success) {
toast.error(result.error || 'Failed to create database')
return
}

toast.success('Database is being created...')
router.refresh()
})
}

return (
<Card>
<CardHeader>
<CardTitle>No Database</CardTitle>
<CardDescription>
This project doesn&apos;t have a database yet. Add a PostgreSQL database to get started.
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={handleCreateDatabase}
disabled={isPending}
className="w-full"
>
{isPending ? 'Creating Database...' : 'Add PostgreSQL Database'}
</Button>
<p className="text-xs text-muted-foreground mt-4">
A PostgreSQL cluster will be created with 1Gi storage, 100m CPU, and 128Mi memory.
</p>
</CardContent>
</Card>
)
}
17 changes: 14 additions & 3 deletions app/(dashboard)/projects/[id]/database/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getProject } from '@/lib/data/project';

import { SettingsLayout } from '../_components/settings-layout';

import { AddDatabaseCard } from './_components/add-database-card';
import { ConnectionString } from './_components/connection-string';
import { FeatureCards } from './_components/feature-cards';
import { ReadOnlyField } from './_components/read-only-field';
Expand All @@ -28,7 +29,17 @@ export default async function DatabasePage({ params }: { params: Promise<{ id: s
if (!project) notFound();

const database = project.databases[0];
const connectionString = database?.connectionUrl || '';

// If no database exists, show "Add Database" card
if (!database) {
return (
<SettingsLayout title="Database Information" description="Add a PostgreSQL database to your project">
<AddDatabaseCard projectId={project.id} projectName={project.name} />
</SettingsLayout>
);
}

const connectionString = database.connectionUrl || '';
const connectionInfo = parseConnectionUrl(connectionString) || {
host: '', port: '', database: '', username: '', password: ''
};
Expand Down Expand Up @@ -57,9 +68,9 @@ export default async function DatabasePage({ params }: { params: Promise<{ id: s
</>
) : (
<div className="py-12 text-center">
<p className="text-sm text-muted-foreground">No database configured</p>
<p className="text-sm text-muted-foreground">Database is being created...</p>
<p className="text-xs text-muted-foreground mt-1">
Database will be automatically provisioned when sandbox is created
Connection details will appear once the database is ready
</p>
</div>
)}
Expand Down
30 changes: 5 additions & 25 deletions app/api/projects/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,9 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth
const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username
const fileBrowserPassword = generateRandomString(16) // 16 char random password
const databaseName = `${k8sProjectName}-${randomSuffix}`
const sandboxName = `${k8sProjectName}-${randomSuffix}`

// Create project with database and sandbox in a transaction
// Create project with sandbox in a transaction
const result = await prisma.$transaction(async (tx) => {
// 1. Create Project with status CREATING
const project = await tx.project.create({
Expand All @@ -211,25 +210,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
},
})

// 2. Create Database record - lockedUntil is null so reconcile job can process immediately
const database = await tx.database.create({
data: {
projectId: project.id,
name: databaseName,
k8sNamespace: namespace,
databaseName: databaseName,
status: 'CREATING',
lockedUntil: null, // Unlocked - ready for reconcile job to process
// Resource configuration from versions
storageSize: VERSIONS.STORAGE.DATABASE_SIZE,
cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu,
cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu,
memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory,
memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory,
},
})

// 3. Create Sandbox record - lockedUntil is null so reconcile job can process immediately
// 2. Create Sandbox record - lockedUntil is null so reconcile job can process immediately
const sandbox = await tx.sandbox.create({
data: {
projectId: project.id,
Expand All @@ -247,7 +228,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
},
})

// 4. Create Environment record for ttyd access token
// 3. Create Environment record for ttyd access token
const ttydEnv = await tx.environment.create({
data: {
projectId: project.id,
Expand All @@ -258,7 +239,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
},
})

// 5. Create Environment records for filebrowser credentials
// 4. Create Environment records for filebrowser credentials
const fileBrowserUsernameEnv = await tx.environment.create({
data: {
projectId: project.id,
Expand All @@ -281,7 +262,6 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)

return {
project,
database,
sandbox,
ttydEnv,
fileBrowserUsernameEnv,
Expand All @@ -292,7 +272,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
})

logger.info(
`Project created: ${result.project.id} with database: ${result.database.id}, sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}`
`Project created: ${result.project.id} with sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}`
)

return NextResponse.json(result.project)
Expand Down
16 changes: 11 additions & 5 deletions components/layout/status-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export function StatusBar({ projectId }: StatusBarProps) {
const { data: project } = useProject(projectId);

const database = project?.databases?.[0];
const dbStatus = database?.status || 'CREATING';
const sandbox = project?.sandboxes?.[0];
const sbStatus = sandbox?.status || 'CREATING';

Expand All @@ -35,10 +34,17 @@ export function StatusBar({ projectId }: StatusBarProps) {
<span>Sandbox: {sbStatus}</span>
</div>
<div className="w-px h-3 bg-card-foreground/60 mx-1" />
<div className="flex items-center gap-1.5 px-1 rounded cursor-pointer transition-colors">
<div className={`w-2 h-2 rounded-full shadow-[0_0_1px_0.5px_currentColor] ${getStatusBgColor(dbStatus)}`} />
<span>Database: {dbStatus}</span>
</div>
{database ? (
<div className="flex items-center gap-1.5 px-1 rounded cursor-pointer transition-colors">
<div className={`w-2 h-2 rounded-full shadow-[0_0_1px_0.5px_currentColor] ${getStatusBgColor(database.status)}`} />
<span>Database: {database.status}</span>
</div>
) : (
<div className="flex items-center gap-1.5 px-1 rounded cursor-pointer transition-colors opacity-60">
<div className="w-2 h-2 rounded-full bg-muted-foreground/40" />
<span>Database: Not Configured</span>
</div>
)}
</div>
</div>
);
Expand Down
137 changes: 137 additions & 0 deletions lib/actions/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use server'

/**
* Database Server Actions
*
* Server Actions for database operations. Frontend components call these
* to create and delete databases on-demand.
*/

import type { Database } from '@prisma/client'

import { auth } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils'
import { VERSIONS } from '@/lib/k8s/versions'
import { logger as baseLogger } from '@/lib/logger'

import type { ActionResult } from './types'

const logger = baseLogger.child({ module: 'actions/database' })

/**
* Create a database for an existing project
*
* @param projectId - Project ID
* @param databaseName - Optional custom database name (auto-generated if not provided)
*/
export async function createDatabase(
projectId: string,
databaseName?: string
): Promise<ActionResult<Database>> {
const session = await auth()
if (!session) {
return { success: false, error: 'Unauthorized' }
}

// Verify project exists and belongs to user
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { databases: true },
})

if (!project) {
return { success: false, error: 'Project not found' }
}

if (project.userId !== session.user.id) {
return { success: false, error: 'Unauthorized' }
}

// Check if database already exists
if (project.databases.length > 0) {
return { success: false, error: 'Database already exists for this project' }
}

// Get K8s service for user
let k8sService
let namespace
try {
k8sService = await getK8sServiceForUser(session.user.id)
namespace = k8sService.getDefaultNamespace()
} catch (error) {
if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) {
return {
success: false,
error: 'Please configure your kubeconfig before creating a database',
}
}
throw error
}

// Generate database name if not provided
const k8sProjectName = KubernetesUtils.toK8sProjectName(project.name)
const randomSuffix = KubernetesUtils.generateRandomString()
const finalDatabaseName = databaseName || `${k8sProjectName}-db-${randomSuffix}`

// Create Database record
const database = await prisma.database.create({
data: {
projectId: project.id,
name: finalDatabaseName,
k8sNamespace: namespace,
databaseName: finalDatabaseName,
status: 'CREATING',
lockedUntil: null,
storageSize: VERSIONS.STORAGE.DATABASE_SIZE,
cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu,
cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu,
memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory,
memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory,
},
})

logger.info(`Database created: ${database.id} for project: ${project.id}`)

return { success: true, data: database }
}

/**
* Delete a database
*
* @param databaseId - Database ID
*/
export async function deleteDatabase(databaseId: string): Promise<ActionResult<void>> {
const session = await auth()
if (!session) {
return { success: false, error: 'Unauthorized' }
}

// Verify database exists and belongs to user
const database = await prisma.database.findUnique({
where: { id: databaseId },
include: { project: true },
})

if (!database) {
return { success: false, error: 'Database not found' }
}

if (database.project.userId !== session.user.id) {
return { success: false, error: 'Unauthorized' }
}

// Update status to TERMINATING (reconciliation job will handle K8s deletion)
await prisma.database.update({
where: { id: databaseId },
data: {
status: 'TERMINATING',
lockedUntil: null, // Unlock for reconciliation job
},
})

logger.info(`Database ${databaseId} marked for deletion`)

return { success: true, data: undefined }
}
Loading