commit 0d808202f6445bf9e35d1e3bbeed680c9417377c Author: changmin hyeon Date: Tue Feb 10 11:33:59 2026 +0900 init diff --git a/ado/azure-pipeline.docker.yaml.j2 b/ado/azure-pipeline.docker.yaml.j2 new file mode 100644 index 0000000..e69de29 diff --git a/ado/azure-pipeline.k8s.yaml.j2 b/ado/azure-pipeline.k8s.yaml.j2 new file mode 100644 index 0000000..b84c779 --- /dev/null +++ b/ado/azure-pipeline.k8s.yaml.j2 @@ -0,0 +1,55 @@ +trigger: none + +## [SH] Import Self-hosting resources by DevOps Org. ================================== + +pool: default + +variables: + - group: SKI_ACR_CREDENTIAL_GRP + - name: GLOBAL_REGISTRY_URL # ACR 주소 + value: {{ regi }} + - name: GLOBAL_REPOSITORY # 이미지 이름 ((프로젝트명)/(dev/prod 등)/(Repos or 이미지 명) --> skiwebacr.azurecr.io/devops/sample) + value: {% if regi_repo %}{{ regi_repo | trim('/') }}/{% endif %}node-{{ node_id }}-main + - name: DEPLOY_K8S # 배포할 K8s 이름 (pipelines -> environment 에서 확인 가능) + value: {{ k8s_name }} + - name: DEPLOY_NAMESPACE # 배포할 K8s namespace (pipelines -> environment -> Resources 에서 확인 가능) + value: {{ namespace }} + - name: DEPLOY_MANIFESTS_PATH # Manifest 파일 위치 (./manifests/dev/* or ./manifests/prd/*) + value: ./k8s/* + +parameters: +- name: IMAGE_TAG + displayName: "컨테이너 이미지 태그" + type: string + default: $(Build.SourceVersion) + +resources: + repositories: + - repository: pipeline-template # In a different organization + endpoint: pipeline-template + type: git + name: DevOps_PF/pipeline-template + ref: main + +##================================================================================= + +stages: + - template: pipeline/v2/ci/python/PipContainerBuildStage.yaml@pipeline-template + parameters: + DOCKERFILE_NAME: Dockerfile # Dockerfile 위치 + CONTAINER_REGISTRY_URL: $(GLOBAL_REGISTRY_URL) # ACR URL + CONTAINER_REPOSITORY: $(GLOBAL_REPOSITORY) # ACR Repository + CONTAINER_TAG: {{ '${{ parameters.IMAGE_TAG }}' }} # 컨테이너 태그 + + COMMON_CONTAINER_REGISTRY_PASSWORD: $(COMMON_ACR_PW) + WEB_CONTAINER_REGISTRY_PASSWORD: $(WEB_ACR_PW) + + - template: pipeline/v2/cd/k8s/ManifestDeployStage.yaml@pipeline-template + parameters: + CONTAINER_REGISTRY_URL: $(GLOBAL_REGISTRY_URL) # ACR URL + CONTAINER_REPOSITORY: $(GLOBAL_REPOSITORY) # ACR Repository + CONTAINER_TAG: {{ '${{ parameters.IMAGE_TAG }}' }} # 이미지 태그 + + ENVIRONMENT_NAME: $(DEPLOY_K8S) # 배포할 K8s 이름 (pipeline -> environment) + NAMESPACE: $(DEPLOY_NAMESPACE) # 배포할 k8s namespace + MANIFESTS_DIR: $(DEPLOY_MANIFESTS_PATH) # Manifest 파일 위치 \ No newline at end of file diff --git a/agent/Dockerfile.j2 b/agent/Dockerfile.j2 new file mode 100644 index 0000000..88e1d7c --- /dev/null +++ b/agent/Dockerfile.j2 @@ -0,0 +1,14 @@ +FROM {{ base_image | default("python:3.11-slim") }} +WORKDIR /app +COPY . /app + +{% set pip_opts = "" %} +{% if pip_registry %} +{% set pip_opts = "--index-url " ~ pip_registry %} +{% endif %} + +RUN pip install --no-cache-dir -U pip +RUN pip install --no-cache-dir {{ pip_opts }} uvicorn fastapi +RUN pip install --no-cache-dir {{ pip_opts }} -r requirements.txt + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "80"] diff --git a/agent/src_main.py.j2 b/agent/src_main.py.j2 new file mode 100644 index 0000000..3c9789b --- /dev/null +++ b/agent/src_main.py.j2 @@ -0,0 +1,10 @@ +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/") +def root(): + """ + This is an api that says, "Hello!" + """ + return {"response": "Hello ! {{ node_name }} !"} \ No newline at end of file diff --git a/common/.gitignore.j2 b/common/.gitignore.j2 new file mode 100644 index 0000000..2b91c72 --- /dev/null +++ b/common/.gitignore.j2 @@ -0,0 +1,4 @@ +venv/ +__pycache__/ +*.pyc +.env diff --git a/common/Jenkinsfile.docker.j2 b/common/Jenkinsfile.docker.j2 new file mode 100644 index 0000000..6eadef8 --- /dev/null +++ b/common/Jenkinsfile.docker.j2 @@ -0,0 +1,53 @@ +pipeline { + agent any + parameters { + string(name: 'BRANCH_NAME', defaultValue: '', description: '빌드할 브랜치명(비우면 main)') + string(name: 'GIT_COMMIT_HASH', defaultValue: '', description: '빌드할 커밋 해시(비우면 최신 커밋 기준)') + } + stages { + stage('Checkout') { + steps { + script { + sh 'git fetch --all' + def checkoutBranch = params.BRANCH_NAME?.trim() ? params.BRANCH_NAME.trim() : 'main' + def commitHash = params.GIT_COMMIT_HASH?.trim() + if (commitHash) { + echo "브랜치(${checkoutBranch})의 커밋 해시(${commitHash})로 체크아웃합니다." + sh "git fetch origin ${checkoutBranch}" + sh "git checkout ${checkoutBranch}" + sh "git checkout ${commitHash}" + } else { + echo "브랜치(${checkoutBranch})의 최신 커밋으로 체크아웃합니다." + sh "git fetch origin ${checkoutBranch}" + sh "git checkout ${checkoutBranch}" + commitHash = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim() + } + env.BRANCH_NAME = checkoutBranch + env.GIT_COMMIT_HASH = commitHash + } + } + } + stage('Build & Deploy') { + steps { + script { + def composeProject = "{{ node_id }}-" + (env.BRANCH_NAME ?: "") + "{{ node_type }}" + + writeFile file: '.env', text: """ +BRANCH_NAME=${env.BRANCH_NAME} +GIT_COMMIT_HASH=${env.GIT_COMMIT_HASH} +""" + sh "cat .env" + sh "cat docker-compose.yml" + sh "docker compose -p ${composeProject} --env-file .env down --rmi all" + sh "docker compose -p ${composeProject} build --no-cache" + sh "docker compose -p ${composeProject} --env-file .env up --force-recreate --remove-orphans -d" + } + } + } + } + post { + always { + cleanWs() + } + } +} diff --git a/common/Jenkinsfile.k8s.j2 b/common/Jenkinsfile.k8s.j2 new file mode 100644 index 0000000..2d8c082 --- /dev/null +++ b/common/Jenkinsfile.k8s.j2 @@ -0,0 +1,168 @@ +pipeline { + agent { label 'py-kaniko' } // JCasC 파드 템플릿: jnlp / ci-python / tooling / kaniko + + options { timestamps() } + + parameters { + string(name: 'BRANCH_NAME', defaultValue: '', description: '빌드할 브랜치명(비우면 main)') + string(name: 'GIT_COMMIT_HASH', defaultValue: '', description: '빌드할 커밋 해시(비우면 최신 커밋 기준)') + } + + environment { + // 고정 값(노드 생성 시점에 Jinja로 박힘) + NODE_ID = '{{ node_id }}' + NODE_TYPE = '{{ node_type }}' + NAMESPACE = '{{ namespace }}' + REGISTRY = '{{ regi }}' + platform_namespace = '{{ platform_namespace }}' + + // ✅ imagePullSecret 이름도 "메소드에서 전달"되도록 Jinja로 박힘 + // (create_node_project_files(extra_context={"image_pull_secret":"..."}) 로 전달) + IMAGE_PULL_SECRET = '{{ image_pull_secret | default("acr-pull") }}' + } + + stages { + + stage('Checkout') { + steps { + container('jnlp') { + sh ''' + set -e + git fetch --all || true + ''' + script { + def checkoutBranch = params.BRANCH_NAME?.trim() ? params.BRANCH_NAME.trim() : 'main' + def commitHash = params.GIT_COMMIT_HASH?.trim() + + if (commitHash) { + sh """ + git fetch origin ${checkoutBranch} + git checkout ${checkoutBranch} + git checkout ${commitHash} + """ + } else { + sh """ + git fetch origin ${checkoutBranch} + git checkout ${checkoutBranch} + """ + commitHash = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim() + } + + env.BRANCH_NAME = checkoutBranch + env.GIT_COMMIT_HASH = commitHash + + env.RELEASE_NAME = "node-${NODE_ID}-${env.BRANCH_NAME}" + env.IMAGE_REPO = "${REGISTRY}/node.${NODE_ID}.${env.BRANCH_NAME}" + env.IMAGE_TAG = env.GIT_COMMIT_HASH + env.FULL_IMAGE = "${env.IMAGE_REPO}:${env.IMAGE_TAG}" + + echo "RELEASE_NAME=${env.RELEASE_NAME}" + echo "FULL_IMAGE=${env.FULL_IMAGE}" + echo "IMAGE_PULL_SECRET=${env.IMAGE_PULL_SECRET}" + } + } + } + } + + stage('Build Image (Kaniko)') { + steps { + container('kaniko') { + retry(3) { + sh ''' + set -e + /kaniko/executor \ + --context=$WORKSPACE \ + --dockerfile=Dockerfile \ + --destination=$FULL_IMAGE \ + --cache=true \ + --snapshot-mode=time + ''' + } + } + } + } + + stage('Deploy (Manifests)') { + steps { + + container('tooling') { + sh ''' + set -euo pipefail + + # ✅ B) imagePullSecret 복제 (platform -> ${NAMESPACE}) + # secret 이름도 IMAGE_PULL_SECRET로 통일 + if [ -n "${IMAGE_PULL_SECRET}" ] && ! kubectl -n "${NAMESPACE}" get secret "${IMAGE_PULL_SECRET}" >/dev/null 2>&1; then + echo "[INFO] Copying imagePullSecret '${IMAGE_PULL_SECRET}' platform -> ${NAMESPACE} ..." + {% raw %} + kubectl -n "${platform_namespace}" get secret "${IMAGE_PULL_SECRET}" -o go-template='{{ index .data ".dockerconfigjson" }}' > .dockercfg.b64 + {% endraw %} + base64 -d .dockercfg.b64 > .dockerconfigjson + kubectl -n "${NAMESPACE}" create secret generic "${IMAGE_PULL_SECRET}" \ + --type=kubernetes.io/dockerconfigjson \ + --from-file=.dockerconfigjson + rm -f .dockercfg.b64 .dockerconfigjson + fi + + # ✅ C) 배포 전 Deployment generation 저장 + PREV_GEN="" + if kubectl -n "${NAMESPACE}" get deploy "${RELEASE_NAME}" >/dev/null 2>&1; then + PREV_GEN="$(kubectl -n "${NAMESPACE}" get deploy "${RELEASE_NAME}" -o jsonpath='{.metadata.generation}' 2>/dev/null || true)" + fi + echo "[INFO] PREV_GEN=${PREV_GEN:-} deploy=${RELEASE_NAME}" + + # ✅ D) repo의 manifests 템플릿을 렌더링 후 apply + # - manifests/*.yaml 은 Jinja로 이미 한번 렌더된 상태(노드 생성 시점) + # - 여기서는 배포마다 바뀌는 값(RELASE_NAME, FULL_IMAGE, BRANCH_NAME)만 envsubst로 주입 + rm -rf .rendered && mkdir -p .rendered + + # envsubst 치환 대상 화이트리스트 + export RELEASE_NAME FULL_IMAGE BRANCH_NAME + + for f in manifests/*.yaml; do + echo "[INFO] render $f" + envsubst '${RELEASE_NAME} ${FULL_IMAGE} ${BRANCH_NAME}' < "$f" > ".rendered/$(basename "$f")" + done + + echo "---- Rendered files ----" + ls -al .rendered + + kubectl -n "${NAMESPACE}" apply -f .rendered/ + + # ✅ E) 롤아웃 대기(Deployment가 이 이름으로 생성된다는 전제) + kubectl -n "${NAMESPACE}" rollout status deploy "${RELEASE_NAME}" --timeout=10m + + # ✅ F) generation 비교 → 안 바뀌면 rollout restart + POST_GEN="$(kubectl -n "${NAMESPACE}" get deploy "${RELEASE_NAME}" -o jsonpath='{.metadata.generation}' 2>/dev/null || true)" + echo "[INFO] POST_GEN=${POST_GEN:-} deploy=${RELEASE_NAME}" + + if [ -n "${PREV_GEN}" ] && [ -n "${POST_GEN}" ] && [ "${PREV_GEN}" = "${POST_GEN}" ]; then + echo "[INFO] Deployment spec unchanged (generation=${POST_GEN}). Running rollout restart..." + kubectl -n "${NAMESPACE}" rollout restart deploy "${RELEASE_NAME}" + kubectl -n "${NAMESPACE}" rollout status deploy "${RELEASE_NAME}" --timeout=10m + else + echo "[INFO] Deployment spec changed (or first install). Skip rollout restart." + fi + + echo '--- STATUS ---' + kubectl -n "${NAMESPACE}" get deploy,po,svc -l watcher.nodeId="${NODE_ID}",watcher.role="${NODE_TYPE}" || true + ''' + } + } + } + } + + post { + always { + container('tooling') { + sh ''' + set +e + echo '--- FINAL SUMMARY ---' + kubectl -n "${NAMESPACE}" get deploy "${RELEASE_NAME}" -o wide || true + kubectl -n "${NAMESPACE}" get po -l app.kubernetes.io/name="${RELEASE_NAME}" -o wide || true + kubectl -n "${NAMESPACE}" get svc "${RELEASE_NAME}" -o wide || true + ''' + } + cleanWs() + } + } +} diff --git a/common/README.md.j2 b/common/README.md.j2 new file mode 100644 index 0000000..a208e6c --- /dev/null +++ b/common/README.md.j2 @@ -0,0 +1,38 @@ +# {{ node_name }} + +파이썬 프로젝트 템플릿 + +--- + +## requirements.txt 작성 안내 + +배포 시 필요한 라이브러리 설치를 위해 **`requirements.txt`를 반드시 작성**해 주세요. + +> ⚠️ 주의 +> 이 프로젝트는 **import 이름과 pip 설치 이름이 다른 라이브러리**가 포함될 수 있어 +> `pipreqs` 같은 자동 생성 도구 결과는 누락/오탐이 날 수 있습니다. +> (예: `import fitz` → `PyMuPDF`) + +### 권장 작성 방법 +1. 프로젝트가 정상 실행되는 가상환경(venv)에서 설치를 완료한 뒤 +2. 아래 명령으로 생성하세요. + +```bash +pip freeze > requirements.txt +``` +## MCP Tool 작성 안내 + +MCP(Custom Tool)로 노드를 구현하는 경우, 툴 함수에 설명(docstring)을 반드시 작성해야 합니다. +설명이 없으면 Dify/Tool Registry에서 노드가 정상적으로 노출되지 않거나 동작이 제한될 수 있습니다. + +아래 형식을 지켜 주세요: +``` +@mcp.tool() +def 함수명(...): + """ + 설명 작성 필수 + - 이 툴이 무엇을 하는지 + - 입력 파라미터 의미 + - 반환값 의미 + """ +``` diff --git a/common/docker-compose.yml.j2 b/common/docker-compose.yml.j2 new file mode 100644 index 0000000..998abc9 --- /dev/null +++ b/common/docker-compose.yml.j2 @@ -0,0 +1,17 @@ +version: "3.8" +services: + {{ node_id }}: + build: . + container_name: {{ node_id }}-${BRANCH_NAME}-{{ node_type }} + image: {{ node_id }}.${BRANCH_NAME}:${GIT_COMMIT_HASH} + environment: + - PYTHONUNBUFFERED=1 + - BRANCH_NAME=${BRANCH_NAME} + - GIT_COMMIT_HASH=${GIT_COMMIT_HASH} + restart: unless-stopped + networks: + - ai + +networks: + ai: + external: true diff --git a/common/requirements.txt.j2 b/common/requirements.txt.j2 new file mode 100644 index 0000000..e69de29 diff --git a/common/src__init__.py.j2 b/common/src__init__.py.j2 new file mode 100644 index 0000000..52ab30e --- /dev/null +++ b/common/src__init__.py.j2 @@ -0,0 +1 @@ +# {{ node_name }} {{ node_type }} 패키지 초기화 파일 \ No newline at end of file diff --git a/k8s/deployment.yaml copy.j2 b/k8s/deployment.yaml copy.j2 new file mode 100644 index 0000000..d4f8bdd --- /dev/null +++ b/k8s/deployment.yaml copy.j2 @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${RELEASE_NAME} + labels: + app.kubernetes.io/name: ${RELEASE_NAME} + app.kubernetes.io/managed-by: "jenkins" + watcher.project: "{{ project_id }}" + watcher.nodeId: "{{ node_id }}" + watcher.role: "{{ node_type }}" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ${RELEASE_NAME} + template: + metadata: + labels: + app.kubernetes.io/name: ${RELEASE_NAME} + app.kubernetes.io/managed-by: "jenkins" + watcher.project: "{{ project_id }}" + watcher.nodeId: "{{ node_id }}" + watcher.role: "{{ node_type }}" + watcher.branch: ${BRANCH_NAME} + annotations: + watcher.enabled: "true" + spec: +{% if image_pull_secret %} + imagePullSecrets: + - name: {{ image_pull_secret }} +{% endif %} + containers: + - name: app + image: ${FULL_IMAGE} + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {{ app_port | default(80) }} + env: + - name: TZ + value: "Asia/Seoul" + # 기본 probe (필요 없으면 제거/수정) + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + resources: + requests: + cpu: "50m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" diff --git a/k8s/deployment.yaml.j2 b/k8s/deployment.yaml.j2 new file mode 100644 index 0000000..f3c8939 --- /dev/null +++ b/k8s/deployment.yaml.j2 @@ -0,0 +1,94 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: node-{{ node_id }}-main + namespace: {{ namespace }} + labels: + app: node-{{ node_id }}-main + watcher.project: "{{ project_id }}" + watcher.nodeId: "{{ node_id }}" + watcher.role: "{{ node_type }}" +spec: + replicas: 1 + selector: + matchLabels: + app: node-{{ node_id }}-main + template: + metadata: + labels: + app: node-{{ node_id }}-main + watcher.project: "{{ project_id }}" + watcher.nodeId: "{{ node_id }}" + watcher.role: "{{ node_type }}" + annotations: + watcher.enabled: "true" + spec: +{% if image_pull_secret %} + imagePullSecrets: + - name: {{ image_pull_secret }} +{% endif %} + +{% if node_affinity_expressions %} + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: +{% for expr in node_affinity_expressions %} + - key: {{ expr["key"] }} + operator: {{ expr["operator"] }} + values: +{% for v in expr["values"] %} + - {{ v }} +{% endfor %} +{% endfor %} +{% endif %} + +{% if tolerations %} + tolerations: +{% for t in tolerations %} + - key: {{ t["key"] }} + operator: {{ t["operator"] }} +{% if t.get("value") %} + value: {{ t["value"] }} +{% endif %} +{% if t.get("effect") %} + effect: {{ t["effect"] }} +{% endif %} +{% endfor %} +{% endif %} + + containers: + - name: app + image: {{ regi }}{% if regi_repo %}/{{ regi_repo | trim('/') }}{% endif %}/node-{{ node_id }}-main + imagePullPolicy: Always + ports: + - name: http + containerPort: {{ app_port | default(80) }} + env: + - name: TZ + value: "Asia/Seoul" + # 기본 probe (필요 없으면 제거/수정) + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + resources: + requests: + cpu: "50m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" diff --git a/k8s/service.yaml copy.j2 b/k8s/service.yaml copy.j2 new file mode 100644 index 0000000..591a57b --- /dev/null +++ b/k8s/service.yaml copy.j2 @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: ${RELEASE_NAME} + labels: + app.kubernetes.io/name: ${RELEASE_NAME} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: ${RELEASE_NAME} + ports: + - name: http + port: {{ service_port | default(80) }} + targetPort: http diff --git a/k8s/service.yaml.j2 b/k8s/service.yaml.j2 new file mode 100644 index 0000000..0bba567 --- /dev/null +++ b/k8s/service.yaml.j2 @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: node-{{ node_id }}-main + namespace: {{ namespace }} + labels: + app: node-{{ node_id }}-main +spec: + type: ClusterIP + selector: + app: node-{{ node_id }}-main + ports: + - name: http + port: {{ service_port | default(80) }} + targetPort: http diff --git a/mcp/Dockerfile.j2 b/mcp/Dockerfile.j2 new file mode 100644 index 0000000..7e51ec9 --- /dev/null +++ b/mcp/Dockerfile.j2 @@ -0,0 +1,14 @@ +FROM {{ base_image | default("python:3.11-slim") }} +WORKDIR /app +COPY . /app + +{% set pip_opts = "" %} +{% if pip_registry %} +{% set pip_opts = "--index-url " ~ pip_registry %} +{% endif %} + +RUN pip install --no-cache-dir -U pip +RUN pip install --no-cache-dir {{ pip_opts }} fastmcp +RUN pip install --no-cache-dir {{ pip_opts }} -r requirements.txt + +CMD ["python", "src/main.py"] diff --git a/mcp/src_main.py.j2 b/mcp/src_main.py.j2 new file mode 100644 index 0000000..facf733 --- /dev/null +++ b/mcp/src_main.py.j2 @@ -0,0 +1,13 @@ +from fastmcp import FastMCP + +mcp = FastMCP(name="{{ node_name }}") + +@mcp.tool() +def hello(name: str) -> str: + """ + This is a tool that says, "Hello!" + """ + return f"Hello ! {{ node_name }} ! {name} !" + +if __name__ == "__main__": + mcp.run(transport="streamable-http", host="0.0.0.0", port=80)