init: web portal
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled

This commit is contained in:
qorgh529
2026-04-06 21:16:17 +09:00
commit 5e7e245858
21 changed files with 1717 additions and 0 deletions

View File

@@ -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

87
GITOPS-GUIDE.md Executable file
View File

@@ -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=<gitea계정> \
--docker-password=<gitea패스워드> \
--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
# → 자동으로 빌드 & 배포됨!
```

102
README.md Executable file
View File

@@ -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] ─── 사용자, 페이지, 권한 데이터
```

6
backend/Dockerfile Executable file
View File

@@ -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"]

41
backend/init.sql Executable file
View File

@@ -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;

253
backend/main.py Executable file
View File

@@ -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"}

7
backend/requirements.txt Executable file
View File

@@ -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

59
build-and-deploy.sh Executable file
View File

@@ -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 "======================================"

5
cleanup.sh Executable file
View File

@@ -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 "✅ 삭제 완료"

43
deploy.sh Executable file
View File

@@ -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 "======================================"

4
frontend/Dockerfile Executable file
View File

@@ -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

516
frontend/index.html Executable file
View File

@@ -0,0 +1,516 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Portal</title>
<style>
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--bg: #f1f5f9;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--danger: #ef4444;
--success: #22c55e;
--border: #e2e8f0;
--admin: #7c3aed;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
/* ── Login ── */
#login-page {
display: flex; align-items: center; justify-content: center; min-height: 100vh;
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
}
.login-box {
background: white; border-radius: 16px; padding: 48px 40px; width: 380px;
box-shadow: 0 25px 60px rgba(0,0,0,0.3);
}
.login-logo { text-align: center; margin-bottom: 32px; }
.login-logo svg { width: 56px; height: 56px; }
.login-logo h1 { font-size: 1.5rem; font-weight: 700; color: var(--primary); margin-top: 12px; }
.login-logo p { color: var(--muted); font-size: 0.875rem; margin-top: 4px; }
.form-group { margin-bottom: 18px; }
.form-group label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 6px; color: var(--text); }
.form-group input {
width: 100%; padding: 11px 14px; border: 2px solid var(--border); border-radius: 8px;
font-size: 0.95rem; transition: border-color .2s;
}
.form-group input:focus { outline: none; border-color: var(--primary); }
.btn {
width: 100%; padding: 12px; background: var(--primary); color: white;
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; transition: background .2s;
}
.btn:hover { background: var(--primary-dark); }
.btn-sm {
width: auto; padding: 7px 16px; font-size: 0.8rem; border-radius: 6px;
}
.btn-danger { background: var(--danger); }
.btn-danger:hover { background: #dc2626; }
.btn-success { background: var(--success); }
.btn-success:hover { background: #16a34a; }
.btn-gray { background: #94a3b8; }
.btn-gray:hover { background: #64748b; }
.btn-purple { background: var(--admin); }
.btn-purple:hover { background: #6d28d9; }
.error-msg { color: var(--danger); font-size: 0.85rem; margin-top: 10px; text-align: center; }
/* ── App Layout ── */
#app-page { display: none; min-height: 100vh; flex-direction: column; }
header {
background: white; border-bottom: 1px solid var(--border);
padding: 0 32px; height: 60px;
display: flex; align-items: center; justify-content: space-between;
box-shadow: 0 1px 6px rgba(0,0,0,0.06);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-logo { font-weight: 700; font-size: 1.1rem; color: var(--primary); }
.badge-admin {
background: var(--admin); color: white; font-size: 0.7rem;
font-weight: 700; padding: 2px 8px; border-radius: 20px; letter-spacing: .5px;
}
.header-right { display: flex; align-items: center; gap: 14px; }
.user-chip {
display: flex; align-items: center; gap: 7px;
background: var(--bg); border-radius: 20px; padding: 5px 14px;
font-size: 0.875rem; font-weight: 500;
}
.user-chip svg { color: var(--muted); }
nav {
background: white; border-bottom: 1px solid var(--border);
padding: 0 32px; display: flex; gap: 4px;
}
.nav-btn {
padding: 14px 18px; border: none; background: none; cursor: pointer;
font-size: 0.9rem; color: var(--muted); font-weight: 500;
border-bottom: 3px solid transparent; transition: all .2s;
}
.nav-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
.nav-btn:hover:not(.active) { color: var(--text); }
main { padding: 32px; flex: 1; }
.page { display: none; }
.page.active { display: block; }
/* ── Cards ── */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.page-header h2 { font-size: 1.3rem; font-weight: 700; }
.page-header p { color: var(--muted); font-size: 0.875rem; margin-top: 2px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.card {
background: white; border-radius: 12px; padding: 24px;
box-shadow: 0 1px 4px rgba(0,0,0,0.07); border: 1px solid var(--border);
transition: transform .15s, box-shadow .15s; cursor: pointer; text-decoration: none; color: inherit;
}
.card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
.card-icon {
width: 48px; height: 48px; border-radius: 10px;
background: linear-gradient(135deg, var(--primary), #60a5fa);
display: flex; align-items: center; justify-content: center; margin-bottom: 14px;
}
.card-icon svg { color: white; }
.card h3 { font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
.card p { font-size: 0.825rem; color: var(--muted); line-height: 1.5; }
.card-url { font-size: 0.75rem; color: var(--primary); margin-top: 10px; word-break: break-all; }
.empty-state { text-align: center; padding: 80px 20px; color: var(--muted); }
.empty-state svg { opacity: .3; margin-bottom: 16px; }
.empty-state p { font-size: 1rem; }
/* ── Admin Table ── */
.table-wrapper { background: white; border-radius: 12px; border: 1px solid var(--border); overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th { background: var(--bg); padding: 12px 16px; text-align: left; font-size: 0.8rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
td { padding: 13px 16px; font-size: 0.875rem; border-top: 1px solid var(--border); vertical-align: middle; }
tr:hover td { background: #f8fafc; }
.actions { display: flex; gap: 8px; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
.tag-admin { background: #ede9fe; color: var(--admin); }
.tag-user { background: #dbeafe; color: var(--primary); }
/* ── Modal ── */
.modal-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 100;
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: white; border-radius: 16px; padding: 32px; width: 480px;
max-width: 95vw; box-shadow: 0 30px 80px rgba(0,0,0,0.25);
max-height: 90vh; overflow-y: auto;
}
.modal h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 20px; }
.modal-footer { display: flex; gap: 10px; margin-top: 24px; justify-content: flex-end; }
.modal-footer .btn { width: auto; }
.checkbox-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; }
.checkbox-item {
display: flex; align-items: center; gap: 10px;
padding: 11px 14px; border-bottom: 1px solid var(--border); cursor: pointer;
}
.checkbox-item:last-child { border-bottom: none; }
.checkbox-item:hover { background: var(--bg); }
.checkbox-item input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--primary); }
.checkbox-item .item-info { flex: 1; }
.checkbox-item .item-info strong { font-size: 0.875rem; display: block; }
.checkbox-item .item-info span { font-size: 0.75rem; color: var(--muted); }
.section-divider { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
</style>
</head>
<body>
<!-- ══════════ LOGIN ══════════ -->
<div id="login-page">
<div class="login-box">
<div class="login-logo">
<svg viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="56" rx="14" fill="#2563eb"/>
<path d="M14 20h28M14 28h28M14 36h18" stroke="white" stroke-width="3" stroke-linecap="round"/>
</svg>
<h1>Web Portal</h1>
<p>조직 내부 웹페이지 통합 포털</p>
</div>
<div class="form-group">
<label>아이디</label>
<input type="text" id="login-user" placeholder="사용자명 입력" />
</div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()" />
</div>
<button class="btn" onclick="doLogin()">로그인</button>
<div id="login-error" class="error-msg"></div>
</div>
</div>
<!-- ══════════ APP ══════════ -->
<div id="app-page">
<header>
<div class="header-left">
<span class="header-logo">🌐 Web Portal</span>
<span id="admin-badge" class="badge-admin" style="display:none">ADMIN</span>
</div>
<div class="header-right">
<div class="user-chip">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
<span id="header-username"></span>
</div>
<button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button>
</div>
</header>
<nav id="main-nav">
<button class="nav-btn active" onclick="showPage('my-pages')">🏠 내 페이지</button>
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">📋 페이지 관리</button>
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
</nav>
<main>
<!-- My Pages -->
<div id="page-my-pages" class="page active">
<div class="page-header">
<div><h2>내 페이지 목록</h2><p>접속 가능한 웹페이지입니다. 클릭하면 새 탭에서 열립니다.</p></div>
</div>
<div id="my-pages-grid" class="grid"></div>
</div>
<!-- Admin: Pages -->
<div id="page-admin-pages" class="page">
<div class="page-header">
<div><h2>페이지 관리</h2><p>웹페이지를 추가·수정·삭제합니다.</p></div>
<button class="btn btn-sm" onclick="openPageModal()">+ 페이지 추가</button>
</div>
<div class="table-wrapper">
<table>
<thead><tr><th>이름</th><th>URL</th><th>설명</th><th>작업</th></tr></thead>
<tbody id="pages-table-body"></tbody>
</table>
</div>
</div>
<!-- Admin: Users -->
<div id="page-admin-users" class="page">
<div class="page-header">
<div><h2>사용자 관리</h2><p>계정 생성, 삭제, 페이지 접근 권한을 설정합니다.</p></div>
<button class="btn btn-sm" onclick="openUserModal()">+ 사용자 추가</button>
</div>
<div class="table-wrapper">
<table>
<thead><tr><th>사용자명</th><th>권한</th><th>작업</th></tr></thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
</div>
</main>
</div>
<!-- ══════════ MODALS ══════════ -->
<!-- Page Modal -->
<div id="page-modal" class="modal-overlay">
<div class="modal">
<h3 id="page-modal-title">페이지 추가</h3>
<div class="form-group"><label>페이지 이름 *</label><input type="text" id="pm-name" placeholder="예: Jenkins CI"></div>
<div class="form-group"><label>URL *</label><input type="text" id="pm-url" placeholder="예: http://192.168.1.10:8080"></div>
<div class="form-group"><label>설명</label><input type="text" id="pm-desc" placeholder="짧은 설명 (선택)"></div>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('page-modal')">취소</button>
<button class="btn btn-sm" onclick="savePage()">저장</button>
</div>
</div>
</div>
<!-- User Modal -->
<div id="user-modal" class="modal-overlay">
<div class="modal">
<h3>사용자 추가</h3>
<div class="form-group"><label>사용자명 *</label><input type="text" id="um-username" placeholder="예: john"></div>
<div class="form-group"><label>비밀번호 *</label><input type="password" id="um-password" placeholder="비밀번호 입력"></div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="um-admin" style="width:16px;height:16px;accent-color:var(--admin)">
관리자 권한 부여
</label>
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('user-modal')">취소</button>
<button class="btn btn-sm" onclick="saveUser()">저장</button>
</div>
</div>
</div>
<!-- Access Modal -->
<div id="access-modal" class="modal-overlay">
<div class="modal">
<h3 id="access-modal-title">접근 권한 설정</h3>
<p style="font-size:.85rem;color:var(--muted);margin-bottom:14px">접근 허용할 페이지를 선택하세요.</p>
<div id="access-checkbox-list" class="checkbox-list"></div>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('access-modal')">취소</button>
<button class="btn btn-sm btn-purple" onclick="saveAccess()">저장</button>
</div>
</div>
</div>
<script>
const API = '/api';
let token = localStorage.getItem('portal_token');
let currentUser = null;
let editingPageId = null;
let editingUserId = null;
// ── Helpers ─────────────────────────────────────────────
async function apiFetch(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
...opts
});
if (res.status === 401) { doLogout(); return; }
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.detail || 'Error'); }
return res.json();
}
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
const btns = document.querySelectorAll('.nav-btn');
btns.forEach(b => { if (b.getAttribute('onclick') && b.getAttribute('onclick').includes(name)) b.classList.add('active'); });
if (name === 'my-pages') loadMyPages();
if (name === 'admin-pages') loadAdminPages();
if (name === 'admin-users') loadAdminUsers();
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function openModal(id) { document.getElementById(id).classList.add('open'); }
// ── Login / Logout ───────────────────────────────────────
async function doLogin() {
const username = document.getElementById('login-user').value.trim();
const password = document.getElementById('login-pass').value;
document.getElementById('login-error').textContent = '';
try {
const data = await apiFetch('/auth/login', {
method: 'POST', body: JSON.stringify({ username, password })
});
token = data.token;
localStorage.setItem('portal_token', token);
currentUser = { username: data.username, is_admin: data.is_admin };
showApp();
} catch (e) {
document.getElementById('login-error').textContent = '아이디 또는 비밀번호가 올바르지 않습니다.';
}
}
function doLogout() {
token = null; currentUser = null;
localStorage.removeItem('portal_token');
document.getElementById('app-page').style.display = 'none';
document.getElementById('login-page').style.display = 'flex';
document.getElementById('login-user').value = '';
document.getElementById('login-pass').value = '';
}
function showApp() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('app-page').style.display = 'flex';
document.getElementById('header-username').textContent = currentUser.username;
document.getElementById('admin-badge').style.display = currentUser.is_admin ? 'inline' : 'none';
document.querySelectorAll('.admin-only').forEach(el => {
el.style.display = currentUser.is_admin ? 'inline-block' : 'none';
});
showPage('my-pages');
}
// ── My Pages ─────────────────────────────────────────────
async function loadMyPages() {
const pages = await apiFetch('/my-pages');
const grid = document.getElementById('my-pages-grid');
if (!pages || pages.length === 0) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
<p>접근 가능한 페이지가 없습니다.<br>관리자에게 문의하세요.</p></div>`;
return;
}
grid.innerHTML = pages.map(p => `
<a class="card" href="${escHtml(p.url)}" target="_blank" rel="noopener">
<div class="card-icon">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 010 20M12 2a15 15 0 000 20"/></svg>
</div>
<h3>${escHtml(p.name)}</h3>
<p>${escHtml(p.description || '설명 없음')}</p>
<div class="card-url">${escHtml(p.url)}</div>
</a>`).join('');
}
// ── Admin: Pages ─────────────────────────────────────────
async function loadAdminPages() {
const pages = await apiFetch('/admin/webpages');
const tbody = document.getElementById('pages-table-body');
tbody.innerHTML = pages.map(p => `
<tr>
<td><strong>${escHtml(p.name)}</strong></td>
<td><a href="${escHtml(p.url)}" target="_blank" style="color:var(--primary)">${escHtml(p.url)}</a></td>
<td style="color:var(--muted)">${escHtml(p.description || '-')}</td>
<td><div class="actions">
<button class="btn btn-sm btn-gray" onclick="openPageModal(${p.id},'${escJs(p.name)}','${escJs(p.url)}','${escJs(p.description||'')}')">수정</button>
<button class="btn btn-sm btn-danger" onclick="deletePage(${p.id})">삭제</button>
</div></td>
</tr>`).join('') || '<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">등록된 페이지가 없습니다.</td></tr>';
}
function openPageModal(id, name, url, desc) {
editingPageId = id || null;
document.getElementById('page-modal-title').textContent = id ? '페이지 수정' : '페이지 추가';
document.getElementById('pm-name').value = name || '';
document.getElementById('pm-url').value = url || '';
document.getElementById('pm-desc').value = desc || '';
openModal('page-modal');
}
async function savePage() {
const body = {
name: document.getElementById('pm-name').value.trim(),
url: document.getElementById('pm-url').value.trim(),
description: document.getElementById('pm-desc').value.trim()
};
if (!body.name || !body.url) { alert('이름과 URL은 필수입니다.'); return; }
try {
if (editingPageId) {
await apiFetch('/admin/webpages/' + editingPageId, { method: 'PUT', body: JSON.stringify(body) });
} else {
await apiFetch('/admin/webpages', { method: 'POST', body: JSON.stringify(body) });
}
closeModal('page-modal');
loadAdminPages();
} catch(e) { alert(e.message); }
}
async function deletePage(id) {
if (!confirm('이 페이지를 삭제하시겠습니까?')) return;
await apiFetch('/admin/webpages/' + id, { method: 'DELETE' });
loadAdminPages();
}
// ── Admin: Users ─────────────────────────────────────────
async function loadAdminUsers() {
const users = await apiFetch('/admin/users');
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = users.map(u => `
<tr>
<td><strong>${escHtml(u.username)}</strong></td>
<td><span class="tag ${u.is_admin ? 'tag-admin' : 'tag-user'}">${u.is_admin ? '관리자' : '일반사용자'}</span></td>
<td><div class="actions">
${!u.is_admin ? `<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한 설정</button>` : ''}
${u.username !== 'admin' ? `<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">삭제</button>` : ''}
</div></td>
</tr>`).join('') || '<tr><td colspan="3" style="text-align:center;color:var(--muted);padding:40px">사용자가 없습니다.</td></tr>';
}
function openUserModal() {
document.getElementById('um-username').value = '';
document.getElementById('um-password').value = '';
document.getElementById('um-admin').checked = false;
openModal('user-modal');
}
async function saveUser() {
const body = {
username: document.getElementById('um-username').value.trim(),
password: document.getElementById('um-password').value,
is_admin: document.getElementById('um-admin').checked
};
if (!body.username || !body.password) { alert('사용자명과 비밀번호는 필수입니다.'); return; }
try {
await apiFetch('/admin/users', { method: 'POST', body: JSON.stringify(body) });
closeModal('user-modal');
loadAdminUsers();
} catch(e) { alert(e.message); }
}
async function deleteUser(id) {
if (!confirm('이 사용자를 삭제하시겠습니까?')) return;
await apiFetch('/admin/users/' + id, { method: 'DELETE' });
loadAdminUsers();
}
async function openAccessModal(userId, username) {
editingUserId = userId;
document.getElementById('access-modal-title').textContent = `'${username}' 접근 권한 설정`;
const pages = await apiFetch('/admin/users/' + userId + '/pages');
const list = document.getElementById('access-checkbox-list');
list.innerHTML = pages.map(p => `
<label class="checkbox-item">
<input type="checkbox" value="${p.id}" ${p.has_access ? 'checked' : ''}>
<div class="item-info">
<strong>${escHtml(p.name)}</strong>
<span>${escHtml(p.url)}</span>
</div>
</label>`).join('') || '<div style="padding:20px;text-align:center;color:var(--muted)">등록된 페이지가 없습니다.</div>';
openModal('access-modal');
}
async function saveAccess() {
const checked = [...document.querySelectorAll('#access-checkbox-list input:checked')].map(i => parseInt(i.value));
await apiFetch('/admin/users/' + editingUserId + '/pages', {
method: 'PUT', body: JSON.stringify({ webpage_ids: checked })
});
closeModal('access-modal');
alert('권한이 저장되었습니다.');
}
// ── Utils ────────────────────────────────────────────────
function escHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
// ── Init ─────────────────────────────────────────────────
(async function init() {
if (token) {
try {
currentUser = await apiFetch('/auth/me');
showApp();
} catch { doLogout(); }
}
})();
</script>
</body>
</html>

22
frontend/nginx.conf Executable file
View File

@@ -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;
}
}

14
k8s/00-registry-secret.yaml Executable file
View File

@@ -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=<gitea계정> \
# --docker-password=<gitea패스워드> \
# --docker-email=<이메일>
#
# 생성 확인:
# kubectl get secret gitea-registry-secret -n web-portal

4
k8s/01-namespace.yaml Executable file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: web-portal

65
k8s/02-postgres.yaml Executable file
View File

@@ -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

9
k8s/03-secrets.yaml Executable file
View File

@@ -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"

68
k8s/04-backend.yaml Executable file
View File

@@ -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

43
k8s/05-frontend.yaml Executable file
View File

@@ -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

20
k8s/06-argocd-app.yaml Executable file
View File

@@ -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

299
k8s/portal.yaml Executable file
View File

@@ -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 으로 접속