init
This commit is contained in:
+29
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "tsconfig.json"
|
||||
},
|
||||
"sourceRoot": "src",
|
||||
"collection": "@nestjs/schematics",
|
||||
"entryFile": "main"
|
||||
}
|
||||
Generated
+11419
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: "{{namespace}}"
|
||||
labels:
|
||||
managed-by: cloudhost
|
||||
user: "{{userId}}"
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: "{{name}}"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
managed-by: cloudhost
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: "{{size}}"
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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:
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.env.local
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
@@ -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"]
|
||||
Vendored
+5
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+6314
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user