From ee3e3852eed4898050173551216a178f7672c4bb Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Wed, 30 Sep 2026 20:41:56 +0900 Subject: [PATCH] [verified] feat(deploy): run v0.1.2 as non-root --- .gitea/release.yaml | 9 +- Dockerfile | 16 ++- README.md | 11 +- deploy/k8s/README.md | 68 +++++++++++ deploy/k8s/base/deployment.yaml | 184 +++++++++++++++++++++++++++++ deploy/k8s/base/kustomization.yaml | 9 ++ deploy/k8s/base/namespace.yaml | 4 + deploy/k8s/base/networkpolicy.yaml | 14 +++ deploy/k8s/base/pvc.yaml | 11 ++ deploy/k8s/base/services.yaml | 13 ++ jarca_ledger_mcp/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 13 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 deploy/k8s/README.md create mode 100644 deploy/k8s/base/deployment.yaml create mode 100644 deploy/k8s/base/kustomization.yaml create mode 100644 deploy/k8s/base/namespace.yaml create mode 100644 deploy/k8s/base/networkpolicy.yaml create mode 100644 deploy/k8s/base/pvc.yaml create mode 100644 deploy/k8s/base/services.yaml diff --git a/.gitea/release.yaml b/.gitea/release.yaml index a280d73..ca7a817 100644 --- a/.gitea/release.yaml +++ b/.gitea/release.yaml @@ -50,14 +50,19 @@ artifacts: expected_architecture: arm64 - type: container-command environment: - JARCA_LEDGER_DB_PATH: /tmp/ledger.sqlite3 + JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3 argv: - python - -c - >- - import platform; from jarca_ledger_mcp import service; + import os, pathlib, platform; from jarca_ledger_mcp import service; + assert os.getuid() == 1000, os.getuid(); + assert os.getgid() == 1000, os.getgid(); + probe = pathlib.Path("/data/.write-probe"); + probe.write_text("ok"); probe.unlink(); result = service.health_check(); assert platform.machine() == "aarch64"; + assert pathlib.Path("/data/ledger.sqlite3").is_file(); assert result["success"], result - type: container-health name_template: "jarca-mcp-{run_id}" diff --git a/Dockerfile b/Dockerfile index 020e9b2..1efc05f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,8 @@ ENV PYTHONUNBUFFERED=1 \ JARCA_MCP_HOST=0.0.0.0 \ JARCA_MCP_PORT=8000 \ JARCA_MCP_STREAMABLE_HTTP_PATH=/mcp \ + HOME=/home/jarca \ + TMPDIR=/tmp \ PATH=/app/.venv/bin:$PATH COPY --from=uv /uv /uvx /usr/local/bin/ @@ -20,9 +22,15 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md ./ RUN uv sync --frozen --no-dev --extra dashboard --no-install-project -COPY jarca_ledger_mcp ./jarca_ledger_mcp -COPY dashboard ./dashboard -RUN uv sync --frozen --no-dev --extra dashboard +RUN groupadd --gid 1000 jarca \ + && useradd --uid 1000 --gid 1000 --create-home --home-dir /home/jarca --shell /usr/sbin/nologin jarca \ + && chown -R 1000:1000 /home/jarca \ + && install -d -o 1000 -g 1000 -m 0770 /data + +COPY --chown=1000:1000 jarca_ledger_mcp ./jarca_ledger_mcp +COPY --chown=1000:1000 dashboard ./dashboard +RUN uv sync --frozen --no-dev --extra dashboard \ + && chmod -R a+rX /app VOLUME ["/data"] EXPOSE 8000 8501 @@ -30,4 +38,6 @@ EXPOSE 8000 8501 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD python -c "import os, socket; s=socket.create_connection(('127.0.0.1', int(os.getenv('JARCA_MCP_PORT', '8000'))), timeout=3); s.close()" +USER 1000:1000 + CMD ["jarca-ledger-mcp"] diff --git a/README.md b/README.md index 33c1fc0..687a396 100644 --- a/README.md +++ b/README.md @@ -85,16 +85,19 @@ Deployment: jarca-ledger (replicas: 1) - 하나의 Pod 안에서 MCP와 Streamlit을 **별도 컨테이너**로 실행합니다. 한 컨테이너에서 두 프로세스를 supervisor나 background command로 함께 실행하지 않습니다. - 두 컨테이너는 동일한 PVC를 `/data`에 마운트하고 `JARCA_LEDGER_DB_PATH=/data/ledger.sqlite3`를 사용합니다. +- 이미지와 Pod는 UID/GID `1000:1000`으로 실행하며, PVC 쓰기 권한은 `fsGroup: 1000`으로 설정합니다. - frontend 컨테이너에만 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD`를 Kubernetes Secret 또는 동등한 secret store에서 주입합니다. 실제 값은 manifest나 repository에 기록하지 않습니다. -- SQLite 파일을 공유하므로 Deployment의 replica는 `1`로 유지합니다. -- MCP와 frontend는 목적별 Kubernetes Service로 분리합니다. MCP는 `8000`, frontend는 `8501`을 대상으로 합니다. -- 외부 공개가 필요하면 일반적으로 frontend Service만 Ingress 또는 NodePort에 연결하고, MCP Service는 필요한 범위에서만 노출합니다. +- SQLite 파일을 공유하므로 Deployment의 replica는 `1`, update 전략은 `Recreate`로 유지합니다. +- Base는 Dashboard용 `ClusterIP` Service(`8501`)만 생성합니다. 인증 계층이 없는 MCP port `8000`에는 Service를 만들지 않고 `NetworkPolicy`로 ingress를 차단합니다. MCP 원격 접근이 필요하면 environment overlay에서 인증된 gateway와 제한된 ingress 정책을 함께 정의합니다. +- 외부 공개가 필요하면 Dashboard Service만 environment overlay의 Ingress 또는 NodePort에 연결합니다. - ARM64 worker에서만 실행하도록 Pod의 `nodeSelector` 또는 node affinity를 설정합니다. - 배포 이미지는 가변 태그 대신 검증된 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` Gitea Container Registry digest로 고정합니다. +검증된 k3s Kustomize base는 [`deploy/k8s/base`](deploy/k8s/base)에 있으며, Secret 준비·digest pinning·배포 절차는 [`deploy/k8s/README.md`](deploy/k8s/README.md)를 따릅니다. Base에는 실제 Secret, Ingress, TLS와 cluster별 설정을 포함하지 않습니다. + Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변경하거나 네트워크 DB로 전환한 뒤에는 MCP와 frontend를 각각 독립 Deployment로 분리할 수 있습니다. -로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Streamlit은 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 두 Dashboard credential 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. Gitea Container Registry 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` digest로 지정해야 합니다. +로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Streamlit은 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 두 Dashboard credential 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. Bind mount 대상 `/volume1/docker/jarca-ledger/data`는 UID/GID `1000:1000`이 쓸 수 있어야 합니다. Gitea Container Registry 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` digest로 지정해야 합니다. ## 주요 도구 diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..c19a3ae --- /dev/null +++ b/deploy/k8s/README.md @@ -0,0 +1,68 @@ +# k3s 배포 + +`base/`는 `jarca-ledger` namespace에 다음 리소스를 배포하는 Kustomize base다. + +- 단일 `Deployment`와 `Recreate` 전략 +- 동일 ARM64 이미지의 MCP·Dashboard 컨테이너 +- SQLite 초기화를 담당하는 init container +- 공유 `ReadWriteOnce` PVC +- Dashboard용 내부 `ClusterIP` Service +- Dashboard port `8501` ingress만 허용하는 `NetworkPolicy` +- UID/GID `1000:1000`, `fsGroup: 1000`, read-only root filesystem + +## 사전 준비 + +Base는 실제 Secret 값을 포함하지 않는다. 먼저 namespace를 생성한다. + +```bash +kubectl apply -f deploy/k8s/base/namespace.yaml +``` + +그 다음 승인된 secret manager나 Git에 기록되지 않는 운영 절차로 다음 Secret을 `jarca-ledger` namespace에 생성한다. + +- `jarca-ledger-registry` + - type: `kubernetes.io/dockerconfigjson` + - `gitea.jsfamily.quest`에서 image를 pull할 수 있는 credential +- `jarca-ledger-dashboard-auth` + - key `admin-id` + - key `admin-password` + +Registry credential을 application container에 환경변수나 volume으로 전달하지 않는다. `imagePullSecrets`로만 사용한다. + +## Immutable image 지정 + +`base/deployment.yaml`의 release version tag는 템플릿 기본값일 뿐이며 운영 배포에 안전한 immutable 참조가 아니다. Release commit 자체에는 최종 image digest를 넣을 수 없다. Image의 revision label이 release commit을 가리키므로, 그 commit에 digest를 기록하면 commit과 image digest가 다시 바뀌는 자기참조가 발생한다. + +Release가 완료된 뒤 검증된 digest를 사용하는 환경/인프라 저장소의 overlay를 생성한다. 예를 들어 `environments/production/jarca-ledger/kustomization.yaml`은 다음과 같다. + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../../../jarca-ledger-mcp/deploy/k8s/base +images: + - name: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp + newName: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp + digest: sha256: +``` + +두 application container와 init container는 반드시 같은 digest를 사용해야 한다. + +## 배포 + +Namespace와 Secret 준비가 끝난 뒤, base가 아니라 위의 외부 digest-pinned overlay를 적용한다. + +```bash +kubectl apply -k environments/production/jarca-ledger +kubectl -n jarca-ledger rollout status deployment/jarca-ledger +kubectl -n jarca-ledger get pods,svc,pvc +``` + +Dashboard를 외부에 공개하는 Ingress나 NodePort는 environment overlay에서 추가한다. Base에는 MCP Service가 없고, `NetworkPolicy`는 workload ingress를 dashboard port `8501`로 제한하여 인증 계층이 없는 MCP port `8000` ingress를 차단한다. Cluster의 network plugin이 `NetworkPolicy`를 enforce하는지 운영 환경에서 확인한다. + +## SQLite와 PVC + +- PVC 전체를 `/data`에 mount한다. SQLite journal, WAL, SHM 파일이 DB 옆에 생성되므로 DB 파일 하나만 `subPath`로 mount하지 않는다. +- `replicas`는 `1`, update 전략은 `Recreate`로 유지한다. RollingUpdate는 일시적으로 두 Pod가 같은 SQLite DB를 열 수 있다. +- k3s `local-path` volume에서는 `fsGroup: 1000` 적용 여부를 기존 PVC에서도 확인한다. 권한이 맞지 않으면 rollout 전에 운영 절차로 소유권을 수정하고, 상시 privileged `chown` init container는 두지 않는다. +- 같은 PVC 안의 backup은 node 또는 disk 손실에 대한 재해 복구가 아니다. 별도 저장소로 backup을 내보낸다. diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml new file mode 100644 index 0000000..abab0a4 --- /dev/null +++ b/deploy/k8s/base/deployment.yaml @@ -0,0 +1,184 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jarca-ledger + labels: + app.kubernetes.io/name: jarca-ledger +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: jarca-ledger + template: + metadata: + labels: + app.kubernetes.io/name: jarca-ledger + spec: + automountServiceAccountToken: false + enableServiceLinks: false + terminationGracePeriodSeconds: 30 + imagePullSecrets: + - name: jarca-ledger-registry + nodeSelector: + kubernetes.io/os: linux + kubernetes.io/arch: arm64 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + initContainers: + - name: init-db + image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2 + imagePullPolicy: IfNotPresent + command: + - python + - -c + - >- + from jarca_ledger_mcp.service import health_check; + result = health_check(); + assert result["success"], result + env: + - name: JARCA_LEDGER_DB_PATH + value: /data/ledger.sqlite3 + - name: HOME + value: /tmp + - name: TMPDIR + value: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: data + mountPath: /data + - name: init-tmp + mountPath: /tmp + containers: + - name: mcp + image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2 + imagePullPolicy: IfNotPresent + env: + - name: JARCA_LEDGER_DB_PATH + value: /data/ledger.sqlite3 + - name: HOME + value: /tmp + - name: TMPDIR + value: /tmp + ports: + - name: mcp + containerPort: 8000 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + startupProbe: + tcpSocket: + port: mcp + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 24 + readinessProbe: + tcpSocket: + port: mcp + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + tcpSocket: + port: mcp + periodSeconds: 20 + timeoutSeconds: 2 + failureThreshold: 3 + volumeMounts: + - name: data + mountPath: /data + - name: mcp-tmp + mountPath: /tmp + - name: dashboard + image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2 + imagePullPolicy: IfNotPresent + command: + - python + - -m + - streamlit + - run + - dashboard/app.py + - --server.address + - 0.0.0.0 + - --server.port + - "8501" + - --server.headless + - "true" + - --browser.gatherUsageStats + - "false" + env: + - name: JARCA_LEDGER_DB_PATH + value: /data/ledger.sqlite3 + - name: HOME + value: /tmp + - name: TMPDIR + value: /tmp + - name: JARCA_DASHBOARD_ADMIN_ID + valueFrom: + secretKeyRef: + name: jarca-ledger-dashboard-auth + key: admin-id + - name: JARCA_DASHBOARD_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: jarca-ledger-dashboard-auth + key: admin-password + ports: + - name: dashboard + containerPort: 8501 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + startupProbe: + httpGet: + path: /_stcore/health + port: dashboard + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 24 + readinessProbe: + httpGet: + path: /_stcore/health + port: dashboard + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /_stcore/health + port: dashboard + periodSeconds: 20 + timeoutSeconds: 2 + failureThreshold: 3 + volumeMounts: + - name: data + mountPath: /data + - name: dashboard-tmp + mountPath: /tmp + volumes: + - name: data + persistentVolumeClaim: + claimName: jarca-ledger-data + - name: init-tmp + emptyDir: {} + - name: mcp-tmp + emptyDir: {} + - name: dashboard-tmp + emptyDir: {} diff --git a/deploy/k8s/base/kustomization.yaml b/deploy/k8s/base/kustomization.yaml new file mode 100644 index 0000000..6becec6 --- /dev/null +++ b/deploy/k8s/base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: jarca-ledger +resources: + - namespace.yaml + - pvc.yaml + - deployment.yaml + - services.yaml + - networkpolicy.yaml diff --git a/deploy/k8s/base/namespace.yaml b/deploy/k8s/base/namespace.yaml new file mode 100644 index 0000000..730f98c --- /dev/null +++ b/deploy/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: jarca-ledger diff --git a/deploy/k8s/base/networkpolicy.yaml b/deploy/k8s/base/networkpolicy.yaml new file mode 100644 index 0000000..934b265 --- /dev/null +++ b/deploy/k8s/base/networkpolicy.yaml @@ -0,0 +1,14 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: jarca-ledger-ingress +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: jarca-ledger + policyTypes: + - Ingress + ingress: + - ports: + - protocol: TCP + port: 8501 diff --git a/deploy/k8s/base/pvc.yaml b/deploy/k8s/base/pvc.yaml new file mode 100644 index 0000000..12d862c --- /dev/null +++ b/deploy/k8s/base/pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jarca-ledger-data +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml new file mode 100644 index 0000000..88f70f8 --- /dev/null +++ b/deploy/k8s/base/services.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: jarca-ledger-dashboard +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: jarca-ledger + ports: + - name: http + port: 8501 + targetPort: dashboard + appProtocol: http diff --git a/jarca_ledger_mcp/__init__.py b/jarca_ledger_mcp/__init__.py index 7d9c041..25143c1 100644 --- a/jarca_ledger_mcp/__init__.py +++ b/jarca_ledger_mcp/__init__.py @@ -1,3 +1,3 @@ """Jarca Ledger MCP server.""" -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/pyproject.toml b/pyproject.toml index 921be8b..748ce46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jarca-ledger-mcp" -version = "0.1.1" +version = "0.1.2" description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 25edd7e..11c5bf8 100644 --- a/uv.lock +++ b/uv.lock @@ -469,7 +469,7 @@ wheels = [ [[package]] name = "jarca-ledger-mcp" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "mcp" },