This commit is contained in:
keyhan
2026-04-05 15:22:01 +03:30
commit 33be1649c4
82 changed files with 23956 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# Dependencies
node_modules/
# Build outputs
dist/
.next/
# Environment
.env
.env.local
# Logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
*.swp
*.swo
# Uploads
uploads/
# Docker
docker-compose.override.yml
+228
View File
@@ -0,0 +1,228 @@
# 🏗️ CloudHost PaaS — System Architecture
## Overview
CloudHost is a self-service PaaS platform that enables users to deploy Node.js and Laravel applications onto Kubernetes clusters managed by a super admin.
---
## 🧱 High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ USERS / ADMINS │
│ (Browser / CLI) │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (Next.js) │
│ ┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Auth UI │ │ Deploy Wizard │ │ Dashboard│ │Admin Panel│ │
│ └──────────┘ └───────────────┘ └──────────┘ └───────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│ REST API (JSON)
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND (NestJS) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐ │
│ │Auth │ │Applications │ │Deployments │ │Clusters │ │
│ │Module │ │Module │ │Module │ │Module │ │
│ └──────────┘ └──────────────┘ └────────────┘ └──────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Kubernetes │ │ Build │ │ Logs & │ │
│ │ Service │ │ Service │ │ Metrics Service │ │
│ └────────┬─────────┘ └──────┬───────┘ └──────────────────┘ │
│ │ │ │
└───────────┼───────────────────┼──────────────────────────────────┘
│ │
┌───────▼────────┐ ┌──────▼────────┐
│ Kubernetes │ │ Container │
│ Cluster(s) │ │ Registry │
│ │ │ (Harbor/ECR) │
│ ┌───────────┐ │ └───────────────┘
│ │Namespace A│ │
│ │ ┌─Pod────┐│ │
│ │ │App ││ │ ┌────────────────┐
│ │ └────────┘│ │ │ PostgreSQL │
│ │ ┌─Pod────┐│ │ │ (Metadata DB) │
│ │ │DB ││ │ └────────────────┘
│ │ └────────┘│ │
│ └───────────┘ │
│ ┌───────────┐ │
│ │Namespace B│ │
│ │ ... │ │
│ └───────────┘ │
└───────────────┘
```
---
## 🔧 Tech Stack Justification
### Frontend: **Next.js 14 (App Router) + Tailwind CSS**
| Reason | Detail |
|--------|--------|
| **SSR & SEO** | Server-side rendering for fast initial loads |
| **App Router** | Modern React Server Components, layouts, loading states |
| **Tailwind CSS** | Rapid UI development, consistent design system |
| **TypeScript** | End-to-end type safety with shared types |
| **React Query** | Efficient server state management, caching, polling for live status |
### Backend: **NestJS (Node.js)**
| Reason | Detail |
|--------|--------|
| **Modular architecture** | Each domain (auth, apps, deployments, clusters) is a self-contained module |
| **TypeScript native** | Full type safety, shared interfaces with frontend |
| **Decorator-based** | Clean controller/service pattern, guards, interceptors |
| **@kubernetes/client-node** | Official K8s client for Node.js — direct API interaction |
| **Bull/BullMQ** | Redis-backed job queues for async build & deploy pipelines |
| **TypeORM** | Mature PostgreSQL ORM with migration support |
### Database: **PostgreSQL**
| Reason | Detail |
|--------|--------|
| **ACID compliance** | Critical for deployment state tracking |
| **JSON columns** | Store flexible config/metadata without schema changes |
| **Mature ecosystem** | Battle-tested, excellent TypeORM support |
| **Scalability** | Read replicas, partitioning for growth |
### Build System: **Kaniko (in-cluster)**
| Reason | Detail |
|--------|--------|
| **No Docker daemon** | Builds images inside K8s pods — no Docker-in-Docker security issues |
| **Registry push** | Native push to any OCI-compatible registry |
| **Caching** | Layer caching for faster rebuilds |
### Container Registry: **Harbor (self-hosted) or cloud-managed (ECR/GCR/ACR)**
| Reason | Detail |
|--------|--------|
| **Private images** | User apps must not be publicly accessible |
| **Vulnerability scanning** | Harbor provides built-in image scanning |
| **Multi-tenancy** | Project-based access control |
### Queue System: **Redis + BullMQ**
| Reason | Detail |
|--------|--------|
| **Async builds** | Image builds are long-running — must not block API |
| **Retries** | Failed builds auto-retry with backoff |
| **Progress tracking** | Real-time build status updates |
---
## 🔐 Security Architecture
```
┌─────────────────────────────────────────┐
│ Security Layers │
├─────────────────────────────────────────┤
│ │
│ 1. JWT Authentication (access/refresh) │
│ 2. Role-Based Access (User / Admin) │
│ 3. K8s Namespace Isolation per user │
│ 4. K8s RBAC — scoped ServiceAccounts │
│ 5. Network Policies between namespaces │
│ 6. Resource Quotas & Limit Ranges │
│ 7. Secrets encryption (K8s Secrets) │
│ 8. Input validation on all user inputs │
│ 9. Rate limiting on API endpoints │
│ │
└─────────────────────────────────────────┘
```
---
## 🔄 Deployment Flow
```
User uploads code ──► API receives ──► Store metadata in PostgreSQL
Queue build job (BullMQ)
Kaniko Pod builds image
Push to Container Registry
Generate K8s manifests from templates
Apply to target cluster via K8s API
Create: Namespace, Deployment, Service,
Ingress, PVC, DB, Secrets
Update deployment status in DB
User sees live status in dashboard
```
---
## 📁 Project Structure
```
host/
├── ARCHITECTURE.md
├── README.md
├── docker-compose.yml
├── backend/ # NestJS API
│ ├── src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── common/ # Shared utilities, guards, decorators
│ │ ├── config/ # Environment configuration
│ │ ├── auth/ # JWT auth, strategies, guards
│ │ ├── users/ # User management
│ │ ├── applications/ # App CRUD, code upload
│ │ ├── deployments/ # Deployment lifecycle
│ │ ├── clusters/ # K8s cluster management (admin)
│ │ ├── kubernetes/ # K8s client, manifest generation
│ │ ├── build/ # Image build pipeline
│ │ └── database/ # TypeORM entities, migrations
│ ├── templates/ # K8s YAML templates (Handlebars)
│ ├── Dockerfile
│ ├── package.json
│ └── tsconfig.json
├── frontend/ # Next.js App
│ ├── src/
│ │ ├── app/ # App Router pages
│ │ ├── components/ # Reusable UI components
│ │ ├── lib/ # API client, utilities
│ │ ├── hooks/ # Custom React hooks
│ │ └── types/ # TypeScript interfaces
│ ├── Dockerfile
│ ├── package.json
│ └── tailwind.config.ts
└── k8s/ # Platform's own K8s deployment
├── base/
└── overlays/
```
---
## 🚀 Future Scaling Considerations
1. **Multi-cluster support** — Deploy to different clusters/regions
2. **Custom domains** — Let users bring their own domains with auto TLS
3. **Horizontal Pod Autoscaler** — Auto-scale based on metrics
4. **WebSocket/SSE** — Real-time build logs streaming
5. **Plugin system** — Support Python, Go, Rust runtimes
6. **Marketplace** — Pre-built app templates (WordPress, etc.)
7. **Billing integration** — Usage-based billing per resource consumption
8. **GitOps** — ArgoCD integration for declarative deployments
+284
View File
@@ -0,0 +1,284 @@
# ☁️ CloudHost — Self-Service PaaS Platform
A self-service Platform-as-a-Service (PaaS) that lets developers deploy **Node.js** and **Laravel** applications onto Kubernetes with zero DevOps overhead. Super admins manage clusters, quotas, and users; developers simply push code and deploy.
---
## Architecture Overview
```
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Next.js 14 │ REST │ NestJS API │ K8s │ Kubernetes │
│ Frontend │◄───────►│ Backend │◄──────►│ Cluster(s) │
└─────────────┘ └────────┬────────┘ └──────────────┘
┌──────────┼──────────┐
▼ ▼ ▼
PostgreSQL Redis Container
(Bull) Registry
```
| Layer | Technology |
| ------------ | ------------------------------------------------------- |
| Frontend | Next.js 14, Tailwind CSS, React Query, Zustand |
| Backend API | NestJS 10, TypeORM, Passport JWT, Bull (Redis) |
| Build Engine | Kaniko (in-cluster, daemon-less Docker builds) |
| Orchestrator | @kubernetes/client-node, Handlebars YAML templates |
| Database | PostgreSQL 16 |
| Queue | Redis 7 + BullMQ |
---
## Features
### For Developers
- 🚀 **One-click deploys** from a Git URL or uploaded code archive
- 🟢 **Node.js** (with `npm run build` & `npm start`) support
- 🟣 **Laravel** (PHP 8.3 + Nginx + Supervisor) support
- 🗄️ **Managed databases** — PostgreSQL or MySQL provisioned automatically
- 📊 **Live logs** & deployment history
- 🔒 **Environment variables** managed as Kubernetes Secrets
- ⚙️ **Resource controls** — CPU, memory, replica count
### For Super Admins
- 🖥️ **Multi-cluster management** — register/remove Kubernetes clusters
- 👥 **User management** — activate, deactivate, change roles
- 📈 **Quotas** — per-cluster limits (CPU, memory, max apps)
- 🔐 **RBAC** — role-based guards on every endpoint
---
## Project Structure
```
host/
├── ARCHITECTURE.md # Detailed architecture document
├── docker-compose.yml # Local dev / production compose
├── backend/ # NestJS API
│ ├── Dockerfile
│ ├── package.json
│ ├── src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── auth/ # JWT auth (register, login, refresh)
│ │ ├── users/ # User CRUD + admin ops
│ │ ├── applications/ # Application CRUD
│ │ ├── deployments/ # Deployment pipeline orchestration
│ │ ├── clusters/ # Cluster management (admin)
│ │ ├── kubernetes/ # K8s client & manifest generator
│ │ ├── build/ # Kaniko build jobs (Bull queue)
│ │ ├── common/ # Enums, decorators, guards
│ │ └── config/ # Env configuration loader
│ └── templates/ # Handlebars K8s YAML templates
└── frontend/ # Next.js 14 App Router
├── Dockerfile
├── package.json
└── src/
├── app/
│ ├── login/ # Auth pages
│ ├── register/
│ └── dashboard/ # Protected dashboard
│ ├── apps/ # App list & detail
│ ├── deploy/ # 4-step deploy wizard
│ └── admin/ # Admin: users & clusters
├── components/
├── lib/ # API client, auth store
└── types/ # TypeScript interfaces
```
---
## Quick Start
### Prerequisites
| Tool | Version |
| --------------- | ------- |
| Node.js | ≥ 20 |
| Docker & Compose| ≥ 24 |
| PostgreSQL | 16 (or use Docker) |
| Redis | 7 (or use Docker) |
### 1. Clone & Install
```bash
git clone <repo-url> host && cd host
# Backend
cd backend && npm install && cd ..
# Frontend
cd frontend && npm install && cd ..
```
### 2. Environment Variables
```bash
# Backend
cp backend/.env.example backend/.env
# Edit backend/.env with your DB, JWT, Redis, and registry settings
# Frontend
cp frontend/.env.local.example frontend/.env.local
```
### 3. Run with Docker Compose (recommended)
```bash
docker compose up --build
```
This spins up **PostgreSQL**, **Redis**, **Backend** (port 4000), and **Frontend** (port 3000).
Open [http://localhost:3000](http://localhost:3000) in your browser.
### 4. Run Locally (development)
```bash
# Terminal 1 — Backend
cd backend
npm run start:dev
# Terminal 2 — Frontend
cd frontend
npm run dev
```
---
## API Endpoints
All endpoints are prefixed with `/api/v1`.
### Auth
| Method | Path | Description |
| ------ | ----------------- | ------------------- |
| POST | /auth/register | Create account |
| POST | /auth/login | Get JWT tokens |
| POST | /auth/refresh | Refresh access token|
### Applications
| Method | Path | Description |
| ------ | ------------------ | ------------------- |
| POST | /applications | Create app |
| GET | /applications | List user's apps |
| GET | /applications/:id | Get app details |
| PATCH | /applications/:id | Update app |
| DELETE | /applications/:id | Delete app |
### Deployments
| Method | Path | Description |
| ------ | ------------------------------------ | -------------------- |
| POST | /applications/:appId/deployments | Trigger deploy |
| GET | /applications/:appId/deployments | List deployments |
| GET | /deployments/:id | Deployment detail |
| GET | /deployments/:id/logs | Get pod logs |
| POST | /deployments/:id/stop | Stop deployment |
| POST | /deployments/:id/restart | Restart deployment |
### Users (authenticated)
| Method | Path | Description |
| ------ | ---------- | ---------------- |
| GET | /users/me | Current user |
| PATCH | /users/me | Update profile |
### Admin — Users
| Method | Path | Description |
| ------ | ------------------------------ | ------------------ |
| GET | /users | List all users |
| PATCH | /users/:id/activate | Activate user |
| PATCH | /users/:id/deactivate | Deactivate user |
| PATCH | /users/:id/role | Change role |
### Admin — Clusters
| Method | Path | Description |
| ------ | --------------- | ----------------- |
| POST | /clusters | Add cluster |
| GET | /clusters | List clusters |
| GET | /clusters/:id | Cluster details |
| PATCH | /clusters/:id | Update cluster |
| DELETE | /clusters/:id | Remove cluster |
> 📖 Full Swagger docs available at `http://localhost:4000/docs` when the backend is running.
---
## Deployment Flow
```
Developer creates app → Uploads code / provides Git URL
Build Service creates Kaniko Job in K8s
Kaniko builds Docker image → Pushes to Container Registry
Kubernetes Service generates manifests from Handlebars templates:
• Namespace • Deployment • Service • Ingress
• Database (optional) • PVC • Secret
Applies manifests to target cluster via @kubernetes/client-node
App is live at https://<subdomain>.apps.yourdomain.com
```
---
## Kubernetes Templates
The platform dynamically generates K8s manifests using **Handlebars** templates located in `backend/templates/`:
| Template | Purpose |
| ----------------- | --------------------------------------------- |
| `namespace.yaml` | Per-user namespace with resource quotas |
| `deployment.yaml` | App deployment with health probes & resources |
| `service.yaml` | ClusterIP service |
| `ingress.yaml` | Ingress with TLS (cert-manager annotations) |
| `database.yaml` | PostgreSQL or MySQL StatefulSet |
| `pvc.yaml` | Persistent volume claim for databases |
| `secret.yaml` | Environment variables as K8s Secrets |
---
## Security
- **JWT** access + refresh tokens with configurable expiry
- **Bcrypt** password hashing (12 rounds)
- **Helmet** HTTP security headers
- **RBAC** role-based route guards (`@Roles(UserRole.ADMIN)`)
- **Namespace isolation** — each user deploys to their own K8s namespace
- **Secrets** — env vars stored as K8s Secrets, never in plain manifests
- **Input validation** — `class-validator` on all DTOs
---
## Configuration
All configuration is via environment variables. See `backend/.env.example` for the full list:
| Variable | Description | Default |
| ------------------- | -------------------------------- | ----------------- |
| `PORT` | Backend port | `4000` |
| `DB_HOST` | PostgreSQL host | `localhost` |
| `DB_PORT` | PostgreSQL port | `5432` |
| `DB_USERNAME` | Database user | `cloudhost` |
| `DB_PASSWORD` | Database password | — |
| `DB_NAME` | Database name | `cloudhost` |
| `JWT_SECRET` | JWT signing secret | — |
| `JWT_EXPIRES_IN` | Access token TTL | `15m` |
| `REDIS_HOST` | Redis host | `localhost` |
| `REDIS_PORT` | Redis port | `6379` |
| `REGISTRY_URL` | Container registry URL | — |
| `PLATFORM_DOMAIN` | Base domain for app subdomains | `apps.localhost` |
---
## License
MIT
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log
+33
View File
@@ -0,0 +1,33 @@
# Environment
NODE_ENV=development
PORT=4000
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=cloudhost
DB_PASSWORD=cloudhost_secret
DB_DATABASE=cloudhost
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
JWT_REFRESH_EXPIRES_IN=7d
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# Container Registry
REGISTRY_URL=registry.example.com
REGISTRY_USERNAME=admin
REGISTRY_PASSWORD=registry_secret
# Build
BUILD_NAMESPACE=cloudhost-builds
BUILD_SERVICE_ACCOUNT=kaniko-builder
# Platform
PLATFORM_DOMAIN=apps.cloudhost.local
UPLOAD_DIR=./uploads
+32
View File
@@ -0,0 +1,32 @@
# ---- Stage 1: Build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Stage 2: Production ----
FROM node:20-alpine AS production
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/templates ./templates
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 4000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"tsConfigPath": "tsconfig.json"
},
"sourceRoot": "src",
"collection": "@nestjs/schematics",
"entryFile": "main"
}
+11419
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
{
"name": "cloudhost-backend",
"version": "1.0.0",
"description": "CloudHost PaaS Backend API",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
},
"dependencies": {
"@kubernetes/client-node": "^0.21.0",
"@nestjs/bull": "^10.1.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.2.0",
"@nestjs/typeorm": "^10.0.1",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"handlebars": "^4.7.8",
"helmet": "^7.1.0",
"js-yaml": "^4.1.0",
"multer": "^1.4.5-lts.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"typeorm": "^0.3.19",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/multer": "^1.4.11",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.0",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"prettier": "^3.2.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ApplicationsModule } from './applications/applications.module';
import { DeploymentsModule } from './deployments/deployments.module';
import { ClustersModule } from './clusters/clusters.module';
import { KubernetesModule } from './kubernetes/kubernetes.module';
import { BuildModule } from './build/build.module';
import configuration from './config/configuration';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
// Database
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('database.host'),
port: configService.get('database.port'),
username: configService.get('database.username'),
password: configService.get('database.password'),
database: configService.get('database.name'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: configService.get('nodeEnv') === 'development',
logging: configService.get('nodeEnv') === 'development' ? ['error', 'warn'] : false,
}),
inject: [ConfigService],
}),
// Redis / Bull Queue
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
redis: {
host: configService.get('redis.host'),
port: configService.get('redis.port'),
},
}),
inject: [ConfigService],
}),
// Feature modules
AuthModule,
UsersModule,
ApplicationsModule,
DeploymentsModule,
ClustersModule,
KubernetesModule,
BuildModule,
],
})
export class AppModule {}
@@ -0,0 +1,118 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
Request,
UseInterceptors,
UploadedFile,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { ApplicationsService } from './applications.service';
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { DeploymentsService } from '../deployments/deployments.service';
@ApiTags('Applications')
@ApiBearerAuth()
@Controller('applications')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class ApplicationsController {
private readonly logger = new Logger(ApplicationsController.name);
constructor(
private readonly applicationsService: ApplicationsService,
private readonly kubernetesService: KubernetesService,
@Inject(forwardRef(() => DeploymentsService))
private readonly deploymentsService: DeploymentsService,
) {}
@Post()
@ApiOperation({ summary: 'Create a new application' })
async create(@Request() req: any, @Body() dto: CreateApplicationDto) {
return this.applicationsService.create(req.user.id, dto);
}
@Post(':id/upload')
@ApiOperation({ summary: 'Upload application code (zip file)' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
}))
async uploadCode(
@Param('id') id: string,
@Request() req: any,
@UploadedFile() file: Express.Multer.File,
) {
return this.applicationsService.uploadCode(id, req.user.id, file);
}
@Get()
@ApiOperation({ summary: 'List my applications' })
async findAll(@Request() req: any) {
if (req.user.role === UserRole.ADMIN) {
return this.applicationsService.findAll();
}
return this.applicationsService.findAllByUser(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get application details' })
async findOne(@Param('id') id: string, @Request() req: any) {
if (req.user.role === UserRole.ADMIN) {
return this.applicationsService.findOne(id);
}
return this.applicationsService.findOne(id, req.user.id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update application configuration' })
async update(
@Param('id') id: string,
@Request() req: any,
@Body() dto: UpdateApplicationDto,
) {
return this.applicationsService.update(id, req.user.id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an application and all its resources' })
async delete(@Param('id') id: string, @Request() req: any) {
// 1. Get the app first
const app = await this.applicationsService.findOne(id, req.user.id);
// 2. Delete K8s resources (deployment, service, ingress, db, secrets)
try {
if (app.clusterId && app.latestImageTag) {
await this.kubernetesService.deleteApplication(app);
this.logger.log(`Deleted K8s resources for ${app.name}`);
}
} catch (e: any) {
this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`);
}
// 3. Delete deployment records from DB
try {
await this.deploymentsService.deleteAllForApplication(app.id);
} catch (e: any) {
this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`);
}
// 4. Delete app (also deletes uploaded files)
await this.applicationsService.delete(id, req.user.id);
return { message: `Application "${app.name}" and all resources deleted` };
}
}
@@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationsService } from './applications.service';
import { ApplicationsController } from './applications.controller';
import { Application } from './entities/application.entity';
import { ClustersModule } from '../clusters/clusters.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { DeploymentsModule } from '../deployments/deployments.module';
@Module({
imports: [
TypeOrmModule.forFeature([Application]),
ClustersModule,
KubernetesModule,
forwardRef(() => DeploymentsModule),
],
controllers: [ApplicationsController],
providers: [ApplicationsService],
exports: [ApplicationsService],
})
export class ApplicationsModule {}
@@ -0,0 +1,133 @@
import { Injectable, NotFoundException, ForbiddenException, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import { Application } from './entities/application.entity';
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
import { ClustersService } from '../clusters/clusters.service';
@Injectable()
export class ApplicationsService {
private readonly logger = new Logger(ApplicationsService.name);
constructor(
@InjectRepository(Application)
private appsRepository: Repository<Application>,
private clustersService: ClustersService,
private configService: ConfigService,
) {}
async create(userId: string, dto: CreateApplicationDto): Promise<Application> {
// Auto-assign default cluster if not specified
let clusterId = dto.clusterId;
if (!clusterId) {
try {
const defaultCluster = await this.clustersService.getDefault();
clusterId = defaultCluster.id;
this.logger.log(`Auto-assigned default cluster "${defaultCluster.name}" to app "${dto.name}"`);
} catch {
this.logger.warn('No default cluster found — app will be created without cluster assignment');
}
}
const app = this.appsRepository.create({
...dto,
userId,
clusterId,
subdomain: `${dto.name}-${userId.split('-')[0]}`,
});
return this.appsRepository.save(app);
}
async findAllByUser(userId: string): Promise<Application[]> {
return this.appsRepository.find({
where: { userId },
relations: ['deployments'],
order: { createdAt: 'DESC' },
});
}
async findAll(): Promise<Application[]> {
return this.appsRepository.find({
relations: ['user', 'deployments'],
order: { createdAt: 'DESC' },
});
}
async findOne(id: string, userId?: string): Promise<Application> {
const where: any = { id };
if (userId) {
where.userId = userId;
}
const app = await this.appsRepository.findOne({
where,
relations: ['deployments'],
});
if (!app) {
throw new NotFoundException('Application not found');
}
return app;
}
async update(id: string, userId: string, dto: UpdateApplicationDto): Promise<Application> {
const app = await this.findOne(id, userId);
Object.assign(app, dto);
return this.appsRepository.save(app);
}
async delete(id: string, userId: string): Promise<Application> {
const app = await this.findOne(id, userId);
// Delete uploaded files
if (app.codePath) {
try {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
if (fs.existsSync(appDir)) {
fs.rmSync(appDir, { recursive: true, force: true });
this.logger.log(`Deleted upload directory: ${appDir}`);
}
} catch (e: any) {
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
}
}
await this.appsRepository.remove(app);
this.logger.log(`Deleted application ${app.name} (${id})`);
return app;
}
async updateImageTag(id: string, imageTag: string): Promise<Application> {
const app = await this.findOne(id);
app.latestImageTag = imageTag;
return this.appsRepository.save(app);
}
async uploadCode(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
if (!file) {
throw new BadRequestException('No file uploaded');
}
const app = await this.findOne(id, userId);
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
// Ensure directory exists
fs.mkdirSync(appDir, { recursive: true });
// Save the zip file
const zipPath = path.join(appDir, 'source.zip');
fs.writeFileSync(zipPath, file.buffer);
// Update app with code path
app.codePath = zipPath;
const saved = await this.appsRepository.save(app);
this.logger.log(`Uploaded code for ${app.name}${zipPath} (${(file.size / 1024).toFixed(1)} KB)`);
return saved;
}
}
@@ -0,0 +1,120 @@
import {
IsString,
IsEnum,
IsOptional,
IsNumber,
IsObject,
Min,
Max,
Matches,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AppRuntime, DatabaseType } from '../../common/enums';
export class CreateApplicationDto {
@ApiProperty({ example: 'my-app' })
@IsString()
@Matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, {
message: 'Name must be lowercase alphanumeric with hyphens only',
})
name: string;
@ApiPropertyOptional({ example: 'My awesome Node.js application' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ enum: AppRuntime, example: AppRuntime.NODEJS })
@IsEnum(AppRuntime)
runtime: AppRuntime;
@ApiProperty({ enum: DatabaseType, example: DatabaseType.POSTGRESQL })
@IsEnum(DatabaseType)
databaseType: DatabaseType;
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
@IsOptional()
@IsString()
gitUrl?: string;
@ApiPropertyOptional({ example: { NODE_ENV: 'production', PORT: '3000' } })
@IsOptional()
@IsObject()
envVars?: Record<string, string>;
@ApiPropertyOptional({ example: '250m' })
@IsOptional()
@IsString()
cpuRequest?: string;
@ApiPropertyOptional({ example: '500m' })
@IsOptional()
@IsString()
cpuLimit?: string;
@ApiPropertyOptional({ example: '256Mi' })
@IsOptional()
@IsString()
memoryRequest?: string;
@ApiPropertyOptional({ example: '512Mi' })
@IsOptional()
@IsString()
memoryLimit?: string;
@ApiPropertyOptional({ example: 2, minimum: 1, maximum: 10 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
replicas?: number;
@ApiPropertyOptional({ example: 3000 })
@IsOptional()
@IsNumber()
port?: number;
@ApiPropertyOptional({ description: 'Cluster ID to deploy to (auto-assigns default if empty)' })
@IsOptional()
@IsString()
clusterId?: string;
}
export class UpdateApplicationDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsObject()
envVars?: Record<string, string>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cpuRequest?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cpuLimit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
memoryRequest?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
memoryLimit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
replicas?: number;
}
@@ -0,0 +1,86 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { AppRuntime, DatabaseType } from '../../common/enums';
import { User } from '../../users/entities/user.entity';
import { Deployment } from '../../deployments/entities/deployment.entity';
@Entity('applications')
export class Application {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ nullable: true })
description: string;
@Column({ type: 'enum', enum: AppRuntime })
runtime: AppRuntime;
@Column({ type: 'enum', enum: DatabaseType, default: DatabaseType.NONE })
databaseType: DatabaseType;
@Column({ nullable: true })
gitUrl: string;
@Column({ nullable: true })
codePath: string; // Path to uploaded zip
@Column({ type: 'jsonb', nullable: true })
envVars: Record<string, string>;
// Resource configuration
@Column({ default: '100m' })
cpuRequest: string;
@Column({ default: '500m' })
cpuLimit: string;
@Column({ default: '128Mi' })
memoryRequest: string;
@Column({ default: '512Mi' })
memoryLimit: string;
@Column({ default: 1 })
replicas: number;
@Column({ default: 3000 })
port: number;
// Relations
@ManyToOne(() => User, (user: User) => user.applications, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: string;
@Column({ nullable: true })
clusterId: string;
@OneToMany(() => Deployment, (deployment: Deployment) => deployment.application)
deployments: Deployment[];
// Metadata
@Column({ nullable: true })
latestImageTag: string;
@Column({ nullable: true })
subdomain: string; // <subdomain>.apps.cloudhost.local
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+38
View File
@@ -0,0 +1,38 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiResponse({ status: 409, description: 'Email already registered' })
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
@ApiResponse({ status: 200, description: 'Token refreshed' })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get('jwt.secret'),
signOptions: { expiresIn: configService.get('jwt.expiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+100
View File
@@ -0,0 +1,100 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
async register(registerDto: RegisterDto) {
const existingUser = await this.usersService.findByEmail(registerDto.email);
if (existingUser) {
throw new ConflictException('Email already registered');
}
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
const user = await this.usersService.create({
...registerDto,
password: hashedPassword,
});
const tokens = await this.generateTokens(user.id, user.email, user.role);
return {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
...tokens,
};
}
async login(loginDto: LoginDto) {
const user = await this.usersService.findByEmail(loginDto.email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
if (!user.isActive) {
throw new UnauthorizedException('Account is deactivated');
}
const tokens = await this.generateTokens(user.id, user.email, user.role);
return {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
...tokens,
};
}
async refreshToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('jwt.refreshSecret'),
});
const user = await this.usersService.findById(payload.sub);
if (!user || !user.isActive) {
throw new UnauthorizedException();
}
return this.generateTokens(user.id, user.email, user.role);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
private async generateTokens(userId: string, email: string, role: string) {
const payload = { sub: userId, email, role };
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.refreshSecret'),
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
}),
]);
return { accessToken, refreshToken };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { IsEmail, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecureP@ss123' })
@IsString()
password: string;
}
@@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty()
@IsString()
refreshToken: string;
}
+26
View File
@@ -0,0 +1,26 @@
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecureP@ss123' })
@IsString()
@MinLength(8)
@MaxLength(64)
password: string;
@ApiProperty({ example: 'John' })
@IsString()
@MinLength(1)
@MaxLength(50)
firstName: string;
@ApiProperty({ example: 'Doe' })
@IsString()
@MinLength(1)
@MaxLength(50)
lastName: string;
}
@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
interface JwtPayload {
sub: string;
email: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('jwt.secret'),
});
}
async validate(payload: JwtPayload) {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
};
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module, forwardRef } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { BuildService } from './build.service';
import { BuildProcessor } from './build.processor';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { ClustersModule } from '../clusters/clusters.module';
@Module({
imports: [
BullModule.registerQueue({ name: 'build' }),
forwardRef(() => KubernetesModule),
ClustersModule,
],
providers: [BuildService, BuildProcessor],
exports: [BuildService],
})
export class BuildModule {}
+42
View File
@@ -0,0 +1,42 @@
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';
import { BuildService } from './build.service';
export interface BuildJobData {
applicationId: string;
deploymentId: string;
appName: string;
runtime: string;
gitUrl?: string;
codePath?: string;
}
@Processor('build')
export class BuildProcessor {
private readonly logger = new Logger(BuildProcessor.name);
constructor(private buildService: BuildService) {}
@Process('build-image')
async handleBuild(job: Job<BuildJobData>) {
this.logger.log(`Processing build job ${job.id} for app: ${job.data.appName}`);
try {
await job.progress(10);
// The actual build logic is in BuildService
// This processor handles the queue job lifecycle
this.logger.log(`Build job ${job.id} started for ${job.data.appName}`);
await job.progress(50);
await job.progress(100);
this.logger.log(`Build job ${job.id} completed for ${job.data.appName}`);
return { status: 'completed', appName: job.data.appName };
} catch (error: any) {
this.logger.error(`Build job ${job.id} failed: ${error.message}`);
throw error;
}
}
}
+425
View File
@@ -0,0 +1,425 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
import * as fs from 'fs';
import * as path from 'path';
import { Application } from '../applications/entities/application.entity';
import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
@Injectable()
export class BuildService {
private readonly logger = new Logger(BuildService.name);
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
) {}
/**
* Builds a Docker image for the application using Kaniko inside K8s.
* Returns the full image URI (registry/repo:tag).
*/
async buildImage(app: Application): Promise<string> {
// Internal registry (used by Kaniko inside K8s for pushing)
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
// External registry URL (used by kubelet for pulling — NodePort or external)
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
const tag = `${Date.now()}`;
const pushImageUri = `${internalRegistryUrl}/${app.userId}/${app.name}:${tag}`;
const pullImageUri = `${pullRegistryUrl}/${app.userId}/${app.name}:${tag}`;
this.logger.log(`Starting image build for ${app.name} → push: ${pushImageUri}, pull: ${pullImageUri}`);
// Determine Dockerfile based on runtime
const dockerfileContent = this.generateDockerfile(app);
// Create Kaniko build pod
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
// Use the cluster's kubeconfig instead of default
const cluster = app.clusterId
? await this.clustersService.findOne(app.clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
// Determine if we have uploaded code or git URL
const codePath = app.codePath ? path.resolve(app.codePath) : null;
const hasUploadedCode = codePath && fs.existsSync(codePath);
const hasGitUrl = !!app.gitUrl;
// Create ConfigMap with Dockerfile
const dockerfileConfigMap = {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: `${buildPodName}-dockerfile`,
namespace: buildNamespace,
},
data: {
Dockerfile: dockerfileContent,
},
};
// If we have uploaded code, create a ConfigMap with the zip as base64
let sourceConfigMapName: string | undefined;
if (hasUploadedCode) {
const zipBuffer = fs.readFileSync(codePath!);
const zipBase64 = zipBuffer.toString('base64');
sourceConfigMapName = `${buildPodName}-source`;
// ConfigMap has 1MB limit, for larger files we'd need a PVC approach
// For now, use a Secret (which can hold up to 1MB too, but binary-safe)
const sourceSecret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: sourceConfigMapName,
namespace: buildNamespace,
},
data: {
'source.zip': zipBase64,
},
};
await coreApi.createNamespacedSecret(buildNamespace!, sourceSecret);
this.logger.log(`Created source secret: ${sourceConfigMapName} (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
}
// Build the Kaniko Job spec
// Always use dir context — init containers prepare /workspace/source
const kanikoArgs = [
'--dockerfile=/workspace/Dockerfile',
'--context=dir:///workspace/source',
`--destination=${pushImageUri}`,
'--cache=true',
`--cache-repo=${internalRegistryUrl}/${app.userId}/cache`,
'--insecure',
'--skip-tls-verify',
];
const volumes: any[] = [
{
name: 'docker-config',
secret: { secretName: 'registry-credentials' },
},
{
name: 'dockerfile',
configMap: {
name: `${buildPodName}-dockerfile`,
},
},
{
name: 'workspace',
emptyDir: {},
},
];
const initContainers: any[] = [];
if (hasUploadedCode && sourceConfigMapName) {
// Add the source secret as a volume
volumes.push({
name: 'source-zip',
secret: { secretName: sourceConfigMapName },
});
// Add init container that unzips the source code
initContainers.push({
name: 'unzip-source',
image: 'alpine:3.19',
command: ['sh', '-c', `
apk add --no-cache unzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
mkdir -p /workspace-out/source &&
cd /workspace-out/source &&
unzip /source/source.zip &&
ls -la /workspace-out/source/
`],
volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' },
{ name: 'source-zip', mountPath: '/source' },
],
});
} else if (hasGitUrl) {
// Clone git repo into /workspace/source, then copy our generated Dockerfile
initContainers.push({
name: 'git-clone',
image: 'alpine/git:2.43.0',
command: ['sh', '-c', `
echo ">>> Cloning ${app.gitUrl}" &&
git clone --depth 1 ${app.gitUrl} /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
echo ">>> Workspace contents:" &&
ls -la /workspace-out/source/
`],
volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' },
],
});
}
// Kaniko container volume mounts
const kanikoVolumeMounts: any[] = [
{ name: 'docker-config', mountPath: '/kaniko/.docker' },
{ name: 'workspace', mountPath: '/workspace' },
];
// If no uploaded code and no git, mount dockerfile directly
if (!hasUploadedCode && !hasGitUrl) {
kanikoVolumeMounts.push({
name: 'dockerfile',
mountPath: '/workspace/Dockerfile',
subPath: 'Dockerfile',
});
}
const buildJob: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: buildPodName,
namespace: buildNamespace,
},
spec: {
backoffLimit: 2,
ttlSecondsAfterFinished: 300,
template: {
spec: {
serviceAccountName: this.configService.get<string>('build.serviceAccount'),
initContainers: initContainers.length > 0 ? initContainers : undefined,
containers: [
{
name: 'kaniko',
image: 'gcr.io/kaniko-project/executor:latest',
args: kanikoArgs,
volumeMounts: kanikoVolumeMounts,
resources: {
requests: { cpu: '500m', memory: '1Gi' },
limits: { cpu: '2', memory: '4Gi' },
},
},
],
restartPolicy: 'Never',
volumes,
},
},
},
};
try {
await coreApi.createNamespacedConfigMap(buildNamespace!, dockerfileConfigMap);
await batchApi.createNamespacedJob(buildNamespace!, buildJob);
// Wait for build to complete
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
this.logger.log(`Build completed successfully: ${pullImageUri}`);
return pullImageUri;
} catch (error: any) {
// Try to get build logs for debugging
try {
const logs = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
this.logger.error(`Build logs for ${buildPodName}:\n${logs}`);
} catch {}
this.logger.error(`Build failed for ${app.name}:`, error.body || error.message);
throw new Error(`Image build failed: ${error.body?.message || error.message}`);
}
}
private generateDockerfile(app: Application): string {
switch (app.runtime) {
case AppRuntime.NODEJS:
return this.nodeDockerfile(app);
case AppRuntime.LARAVEL:
return this.laravelDockerfile(app);
default:
throw new Error(`Unsupported runtime: ${app.runtime}`);
}
}
private nodeDockerfile(app: Application): string {
const port = app.port || 3000;
return `# --- Build stage ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm cache clean --force
COPY . .
# Auto-detect Next.js and enable standalone output
RUN if ([ -f next.config.js ] || [ -f next.config.mjs ] || [ -f next.config.ts ]); then \\
echo ">>> Next.js detected, injecting standalone output"; \\
node -e " \\
const fs = require('fs'); \\
const files = ['next.config.js','next.config.mjs','next.config.ts']; \\
for (const f of files) { \\
if (fs.existsSync(f)) { \\
let c = fs.readFileSync(f,'utf8'); \\
if (!c.includes('standalone')) { \\
c = c.replace(/output\\s*:\\s*['\\\"][^'\\\"]*['\\\"]\\s*,?/g, ''); \\
c = c.replace(/(\\{)/, '\\$1 output: \\\"standalone\\\",'); \\
fs.writeFileSync(f, c); \\
console.log('Patched ' + f + ' with standalone output'); \\
} else { \\
console.log(f + ' already has standalone'); \\
} \\
break; \\
} \\
} \\
"; \\
fi
RUN npm run build 2>/dev/null || true
# --- Production stage ---
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
# Copy all build output to temp
COPY --from=builder /app /tmp/fullapp
# Detect: Next.js standalone vs regular Node.js
RUN if [ -d /tmp/fullapp/.next/standalone ]; then \\
echo ">>> Next.js standalone mode"; \\
cp -a /tmp/fullapp/.next/standalone/. .; \\
mkdir -p .next/static; \\
[ -d /tmp/fullapp/.next/static ] && cp -a /tmp/fullapp/.next/static/. .next/static/; \\
[ -d /tmp/fullapp/public ] && cp -a /tmp/fullapp/public ./public; \\
echo "standalone" > /app/.mode; \\
else \\
echo ">>> Regular Node.js app"; \\
cp -a /tmp/fullapp/. .; \\
echo "regular" > /app/.mode; \\
fi && rm -rf /tmp/fullapp
USER appuser
ENV PORT=${port}
ENV HOSTNAME=0.0.0.0
EXPOSE ${port}
CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f server.js ]; then node server.js; else npm start; fi"]
`;
}
private laravelDockerfile(app: Application): string {
return `# --- Build stage ---
FROM composer:2 AS composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev
# --- Production stage ---
FROM php:8.3-fpm-alpine
RUN apk add --no-cache nginx supervisor \\
&& docker-php-ext-install pdo pdo_mysql pdo_pgsql opcache
WORKDIR /var/www/html
COPY --from=composer /app .
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache || true
EXPOSE ${app.port || 8000}
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
`;
}
private async waitForJobCompletion(
batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api,
jobName: string,
namespace: string,
timeoutSeconds: number,
): Promise<void> {
const startTime = Date.now();
const timeoutMs = timeoutSeconds * 1000;
while (Date.now() - startTime < timeoutMs) {
const job = await batchApi.readNamespacedJob(jobName, namespace);
const status = job.body.status;
if (status?.succeeded && status.succeeded > 0) {
return; // Build completed
}
if (status?.failed && status.failed > 0) {
// Try to get pod logs for more info
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`);
}
// Wait 5 seconds before polling again
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s`);
}
private async getBuildLogs(
coreApi: k8s.CoreV1Api,
jobName: string,
namespace: string,
): Promise<string> {
try {
const pods = await coreApi.listNamespacedPod(
namespace,
undefined,
undefined,
undefined,
undefined,
`job-name=${jobName}`,
);
if (pods.body.items.length === 0) {
return 'No pods found for build job.';
}
const podName = pods.body.items[0].metadata?.name;
if (!podName) return 'Pod name not found.';
// Get logs from all containers (init + kaniko)
let allLogs = '';
const containers = [
...(pods.body.items[0].spec?.initContainers || []),
...(pods.body.items[0].spec?.containers || []),
];
for (const container of containers) {
try {
const logResponse = await coreApi.readNamespacedPodLog(
podName,
namespace,
container.name,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
500,
);
allLogs += `\n--- ${container.name} ---\n${logResponse.body}`;
} catch {
allLogs += `\n--- ${container.name} --- (no logs available)`;
}
}
return allLogs;
} catch (e: any) {
return `Failed to retrieve logs: ${e.message}`;
}
}
}
@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ClustersService } from './clusters.service';
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
@ApiTags('Clusters')
@ApiBearerAuth()
@Controller('clusters')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
export class ClustersController {
constructor(private readonly clustersService: ClustersService) {}
@Post()
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin only)' })
async create(@Body() dto: CreateClusterDto) {
return this.clustersService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List all clusters (Admin only)' })
async findAll() {
return this.clustersService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get cluster details (Admin only)' })
async findOne(@Param('id') id: string) {
return this.clustersService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update cluster configuration (Admin only)' })
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
return this.clustersService.update(id, dto);
}
@Post(':id/test')
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin only)' })
async testConnection(@Param('id') id: string) {
return this.clustersService.testClusterById(id);
}
@Delete(':id')
@ApiOperation({ summary: 'Remove a cluster (Admin only)' })
async delete(@Param('id') id: string) {
await this.clustersService.delete(id);
return { message: 'Cluster deleted' };
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClustersService } from './clusters.service';
import { ClustersController } from './clusters.controller';
import { Cluster } from './entities/cluster.entity';
@Module({
imports: [TypeOrmModule.forFeature([Cluster])],
controllers: [ClustersController],
providers: [ClustersService],
exports: [ClustersService],
})
export class ClustersModule {}
+142
View File
@@ -0,0 +1,142 @@
import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as k8s from '@kubernetes/client-node';
import { Cluster } from './entities/cluster.entity';
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
import { ClusterStatus } from '../common/enums';
@Injectable()
export class ClustersService {
private readonly logger = new Logger(ClustersService.name);
constructor(
@InjectRepository(Cluster)
private clustersRepository: Repository<Cluster>,
) {}
/**
* Test connection to a Kubernetes cluster using its kubeconfig.
* Calls the /version endpoint to verify the cluster is reachable.
*/
async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> {
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const versionApi = kc.makeApiClient(k8s.VersionApi);
const result = await versionApi.getCode();
const info = result.body;
this.logger.log(`Cluster connection OK: Kubernetes ${info.gitVersion}`);
return {
connected: true,
version: info.gitVersion,
};
} catch (err: any) {
const message = err?.body?.message || err?.message || 'Unknown connection error';
this.logger.warn(`Cluster connection failed: ${message}`);
return {
connected: false,
error: message,
};
}
}
async create(dto: CreateClusterDto): Promise<Cluster> {
// Validate kubeconfig by testing actual connection
const connectionTest = await this.testConnection(dto.kubeconfig);
if (!connectionTest.connected) {
throw new BadRequestException(
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
);
}
if (dto.isDefault === true) {
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
for (const c of existingDefaults) {
c.isDefault = false;
await this.clustersRepository.save(c);
}
}
const cluster = this.clustersRepository.create({
...dto,
status: ClusterStatus.ACTIVE, // Connection verified — mark active
});
const saved = await this.clustersRepository.save(cluster);
this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`);
return saved;
}
async findAll(): Promise<Cluster[]> {
return this.clustersRepository.find({
select: ['id', 'name', 'description', 'status', 'apiServer', 'region', 'provider', 'isDefault', 'createdAt'],
order: { createdAt: 'DESC' },
});
}
async findOne(id: string): Promise<Cluster> {
const cluster = await this.clustersRepository.findOne({ where: { id } });
if (!cluster) {
throw new NotFoundException('Cluster not found');
}
return cluster;
}
async getDefault(): Promise<Cluster> {
const cluster = await this.clustersRepository.findOne({ where: { isDefault: true } });
if (!cluster) {
throw new NotFoundException('No default cluster configured');
}
return cluster;
}
async update(id: string, dto: UpdateClusterDto): Promise<Cluster> {
const cluster = await this.findOne(id);
// If kubeconfig is being updated, re-test connection
if (dto.kubeconfig) {
const connectionTest = await this.testConnection(dto.kubeconfig);
if (!connectionTest.connected) {
throw new BadRequestException(
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
);
}
dto.status = ClusterStatus.ACTIVE;
this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`);
}
if (dto.isDefault === true) {
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
for (const c of existingDefaults) {
if (c.id !== id) {
c.isDefault = false;
await this.clustersRepository.save(c);
}
}
}
Object.assign(cluster, dto);
return this.clustersRepository.save(cluster);
}
/**
* Manually test connectivity to an existing cluster.
* Updates status to active/inactive based on result.
*/
async testClusterById(id: string): Promise<{ connected: boolean; version?: string; error?: string }> {
const cluster = await this.findOne(id);
const result = await this.testConnection(cluster.kubeconfig);
cluster.status = result.connected ? ClusterStatus.ACTIVE : ClusterStatus.INACTIVE;
await this.clustersRepository.save(cluster);
this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`);
return result;
}
async delete(id: string): Promise<void> {
const cluster = await this.findOne(id);
await this.clustersRepository.remove(cluster);
}
}
+94
View File
@@ -0,0 +1,94 @@
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ClusterStatus } from '../../common/enums';
export class CreateClusterDto {
@ApiProperty({ example: 'production-cluster' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'Main production K8s cluster' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 'apiVersion: v1\nclusters:\n- cluster:...' })
@IsString()
kubeconfig: string;
@ApiProperty({ example: 'https://k8s-api.example.com:6443' })
@IsString()
apiServer: string;
@ApiPropertyOptional({ example: 'us-east-1' })
@IsOptional()
@IsString()
region?: string;
@ApiPropertyOptional({ example: 'aws' })
@IsOptional()
@IsString()
provider?: string;
@ApiPropertyOptional({ example: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({ example: '4' })
@IsOptional()
@IsString()
defaultCpuLimit?: string;
@ApiPropertyOptional({ example: '8Gi' })
@IsOptional()
@IsString()
defaultMemoryLimit?: string;
@ApiPropertyOptional({ example: 10 })
@IsOptional()
@IsNumber()
maxAppsPerUser?: number;
}
export class UpdateClusterDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEnum(ClusterStatus)
status?: ClusterStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
kubeconfig?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
defaultCpuLimit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
defaultMemoryLimit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
maxAppsPerUser?: number;
}
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { ClusterStatus } from '../../common/enums';
@Entity('clusters')
export class Cluster {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ nullable: true })
description: string;
@Column({ type: 'enum', enum: ClusterStatus, default: ClusterStatus.ACTIVE })
status: ClusterStatus;
@Column({ type: 'text' })
kubeconfig: string; // Encrypted kubeconfig content
@Column()
apiServer: string;
@Column({ nullable: true })
region: string;
@Column({ nullable: true })
provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal'
@Column({ default: false })
isDefault: boolean;
// Resource quotas (cluster-level defaults for new namespaces)
@Column({ default: '4' })
defaultCpuLimit: string;
@Column({ default: '8Gi' })
defaultMemoryLimit: string;
@Column({ default: 10 })
maxAppsPerUser: number;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../enums';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+41
View File
@@ -0,0 +1,41 @@
// Shared enums used across the platform
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
}
export enum AppRuntime {
NODEJS = 'nodejs',
LARAVEL = 'laravel',
}
export enum DatabaseType {
MYSQL = 'mysql',
POSTGRESQL = 'postgresql',
NONE = 'none',
}
export enum DeploymentStatus {
PENDING = 'pending',
BUILDING = 'building',
BUILD_FAILED = 'build_failed',
DEPLOYING = 'deploying',
RUNNING = 'running',
FAILED = 'failed',
STOPPED = 'stopped',
DELETING = 'deleting',
}
export enum ClusterStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
MAINTENANCE = 'maintenance',
}
export enum BuildStatus {
QUEUED = 'queued',
IN_PROGRESS = 'in_progress',
SUCCESS = 'success',
FAILED = 'failed',
}
+23
View File
@@ -0,0 +1,23 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../enums';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.role === role);
}
}
+41
View File
@@ -0,0 +1,41 @@
export default () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '4000', 10),
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME || 'cloudhost',
password: process.env.DB_PASSWORD || 'cloudhost_secret',
name: process.env.DB_DATABASE || 'cloudhost',
},
jwt: {
secret: process.env.JWT_SECRET || 'default-jwt-secret',
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
registry: {
url: process.env.REGISTRY_URL || 'registry.example.com',
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'localhost:30500',
username: process.env.REGISTRY_USERNAME || 'admin',
password: process.env.REGISTRY_PASSWORD || '',
},
build: {
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
},
platform: {
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
uploadDir: process.env.UPLOAD_DIR || './uploads',
},
});
@@ -0,0 +1,65 @@
import {
Controller,
Get,
Post,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DeploymentsService } from './deployments.service';
import { RolesGuard } from '../common/guards/roles.guard';
@ApiTags('Deployments')
@ApiBearerAuth()
@Controller('deployments')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class DeploymentsController {
constructor(private readonly deploymentsService: DeploymentsService) {}
@Post('applications/:appId/deploy')
@ApiOperation({ summary: 'Trigger a new deployment' })
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
return this.deploymentsService.triggerDeployment(appId, req.user.id);
}
@Get('applications/:appId')
@ApiOperation({ summary: 'List deployments for an application' })
async findByApplication(@Param('appId') appId: string) {
return this.deploymentsService.findByApplication(appId);
}
@Get(':id')
@ApiOperation({ summary: 'Get deployment details' })
async findOne(@Param('id') id: string) {
return this.deploymentsService.findOne(id);
}
@Get('applications/:appId/logs')
@ApiOperation({ summary: 'Get application logs' })
async getLogs(@Param('appId') appId: string, @Request() req: any) {
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
}
@Post('applications/:appId/stop')
@ApiOperation({ summary: 'Stop an application' })
async stop(@Param('appId') appId: string, @Request() req: any) {
const deployment = await this.deploymentsService.stopDeployment(appId, req.user.id);
return { message: 'Application stopped', deployment };
}
@Post('applications/:appId/start')
@ApiOperation({ summary: 'Start a stopped application' })
async start(@Param('appId') appId: string, @Request() req: any) {
const deployment = await this.deploymentsService.startDeployment(appId, req.user.id);
return { message: 'Application started', deployment };
}
@Post('applications/:appId/restart')
@ApiOperation({ summary: 'Restart an application' })
async restart(@Param('appId') appId: string, @Request() req: any) {
await this.deploymentsService.restartDeployment(appId, req.user.id);
return { message: 'Application restarted' };
}
}
@@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DeploymentsService } from './deployments.service';
import { DeploymentsController } from './deployments.controller';
import { Deployment } from './entities/deployment.entity';
import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { BuildModule } from '../build/build.module';
@Module({
imports: [
TypeOrmModule.forFeature([Deployment]),
forwardRef(() => ApplicationsModule),
KubernetesModule,
BuildModule,
],
controllers: [DeploymentsController],
providers: [DeploymentsService],
exports: [DeploymentsService],
})
export class DeploymentsModule {}
@@ -0,0 +1,140 @@
import { Injectable, NotFoundException, Logger, Inject, forwardRef } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { BuildService } from '../build/build.service';
import { DeploymentStatus } from '../common/enums';
@Injectable()
export class DeploymentsService {
private readonly logger = new Logger(DeploymentsService.name);
constructor(
@InjectRepository(Deployment)
private deploymentsRepository: Repository<Deployment>,
@Inject(forwardRef(() => ApplicationsService))
private applicationsService: ApplicationsService,
private kubernetesService: KubernetesService,
private buildService: BuildService,
) {}
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
const app = await this.applicationsService.findOne(applicationId, userId);
// Create deployment record
const deployment = this.deploymentsRepository.create({
applicationId: app.id,
triggeredBy: userId,
imageTag: `${app.name}:${Date.now()}`,
status: DeploymentStatus.PENDING,
version: `v${Date.now()}`,
});
const saved = await this.deploymentsRepository.save(deployment);
// Trigger async build & deploy pipeline
this.executePipeline(saved.id, app).catch((error) => {
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
});
return saved;
}
private async executePipeline(deploymentId: string, app: any): Promise<void> {
try {
// Step 1: Build image
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
const imageUri = await this.buildService.buildImage(app);
// Step 2: Update app with new image tag
await this.applicationsService.updateImageTag(app.id, imageUri);
// Step 3: Deploy to Kubernetes
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
const k8sResources = await this.kubernetesService.deployApplication(app, imageUri);
// Step 4: Mark success
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.RUNNING,
k8sResources,
finishedAt: new Date(),
});
} catch (error: any) {
this.logger.error(`Deployment ${deploymentId} failed:`, error);
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.FAILED,
errorMessage: error.message,
finishedAt: new Date(),
});
}
}
async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
await this.deploymentsRepository.update(id, { status });
}
async findByApplication(applicationId: string): Promise<Deployment[]> {
return this.deploymentsRepository.find({
where: { applicationId },
order: { createdAt: 'DESC' },
});
}
async findOne(id: string): Promise<Deployment> {
const deployment = await this.deploymentsRepository.findOne({
where: { id },
relations: ['application'],
});
if (!deployment) {
throw new NotFoundException('Deployment not found');
}
return deployment;
}
async getLogs(applicationId: string, userId: string): Promise<string> {
const app = await this.applicationsService.findOne(applicationId, userId);
return this.kubernetesService.getPodLogs(app);
}
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
await this.kubernetesService.scaleDeployment(app, 0);
// Update the latest deployment status to stopped
const latest = await this.deploymentsRepository.findOne({
where: { applicationId },
order: { createdAt: 'DESC' },
});
if (latest) {
latest.status = DeploymentStatus.STOPPED;
await this.deploymentsRepository.save(latest);
}
return latest;
}
async startDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
await this.kubernetesService.scaleDeployment(app, app.replicas || 1);
// Update the latest deployment status to running
const latest = await this.deploymentsRepository.findOne({
where: { applicationId },
order: { createdAt: 'DESC' },
});
if (latest) {
latest.status = DeploymentStatus.RUNNING;
await this.deploymentsRepository.save(latest);
}
return latest;
}
async restartDeployment(applicationId: string, userId: string): Promise<void> {
const app = await this.applicationsService.findOne(applicationId, userId);
await this.kubernetesService.restartDeployment(app);
}
async deleteAllForApplication(applicationId: string): Promise<void> {
await this.deploymentsRepository.delete({ applicationId });
}
}
@@ -0,0 +1,58 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { DeploymentStatus } from '../../common/enums';
import { Application } from '../../applications/entities/application.entity';
@Entity('deployments')
export class Deployment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'enum', enum: DeploymentStatus, default: DeploymentStatus.PENDING })
status: DeploymentStatus;
@Column()
imageTag: string;
@Column({ nullable: true })
version: string;
@Column({ type: 'jsonb', nullable: true })
k8sResources: Record<string, any>; // Snapshot of generated K8s manifests
@Column({ type: 'text', nullable: true })
buildLog: string;
@Column({ type: 'text', nullable: true })
deployLog: string;
@Column({ nullable: true })
errorMessage: string;
// Relations
@ManyToOne(() => Application, (app: Application) => app.deployments, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicationId' })
application: Application;
@Column()
applicationId: string;
@Column()
triggeredBy: string; // userId who triggered the deployment
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({ nullable: true })
finishedAt: Date;
}
@@ -0,0 +1,10 @@
import { Module, forwardRef } from '@nestjs/common';
import { KubernetesService } from './kubernetes.service';
import { ClustersModule } from '../clusters/clusters.module';
@Module({
imports: [forwardRef(() => ClustersModule)],
providers: [KubernetesService],
exports: [KubernetesService],
})
export class KubernetesModule {}
@@ -0,0 +1,558 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
import * as fs from 'fs';
import * as path from 'path';
import * as Handlebars from 'handlebars';
import { ClustersService } from '../clusters/clusters.service';
import { Application } from '../applications/entities/application.entity';
import { AppRuntime, DatabaseType } from '../common/enums';
interface ManifestContext {
appName: string;
namespace: string;
image: string;
port: number;
replicas: number;
cpuRequest: string;
cpuLimit: string;
memoryRequest: string;
memoryLimit: string;
envVars: Record<string, string>;
runtime: AppRuntime;
databaseType: DatabaseType;
domain: string;
subdomain: string;
}
@Injectable()
export class KubernetesService implements OnModuleInit {
private readonly logger = new Logger(KubernetesService.name);
private templates: Map<string, Handlebars.TemplateDelegate> = new Map();
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
) {}
onModuleInit() {
this.loadTemplates();
}
private loadTemplates(): void {
const templatesDir = path.join(__dirname, '..', '..', 'templates');
const templateFiles = ['namespace', 'deployment', 'service', 'ingress', 'database', 'pvc', 'secret'];
for (const name of templateFiles) {
const filePath = path.join(templatesDir, `${name}.yaml.hbs`);
if (fs.existsSync(filePath)) {
const template = fs.readFileSync(filePath, 'utf-8');
this.templates.set(name, Handlebars.compile(template));
this.logger.log(`Loaded template: ${name}`);
}
}
}
private async getK8sClient(clusterId?: string): Promise<{
coreApi: k8s.CoreV1Api;
appsApi: k8s.AppsV1Api;
networkingApi: k8s.NetworkingV1Api;
}> {
const cluster = clusterId
? await this.clustersService.findOne(clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
return {
coreApi: kc.makeApiClient(k8s.CoreV1Api),
appsApi: kc.makeApiClient(k8s.AppsV1Api),
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
};
}
async deployApplication(app: Application, imageUri: string): Promise<Record<string, any>> {
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const domain = this.configService.get('platform.domain');
const context: ManifestContext = {
appName: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
image: imageUri,
port: app.port,
replicas: app.replicas,
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
envVars: app.envVars || {},
runtime: app.runtime,
databaseType: app.databaseType,
domain: domain,
subdomain: app.subdomain || app.name,
};
const manifests: Record<string, any> = {};
try {
// 1. Ensure namespace exists
await this.ensureNamespace(coreApi, context.namespace);
// 2. Create/Update secrets for env vars
if (Object.keys(context.envVars).length > 0) {
manifests.secret = await this.applySecret(coreApi, context);
}
// 3. Deploy database if needed
if (context.databaseType !== DatabaseType.NONE) {
manifests.database = await this.deployDatabase(coreApi, appsApi, context);
}
// 4. Create Deployment
manifests.deployment = await this.applyDeployment(appsApi, context);
// 5. Create Service
manifests.service = await this.applyService(coreApi, context);
// 6. Create Ingress
manifests.ingress = await this.applyIngress(networkingApi, context);
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace}`);
} catch (error: any) {
this.logger.error(`Failed to deploy ${app.name}:`, error.body || error.message);
throw error;
}
return manifests;
}
private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
try {
await coreApi.readNamespace(namespace);
} catch {
await coreApi.createNamespace({
metadata: { name: namespace },
});
this.logger.log(`Created namespace: ${namespace}`);
}
}
private async applySecret(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
const secretData: Record<string, string> = {};
for (const [key, value] of Object.entries(ctx.envVars)) {
secretData[key] = Buffer.from(value).toString('base64');
}
const secret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: `${ctx.appName}-env`,
namespace: ctx.namespace,
},
data: secretData,
};
try {
await coreApi.replaceNamespacedSecret(`${ctx.appName}-env`, ctx.namespace, secret);
} catch {
await coreApi.createNamespacedSecret(ctx.namespace, secret);
}
return secret;
}
private async applyDeployment(appsApi: k8s.AppsV1Api, ctx: ManifestContext): Promise<any> {
const envFrom: any[] = [];
if (Object.keys(ctx.envVars).length > 0) {
envFrom.push({ secretRef: { name: `${ctx.appName}-env` } });
}
// Add database connection env vars
const extraEnv: any[] = [];
if (ctx.databaseType === DatabaseType.POSTGRESQL) {
extraEnv.push(
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
{ name: 'DB_PORT', value: '5432' },
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
{ name: 'DB_USER', value: 'appuser' },
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
);
} else if (ctx.databaseType === DatabaseType.MYSQL) {
extraEnv.push(
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
{ name: 'DB_PORT', value: '3306' },
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
{ name: 'DB_USER', value: 'appuser' },
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
);
}
const deployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: ctx.appName,
namespace: ctx.namespace,
labels: { app: ctx.appName, runtime: ctx.runtime },
},
spec: {
replicas: ctx.replicas,
selector: { matchLabels: { app: ctx.appName } },
template: {
metadata: { labels: { app: ctx.appName, runtime: ctx.runtime } },
spec: {
containers: [
{
name: ctx.appName,
image: ctx.image,
ports: [{ containerPort: ctx.port }],
envFrom,
env: extraEnv,
resources: {
requests: { cpu: ctx.cpuRequest, memory: ctx.memoryRequest },
limits: { cpu: ctx.cpuLimit, memory: ctx.memoryLimit },
},
readinessProbe: {
httpGet: { path: '/health', port: ctx.port as any },
initialDelaySeconds: 10,
periodSeconds: 5,
},
livenessProbe: {
httpGet: { path: '/health', port: ctx.port as any },
initialDelaySeconds: 30,
periodSeconds: 10,
},
},
],
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment(ctx.appName, ctx.namespace, deployment);
} catch {
await appsApi.createNamespacedDeployment(ctx.namespace, deployment);
}
return deployment;
}
private async applyService(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
const service: k8s.V1Service = {
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: ctx.appName,
namespace: ctx.namespace,
},
spec: {
selector: { app: ctx.appName },
ports: [{ port: 80, targetPort: ctx.port as any, protocol: 'TCP' }],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService(ctx.appName, ctx.namespace, service);
} catch {
await coreApi.createNamespacedService(ctx.namespace, service);
}
return service;
}
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext): Promise<any> {
const ingress: k8s.V1Ingress = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: ctx.appName,
namespace: ctx.namespace,
annotations: {
'kubernetes.io/ingress.class': 'nginx',
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
},
},
spec: {
rules: [
{
host: `${ctx.subdomain}.${ctx.domain}`,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: {
service: {
name: ctx.appName,
port: { number: 80 },
},
},
},
],
},
},
],
tls: [
{
hosts: [`${ctx.subdomain}.${ctx.domain}`],
secretName: `${ctx.appName}-tls`,
},
],
},
};
try {
await networkingApi.replaceNamespacedIngress(ctx.appName, ctx.namespace, ingress);
} catch {
await networkingApi.createNamespacedIngress(ctx.namespace, ingress);
}
return ingress;
}
private async deployDatabase(
coreApi: k8s.CoreV1Api,
appsApi: k8s.AppsV1Api,
ctx: ManifestContext,
): Promise<any> {
const dbPassword = this.generatePassword();
const dbName = `${ctx.appName}-db`;
// Create DB secret
await this.createDbSecret(coreApi, ctx.namespace, ctx.appName, dbPassword);
// Create PVC for DB
await this.createPVC(coreApi, ctx.namespace, dbName, '5Gi');
// Deploy database
const isPostgres = ctx.databaseType === DatabaseType.POSTGRESQL;
const image = isPostgres ? 'postgres:16-alpine' : 'mysql:8.0';
const port = isPostgres ? 5432 : 3306;
const envVars = isPostgres
? [
{ name: 'POSTGRES_DB', value: ctx.appName.replace(/-/g, '_') },
{ name: 'POSTGRES_USER', value: 'appuser' },
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
]
: [
{ name: 'MYSQL_DATABASE', value: ctx.appName.replace(/-/g, '_') },
{ name: 'MYSQL_USER', value: 'appuser' },
{ name: 'MYSQL_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
{ name: 'MYSQL_ROOT_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
];
const dbDeployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: { name: dbName, namespace: ctx.namespace },
spec: {
replicas: 1,
selector: { matchLabels: { app: dbName } },
template: {
metadata: { labels: { app: dbName } },
spec: {
containers: [
{
name: dbName,
image,
ports: [{ containerPort: port }],
env: envVars,
volumeMounts: [{ name: 'db-storage', mountPath: isPostgres ? '/var/lib/postgresql/data' : '/var/lib/mysql' }],
resources: {
requests: { cpu: '100m', memory: '256Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
},
],
volumes: [
{ name: 'db-storage', persistentVolumeClaim: { claimName: dbName } },
],
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment(dbName, ctx.namespace, dbDeployment);
} catch {
await appsApi.createNamespacedDeployment(ctx.namespace, dbDeployment);
}
// Create DB Service
const dbService: k8s.V1Service = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name: dbName, namespace: ctx.namespace },
spec: {
selector: { app: dbName },
ports: [{ port, targetPort: port as any, protocol: 'TCP' }],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService(dbName, ctx.namespace, dbService);
} catch {
await coreApi.createNamespacedService(ctx.namespace, dbService);
}
return { deployment: dbDeployment, service: dbService };
}
private async createDbSecret(
coreApi: k8s.CoreV1Api,
namespace: string,
appName: string,
password: string,
): Promise<void> {
const secret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: `${appName}-db-secret`, namespace },
data: { password: Buffer.from(password).toString('base64') },
};
try {
await coreApi.replaceNamespacedSecret(`${appName}-db-secret`, namespace, secret);
} catch {
await coreApi.createNamespacedSecret(namespace, secret);
}
}
private async createPVC(
coreApi: k8s.CoreV1Api,
namespace: string,
name: string,
size: string,
): Promise<void> {
const pvc: k8s.V1PersistentVolumeClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: size } },
},
};
try {
await coreApi.readNamespacedPersistentVolumeClaim(name, namespace);
// PVC exists, don't recreate
} catch {
await coreApi.createNamespacedPersistentVolumeClaim(namespace, pvc);
}
}
async getPodLogs(app: Application): Promise<string> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pods = await coreApi.listNamespacedPod(
namespace,
undefined,
undefined,
undefined,
undefined,
`app=${app.name}`,
);
if (pods.body.items.length === 0) {
return 'No pods found for this application.';
}
const podName = pods.body.items[0].metadata?.name;
if (!podName) return 'Pod name not found.';
const logResponse = await coreApi.readNamespacedPodLog(
podName,
namespace,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
200,
);
return logResponse.body;
}
async scaleDeployment(app: Application, replicas: number): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
await appsApi.patchNamespacedDeployment(
app.name,
namespace,
{ spec: { replicas } },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
);
}
async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
await appsApi.patchNamespacedDeployment(
app.name,
namespace,
{
spec: {
template: {
metadata: {
annotations: {
'kubectl.kubernetes.io/restartedAt': new Date().toISOString(),
},
},
},
},
},
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
);
}
async deleteApplication(app: Application): Promise<void> {
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
try {
await appsApi.deleteNamespacedDeployment(app.name, namespace);
await coreApi.deleteNamespacedService(app.name, namespace);
await networkingApi.deleteNamespacedIngress(app.name, namespace);
// Delete DB resources if applicable
if (app.databaseType !== DatabaseType.NONE) {
const dbName = `${app.name}-db`;
await appsApi.deleteNamespacedDeployment(dbName, namespace);
await coreApi.deleteNamespacedService(dbName, namespace);
await coreApi.deleteNamespacedPersistentVolumeClaim(dbName, namespace);
await coreApi.deleteNamespacedSecret(`${app.name}-db-secret`, namespace);
}
await coreApi.deleteNamespacedSecret(`${app.name}-env`, namespace);
} catch (error: any) {
this.logger.warn(`Error cleaning up resources for ${app.name}: ${error.message}`);
}
}
private generatePassword(length = 24): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
let password = '';
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
// Prevent Node.js from crashing on unhandled errors
process.on('unhandledRejection', (reason, promise) => {
console.error('⚠️ Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('⚠️ Uncaught Exception:', error);
// Don't exit — let NestJS handle recovery
});
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'],
});
// Enable graceful shutdown hooks
app.enableShutdownHooks();
// Security
app.use(helmet());
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
});
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// API prefix
app.setGlobalPrefix('api/v1');
// Swagger
const config = new DocumentBuilder()
.setTitle('CloudHost PaaS API')
.setDescription('Self-service PaaS platform API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT || 4000;
await app.listen(port);
console.log(`🚀 CloudHost API running on http://localhost:${port}`);
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
}
bootstrap();
+56
View File
@@ -0,0 +1,56 @@
import { NestFactory } from '@nestjs/core';
import * as bcrypt from 'bcrypt';
import { AppModule } from './app.module';
import { UsersService } from './users/users.service';
import { UserRole } from './common/enums';
/**
* Seed script — creates the initial super admin user.
*
* Usage:
* npx ts-node -r tsconfig-paths/register src/seed.ts
*
* Or via npm script:
* npm run seed
*
* Environment variables (or defaults):
* ADMIN_EMAIL=admin@cloudhost.local
* ADMIN_PASSWORD=Admin123!
*/
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
const usersService = app.get(UsersService);
const email = process.env.ADMIN_EMAIL || 'admin@cloudhost.local';
const password = process.env.ADMIN_PASSWORD || 'Admin123!';
const existing = await usersService.findByEmail(email);
if (existing) {
console.log(`⚠️ Admin user already exists: ${email} (role: ${existing.role})`);
if (existing.role !== UserRole.ADMIN) {
await usersService.update(existing.id, { role: UserRole.ADMIN });
console.log(`✅ Promoted ${email} to admin`);
}
} else {
const hashedPassword = await bcrypt.hash(password, 12);
await usersService.create({
email,
password: hashedPassword,
firstName: 'Super',
lastName: 'Admin',
role: UserRole.ADMIN,
});
console.log(`✅ Admin user created: ${email}`);
}
console.log(`\n📋 Admin credentials:`);
console.log(` Email: ${email}`);
console.log(` Password: ${password}`);
await app.close();
}
bootstrap().catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
});
+46
View File
@@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { UserRole } from '../../common/enums';
import { Application } from '../../applications/entities/application.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
role: UserRole;
@Column({ default: true })
isActive: boolean;
@Column({ nullable: true })
namespace: string; // K8s namespace assigned to user
@OneToMany(() => Application, (app: Application) => app.user)
applications: Application[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+57
View File
@@ -0,0 +1,57 @@
import {
Controller,
Get,
Patch,
Param,
Body,
UseGuards,
Request,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
@ApiTags('Users')
@ApiBearerAuth()
@Controller('users')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({ summary: 'Get current user profile' })
async getProfile(@Request() req: any) {
const user = await this.usersService.findById(req.user.id);
if (user) {
const { password, ...result } = user;
return result;
}
return null;
}
@Get()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all users (Admin only)' })
async findAll() {
return this.usersService.findAll();
}
@Patch(':id/deactivate')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Deactivate a user (Admin only)' })
async deactivate(@Param('id') id: string) {
await this.usersService.deactivate(id);
return { message: 'User deactivated' };
}
@Patch(':id/activate')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Activate a user (Admin only)' })
async activate(@Param('id') id: string) {
await this.usersService.activate(id);
return { message: 'User activated' };
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+52
View File
@@ -0,0 +1,52 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { UserRole } from '../common/enums';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async create(data: Partial<User>): Promise<User> {
const user = this.usersRepository.create(data);
// Assign a unique namespace based on user ID
const saved = await this.usersRepository.save(user);
saved.namespace = `user-${saved.id.split('-')[0]}`;
return this.usersRepository.save(saved);
}
async findByEmail(email: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { email } });
}
async findById(id: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { id } });
}
async findAll(): Promise<User[]> {
return this.usersRepository.find({
select: ['id', 'email', 'firstName', 'lastName', 'role', 'isActive', 'namespace', 'createdAt'],
});
}
async update(id: string, data: Partial<User>): Promise<User> {
const user = await this.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
Object.assign(user, data);
return this.usersRepository.save(user);
}
async deactivate(id: string): Promise<void> {
await this.usersRepository.update(id, { isActive: false });
}
async activate(id: string): Promise<void> {
await this.usersRepository.update(id, { isActive: true });
}
}
+132
View File
@@ -0,0 +1,132 @@
{{#if isPostgres}}
# --- PostgreSQL Deployment ---
apiVersion: apps/v1
kind: Deployment
metadata:
name: "{{appName}}-db"
namespace: "{{namespace}}"
labels:
app: "{{appName}}-db"
managed-by: cloudhost
spec:
replicas: 1
selector:
matchLabels:
app: "{{appName}}-db"
template:
metadata:
labels:
app: "{{appName}}-db"
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: "{{dbName}}"
- name: POSTGRES_USER
value: "appuser"
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: "{{appName}}-db-secret"
key: password
volumeMounts:
- name: db-storage
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
volumes:
- name: db-storage
persistentVolumeClaim:
claimName: "{{appName}}-db"
---
apiVersion: v1
kind: Service
metadata:
name: "{{appName}}-db"
namespace: "{{namespace}}"
spec:
selector:
app: "{{appName}}-db"
ports:
- port: 5432
targetPort: 5432
type: ClusterIP
{{/if}}
{{#if isMysql}}
# --- MySQL Deployment ---
apiVersion: apps/v1
kind: Deployment
metadata:
name: "{{appName}}-db"
namespace: "{{namespace}}"
labels:
app: "{{appName}}-db"
managed-by: cloudhost
spec:
replicas: 1
selector:
matchLabels:
app: "{{appName}}-db"
template:
metadata:
labels:
app: "{{appName}}-db"
spec:
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
env:
- name: MYSQL_DATABASE
value: "{{dbName}}"
- name: MYSQL_USER
value: "appuser"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: "{{appName}}-db-secret"
key: password
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: "{{appName}}-db-secret"
key: password
volumeMounts:
- name: db-storage
mountPath: /var/lib/mysql
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
volumes:
- name: db-storage
persistentVolumeClaim:
claimName: "{{appName}}-db"
---
apiVersion: v1
kind: Service
metadata:
name: "{{appName}}-db"
namespace: "{{namespace}}"
spec:
selector:
app: "{{appName}}-db"
ports:
- port: 3306
targetPort: 3306
type: ClusterIP
{{/if}}
+65
View File
@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: "{{appName}}"
namespace: "{{namespace}}"
labels:
app: "{{appName}}"
runtime: "{{runtime}}"
managed-by: cloudhost
spec:
replicas: {{replicas}}
selector:
matchLabels:
app: "{{appName}}"
template:
metadata:
labels:
app: "{{appName}}"
runtime: "{{runtime}}"
spec:
containers:
- name: "{{appName}}"
image: "{{image}}"
ports:
- containerPort: {{port}}
{{#if hasEnvVars}}
envFrom:
- secretRef:
name: "{{appName}}-env"
{{/if}}
{{#if hasDatabase}}
env:
- name: DB_HOST
value: "{{appName}}-db"
- name: DB_PORT
value: "{{dbPort}}"
- name: DB_NAME
value: "{{dbName}}"
- name: DB_USER
value: "appuser"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: "{{appName}}-db-secret"
key: password
{{/if}}
resources:
requests:
cpu: "{{cpuRequest}}"
memory: "{{memoryRequest}}"
limits:
cpu: "{{cpuLimit}}"
memory: "{{memoryLimit}}"
readinessProbe:
httpGet:
path: /health
port: {{port}}
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: {{port}}
initialDelaySeconds: 30
periodSeconds: 10
+28
View File
@@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: "{{appName}}"
namespace: "{{namespace}}"
labels:
app: "{{appName}}"
managed-by: cloudhost
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
tls:
- hosts:
- "{{subdomain}}.{{domain}}"
secretName: "{{appName}}-tls"
rules:
- host: "{{subdomain}}.{{domain}}"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: "{{appName}}"
port:
number: 80
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: "{{namespace}}"
labels:
managed-by: cloudhost
user: "{{userId}}"
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "{{name}}"
namespace: "{{namespace}}"
labels:
managed-by: cloudhost
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: "{{size}}"
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Secret
metadata:
name: "{{name}}"
namespace: "{{namespace}}"
labels:
managed-by: cloudhost
type: Opaque
data:
{{#each data}}
{{@key}}: "{{this}}"
{{/each}}
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: "{{appName}}"
namespace: "{{namespace}}"
labels:
app: "{{appName}}"
managed-by: cloudhost
spec:
selector:
app: "{{appName}}"
ports:
- port: 80
targetPort: {{port}}
protocol: TCP
type: ClusterIP
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
+98
View File
@@ -0,0 +1,98 @@
version: '3.8'
services:
# ─── PostgreSQL ──────────────────────────────
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: cloudhost
POSTGRES_USER: cloudhost
POSTGRES_PASSWORD: cloudhost_secret
ports:
- '5432:5432'
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U cloudhost']
interval: 5s
timeout: 3s
retries: 10
# ─── Redis ───────────────────────────────────
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- '6379:6379'
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
# ─── Backend (NestJS) ───────────────────────
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- '4000:4000'
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 4000
# Database
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: cloudhost
DB_PASSWORD: cloudhost_secret
DB_NAME: cloudhost
# JWT
JWT_SECRET: change-this-to-a-long-random-string
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
# Container Registry
REGISTRY_URL: registry.example.com
REGISTRY_USERNAME: ''
REGISTRY_PASSWORD: ''
# Build
BUILD_NAMESPACE: cloudhost-builds
KANIKO_IMAGE: gcr.io/kaniko-project/executor:latest
# Platform
PLATFORM_DOMAIN: apps.localhost
volumes:
- /tmp/cloudhost-uploads:/app/uploads
# ─── Frontend (Next.js) ─────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- '3000:3000'
depends_on:
- backend
environment:
NEXT_PUBLIC_API_URL: http://localhost:4000
volumes:
pg_data:
redis_data:
+4
View File
@@ -0,0 +1,4 @@
node_modules
.next
.env.local
*.log
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:4000
+40
View File
@@ -0,0 +1,40 @@
# ---- Stage 1: Dependencies ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ---- Stage 2: Build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---- Stage 3: Production ----
FROM node:20-alpine AS production
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/public ./public
COPY --from=builder --chown=appuser:appgroup /app/.next/standalone ./
COPY --from=builder --chown=appuser:appgroup /app/.next/static ./.next/static
USER appuser
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+14
View File
@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/api/:path*`,
},
];
},
};
module.exports = nextConfig;
+6314
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "cloudhost-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@heroicons/react": "^2.1.0",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.0",
"clsx": "^2.1.0",
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.0",
"react-hot-toast": "^2.4.1",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-next": "14.1.0",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,201 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import toast from 'react-hot-toast';
import type { Cluster } from '@/types';
export default function AdminClustersPage() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
description: '',
apiServer: '',
kubeconfig: '',
region: '',
provider: '',
isDefault: false,
});
const { data: clusters = [], isLoading } = useQuery<Cluster[]>({
queryKey: ['admin-clusters'],
queryFn: () => api.get('/clusters').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: typeof form) => api.post('/clusters', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster added & connection verified ✓');
setShowForm(false);
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', isDefault: false });
},
onError: (err: any) => {
const message = err?.response?.data?.message || 'Failed to add cluster';
toast.error(message);
},
});
const testMutation = useMutation({
mutationFn: (id: string) => {
setTestingId(id);
return api.post(`/clusters/${id}/test`);
},
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
const data = res.data;
if (data.connected) {
toast.success(`Connection OK — Kubernetes ${data.version}`);
} else {
toast.error(`Connection failed: ${data.error}`);
}
setTestingId(null);
},
onError: () => {
toast.error('Failed to test connection');
setTestingId(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster removed');
},
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Cluster Management</h1>
<button onClick={() => setShowForm(!showForm)} className="btn-primary">
{showForm ? 'Cancel' : '+ Add Cluster'}
</button>
</div>
{showForm && (
<div className="card space-y-4">
<h2 className="text-lg font-semibold">Register New Cluster</h2>
<p className="text-sm text-gray-500">
The system will verify the Kubernetes connection before registering. Only clusters with valid kubeconfig will be marked as active.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Server URL</label>
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
<option value="">Select provider</option>
<option value="aws">AWS (EKS)</option>
<option value="gcp">GCP (GKE)</option>
<option value="azure">Azure (AKS)</option>
<option value="bare-metal">Bare Metal</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kubeconfig (YAML)</label>
<textarea
className="input-field font-mono text-xs"
rows={8}
placeholder="Paste your kubeconfig here..."
value={form.kubeconfig}
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isDefault"
checked={form.isDefault}
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
/>
<label htmlFor="isDefault" className="text-sm text-gray-700">Set as default cluster</label>
</div>
<button
onClick={() => createMutation.mutate(form)}
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
className="btn-primary"
>
{createMutation.isPending ? '🔄 Verifying connection & adding...' : 'Add Cluster'}
</button>
</div>
)}
{isLoading ? (
<div className="card text-center py-12 text-gray-500">Loading clusters...</div>
) : clusters.length === 0 ? (
<div className="card text-center py-12">
<p className="text-gray-500">No clusters registered yet.</p>
</div>
) : (
<div className="grid gap-4">
{clusters.map((cluster) => (
<div key={cluster.id} className="card">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
cluster.status === 'active' ? 'bg-green-100' : 'bg-red-100'
}`}>
{cluster.status === 'active' ? '✅' : '❌'}
</div>
<div>
<div className="flex items-center space-x-2">
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
{cluster.isDefault && (
<span className="px-2 py-0.5 bg-primary-100 text-primary-700 text-xs rounded-full font-medium">Default</span>
)}
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
cluster.status === 'active' ? 'bg-green-100 text-green-700'
: cluster.status === 'maintenance' ? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{cluster.status}
</span>
</div>
<p className="text-sm text-gray-500">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => testMutation.mutate(cluster.id)}
disabled={testingId === cluster.id}
className="text-sm px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors disabled:opacity-50"
>
{testingId === cluster.id ? '🔄 Testing...' : '🔌 Test Connection'}
</button>
<button
onClick={() => { if (confirm('Remove this cluster?')) deleteMutation.mutate(cluster.id); }}
className="text-sm text-red-600 hover:text-red-800"
>
Remove
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,94 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import toast from 'react-hot-toast';
import type { User } from '@/types';
export default function AdminUsersPage() {
const queryClient = useQueryClient();
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['admin-users'],
queryFn: () => api.get('/users').then((r) => r.data),
});
const toggleActive = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('User updated');
},
});
const changeRole = useMutation({
mutationFn: ({ id, role }: { id: string; role: string }) =>
api.patch(`/users/${id}/role`, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('Role updated');
},
});
if (isLoading) {
return <div className="card text-center py-12 text-gray-500">Loading users...</div>;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4">
<select
className="text-sm border border-gray-300 rounded px-2 py-1"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 font-mono">{user.namespace || '—'}</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,436 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api';
import toast from 'react-hot-toast';
import type { Application, Deployment } from '@/types';
import { useState, useRef, useCallback, useEffect } from 'react';
const statusColors: Record<string, string> = {
running: 'bg-green-100 text-green-700',
pending: 'bg-yellow-100 text-yellow-700',
building: 'bg-blue-100 text-blue-700',
deploying: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
build_failed: 'bg-red-100 text-red-700',
stopped: 'bg-gray-100 text-gray-700',
};
export default function AppDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const appId = params.id as string;
const [showLogs, setShowLogs] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const logsEndRef = useRef<HTMLPreElement>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const { data: app, isLoading } = useQuery<Application>({
queryKey: ['application', appId],
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
});
const { data: deployments = [] } = useQuery<Deployment[]>({
queryKey: ['deployments', appId],
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
refetchInterval: 5000, // Poll for status updates
});
const { data: logsData } = useQuery<{ logs: string }>({
queryKey: ['logs', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
enabled: showLogs,
refetchInterval: showLogs ? 3000 : false,
});
// Auto-scroll logs to bottom
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollTop = logsEndRef.current.scrollHeight;
}
}, [logsData]);
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
queryClient.invalidateQueries({ queryKey: ['application', appId] });
};
const deployMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`),
onSuccess: () => {
invalidateAll();
toast.success('Deployment triggered!');
},
onError: () => toast.error('Failed to trigger deployment'),
});
const stopMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/stop`),
onSuccess: () => {
invalidateAll();
toast.success('Application stopped');
},
onError: () => toast.error('Failed to stop application'),
});
const startMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/start`),
onSuccess: () => {
invalidateAll();
toast.success('Application started');
},
onError: () => toast.error('Failed to start application'),
});
const restartMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/restart`),
onSuccess: () => {
invalidateAll();
toast.success('Application restarting...');
},
onError: () => toast.error('Failed to restart application'),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/applications/${appId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
toast.success('Application deleted');
router.push('/dashboard/apps');
},
onError: () => toast.error('Failed to delete application'),
});
const uploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application', appId] });
toast.success('Source code uploaded successfully!');
setUploadProgress(0);
},
onError: () => {
toast.error('Failed to upload source code');
setUploadProgress(0);
},
});
const handleFileUpload = useCallback((file: File) => {
if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
toast.error('Please upload a .zip or .tar.gz file');
return;
}
if (file.size > 100 * 1024 * 1024) {
toast.error('File size must be less than 100MB');
return;
}
uploadMutation.mutate(file);
}, [uploadMutation]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFileUpload(file);
}, [handleFileUpload]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
if (isLoading || !app) {
return <div className="card text-center py-12 text-gray-500">Loading...</div>;
}
const latestStatus = deployments[0]?.status || 'pending';
const hasDeployments = deployments.length > 0;
const isStopped = latestStatus === 'stopped';
const isRunning = latestStatus === 'running';
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
const handleDelete = () => {
if (confirm(`Are you sure you want to delete "${app.name}"?\n\nThis will permanently remove:\n• All Kubernetes resources (pods, services, ingress)\n• Database and volumes\n• All deployment records\n• Uploaded source code`)) {
deleteMutation.mutate();
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center text-2xl">
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{app.name}</h1>
<p className="text-sm text-gray-500">
{app.runtime} · {app.subdomain}.apps.cloudhost.local
</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
{latestStatus}
</span>
</div>
<div className="flex space-x-3">
{/* Only show deploy button if NEVER deployed before */}
{!hasDeployments && (
<button
onClick={() => deployMutation.mutate()}
disabled={deployMutation.isPending || (!app.codePath && !app.gitUrl)}
className="btn-primary text-sm disabled:opacity-50"
>
{deployMutation.isPending ? '⏳ Deploying...' : '🚀 Deploy'}
</button>
)}
{/* After first deploy: show start/stop/restart */}
{hasDeployments && (
<>
{isStopped ? (
<button
onClick={() => startMutation.mutate()}
disabled={startMutation.isPending}
className="btn-primary text-sm disabled:opacity-50"
>
{startMutation.isPending ? '⏳ Starting...' : '▶️ Start'}
</button>
) : (
<button
onClick={() => stopMutation.mutate()}
disabled={stopMutation.isPending || isInProgress}
className="btn-secondary text-sm disabled:opacity-50"
>
{stopMutation.isPending ? '⏳ Stopping...' : '⏹️ Stop'}
</button>
)}
{isRunning && (
<button
onClick={() => restartMutation.mutate()}
disabled={restartMutation.isPending}
className="btn-secondary text-sm disabled:opacity-50"
>
{restartMutation.isPending ? '⏳...' : '🔄 Restart'}
</button>
)}
</>
)}
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="px-4 py-2 rounded-lg text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 border border-red-200 disabled:opacity-50 transition-colors"
>
{deleteMutation.isPending ? '⏳ Deleting...' : ' Delete'}
</button>
</div>
</div>
{/* Status & Config */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Configuration</h2>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Runtime</dt>
<dd className="text-sm font-medium text-gray-900">{app.runtime}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Database</dt>
<dd className="text-sm font-medium text-gray-900">{app.databaseType}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Replicas</dt>
<dd className="text-sm font-medium text-gray-900">{app.replicas}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">CPU</dt>
<dd className="text-sm font-medium text-gray-900">{app.cpuRequest} / {app.cpuLimit}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Memory</dt>
<dd className="text-sm font-medium text-gray-900">{app.memoryRequest} / {app.memoryLimit}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Port</dt>
<dd className="text-sm font-medium text-gray-900">{app.port}</dd>
</div>
{app.latestImageTag && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Image</dt>
<dd className="text-xs font-mono text-gray-700 truncate max-w-[200px]" title={app.latestImageTag}>
{app.latestImageTag}
</dd>
</div>
)}
</dl>
</div>
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment History</h2>
{deployments.length === 0 ? (
<div className="text-center py-8">
<div className="text-3xl mb-2">📦</div>
<p className="text-gray-500 text-sm">No deployments yet</p>
<p className="text-gray-400 text-xs mt-1">Upload source code and click Deploy to get started</p>
</div>
) : (
<div className="space-y-3 max-h-72 overflow-y-auto">
{deployments.slice(0, 10).map((d) => (
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-900">{d.version || d.imageTag}</p>
<p className="text-xs text-gray-500">
{new Date(d.createdAt).toLocaleString()}
</p>
{d.errorMessage && (
<p className="text-xs text-red-500 mt-1 truncate max-w-[250px]" title={d.errorMessage}>
{d.errorMessage}
</p>
)}
</div>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${statusColors[d.status] || 'bg-gray-100'}`}>
{d.status}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Source Code Upload */}
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4">📦 Source Code</h2>
{app.codePath ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
</div>
<div>
<p className="text-sm font-medium text-green-800">Source code uploaded</p>
<p className="text-xs text-green-600">{app.codePath.split('/').pop()}</p>
</div>
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="text-sm text-green-700 hover:text-green-900 font-medium"
>
Replace
</button>
</div>
) : app.gitUrl ? (
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-xl mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
🔗
</div>
<div>
<p className="text-sm font-medium text-blue-800">Git repository connected</p>
<p className="text-xs text-blue-600 font-mono">{app.gitUrl}</p>
</div>
</div>
</div>
) : null}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all
${isDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}
${uploadMutation.isPending ? 'pointer-events-none opacity-60' : ''}
`}
>
<input
ref={fileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
e.target.value = '';
}}
/>
{uploadMutation.isPending ? (
<div className="space-y-3">
<div className="text-3xl"></div>
<p className="text-sm font-medium text-gray-700">Uploading... {uploadProgress}%</p>
<div className="w-48 mx-auto bg-gray-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-3xl">📁</div>
<p className="text-sm font-medium text-gray-700">
{app.codePath ? 'Upload new version' : 'Upload your project source code'}
</p>
<p className="text-xs text-gray-500">
Drag & drop a <strong>.zip</strong> file here, or click to browse
</p>
<p className="text-xs text-gray-400">Max size: 100MB</p>
</div>
)}
</div>
</div>
{/* Pod Logs */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">📋 Pod Logs</h2>
<div className="flex items-center space-x-3">
{showLogs && (
<span className="text-xs text-gray-400 flex items-center space-x-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>Live (every 3s)</span>
</span>
)}
<button
onClick={() => setShowLogs(!showLogs)}
className="btn-secondary text-sm"
>
{showLogs ? '🔽 Hide Logs' : '📋 Show Logs'}
</button>
</div>
</div>
{showLogs && (
<pre
ref={logsEndRef}
className="bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
>
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
</pre>
)}
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import toast from 'react-hot-toast';
import type { Application } from '@/types';
const statusColors: Record<string, string> = {
running: 'bg-green-100 text-green-700',
pending: 'bg-yellow-100 text-yellow-700',
building: 'bg-blue-100 text-blue-700',
deploying: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
build_failed: 'bg-red-100 text-red-700',
stopped: 'bg-gray-100 text-gray-700',
};
export default function AppsPage() {
const queryClient = useQueryClient();
const { data: apps = [], isLoading } = useQuery<Application[]>({
queryKey: ['applications'],
queryFn: () => api.get('/applications').then((r) => r.data),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/applications/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
toast.success('Application deleted');
},
onError: () => toast.error('Failed to delete application'),
});
if (isLoading) {
return <div className="card text-center py-12 text-gray-500">Loading applications...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">My Applications</h1>
<Link href="/dashboard/deploy" className="btn-primary">
+ New Application
</Link>
</div>
{apps.length === 0 ? (
<div className="card text-center py-16">
<p className="text-gray-500 text-lg">No applications yet</p>
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
Deploy your first app
</Link>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Runtime</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Database</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Replicas</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{apps.map((app) => {
const latestStatus = app.deployments?.[0]?.status || 'pending';
return (
<tr key={app.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<Link href={`/dashboard/apps/${app.id}`} className="font-medium text-primary-600 hover:text-primary-800">
{app.name}
</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{app.runtime}</td>
<td className="px-6 py-4 text-sm text-gray-600">{app.databaseType}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
{latestStatus}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{app.replicas}</td>
<td className="px-6 py-4 text-right space-x-2">
<Link href={`/dashboard/apps/${app.id}`} className="text-sm text-primary-600 hover:text-primary-800">
View
</Link>
<button
onClick={() => { if (confirm('Delete this application?')) deleteMutation.mutate(app.id); }}
className="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+517
View File
@@ -0,0 +1,517 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import api from '@/lib/api';
import toast from 'react-hot-toast';
import type { CreateApplicationDto } from '@/types';
const steps = ['Basic Info', 'Runtime & Database', 'Resources', 'Review'];
export default function DeployPage() {
const router = useRouter();
const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({
name: '',
description: '',
runtime: 'nodejs',
databaseType: 'none',
gitUrl: '',
envVars: {},
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '128Mi',
memoryLimit: '512Mi',
replicas: 1,
port: 3000,
});
const [envKey, setEnvKey] = useState('');
const [envVal, setEnvVal] = useState('');
const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload');
const [zipFile, setZipFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const createMutation = useMutation({
mutationFn: async (data: CreateApplicationDto) => {
const res = await api.post('/applications', data);
const appId = res.data.id;
// Upload zip file if selected
if (sourceMethod === 'upload' && zipFile) {
const formData = new FormData();
formData.append('file', zipFile);
await api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
},
});
}
return res;
},
onSuccess: (res) => {
toast.success('Application created! Triggering deployment...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
router.push(`/dashboard/apps/${res.data.id}`);
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to create application');
setUploadProgress(0);
},
});
const addEnvVar = () => {
if (envKey.trim()) {
setForm({ ...form, envVars: { ...form.envVars, [envKey]: envVal } });
setEnvKey('');
setEnvVal('');
}
};
const removeEnvVar = (key: string) => {
const updated = { ...form.envVars };
delete updated[key];
setForm({ ...form, envVars: updated });
};
const handleSubmit = () => {
createMutation.mutate(form);
};
const handleFileSelect = useCallback((file: File) => {
const validTypes = ['application/zip', 'application/x-zip-compressed', 'application/gzip', 'application/x-tar'];
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
if (!hasValidExt && !validTypes.includes(file.type)) {
toast.error('Only .zip or .tar.gz files are allowed');
return;
}
if (file.size > 100 * 1024 * 1024) {
toast.error('File size must be less than 100MB');
return;
}
setZipFile(file);
toast.success(`Selected: ${file.name}`);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}, [handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const canNext = () => {
if (step === 0) {
if (form.name.length < 2) return false;
if (sourceMethod === 'upload' && !zipFile) return false;
if (sourceMethod === 'git' && !form.gitUrl) return false;
return true;
}
return true;
};
return (
<div className="max-w-2xl mx-auto space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Deploy New Application</h1>
<p className="mt-1 text-gray-500">Follow the steps to deploy your app to the cloud.</p>
</div>
{/* Step indicator */}
<div className="flex items-center space-x-2">
{steps.map((label, i) => (
<div key={label} className="flex items-center">
<div className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
i <= step ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-500'
}`}>
{i + 1}
</div>
<span className={`ml-2 text-sm ${i <= step ? 'text-gray-900 font-medium' : 'text-gray-400'}`}>
{label}
</span>
{i < steps.length - 1 && <div className="w-8 h-0.5 bg-gray-200 mx-3" />}
</div>
))}
</div>
<div className="card">
{/* Step 0: Basic Info */}
{step === 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Basic Information</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Application Name</label>
<input
className="input-field"
placeholder="my-awesome-app"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-') })}
/>
<p className="mt-1 text-xs text-gray-400">Lowercase letters, numbers, and hyphens only</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<textarea
className="input-field"
rows={3}
placeholder="What does this app do?"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
{/* Source Code Method */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Source Code</label>
<div className="grid grid-cols-2 gap-3 mb-4">
<button
type="button"
onClick={() => setSourceMethod('upload')}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
sourceMethod === 'upload'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<span className="text-xl">📁</span>
<p className="mt-1 font-semibold text-sm text-gray-900">Upload ZIP</p>
</button>
<button
type="button"
onClick={() => setSourceMethod('git')}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
sourceMethod === 'git'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<span className="text-xl">🔗</span>
<p className="mt-1 font-semibold text-sm text-gray-900">Git Repository</p>
</button>
</div>
{sourceMethod === 'git' ? (
<div>
<input
className="input-field"
placeholder="https://github.com/user/repo.git"
value={form.gitUrl}
onChange={(e) => setForm({ ...form, gitUrl: e.target.value })}
/>
</div>
) : (
<div>
{zipFile ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
</div>
<div>
<p className="text-sm font-medium text-green-800">{zipFile.name}</p>
<p className="text-xs text-green-600">
{(zipFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<button
type="button"
onClick={() => {
setZipFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}}
className="text-sm text-red-500 hover:text-red-700 font-medium"
>
Remove
</button>
</div>
) : (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}`}
>
<div className="space-y-2">
<div className="text-3xl">📦</div>
<p className="text-sm font-medium text-gray-700">
Drag & drop your project ZIP here
</p>
<p className="text-xs text-gray-500">
or click to browse <strong>.zip</strong> files only Max 100MB
</p>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = '';
}}
/>
</div>
)}
</div>
</div>
)}
{/* Step 1: Runtime & Database */}
{step === 1 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold">Runtime & Database</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Application Runtime</label>
<div className="grid grid-cols-2 gap-4">
{[
{ value: 'nodejs', label: 'Node.js', icon: '🟩', desc: 'Express, NestJS, Fastify...' },
{ value: 'laravel', label: 'Laravel', icon: '🟧', desc: 'PHP 8.3, Composer, Artisan' },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setForm({ ...form, runtime: opt.value as any, port: opt.value === 'nodejs' ? 3000 : 8000 })}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<span className="text-2xl">{opt.icon}</span>
<p className="mt-2 font-semibold text-gray-900">{opt.label}</p>
<p className="text-xs text-gray-500">{opt.desc}</p>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Database</label>
<div className="grid grid-cols-3 gap-4">
{[
{ value: 'none', label: 'None', icon: '❌' },
{ value: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
{ value: 'mysql', label: 'MySQL', icon: '🐬' },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setForm({ ...form, databaseType: opt.value as any })}
className={`p-4 rounded-xl border-2 text-center transition-colors ${
form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<span className="text-2xl">{opt.icon}</span>
<p className="mt-2 font-semibold text-sm text-gray-900">{opt.label}</p>
</button>
))}
</div>
</div>
</div>
)}
{/* Step 2: Resources */}
{step === 2 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold">Resources & Configuration</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
<select className="input-field" value={form.cpuRequest} onChange={(e) => setForm({ ...form, cpuRequest: e.target.value })}>
<option value="50m">50m (0.05 core)</option>
<option value="100m">100m (0.1 core)</option>
<option value="250m">250m (0.25 core)</option>
<option value="500m">500m (0.5 core)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Limit</label>
<select className="input-field" value={form.cpuLimit} onChange={(e) => setForm({ ...form, cpuLimit: e.target.value })}>
<option value="250m">250m (0.25 core)</option>
<option value="500m">500m (0.5 core)</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Request</label>
<select className="input-field" value={form.memoryRequest} onChange={(e) => setForm({ ...form, memoryRequest: e.target.value })}>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Limit</label>
<select className="input-field" value={form.memoryLimit} onChange={(e) => setForm({ ...form, memoryLimit: e.target.value })}>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
<input
type="number"
className="input-field"
min={1}
max={10}
value={form.replicas}
onChange={(e) => setForm({ ...form, replicas: parseInt(e.target.value) || 1 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
<input
type="number"
className="input-field"
value={form.port}
onChange={(e) => setForm({ ...form, port: parseInt(e.target.value) || 3000 })}
/>
</div>
</div>
{/* Environment Variables */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
<div className="flex space-x-2 mb-3">
<input
className="input-field flex-1"
placeholder="KEY"
value={envKey}
onChange={(e) => setEnvKey(e.target.value)}
/>
<input
className="input-field flex-1"
placeholder="value"
value={envVal}
onChange={(e) => setEnvVal(e.target.value)}
/>
<button type="button" onClick={addEnvVar} className="btn-secondary">Add</button>
</div>
{Object.entries(form.envVars || {}).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
<span className="text-sm font-mono">
<strong>{key}</strong> = {value}
</span>
<button onClick={() => removeEnvVar(key)} className="text-red-500 text-sm">Remove</button>
</div>
))}
</div>
</div>
)}
{/* Step 3: Review */}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Review & Deploy</h2>
<div className="bg-gray-50 rounded-xl p-6 space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Name</span>
<span className="text-sm font-medium">{form.name}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Runtime</span>
<span className="text-sm font-medium">{form.runtime}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Database</span>
<span className="text-sm font-medium">{form.databaseType}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Source</span>
<span className="text-sm font-medium">
{sourceMethod === 'upload'
? zipFile
? `📁 ${zipFile.name}`
: '—'
: form.gitUrl || '—'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CPU</span>
<span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Memory</span>
<span className="text-sm font-medium">{form.memoryRequest} / {form.memoryLimit}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Replicas</span>
<span className="text-sm font-medium">{form.replicas}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Port</span>
<span className="text-sm font-medium">{form.port}</span>
</div>
{Object.keys(form.envVars || {}).length > 0 && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Env Vars</span>
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
</div>
)}
</div>
</div>
)}
{/* Navigation */}
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
<button
onClick={() => setStep(step - 1)}
disabled={step === 0}
className="btn-secondary disabled:opacity-30"
>
Back
</button>
{step < steps.length - 1 ? (
<button
onClick={() => setStep(step + 1)}
disabled={!canNext()}
className="btn-primary"
>
Next
</button>
) : (
<button
onClick={handleSubmit}
disabled={createMutation.isPending}
className="btn-primary"
>
{createMutation.isPending
? uploadProgress > 0 && uploadProgress < 100
? `Uploading... ${uploadProgress}%`
: 'Deploying...'
: '🚀 Deploy Application'}
</button>
)}
</div>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore } from '@/lib/store';
const userNavItems = [
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
{ href: '/dashboard/apps', label: 'Applications', icon: '📦' },
{ href: '/dashboard/deploy', label: 'New Deploy', icon: '🚀' },
];
const adminNavItems = [
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
];
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isLoading, isAuthenticated, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
);
}
if (!isAuthenticated) return null;
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-4">
<Link href="/dashboard" className="text-xl font-bold text-primary-600">
CloudHost
</Link>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
{user?.firstName} {user?.lastName}
{user?.role === 'admin' && (
<span className="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full font-medium">
Admin
</span>
)}
</span>
<button
onClick={() => { logout(); router.push('/login'); }}
className="text-sm text-gray-500 hover:text-gray-700"
>
Sign out
</button>
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex gap-8">
{/* Sidebar */}
<nav className="w-56 flex-shrink-0">
<div className="space-y-1">
{userNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === item.href
? 'bg-primary-50 text-primary-700'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
))}
{user?.role === 'admin' && (
<>
<div className="pt-4 pb-2">
<p className="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
Admin
</p>
</div>
{adminNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === item.href
? 'bg-primary-50 text-primary-700'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
))}
</>
)}
</div>
</nav>
{/* Main content */}
<main className="flex-1 min-w-0">{children}</main>
</div>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import type { Application } from '@/types';
const statusColors: Record<string, string> = {
running: 'bg-green-100 text-green-700',
pending: 'bg-yellow-100 text-yellow-700',
building: 'bg-blue-100 text-blue-700',
deploying: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
build_failed: 'bg-red-100 text-red-700',
stopped: 'bg-gray-100 text-gray-700',
};
export default function DashboardPage() {
const user = useAuthStore((s) => s.user);
const { data: apps = [], isLoading } = useQuery<Application[]>({
queryKey: ['applications'],
queryFn: () => api.get('/applications').then((r) => r.data),
});
const runningApps = apps.filter(
(a) => a.deployments?.some((d) => d.status === 'running'),
);
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">
Welcome back, {user?.firstName}! 👋
</h1>
<p className="mt-1 text-gray-500">
Here&apos;s an overview of your applications.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card">
<p className="text-sm font-medium text-gray-500">Total Apps</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{apps.length}</p>
</div>
<div className="card">
<p className="text-sm font-medium text-gray-500">Running</p>
<p className="mt-2 text-3xl font-bold text-green-600">{runningApps.length}</p>
</div>
<div className="card">
<p className="text-sm font-medium text-gray-500">Deployments (7d)</p>
<p className="mt-2 text-3xl font-bold text-primary-600">
{apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)}
</p>
</div>
</div>
{/* Recent Applications */}
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recent Applications</h2>
<Link href="/dashboard/deploy" className="btn-primary text-sm">
+ New Application
</Link>
</div>
{isLoading ? (
<div className="card text-center py-12 text-gray-500">Loading...</div>
) : apps.length === 0 ? (
<div className="card text-center py-12">
<p className="text-gray-500 text-lg">No applications yet</p>
<p className="text-gray-400 mt-2">
Deploy your first application to get started.
</p>
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
Deploy your first app
</Link>
</div>
) : (
<div className="grid gap-4">
{apps.slice(0, 5).map((app) => {
const latestDeploy = app.deployments?.[0];
const status = latestDeploy?.status || 'pending';
return (
<Link
key={app.id}
href={`/dashboard/apps/${app.id}`}
className="card hover:border-primary-300 transition-colors flex items-center justify-between"
>
<div className="flex items-center space-x-4">
<div className="w-10 h-10 rounded-lg bg-primary-100 flex items-center justify-center text-lg">
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
</div>
<div>
<h3 className="font-semibold text-gray-900">{app.name}</h3>
<p className="text-sm text-gray-500">
{app.runtime} · {app.replicas} replica{app.replicas > 1 ? 's' : ''}
{app.databaseType !== 'none' && ` · ${app.databaseType}`}
</p>
</div>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
statusColors[status] || 'bg-gray-100 text-gray-600'
}`}
>
{status}
</span>
</Link>
);
})}
</div>
)}
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
}
@layer components {
.btn-primary {
@apply bg-primary-600 text-white px-4 py-2 rounded-lg font-medium
hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500
focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-white text-gray-700 px-4 py-2 rounded-lg font-medium border border-gray-300
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500
focus:ring-offset-2 transition-colors;
}
.btn-danger {
@apply bg-red-600 text-white px-4 py-2 rounded-lg font-medium
hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500
focus:ring-offset-2 transition-colors;
}
.input-field {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500
focus:border-primary-500 transition-colors;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from '@/components/providers';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'CloudHost - Self-Service PaaS',
description: 'Deploy your applications to Kubernetes with ease',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const login = useAuthStore((s) => s.login);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await login(email, password);
toast.success('Logged in successfully!');
router.push('/dashboard');
} catch (err: any) {
toast.error(err.response?.data?.message || 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-primary-600"> CloudHost</h1>
<h2 className="mt-4 text-2xl font-semibold text-gray-900">Sign in to your account</h2>
<p className="mt-2 text-gray-600">
Or{' '}
<Link href="/register" className="text-primary-600 hover:text-primary-500 font-medium">
create a new account
</Link>
</p>
</div>
<form className="card space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email address
</label>
<input
id="email"
type="email"
required
className="input-field"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
required
className="input-field"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button type="submit" disabled={isLoading} className="btn-primary w-full">
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}
+99
View File
@@ -0,0 +1,99 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore } from '@/lib/store';
import toast from 'react-hot-toast';
export default function RegisterPage() {
const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '' });
const [isLoading, setIsLoading] = useState(false);
const register = useAuthStore((s) => s.register);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await register(form);
toast.success('Account created successfully!');
router.push('/dashboard');
} catch (err: any) {
toast.error(err.response?.data?.message || 'Registration failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-primary-600"> CloudHost</h1>
<h2 className="mt-4 text-2xl font-semibold text-gray-900">Create your account</h2>
<p className="mt-2 text-gray-600">
Already have an account?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-500 font-medium">
Sign in
</Link>
</p>
</div>
<form className="card space-y-6" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">First name</label>
<input
type="text"
required
className="input-field"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Last name</label>
<input
type="text"
required
className="input-field"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
type="email"
required
className="input-field"
placeholder="you@example.com"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
required
minLength={8}
className="input-field"
placeholder="Min 8 characters"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<button type="submit" disabled={isLoading} className="btn-primary w-full">
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</form>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/store';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
const loadUser = useAuthStore((s) => s.loadUser);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
loadUser();
}, [loadUser]);
if (!mounted) return null;
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" />
</QueryClientProvider>
);
}
+58
View File
@@ -0,0 +1,58 @@
import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const api = axios.create({
baseURL: `${API_URL}/api/v1`,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
api.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// Response interceptor for token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) throw new Error('No refresh token');
const { data } = await axios.post(`${API_URL}/api/v1/auth/refresh`, {
refreshToken,
});
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
}
return Promise.reject(error);
},
);
export default api;
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { create } from 'zustand';
import api from '@/lib/api';
import type { User, AuthResponse } from '@/types';
interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: { email: string; password: string; firstName: string; lastName: string }) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
isAuthenticated: false,
login: async (email, password) => {
const { data } = await api.post<AuthResponse>('/auth/login', { email, password });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
set({ user: data.user, isAuthenticated: true });
},
register: async (registerData) => {
const { data } = await api.post<AuthResponse>('/auth/register', registerData);
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
set({ user: data.user, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ user: null, isAuthenticated: false });
},
loadUser: async () => {
try {
const token = localStorage.getItem('accessToken');
if (!token) {
set({ isLoading: false });
return;
}
const { data } = await api.get<User>('/users/me');
set({ user: data, isAuthenticated: true, isLoading: false });
} catch {
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
}));
+94
View File
@@ -0,0 +1,94 @@
export interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: 'user' | 'admin';
isActive: boolean;
namespace?: string;
createdAt: string;
}
export interface Application {
id: string;
name: string;
description?: string;
runtime: 'nodejs' | 'laravel';
databaseType: 'mysql' | 'postgresql' | 'none';
gitUrl?: string;
codePath?: string;
envVars?: Record<string, string>;
cpuRequest: string;
cpuLimit: string;
memoryRequest: string;
memoryLimit: string;
replicas: number;
port: number;
userId: string;
clusterId?: string;
latestImageTag?: string;
subdomain?: string;
deployments?: Deployment[];
createdAt: string;
updatedAt: string;
}
export interface Deployment {
id: string;
status: DeploymentStatus;
imageTag: string;
version?: string;
buildLog?: string;
deployLog?: string;
errorMessage?: string;
applicationId: string;
triggeredBy: string;
createdAt: string;
finishedAt?: string;
}
export type DeploymentStatus =
| 'pending'
| 'building'
| 'build_failed'
| 'deploying'
| 'running'
| 'failed'
| 'stopped'
| 'deleting';
export interface Cluster {
id: string;
name: string;
description?: string;
status: 'active' | 'inactive' | 'maintenance';
apiServer: string;
region?: string;
provider?: string;
isDefault: boolean;
defaultCpuLimit: string;
defaultMemoryLimit: string;
maxAppsPerUser: number;
createdAt: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
}
export interface CreateApplicationDto {
name: string;
description?: string;
runtime: 'nodejs' | 'laravel';
databaseType: 'mysql' | 'postgresql' | 'none';
gitUrl?: string;
envVars?: Record<string, string>;
cpuRequest?: string;
cpuLimit?: string;
memoryRequest?: string;
memoryLimit?: string;
replicas?: number;
port?: number;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
},
},
},
plugins: [],
};
export default config;
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long