From ad9ab2587ff007ef15ce4ecfafa0f62b8c308742 Mon Sep 17 00:00:00 2001 From: sertdev Date: Sun, 22 Mar 2026 16:19:12 +0600 Subject: [PATCH 1/2] feat(runtime): replace t3 editor with code-server --- runtime/.bashrc | 9 --- runtime/.tmux.conf | 15 +++++ runtime/Dockerfile | 39 ++++++----- runtime/entrypoint.sh | 151 ++++++++++++++++++++++++++++++++++-------- runtime/ttyd-auth.sh | 100 ++++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 53 deletions(-) create mode 100644 runtime/.tmux.conf create mode 100644 runtime/ttyd-auth.sh diff --git a/runtime/.bashrc b/runtime/.bashrc index 728d9e2..3e75b70 100644 --- a/runtime/.bashrc +++ b/runtime/.bashrc @@ -51,12 +51,3 @@ PROMPT_COMMAND=_ps1_update # cd "$HOME/next" # fi -# Auto-start Claude Code CLI on first terminal connection only -# Use a file flag that persists across ttyd reconnections -CLAUDE_FLAG_FILE="/tmp/.claude_started" - -if [ ! -f "$CLAUDE_FLAG_FILE" ]; then - touch "$CLAUDE_FLAG_FILE" - echo "🤖 Starting Claude Code CLI..." - claude -fi diff --git a/runtime/.tmux.conf b/runtime/.tmux.conf new file mode 100644 index 0000000..863e32a --- /dev/null +++ b/runtime/.tmux.conf @@ -0,0 +1,15 @@ +# FullstackAgent tmux configuration +# Persistent terminal sessions for web terminal + +# Disable status bar for clean terminal appearance +set -g status off + +# 256 color and true color support +set -g default-terminal "xterm-256color" +set -ga terminal-overrides ",xterm-256color:Tc" + +# Large scrollback buffer +set -g history-limit 50000 + +# Enable mouse support (scroll, click, resize panes) +set -g mouse on diff --git a/runtime/Dockerfile b/runtime/Dockerfile index cafaaaf..ec9812b 100644 --- a/runtime/Dockerfile +++ b/runtime/Dockerfile @@ -20,18 +20,14 @@ LABEL maintainer="FullstackAgent" \ ENV DEBIAN_FRONTEND=noninteractive \ NODE_VERSION=22.x \ NEXT_VERSION=15.5.9 \ - CLAUDE_CODE_VERSION=latest \ + CODE_SERVER_VERSION=4.104.3 \ PATH="/root/.local/bin:/home/fulling/.local/bin:$PATH" \ TERM=xterm-256color \ COLORTERM=truecolor \ LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - ANTHROPIC_BASE_URL="" \ - ANTHROPIC_AUTH_TOKEN="" \ - ANTHROPIC_MODEL="" \ - ANTHROPIC_SMALL_FAST_MODEL="" \ - GEMINI_API_KEY="" \ - OPENAI_API_KEY="" \ + CODEX_API_KEY="" \ + CODEX_BASE_URL="" \ DOCKER_HUB_NAME="" \ DOCKER_HUB_PASSWD="" @@ -81,12 +77,10 @@ RUN set -eux; \ # ----------------------------------------------------------------------------- # Install global npm packages in a single layer -# Includes CLI tools for Next.js, package managers, deployment, and AI assistance +# Includes CLI tools for Next.js, package managers, deployment, and Codex AI assistant # ----------------------------------------------------------------------------- RUN npm install -g \ - @anthropic-ai/claude-code \ - @google/gemini-cli \ - @openai/codex \ + @openai/codex@latest \ create-next-app@${NEXT_VERSION} \ pnpm \ prisma \ @@ -163,7 +157,9 @@ RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ bat \ + bubblewrap \ dnsutils \ + dtach \ fd-find \ gh \ htop \ @@ -206,11 +202,20 @@ WORKDIR /home/fulling/next # Copy configuration files (placed before user switch for better caching) # entrypoint.sh: Container startup script # ttyd-startup.sh: Startup script for ttyd (session tracking, welcome message) -# .bashrc: Shell configuration with custom prompt and Claude CLI auto-start +# .bashrc: Shell configuration with custom prompt # ----------------------------------------------------------------------------- -COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh -COPY --chmod=755 ttyd-startup.sh /usr/local/bin/ttyd-startup.sh -COPY --chmod=644 .bashrc /etc/skel/.bashrc +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY ttyd-auth.sh /usr/local/bin/ttyd-auth.sh +COPY .bashrc /etc/skel/.bashrc +COPY .tmux.conf /etc/skel/.tmux.conf +RUN chmod 755 /usr/local/bin/entrypoint.sh /usr/local/bin/ttyd-auth.sh && chmod 644 /etc/skel/.bashrc /etc/skel/.tmux.conf + +# ----------------------------------------------------------------------------- +# Install code-server for the embedded editor +# ----------------------------------------------------------------------------- +RUN set -eux; \ + curl -fsSL https://code-server.dev/install.sh | sh -s -- --version "${CODE_SERVER_VERSION}"; \ + code-server --version # ============================================================================= # Stage 2: Next.js project template preparation @@ -301,7 +306,7 @@ RUN set -eux; \ # 5432: PostgreSQL client connections # 7681: ttyd web terminal # ----------------------------------------------------------------------------- -EXPOSE 3000 3001 5000 5173 8080 8000 5432 7681 +EXPOSE 3000 3001 3773 5000 5173 8080 8000 5432 7681 # ----------------------------------------------------------------------------- # Health check configuration @@ -316,6 +321,6 @@ HEALTHCHECK --interval=2m --timeout=30s --start-period=1m --retries=3 \ # ----------------------------------------------------------------------------- # Container entrypoint # Starts ttyd web terminal which provides browser-based shell access -# The .bashrc will auto-start Claude Code CLI on first connection +# Codex configuration is written from environment variables at startup # ----------------------------------------------------------------------------- CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/runtime/entrypoint.sh b/runtime/entrypoint.sh index adea22b..4f30767 100755 --- a/runtime/entrypoint.sh +++ b/runtime/entrypoint.sh @@ -1,39 +1,110 @@ #!/bin/bash # ============================================================================= -# ttyd Entrypoint Script +# Sandbox Entrypoint Script # ============================================================================= # -# Starts ttyd web terminal with HTTP Basic Auth enabled. +# Sets up Codex configuration, starts code-server, and starts ttyd web terminal. # # Authentication Flow: -# 1. ttyd validates credentials at HTTP/WebSocket layer via -c parameter -# 2. URL format: ?authorization=base64(user:password)&arg=SESSION_ID -# 3. ttyd-startup.sh handles session tracking (not auth) for file upload cwd detection +# ttyd-auth.sh receives the access token via ?arg=TOKEN and validates it +# against the TTYD_ACCESS_TOKEN environment variable. # # Required Environment Variables: -# TTYD_ACCESS_TOKEN - Password for HTTP Basic Auth (username is 'user') +# TTYD_ACCESS_TOKEN - Access token for terminal authentication # -# Optional URL Parameters (via -a flag): -# arg=SESSION_ID - Terminal session ID for file upload directory tracking +# Optional Environment Variables: +# CODEX_API_KEY - API key for Codex (written to ~/.codex/auth.json) +# CODEX_BASE_URL - LLM API base URL for Codex (written to ~/.codex/config.toml) # # ============================================================================= set -euo pipefail +FULLING_USER="${FULLING_USER:-fulling}" +FULLING_GROUP="${FULLING_GROUP:-fulling}" +FULLING_HOME="${FULLING_HOME:-/home/fulling}" +SKEL_DIR="${SKEL_DIR:-/etc/skel}" +TTYD_AUTH_SCRIPT="${TTYD_AUTH_SCRIPT:-/usr/local/bin/ttyd-auth.sh}" +FULLING_WORKSPACE="${FULLING_WORKSPACE:-$FULLING_HOME/next}" +EDITOR_PASSWORD="${EDITOR_PASSWORD:-${TTYD_ACCESS_TOKEN:-}}" + +maybe_chown() { + if [ "$(id -u)" -eq 0 ]; then + chown "$@" + fi +} + +mkdir -p "$FULLING_HOME" +mkdir -p "$FULLING_WORKSPACE" +export HOME="$FULLING_HOME" +export USER="$FULLING_USER" +export LOGNAME="$FULLING_USER" +export CODEX_HOME="${CODEX_HOME:-$FULLING_HOME/.codex}" + # ----------------------------------------------------------------------------- # Validate required environment variables # ----------------------------------------------------------------------------- -if [ -z "$TTYD_ACCESS_TOKEN" ]; then +if [ -z "${TTYD_ACCESS_TOKEN:-}" ]; then echo "ERROR: TTYD_ACCESS_TOKEN environment variable is not set" echo "This is required for terminal authentication" exit 1 fi # ----------------------------------------------------------------------------- -# Build HTTP Basic Auth credential -# Format: username:password (username is fixed as 'user') +# Copy shell config files from skeleton to PVC-mounted home directory +# On first run the PVC is empty, so .bashrc needs to be copied # ----------------------------------------------------------------------------- -TTYD_CREDENTIAL="user:${TTYD_ACCESS_TOKEN}" +for skelfile in .bashrc .tmux.conf; do + if [ ! -f "$FULLING_HOME/$skelfile" ] && [ -f "$SKEL_DIR/$skelfile" ]; then + cp "$SKEL_DIR/$skelfile" "$FULLING_HOME/$skelfile" + maybe_chown "$FULLING_USER:$FULLING_GROUP" "$FULLING_HOME/$skelfile" + fi +done + +# ----------------------------------------------------------------------------- +# Setup Codex configuration +# Writes ~/.codex/config.toml and ~/.codex/auth.json from environment variables. +# These are regenerated on every container start so settings changes take effect +# after pod restart. +# ----------------------------------------------------------------------------- +CODEX_CONFIG_DIR="$CODEX_HOME" +CODEX_CONFIG_FILE="${CODEX_CONFIG_DIR}/config.toml" +CODEX_AUTH_FILE="${CODEX_CONFIG_DIR}/auth.json" + +# Write config.toml if CODEX_BASE_URL is set +if [ -n "${CODEX_BASE_URL:-}" ]; then + mkdir -p "$CODEX_CONFIG_DIR" + cat > "$CODEX_CONFIG_FILE" << CODEX_CONFIG_EOF +service_tier = "fast" +model_provider = "litellm" + +[model_providers.litellm] +name = "OpenAI" +base_url = "${CODEX_BASE_URL}" +wire_api = "responses" +requires_openai_auth = true +CODEX_CONFIG_EOF + maybe_chown "$FULLING_USER:$FULLING_GROUP" "$CODEX_CONFIG_FILE" + echo "✓ Codex config initialized (base_url: ${CODEX_BASE_URL})" +fi + +# Write auth.json if CODEX_API_KEY is set +if [ -n "${CODEX_API_KEY:-}" ]; then + mkdir -p "$CODEX_CONFIG_DIR" + cat > "$CODEX_AUTH_FILE" << CODEX_AUTH_EOF +{ + "auth_mode": "apikey", + "OPENAI_API_KEY": "${CODEX_API_KEY}" +} +CODEX_AUTH_EOF + maybe_chown "$FULLING_USER:$FULLING_GROUP" "$CODEX_AUTH_FILE" + echo "✓ Codex auth configured" +fi + +# Ensure proper ownership of codex config directory +if [ -d "$CODEX_CONFIG_DIR" ]; then + maybe_chown -R "$FULLING_USER:$FULLING_GROUP" "$CODEX_CONFIG_DIR" 2>/dev/null || true +fi # ----------------------------------------------------------------------------- # Terminal theme configuration @@ -62,34 +133,60 @@ THEME='theme={ }' # ----------------------------------------------------------------------------- -# Verify startup script exists +# Configure and start code-server as a background daemon. +# The editor listens on port 3773 and serves the project workspace. +# ----------------------------------------------------------------------------- +CODE_SERVER_CONFIG_DIR="${FULLING_HOME}/.config/code-server" +CODE_SERVER_CONFIG_FILE="${CODE_SERVER_CONFIG_DIR}/config.yaml" + +mkdir -p "$CODE_SERVER_CONFIG_DIR" +cat > "$CODE_SERVER_CONFIG_FILE" << CODE_SERVER_CONFIG_EOF +bind-addr: 0.0.0.0:3773 +auth: password +password: ${EDITOR_PASSWORD} +cert: false +CODE_SERVER_CONFIG_EOF +maybe_chown -R "$FULLING_USER:$FULLING_GROUP" "$CODE_SERVER_CONFIG_DIR" + +if command -v code-server >/dev/null 2>&1; then + echo "Starting code-server (port 3773)..." + PASSWORD="$EDITOR_PASSWORD" \ + HOME="$HOME" \ + USER="$USER" \ + LOGNAME="$LOGNAME" \ + CODEX_HOME="$CODEX_HOME" \ + nohup code-server "$FULLING_WORKSPACE" > /tmp/code-server.log 2>&1 & + echo "✓ code-server started (PID: $!)" +else + echo "ERROR: code-server is not installed" + exit 1 +fi + +# ----------------------------------------------------------------------------- +# Verify auth script exists # ----------------------------------------------------------------------------- -if [ ! -f /usr/local/bin/ttyd-startup.sh ]; then - echo "ERROR: ttyd-startup.sh not found at /usr/local/bin/ttyd-startup.sh" +if [ ! -f "$TTYD_AUTH_SCRIPT" ]; then + echo "ERROR: ttyd-auth.sh not found at $TTYD_AUTH_SCRIPT" exit 1 fi # ----------------------------------------------------------------------------- -# Start ttyd with authentication +# Start ttyd with token-based authentication # ----------------------------------------------------------------------------- # Parameters: -# -T xterm-256color : Terminal type (widely supported) +# -T xterm-256color : Terminal type # -W : Enable WebSocket compression -# -a : Allow URL arguments (?arg=SESSION_ID) to be passed to command -# -c credential : HTTP Basic Auth (user:password) +# -a : Allow URL arguments (?arg=TOKEN&arg=SESSION_ID) # -t theme : Terminal color theme # -# The command (ttyd-startup.sh) receives URL arguments: -# $1 = SESSION_ID (from ?arg=...) -# -# Authentication happens at HTTP/WebSocket level by ttyd. -# ttyd-startup.sh only handles session tracking for file upload cwd detection. +# ttyd-auth.sh receives: +# $1 = ACCESS_TOKEN (from first ?arg=) +# $2 = SESSION_ID (from second ?arg=) # ----------------------------------------------------------------------------- -echo "Starting ttyd with HTTP Basic Auth..." +echo "Starting ttyd..." exec ttyd \ -T xterm-256color \ -W \ -a \ - -c "$TTYD_CREDENTIAL" \ -t "$THEME" \ - /usr/local/bin/ttyd-startup.sh \ No newline at end of file + "$TTYD_AUTH_SCRIPT" diff --git a/runtime/ttyd-auth.sh b/runtime/ttyd-auth.sh new file mode 100644 index 0000000..520e2b3 --- /dev/null +++ b/runtime/ttyd-auth.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# ttyd authentication wrapper script +# Validates TTYD_ACCESS_TOKEN before granting shell access +# Uses tmux for per-session persistence (survives browser refresh/tab close) +# +# Arguments (passed via URL ?arg=...&arg=...): +# $1 - TTYD_ACCESS_TOKEN (required) +# $2 - TERMINAL_SESSION_ID (optional, for session persistence + file upload CWD) + +# Get the expected token from environment variable +EXPECTED_TOKEN="${TTYD_ACCESS_TOKEN:-}" + +# Check if token is configured +if [ -z "$EXPECTED_TOKEN" ]; then + echo "ERROR: TTYD_ACCESS_TOKEN is not configured" + echo "Please contact your system administrator" + sleep infinity +fi + +# Check if token was provided as argument +if [ "$#" -lt 1 ]; then + echo "ERROR: Authentication failed - no token provided" + sleep infinity +fi + +PROVIDED_TOKEN="$1" + +# Validate token +if [ "$PROVIDED_TOKEN" != "$EXPECTED_TOKEN" ]; then + echo "ERROR: Authentication failed - invalid token" + sleep infinity +fi + +# Authentication successful +echo "✓ Authentication successful" + +# Handle terminal session ID +SESSION_FILE="" +TERMINAL_SESSION_ID="" +TMUX_SESSION_NAME="" +if [ "$#" -ge 2 ] && [ -n "$2" ]; then + # Validate format: only allow alphanumeric, hyphens, and underscores + if [[ ! "$2" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "ERROR: Invalid session ID format" + sleep infinity + fi + TERMINAL_SESSION_ID="$2" + export TERMINAL_SESSION_ID + SESSION_FILE="/tmp/.terminal-session-${TERMINAL_SESSION_ID}" + TMUX_SESSION_NAME="terminal-${TERMINAL_SESSION_ID}" +fi + +write_tmux_pane_pid() { + if [ -z "$SESSION_FILE" ] || [ -z "$TMUX_SESSION_NAME" ]; then + return + fi + + PANE_PID="$(tmux display-message -p -t "$TMUX_SESSION_NAME":0 '#{pane_pid}' 2>/dev/null | tr -d '\r\n')" + if [ -n "$PANE_PID" ]; then + echo "$PANE_PID" > "$SESSION_FILE" + fi +} + +if [ -n "$TERMINAL_SESSION_ID" ]; then + if tmux has-session -t "$TMUX_SESSION_NAME" 2>/dev/null; then + echo "↻ Reconnecting to session..." + write_tmux_pane_pid + exec tmux attach-session -t "$TMUX_SESSION_NAME" + else + echo "" + echo "Welcome to your FullstackAgent Sandbox!" + echo "========================================" + echo "" + echo " codex - Start AI coding assistant" + echo " pnpm install - Install dependencies" + echo " pnpm dev - Start dev server" + echo "" + + tmux new-session -d -s "$TMUX_SESSION_NAME" /bin/bash + sleep 0.2 + write_tmux_pane_pid + exec tmux attach-session -t "$TMUX_SESSION_NAME" + fi +else + # No session ID — plain bash (fallback) + if [ -n "$SESSION_FILE" ]; then + echo "$$" > "$SESSION_FILE" + fi + + echo "" + echo "Welcome to your FullstackAgent Sandbox!" + echo "========================================" + echo "" + echo " codex - Start AI coding assistant" + echo " pnpm install - Install dependencies" + echo " pnpm dev - Start dev server" + echo "" + + exec /bin/bash +fi From d8fe4fae97482cf002ebf93953cc83f74acb2684 Mon Sep 17 00:00:00 2001 From: sertdev Date: Sun, 22 Mar 2026 19:48:51 +0600 Subject: [PATCH 2/2] feat(editor): route sandbox editor to code-server --- app/api/projects/route.ts | 314 +++++++++++++++++ app/api/sandbox/[id]/ports/route.ts | 162 +++++++++ components/editor/editor-panel-host.tsx | 27 ++ components/terminal/terminal-container.tsx | 46 ++- .../terminal/toolbar/network-dialog.tsx | 274 +++++++++++---- .../toolbar/network-endpoints.test.ts | 111 ++++++ .../terminal/toolbar/network-endpoints.ts | 96 ++++++ components/terminal/toolbar/toolbar.tsx | 50 ++- lib/events/sandbox/sandboxListener.ts | 96 ++++-- lib/k8s/sandbox-endpoints.test.ts | 52 +++ lib/k8s/sandbox-endpoints.ts | 29 ++ lib/k8s/sandbox-manager.ts | 325 ++++++++++++++++-- lib/k8s/versions.test.ts | 16 + lib/k8s/versions.ts | 2 +- lib/repo/sandbox.ts | 5 +- prisma/schema.prisma | 169 +++------ runtime/build.sh | 33 ++ runtime/build.test.sh | 36 ++ runtime/entrypoint.test.sh | 84 +++++ 19 files changed, 1663 insertions(+), 264 deletions(-) create mode 100644 app/api/projects/route.ts create mode 100644 app/api/sandbox/[id]/ports/route.ts create mode 100644 components/editor/editor-panel-host.tsx create mode 100644 components/terminal/toolbar/network-endpoints.test.ts create mode 100644 components/terminal/toolbar/network-endpoints.ts create mode 100644 lib/k8s/sandbox-endpoints.test.ts create mode 100644 lib/k8s/sandbox-endpoints.ts create mode 100644 lib/k8s/versions.test.ts create mode 100755 runtime/build.sh create mode 100644 runtime/build.test.sh create mode 100755 runtime/entrypoint.test.sh diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..5e92dec --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,314 @@ +import type { Database, Environment, Prisma, Project, Sandbox } from '@prisma/client' +import { NextResponse } from 'next/server' + +import { withAuth } from '@/lib/api-auth' +import { EnvironmentCategory } from '@/lib/const' +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 { generateRandomString } from '@/lib/util/common' + +const logger = baseLogger.child({ module: 'api/projects' }) + +/** + * Validate project name format + * Rules: + * - Only letters, numbers, spaces, and hyphens allowed + * - Must start with a letter + * - Must end with a letter + */ +function validateProjectName(name: string): { valid: boolean; error?: string } { + // Check if name is empty or only whitespace + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Project name cannot be empty' } + } + + // Check if name contains only allowed characters (letters, numbers, spaces, hyphens) + const allowedPattern = /^[a-zA-Z0-9\s-]+$/ + if (!allowedPattern.test(name)) { + return { + valid: false, + error: 'Project name can only contain letters, numbers, spaces, and hyphens', + } + } + + // Check if name starts with a letter + const trimmedName = name.trim() + if (!/^[a-zA-Z]/.test(trimmedName)) { + return { valid: false, error: 'Project name must start with a letter' } + } + + // Check if name ends with a letter + if (!/[a-zA-Z]$/.test(trimmedName)) { + return { valid: false, error: 'Project name must end with a letter' } + } + + return { valid: true } +} + +type ProjectWithRelations = Project & { + databases: Database[] + sandboxes: Sandbox[] + environments: Environment[] +} + +type GetProjectsResponse = ProjectWithRelations[] + +export const GET = withAuth(async (req, _context, session) => { + // Get query parameters for filtering + const { searchParams } = new URL(req.url) + const allParam = searchParams.get('all') + const keywordParam = searchParams.get('keyword') + const createdFromParam = searchParams.get('createdFrom') + const createdToParam = searchParams.get('createdTo') + + // Build where clause + const whereClause: Prisma.ProjectWhereInput = { + userId: session.user.id, + } + + // Add keyword filter if provided (searches in both name and description) + if (keywordParam) { + whereClause.OR = [ + { + name: { + contains: keywordParam, + mode: 'insensitive', + }, + }, + { + description: { + contains: keywordParam, + mode: 'insensitive', + }, + }, + ] + } + + // Add createdAt date filters if provided + const createdAtFilter: { gte?: Date; lte?: Date } = {} + if (createdFromParam) { + const createdFrom = new Date(createdFromParam) + if (!isNaN(createdFrom.getTime())) { + createdAtFilter.gte = createdFrom + } + } + if (createdToParam) { + const createdTo = new Date(createdToParam) + if (!isNaN(createdTo.getTime())) { + createdAtFilter.lte = createdTo + } + } + if (Object.keys(createdAtFilter).length > 0) { + whereClause.createdAt = createdAtFilter + } + + // Add namespace filter from user's kubeconfig (unless 'all' parameter is provided) + if (allParam !== 'true') { + try { + const k8sService = await getK8sServiceForUser(session.user.id) + const namespace = k8sService.getDefaultNamespace() + whereClause.sandboxes = { + some: { + k8sNamespace: namespace, + }, + } + } catch { + // If user doesn't have kubeconfig configured, log warning but don't fail + // Skip namespace filtering and return all projects for the user + logger.warn( + `User ${session.user.id} does not have KUBECONFIG configured, returning all projects` + ) + } + } + + const projects = await prisma.project.findMany({ + where: whereClause, + include: { + databases: true, + sandboxes: true, + environments: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }) + + logger.info( + `Fetched ${projects.length} projects for user ${session.user.id}${allParam === 'true' ? ' (all namespaces)' : ''}` + ) + + return NextResponse.json(projects) +}) + +type PostProjectResponse = { error: string; errorCode?: string; message?: string } | Project + +export const POST = withAuth(async (req, _context, session) => { + const body = await req.json() + const { name, description, githubRepo, githubBranch } = body + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Project name is required' }, { status: 400 }) + } + + // Validate project name format + const nameValidation = validateProjectName(name) + if (!nameValidation.valid) { + return NextResponse.json( + { + error: nameValidation.error || 'Invalid project name format', + errorCode: 'INVALID_PROJECT_NAME', + }, + { status: 400 } + ) + } + + logger.info(`Creating project: ${name} for user: ${session.user.id}`) + + // Get K8s service for user - will throw if KUBECONFIG is missing + let k8sService + let namespace + try { + k8sService = await getK8sServiceForUser(session.user.id) + namespace = k8sService.getDefaultNamespace() + } catch (error) { + // Check if error is due to missing kubeconfig + if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { + logger.warn(`Project creation failed - missing kubeconfig for user: ${session.user.id}`) + return NextResponse.json( + { + error: 'Kubeconfig not configured', + errorCode: 'KUBECONFIG_MISSING', + message: 'Please configure your kubeconfig before creating a project', + }, + { status: 400 } + ) + } + // Re-throw other errors + throw error + } + + // Generate K8s compatible names + const k8sProjectName = KubernetesUtils.toK8sProjectName(name) + const randomSuffix = KubernetesUtils.generateRandomString() + const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth + const editorPassword = generateRandomString(20) // code-server password + 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 + const result = await prisma.$transaction(async (tx) => { + // 1. Create Project with status CREATING + const project = await tx.project.create({ + data: { + name, + description, + userId: session.user.id, + status: 'CREATING', + githubRepo: githubRepo || undefined, + githubBranch: githubBranch || undefined, + }, + }) + + // 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 + const sandbox = await tx.sandbox.create({ + data: { + projectId: project.id, + name: sandboxName, + k8sNamespace: namespace, + sandboxName: sandboxName, + status: 'CREATING', + lockedUntil: null, // Unlocked - ready for reconcile job to process + // Resource configuration from versions + runtimeImage: VERSIONS.RUNTIME_IMAGE, + cpuRequest: VERSIONS.RESOURCES.SANDBOX.requests.cpu, + cpuLimit: VERSIONS.RESOURCES.SANDBOX.limits.cpu, + memoryRequest: VERSIONS.RESOURCES.SANDBOX.requests.memory, + memoryLimit: VERSIONS.RESOURCES.SANDBOX.limits.memory, + }, + }) + + // 4. Create Environment record for ttyd access token + const ttydEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'TTYD_ACCESS_TOKEN', + value: ttydAuthToken, + category: EnvironmentCategory.TTYD, + isSecret: true, // Mark as secret since it's an access token + }, + }) + + // 4b. Create Environment record for editor password + const editorPasswordEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'EDITOR_PASSWORD', + value: editorPassword, + category: EnvironmentCategory.AUTH, + isSecret: true, + }, + }) + + // 5. Create Environment records for filebrowser credentials + const fileBrowserUsernameEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_USERNAME', + value: fileBrowserUsername, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: false, + }, + }) + + const fileBrowserPasswordEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_PASSWORD', + value: fileBrowserPassword, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: true, // Mark as secret since it's a password + }, + }) + + return { + project, + database, + sandbox, + ttydEnv, + editorPasswordEnv, + fileBrowserUsernameEnv, + fileBrowserPasswordEnv, + } + }, { + timeout: 20000, + }) + + logger.info( + `Project created: ${result.project.id} with database: ${result.database.id}, sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, editor password env: ${result.editorPasswordEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}` + ) + + return NextResponse.json(result.project) +}) diff --git a/app/api/sandbox/[id]/ports/route.ts b/app/api/sandbox/[id]/ports/route.ts new file mode 100644 index 0000000..9a997f8 --- /dev/null +++ b/app/api/sandbox/[id]/ports/route.ts @@ -0,0 +1,162 @@ +/** + * GET/POST/DELETE /api/sandbox/[id]/ports + * + * Manage custom exposed ports for a sandbox. + * + * GET — Returns current exposed ports + * POST — Expose a new port (creates K8s Ingress + Service port) + * DELETE — Unexpose a port (removes K8s Ingress + Service port) + */ + +import type { Prisma } from '@prisma/client' +import { NextResponse } from 'next/server' + +import { verifySandboxAccess, withAuth } from '@/lib/api-auth' +import { prisma } from '@/lib/db' +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' +import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' +import { logger as baseLogger } from '@/lib/logger' + +const logger = baseLogger.child({ module: 'api/sandbox/[id]/ports' }) + +interface ExposedPort { + port: number + url: string +} + +// Built-in ports that cannot be exposed/unexposed by users +const BUILT_IN_PORTS = [3000, 3773, 7681, 8080] + +function getExposedPorts(json: Prisma.JsonValue): ExposedPort[] { + if (Array.isArray(json)) return json as unknown as ExposedPort[] + return [] +} + +export const GET = withAuth(async (_req, context, session) => { + const resolvedParams = await context.params + const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id + + const sandbox = await verifySandboxAccess(sandboxId, session.user.id) + const exposedPorts = getExposedPorts(sandbox.exposedPorts) + + return NextResponse.json({ ports: exposedPorts }) +}) + +export const POST = withAuth<{ port?: number; url?: string; error?: string }>(async (req, context, session) => { + const resolvedParams = await context.params + const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id + + try { + const body = await req.json() + const port = Number(body.port) + + if (!port || port < 1 || port > 65535 || !Number.isInteger(port)) { + return NextResponse.json({ error: 'Invalid port number (1-65535)' }, { status: 400 }) + } + + if (BUILT_IN_PORTS.includes(port)) { + return NextResponse.json( + { error: `Port ${port} is a built-in port and cannot be exposed manually` }, + { status: 400 } + ) + } + + const sandbox = await verifySandboxAccess(sandboxId, session.user.id) + const existingPorts = getExposedPorts(sandbox.exposedPorts) + + // Check if already exposed + if (existingPorts.some((p) => p.port === port)) { + const existing = existingPorts.find((p) => p.port === port)! + return NextResponse.json({ port: existing.port, url: existing.url }) + } + + // Get project name for k8s labels + const project = await prisma.project.findUnique({ + where: { id: sandbox.projectId }, + select: { name: true }, + }) + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + } + + const k8sProjectName = KubernetesUtils.toK8sProjectName(project.name) + const k8sService = await getK8sServiceForUser(session.user.id) + + // Expose port in K8s + const url = await k8sService.exposePort( + sandbox.k8sNamespace, + sandbox.sandboxName, + k8sProjectName, + port + ) + + // Store in database + const updatedPorts: Prisma.JsonArray = [...existingPorts, { port, url }] as unknown as Prisma.JsonArray + await prisma.sandbox.update({ + where: { id: sandboxId }, + data: { exposedPorts: updatedPorts }, + }) + + logger.info(`Port ${port} exposed for sandbox ${sandboxId}: ${url}`) + return NextResponse.json({ port, url }) + } catch (error) { + logger.error(`Failed to expose port for sandbox ${sandboxId}: ${error}`) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json( + { error: `Failed to expose port: ${errorMessage}` }, + { status: 500 } + ) + } +}) + +export const DELETE = withAuth<{ success?: boolean; error?: string }>(async (req, context, session) => { + const resolvedParams = await context.params + const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id + + try { + const body = await req.json() + const port = Number(body.port) + + if (!port || port < 1 || port > 65535 || !Number.isInteger(port)) { + return NextResponse.json({ error: 'Invalid port number (1-65535)' }, { status: 400 }) + } + + if (BUILT_IN_PORTS.includes(port)) { + return NextResponse.json( + { error: `Port ${port} is a built-in port and cannot be removed` }, + { status: 400 } + ) + } + + const sandbox = await verifySandboxAccess(sandboxId, session.user.id) + const existingPorts = getExposedPorts(sandbox.exposedPorts) + + // Check if port is actually exposed + if (!existingPorts.some((p) => p.port === port)) { + return NextResponse.json({ error: `Port ${port} is not exposed` }, { status: 404 }) + } + + const k8sService = await getK8sServiceForUser(session.user.id) + + // Unexpose port in K8s + await k8sService.unexposePort(sandbox.k8sNamespace, sandbox.sandboxName, port) + + // Remove from database + const updatedPorts: Prisma.JsonArray = existingPorts.filter((p) => p.port !== port) as unknown as Prisma.JsonArray + await prisma.sandbox.update({ + where: { id: sandboxId }, + data: { exposedPorts: updatedPorts }, + }) + + logger.info(`Port ${port} unexposed for sandbox ${sandboxId}`) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`Failed to unexpose port for sandbox ${sandboxId}: ${error}`) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json( + { error: `Failed to unexpose port: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/components/editor/editor-panel-host.tsx b/components/editor/editor-panel-host.tsx new file mode 100644 index 0000000..e13c560 --- /dev/null +++ b/components/editor/editor-panel-host.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { Sandbox } from '@prisma/client'; + +interface EditorPanelHostProps { + sandbox: Sandbox | undefined; +} + +export function EditorPanelHost({ sandbox }: EditorPanelHostProps) { + if (!sandbox?.editorUrl) { + return ( +
+ Editor is not available yet. +
+ ); + } + + return ( +
+