This commit is contained in:
50
.gitea/workflows/build-and-push.yaml
Executable file
50
.gitea/workflows/build-and-push.yaml
Executable 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
87
GITOPS-GUIDE.md
Executable 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
102
README.md
Executable 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
6
backend/Dockerfile
Executable 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
41
backend/init.sql
Executable 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
253
backend/main.py
Executable 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
7
backend/requirements.txt
Executable 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
59
build-and-deploy.sh
Executable 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
5
cleanup.sh
Executable 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
43
deploy.sh
Executable 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
4
frontend/Dockerfile
Executable 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
516
frontend/index.html
Executable 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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
22
frontend/nginx.conf
Executable 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
14
k8s/00-registry-secret.yaml
Executable 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
4
k8s/01-namespace.yaml
Executable file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: web-portal
|
||||
65
k8s/02-postgres.yaml
Executable file
65
k8s/02-postgres.yaml
Executable 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
9
k8s/03-secrets.yaml
Executable 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
68
k8s/04-backend.yaml
Executable 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
43
k8s/05-frontend.yaml
Executable 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
20
k8s/06-argocd-app.yaml
Executable 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
299
k8s/portal.yaml
Executable 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 으로 접속
|
||||
Reference in New Issue
Block a user