From 5e7e24585899081f14cbe6441ac6d7a03f33ce07 Mon Sep 17 00:00:00 2001 From: qorgh529 Date: Mon, 6 Apr 2026 21:16:17 +0900 Subject: [PATCH] init: web portal --- .gitea/workflows/build-and-push.yaml | 50 +++ GITOPS-GUIDE.md | 87 +++++ README.md | 102 ++++++ backend/Dockerfile | 6 + backend/init.sql | 41 +++ backend/main.py | 253 +++++++++++++ backend/requirements.txt | 7 + build-and-deploy.sh | 59 +++ cleanup.sh | 5 + deploy.sh | 43 +++ frontend/Dockerfile | 4 + frontend/index.html | 516 +++++++++++++++++++++++++++ frontend/nginx.conf | 22 ++ k8s/00-registry-secret.yaml | 14 + k8s/01-namespace.yaml | 4 + k8s/02-postgres.yaml | 65 ++++ k8s/03-secrets.yaml | 9 + k8s/04-backend.yaml | 68 ++++ k8s/05-frontend.yaml | 43 +++ k8s/06-argocd-app.yaml | 20 ++ k8s/portal.yaml | 299 ++++++++++++++++ 21 files changed, 1717 insertions(+) create mode 100755 .gitea/workflows/build-and-push.yaml create mode 100755 GITOPS-GUIDE.md create mode 100755 README.md create mode 100755 backend/Dockerfile create mode 100755 backend/init.sql create mode 100755 backend/main.py create mode 100755 backend/requirements.txt create mode 100755 build-and-deploy.sh create mode 100755 cleanup.sh create mode 100755 deploy.sh create mode 100755 frontend/Dockerfile create mode 100755 frontend/index.html create mode 100755 frontend/nginx.conf create mode 100755 k8s/00-registry-secret.yaml create mode 100755 k8s/01-namespace.yaml create mode 100755 k8s/02-postgres.yaml create mode 100755 k8s/03-secrets.yaml create mode 100755 k8s/04-backend.yaml create mode 100755 k8s/05-frontend.yaml create mode 100755 k8s/06-argocd-app.yaml create mode 100755 k8s/portal.yaml diff --git a/.gitea/workflows/build-and-push.yaml b/.gitea/workflows/build-and-push.yaml new file mode 100755 index 0000000..9416b6d --- /dev/null +++ b/.gitea/workflows/build-and-push.yaml @@ -0,0 +1,50 @@ +name: Build and Push Images + +on: + push: + branches: + - main + +jobs: + build-backend: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Login to Gitea Registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_HOST }} \ + -u ${{ secrets.REGISTRY_USER }} --password-stdin + + - name: Build & Push Backend + run: | + IMAGE=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_USER }}/portal-backend:${{ github.sha }} + IMAGE_LATEST=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_USER }}/portal-backend:latest + docker build -t $IMAGE -t $IMAGE_LATEST ./backend/ + docker push $IMAGE + docker push $IMAGE_LATEST + + - name: Build & Push Frontend + run: | + IMAGE=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_USER }}/portal-frontend:${{ github.sha }} + IMAGE_LATEST=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_USER }}/portal-frontend:latest + docker build -t $IMAGE -t $IMAGE_LATEST ./frontend/ + docker push $IMAGE + docker push $IMAGE_LATEST + + - name: Update K8s image tags + run: | + BACKEND_IMAGE=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_USER }}/portal-backend:${{ github.sha }} + FRONTEND_IMAGE=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_USER }}/portal-frontend:${{ github.sha }} + + sed -i "s|image: .*portal-backend.*|image: ${BACKEND_IMAGE}|g" k8s/04-backend.yaml + sed -i "s|image: .*portal-frontend.*|image: ${FRONTEND_IMAGE}|g" k8s/05-frontend.yaml + + - name: Commit updated yaml + run: | + git config user.name "gitea-actions" + git config user.email "actions@gitea" + git add k8s/04-backend.yaml k8s/05-frontend.yaml + git diff --staged --quiet || git commit -m "ci: update image tags to ${{ github.sha }}" + git push diff --git a/GITOPS-GUIDE.md b/GITOPS-GUIDE.md new file mode 100755 index 0000000..f8fd9b6 --- /dev/null +++ b/GITOPS-GUIDE.md @@ -0,0 +1,87 @@ +# GitOps 배포 가이드 (Gitea + ArgoCD) + +## 📋 전체 흐름 +``` +git push (main) + → Gitea Actions 실행 + → docker build & push → Gitea Registry + → k8s yaml image 태그 자동 업데이트 & commit + → ArgoCD 변경 감지 + → K8s 자동 배포 +``` + +## 🔧 최초 1회 설정 + +### 1단계. Gitea Actions Secret 등록 +Gitea 저장소 → Settings → Secrets → Actions 에서 아래 3개 추가: + +| Secret 이름 | 값 예시 | +|---|---| +| `REGISTRY_HOST` | `192.168.1.100:3000` | +| `REGISTRY_USER` | `admin` (Gitea 계정명) | +| `REGISTRY_PASSWORD` | Gitea 패스워드 | + +### 2단계. K8s에 Registry 인증 Secret 생성 +```bash +kubectl create namespace web-portal + +kubectl create secret docker-registry gitea-registry-secret \ + --namespace=web-portal \ + --docker-server=192.168.1.100:3000 \ + --docker-username= \ + --docker-password= \ + --docker-email=<이메일> +``` + +### 3단계. K8s yaml 파일 실제 주소로 수정 +아래 파일에서 `192.168.x.x:3000/username` 부분을 실제 주소로 변경: +- `k8s/04-backend.yaml` → image 경로 +- `k8s/05-frontend.yaml` → image 경로 +- `k8s/06-argocd-app.yaml` → repoURL +- `.gitea/workflows/build-and-push.yaml` → secrets로 자동 처리됨 + +### 4단계. ArgoCD Application 등록 +```bash +kubectl apply -f k8s/06-argocd-app.yaml +``` +또는 ArgoCD UI에서 직접 추가: +- Repository URL: `http://192.168.1.100:3000/username/k8s-portal.git` +- Path: `k8s` +- Namespace: `web-portal` +- Auto-sync: ON + +### 5단계. Gitea에 코드 push +```bash +git init +git remote add origin http://192.168.1.100:3000/username/k8s-portal.git +git add . +git commit -m "init: web portal" +git push -u origin main +``` + +→ Gitea Actions가 자동으로 이미지 빌드 & 배포까지 진행! + +## ⚠️ HTTP Registry 허용 설정 (중요!) +Gitea Registry가 HTTP(비SSL)인 경우 Docker에서 insecure registry 허용 필요: + +Docker Desktop → Settings → Docker Engine: +```json +{ + "insecure-registries": ["192.168.1.100:3000"] +} +``` + +K8s 노드(docker-desktop)도 동일하게 적용 필요: +```bash +# docker-desktop VM 내부 /etc/docker/daemon.json 수정 필요 +# Docker Desktop 재시작 후 적용됨 +``` + +## 🔄 이후 배포 방법 +```bash +# 코드 수정 후 +git add . +git commit -m "feat: 변경내용" +git push +# → 자동으로 빌드 & 배포됨! +``` diff --git a/README.md b/README.md new file mode 100755 index 0000000..7a296d1 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Web Portal - Kubernetes 배포 가이드 + +## 📁 프로젝트 구조 + +``` +k8s-portal/ +├── backend/ +│ ├── main.py # FastAPI 백엔드 (전체 API) +│ ├── requirements.txt +│ └── Dockerfile +├── frontend/ +│ ├── index.html # 싱글 페이지 앱 (SPA) +│ ├── nginx.conf # Nginx 설정 (API 프록시 포함) +│ └── Dockerfile +├── k8s/ +│ ├── 01-namespace.yaml # web-portal 네임스페이스 +│ ├── 02-postgres.yaml # PostgreSQL + PVC + Service +│ ├── 03-secrets.yaml # DB 패스워드, JWT 시크릿 +│ ├── 04-backend.yaml # FastAPI Deployment + Service +│ └── 05-frontend.yaml # Nginx Deployment + NodePort(30080) +├── build-and-deploy.sh # 원클릭 빌드 & 배포 +├── cleanup.sh # 전체 삭제 +└── README.md +``` + +## 🚀 설치 및 실행 + +### 전제 조건 +- Docker Desktop 설치 및 실행 +- Docker Desktop > Settings > Kubernetes > Enable Kubernetes ✅ + +### 배포 (원클릭) +```bash +chmod +x build-and-deploy.sh +./build-and-deploy.sh +``` + +### 접속 +브라우저에서: **http://localhost:30080** + +## 🔑 기본 계정 + +| 구분 | ID | Password | +|------|-----|----------| +| 관리자 | `admin` | `admin1234` | +| 일반사용자 | `user1` | `user1234` | + +## ✨ 기능 설명 + +### 일반 사용자 +- 로그인 후 **본인에게 할당된 웹페이지 목록** 확인 +- 카드 클릭 시 **새 탭에서 해당 URL로 이동** + +### 관리자 +일반 사용자 기능 + 추가: +- **페이지 관리**: 웹페이지 추가/수정/삭제 +- **사용자 관리**: 계정 생성/삭제 +- **권한 설정**: 사용자별 접근 가능 페이지 체크박스로 지정 + +## 🔧 운영 명령어 + +```bash +# 전체 리소스 상태 확인 +kubectl get all -n web-portal + +# 백엔드 로그 확인 +kubectl logs -n web-portal deployment/backend -f + +# 프론트엔드 로그 확인 +kubectl logs -n web-portal deployment/frontend -f + +# 재시작 +kubectl rollout restart deployment/backend -n web-portal +kubectl rollout restart deployment/frontend -n web-portal + +# 전체 삭제 +./cleanup.sh +``` + +## 🔐 운영 시 보안 설정 + +`k8s/03-secrets.yaml`에서 반드시 변경: +```yaml +stringData: + db-password: "강력한패스워드로변경" # PostgreSQL 패스워드 + jwt-secret: "64자이상의랜덤문자열로변경" # JWT 서명 키 +``` + +## 🏗️ 아키텍처 + +``` +브라우저 + │ + ▼ :30080 (NodePort) +[Nginx Frontend] ─── 정적 HTML/JS 제공 + │ /api/* 프록시 + ▼ +[FastAPI Backend] ─── JWT 인증, REST API + │ + ▼ +[PostgreSQL] ─── 사용자, 페이지, 권한 데이터 +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100755 index 0000000..10af9be --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/init.sql b/backend/init.sql new file mode 100755 index 0000000..7611894 --- /dev/null +++ b/backend/init.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS webpages ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + url TEXT NOT NULL, + description TEXT DEFAULT '', + icon VARCHAR(10) DEFAULT '🌐', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_webpages ( + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + webpage_id INTEGER REFERENCES webpages(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, webpage_id) +); + +-- Default admin account (password: admin1234) +INSERT INTO users (username, password_hash, is_admin) +VALUES ('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiAYMxRmfvym', TRUE) +ON CONFLICT (username) DO NOTHING; + +-- Default test user (password: user1234) +INSERT INTO users (username, password_hash, is_admin) +VALUES ('user1', '$2b$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', FALSE) +ON CONFLICT (username) DO NOTHING; + +-- Sample webpages +INSERT INTO webpages (name, url, description, icon) VALUES +('Google', 'https://www.google.com', '구글 검색 엔진', '🔍'), +('GitHub', 'https://github.com', '소스코드 저장소', '🐙'), +('Kubernetes Dashboard', 'http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/', '쿠버네티스 대시보드', '☸️'), +('Grafana', 'http://grafana.monitoring.svc:3000', '모니터링 대시보드', '📊'), +('Notion', 'https://notion.so', '문서 협업 툴', '📝') +ON CONFLICT DO NOTHING; diff --git a/backend/main.py b/backend/main.py new file mode 100755 index 0000000..c8c2fa2 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,253 @@ +from fastapi import FastAPI, HTTPException, Depends, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +import psycopg2 +import psycopg2.extras +import bcrypt +import jwt +import os +from datetime import datetime, timedelta + +app = FastAPI(title="Web Portal API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +SECRET_KEY = os.getenv("JWT_SECRET", "supersecretkey1234") +ALGORITHM = "HS256" +security = HTTPBearer() + +DB_CONFIG = { + "host": os.getenv("DB_HOST", "postgres-service"), + "port": int(os.getenv("DB_PORT", "5432")), + "database": os.getenv("DB_NAME", "portaldb"), + "user": os.getenv("DB_USER", "portaluser"), + "password": os.getenv("DB_PASSWORD", "portalpass"), +} + +def get_db(): + conn = psycopg2.connect(**DB_CONFIG) + try: + yield conn + finally: + conn.close() + +def init_db(): + conn = psycopg2.connect(**DB_CONFIG) + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS webpages ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + url VARCHAR(500) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS user_webpage_access ( + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + webpage_id INTEGER REFERENCES webpages(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, webpage_id) + ); + """) + cur.execute("SELECT id FROM users WHERE username = 'admin'") + if not cur.fetchone(): + hashed = bcrypt.hashpw("admin1234".encode(), bcrypt.gensalt()).decode() + cur.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, TRUE)", + ("admin", hashed) + ) + cur.execute("SELECT id FROM users WHERE username = 'user1'") + if not cur.fetchone(): + hashed = bcrypt.hashpw("user1234".encode(), bcrypt.gensalt()).decode() + cur.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, FALSE)", + ("user1", hashed) + ) + conn.commit() + cur.close() + conn.close() + +@app.on_event("startup") +def startup(): + import time + for _ in range(10): + try: + init_db() + print("DB initialized successfully") + break + except Exception as e: + print(f"DB not ready, retrying... {e}") + time.sleep(3) + +def create_token(user_id: int, username: str, is_admin: bool): + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(hours=8) + } + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + +def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + +def require_admin(token=Depends(verify_token)): + if not token.get("is_admin"): + raise HTTPException(status_code=403, detail="Admin only") + return token + +class LoginRequest(BaseModel): + username: str + password: str + +@app.post("/api/auth/login") +def login(req: LoginRequest, conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM users WHERE username = %s", (req.username,)) + user = cur.fetchone() + if not user or not bcrypt.checkpw(req.password.encode(), user["password_hash"].encode()): + raise HTTPException(status_code=401, detail="Invalid credentials") + token = create_token(user["id"], user["username"], user["is_admin"]) + return {"token": token, "username": user["username"], "is_admin": user["is_admin"]} + +@app.get("/api/auth/me") +def me(token=Depends(verify_token)): + return {"username": token["username"], "is_admin": token["is_admin"]} + +@app.get("/api/my-pages") +def my_pages(token=Depends(verify_token), conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + user_id = int(token["sub"]) + if token["is_admin"]: + cur.execute("SELECT * FROM webpages ORDER BY name") + else: + cur.execute(""" + SELECT w.* FROM webpages w + JOIN user_webpage_access ua ON w.id = ua.webpage_id + WHERE ua.user_id = %s ORDER BY w.name + """, (user_id,)) + return cur.fetchall() + +@app.get("/api/admin/webpages") +def list_webpages(token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM webpages ORDER BY name") + return cur.fetchall() + +class WebpageCreate(BaseModel): + name: str + url: str + description: Optional[str] = "" + +@app.post("/api/admin/webpages") +def create_webpage(data: WebpageCreate, token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "INSERT INTO webpages (name, url, description) VALUES (%s, %s, %s) RETURNING *", + (data.name, data.url, data.description) + ) + conn.commit() + return cur.fetchone() + +@app.put("/api/admin/webpages/{page_id}") +def update_webpage(page_id: int, data: WebpageCreate, token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "UPDATE webpages SET name=%s, url=%s, description=%s WHERE id=%s RETURNING *", + (data.name, data.url, data.description, page_id) + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="Not found") + conn.commit() + return result + +@app.delete("/api/admin/webpages/{page_id}") +def delete_webpage(page_id: int, token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor() + cur.execute("DELETE FROM webpages WHERE id = %s", (page_id,)) + conn.commit() + return {"ok": True} + +@app.get("/api/admin/users") +def list_users(token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT id, username, is_admin, created_at FROM users ORDER BY username") + return cur.fetchall() + +class UserCreate(BaseModel): + username: str + password: str + is_admin: bool = False + +@app.post("/api/admin/users") +def create_user(data: UserCreate, token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + hashed = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode() + try: + cur.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, %s) RETURNING id, username, is_admin", + (data.username, hashed, data.is_admin) + ) + conn.commit() + return cur.fetchone() + except psycopg2.errors.UniqueViolation: + raise HTTPException(status_code=400, detail="Username already exists") + +@app.delete("/api/admin/users/{user_id}") +def delete_user(user_id: int, token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor() + cur.execute("DELETE FROM users WHERE id = %s AND username != 'admin'", (user_id,)) + conn.commit() + return {"ok": True} + +@app.get("/api/admin/users/{user_id}/pages") +def get_user_pages(user_id: int, token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(""" + SELECT w.id, w.name, w.url, w.description, + (ua.user_id IS NOT NULL) as has_access + FROM webpages w + LEFT JOIN user_webpage_access ua ON w.id = ua.webpage_id AND ua.user_id = %s + ORDER BY w.name + """, (user_id,)) + return cur.fetchall() + +class AccessUpdate(BaseModel): + webpage_ids: List[int] + +@app.put("/api/admin/users/{user_id}/pages") +def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_admin), conn=Depends(get_db)): + cur = conn.cursor() + cur.execute("DELETE FROM user_webpage_access WHERE user_id = %s", (user_id,)) + for page_id in data.webpage_ids: + cur.execute( + "INSERT INTO user_webpage_access (user_id, webpage_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", + (user_id, page_id) + ) + conn.commit() + return {"ok": True} + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..9ac27e1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.110.0 +uvicorn==0.29.0 +psycopg2-binary==2.9.9 +bcrypt==4.1.2 +PyJWT==2.8.0 +pydantic==2.6.4 +python-multipart==0.0.9 diff --git a/build-and-deploy.sh b/build-and-deploy.sh new file mode 100755 index 0000000..82e58ee --- /dev/null +++ b/build-and-deploy.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +echo "======================================" +echo " Web Portal - Build & Deploy Script" +echo "======================================" + +# Docker Desktop의 쿠버네티스 컨텍스트 확인 +echo "" +echo "[1/5] 쿠버네티스 컨텍스트 확인..." +kubectl config current-context +echo "✅ 쿠버네티스 연결 OK" + +# Docker 이미지 빌드 +echo "" +echo "[2/5] Docker 이미지 빌드..." +cd "$(dirname "$0")" +docker build -t portal-backend:latest ./backend/ +docker build -t portal-frontend:latest ./frontend/ +echo "✅ 이미지 빌드 완료" + +# 네임스페이스 및 시크릿 생성 +echo "" +echo "[3/5] 네임스페이스 및 기본 리소스 생성..." +kubectl apply -f k8s/01-namespace.yaml +kubectl apply -f k8s/03-secrets.yaml +echo "✅ 네임스페이스 생성 완료" + +# PostgreSQL 배포 +echo "" +echo "[4/5] PostgreSQL 배포..." +kubectl apply -f k8s/02-postgres.yaml +echo "PostgreSQL 준비 대기중..." +kubectl rollout status deployment/postgres -n web-portal --timeout=120s +echo "✅ PostgreSQL 준비 완료" + +# Backend & Frontend 배포 +echo "" +echo "[5/5] Backend & Frontend 배포..." +kubectl apply -f k8s/04-backend.yaml +kubectl apply -f k8s/05-frontend.yaml +echo "서비스 준비 대기중..." +kubectl rollout status deployment/backend -n web-portal --timeout=120s +kubectl rollout status deployment/frontend -n web-portal --timeout=120s +echo "✅ 모든 서비스 배포 완료" + +echo "" +echo "======================================" +echo " 🎉 배포 성공!" +echo "======================================" +echo "" +echo " 접속 URL: http://localhost:30090" +echo "" +echo " 기본 계정:" +echo " 관리자 - ID: admin PW: admin1234" +echo " 일반 - ID: user1 PW: user1234" +echo "" +echo " 상태 확인: kubectl get all -n web-portal" +echo "======================================" diff --git a/cleanup.sh b/cleanup.sh new file mode 100755 index 0000000..8d47a67 --- /dev/null +++ b/cleanup.sh @@ -0,0 +1,5 @@ +#!/bin/bash +echo "Web Portal 전체 삭제 중..." +kubectl delete namespace web-portal --ignore-not-found +docker rmi portal-backend:latest portal-frontend:latest 2>/dev/null || true +echo "✅ 삭제 완료" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..5d2cb7b --- /dev/null +++ b/deploy.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +echo "======================================" +echo " Web Portal - 쿠버네티스 배포 스크립트" +echo "======================================" +echo "" + +# 1. Docker 이미지 빌드 +echo "[1/4] Docker 이미지 빌드 중..." +docker build -t web-portal-backend:latest ./backend +docker build -t web-portal-frontend:latest ./frontend +echo "✅ 이미지 빌드 완료" +echo "" + +# 2. 기존 배포 삭제 (있을 경우) +echo "[2/4] 기존 리소스 정리 중..." +kubectl delete namespace web-portal --ignore-not-found=true +sleep 3 +echo "✅ 정리 완료" +echo "" + +# 3. 쿠버네티스 배포 +echo "[3/4] 쿠버네티스 배포 중..." +kubectl apply -f k8s/portal.yaml +echo "✅ 매니페스트 적용 완료" +echo "" + +# 4. Pod 준비 대기 +echo "[4/4] Pod 시작 대기 중... (최대 3분)" +kubectl wait --for=condition=ready pod -l app=frontend -n web-portal --timeout=180s +kubectl wait --for=condition=ready pod -l app=backend -n web-portal --timeout=180s +echo "" + +echo "======================================" +echo "✅ 배포 완료!" +echo "" +echo "🌐 접속 주소: http://localhost:30080" +echo "" +echo "기본 계정:" +echo " 관리자: admin / admin1234" +echo " 일반: user1 / user1234" +echo "======================================" diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100755 index 0000000..7db1795 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/index.html +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100755 index 0000000..25d1d0b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,516 @@ + + + + + +Web Portal + + + + + +
+ +
+ + +
+
+
+ + +
+
+
+ + +
+ +
+
+ +
+ +
+ +
+
+ + +
+ +
+ + + +
이름URL설명작업
+
+
+ + +
+ +
+ + + +
사용자명권한작업
+
+
+
+
+ + + + + + + + + + + + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100755 index 0000000..bfe92e0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 60s; + proxy_connect_timeout 10s; + } + + location /health { + proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/health; + } +} diff --git a/k8s/00-registry-secret.yaml b/k8s/00-registry-secret.yaml new file mode 100755 index 0000000..8551bbc --- /dev/null +++ b/k8s/00-registry-secret.yaml @@ -0,0 +1,14 @@ +# ⚠️ 이 파일은 직접 kubectl로 적용합니다 (ArgoCD에서 관리 X) +# Gitea Registry 인증 정보를 K8s에 등록하는 Secret +# +# 아래 명령어로 생성하세요 (파일 대신 명령어 사용 권장): +# +# kubectl create secret docker-registry gitea-registry-secret \ +# --namespace=web-portal \ +# --docker-server=192.168.x.x:3000 \ +# --docker-username= \ +# --docker-password= \ +# --docker-email=<이메일> +# +# 생성 확인: +# kubectl get secret gitea-registry-secret -n web-portal diff --git a/k8s/01-namespace.yaml b/k8s/01-namespace.yaml new file mode 100755 index 0000000..861dac4 --- /dev/null +++ b/k8s/01-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: web-portal diff --git a/k8s/02-postgres.yaml b/k8s/02-postgres.yaml new file mode 100755 index 0000000..31aad1c --- /dev/null +++ b/k8s/02-postgres.yaml @@ -0,0 +1,65 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pvc + namespace: web-portal +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: web-portal +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:15-alpine + env: + - name: POSTGRES_DB + value: portaldb + - name: POSTGRES_USER + value: portaluser + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: portal-secrets + key: db-password + ports: + - containerPort: 5432 + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: [pg_isready, -U, portaluser, -d, portaldb] + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: postgres-data + persistentVolumeClaim: + claimName: postgres-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-service + namespace: web-portal +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 diff --git a/k8s/03-secrets.yaml b/k8s/03-secrets.yaml new file mode 100755 index 0000000..6783408 --- /dev/null +++ b/k8s/03-secrets.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: portal-secrets + namespace: web-portal +type: Opaque +stringData: + db-password: "portalpass" + jwt-secret: "your-super-secret-jwt-key-change-this-in-production" diff --git a/k8s/04-backend.yaml b/k8s/04-backend.yaml new file mode 100755 index 0000000..0e34d12 --- /dev/null +++ b/k8s/04-backend.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: web-portal +spec: + replicas: 1 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + imagePullSecrets: + - name: gitea-registry-secret + containers: + - name: backend + image: 192.168.x.x:3000/username/portal-backend:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + env: + - name: DB_HOST + value: postgres-service + - name: DB_PORT + value: "5432" + - name: DB_NAME + value: portaldb + - name: DB_USER + value: portaluser + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: portal-secrets + key: db-password + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: portal-secrets + key: jwt-secret + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 20 + periodSeconds: 5 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 60 + periodSeconds: 15 + failureThreshold: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: backend-service + namespace: web-portal +spec: + selector: + app: backend + ports: + - port: 8000 + targetPort: 8000 diff --git a/k8s/05-frontend.yaml b/k8s/05-frontend.yaml new file mode 100755 index 0000000..31e2f9b --- /dev/null +++ b/k8s/05-frontend.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: web-portal +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + imagePullSecrets: + - name: gitea-registry-secret + containers: + - name: frontend + image: 192.168.x.x:3000/username/portal-frontend:latest + imagePullPolicy: Always + ports: + - containerPort: 80 + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend-service + namespace: web-portal +spec: + type: NodePort + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 + nodePort: 30090 diff --git a/k8s/06-argocd-app.yaml b/k8s/06-argocd-app.yaml new file mode 100755 index 0000000..08b677e --- /dev/null +++ b/k8s/06-argocd-app.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: web-portal + namespace: argocd +spec: + project: default + source: + repoURL: http://192.168.x.x:3000/username/k8s-portal.git + targetRevision: main + path: k8s + destination: + server: https://kubernetes.default.svc + namespace: web-portal + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/k8s/portal.yaml b/k8s/portal.yaml new file mode 100755 index 0000000..1730ac1 --- /dev/null +++ b/k8s/portal.yaml @@ -0,0 +1,299 @@ +# ============================================================ +# 1. Namespace +# ============================================================ +apiVersion: v1 +kind: Namespace +metadata: + name: web-portal + +--- +# ============================================================ +# 2. PostgreSQL - PersistentVolumeClaim +# ============================================================ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pvc + namespace: web-portal +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 2Gi + +--- +# ============================================================ +# 3. PostgreSQL - Secret +# ============================================================ +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + namespace: web-portal +type: Opaque +stringData: + POSTGRES_DB: portaldb + POSTGRES_USER: portaluser + POSTGRES_PASSWORD: portalpass + +--- +# ============================================================ +# 4. PostgreSQL - ConfigMap (init SQL) +# ============================================================ +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgres-init-sql + namespace: web-portal +data: + init.sql: | + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS webpages ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + url TEXT NOT NULL, + description TEXT DEFAULT '', + icon VARCHAR(10) DEFAULT '🌐', + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS user_webpages ( + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + webpage_id INTEGER REFERENCES webpages(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, webpage_id) + ); + -- admin / admin1234 + INSERT INTO users (username, password_hash, is_admin) + VALUES ('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiAYMxRmfvym', TRUE) + ON CONFLICT (username) DO NOTHING; + -- user1 / user1234 + INSERT INTO users (username, password_hash, is_admin) + VALUES ('user1', '$2b$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', FALSE) + ON CONFLICT (username) DO NOTHING; + INSERT INTO webpages (name, url, description, icon) VALUES + ('Google', 'https://www.google.com', '구글 검색 엔진', '🔍'), + ('GitHub', 'https://github.com', '소스코드 저장소', '🐙'), + ('Notion', 'https://notion.so', '문서 협업 툴', '📝') + ON CONFLICT DO NOTHING; + +--- +# ============================================================ +# 5. PostgreSQL - Deployment +# ============================================================ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: web-portal +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:15-alpine + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: postgres-secret + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + - name: init-sql + mountPath: /docker-entrypoint-initdb.d + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumes: + - name: data + persistentVolumeClaim: + claimName: postgres-pvc + - name: init-sql + configMap: + name: postgres-init-sql + +--- +# ============================================================ +# 6. PostgreSQL - Service +# ============================================================ +apiVersion: v1 +kind: Service +metadata: + name: postgres-service + namespace: web-portal +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 + +--- +# ============================================================ +# 7. Backend - Secret (JWT) +# ============================================================ +apiVersion: v1 +kind: Secret +metadata: + name: backend-secret + namespace: web-portal +type: Opaque +stringData: + JWT_SECRET: "change-this-to-a-secure-random-string-in-production" + +--- +# ============================================================ +# 8. Backend - Deployment +# ============================================================ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: web-portal +spec: + replicas: 2 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + initContainers: + - name: wait-for-db + image: busybox + command: ['sh', '-c', 'until nc -z postgres-service 5432; do echo waiting for postgres; sleep 2; done'] + containers: + - name: backend + image: web-portal-backend:latest + imagePullPolicy: Never # Docker Desktop 로컬 이미지 사용 + ports: + - containerPort: 8000 + env: + - name: DB_HOST + value: postgres-service + - name: DB_PORT + value: "5432" + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_DB + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_PASSWORD + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: backend-secret + key: JWT_SECRET + livenessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" + +--- +# ============================================================ +# 9. Backend - Service +# ============================================================ +apiVersion: v1 +kind: Service +metadata: + name: backend-service + namespace: web-portal +spec: + selector: + app: backend + ports: + - port: 8000 + targetPort: 8000 + +--- +# ============================================================ +# 10. Frontend (Nginx) - Deployment +# ============================================================ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: web-portal +spec: + replicas: 2 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: web-portal-frontend:latest + imagePullPolicy: Never # Docker Desktop 로컬 이미지 사용 + ports: + - containerPort: 80 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "200m" + +--- +# ============================================================ +# 11. Frontend - Service (NodePort - 외부 접속용) +# ============================================================ +apiVersion: v1 +kind: Service +metadata: + name: frontend-service + namespace: web-portal +spec: + type: NodePort + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 + nodePort: 30080 # http://localhost:30080 으로 접속