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