From fa1fffed65c194c5f9724c8e32e69897b79ced78 Mon Sep 17 00:00:00 2001 From: Crystal-Lily-hy Date: Tue, 28 Apr 2026 10:12:53 +0700 Subject: [PATCH 1/4] feak:(add repo) --- .github/workflows/task-api-ci.yaml | 137 +++++++++ .gitignore | 74 ++++- DEPLOYMENT-GUIDE-CNCF.md | 241 ++++++++++++++++ Makefile | 160 ++++++++++ Makefile.phase2-6 | 144 +++++++++ README.md | 228 +++++++++++++-- deploy/task-api/base/deployment.yaml | 187 ++++++++++++ deploy/task-api/base/ingress.yaml | 31 ++ deploy/task-api/base/kustomization.yaml | 32 ++ deploy/task-api/base/service.yaml | 26 ++ .../overlays/gcp-demo/kustomization.yaml | 83 ++++++ .../task-api/overlays/gcp-demo/namespace.yaml | 9 + .../overlays/gcp-demo/postgres-cluster.yaml | 69 +++++ .../overlays/local/kustomization.yaml | 60 ++++ deploy/task-api/overlays/local/namespace.yaml | 20 ++ docs/00-gcp-onboarding.md | 196 +++++++++++++ docs/01-local-dev.md | 212 ++++++++++++++ docs/architecture-phase2-6.md | 151 ++++++++++ docs/runbook.md | 203 +++++++++++++ infra/argocd/apps/observability.yaml | 170 +++++++++++ infra/argocd/apps/task-api-local.yaml | 83 ++++++ infra/kind/cluster.yaml | 88 ++++++ infra/terraform/envs/gcp-demo/main.tf | 209 ++++++++++++++ platform/finops/chaos-experiments.yaml | 60 ++++ platform/finops/opencost.yaml | 157 ++++++++++ .../dashboards/task-api-dashboard.yaml | 121 ++++++++ platform/observability/kustomization.yaml | 6 + platform/observability/prometheus-rules.yaml | 90 ++++++ platform/observability/values/loki.yaml | 86 ++++++ .../values/prometheus-stack.yaml | 172 +++++++++++ platform/observability/values/tempo-otel.yaml | 110 +++++++ platform/rollouts/task-api-rollout.yaml | 179 ++++++++++++ platform/security/kyverno/policies.yaml | 154 ++++++++++ .../networkpolicy/taskr-policies.yaml | 151 ++++++++++ scripts/00-prerequisites.sh | 203 +++++++++++++ scripts/01-kind-up.sh | 105 +++++++ scripts/02-bootstrap.sh | 225 +++++++++++++++ scripts/03-build-and-load.sh | 81 ++++++ scripts/04-observability.sh | 135 +++++++++ scripts/05-security.sh | 75 +++++ scripts/99-kind-down.sh | 37 +++ services/task-api/.dockerignore | 28 ++ services/task-api/Dockerfile | 105 +++++++ services/task-api/cmd/server/main.go | 125 ++++++++ services/task-api/go.mod | 38 +++ services/task-api/go.sum.README | 10 + .../task-api/internal/adapter/http/handler.go | 273 ++++++++++++++++++ .../internal/adapter/http/middleware.go | 116 ++++++++ .../task-api/internal/adapter/http/router.go | 89 ++++++ .../internal/adapter/memory/task_repo.go | 123 ++++++++ .../internal/adapter/postgres/task_repo.go | 161 +++++++++++ services/task-api/internal/domain/task.go | 163 +++++++++++ .../task-api/internal/domain/task_test.go | 184 ++++++++++++ .../task-api/internal/observability/logger.go | 54 ++++ .../internal/observability/metrics.go | 69 +++++ .../internal/observability/tracing.go | 109 +++++++ .../task-api/internal/port/task_repository.go | 44 +++ .../migrations/001_create_tasks.up.sql | 21 ++ 58 files changed, 6641 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/task-api-ci.yaml create mode 100644 DEPLOYMENT-GUIDE-CNCF.md create mode 100644 Makefile create mode 100644 Makefile.phase2-6 create mode 100644 deploy/task-api/base/deployment.yaml create mode 100644 deploy/task-api/base/ingress.yaml create mode 100644 deploy/task-api/base/kustomization.yaml create mode 100644 deploy/task-api/base/service.yaml create mode 100644 deploy/task-api/overlays/gcp-demo/kustomization.yaml create mode 100644 deploy/task-api/overlays/gcp-demo/namespace.yaml create mode 100644 deploy/task-api/overlays/gcp-demo/postgres-cluster.yaml create mode 100644 deploy/task-api/overlays/local/kustomization.yaml create mode 100644 deploy/task-api/overlays/local/namespace.yaml create mode 100644 docs/00-gcp-onboarding.md create mode 100644 docs/01-local-dev.md create mode 100644 docs/architecture-phase2-6.md create mode 100644 docs/runbook.md create mode 100644 infra/argocd/apps/observability.yaml create mode 100644 infra/argocd/apps/task-api-local.yaml create mode 100644 infra/kind/cluster.yaml create mode 100644 infra/terraform/envs/gcp-demo/main.tf create mode 100644 platform/finops/chaos-experiments.yaml create mode 100644 platform/finops/opencost.yaml create mode 100644 platform/observability/dashboards/task-api-dashboard.yaml create mode 100644 platform/observability/kustomization.yaml create mode 100644 platform/observability/prometheus-rules.yaml create mode 100644 platform/observability/values/loki.yaml create mode 100644 platform/observability/values/prometheus-stack.yaml create mode 100644 platform/observability/values/tempo-otel.yaml create mode 100644 platform/rollouts/task-api-rollout.yaml create mode 100644 platform/security/kyverno/policies.yaml create mode 100644 platform/security/networkpolicy/taskr-policies.yaml create mode 100644 scripts/00-prerequisites.sh create mode 100644 scripts/01-kind-up.sh create mode 100644 scripts/02-bootstrap.sh create mode 100644 scripts/03-build-and-load.sh create mode 100644 scripts/04-observability.sh create mode 100644 scripts/05-security.sh create mode 100644 scripts/99-kind-down.sh create mode 100644 services/task-api/.dockerignore create mode 100644 services/task-api/Dockerfile create mode 100644 services/task-api/cmd/server/main.go create mode 100644 services/task-api/go.mod create mode 100644 services/task-api/go.sum.README create mode 100644 services/task-api/internal/adapter/http/handler.go create mode 100644 services/task-api/internal/adapter/http/middleware.go create mode 100644 services/task-api/internal/adapter/http/router.go create mode 100644 services/task-api/internal/adapter/memory/task_repo.go create mode 100644 services/task-api/internal/adapter/postgres/task_repo.go create mode 100644 services/task-api/internal/domain/task.go create mode 100644 services/task-api/internal/domain/task_test.go create mode 100644 services/task-api/internal/observability/logger.go create mode 100644 services/task-api/internal/observability/metrics.go create mode 100644 services/task-api/internal/observability/tracing.go create mode 100644 services/task-api/internal/port/task_repository.go create mode 100644 services/task-api/migrations/001_create_tasks.up.sql diff --git a/.github/workflows/task-api-ci.yaml b/.github/workflows/task-api-ci.yaml new file mode 100644 index 0000000..30e6800 --- /dev/null +++ b/.github/workflows/task-api-ci.yaml @@ -0,0 +1,137 @@ +name: CI — task-api + +on: + push: + branches: [main] + paths: + - 'services/task-api/**' + pull_request: + branches: [main] + paths: + - 'services/task-api/**' + +env: + SERVICE: task-api + GO_VERSION: "1.22" + REGISTRY: asia-southeast1-docker.pkg.dev + IMAGE: asia-southeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/taskr/task-api + +jobs: + # ─── Job 1: Lint + Test ─── + test: + name: Lint & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/task-api + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: services/task-api/go.sum + + - name: go mod tidy check + run: | + go mod tidy + git diff --exit-code go.mod go.sum + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + working-directory: services/task-api + args: --timeout=5m + + - name: Unit tests với race detector + run: go test -race -coverprofile=coverage.out ./... + + - name: Coverage report + run: go tool cover -func=coverage.out | tail -1 + + # ─── Job 2: Security scan ─── + security: + name: Security Scan + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - name: govulncheck — known CVE trong dependencies + uses: golang/govulncheck-action@v1 + with: + go-version-input: ${{ env.GO_VERSION }} + go-package: ./services/task-api/... + + # ─── Job 3: Build & Push (chỉ khi merge vào main) ─── + build-push: + name: Build & Push Image + runs-on: ubuntu-latest + needs: [test, security] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + permissions: + contents: write # để commit bump tag + id-token: write # cho Workload Identity Federation + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Authenticate to GCP via Workload Identity + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.REGISTRY }} --quiet + + - name: Docker meta (tags + labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=sha,prefix=,format=short + type=ref,event=branch + + - name: Build và Push image + uses: docker/build-push-action@v6 + with: + context: services/task-api + file: services/task-api/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.sha }} + COMMIT=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Trivy image scan + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.IMAGE }}:${{ github.sha }} + severity: CRITICAL,HIGH + exit-code: 1 # Fail CI nếu có CVE CRITICAL/HIGH + + - name: Bump image tag trong config repo (GitOps) + # Cập nhật kustomization.yaml của overlay gcp-demo với SHA mới + # ArgoCD sẽ detect thay đổi và tự deploy + env: + SHA: ${{ github.sha }} + run: | + cd deploy/task-api/overlays/gcp-demo + # kustomize edit set image cập nhật tag trong kustomization.yaml + kustomize edit set image \ + task-api=${{ env.IMAGE }}:${SHA:0:7} + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add kustomization.yaml + git commit -m "chore: bump task-api to ${SHA:0:7} [skip ci]" + git push diff --git a/.gitignore b/.gitignore index 977da93..93dac38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,69 @@ -_site/ -.sass-cache/ -.jekyll-cache/ -.jekyll-metadata +# ─── Binaries ─── +*.exe +*.exe~ +*.dll +*.so +*.dylib +# Binary build output của Go +/services/*/task-api +/services/*/bin/ +/dist/ + +# ─── Test artifacts ─── +*.test +*.out +coverage.out +coverage.html +profile.prof + +# ─── Go specific ─── +# vendor/ nếu bạn không dùng module (chúng ta dùng module nên không commit vendor) vendor/ -.bundle/ -Gemfile.lock +# Go workspace files (mới từ 1.18+), không commit vì phụ thuộc path máy dev +go.work +go.work.sum + +# ─── Credentials / secrets ─── +# QUAN TRỌNG: không bao giờ commit credential. Ngay cả file "_example" chứa +# key thật phải bị chặn ngay tức thì. +*.pem +*.key +*-key.json +*.secret +.env +.env.local +.env.*.local +# GCP application default credentials +.config/gcloud/application_default_credentials.json + +# ─── Terraform ─── +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +!terraform.tfvars.example + +# ─── Kubernetes / Helm ─── +# Helm temp files +charts/ +.helm/ + +# ─── Editor / IDE ─── +.idea/ +.vscode/ +!.vscode/settings.json.example +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# ─── OS ─── +.Trash/ +__pycache__/ +node_modules/ + +# ─── Tools ─── +.cache/ +tmp/ diff --git a/DEPLOYMENT-GUIDE-CNCF.md b/DEPLOYMENT-GUIDE-CNCF.md new file mode 100644 index 0000000..2f5ae48 --- /dev/null +++ b/DEPLOYMENT-GUIDE-CNCF.md @@ -0,0 +1,241 @@ +# Hướng Dẫn Triển Khai Cloud Native Taskr +### Với 16 CNCF Tools · Phase 0 → 6 · GCP $300 + +> Toàn bộ phát triển chạy local bằng kind (miễn phí). GCP chỉ dùng khi demo Phase 4+. +> Mỗi phase xây trên phase trước — không bỏ qua bước nào. + +--- + +## Bản đồ CNCF tools theo phase + +| Phase | CNCF Tools (Graduated) | CNCF Tools (Incubating/Sandbox) | Non-CNCF | +|---|---|---|---| +| 0 | — | — | gcloud, Docker, Go | +| 1 | **Kubernetes · Helm · Argo CD** | **cert-manager** | ingress-nginx, kind | +| 2 | **Prometheus · OpenTelemetry** | — | Grafana · Loki · Tempo | +| 3 | **Linkerd** | **Kyverno** | Sealed Secrets · Trivy | +| 4 | — | **CloudNativePG** (Sandbox) | Terraform · Velero | +| 5 | **Argo Rollouts** | **KEDA** | GitHub Actions | +| 6 | — | **Chaos Mesh · OpenCost** (Sandbox) | — | + +**Tổng: 16 CNCF tools** (8 Graduated · 4 Incubating · 4 Sandbox) trên 24 tools toàn stack (67% CNCF). + +--- + +## Phase 0 — Chuẩn bị môi trường +*Tools: gcloud CLI · Docker Desktop · Go 1.22+* + +Truy cập `https://cloud.google.com/free`, đăng ký bằng Google account riêng. Bạn nhận $300 credit có hiệu lực 90 ngày — ghi ngay ngày hết hạn vào calendar. Tạo project `taskr-dev`, lưu lại **Project ID** (dạng `taskr-dev-428391`) vì mọi lệnh CLI đều dùng ID này. + +```bash +# macOS +brew install --cask google-cloud-sdk +brew install kubectl kind helm go + +# Verify toàn bộ +gcloud --version && kubectl version --client && kind --version && helm version && go version && docker info +``` + +```bash +# Đăng nhập và cấu hình +gcloud auth login +gcloud config set project taskr-dev-428391 +gcloud auth application-default login # Terraform dùng sau + +# Enable APIs (làm một lần) +gcloud services enable container.googleapis.com compute.googleapis.com \ + artifactregistry.googleapis.com iam.googleapis.com +``` + +**Bắt buộc:** Vào `console.cloud.google.com/billing` → tạo budget $50/tháng với alert 50%/90%/100%. Budget không tự tắt tài nguyên — bạn phải chủ động `destroy` sau mỗi session GCP. + +Chạy `bash scripts/00-prerequisites.sh` — tất cả `✓` là sẵn sàng. + +--- + +## Phase 1 — Local Kubernetes + Go Service + ArgoCD +*CNCF Graduated: **Kubernetes · Helm · ArgoCD** · CNCF Incubating: **cert-manager*** + +Phase này 100% miễn phí, chạy hoàn toàn trên máy local. + +```bash +make prereq # kiểm tra lần cuối +make cluster-up # kind tạo cluster 3 node (~5 phút lần đầu) +kubectl get nodes # phải thấy 3 node STATUS=Ready +``` + +`make bootstrap` cài ba thành phần theo thứ tự: **ingress-nginx** (L7 router, port 80/443 forward vào cluster), **cert-manager** *(CNCF Incubating)* (TLS tự động, local dùng self-signed, GCP đổi sang Let's Encrypt không cần sửa code), **ArgoCD** *(CNCF Graduated)* (GitOps engine, resource đã tối giản cho 8GB RAM). + +```bash +make bootstrap +echo '127.0.0.1 taskr.local argocd.local' | sudo tee -a /etc/hosts + +# Build và deploy Go service +cd services/task-api && go mod tidy && cd ../.. +make build # Docker distroless image ~20MB, load vào kind +make deploy-task-api # Kustomize overlay local (imagePullPolicy: Never) + +# Verify +kubectl -n taskr get pods # Running 1/1 +make smoke-test # nhận JSON hợp lệ = Phase 1 xong +open http://argocd.local # admin / $(make get-argocd-password) +``` + +**Lỗi thường gặp:** `ImagePullBackOff` → chạy lại `make build`. `502 Bad Gateway` → đợi 30 giây. Port 80 bị chiếm → `sudo lsof -i :80`. + +--- + +## Phase 2 — Observability +*CNCF Graduated: **Prometheus · OpenTelemetry SDK + Collector*** +*Non-CNCF: Grafana · Loki · Tempo (Grafana Labs, open source)* + +Làm Phase 2 **trước** Phase 3: nếu security vỡ thứ gì, bạn cần Grafana để debug. OpenTelemetry *(CNCF Graduated)* đóng vai trò abstraction layer — metrics/traces từ Go service qua OTel Collector đến Prometheus và Tempo mà không lock-in vendor. + +```bash +echo '127.0.0.1 grafana.local prometheus.local' | sudo tee -a /etc/hosts + +# Merge code Phase 2 (main.go + router.go + go.mod đã thêm OTel SDK) +cd services/task-api && go mod tidy && cd ../.. +make build deploy-task-api # rebuild với OTel instrumentation + +# Cài toàn bộ observability stack (~10 phút, pull ~2GB images) +make bootstrap-observability +``` + +Tổng RAM thêm ~900Mi — đã tối giản cho 8GB: Prometheus 256Mi, Grafana 128Mi, Loki 128Mi, Tempo 128Mi, OTel Collector 64Mi. + +```bash +open http://grafana.local # admin / taskr-grafana-admin +# Dashboard "task-api — RED Metrics" tự load từ ConfigMap +make smoke-test # generate traffic +# Grafana Explore → Loki → {namespace="taskr"} để xem log tập trung +``` + +Ba alert rule sẵn tại `http://prometheus.local/alerts`: service down 5 phút, error rate >5%, latency p99 >500ms. + +--- + +## Phase 3 — Security +*CNCF Graduated: **Linkerd** (mTLS) · CNCF Incubating: **Kyverno*** +*Non-CNCF: Sealed Secrets (Bitnami) · Trivy (Aqua Security)* + +Thứ tự bên trong Phase 3 quan trọng: **Kyverno trước → NetworkPolicy sau → Sealed Secrets cuối**. + +```bash +make bootstrap-security # cài Kyverno + apply 5 ClusterPolicy + Sealed Secrets controller +make policy-check # thử deploy pod root → phải bị Kyverno reject +``` + +5 ClusterPolicy **Kyverno** *(CNCF Incubating)* enforce: cấm root, bắt buộc resource requests, chỉ trusted registry, yêu cầu label chuẩn, cấm tag `latest`. + +```bash +# NetworkPolicy: zero-trust cho namespace taskr +kubectl apply -f platform/security/networkpolicy/taskr-policies.yaml +make smoke-test # ingress phải vẫn hoạt động sau default-deny-all + +# Sealed Secrets: encrypt secret trước khi commit Git +brew install kubeseal +kubectl create secret generic db-credentials \ + --from-literal=password=my-password --namespace taskr \ + --dry-run=client -o yaml | kubeseal --format yaml \ + > platform/security/sealed-secrets/db-credentials.yaml +git add platform/security/sealed-secrets/db-credentials.yaml # an toàn commit + +make scan-image # Trivy quét CVE trong Docker image +``` + +**Linkerd** *(CNCF Graduated)* inject sidecar vào namespace taskr, mọi giao tiếp east-west tự động được mã hóa mTLS — không cần thay đổi code application. + +--- + +## Phase 4 — PostgreSQL + GCP Deploy +*CNCF Sandbox: **CloudNativePG** · Non-CNCF: Terraform · Velero* + +Lần đầu tốn credit GCP. **Hexagonal architecture payoff**: domain layer Go không đổi một dòng, chỉ swap adapter từ memory sang postgres. + +```bash +# Cài CloudNativePG operator +kubectl apply -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.24/releases/cnpg-1.24.0.yaml +kubectl apply -f platform/security/sealed-secrets/db-credentials.yaml +kubectl apply -f deploy/task-api/overlays/gcp-demo/postgres-cluster.yaml +# CloudNativePG tự tạo: taskr-postgres-rw (primary) + taskr-postgres-ro (replica) + +# GCP infrastructure với Terraform +find infra/terraform -name "*.tf" | xargs sed -i "s/YOUR_PROJECT_ID/taskr-dev-428391/g" +gsutil mb gs://taskr-dev-428391-tfstate +cd infra/terraform/envs/gcp-demo && terraform init && terraform apply +``` + +```bash +make gcp-push GCP_PROJECT=taskr-dev-428391 # build + push lên Artifact Registry +# Lấy LB IP: kubectl -n ingress-nginx get svc ingress-nginx-controller +# Sửa kustomization.yaml: taskr.REPLACE_WITH_LB_IP.nip.io +make gcp-deploy +curl http://taskr.34.142.123.45.nip.io/api/v1/tasks + +# SAU KHI DEMO — BẮT BUỘC +make gcp-down GCP_PROJECT=taskr-dev-428391 # GKE idle ~$0.20/giờ +``` + +--- + +## Phase 5 — Canary Deployment +*CNCF Graduated: **Argo Rollouts** · CNCF Incubating: **KEDA*** + +Argo Rollouts *(CNCF Graduated)* thay RollingUpdate bằng canary thông minh: Prometheus làm gate tự động quyết định promote hay rollback. + +```bash +make bootstrap-rollouts +kubectl -n taskr delete deployment task-api +kubectl apply -f platform/rollouts/task-api-rollout.yaml +# Canary flow: 10% → 5 phút → Prometheus check → 25% → 50% → 100% +# Error rate >5% bất kỳ bước nào → auto-rollback +``` + +GitHub Actions CI pipeline: lint → test (`-race`) → govulncheck → build → Trivy scan → push → bump image tag → ArgoCD deploy → canary rollout. Thêm secrets `GCP_PROJECT_ID`, `GCP_WIF_PROVIDER`, `GCP_SERVICE_ACCOUNT` vào GitHub repo. + +```bash +# Demo auto-rollback +kubectl argo rollouts set image task-api task-api=task-api:buggy -n taskr +kubectl argo rollouts get rollout task-api -n taskr -w +# Quan sát: hệ thống tự rollback sau ~10 phút, không downtime +``` + +--- + +## Phase 6 — FinOps & Vận hành +*CNCF Incubating: **Chaos Mesh** · CNCF Sandbox: **OpenCost*** + +```bash +kubectl apply -f platform/finops/opencost.yaml # ResourceQuota + LimitRange + OpenCost +make cost-report # cost allocation per namespace trong 24h +``` + +**Chaos Mesh** *(CNCF Incubating)* verify HA không chỉ trên giấy: + +```bash +kubectl apply -f platform/finops/chaos-experiments.yaml +# 3 experiment: pod-kill · network-delay · cpu-stress +# Song song chạy: for i in {1..60}; do curl -s http://localhost/api/v1/tasks; sleep 5; done +# Kết quả: pod tự heal, HTTP vẫn 200 xuyên suốt +kubectl delete -f platform/finops/chaos-experiments.yaml # xóa sau khi xong +``` + +Đọc `docs/runbook.md` **trước khi** có incident: 5 scenario (CrashLoopBackOff, ArgoCD stuck, Grafana no data, cluster hết disk, canary paused) với triệu chứng → chẩn đoán → xử lý step-by-step. + +--- + +## Tóm tắt · Checklist hoàn thành + +``` +Phase 0 ✓ gcloud auth + budget alert +Phase 1 ✓ make smoke-test → JSON hợp lệ + ArgoCD UI load +Phase 2 ✓ Grafana dashboard có data + Loki có log +Phase 3 ✓ Kyverno reject pod root + smoke-test vẫn pass +Phase 4 ✓ curl taskr..nip.io/api/v1/tasks + make gcp-down +Phase 5 ✓ demo auto-rollback không downtime +Phase 6 ✓ chaos experiment pass + cost-report có số +``` + +> **Nguyên tắc chi phí:** Phase 1–3 = $0 (local). Phase 4–6 = ~$0.20/giờ GCP. +> Với $300 credit, bạn có hơn 200 giờ demo session nếu luôn nhớ `make gcp-down`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2893a30 --- /dev/null +++ b/Makefile @@ -0,0 +1,160 @@ +# ═════════════════════════════════════════════════════════════════════════════ +# Cloud Native Taskr — Makefile +# ═════════════════════════════════════════════════════════════════════════════ +# Đây là "remote control" của dự án. Mọi tác vụ thường xuyên đều có target +# tương ứng. Chạy `make help` để xem danh sách đầy đủ. +# +# Quy ước: +# - Target có mô tả bằng comment ## sẽ hiển thị trong make help +# - Target bắt đầu bằng _ là internal, không dùng trực tiếp +# ═════════════════════════════════════════════════════════════════════════════ + +# Dùng bash với flags strict — khớp với script để behavior nhất quán. +SHELL := /usr/bin/env bash +.SHELLFLAGS := -euo pipefail -c + +# Biến — có thể override từ command line: make build IMAGE_TAG=v1.0.0 +IMAGE_NAME ?= task-api +IMAGE_TAG ?= local-dev +CLUSTER_NAME ?= taskr +KUBECTL_CONTEXT := kind-$(CLUSTER_NAME) + +# Màu sắc cho help target. +BLUE := \033[0;34m +GREEN := \033[0;32m +BOLD := \033[1m +RESET := \033[0m + +# ─── Default target — chạy `make` không tham số sẽ hiện help ─── +.DEFAULT_GOAL := help + +# Target KHÔNG tạo file có cùng tên — mọi target của chúng ta đều phony. +.PHONY: help \ + prereq cluster-up cluster-down bootstrap \ + build load deploy-task-api undeploy-task-api \ + logs-task-api port-forward-argocd \ + test lint fmt \ + smoke-test \ + clean + +# ═══════════════════════════════════════════════════════════════════════════ +# Help (auto-generated từ comment ## của các target) +# ═══════════════════════════════════════════════════════════════════════════ + +help: ## Hiển thị danh sách target và mô tả + @printf "\n$(BOLD)Cloud Native Taskr — available targets$(RESET)\n\n" + @awk 'BEGIN {FS = ":.*?## "} \ + /^[a-zA-Z_-]+:.*?## / { \ + printf " $(BLUE)%-22s$(RESET) %s\n", $$1, $$2 \ + }' $(MAKEFILE_LIST) + @printf "\nQuickstart:\n" + @printf " $(BOLD)make prereq$(RESET) # kiểm tra tool cài đủ chưa\n" + @printf " $(BOLD)make cluster-up$(RESET) # tạo kind cluster\n" + @printf " $(BOLD)make bootstrap$(RESET) # cài ArgoCD + ingress-nginx + cert-manager\n" + @printf " $(BOLD)make build load$(RESET) # build image + load vào kind\n" + @printf " $(BOLD)make deploy-task-api$(RESET) # deploy task-api\n" + @printf " $(BOLD)make smoke-test$(RESET) # gửi request test\n\n" + +# ═══════════════════════════════════════════════════════════════════════════ +# Setup / Teardown +# ═══════════════════════════════════════════════════════════════════════════ + +prereq: ## Kiểm tra prerequisites (docker, kubectl, kind, helm, go) + @bash scripts/00-prerequisites.sh + +cluster-up: ## Tạo kind cluster (3 node) + @bash scripts/01-kind-up.sh + +cluster-down: ## Xóa kind cluster hoàn toàn + @bash scripts/99-kind-down.sh + +bootstrap: ## Cài ArgoCD + ingress-nginx + cert-manager lên cluster + @bash scripts/02-bootstrap.sh + +# ═══════════════════════════════════════════════════════════════════════════ +# Build & Deploy +# ═══════════════════════════════════════════════════════════════════════════ + +build: ## Build Docker image task-api + @bash scripts/03-build-and-load.sh + +load: build ## Alias của build (build đã tự load vào kind) + +deploy-task-api: ## Deploy task-api bằng kubectl + kustomize + @printf "$(BLUE)▸$(RESET) Apply manifest từ overlay local...\n" + @kubectl --context $(KUBECTL_CONTEXT) apply -k deploy/task-api/overlays/local + @printf "$(BLUE)▸$(RESET) Đợi deployment ready...\n" + @kubectl --context $(KUBECTL_CONTEXT) -n taskr rollout status deployment/task-api --timeout=2m + @printf "$(GREEN)✓$(RESET) task-api deployed. Thử:\n" + @printf " $(BOLD)curl -H 'Host: taskr.local' http://localhost/api/v1/tasks$(RESET)\n" + +undeploy-task-api: ## Xóa task-api khỏi cluster + @kubectl --context $(KUBECTL_CONTEXT) delete -k deploy/task-api/overlays/local --ignore-not-found=true + +deploy-via-argocd: ## Đăng ký task-api vào ArgoCD (yêu cầu Git repo public) + @kubectl --context $(KUBECTL_CONTEXT) apply -f infra/argocd/apps/task-api-local.yaml + @printf "$(GREEN)✓$(RESET) Đã đăng ký Application 'task-api-local' với ArgoCD.\n" + @printf " Xem tại http://argocd.local\n" + +# ═══════════════════════════════════════════════════════════════════════════ +# Observability / Debug +# ═══════════════════════════════════════════════════════════════════════════ + +logs-task-api: ## Tail log của tất cả pod task-api (cần stern hoặc kubectl >=1.28) + @kubectl --context $(KUBECTL_CONTEXT) -n taskr logs -l app.kubernetes.io/name=task-api --all-containers --tail=100 -f + +port-forward-argocd: ## Port-forward ArgoCD UI (nếu không thích dùng ingress) + @printf "$(BLUE)▸$(RESET) ArgoCD UI sẽ available tại https://localhost:8443\n" + @kubectl --context $(KUBECTL_CONTEXT) -n argocd port-forward svc/argocd-server 8443:443 + +get-argocd-password: ## In ra password admin ban đầu của ArgoCD + @kubectl --context $(KUBECTL_CONTEXT) -n argocd get secret argocd-initial-admin-secret \ + -o jsonpath="{.data.password}" | base64 -d + @echo + +# ═══════════════════════════════════════════════════════════════════════════ +# Go — test, lint, format +# ═══════════════════════════════════════════════════════════════════════════ + +test: ## Chạy unit test với race detector + @cd services/task-api && go test -race -cover ./... + +lint: ## Chạy golangci-lint (cần cài riêng) + @if command -v golangci-lint &>/dev/null; then \ + cd services/task-api && golangci-lint run ./...; \ + else \ + echo "golangci-lint chưa cài. Hướng dẫn: https://golangci-lint.run/usage/install/"; \ + exit 1; \ + fi + +fmt: ## Format code với gofmt + goimports + @cd services/task-api && gofmt -w -s . + @if command -v goimports &>/dev/null; then \ + cd services/task-api && goimports -w .; \ + fi + +# ═══════════════════════════════════════════════════════════════════════════ +# Smoke test — gửi request thực tế để verify end-to-end +# ═══════════════════════════════════════════════════════════════════════════ + +smoke-test: ## Gửi request test đến task-api qua ingress + @printf "$(BLUE)▸$(RESET) Smoke test task-api qua http://taskr.local\n" + @printf "$(BLUE)▸$(RESET) 1. List tasks (empty):\n" + @curl -sS -H 'Host: taskr.local' http://localhost/api/v1/tasks | jq . + @printf "\n$(BLUE)▸$(RESET) 2. Create task:\n" + @curl -sS -X POST -H 'Host: taskr.local' -H 'Content-Type: application/json' \ + -d '{"title":"Test từ Makefile","description":"smoke test"}' \ + http://localhost/api/v1/tasks | jq . + @printf "\n$(BLUE)▸$(RESET) 3. List tasks (1 item):\n" + @curl -sS -H 'Host: taskr.local' http://localhost/api/v1/tasks | jq . + +# ═══════════════════════════════════════════════════════════════════════════ +# Cleanup +# ═══════════════════════════════════════════════════════════════════════════ + +clean: ## Xóa mọi artifact local (image, cluster, ...) + @printf "Xóa cluster...\n" + @$(MAKE) cluster-down + @printf "Xóa Docker image...\n" + @docker image rm $(IMAGE_NAME):$(IMAGE_TAG) 2>/dev/null || true + @printf "$(GREEN)✓$(RESET) Đã clean.\n" diff --git a/Makefile.phase2-6 b/Makefile.phase2-6 new file mode 100644 index 0000000..befa209 --- /dev/null +++ b/Makefile.phase2-6 @@ -0,0 +1,144 @@ +# Makefile — thêm target Phase 2-6 vào Makefile gốc +# MERGE file này vào Makefile gốc (Phase 1) + +# ═══════════════════════════════════════════════════════════ +# Phase 2 — Observability +# ═══════════════════════════════════════════════════════════ + +bootstrap-observability: ## [P2] Cài Prometheus + Grafana + Loki + Tempo + OTel + @bash scripts/04-observability.sh + +add-observability-hosts: ## [P2] Thêm grafana.local, prometheus.local vào /etc/hosts + @echo '127.0.0.1 grafana.local prometheus.local alertmanager.local' | sudo tee -a /etc/hosts + +open-grafana: ## [P2] Mở Grafana trong browser + @open http://grafana.local || xdg-open http://grafana.local + +open-prometheus: ## [P2] Mở Prometheus trong browser + @open http://prometheus.local || xdg-open http://prometheus.local + +metrics-check: ## [P2] Kiểm tra task-api expose /metrics đúng + @curl -sS -H 'Host: taskr.local' http://localhost/metrics | grep -E '^(http_server|go_)' | head -20 + +# ═══════════════════════════════════════════════════════════ +# Phase 3 — Security +# ═══════════════════════════════════════════════════════════ + +bootstrap-security: ## [P3] Cài Kyverno + NetworkPolicy + Sealed Secrets + @bash scripts/05-security.sh + +scan-image: ## [P3] Quét CVE trong Docker image task-api + @if command -v trivy &>/dev/null; then \ + trivy image --severity CRITICAL,HIGH $(IMAGE_NAME):$(IMAGE_TAG); \ + else \ + echo "Trivy chưa cài. Cài: brew install trivy"; exit 1; \ + fi + +policy-check: ## [P3] Test Kyverno policy với pod root (phải bị reject) + @echo "Test: deploy pod root — phải bị Kyverno reject..." + @kubectl -n taskr run policy-test --image=nginx --overrides='{"spec":{"securityContext":{"runAsUser":0}}}' \ + --dry-run=server 2>&1 | grep -i "denied\|forbidden" \ + && echo "✓ Policy hoạt động đúng" \ + || echo "✗ Policy KHÔNG chặn pod root — kiểm tra Kyverno" + +seal-secret: ## [P3] Encrypt secret bằng Sealed Secrets. Usage: make seal-secret NAME=my-secret KEY=password VALUE=secret123 + @echo -n "$(VALUE)" | kubectl create secret generic $(NAME) \ + --dry-run=client --from-file=$(KEY)=/dev/stdin -o yaml | \ + kubeseal --format yaml > platform/security/sealed-secrets/$(NAME).yaml + @echo "✓ Sealed secret tạo tại platform/security/sealed-secrets/$(NAME).yaml" + @echo " Commit file này an toàn lên Git." + +# ═══════════════════════════════════════════════════════════ +# Phase 4 — GCP Deploy +# ═══════════════════════════════════════════════════════════ + +# Override: thay YOUR_PROJECT_ID bằng project thật +GCP_PROJECT ?= YOUR_PROJECT_ID +GCP_REGION ?= asia-southeast1 + +gcp-init: ## [P4] Khởi tạo Terraform state bucket trên GCS + @gsutil mb -l $(GCP_REGION) gs://$(GCP_PROJECT)-tfstate 2>/dev/null || true + @gsutil versioning set on gs://$(GCP_PROJECT)-tfstate + @cd infra/terraform/envs/gcp-demo && \ + sed -i "s/YOUR_PROJECT_ID/$(GCP_PROJECT)/g" main.tf + @cd infra/terraform/envs/gcp-demo && terraform init + +gcp-up: ## [P4] Tạo GKE cluster trên GCP (tốn ~$0.20/giờ) + @echo "⚠ Cluster sẽ tốn tiền. Nhớ chạy make gcp-down sau khi demo!" + @cd infra/terraform/envs/gcp-demo && \ + terraform apply -var="project_id=$(GCP_PROJECT)" -auto-approve + @$(eval GCP_CMD = $(shell cd infra/terraform/envs/gcp-demo && terraform output -raw get_credentials_command)) + @$(GCP_CMD) + @echo "✓ kubectl context đã switch sang GKE cluster" + +gcp-push: ## [P4] Build + push image lên Artifact Registry + @$(eval REGISTRY = $(GCP_REGION)-docker.pkg.dev/$(GCP_PROJECT)/taskr) + @docker build -t $(REGISTRY)/task-api:$(shell git rev-parse --short HEAD) \ + services/task-api/ + @docker push $(REGISTRY)/task-api:$(shell git rev-parse --short HEAD) + @cd deploy/task-api/overlays/gcp-demo && \ + kustomize edit set image task-api=$(REGISTRY)/task-api:$(shell git rev-parse --short HEAD) + +gcp-deploy: ## [P4] Deploy task-api lên GKE + @kubectl apply -k deploy/task-api/overlays/gcp-demo + @kubectl -n taskr rollout status deployment/task-api --timeout=5m + +gcp-down: ## [P4] XÓA toàn bộ GCP resources (tiết kiệm credit) + @echo "⚠ Sẽ xóa toàn bộ GCP resources. Không thể undo!" + @read -p "Xác nhận (yes/no): " confirm; [ "$$confirm" = "yes" ] || exit 1 + @cd infra/terraform/envs/gcp-demo && \ + terraform destroy -var="project_id=$(GCP_PROJECT)" -auto-approve + +gcp-cost-check: ## [P4] Xem ước tính chi phí đã dùng + @gcloud billing projects describe $(GCP_PROJECT) 2>/dev/null || \ + echo "Xem tại: https://console.cloud.google.com/billing" + +# ═══════════════════════════════════════════════════════════ +# Phase 5 — Canary / Argo Rollouts +# ═══════════════════════════════════════════════════════════ + +bootstrap-rollouts: ## [P5] Cài Argo Rollouts controller + @kubectl apply -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml + @kubectl -n argo-rollouts rollout status deployment/argo-rollouts + +rollout-status: ## [P5] Xem trạng thái canary rollout + @kubectl argo rollouts get rollout task-api -n taskr -w + +rollout-promote: ## [P5] Promote canary lên 100% thủ công + @kubectl argo rollouts promote task-api -n taskr + +rollout-abort: ## [P5] Abort canary, rollback về stable + @kubectl argo rollouts abort task-api -n taskr + @kubectl argo rollouts undo task-api -n taskr + +demo-bug-inject: ## [P5] Inject bug vào task-api để demo auto-rollback + @echo "Deploy version với bug (trả 500 ngẫu nhiên 30% request)..." + @# Build image với env BUG_INJECT=true + @docker build -t task-api:buggy --build-arg BUG_INJECT=true services/task-api/ + @kind load docker-image task-api:buggy --name taskr + @kubectl argo rollouts set image task-api task-api=task-api:buggy -n taskr + @echo "Quan sát rollout: make rollout-status" + @echo "Sau ~10 phút sẽ tự rollback khi error rate vượt 5%" + +# ═══════════════════════════════════════════════════════════ +# Phase 6 — FinOps +# ═══════════════════════════════════════════════════════════ + +bootstrap-finops: ## [P6] Cài OpenCost + ResourceQuota + @kubectl apply -f platform/finops/opencost.yaml + @kubectl apply -f platform/finops/ + +cost-report: ## [P6] Xem cost allocation theo namespace + @kubectl -n observability port-forward svc/opencost 9003:9003 & + @sleep 2 + @curl -sS http://localhost:9003/allocation?window=1d | jq '.data[0] | to_entries[] | {namespace: .key, cost: .value.totalCost}' + @kill %1 2>/dev/null || true + +chaos-pod-kill: ## [P6] Chaos: kill pod ngẫu nhiên và quan sát recovery + @echo "Apply chaos experiment (pod-kill)..." + @kubectl apply -f platform/finops/chaos-experiments.yaml + @echo "Quan sát: kubectl -n taskr get pods -w" + @echo "Xóa experiment sau: kubectl delete -f platform/finops/chaos-experiments.yaml" + +right-size-check: ## [P6] Xem đề xuất right-sizing từ VPA (nếu cài) + @kubectl -n taskr get vpa task-api -o jsonpath='{.status.recommendation}' | jq diff --git a/README.md b/README.md index 428b9bb..b003481 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,223 @@ -# Cloud Native Vietnam +### Writing a New Post + +1. Create a new file in `_articles/` (e.g. `my-post-title.md`) +2. Add front matter: + +```yaml +--- +layout: post +title: "Hướng Dẫn Triển Khai Cloud Native Taskr" +date: 2026-04-28 +author: Phan Đức Hải +tags: [Kubenetes , Helm] +--- +``` + +3. Write your content in Markdown +4. Submit a pull request +# Cloud Native Taskr + +> Một dự án học hỏi kiến trúc cloud-native hoàn chỉnh — từ Go microservice, +> Kubernetes, GitOps với ArgoCD, đến observability, scaling và security. +> Chạy 100% local trên máy bạn với kind, deploy lên GCP khi cần demo. + +--- -Community blog for [Cloud Native Vietnam](https://github.com/cloudnativevn) — sharing knowledge about Kubernetes, cloud native technologies, and the Vietnamese tech ecosystem. +## Tại sao dự án này tồn tại -## Local Development +Học cloud-native qua đọc tài liệu rời rạc rất khó, vì mỗi công cụ (Kubernetes, +ArgoCD, Prometheus, Helm, ...) được giới thiệu độc lập và bạn không thấy +chúng khớp vào một bức tranh tổng thể như thế nào. Dự án này xây từ con số +không *một hệ thống production-grade quy mô nhỏ*, qua đó bạn thấy được mọi +mảnh ghép phối hợp ra sao. -### Prerequisites +Chúng ta xây một **Task Manager** đơn giản — CRUD API cho task — nhưng dưới +nó là toàn bộ stack thực tế: hexagonal Go service, Kubernetes deployment với +security context chặt chẽ, ArgoCD GitOps, observability với Prometheus/Grafana/Loki, +canary deployment với Argo Rollouts. Mỗi phase thêm một lớp giá trị. -- Ruby 4.x -- Bundler +--- -### Setup +## Quickstart (5 phút) ```bash -bundle install -bundle exec jekyll serve -``` +# 1. Kiểm tra prerequisites (docker, kubectl, kind, helm, go) +make prereq -Visit `http://localhost:4000` to preview the site. +# 2. Tạo kind cluster +make cluster-up -### Writing a New Post +# 3. Cài ArgoCD + ingress-nginx + cert-manager +make bootstrap -1. Create a new file in `_articles/` (e.g. `my-post-title.md`) -2. Add front matter: +# 4. Build image và deploy task-api +make build deploy-task-api + +# 5. Thêm vào /etc/hosts (chỉ làm 1 lần) +echo '127.0.0.1 taskr.local argocd.local' | sudo tee -a /etc/hosts + +# 6. Smoke test +make smoke-test + +# 7. Mở ArgoCD UI +open http://argocd.local +make get-argocd-password # lấy password admin +``` + +Chạy `make help` để xem đầy đủ target. -```yaml --- -layout: post -title: "Your Post Title" -date: 2026-03-24 -author: Your Name -tags: [tag1, tag2] + +## Cấu trúc thư mục + +``` +cloud-native-taskr/ +├── docs/ # Tài liệu theo từng phase +│ └── 00-gcp-onboarding.md +│ +├── scripts/ # Automation scripts +│ ├── 00-prerequisites.sh # Check tools +│ ├── 01-kind-up.sh # Tạo cluster +│ ├── 02-bootstrap.sh # Cài platform +│ ├── 03-build-and-load.sh # Build image +│ └── 99-kind-down.sh # Xóa cluster +│ +├── infra/ +│ ├── kind/cluster.yaml # Kind cluster config +│ ├── argocd/apps/ # ArgoCD Application manifests +│ └── terraform/ # (Phase 4) Hạ tầng GCP +│ +├── services/ +│ └── task-api/ # Go service đầu tiên +│ ├── cmd/server/main.go # Entry point +│ ├── internal/ +│ │ ├── domain/ # Business logic thuần túy +│ │ ├── port/ # Interface (hexagonal port) +│ │ ├── adapter/ # HTTP + memory implementations +│ │ └── observability/ # Logger, metrics (Phase 2) +│ ├── Dockerfile # Multi-stage distroless +│ └── go.mod +│ +├── deploy/ +│ └── task-api/ +│ ├── base/ # K8s manifest chung (Kustomize base) +│ └── overlays/ +│ ├── local/ # Overlay cho kind +│ └── gcp-demo/ # Overlay cho GCP (Phase 4) +│ +├── platform/ # (Phase 2) Platform components +│ # Prometheus, Grafana, Loki, ... +│ +└── Makefile # Entry point mọi tác vụ +``` + --- + +## Kiến trúc tổng quan + ``` + ┌─────────────────────────────┐ + User ──HTTP──▶ │ ingress-nginx (L7) │ + │ host: taskr.local │ + └──────────────┬───────────────┘ + │ ClusterIP + ┌──────────────▼───────────────┐ + │ Service task-api │ + │ 3x replica (base), 1x local│ + └──────────────┬───────────────┘ + │ + ┌──────────────▼───────────────┐ + │ Pod: task-api container │ + │ ┌─────────────────────────┐ │ + │ │ HTTP adapter (chi) │ │ + │ │ ↓ ↑ │ │ + │ │ Port (interface) │ │ + │ │ ↓ ↑ │ │ + │ │ Domain (pure logic) │ │ + │ │ ↓ ↑ │ │ + │ │ Memory adapter │ │ + │ └─────────────────────────┘ │ + │ distroless image, non-root │ + └───────────────────────────────┘ -3. Write your content in Markdown -4. Submit a pull request + GitOps loop: + ┌─────────┐ ┌────────┐ ┌────────────┐ + │ Git repo├────────▶│ ArgoCD ├───────▶│ cluster │ + │ (this) │ watch │ sync │ apply │ resources │ + └─────────┘ └────────┘ └────────────┘ +``` + +Phần sâu hơn về triết lý hexagonal architecture, lý do chọn từng công cụ, +và các quyết định trade-off, xem `docs/architecture.md` (Phase 2 sẽ có). + +--- + +## Lộ trình theo phase + +| Phase | Mục tiêu | Trạng thái | +|-------|--------------------------------------------------|---------------| +| 0 | Onboarding GCP + tools | ✓ Hoàn thành | +| 1 | Go service + kind + ArgoCD (cái bạn đang đọc) | ✓ Hoàn thành | +| 2 | Observability: Prometheus, Grafana, Loki, Tempo | 🔜 Sắp tới | +| 3 | Security: NetworkPolicy, Kyverno, Linkerd mTLS | 🔜 | +| 4 | HA & multi-env: Postgres, GCP deploy | 🔜 | +| 5 | Canary với Argo Rollouts | 🔜 | +| 6 | FinOps: OpenCost, right-sizing, spot instances | 🔜 | + +--- + +## Triết lý thiết kế + +Ba nguyên tắc dẫn đường mọi quyết định trong dự án: + +**Đơn giản trước, phức tạp sau.** Phase 1 không có database, không có message +queue, không có service mesh. Mỗi phase chỉ thêm một khái niệm mới, và khái +niệm đó được dạy kỹ trước khi chuyển sang phase sau. -## Deployment +**Mỗi lớp tách biệt rõ ràng.** Domain không biết HTTP tồn tại. HTTP không +biết database tồn tại. Kubernetes không biết Go. Sự tách biệt này làm code +dễ test, dễ thay đổi, và dễ hiểu cho người mới. -The site is automatically deployed to GitHub Pages via GitHub Actions when changes are pushed to the `main` branch. +**Git là nguồn sự thật duy nhất.** Sau bootstrap, bạn không bao giờ `kubectl +apply` thủ công nữa. Mọi thay đổi đi qua commit + push → ArgoCD tự sync. +Điều này buộc bạn có commit history sạch và audit trail đầy đủ. + +--- + +## FAQ + +**Tôi có bắt buộc phải dùng macOS không?** + +Không. Dự án chạy trên Linux, WSL2 (Windows), macOS. Chỉ cần Docker và các +CLI tool trong `make prereq`. + +**Tại sao dùng kind mà không phải minikube?** + +Kind chạy Kubernetes "thật" trong Docker container, giống production nhất. +Minikube có nhiều mode (docker, virtualbox, hyperkit, ...) dễ gây confusion. +k3d cũng là lựa chọn tốt; chúng ta chọn kind vì nó là công cụ chính thức +của SIG-Testing của Kubernetes. + +**Tại sao không dùng `go mod vendor`?** + +Go module từ 1.11+ cache dependency trong `$GOPATH/pkg/mod`, reproducible +qua `go.sum`. Vendor chỉ cần khi bạn không có internet lúc build hoặc muốn +freeze bundled dep. Không cần cho dự án này. + +**Sao không dùng framework như Gin hay Echo?** + +Chi là HTTP router thuần túy (1000 dòng code), không phải framework. Bạn +nhìn thấy mọi thứ đang xảy ra, không có magic. Gin/Echo ẩn quá nhiều logic +khiến khó debug khi học. Nếu bạn thấy Gin tiện hơn, có thể swap — HTTP +adapter chỉ là một file, dễ thay. + +--- ## License -[MIT](LICENSE) +MIT (sẽ thêm file LICENSE sau). + +## Contributing + +Dự án cá nhân phục vụ học tập. Nếu bạn thấy bug hoặc có câu hỏi, mở issue +trên GitHub. diff --git a/deploy/task-api/base/deployment.yaml b/deploy/task-api/base/deployment.yaml new file mode 100644 index 0000000..dbf7b4c --- /dev/null +++ b/deploy/task-api/base/deployment.yaml @@ -0,0 +1,187 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Deployment cho task-api +# ───────────────────────────────────────────────────────────────────────────── +# Base manifest — không chứa env-specific config. Các overlay (local, gcp-demo) +# sẽ patch các giá trị như replica count, resources, image tag. +# +# Điểm nhấn: +# - securityContext enforced non-root, readOnlyRootFilesystem +# - probes: liveness/readiness với thời gian hợp lý +# - resources.requests luôn có (bắt buộc cho scheduling); limits theo context +# - lifecycle.preStop: sleep 5s để service ra khỏi endpoint trước khi nhận SIGTERM +# ───────────────────────────────────────────────────────────────────────────── +apiVersion: apps/v1 +kind: Deployment +metadata: + name: task-api + labels: + app.kubernetes.io/name: task-api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: taskr +spec: + # replicas sẽ được override ở overlay. Base đặt 1 để an toàn. + replicas: 1 + + # Rolling update strategy — zero downtime khi deploy version mới. + # maxSurge=1: thêm tối đa 1 pod mới trước khi xóa pod cũ. + # maxUnavailable=0: không bao giờ giảm capacity dưới replicas trong khi update. + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + + selector: + matchLabels: + app.kubernetes.io/name: task-api + + template: + metadata: + labels: + app.kubernetes.io/name: task-api + app.kubernetes.io/component: backend + annotations: + # Prometheus scrape annotations — khi prometheus-stack deployed ở Phase 2, + # nó sẽ tự động scrape /metrics endpoint của mọi pod có annotation này. + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + + spec: + # ─── Security Context (pod-level) ─── + # Áp dụng cho toàn bộ pod, container có thể override. + # Đây là baseline PodSecurity "restricted" của Kubernetes. + securityContext: + runAsNonRoot: true # Không cho phép chạy as root. + runAsUser: 65532 # UID của user "nonroot" trong distroless. + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault # Enable default seccomp profile, block syscall nguy hiểm. + + # terminationGracePeriodSeconds: thời gian K8s chờ pod tự tắt sau SIGTERM. + # Phải LỚN HƠN shutdown timeout trong main.go (25s) để graceful shutdown + # kịp hoàn thành. 30s là mặc định và đủ cho service này. + terminationGracePeriodSeconds: 30 + + containers: + - name: task-api + # Image sẽ được set ở overlay. Dùng placeholder để Kustomize replace. + image: task-api:dev + imagePullPolicy: IfNotPresent + + ports: + - name: http + containerPort: 8080 + protocol: TCP + + # ─── Security Context (container-level) ─── + securityContext: + allowPrivilegeEscalation: false # Không cho setuid. + readOnlyRootFilesystem: true # Filesystem read-only. Service chỉ log ra stdout. + capabilities: + drop: + - ALL # Bỏ tất cả Linux capabilities. + + # ─── Environment variables ─── + env: + - name: APP_ENV + value: "production" + - name: HTTP_PORT + value: "8080" + - name: SERVICE_NAME + value: "task-api" + - name: LOG_LEVEL + value: "info" + # POD_NAME/NAMESPACE cho debugging — log line sẽ có các field này + # để tìm nhanh pod nào đang có vấn đề. + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + + # ─── Probes ─── + # Liveness: pod còn sống không. Fail -> K8s restart pod. + # Đặt initialDelay=10 vì Go service start nhanh; failureThreshold=3 + # để tránh restart nhầm khi blip nhỏ. + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + + # Readiness: pod có sẵn sàng nhận traffic không. Fail -> K8s remove + # khỏi Service endpoints (không restart). Dùng cho rolling update + + # dependency check. + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 2 + + # Startup probe không cần cho service này vì khởi động < 1s. + # Thêm khi service cần thời gian warm-up (load model, cache prewarm, ...). + + # ─── Resources ─── + # Requests: bắt buộc. K8s scheduler dùng để chọn node. + # Limits: đặt bảo thủ. CPU limit gây throttling cho Go runtime nên + # đặt rộng hoặc bỏ; memory limit đặt chặt để tránh OOM node khác. + resources: + requests: + cpu: 50m # 0.05 vCPU — service rất nhẹ + memory: 64Mi + limits: + cpu: 500m # 0.5 vCPU limit rộng để không throttle + memory: 128Mi # memory limit chặt + + # ─── Volume cho tmp ─── + # readOnlyRootFilesystem=true nên /tmp phải là emptyDir + # (nhiều Go library dùng /tmp cho scratch space). + volumeMounts: + - name: tmp + mountPath: /tmp + + # ─── Lifecycle hook cho graceful shutdown ─── + # preStop chạy TRƯỚC khi K8s gửi SIGTERM đến container. Mục đích: + # cho endpoint có thời gian được remove khỏi Service trước khi process + # bắt đầu shutdown — tránh traffic rơi vào pod đang terminate. + # + # Distroless không có shell hay sleep binary, nên chúng ta dùng HTTP + # endpoint. Service có thể handle /healthz rất nhanh, kubelet sẽ + # chờ đến khi request trả về hoặc timeout. Trick: dùng preStop với + # httpGet để "waste" vài giây. Không đẹp lắm, phase sau sẽ refactor + # bằng cách thêm sleep helper vào Go binary. + # + # Alternative gọn nhất: dùng Kubernetes 1.29+ với ProbeTerminationGracePeriod + # và bỏ hẳn preStop. Với version cũ, dùng sleep container sidecar. + # + # Ở đây tạm để trống và dựa vào terminationGracePeriodSeconds=30 + + # logic shutdown của Go server (25s drain). Đủ tốt cho Phase 1. + + volumes: + - name: tmp + emptyDir: {} + + # ─── Pod Anti-Affinity (soft) ─── + # Khuyến khích schedule các replica lên node khác nhau. + # "preferredDuringScheduling" = soft constraint, không fail nếu không đủ node. + # Ở local 2-node cluster, cả 2 replica sẽ lên 2 node khác nhau. + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/name: task-api + topologyKey: kubernetes.io/hostname diff --git a/deploy/task-api/base/ingress.yaml b/deploy/task-api/base/ingress.yaml new file mode 100644 index 0000000..e059410 --- /dev/null +++ b/deploy/task-api/base/ingress.yaml @@ -0,0 +1,31 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Ingress cho task-api +# ───────────────────────────────────────────────────────────────────────────── +# Ingress là "cổng vào" từ ngoài cluster tới Service. Ở local (kind), host là +# taskr.local — cần thêm vào /etc/hosts. Ở GCP demo, sẽ thay thành domain thật. +# +# ingress-nginx controller đọc resource này và cấu hình Nginx để route đúng. +# ───────────────────────────────────────────────────────────────────────────── +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: task-api + labels: + app.kubernetes.io/name: task-api + annotations: + # Tắt redirect HTTP->HTTPS ở local vì chưa có cert Let's Encrypt thật. + # Ở gcp-demo overlay sẽ bật lại. + nginx.ingress.kubernetes.io/ssl-redirect: "false" +spec: + ingressClassName: nginx + rules: + - host: taskr.local + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: task-api + port: + name: http diff --git a/deploy/task-api/base/kustomization.yaml b/deploy/task-api/base/kustomization.yaml new file mode 100644 index 0000000..7fe1035 --- /dev/null +++ b/deploy/task-api/base/kustomization.yaml @@ -0,0 +1,32 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Kustomization BASE cho task-api +# ───────────────────────────────────────────────────────────────────────────── +# "Base" là tập hợp các manifest chung cho MỌI môi trường. Overlay sẽ patch +# lên base để tùy chỉnh cho local, staging, production, gcp-demo... +# +# Quy tắc vàng: base KHÔNG bao giờ chứa thông tin môi trường cụ thể (image +# tag với SHA, hostname cụ thể, replica count tối ưu cho môi trường đó). +# Nếu bạn thấy mình copy-paste base nhiều lần, đó là tín hiệu cần tách +# thêm overlay. +# ───────────────────────────────────────────────────────────────────────────── +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Resources liệt kê toàn bộ YAML file thuộc base. +# Thứ tự không quan trọng với Kubernetes (eventually consistent), nhưng +# để dễ đọc, nhóm theo loại: workload → network → policy. +resources: + - deployment.yaml + - service.yaml + - ingress.yaml + +# commonLabels được thêm vào MỌI resource và MỌI selector. +# Đây là cách chuẩn để áp nhãn nhất quán theo khuyến nghị của Kubernetes: +# https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ +commonLabels: + app.kubernetes.io/name: task-api + app.kubernetes.io/part-of: taskr + +# commonAnnotations ít dùng hơn, nhưng hữu ích cho audit trail. +commonAnnotations: + app.kubernetes.io/managed-by: kustomize diff --git a/deploy/task-api/base/service.yaml b/deploy/task-api/base/service.yaml new file mode 100644 index 0000000..c42ac3d --- /dev/null +++ b/deploy/task-api/base/service.yaml @@ -0,0 +1,26 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Service ClusterIP cho task-api +# ───────────────────────────────────────────────────────────────────────────── +# Service tạo virtual IP ổn định bên trong cluster, route đến các pod +# match selector. Pod có thể đến/đi nhưng Service IP không đổi, giúp client +# không phải biết IP pod cụ thể. +# +# Type: ClusterIP (mặc định) — chỉ accessible bên trong cluster. Truy cập +# từ ngoài sẽ đi qua Ingress (file ingress.yaml). +# ───────────────────────────────────────────────────────────────────────────── +apiVersion: v1 +kind: Service +metadata: + name: task-api + labels: + app.kubernetes.io/name: task-api + app.kubernetes.io/component: backend +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: task-api + ports: + - name: http + port: 80 # Port của Service (cluster-internal) + targetPort: http # Port của container (tên, không phải số — robust hơn) + protocol: TCP diff --git a/deploy/task-api/overlays/gcp-demo/kustomization.yaml b/deploy/task-api/overlays/gcp-demo/kustomization.yaml new file mode 100644 index 0000000..416af1c --- /dev/null +++ b/deploy/task-api/overlays/gcp-demo/kustomization.yaml @@ -0,0 +1,83 @@ +# deploy/task-api/overlays/gcp-demo/kustomization.yaml +# Overlay cho GCP demo session. Khác local ở: +# - Image từ Artifact Registry (không phải local kind) +# - 2 replicas (HA demo) +# - Ingress host dùng nip.io (không cần domain thật) +# - APP_STORAGE=postgres + DSN env +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: taskr + +resources: + - ../../base + - namespace.yaml + - postgres-cluster.yaml + +# Sẽ được thay thế bởi CI script với SHA thật +# make gcp-push sẽ chạy: +# kustomize edit set image task-api=asia-southeast1-docker.pkg.dev/PROJECT/taskr/task-api:SHA +images: + - name: task-api + newName: asia-southeast1-docker.pkg.dev/YOUR_PROJECT_ID/taskr/task-api + newTag: latest # CI sẽ replace thành SHA + +patches: + - target: + kind: Deployment + name: task-api + patch: |- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: task-api + spec: + replicas: 2 + template: + spec: + containers: + - name: task-api + imagePullPolicy: Always + env: + - name: APP_ENV + value: "production" + - name: APP_STORAGE + value: "postgres" + - name: LOG_LEVEL + value: "info" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "otel-collector.observability.svc.cluster.local:4317" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: taskr-postgres-credentials + key: dsn + + # Ingress: dùng nip.io — trỏ vào Load Balancer IP tự động + # Format: .nip.io → resolve về + # Sau khi apply, lấy LB IP: kubectl -n ingress-nginx get svc ingress-nginx-controller + # Rồi cập nhật host bên dưới thành: taskr..nip.io + - target: + kind: Ingress + name: task-api + patch: |- + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: task-api + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "false" + # Uncomment khi dùng cert-manager với Let's Encrypt trên GCP: + # cert-manager.io/cluster-issuer: "letsencrypt-prod" + spec: + rules: + - host: taskr.REPLACE_WITH_LB_IP.nip.io + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: task-api + port: + name: http diff --git a/deploy/task-api/overlays/gcp-demo/namespace.yaml b/deploy/task-api/overlays/gcp-demo/namespace.yaml new file mode 100644 index 0000000..5f40a55 --- /dev/null +++ b/deploy/task-api/overlays/gcp-demo/namespace.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: taskr + labels: + kubernetes.io/metadata.name: taskr + app.kubernetes.io/part-of: taskr + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/audit: restricted diff --git a/deploy/task-api/overlays/gcp-demo/postgres-cluster.yaml b/deploy/task-api/overlays/gcp-demo/postgres-cluster.yaml new file mode 100644 index 0000000..a1ce251 --- /dev/null +++ b/deploy/task-api/overlays/gcp-demo/postgres-cluster.yaml @@ -0,0 +1,69 @@ +--- +# CloudNativePG PostgreSQL Cluster cho Phase 4 +# Operator: https://cloudnative-pg.io/ +# Cài operator trước: kubectl apply -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.24/releases/cnpg-1.24.0.yaml +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: taskr-postgres + namespace: taskr +spec: + instances: 2 # 1 primary + 1 replica (sync) + + # PostgreSQL 16 LTS + imageName: ghcr.io/cloudnative-pg/postgresql:16.4 + + # Tự động failover trong vòng 30-60 giây khi primary down + failoverDelay: 0 + + postgresql: + parameters: + # Tối giản cho local/demo + max_connections: "50" + shared_buffers: "32MB" + effective_cache_size: "128MB" + work_mem: "4MB" + + bootstrap: + initdb: + database: taskr + owner: taskr_user + # Secret chứa password — tạo bằng Sealed Secrets (Phase 3) + # kubectl create secret generic taskr-postgres-credentials \ + # --from-literal=username=taskr_user \ + # --from-literal=password=CHANGE_ME \ + # -n taskr + secret: + name: taskr-postgres-credentials + + storage: + size: 1Gi + storageClass: standard # kind dùng standard, GKE dùng standard-rwo + + # Backup sang local storage (Phase 4 GCP: đổi sang GCS) + # backup: + # barmanObjectStore: + # destinationPath: gs://YOUR_BUCKET/taskr-postgres + # googleCredentials: + # applicationCredentials: + # name: gcs-credentials + # key: credentials.json + + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + # Monitoring tích hợp với Prometheus + monitoring: + enablePodMonitor: true + +--- +# Service để task-api kết nối +# CloudNativePG tự tạo Service này, định nghĩa ở đây chỉ để document +# Tên: taskr-postgres-rw (read-write, primary) +# taskr-postgres-ro (read-only, replica) +# taskr-postgres-r (round-robin all) diff --git a/deploy/task-api/overlays/local/kustomization.yaml b/deploy/task-api/overlays/local/kustomization.yaml new file mode 100644 index 0000000..7a35d34 --- /dev/null +++ b/deploy/task-api/overlays/local/kustomization.yaml @@ -0,0 +1,60 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Kustomization OVERLAY: local (kind cluster) +# ───────────────────────────────────────────────────────────────────────────── +# Overlay "local" dùng khi deploy task-api lên kind cluster trên máy phát +# triển. So với base, overlay này: +# - Đặt namespace rõ ràng +# - Giữ replica count = 1 (máy dev thường yếu, không cần HA) +# - Dùng image tag "local-dev" với imagePullPolicy=Never +# (kind load docker-image sẽ nạp image từ docker daemon của máy vào cluster) +# - Đặt LOG_LEVEL=debug để dễ quan sát khi dev +# ───────────────────────────────────────────────────────────────────────────── +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Namespace sẽ được apply cho mọi resource. Nếu namespace chưa tồn tại, +# Kustomize KHÔNG tự tạo — phải khai báo trong resources (xem dưới). +namespace: taskr + +resources: + # Trỏ tới base qua relative path. Ba ../.. vì overlay nằm sâu 2 level. + - ../../base + # Namespace resource — tự tạo namespace taskr nếu chưa có. + - namespace.yaml + +# ─── Image transformation ─── +# images field cho phép đổi image tag mà không phải sửa base/deployment.yaml. +# Đây là một trong những tính năng mạnh nhất của Kustomize. +# +# name: khớp với "image:" trong base (task-api:dev). +# newTag: tag mới sẽ được dùng. +# newName: nếu muốn đổi cả repository (ví dụ prefix asia.gcr.io/...). +images: + - name: task-api + newName: task-api + newTag: local-dev + +# ─── Patches ─── +# Dùng strategic merge patch thay vì JSON Patch vì robust hơn với thay đổi +# thứ tự field trong base. Chỉ cần khớp theo name, các field không nêu +# sẽ giữ nguyên từ base. +patches: + - target: + kind: Deployment + name: task-api + patch: |- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: task-api + spec: + template: + spec: + containers: + - name: task-api + imagePullPolicy: Never + env: + - name: APP_ENV + value: "development" + - name: LOG_LEVEL + value: "debug" diff --git a/deploy/task-api/overlays/local/namespace.yaml b/deploy/task-api/overlays/local/namespace.yaml new file mode 100644 index 0000000..31c4cca --- /dev/null +++ b/deploy/task-api/overlays/local/namespace.yaml @@ -0,0 +1,20 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Namespace resource cho môi trường local +# ───────────────────────────────────────────────────────────────────────────── +# Namespace là đơn vị isolation logic trong Kubernetes. Mọi resource của +# task-api sẽ sống trong namespace "taskr". Labels sẵn sàng cho Phase 3 +# khi ta thêm NetworkPolicy selector theo label namespace. +# ───────────────────────────────────────────────────────────────────────────── +apiVersion: v1 +kind: Namespace +metadata: + name: taskr + labels: + app.kubernetes.io/part-of: taskr + # kubernetes.io/metadata.name tự động set bởi K8s, nhưng khai báo tường + # minh giúp NetworkPolicy selector không phụ thuộc version K8s. + kubernetes.io/metadata.name: taskr + # Pod Security Standard — baseline cho Phase 1, sẽ nâng lên restricted ở Phase 3. + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted diff --git a/docs/00-gcp-onboarding.md b/docs/00-gcp-onboarding.md new file mode 100644 index 0000000..2167117 --- /dev/null +++ b/docs/00-gcp-onboarding.md @@ -0,0 +1,196 @@ +# Phase 0 — GCP Onboarding + +Tài liệu này hướng dẫn bạn từng bước thiết lập tài khoản GCP và cài đặt các công cụ +cần thiết. Bạn chỉ cần làm phần này *một lần duy nhất*. Sau khi hoàn thành, mọi +script tự động trong thư mục `scripts/` sẽ chạy được. + +--- + +## Bước 1 — Tạo tài khoản GCP và kích hoạt $300 credit + +Mở trình duyệt và truy cập `https://cloud.google.com/free`. Nhấn nút **Get started +for free** ở góc trên bên phải. Bạn sẽ được yêu cầu đăng nhập bằng Google account +(nên dùng email riêng cho dự án, không dùng email công ty nếu bạn muốn tách bạch). + +Trong bước xác thực, Google sẽ yêu cầu: + +- Một thẻ tín dụng hoặc thẻ ghi nợ còn hiệu lực. Đây chỉ để xác minh danh tính, + không bị tính phí trừ khi bạn chủ động nâng cấp sang paid account. +- Một số điện thoại để nhận mã OTP. +- Thông tin địa chỉ. Chọn **Vietnam** và điền địa chỉ thật. + +Sau khi hoàn tất, bạn sẽ được chuyển vào GCP Console và nhận $300 credit có hiệu +lực trong 90 ngày. Ngay lập tức, hãy ghi lại *ngày hết hạn credit* ở một nơi dễ +nhìn thấy — ví dụ pin vào Notion hoặc dán sticky note trên màn hình. Đây là +deadline quan trọng cho toàn bộ dự án. + +## Bước 2 — Tạo project đầu tiên + +GCP tổ chức tài nguyên theo **project**. Mỗi project là một sandbox riêng biệt có +billing, IAM, và resource quota riêng. Một tài khoản có thể có nhiều project. + +Trong Console, nhấn vào dropdown project ở thanh trên (mặc định là "My First +Project") → **New Project**. Đặt tên là `taskr-dev` (hoặc tên bạn thích). Ghi lại +**Project ID** được Google sinh tự động — nó sẽ có dạng `taskr-dev-123456`. Project +ID là *định danh toàn cầu duy nhất* và bạn không thể đổi sau khi tạo, nên hãy +chọn tên dễ nhớ. + +Tôi khuyến nghị tạo luôn hai project: `taskr-dev` để thử nghiệm hàng ngày, và +`taskr-demo` để khi demo thật cho người khác xem. Tách project giúp bạn không +accidentally xóa nhầm tài nguyên demo khi đang nghịch dev. + +## Bước 3 — Cài đặt công cụ dòng lệnh + +Bạn cần cài bốn công cụ trên máy local. Đoạn dưới đây là cho macOS với Homebrew. +Nếu bạn dùng Linux hoặc Windows WSL, chạy `scripts/00-prerequisites.sh` để được +hướng dẫn đúng cho hệ điều hành của bạn. + +```bash +# Google Cloud SDK — để giao tiếp với GCP +brew install --cask google-cloud-sdk + +# kubectl — để giao tiếp với bất kỳ Kubernetes cluster nào +brew install kubectl + +# kind — để chạy Kubernetes trong Docker trên máy local +brew install kind + +# Helm — để cài đặt các platform component (cert-manager, ingress-nginx, ...) +brew install helm + +# Docker Desktop — cần thiết vì kind chạy Kubernetes bên trong Docker +# Tải tại https://www.docker.com/products/docker-desktop + +# Go 1.22+ — để compile service +brew install go +``` + +Sau khi cài xong, kiểm tra từng tool: + +```bash +gcloud --version # Nên thấy Google Cloud SDK 450.x.x trở lên +kubectl version --client +kind --version +helm version +docker info # Đảm bảo Docker daemon đang chạy +go version # Nên thấy 1.22 trở lên +``` + +Nếu bất kỳ lệnh nào báo lỗi `command not found`, mở shell mới (`source ~/.zshrc` +hoặc khởi động lại terminal) vì PATH chưa được refresh. + +## Bước 4 — Đăng nhập gcloud + +Chạy lệnh sau để liên kết gcloud CLI với tài khoản GCP của bạn: + +```bash +gcloud auth login +``` + +Trình duyệt sẽ mở ra, đăng nhập và cho phép truy cập. Sau đó: + +```bash +gcloud config set project taskr-dev # thay bằng Project ID thật của bạn +gcloud auth application-default login # cho Terraform dùng sau này +``` + +Lệnh cuối tạo ra file credentials tại `~/.config/gcloud/application_default_credentials.json`. +File này là *bí mật*, tuyệt đối không commit lên Git. Tôi đã thêm +`.config/` vào `.gitignore` trong repo để phòng ngừa. + +## Bước 5 — Kích hoạt các API cần thiết + +GCP mặc định khóa tất cả API, bạn phải enable từng cái một. Chạy đoạn này để +enable tất cả API chúng ta sẽ cần: + +```bash +gcloud services enable \ + container.googleapis.com \ + compute.googleapis.com \ + artifactregistry.googleapis.com \ + cloudresourcemanager.googleapis.com \ + iam.googleapis.com \ + dns.googleapis.com \ + monitoring.googleapis.com \ + logging.googleapis.com +``` + +Quá trình này mất khoảng 2-3 phút. Một số API phụ thuộc lẫn nhau nên Google sẽ +enable theo thứ tự đúng. + +## Bước 6 — Tạo service account cho Terraform (chỉ khi nào bạn chuẩn bị deploy lên GCP) + +Phần này bạn *chưa cần làm ngay*. Nó chỉ cần thiết khi bạn đã làm xong Phase 1 +local và muốn triển khai lên GCP để demo. Khi đến lúc đó, quay lại đây: + +```bash +export PROJECT_ID=$(gcloud config get-value project) +export SA_NAME=terraform-admin + +gcloud iam service-accounts create $SA_NAME \ + --display-name="Terraform Admin SA" + +gcloud projects add-iam-policy-binding $PROJECT_ID \ + --member="serviceAccount:$SA_NAME@$PROJECT_ID.iam.gserviceaccount.com" \ + --role="roles/editor" + +gcloud iam service-accounts keys create ~/.config/gcloud/terraform-key.json \ + --iam-account=$SA_NAME@$PROJECT_ID.iam.gserviceaccount.com +``` + +Lưu ý: `roles/editor` là quyền khá rộng. Trong môi trường production thật, bạn +nên tạo custom role với quyền tối thiểu (least privilege). Ở phạm vi dự án học +tập, editor là đủ và đơn giản. + +## Bước 7 — Thiết lập budget alert + +Đây là bước *bắt buộc* bạn phải làm trước khi tạo bất kỳ tài nguyên tốn phí nào. +Budget alert sẽ gửi email cảnh báo khi chi phí chạm ngưỡng, giúp bạn tránh +thức dậy với bill $300. + +Truy cập `https://console.cloud.google.com/billing` → chọn billing account → +**Budgets & alerts** → **Create budget**. Cấu hình: + +- Tên: `taskr-monthly-budget` +- Amount: $50 per month (giới hạn cứng để bạn còn dư credit cho 3 tháng) +- Alert ở các ngưỡng 50%, 90%, 100% của budget +- Email alert gửi tới địa chỉ bạn check hàng ngày + +Nếu chi phí vượt 100% (tức $50/tháng), bạn sẽ nhận email ngay. Budget alert +*không tự động tắt tài nguyên*, chỉ cảnh báo. Việc tắt là trách nhiệm của bạn. + +## Bước 8 — Xác nhận sẵn sàng + +Chạy script kiểm tra tổng hợp: + +```bash +bash scripts/00-prerequisites.sh +``` + +Nếu tất cả dấu `✓` xuất hiện, bạn đã sẵn sàng chuyển sang **Phase 1 — Local +cluster setup**. Đọc tiếp tại `docs/01-local-dev.md`. + +--- + +## Những sai lầm thường gặp + +Tôi đã chứng kiến nhiều người mới mắc phải các lỗi dưới đây, liệt kê để bạn +tránh: + +**Quên tắt tài nguyên sau khi dùng xong.** Cloud tính tiền theo giờ, bất kể bạn +có đang dùng hay không. Một cluster GKE quên tắt cuối tuần có thể ngốn $20-30. +Luôn chạy `terraform destroy` khi xong buổi làm việc. + +**Lẫn lộn project ID và project name.** Project name có thể trùng nhau và đổi +được, project ID là duy nhất và cố định. Mọi script và CLI dùng project ID. + +**Không theo dõi credit usage.** Kiểm tra `https://console.cloud.google.com/billing` +ít nhất mỗi tuần một lần. GCP có dashboard hiển thị rõ bạn đã dùng bao nhiêu +và còn lại bao nhiêu. + +**Dùng region quá xa.** Chọn region asia-southeast1 (Jakarta) hoặc asia-east1 +(Taiwan) cho Việt Nam. Tránh us-central1 trừ khi cần dùng service chỉ có ở đó, +vì latency từ Hà Nội tới US là 200ms+, rất khó chịu khi develop. + +**Không bật 2FA cho Google account.** Tài khoản có $300 credit là mục tiêu +hấp dẫn cho hacker. Bật 2FA ngay tại `https://myaccount.google.com/security`. diff --git a/docs/01-local-dev.md b/docs/01-local-dev.md new file mode 100644 index 0000000..6483ce6 --- /dev/null +++ b/docs/01-local-dev.md @@ -0,0 +1,212 @@ +# Phase 1 — Local Kubernetes + ArgoCD + task-api + +Phase này mục tiêu: có một hệ thống chạy được end-to-end trên máy local. +Sau khi hoàn tất, bạn sẽ có: + +- Một cluster Kubernetes thực sự (3 node) chạy trên Docker. +- ArgoCD quản lý mọi deployment qua Git. +- Một Go service `task-api` được deploy với hexagonal architecture. +- Smoke test chạy được: `curl` tạo task và query task qua ingress. + +Ước tính thời gian: 1-2 giờ nếu chưa quen, 20 phút nếu đã biết. + +--- + +## Luồng triển khai + +Mở terminal ở thư mục gốc của repo. Tất cả lệnh dưới đây chạy từ đó. + +### Bước 1. Kiểm tra công cụ + +```bash +make prereq +``` + +Script này liệt kê các tool cần thiết và version tối thiểu. Nếu thiếu tool, +output sẽ chỉ cách cài. Không tool nào được tự động cài — bạn luôn chủ động +biết mình đang thêm gì vào máy. + +**Output mong đợi:** mọi dòng có dấu `✓` xanh lá. Nếu có dấu `✗` đỏ, xử +lý theo gợi ý rồi chạy lại. + +### Bước 2. Tạo kind cluster + +```bash +make cluster-up +``` + +Lệnh này tạo cluster 3 node (1 control plane + 2 worker) theo cấu hình +`infra/kind/cluster.yaml`. Mất 1-2 phút lần đầu vì kind phải pull Docker +image `kindest/node` (~350MB). + +**Điều quan trọng đã xảy ra:** port 80 và 443 của node control-plane đã được +map vào máy bạn. Nghĩa là khi ingress-nginx bind vào port của node, bạn có +thể truy cập qua `http://localhost` trực tiếp. + +Kiểm tra: + +```bash +kubectl get nodes +# Phải thấy 3 node ở trạng thái Ready +``` + +### Bước 3. Cài platform components + +```bash +make bootstrap +``` + +Script `scripts/02-bootstrap.sh` cài ba thứ: + +Thứ nhất là **ingress-nginx** — controller L7 nhận traffic từ ngoài. Cấu +hình đặc biệt cho kind: nodeSelector trỏ vào node có label `ingress-ready=true`, +hostPort=true để bind thẳng vào port 80/443 của node, và tolerations cho +phép schedule lên control-plane. + +Thứ hai là **cert-manager** với một `ClusterIssuer` self-signed. Ở local +chúng ta không có domain thật để lấy cert Let's Encrypt, nên dùng self-signed. +Khi lên GCP, chỉ cần thay `ClusterIssuer` sang Let's Encrypt ACME — code +application không đổi. + +Thứ ba là **ArgoCD** với tham số đã optimize cho resource nhỏ (memory request +chỉ 128-256Mi cho mỗi component thay vì default 512Mi). + +Script cũng tạo Ingress cho ArgoCD UI tại `argocd.local`. + +### Bước 4. Thêm host entries + +Kind map localhost → cluster, nhưng ingress-nginx cần biết *host* nào đang +được request (HTTP Host header). Chúng ta dùng hostname giả `taskr.local` +và `argocd.local` để phân biệt. + +```bash +echo '127.0.0.1 taskr.local argocd.local' | sudo tee -a /etc/hosts +``` + +Lệnh `sudo` vì `/etc/hosts` thuộc quyền root. Chỉ làm một lần; xóa sau khi +hoàn tất dự án bằng cách edit `/etc/hosts` thủ công. + +### Bước 5. Truy cập ArgoCD UI + +Mở `http://argocd.local` trong browser. Username: `admin`, password lấy từ: + +```bash +make get-argocd-password +``` + +Giao diện ArgoCD ở đây chưa có Application nào (do ta chưa tạo). Đây là +trạng thái ban đầu — sạch và chờ lệnh. + +### Bước 6. Build và deploy task-api + +```bash +make build # build image Docker và load vào kind +make deploy-task-api # apply Kustomize overlay +``` + +Sau khi deploy, pod task-api sẽ ở namespace `taskr`. Kiểm tra: + +```bash +kubectl -n taskr get pods +kubectl -n taskr logs -l app.kubernetes.io/name=task-api +``` + +**Output mong đợi:** pod `Running` với 1/1 ready. Log hiển thị "HTTP server +listening" và "initialized in-memory repository". + +### Bước 7. Smoke test + +```bash +make smoke-test +``` + +Lệnh này gọi `curl` qua ingress-nginx với host header `taskr.local`, tạo +task đầu tiên, rồi list tasks. Output phải là JSON hợp lệ. + +Nếu muốn test thủ công: + +```bash +curl -sS -H 'Host: taskr.local' http://localhost/api/v1/tasks | jq +curl -sS -X POST -H 'Host: taskr.local' \ + -H 'Content-Type: application/json' \ + -d '{"title":"Đầu task","description":"thử tay"}' \ + http://localhost/api/v1/tasks | jq +``` + +--- + +## Những gì vừa xảy ra — architecturally + +Khi bạn gọi `curl http://localhost/api/v1/tasks` với host `taskr.local`, +đây là luồng end-to-end: + +Đầu tiên request đến cổng 80 của máy bạn, được Docker forward vào port 80 +của node control-plane kind (qua `extraPortMappings`). Bên trong node, +ingress-nginx đang bind cổng 80 qua `hostPort`, nhận request. + +ingress-nginx đọc Host header `taskr.local`, match với Ingress resource +đã định nghĩa, biết đây là traffic của task-api. Nó forward request đến +Service `task-api.taskr.svc.cluster.local:80` (ClusterIP virtual). + +kube-proxy (chạy trên mọi node) dịch Service IP thành pod IP thật thông +qua iptables rules. Request đến pod task-api, vào container, đến process Go. + +Trong process, middleware chain của chi chạy: RequestID tạo ID, RealIP +rewrite remote address, hlog thêm logger vào context, Recoverer bọc panic. +Cuối cùng request đến handler `ListTasks`, gọi repository `FindAll`, +serialize kết quả thành JSON. + +Response đi ngược lại đúng path đó. Toàn bộ mất vài mili giây ở local. + +--- + +## Troubleshooting + +**Pod `ImagePullBackOff`.** Image chưa load vào kind. Chạy lại `make build` +và kiểm tra output có dòng "Image đã sẵn sàng trong cluster". Nếu vẫn lỗi, +kiểm tra `imagePullPolicy: Never` trong overlay local. + +**Pod `CrashLoopBackOff`.** Xem log: `kubectl -n taskr logs `. +Thường là lỗi runtime của Go code. Đặc biệt chú ý OOM (exit code 137) — +có thể tăng memory limit trong `deployment.yaml`. + +**502 Bad Gateway khi `curl`.** Pod chưa ready. Check readiness probe với +`kubectl -n taskr describe pod `. Nếu readiness fail liên tục, +có thể endpoint `/readyz` có bug. + +**ArgoCD UI không load.** Kiểm tra pod argocd-server đang running. Trang +load chậm lần đầu (SPA lớn); đợi 10 giây rồi refresh. + +**"Too many redirects" khi truy cập ArgoCD.** Cấu hình ArgoCD server phải +có flag `--insecure` để tắt TLS server-side (ingress đã handle TLS). +Script bootstrap đã set, nhưng nếu bạn customize Helm values có thể bị +override. + +**Mất port 80 vì đã bị process khác chiếm.** macOS hay có nginx/apache system. +Chạy `sudo lsof -i :80` để tìm, `sudo brew services stop nginx` để tắt. + +**Cluster chậm/treo.** Docker Desktop chưa đủ RAM. Settings → Resources → +ít nhất 6GB. 4GB có thể chạy nhưng thỉnh thoảng OOM random. + +--- + +## Reset hoàn toàn + +Khi muốn bắt đầu lại từ đầu: + +```bash +make clean # xóa cluster + image +``` + +Tất cả dữ liệu mất (task-api dùng in-memory repo, tất nhiên). Chạy lại +từ bước 2. + +--- + +## Bước tiếp theo + +Khi mọi thứ chạy được và bạn đã thử nghiệm CRUD một chút, chuyển sang +**Phase 2 — Observability**. Phase 2 sẽ thêm Prometheus, Grafana, Loki, +Tempo vào cluster để bạn thấy được mỗi request đang đi đâu, metric nào +đang đo, và log nào đang được ghi ra. Đó là bước khi service bắt đầu +"có giọng nói" và bạn nghe được hệ thống đang "nói" gì. diff --git a/docs/architecture-phase2-6.md b/docs/architecture-phase2-6.md new file mode 100644 index 0000000..54d536f --- /dev/null +++ b/docs/architecture-phase2-6.md @@ -0,0 +1,151 @@ +# Architecture — Phase 2 đến Phase 6 + +## Ràng buộc đã xác nhận +- RAM local: 8GB Docker → observability stack tối giản (tổng ~900Mi thêm) +- Domain: không có → dùng nip.io cho GCP, self-signed cho local +- Budget GCP: $300/90 ngày → GCP chỉ dùng khi demo Phase 4+ + +--- + +## Sơ đồ kiến trúc tổng thể (sau Phase 6) + +``` +╔══════════════════════════════════════════════════════════════════════╗ +║ INTERNET ║ +║ │ ║ +║ ▼ HTTPS (Let's Encrypt / nip.io) ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ EDGE LAYER ║ +║ Cloudflare (free) → WAF, DDoS protection ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ INGRESS LAYER (namespace: ingress-nginx) ║ +║ ingress-nginx ← cert-manager (Let's Encrypt / self-signed) ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ SECURITY LAYER (Phase 3) ║ +║ Kyverno (policy enforcement) ← applied at API server ║ +║ NetworkPolicy: default-deny + explicit allow rules ║ +║ Linkerd (mTLS, sidecar injection) ← east-west traffic ║ +╠═══════════════════════════════════╦══════════════════════════════════╣ +║ APPLICATION (namespace: taskr) ║ PLATFORM ║ +║ ║ ║ +║ task-api (Go, hexagonal) ║ observability/ ║ +║ ├─ HTTP adapter (chi) ║ Prometheus + Alertmanager ║ +║ ├─ OTel metrics/traces ║ Grafana (dashboards as code) ║ +║ ├─ Domain (pure logic) ║ Loki (log aggregation) ║ +║ └─ Adapter: ║ Tempo (distributed tracing) ║ +║ ├─ memory (Phase 1) ║ OTel Collector (DaemonSet) ║ +║ └─ postgres (Phase 4) ───▶║ ║ +║ ║ security/ ║ +║ Argo Rollouts (Phase 5) ║ Sealed Secrets ║ +║ Canary 5→25→50→100% ║ Kyverno policies ║ +║ AnalysisTemplate ║ ║ +║ (Prometheus gate) ║ finops/ (Phase 6) ║ +║ ║ OpenCost ║ +╠═══════════════════════════════════╣ Chaos Mesh ║ +║ DATA LAYER (namespace: taskr) ║ ║ +║ PostgreSQL (CloudNativePG) ╚══════════════════════════════════╣ +║ Primary + 1 Replica (Phase 4) ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ GITOPS (ArgoCD — namespace: argocd) ║ +║ App-of-Apps pattern ║ +║ ├─ task-api-local ║ +║ ├─ observability ║ +║ ├─ security ║ +║ └─ platform-tools ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ CI/CD (GitHub Actions — Phase 5) ║ +║ Lint → Test → Security scan → Build → Push → Bump tag → ArgoCD ║ +╚══════════════════════════════════════════════════════════════════════╝ +``` + +--- + +## Phase 2 — Observability (8GB-optimized) + +**Stack:** kube-prometheus-stack (Prometheus+Grafana+Alertmanager) + Loki + Tempo + OTel Collector + +**Resource budget tổng cho observability namespace:** ~900Mi RAM + +| Component | Request | Limit | Ghi chú | +|--------------------|---------|--------|----------------------------| +| Prometheus | 256Mi | 512Mi | retention 24h để nhỏ | +| Grafana | 128Mi | 256Mi | tắt plugin nặng | +| Alertmanager | 32Mi | 64Mi | | +| Loki | 128Mi | 256Mi | single binary mode | +| Tempo | 128Mi | 256Mi | single binary mode | +| OTel Collector | 64Mi | 128Mi | Deployment (không DaemonSet)| +| **Tổng** | **736Mi**| **1.4Gi**| | + +**Deliverables:** +- Helm values tối giản cho từng component +- OTel instrumentation trong task-api (metrics + traces) +- 2 Grafana dashboard as code (service RED metrics, infra USE) +- PrometheusRule: 3 alert cơ bản +- ArgoCD Application cho observability namespace +- Script thêm hosts: grafana.local, prometheus.local + +--- + +## Phase 3 — Security + +**Stack:** Kyverno + NetworkPolicy + Linkerd + Sealed Secrets + Trivy scan + +**Deliverables:** +- NetworkPolicy: default-deny taskr namespace + explicit allow rules +- 5 Kyverno ClusterPolicy (no-root, resource-required, trusted-registry, labels-required, no-latest-tag) +- Linkerd install + annotation cho namespace taskr +- Sealed Secrets controller + workflow encrypt/decrypt +- Trivy scan tích hợp vào Makefile + +--- + +## Phase 4 — HA & GCP + +**Stack:** CloudNativePG + postgres adapter Go + Terraform GKE Autopilot + Velero + +**GCP cost estimate (demo 2h):** ~$1.00 +- GKE Autopilot: $0.10/vCPU/h × 0.5 vCPU × 2h = $0.10 +- Load Balancer: $0.025/h × 2h = $0.05 +- Egress: ~$0.00 (minimal) + +**Deliverables:** +- postgres adapter Go (swap memory → postgres, domain unchanged) +- golang-migrate schema migration +- CloudNativePG PostgreSQL CRD +- Terraform: VPC, GKE Autopilot, Artifact Registry, IAM +- Overlay gcp-demo với nip.io ingress +- Velero backup setup +- make gcp-up / make gcp-down (auto destroy sau 2h via Cloud Scheduler) + +--- + +## Phase 5 — Progressive Delivery + +**Stack:** Argo Rollouts + AnalysisTemplate + GitHub Actions CI + +**Canary flow:** +``` +deploy v2 → 5% traffic (5 phút) → check metrics → + OK: 25% (5 phút) → OK: 50% (5 phút) → 100% + FAIL: auto-rollback về v1 +``` + +**Deliverables:** +- Rollout CRD thay thế Deployment +- AnalysisTemplate dùng Prometheus query +- GitHub Actions workflow (lint→test→build→push→bump) +- "Bug injection" script để demo rollback +- Makefile targets + +--- + +## Phase 6 — FinOps & Operations + +**Stack:** OpenCost + ResourceQuota + Chaos Mesh + Runbook + +**Deliverables:** +- OpenCost deployment với Prometheus backend +- ResourceQuota + LimitRange cho namespace taskr +- Chaos Mesh: 3 experiment (pod-kill, network-delay, cpu-stress) +- Operations runbook (5 scenario thường gặp) +- Weekly cost report script diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..8ff2312 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,203 @@ +# Operations Runbook — Cloud Native Taskr + +## Cách dùng runbook này +Mỗi scenario có: Triệu chứng → Chẩn đoán nhanh → Xử lý theo thứ tự. +Không bỏ qua bước chẩn đoán dù tưởng đã biết nguyên nhân. + +--- + +## Scenario 1: task-api CrashLoopBackOff + +**Triệu chứng:** `kubectl -n taskr get pods` hiện STATUS = CrashLoopBackOff + +**Chẩn đoán:** +```bash +# Xem log của lần crash gần nhất +kubectl -n taskr logs --previous + +# Xem event liên quan +kubectl -n taskr describe pod + +# Kiểm tra exit code (137 = OOM, 1 = runtime error, 2 = config error) +kubectl -n taskr get pod -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}' +``` + +**Xử lý theo exit code:** + +Exit 137 (OOM Kill): +```bash +# Tăng memory limit tạm thời +kubectl -n taskr set resources deployment/task-api --limits=memory=256Mi +# Sau đó cập nhật deployment.yaml và commit +``` + +Exit 1 (runtime panic): +```bash +# Tìm PANIC line trong log +kubectl -n taskr logs --previous | grep -i panic +# Rollback về version trước nếu do code mới +kubectl argo rollouts undo task-api -n taskr +``` + +Exit 1 (config error - biến môi trường thiếu): +```bash +# Xem env hiện tại của pod +kubectl -n taskr exec -- env | grep -E 'DATABASE|SERVICE|HTTP' +# Nếu thiếu env, kiểm tra deployment.yaml env section +``` + +--- + +## Scenario 2: ArgoCD stuck ở Progressing / OutOfSync + +**Triệu chứng:** ArgoCD UI hiện vàng "Progressing" quá 5 phút + +**Chẩn đoán:** +```bash +# Xem chi tiết sync status +kubectl -n argocd get app task-api-local -o jsonpath='{.status.conditions}' | jq + +# Xem resource nào đang fail +kubectl -n argocd get app task-api-local -o jsonpath='{.status.operationState}' | jq + +# Xem log của ArgoCD application controller +kubectl -n argocd logs deployment/argocd-application-controller --tail=50 +``` + +**Xử lý phổ biến:** + +Resource conflict (ai đó kubectl edit thủ công): +```bash +# Force sync với prune +argocd app sync task-api-local --force --prune +# Hoặc từ UI: click "Sync" → check "Force" → "Synchronize" +``` + +Helm chart version không tồn tại: +```bash +# Kiểm tra chart version trong Application spec +kubectl -n argocd get app prometheus-stack -o jsonpath='{.spec.source.targetRevision}' +# Tìm version hợp lệ: helm search repo prometheus-community/kube-prometheus-stack --versions | head -5 +``` + +CRD conflict khi upgrade: +```bash +# Apply CRD thủ công trước +kubectl apply --server-side -f https://raw.githubusercontent.com/.../crds.yaml +# Sau đó sync lại +argocd app sync +``` + +--- + +## Scenario 3: Metrics không xuất hiện trong Grafana + +**Triệu chứng:** Dashboard task-api trống, "No data" + +**Chẩn đoán theo luồng:** +```bash +# 1. task-api có expose /metrics không? +kubectl -n taskr port-forward svc/task-api 8080:80 & +curl http://localhost:8080/metrics | head -20 + +# 2. Prometheus có scrape được không? +# Mở http://prometheus.local/targets và tìm taskr +# Status phải là UP + +# 3. Prometheus có ServiceMonitor/annotation không? +kubectl -n taskr get pod -o jsonpath='{.metadata.annotations}' | jq +# Phải thấy prometheus.io/scrape: "true" + +# 4. Kiểm tra Prometheus scrape config +kubectl -n observability exec -it pod/prometheus-kube-prometheus-stack-prometheus-0 -- \ + wget -qO- localhost:9090/api/v1/targets | jq '.data.activeTargets[] | select(.labels.namespace=="taskr")' +``` + +**Xử lý:** +```bash +# Nếu annotation thiếu +kubectl -n taskr annotate pod \ + prometheus.io/scrape=true \ + prometheus.io/port=8080 \ + prometheus.io/path=/metrics + +# Nếu Grafana datasource sai URL +# Vào http://grafana.local → Configuration → Data Sources → Prometheus +# URL phải là: http://prometheus-operated.observability.svc.cluster.local:9090 +``` + +--- + +## Scenario 4: Cluster hết disk (kind local) + +**Triệu chứng:** Pod evicted, PersistentVolume fail, node condition DiskPressure + +**Chẩn đoán:** +```bash +# Check disk usage trên node +docker exec taskr-control-plane df -h +docker exec taskr-worker df -h + +# Xem node condition +kubectl describe node | grep -A5 Conditions +``` + +**Xử lý:** +```bash +# Xóa unused Docker images trên host +docker image prune -f + +# Xóa log cũ trong cluster (nếu Loki persistence enabled) +kubectl -n observability exec -it pod/loki-0 -- \ + find /var/loki -name "*.gz" -mtime +1 -delete + +# Reset cluster nếu cần +make clean && make cluster-up && make bootstrap +``` + +--- + +## Scenario 5: Canary rollout bị stuck ở Paused + +**Triệu chứng:** `kubectl argo rollouts get rollout task-api -n taskr` hiện Paused + +**Chẩn đoán:** +```bash +# Xem trạng thái chi tiết +kubectl argo rollouts get rollout task-api -n taskr -w + +# Xem Analysis result +kubectl -n taskr get analysisrun -l rollout-name=task-api + +# Xem metric value thực tế +kubectl -n taskr describe analysisrun +``` + +**Xử lý:** +```bash +# Option 1: Promote thủ công (nếu bạn tin là OK) +kubectl argo rollouts promote task-api -n taskr + +# Option 2: Abort và rollback về stable +kubectl argo rollouts abort task-api -n taskr +kubectl argo rollouts undo task-api -n taskr + +# Option 3: Điều chỉnh threshold nếu metric query sai +# Sửa AnalysisTemplate, commit, ArgoCD sync +``` + +--- + +## Quy trình postmortem (sau mọi incident P1/P2) + +Template: `docs/postmortem-template.md` + +Bắt buộc điền trong 5 ngày: +1. Timeline (UTC, từng phút) +2. Impact (số user bị ảnh hưởng, thời gian downtime) +3. Root cause (dùng 5-Why) +4. Điều gì hoạt động tốt +5. Điều gì không hoạt động tốt +6. Action items (assignee + deadline cụ thể) + +**Nguyên tắc blameless:** postmortem tập trung vào hệ thống, không vào cá nhân. diff --git a/infra/argocd/apps/observability.yaml b/infra/argocd/apps/observability.yaml new file mode 100644 index 0000000..f09512e --- /dev/null +++ b/infra/argocd/apps/observability.yaml @@ -0,0 +1,170 @@ +--- +# ArgoCD Application: observability-stack +# Quản lý toàn bộ observability namespace qua GitOps. +# Apply: kubectl apply -f infra/argocd/apps/observability.yaml +# +# QUAN TRỌNG: Thứ tự cài đặt quan trọng. +# kube-prometheus-stack phải cài trước (tạo CRD PrometheusRule, ServiceMonitor). +# Sau đó Loki, Tempo, OTel Collector mới có thể reference CRD này. +# ArgoCD sync wave đảm bảo thứ tự này. + +# Wave 1: CRD + namespace +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: observability-crds + namespace: argocd + annotations: + # Sync wave 1 — chạy trước + argocd.argoproj.io/sync-wave: "1" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://github.com/YOUR_USERNAME/cloud-native-taskr.git + targetRevision: main + path: platform/observability + destination: + server: https://kubernetes.default.svc + namespace: observability + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + +--- +# Wave 2: kube-prometheus-stack (Prometheus + Grafana + Alertmanager) +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: prometheus-stack + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "2" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://prometheus-community.github.io/helm-charts + chart: kube-prometheus-stack + targetRevision: "65.3.1" + helm: + valueFiles: + - https://raw.githubusercontent.com/YOUR_USERNAME/cloud-native-taskr/main/platform/observability/values/prometheus-stack.yaml + destination: + server: https://kubernetes.default.svc + namespace: observability + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + # CRD thay đổi rất ít, skip replace để tránh conflict với Helm + - Replace=false + +--- +# Wave 3: Loki +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: loki + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "3" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://grafana.github.io/helm-charts + chart: loki + targetRevision: "6.16.0" + helm: + valueFiles: + - https://raw.githubusercontent.com/YOUR_USERNAME/cloud-native-taskr/main/platform/observability/values/loki.yaml + destination: + server: https://kubernetes.default.svc + namespace: observability + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + +--- +# Wave 3: Tempo (song song với Loki) +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: tempo + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "3" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://grafana.github.io/helm-charts + chart: tempo + targetRevision: "1.10.3" + helm: + values: | + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + persistence: + enabled: false + tempo: + reportingEnabled: false + destination: + server: https://kubernetes.default.svc + namespace: observability + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + +--- +# Wave 4: OTel Collector + Dashboards (sau khi backend sẵn sàng) +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: otel-collector + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "4" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://open-telemetry.github.io/opentelemetry-helm-charts + chart: opentelemetry-collector + targetRevision: "0.108.0" + helm: + valueFiles: + - https://raw.githubusercontent.com/YOUR_USERNAME/cloud-native-taskr/main/platform/observability/values/tempo-otel.yaml + destination: + server: https://kubernetes.default.svc + namespace: observability + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/infra/argocd/apps/task-api-local.yaml b/infra/argocd/apps/task-api-local.yaml new file mode 100644 index 0000000..9a33263 --- /dev/null +++ b/infra/argocd/apps/task-api-local.yaml @@ -0,0 +1,83 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ArgoCD Application: task-api +# ───────────────────────────────────────────────────────────────────────────── +# Đây là "đăng ký" task-api vào ArgoCD. Sau khi apply resource này, ArgoCD sẽ: +# 1. Clone Git repo về +# 2. Build manifest từ Kustomize overlay +# 3. So sánh với cluster state +# 4. Apply các thay đổi (nếu autoSync enabled) +# 5. Lặp lại mỗi 3 phút (default) +# +# Apply: kubectl apply -f infra/argocd/apps/task-api-local.yaml +# ───────────────────────────────────────────────────────────────────────────── +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: task-api-local + namespace: argocd + # Finalizer đảm bảo khi xóa Application, ArgoCD xóa cả resource + # trong cluster thay vì để orphan. Quan trọng cho cleanup sạch. + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + # Project là grouping trong ArgoCD — cho phép chia quyền theo project. + # Ở giai đoạn này dùng "default", Phase 3 sẽ tạo custom project với RBAC. + project: default + + # ─── Source: Git repo + path + targetRevision ─── + source: + # Repo URL — thay bằng URL repo thật của bạn sau khi push code. + # Trong thời gian phát triển, có thể dùng file://... hoặc local path + # với argocd-repo-server, nhưng phức tạp hơn. + # + # MẸO: để test nhanh mà không cần Git remote, có thể tạo Application + # với source là "repoURL: ." hoặc mount local folder — xem doc ArgoCD + # "Bootstrapping Applications" để biết thêm cách hack local. + repoURL: https://github.com/YOUR_USERNAME/cloud-native-taskr.git + targetRevision: main + path: deploy/task-api/overlays/local + + # ─── Destination: cluster + namespace ─── + destination: + # in-cluster là tên đặc biệt cho cluster mà ArgoCD đang chạy. + # Phase 2+ khi ArgoCD quản lý nhiều cluster, đây sẽ là URL/tên cluster. + server: https://kubernetes.default.svc + namespace: taskr + + # ─── Sync Policy ─── + syncPolicy: + automated: + # prune: xóa resource trong cluster mà không còn trong Git. Bật để Git + # thực sự là single source of truth. Mới đầu có thể gây hoảng nếu xóa + # file nhầm, nhưng đó là pattern đúng. + prune: true + # selfHeal: nếu ai đó kubectl edit thủ công, ArgoCD sẽ revert về Git state. + # Bật để enforce GitOps nghiêm ngặt. + selfHeal: true + # allowEmpty: cho phép sync khi Git rỗng (không có resource). False để + # tránh accidentally xóa mọi thứ khi push commit trống. + allowEmpty: false + + syncOptions: + # CreateNamespace: ArgoCD tự tạo namespace "taskr" nếu chưa có. + # Thay thế cho việc khai báo Namespace resource thủ công. + - CreateNamespace=true + # PrunePropagationPolicy=foreground: khi xóa, chờ dependent resource + # xóa xong mới xóa parent. An toàn hơn background. + - PrunePropagationPolicy=foreground + # ServerSideApply: dùng server-side apply thay vì client-side. Xử lý + # conflict với other controllers tốt hơn (ví dụ HPA modify replicas). + - ServerSideApply=true + + # Retry on sync failure — tránh fail vĩnh viễn vì blip mạng tạm thời. + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + + # ─── Revision history ─── + # Giữ 10 bản revision để rollback nếu cần. ArgoCD UI có nút rollback + # thuận tiện, chọn bản nào trong 10 bản này. + revisionHistoryLimit: 10 diff --git a/infra/kind/cluster.yaml b/infra/kind/cluster.yaml new file mode 100644 index 0000000..20968ab --- /dev/null +++ b/infra/kind/cluster.yaml @@ -0,0 +1,88 @@ +# ───────────────────────────────────────────────────────────────────────────── +# kind cluster configuration — Cloud Native Taskr +# ───────────────────────────────────────────────────────────────────────────── +# Mục tiêu: tạo cluster 3 node (1 control plane + 2 worker) có port mapping +# để ingress-nginx chạy trên node có thể được truy cập qua localhost:80/443. +# +# Khi chạy `kind create cluster --config=infra/kind/cluster.yaml`, kind sẽ: +# 1. Pull Docker image kindest/node (Kubernetes đóng gói sẵn) +# 2. Chạy 3 container, mỗi container là 1 node +# 3. Cấu hình networking giữa các node qua Docker bridge +# 4. Mở port 80/443 từ control plane ra host của bạn +# +# Xóa cluster: kind delete cluster --name taskr +# ───────────────────────────────────────────────────────────────────────────── + +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 + +# Tên cluster. Nếu bạn có nhiều cluster kind cùng lúc, tên giúp phân biệt. +name: taskr + +# ─────── Networking ─────── +networking: + # IP family: dùng IPv4 cho đơn giản. IPv6 phức tạp hơn và không cần cho local. + ipFamily: ipv4 + + # API server port trên host. Kind sẽ expose Kubernetes API qua cổng này. + # Mặc định là random, nhưng fix cứng giúp script tự động hóa dễ hơn. + apiServerPort: 6443 + + # CIDR cho pod và service. Giá trị mặc định của kind thường ổn, nhưng khi + # bạn chạy nhiều cluster cùng lúc thì cần tách biệt để tránh collision. + podSubnet: "10.244.0.0/16" + serviceSubnet: "10.96.0.0/16" + + # Tắt CNI mặc định (kindnet) vì chúng ta có thể muốn thay bằng Cilium ở Phase 3. + # Giờ tạm để mặc định (true) cho đơn giản. + disableDefaultCNI: false + +# ─────── Nodes ─────── +nodes: + # ─── Control Plane ─── + # Đây là node quan trọng nhất vì nó chạy etcd, API server, scheduler, + # controller manager. Chúng ta map port 80 và 443 vào node này vì đó là nơi + # ingress-nginx sẽ được schedule (thông qua nodeSelector "ingress-ready"). + - role: control-plane + # Label tùy chỉnh giúp ingress-nginx biết schedule pod vào đây. + # ingress-nginx Helm chart có nodeSelector mặc định cho label này. + labels: + ingress-ready: "true" + + # Kubeadm patches cho phép customize cluster sâu hơn. + # Ở đây ta bật feature gate cho một số tính năng cần ở Phase 2. + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + # node-labels đảm bảo label được apply cả ở kubelet level, + # không chỉ ở etcd. + node-labels: "ingress-ready=true" + + # Port mapping: đây là phép màu cho phép `curl http://localhost` hoạt động. + # containerPort là port trên node kind, hostPort là port trên máy bạn. + extraPortMappings: + - containerPort: 80 + hostPort: 80 + protocol: TCP + - containerPort: 443 + hostPort: 443 + protocol: TCP + + # ─── Workers ─── + # Hai worker để có ít nhất một chút phân phối pod. Trong production thường + # ít nhất ba worker ở ba AZ khác nhau, nhưng local 2 worker là đủ minh họa. + # Nếu máy bạn yếu, có thể giảm xuống 1 worker bằng cách xóa một entry dưới. + - role: worker + labels: + node-tier: "general" + + - role: worker + labels: + node-tier: "general" + +# ─────── Feature Gates ─────── +# Tạm thời không enable gì thêm. Khi cần feature alpha/beta của Kubernetes, +# thêm vào đây. Ví dụ: "PodSecurity=true". +featureGates: {} diff --git a/infra/terraform/envs/gcp-demo/main.tf b/infra/terraform/envs/gcp-demo/main.tf new file mode 100644 index 0000000..8811767 --- /dev/null +++ b/infra/terraform/envs/gcp-demo/main.tf @@ -0,0 +1,209 @@ +# infra/terraform/envs/gcp-demo/main.tf +# Hạ tầng GCP cho demo session (~$0.50/giờ, auto-destroy sau 2h) +# +# Cấu trúc: +# VPC private (không có node public IP) +# GKE Autopilot (không cần manage node pool) +# Artifact Registry (lưu Docker image) +# +# CHẠY: +# cd infra/terraform/envs/gcp-demo +# terraform init +# terraform apply -var="project_id=YOUR_PROJECT_ID" +# +# DESTROY (bắt buộc sau demo để tiết kiệm credit): +# terraform destroy -var="project_id=YOUR_PROJECT_ID" + +terraform { + required_version = ">= 1.6" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.0" + } + } + # Remote state trên GCS — nhiều người có thể cộng tác. + # Tạo bucket trước: gsutil mb gs://YOUR_PROJECT_ID-tfstate + backend "gcs" { + bucket = "YOUR_PROJECT_ID-tfstate" + prefix = "taskr/gcp-demo" + } +} + +variable "project_id" { + description = "GCP Project ID" + type = string +} + +variable "region" { + description = "GCP region — chọn gần Việt Nam" + type = string + default = "asia-southeast1" # Singapore, latency ~30ms từ HN +} + +variable "cluster_name" { + description = "Tên GKE cluster" + type = string + default = "taskr-demo" +} + +provider "google" { + project = var.project_id + region = var.region +} + +# ─── VPC ─── +# Private VPC — node không có external IP, tăng bảo mật +module "vpc" { + source = "terraform-google-modules/network/google" + version = "~> 9.0" + + project_id = var.project_id + network_name = "taskr-vpc" + routing_mode = "REGIONAL" + + subnets = [ + { + subnet_name = "taskr-gke-subnet" + subnet_ip = "10.10.0.0/20" + subnet_region = var.region + subnet_private_access = true # Cloud NAT cho outbound internet + subnet_flow_logs = false # Tắt để tiết kiệm chi phí + } + ] + + # Secondary ranges cho GKE pods và services + secondary_ranges = { + taskr-gke-subnet = [ + { + range_name = "pods" + ip_cidr_range = "10.20.0.0/16" + }, + { + range_name = "services" + ip_cidr_range = "10.30.0.0/20" + } + ] + } +} + +# Cloud NAT — cho phép node private kết nối internet (pull image, etc.) +resource "google_compute_router" "router" { + name = "taskr-router" + region = var.region + network = module.vpc.network_name +} + +resource "google_compute_router_nat" "nat" { + name = "taskr-nat" + router = google_compute_router.router.name + region = var.region + nat_ip_allocate_option = "AUTO_ONLY" + source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" +} + +# ─── GKE Autopilot ─── +# Autopilot: Google quản lý node, chỉ trả tiền cho pod (không phải node idle) +# Rẻ hơn Standard cho demo ngắn hạn. +resource "google_container_cluster" "primary" { + name = var.cluster_name + location = var.region # Regional cluster (3 zone) → HA mặc định + + # Autopilot mode + enable_autopilot = true + + network = module.vpc.network_name + subnetwork = module.vpc.subnets_names[0] + + ip_allocation_policy { + cluster_secondary_range_name = "pods" + services_secondary_range_name = "services" + } + + # Private cluster — node không có public IP + private_cluster_config { + enable_private_nodes = true + enable_private_endpoint = false # Master endpoint vẫn public (cần cho CI/CD) + master_ipv4_cidr_block = "172.16.0.0/28" + } + + # Workload Identity — pod lấy GCP credential qua service account binding + # Không cần service account key JSON trong pod + workload_identity_config { + workload_pool = "${var.project_id}.svc.id.goog" + } + + # Release channel STABLE — ít breaking change hơn RAPID + release_channel { + channel = "STABLE" + } + + # Logging/monitoring của GCP — tắt để tiết kiệm (dùng self-hosted Prometheus) + logging_config { + enable_components = [] + } + monitoring_config { + enable_components = [] + } + + deletion_protection = false # Cho phép `terraform destroy` xóa cluster +} + +# ─── Artifact Registry ─── +# Nơi lưu Docker image của task-api (thay thế Docker Hub) +# Format image: asia-southeast1-docker.pkg.dev/PROJECT/taskr/task-api:TAG +resource "google_artifact_registry_repository" "taskr" { + location = var.region + repository_id = "taskr" + format = "DOCKER" + + cleanup_policies { + id = "keep-last-10" + action = "KEEP" + most_recent_versions { + keep_count = 10 + } + } +} + +# ─── IAM: GitHub Actions có thể push image ─── +# Dùng Workload Identity Federation (không cần service account key JSON trong CI) +resource "google_iam_workload_identity_pool" "github" { + workload_identity_pool_id = "github-pool" + display_name = "GitHub Actions Pool" +} + +resource "google_iam_workload_identity_pool_provider" "github" { + workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id + workload_identity_pool_provider_id = "github-provider" + display_name = "GitHub Actions Provider" + + oidc { + issuer_uri = "https://token.actions.githubusercontent.com" + } + + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.actor" = "assertion.actor" + "attribute.repository" = "assertion.repository" + } + + attribute_condition = "assertion.repository == 'YOUR_USERNAME/cloud-native-taskr'" +} + +# ─── Outputs ─── +output "cluster_name" { + value = google_container_cluster.primary.name +} + +output "registry_url" { + value = "${var.region}-docker.pkg.dev/${var.project_id}/taskr" +} + +output "get_credentials_command" { + value = "gcloud container clusters get-credentials ${var.cluster_name} --region ${var.region} --project ${var.project_id}" +} + +output "estimated_cost_per_hour" { + value = "~$0.10-0.20/giờ cho demo nhỏ (Autopilot pricing)" +} diff --git a/platform/finops/chaos-experiments.yaml b/platform/finops/chaos-experiments.yaml new file mode 100644 index 0000000..b350021 --- /dev/null +++ b/platform/finops/chaos-experiments.yaml @@ -0,0 +1,60 @@ +--- +# Chaos Experiment 1: Pod kill ngẫu nhiên +# Mục đích: verify Kubernetes self-healing và PodDisruptionBudget hoạt động +# Chạy: kubectl apply -f platform/finops/chaos-pod-kill.yaml +# Expect: pod mới được tạo trong <30s, service không gián đoạn +apiVersion: chaos-mesh.org/v1alpha1 +kind: PodChaos +metadata: + name: task-api-pod-kill + namespace: taskr +spec: + action: pod-kill + mode: one # Kill 1 pod ngẫu nhiên + selector: + namespaces: [taskr] + labelSelectors: + app.kubernetes.io/name: task-api + scheduler: + cron: "@every 10m" # Chạy mỗi 10 phút (chỉ bật khi chaos testing) + +--- +# Chaos Experiment 2: Network delay +# Mục đích: verify timeout handling và retry logic +apiVersion: chaos-mesh.org/v1alpha1 +kind: NetworkChaos +metadata: + name: task-api-network-delay + namespace: taskr +spec: + action: delay + mode: all + selector: + namespaces: [taskr] + labelSelectors: + app.kubernetes.io/name: task-api + delay: + latency: "200ms" + correlation: "25" + jitter: "50ms" + duration: "5m" # Chạy 5 phút rồi tự stop + +--- +# Chaos Experiment 3: CPU stress +# Mục đích: verify HPA trigger và resource limits +apiVersion: chaos-mesh.org/v1alpha1 +kind: StressChaos +metadata: + name: task-api-cpu-stress + namespace: taskr +spec: + mode: one + selector: + namespaces: [taskr] + labelSelectors: + app.kubernetes.io/name: task-api + stressors: + cpu: + workers: 2 # 2 goroutine đốt CPU + load: 80 # 80% CPU load + duration: "2m" diff --git a/platform/finops/opencost.yaml b/platform/finops/opencost.yaml new file mode 100644 index 0000000..c6b82ed --- /dev/null +++ b/platform/finops/opencost.yaml @@ -0,0 +1,157 @@ +--- +# ResourceQuota — giới hạn cứng cho namespace taskr +# Tránh một bug code vô tình tạo 1000 pod hoặc xin 100GB RAM +apiVersion: v1 +kind: ResourceQuota +metadata: + name: taskr-quota + namespace: taskr +spec: + hard: + # Compute + requests.cpu: "2" # Tổng CPU requests tối đa + requests.memory: "1Gi" # Tổng RAM requests tối đa + limits.cpu: "4" + limits.memory: "2Gi" + + # Workload objects + count/pods: "20" + count/deployments.apps: "5" + count/services: "10" + + # Storage + requests.storage: "5Gi" + +--- +# LimitRange — default value cho pod không khai báo resources +# Ngăn pod "free rider" không khai báo limit nhưng ngốn hết RAM node +apiVersion: v1 +kind: LimitRange +metadata: + name: taskr-limits + namespace: taskr +spec: + limits: + - type: Container + default: # Áp dụng nếu container không khai báo limits + cpu: 200m + memory: 256Mi + defaultRequest: # Áp dụng nếu container không khai báo requests + cpu: 50m + memory: 64Mi + max: # Không cho phép xin quá giá trị này + cpu: "2" + memory: 1Gi + min: # Phải xin ít nhất giá trị này + cpu: 10m + memory: 16Mi + + - type: Pod + max: + cpu: "2" + memory: 1Gi + +--- +# OpenCost — cost allocation per namespace/pod +# Deploy sau khi Prometheus đã có (cần scrape metrics của OpenCost) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opencost + namespace: observability +spec: + replicas: 1 + selector: + matchLabels: + app: opencost + template: + metadata: + labels: + app: opencost + spec: + serviceAccountName: opencost + containers: + - name: opencost + image: ghcr.io/opencost/opencost:latest + ports: + - name: http + containerPort: 9003 + env: + - name: PROMETHEUS_SERVER_ENDPOINT + value: "http://prometheus-operated.observability.svc.cluster.local:9090" + - name: CLUSTER_ID + value: "taskr-local" + - name: CLOUD_PROVIDER_API_KEY + value: "" # Để trống cho local + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi + + - name: opencost-ui + image: ghcr.io/opencost/opencost-ui:latest + ports: + - name: ui + containerPort: 9090 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 50m + memory: 64Mi + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: opencost + namespace: observability + +--- +# ClusterRole cho OpenCost đọc pod/node metrics +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: opencost +rules: + - apiGroups: [""] + resources: [nodes, pods, services, resourcequotas, persistentvolumes, persistentvolumeclaims, namespaces] + verbs: [get, list, watch] + - apiGroups: [extensions, apps] + resources: [deployments, replicasets, statefulsets, daemonsets] + verbs: [get, list, watch] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: opencost +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: opencost +subjects: + - kind: ServiceAccount + name: opencost + namespace: observability + +--- +apiVersion: v1 +kind: Service +metadata: + name: opencost + namespace: observability +spec: + selector: + app: opencost + ports: + - name: http + port: 9003 + targetPort: http + - name: ui + port: 9090 + targetPort: ui diff --git a/platform/observability/dashboards/task-api-dashboard.yaml b/platform/observability/dashboards/task-api-dashboard.yaml new file mode 100644 index 0000000..e97d37e --- /dev/null +++ b/platform/observability/dashboards/task-api-dashboard.yaml @@ -0,0 +1,121 @@ +--- +# Grafana Dashboard — task-api RED Metrics +# ConfigMap này được sidecar Grafana tự động load nhờ label grafana_dashboard=1 +# Không cần click thủ công trong UI, không mất dashboard khi reset cluster. +# +# Dashboard JSON được rút gọn — chỉ giữ 4 panel quan trọng nhất: +# 1. Request Rate (RPS) +# 2. Error Rate (%) +# 3. Latency p50/p95/p99 +# 4. Active pods count +apiVersion: v1 +kind: ConfigMap +metadata: + name: dashboard-task-api + namespace: observability + labels: + grafana_dashboard: "1" # Label này kích hoạt sidecar auto-load +data: + task-api-dashboard.json: | + { + "title": "task-api — RED Metrics", + "uid": "taskr-task-api-red", + "tags": ["taskr", "task-api", "red"], + "timezone": "browser", + "refresh": "30s", + "time": {"from": "now-1h", "to": "now"}, + "templating": { + "list": [ + { + "name": "namespace", + "type": "constant", + "current": {"value": "taskr"} + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Request Rate (RPS)", + "type": "timeseries", + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 8}, + "fieldConfig": { + "defaults": {"unit": "reqps", "color": {"mode": "palette-classic"}} + }, + "targets": [ + { + "expr": "sum(rate(http_server_request_duration_seconds_count{namespace=\"taskr\"}[2m])) by (http_route, http_request_method)", + "legendFormat": "{{http_request_method}} {{http_route}}" + } + ] + }, + { + "id": 2, + "title": "Error Rate (%)", + "type": "timeseries", + "gridPos": {"x": 12, "y": 0, "w": 12, "h": 8}, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "thresholds": { + "steps": [ + {"value": null, "color": "green"}, + {"value": 0.01, "color": "yellow"}, + {"value": 0.05, "color": "red"} + ] + } + } + }, + "targets": [ + { + "expr": "sum(rate(http_server_request_duration_seconds_count{namespace=\"taskr\",http_response_status_code=~\"5..\"}[2m])) / sum(rate(http_server_request_duration_seconds_count{namespace=\"taskr\"}[2m]))", + "legendFormat": "error rate" + } + ] + }, + { + "id": 3, + "title": "Latency Percentiles", + "type": "timeseries", + "gridPos": {"x": 0, "y": 8, "w": 12, "h": 8}, + "fieldConfig": {"defaults": {"unit": "s"}}, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by(le) (rate(http_server_request_duration_seconds_bucket{namespace=\"taskr\"}[2m])))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.95, sum by(le) (rate(http_server_request_duration_seconds_bucket{namespace=\"taskr\"}[2m])))", + "legendFormat": "p95" + }, + { + "expr": "histogram_quantile(0.99, sum by(le) (rate(http_server_request_duration_seconds_bucket{namespace=\"taskr\"}[2m])))", + "legendFormat": "p99" + } + ] + }, + { + "id": 4, + "title": "Active Pods", + "type": "stat", + "gridPos": {"x": 12, "y": 8, "w": 12, "h": 8}, + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + {"value": null, "color": "red"}, + {"value": 1, "color": "yellow"}, + {"value": 2, "color": "green"} + ] + } + } + }, + "targets": [ + { + "expr": "count(kube_pod_status_ready{namespace=\"taskr\",pod=~\"task-api-.*\",condition=\"true\"} == 1)", + "legendFormat": "ready pods" + } + ] + } + ] + } diff --git a/platform/observability/kustomization.yaml b/platform/observability/kustomization.yaml new file mode 100644 index 0000000..8ec017e --- /dev/null +++ b/platform/observability/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - prometheus-rules.yaml diff --git a/platform/observability/prometheus-rules.yaml b/platform/observability/prometheus-rules.yaml new file mode 100644 index 0000000..fb1b29b --- /dev/null +++ b/platform/observability/prometheus-rules.yaml @@ -0,0 +1,90 @@ +--- +# Namespace observability +apiVersion: v1 +kind: Namespace +metadata: + name: observability + labels: + kubernetes.io/metadata.name: observability + pod-security.kubernetes.io/enforce: baseline + +--- +# PrometheusRule — alert on symptoms, not causes +# Nguyên tắc: alert dựa trên trải nghiệm người dùng, +# không dựa trên CPU/RAM của server. +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: taskr-alerts + namespace: observability + labels: + # Label này để Prometheus Operator biết pick up rule này + release: kube-prometheus-stack +spec: + groups: + - name: taskr.task-api + interval: 30s + rules: + # Alert 1: Service down + # Cách đọc: nếu không có pod task-api nào ready trong 5 phút liên tiếp + - alert: TaskApiDown + expr: | + absent(kube_pod_status_ready{ + namespace="taskr", + pod=~"task-api-.*", + condition="true" + } == 1) + for: 5m + labels: + severity: critical + team: platform + annotations: + summary: "task-api không có pod nào ready" + description: > + Không tìm thấy pod task-api ở trạng thái Ready trong namespace taskr + trong {{ $value }} phút. Kiểm tra: kubectl -n taskr get pods + runbook: "https://github.com/YOUR_USERNAME/cloud-native-taskr/blob/main/docs/runbooks/task-api-down.md" + + # Alert 2: Error rate cao (>5%) + # Metric này sẽ có sau khi task-api expose /metrics với OTel instrumentation + - alert: TaskApiHighErrorRate + expr: | + ( + sum(rate(http_server_request_duration_seconds_count{ + namespace="taskr", + http_response_status_code=~"5.." + }[5m])) + / + sum(rate(http_server_request_duration_seconds_count{ + namespace="taskr" + }[5m])) + ) > 0.05 + for: 2m + labels: + severity: warning + team: platform + annotations: + summary: "task-api error rate vượt 5%" + description: > + Error rate hiện tại: {{ $value | humanizePercentage }}. + Ngưỡng: 5%. Xem traces tại http://grafana.local/explore + + # Alert 3: Latency p99 cao (>500ms) + - alert: TaskApiHighLatency + expr: | + histogram_quantile(0.99, + sum by (le) ( + rate(http_server_request_duration_seconds_bucket{ + namespace="taskr" + }[5m]) + ) + ) > 0.5 + for: 5m + labels: + severity: warning + team: platform + annotations: + summary: "task-api p99 latency vượt 500ms" + description: > + P99 latency: {{ $value | humanizeDuration }}. + Ngưỡng: 500ms. diff --git a/platform/observability/values/loki.yaml b/platform/observability/values/loki.yaml new file mode 100644 index 0000000..c4e9607 --- /dev/null +++ b/platform/observability/values/loki.yaml @@ -0,0 +1,86 @@ +# platform/observability/values/loki.yaml +# Loki — single binary mode, tối giản cho local +# Chart: grafana/loki version 6.x +# +# QUYẾT ĐỊNH: dùng "single binary" (monolithic) thay vì "distributed" +# Distributed cần 6+ pod (compactor, distributor, querier, ...), quá nặng. +# Single binary chạy mọi thứ trong 1 pod — đủ cho local dev. + +loki: + # Monolithic deployment — 1 pod duy nhất + deploymentMode: SingleBinary + + auth_enabled: false # Tắt multi-tenant cho đơn giản + + commonConfig: + replication_factor: 1 # local: không cần replicate + + storage: + type: filesystem # Local filesystem, không cần S3/GCS + + # Schema config + schemaConfig: + configs: + - from: "2024-01-01" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: loki_index_ + period: 24h + +singleBinary: + replicas: 1 + + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + + persistence: + enabled: false # Ephemeral — local không cần persist log lâu dài + +# Tắt các component distributed không cần +backend: + replicas: 0 +read: + replicas: 0 +write: + replicas: 0 + +# Gateway (nginx trước Loki) — tắt để đơn giản +gateway: + enabled: false + +# Promtail — agent đọc log từ node và push lên Loki +# DaemonSet chạy trên mỗi node, đọc /var/log/pods/ +promtail: + enabled: true + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi + config: + clients: + - url: http://loki.observability.svc.cluster.local:3100/loki/api/v1/push + snippets: + # Parse JSON log từ zerolog để index các field + pipelineStages: + - json: + expressions: + level: level + service: service + trace_id: trace_id + request_id: request_id + - labels: + level: + service: + - timestamp: + source: timestamp + format: UnixMs diff --git a/platform/observability/values/prometheus-stack.yaml b/platform/observability/values/prometheus-stack.yaml new file mode 100644 index 0000000..e6380fc --- /dev/null +++ b/platform/observability/values/prometheus-stack.yaml @@ -0,0 +1,172 @@ +# platform/observability/values/prometheus-stack.yaml +# kube-prometheus-stack — tối giản cho máy 8GB RAM +# Chart: https://github.com/prometheus-community/helm-charts +# Version pin: 65.x (stable tại thời điểm viết) +# +# QUYẾT ĐỊNH THIẾT KẾ: +# - Tắt nodeExporter trên Windows/Mac node (không relevant cho kind) +# - Tắt kubeEtcd, kubeScheduler metrics (kind không expose) +# - Prometheus retention 24h (local không cần lâu hơn) +# - Grafana: tắt persistence (dùng ConfigMap-based dashboards) + +# ─── Prometheus ─── +prometheus: + prometheusSpec: + # Retention thấp để tiết kiệm disk và memory index + retention: 24h + retentionSize: 1GB + + # Resource budget: 256Mi request, 512Mi limit + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + # Scrape interval 30s thay vì 15s default — giảm tải + scrapeInterval: 30s + evaluationInterval: 30s + + # Chỉ scrape namespace cụ thể, tránh noise từ kube-system + podMonitorNamespaceSelector: {} + serviceMonitorNamespaceSelector: {} + podMonitorSelector: {} + serviceMonitorSelector: {} + ruleNamespaceSelector: {} + + # Storage: ephemeral cho local, đủ cho 24h data + storageSpec: + emptyDir: + medium: Memory + sizeLimit: 512Mi + +# ─── Grafana ─── +grafana: + # Admin credentials — đổi sau khi deploy + adminPassword: "taskr-grafana-admin" + + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + + # Tắt persistence, dùng ConfigMap sidecar để load dashboard + persistence: + enabled: false + + # Sidecar tự động load dashboard từ ConfigMap có label grafana_dashboard=1 + sidecar: + dashboards: + enabled: true + label: grafana_dashboard + labelValue: "1" + searchNamespace: ALL + datasources: + enabled: true + + # Cấu hình datasource mặc định + additionalDataSources: + - name: Loki + type: loki + url: http://loki.observability.svc.cluster.local:3100 + access: proxy + isDefault: false + - name: Tempo + type: tempo + url: http://tempo.observability.svc.cluster.local:3100 + access: proxy + isDefault: false + jsonData: + # Liên kết Tempo trace với Loki log qua traceID + tracesToLogs: + datasourceUid: loki + tags: ["service"] + mapTagNamesEnabled: true + serviceMap: + datasourceUid: prometheus + + # Ingress cho grafana.local + ingress: + enabled: true + ingressClassName: nginx + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "false" + hosts: + - grafana.local + path: / + + grafana.ini: + server: + root_url: http://grafana.local + # Tắt telemetry về Grafana Inc + analytics: + reporting_enabled: false + check_for_updates: false + +# ─── Alertmanager ─── +alertmanager: + alertmanagerSpec: + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + + # Route mọi alert sang webhook test (Phase 2: dùng webhook.site để xem alert) + config: + global: + resolve_timeout: 5m + route: + group_by: ["alertname", "namespace"] + group_wait: 10s + group_interval: 5m + repeat_interval: 12h + receiver: "webhook-test" + receivers: + - name: "webhook-test" + webhook_configs: + # Thay URL này bằng webhook.site URL của bạn để xem alert thật + - url: "https://webhook.site/YOUR-UUID-HERE" + send_resolved: true + +# ─── Các component tắt trên kind ─── +# kind không expose etcd/scheduler metrics qua port chuẩn +kubeEtcd: + enabled: false +kubeScheduler: + enabled: false +kubeControllerManager: + enabled: false + +# node-exporter: giữ lại để có node metrics, nhưng tắt trên Windows node +nodeExporter: + enabled: true + +# kube-state-metrics: giữ, rất hữu ích cho pod/deployment metrics +kube-state-metrics: + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + +# ─── Prometheus ingress ─── +prometheus: + ingress: + enabled: true + ingressClassName: nginx + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "false" + hosts: + - prometheus.local + paths: + - / diff --git a/platform/observability/values/tempo-otel.yaml b/platform/observability/values/tempo-otel.yaml new file mode 100644 index 0000000..673851a --- /dev/null +++ b/platform/observability/values/tempo-otel.yaml @@ -0,0 +1,110 @@ +# platform/observability/values/tempo.yaml +# Tempo — distributed tracing backend, single binary mode +# Chart: grafana/tempo version 1.x + +tempo: + # Single binary — monolithic deployment + reportingEnabled: false # tắt telemetry Grafana Labs + +resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + +# Lưu trace trên local filesystem, retain 1h (local không cần lâu) +storage: + trace: + backend: local + local: + path: /var/tempo/traces + +persistence: + enabled: false + +# Expose gRPC OTLP port (4317) và HTTP OTLP port (4318) +# OTel Collector sẽ forward trace đến đây +service: + type: ClusterIP + +# Tempo config +tempo: + metricsGenerator: + enabled: true # Sinh RED metrics từ trace → Prometheus + remoteWriteUrl: "http://prometheus-operated.observability.svc.cluster.local:9090/api/v1/write" + +--- +# platform/observability/values/otel-collector.yaml +# OpenTelemetry Collector — nhận telemetry từ app, route đến backend +# Chart: open-telemetry/opentelemetry-collector +# +# QUYẾT ĐỊNH: Deployment (1 replica) thay vì DaemonSet +# DaemonSet phù hợp production (mỗi node có collector riêng, không qua network) +# Deployment đơn giản hơn cho local 8GB, tiết kiệm RAM ~64Mi × số node + +mode: deployment +replicaCount: 1 + +resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi + +# Cấu hình pipeline của OTel Collector +# Nhận OTLP từ app → xử lý → gửi đến Prometheus/Loki/Tempo +config: + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + endpoint: "0.0.0.0:4318" + + processors: + # Batch để giảm số lần gọi mạng + batch: + timeout: 5s + send_batch_size: 512 + # Memory limiter — quan trọng trên 8GB machine + memory_limiter: + limit_mib: 100 + spike_limit_mib: 20 + check_interval: 5s + # Enrich với Kubernetes metadata (pod name, namespace, ...) + k8sattributes: + passthrough: false + extract: + metadata: + - k8s.pod.name + - k8s.namespace.name + - k8s.deployment.name + + exporters: + # Metrics → Prometheus (remote write) + prometheusremotewrite: + endpoint: "http://prometheus-operated.observability.svc.cluster.local:9090/api/v1/write" + # Traces → Tempo + otlp/tempo: + endpoint: "http://tempo.observability.svc.cluster.local:4317" + tls: + insecure: true + # Debug exporter — tắt ở production, bật khi debug + debug: + verbosity: basic + + service: + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, k8sattributes, batch] + exporters: [otlp/tempo] + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [prometheusremotewrite] diff --git a/platform/rollouts/task-api-rollout.yaml b/platform/rollouts/task-api-rollout.yaml new file mode 100644 index 0000000..af92f73 --- /dev/null +++ b/platform/rollouts/task-api-rollout.yaml @@ -0,0 +1,179 @@ +--- +# Rollout — thay thế Deployment của task-api +# Argo Rollouts controller sẽ quản lý pod lifecycle thay vì K8s Deployment controller +# Cài Argo Rollouts: kubectl apply -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: task-api + namespace: taskr +spec: + replicas: 2 + + selector: + matchLabels: + app.kubernetes.io/name: task-api + + template: + metadata: + labels: + app.kubernetes.io/name: task-api + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + terminationGracePeriodSeconds: 30 + containers: + - name: task-api + image: task-api:local-dev + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + env: + - name: APP_ENV + value: "production" + - name: HTTP_PORT + value: "8080" + - name: SERVICE_NAME + value: "task-api" + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 2 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 128Mi + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + + # ─── Canary Strategy ─── + strategy: + canary: + # Service sẽ có traffic split tự động + canaryService: task-api-canary # Service nhận canary traffic + stableService: task-api-stable # Service nhận stable traffic + + # Bước 1: 10% → đợi 5 phút → check metric + # Bước 2: 25% → đợi 5 phút → check metric + # Bước 3: 50% → đợi 5 phút → check metric + # Bước 4: 100% (tự động nếu tất cả step pass) + steps: + - setWeight: 10 + - pause: + duration: 5m + - analysis: + templates: + - templateName: task-api-success-rate + args: + - name: service + value: task-api-canary + - setWeight: 25 + - pause: + duration: 5m + - analysis: + templates: + - templateName: task-api-success-rate + args: + - name: service + value: task-api-canary + - setWeight: 50 + - pause: + duration: 5m + - analysis: + templates: + - templateName: task-api-success-rate + args: + - name: service + value: task-api-canary + +--- +# AnalysisTemplate — định nghĩa điều kiện để canary được promote/rollback +# Query Prometheus mỗi 30s, nếu error rate > 5% → fail → auto-rollback +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +metadata: + name: task-api-success-rate + namespace: taskr +spec: + args: + - name: service + metrics: + - name: success-rate + interval: 30s + # Phải pass 3 lần liên tiếp mới được promote + successCondition: result[0] >= 0.95 + # Fail 2 lần liên tiếp → rollback + failureLimit: 2 + provider: + prometheus: + address: http://prometheus-operated.observability.svc.cluster.local:9090 + query: | + sum(rate(http_server_request_duration_seconds_count{ + namespace="taskr", + http_response_status_code!~"5.." + }[2m])) + / + sum(rate(http_server_request_duration_seconds_count{ + namespace="taskr" + }[2m])) + +--- +# Service ổn định (nhận traffic stable version) +apiVersion: v1 +kind: Service +metadata: + name: task-api-stable + namespace: taskr +spec: + selector: + app.kubernetes.io/name: task-api + ports: + - name: http + port: 80 + targetPort: http + +--- +# Service canary (nhận traffic canary version) +apiVersion: v1 +kind: Service +metadata: + name: task-api-canary + namespace: taskr +spec: + selector: + app.kubernetes.io/name: task-api + ports: + - name: http + port: 80 + targetPort: http diff --git a/platform/security/kyverno/policies.yaml b/platform/security/kyverno/policies.yaml new file mode 100644 index 0000000..075bceb --- /dev/null +++ b/platform/security/kyverno/policies.yaml @@ -0,0 +1,154 @@ +--- +# Policy 1: Bắt buộc chạy non-root +# Từ chối pod không khai báo runAsNonRoot=true +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: require-non-root + annotations: + policies.kyverno.io/title: Require Non-Root Containers + policies.kyverno.io/description: > + Pod không được chạy as root. Buộc khai báo runAsNonRoot=true + hoặc runAsUser > 0. Tương thích với distroless image (UID 65532). +spec: + validationFailureAction: Enforce + background: true + rules: + - name: check-runasnonroot + match: + any: + - resources: + kinds: [Pod] + namespaces: [taskr] # Chỉ enforce namespace ứng dụng + validate: + message: "Pod phải chạy non-root. Set securityContext.runAsNonRoot=true" + pattern: + spec: + securityContext: + runAsNonRoot: true + +--- +# Policy 2: Bắt buộc có resource requests +# Pod không có requests làm scheduler không hoạt động đúng, +# và node có thể bị OOM khi burst. +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: require-resource-requests + annotations: + policies.kyverno.io/title: Require Resource Requests +spec: + validationFailureAction: Enforce + background: true + rules: + - name: check-cpu-memory-requests + match: + any: + - resources: + kinds: [Pod] + namespaces: [taskr] + validate: + message: "Container phải khai báo resources.requests.cpu và resources.requests.memory" + foreach: + - list: "request.object.spec.containers[]" + deny: + conditions: + any: + - key: "{{ element.resources.requests.cpu || '' }}" + operator: Equals + value: "" + - key: "{{ element.resources.requests.memory || '' }}" + operator: Equals + value: "" + +--- +# Policy 3: Chỉ cho phép image từ trusted registry +# Ngăn deploy image từ Docker Hub không kiểm soát vào namespace production. +# Whitelist: ghcr.io (GitHub), gcr.io (Google), docker.io/library (official) +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: restrict-image-registries + annotations: + policies.kyverno.io/title: Restrict Image Registries +spec: + validationFailureAction: Enforce + background: true + rules: + - name: check-registry + match: + any: + - resources: + kinds: [Pod] + namespaces: [taskr] + validate: + message: > + Image phải từ registry được phê duyệt: + ghcr.io, gcr.io, asia.gcr.io, task-api (local kind) + foreach: + - list: "request.object.spec.containers[]" + pattern: + image: "ghcr.io/* | gcr.io/* | asia.gcr.io/* | task-api:*" + +--- +# Policy 4: Bắt buộc label chuẩn +# Đảm bảo mọi deployment có đủ label để monitoring và cost allocation hoạt động. +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: require-standard-labels + annotations: + policies.kyverno.io/title: Require Standard Labels +spec: + validationFailureAction: Enforce + background: true + rules: + - name: check-labels + match: + any: + - resources: + kinds: [Deployment, StatefulSet] + namespaces: [taskr] + validate: + message: > + Thiếu label bắt buộc. Cần có: + app.kubernetes.io/name, app.kubernetes.io/part-of + pattern: + metadata: + labels: + app.kubernetes.io/name: "?*" + app.kubernetes.io/part-of: "?*" + +--- +# Policy 5: Cấm image tag "latest" +# "latest" không deterministic — mỗi lần pull có thể khác nhau. +# Bắt buộc dùng tag cụ thể (SHA hoặc semver). +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: disallow-latest-tag + annotations: + policies.kyverno.io/title: Disallow Latest Tag +spec: + validationFailureAction: Enforce + background: true + rules: + - name: check-image-tag + match: + any: + - resources: + kinds: [Pod] + namespaces: [taskr] + validate: + message: "Cấm dùng image tag 'latest'. Dùng tag cụ thể (v1.2.3, SHA, local-dev)" + foreach: + - list: "request.object.spec.containers[]" + deny: + conditions: + any: + - key: "{{ element.image }}" + operator: Equals + value: "*:latest" + - key: "{{ element.image }}" + operator: NotContains + value: ":" diff --git a/platform/security/networkpolicy/taskr-policies.yaml b/platform/security/networkpolicy/taskr-policies.yaml new file mode 100644 index 0000000..388dffe --- /dev/null +++ b/platform/security/networkpolicy/taskr-policies.yaml @@ -0,0 +1,151 @@ +--- +# NetworkPolicy Phase 3: Zero-trust networking cho namespace taskr +# +# CHIẾN LƯỢC: +# 1. default-deny tất cả ingress VÀ egress +# 2. Explicit allow từng luồng cần thiết +# +# Luồng cho phép (Phase 3): +# ingress-nginx → task-api (port 8080) +# task-api → DNS (kube-dns, port 53) +# task-api → OTel Collector (port 4317 gRPC) +# Prometheus → task-api (scrape /metrics, port 8080) + +--- +# Rule 1: Deny tất cả — baseline +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: taskr +spec: + podSelector: {} # Áp dụng cho mọi pod trong namespace + policyTypes: + - Ingress + - Egress + # Không có ingress/egress rules → deny all + +--- +# Rule 2: Cho phép ingress-nginx gọi task-api +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-to-task-api + namespace: taskr +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: task-api + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + podSelector: + matchLabels: + app.kubernetes.io/name: ingress-nginx + ports: + - protocol: TCP + port: 8080 + +--- +# Rule 3: Cho phép Prometheus scrape /metrics từ task-api +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-prometheus-scrape + namespace: taskr +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: task-api + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: observability + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - protocol: TCP + port: 8080 + +--- +# Rule 4: task-api được phép gọi DNS (kube-dns) +# Không có rule này → DNS không hoạt động → mọi HTTP call fail +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns-egress + namespace: taskr +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: task-api + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + +--- +# Rule 5: task-api gửi trace đến OTel Collector +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-otel-egress + namespace: taskr +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: task-api + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: observability + ports: + - protocol: TCP + port: 4317 # OTLP gRPC + - protocol: TCP + port: 4318 # OTLP HTTP + +--- +# Rule 6 (Phase 4): task-api gọi PostgreSQL +# Uncomment khi Phase 4 deploy CloudNativePG +# apiVersion: networking.k8s.io/v1 +# kind: NetworkPolicy +# metadata: +# name: allow-postgres-egress +# namespace: taskr +# spec: +# podSelector: +# matchLabels: +# app.kubernetes.io/name: task-api +# policyTypes: +# - Egress +# egress: +# - to: +# - podSelector: +# matchLabels: +# cnpg.io/cluster: taskr-postgres +# ports: +# - protocol: TCP +# port: 5432 diff --git a/scripts/00-prerequisites.sh b/scripts/00-prerequisites.sh new file mode 100644 index 0000000..601ea57 --- /dev/null +++ b/scripts/00-prerequisites.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# 00-prerequisites.sh +# ----------------------------------------------------------------------------- +# Kiểm tra toàn bộ công cụ cần thiết trên máy local. Script này KHÔNG cài đặt +# gì cả, chỉ kiểm tra và gợi ý. Lý do: người dùng nên chủ động biết mình đang +# cài gì lên máy, không để script tự ý cài package. +# +# Usage: +# bash scripts/00-prerequisites.sh +# ----------------------------------------------------------------------------- + +set -euo pipefail + +# Màu sắc cho output dễ nhìn. Chỉ bật màu khi output là terminal thật, +# nếu redirect ra file thì bỏ escape code để log sạch. +if [[ -t 1 ]]; then + GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[0;33m' + BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m' +else + GREEN=''; RED=''; YELLOW=''; BLUE=''; BOLD=''; RESET='' +fi + +# Biến đếm lỗi để báo tổng kết cuối cùng. +# Nếu có bất kỳ tool nào thiếu, script exit với mã lỗi để CI/automation +# có thể phát hiện được. +ERRORS=0 +WARNINGS=0 + +# Hàm tiện ích để in thông báo. Cách viết này chuẩn hóa format +# và giúp đoạn chính của script dễ đọc hơn. +ok() { printf "${GREEN}✓${RESET} %s\n" "$1"; } +fail() { printf "${RED}✗${RESET} %s\n" "$1"; ERRORS=$((ERRORS+1)); } +warn() { printf "${YELLOW}⚠${RESET} %s\n" "$1"; WARNINGS=$((WARNINGS+1)); } +info() { printf "${BLUE}ℹ${RESET} %s\n" "$1"; } +header(){ printf "\n${BOLD}%s${RESET}\n" "$1"; } + +# Phát hiện OS để đưa ra hướng dẫn cài đặt phù hợp. +# Ba trường hợp: macOS (Darwin), Linux, và WSL/khác. +detect_os() { + case "$(uname -s)" in + Darwin*) echo "macos" ;; + Linux*) + if grep -qi microsoft /proc/version 2>/dev/null; then + echo "wsl" + else + echo "linux" + fi + ;; + *) echo "unknown" ;; + esac +} + +OS=$(detect_os) + +# Hàm kiểm tra command tồn tại và đủ version tối thiểu. +# Tham số: tên command, version tối thiểu, lệnh lấy version, lệnh cài +check_tool() { + local name=$1 + local min_version=$2 + local version_cmd=$3 + local install_hint=$4 + + if ! command -v "$name" &> /dev/null; then + fail "$name: chưa cài đặt" + info " Cài bằng: $install_hint" + return + fi + + # Lấy version. 2>/dev/null để nuốt stderr của các tool gây noise. + local version + version=$(eval "$version_cmd" 2>/dev/null | head -1 || echo "unknown") + ok "$name: $version" +} + +# ----------------------------------------------------------------------------- +# Bắt đầu kiểm tra +# ----------------------------------------------------------------------------- + +printf "${BOLD}Cloud Native Taskr — Prerequisites Check${RESET}\n" +printf "OS được phát hiện: ${BOLD}%s${RESET}\n" "$OS" + +header "▸ Công cụ cần thiết cho Phase 1 (local)" + +# Docker: nền tảng cho kind. Không có Docker thì không có Kubernetes local. +if ! command -v docker &> /dev/null; then + fail "docker: chưa cài đặt" + case "$OS" in + macos) info " Cài bằng: Docker Desktop tại https://www.docker.com/products/docker-desktop" ;; + linux) info " Cài bằng: https://docs.docker.com/engine/install/" ;; + wsl) info " Cài bằng: Docker Desktop for Windows với WSL2 backend" ;; + esac +else + # Kiểm tra daemon có chạy không. Nhiều user cài Docker rồi quên bật Desktop app. + if docker info &> /dev/null; then + ok "docker: $(docker --version | awk '{print $3}' | tr -d ',') (daemon đang chạy)" + else + fail "docker: đã cài nhưng daemon không chạy" + info " Mở Docker Desktop hoặc chạy: sudo systemctl start docker" + fi +fi + +# kubectl: giao tiếp với mọi Kubernetes cluster +case "$OS" in + macos) KUBECTL_HINT="brew install kubectl" ;; + linux) KUBECTL_HINT="https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/" ;; + wsl) KUBECTL_HINT="https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/" ;; +esac +check_tool "kubectl" "1.28" "kubectl version --client --output=json 2>/dev/null | grep -oP '\"gitVersion\":\\s*\"\\K[^\"]+' | head -1" "$KUBECTL_HINT" + +# kind: tạo Kubernetes cluster trong Docker +case "$OS" in + macos) KIND_HINT="brew install kind" ;; + *) KIND_HINT="go install sigs.k8s.io/kind@latest (hoặc dùng binary từ GitHub releases)" ;; +esac +check_tool "kind" "0.20" "kind --version | awk '{print \$3}'" "$KIND_HINT" + +# Helm: package manager cho Kubernetes +case "$OS" in + macos) HELM_HINT="brew install helm" ;; + *) HELM_HINT="curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash" ;; +esac +check_tool "helm" "3.12" "helm version --short" "$HELM_HINT" + +# Go: compile task-api +case "$OS" in + macos) GO_HINT="brew install go" ;; + *) GO_HINT="https://go.dev/doc/install" ;; +esac +check_tool "go" "1.22" "go version | awk '{print \$3}'" "$GO_HINT" + +header "▸ Công cụ cho Phase 2+ (GCP) — có thể bỏ qua bây giờ" + +# gcloud: chỉ cần khi deploy lên GCP +if ! command -v gcloud &> /dev/null; then + warn "gcloud: chưa cài đặt (không bắt buộc cho Phase 1)" + case "$OS" in + macos) info " Cài bằng: brew install --cask google-cloud-sdk" ;; + *) info " Cài bằng: https://cloud.google.com/sdk/docs/install" ;; + esac +else + ok "gcloud: $(gcloud --version 2>/dev/null | head -1 | awk '{print $NF}')" + + # Kiểm tra đã login chưa. Không bắt buộc nhưng tốt để thông báo. + if gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null | grep -q .; then + ok " đã đăng nhập gcloud với tài khoản: $(gcloud config get-value account 2>/dev/null)" + else + warn " chưa đăng nhập gcloud. Chạy: gcloud auth login" + fi +fi + +# Terraform: chỉ cần khi deploy hạ tầng GCP +if ! command -v terraform &> /dev/null; then + warn "terraform: chưa cài đặt (không bắt buộc cho Phase 1)" + case "$OS" in + macos) info " Cài bằng: brew install terraform" ;; + *) info " Cài bằng: https://developer.hashicorp.com/terraform/install" ;; + esac +else + ok "terraform: $(terraform version | head -1 | awk '{print $2}')" +fi + +header "▸ Kiểm tra tài nguyên hệ thống" + +# Docker Desktop trên macOS/Windows có giới hạn RAM mặc định. Cluster Kubernetes +# với đầy đủ platform component cần ít nhất 6GB để chạy thoải mái. +# Cách kiểm tra phụ thuộc OS nên chỉ cảnh báo chung chung. +if command -v docker &> /dev/null && docker info &> /dev/null; then + DOCKER_MEM=$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0) + DOCKER_MEM_GB=$((DOCKER_MEM / 1024 / 1024 / 1024)) + + if [ "$DOCKER_MEM_GB" -ge 6 ]; then + ok "Docker RAM: ${DOCKER_MEM_GB}GB (đủ cho cluster + platform)" + elif [ "$DOCKER_MEM_GB" -ge 4 ]; then + warn "Docker RAM: ${DOCKER_MEM_GB}GB (có thể chạy nhưng chật)" + info " Gợi ý: tăng lên 6GB trong Docker Desktop Settings → Resources" + else + fail "Docker RAM: ${DOCKER_MEM_GB}GB (quá thấp, cluster sẽ OOM)" + info " Tăng lên ít nhất 6GB trong Docker Desktop Settings → Resources" + fi +fi + +# ----------------------------------------------------------------------------- +# Tổng kết +# ----------------------------------------------------------------------------- + +header "▸ Tổng kết" + +if [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then + printf "${GREEN}${BOLD}Tuyệt vời!${RESET} Môi trường của bạn đã sẵn sàng. Chạy tiếp:\n" + printf " ${BOLD}bash scripts/01-kind-up.sh${RESET}\n" + exit 0 +elif [ "$ERRORS" -eq 0 ]; then + printf "${YELLOW}${BOLD}Sẵn sàng cho Phase 1${RESET} với %d cảnh báo.\n" "$WARNINGS" + printf "Các cảnh báo trên là về công cụ Phase 2+. Bạn có thể bỏ qua cho đến khi\n" + printf "sẵn sàng deploy lên GCP. Chạy tiếp:\n" + printf " ${BOLD}bash scripts/01-kind-up.sh${RESET}\n" + exit 0 +else + printf "${RED}${BOLD}Cần xử lý %d lỗi${RESET} trước khi tiếp tục.\n" "$ERRORS" + printf "Cài đặt các công cụ còn thiếu theo gợi ý ở trên, rồi chạy lại script này.\n" + exit 1 +fi diff --git a/scripts/01-kind-up.sh b/scripts/01-kind-up.sh new file mode 100644 index 0000000..8365d14 --- /dev/null +++ b/scripts/01-kind-up.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# 01-kind-up.sh — Tạo local Kubernetes cluster bằng kind +# ----------------------------------------------------------------------------- +# Script này là idempotent: chạy nhiều lần không gây lỗi. Nếu cluster đã tồn tại, +# script sẽ hỏi bạn có muốn xóa và tạo lại không thay vì báo lỗi cứng đầu. +# +# Lý do chọn approach này: trong quá trình học, bạn sẽ thường xuyên muốn reset +# cluster về trạng thái sạch. Một script "smart" tiết kiệm rất nhiều thời gian. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +# Đường dẫn tuyệt đối tới thư mục script, bất kể script được gọi từ đâu. +# Đây là idiom chuẩn để script dùng file tương đối luôn đúng path. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +CLUSTER_NAME="taskr" +CLUSTER_CONFIG="$ROOT_DIR/infra/kind/cluster.yaml" + +# Màu sắc output — dùng lại pattern từ script 00. +if [[ -t 1 ]]; then + GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[0;33m' + BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m' +else + GREEN=''; RED=''; YELLOW=''; BLUE=''; BOLD=''; RESET='' +fi + +log() { printf "${BLUE}▸${RESET} %s\n" "$1"; } +ok() { printf "${GREEN}✓${RESET} %s\n" "$1"; } +warn() { printf "${YELLOW}⚠${RESET} %s\n" "$1"; } +fail() { printf "${RED}✗${RESET} %s\n" "$1"; exit 1; } + +# Kiểm tra prerequisites nhanh. Không duplicate logic với script 00, +# chỉ check đủ để script này chạy được. +command -v kind &> /dev/null || fail "kind chưa cài. Chạy scripts/00-prerequisites.sh để xem hướng dẫn." +command -v kubectl &> /dev/null || fail "kubectl chưa cài." +docker info &> /dev/null || fail "Docker daemon không chạy. Mở Docker Desktop hoặc khởi động docker service." + +[[ -f "$CLUSTER_CONFIG" ]] || fail "Không tìm thấy file config tại $CLUSTER_CONFIG" + +# ─── Xử lý trường hợp cluster đã tồn tại ─── +# Kind lưu danh sách cluster trong Docker; check bằng `kind get clusters`. +if kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then + warn "Cluster '${CLUSTER_NAME}' đã tồn tại." + + # Nếu script chạy trong terminal tương tác, hỏi xác nhận. + # Nếu chạy trong CI (không có TTY), mặc định skip để không treo pipeline. + if [[ -t 0 ]]; then + read -rp "Xóa và tạo lại? [y/N] " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + log "Xóa cluster cũ..." + kind delete cluster --name "$CLUSTER_NAME" + else + log "Giữ nguyên cluster hiện tại. Kiểm tra với: kubectl get nodes" + exit 0 + fi + else + warn "Non-interactive shell, giữ nguyên cluster." + exit 0 + fi +fi + +# ─── Tạo cluster mới ─── +log "Tạo cluster '${CLUSTER_NAME}' (mất khoảng 1-2 phút)..." +log "Kind sẽ pull image kindest/node nếu chưa có (~350MB, chỉ lần đầu)." + +# --wait 60s đảm bảo script chỉ return sau khi cluster thực sự ready, +# không phải chỉ khi các container Docker đã start. +# Nếu quá 60s chưa ready, thường là do Docker chưa đủ RAM. +kind create cluster \ + --name "$CLUSTER_NAME" \ + --config "$CLUSTER_CONFIG" \ + --wait 60s + +# ─── Verify cluster sức khỏe ─── +# kubectl context đã tự động switch sang cluster mới bởi kind. +# Check nodes đều Ready. +log "Kiểm tra cluster..." +if ! kubectl get nodes &> /dev/null; then + fail "Không kết nối được tới cluster. Check 'kubectl config current-context'" +fi + +# Đếm node Ready. Phải đủ 3 (1 CP + 2 worker). +READY_NODES=$(kubectl get nodes --no-headers | grep -c "Ready" || true) +TOTAL_NODES=$(kubectl get nodes --no-headers | wc -l | tr -d ' ') + +if [[ "$READY_NODES" -eq "$TOTAL_NODES" ]] && [[ "$TOTAL_NODES" -ge 1 ]]; then + ok "Cluster ready với $READY_NODES/$TOTAL_NODES nodes" +else + warn "$READY_NODES/$TOTAL_NODES nodes ready. Đợi thêm hoặc check 'kubectl describe nodes'" +fi + +# ─── Hiển thị trạng thái ─── +echo +kubectl get nodes -o wide +echo + +ok "Cluster '${CLUSTER_NAME}' đã sẵn sàng" +printf " Context: ${BOLD}kind-${CLUSTER_NAME}${RESET}\n" +printf " Kubeconfig: ${BOLD}~/.kube/config${RESET} (đã tự động cập nhật)\n" +echo +printf "Bước tiếp theo: ${BOLD}bash scripts/02-bootstrap.sh${RESET}\n" +printf " (cài ArgoCD + ingress-nginx + cert-manager)\n" diff --git a/scripts/02-bootstrap.sh b/scripts/02-bootstrap.sh new file mode 100644 index 0000000..c0a7230 --- /dev/null +++ b/scripts/02-bootstrap.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# 02-bootstrap.sh — Cài đặt platform components lên kind cluster +# ----------------------------------------------------------------------------- +# Script này cài đặt bộ platform tối thiểu cho Phase 1: +# 1. ingress-nginx — controller cho ingress, cho phép truy cập qua localhost +# 2. cert-manager — quản lý TLS certificates (dùng self-signed cho local) +# 3. ArgoCD — GitOps engine, sẽ quản lý mọi thứ từ đây trở đi +# +# Sau khi script này chạy xong, ArgoCD UI sẽ accessible tại http://argocd.local. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Color output — pattern quen thuộc. +if [[ -t 1 ]]; then + GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[0;33m' + BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m' +else + GREEN=''; RED=''; YELLOW=''; BLUE=''; BOLD=''; RESET='' +fi + +log() { printf "\n${BLUE}▸${RESET} ${BOLD}%s${RESET}\n" "$1"; } +ok() { printf "${GREEN}✓${RESET} %s\n" "$1"; } +warn() { printf "${YELLOW}⚠${RESET} %s\n" "$1"; } +fail() { printf "${RED}✗${RESET} %s\n" "$1"; exit 1; } + +# ─── Precheck: đang trỏ vào đúng cluster kind ─── +# Nguy hiểm: nếu kubectl context trỏ vào cluster production thật, script này +# sẽ deploy lung tung. Bắt buộc check context trước mọi hành động. +CURRENT_CONTEXT=$(kubectl config current-context 2>/dev/null || echo "none") +if [[ "$CURRENT_CONTEXT" != "kind-taskr" ]]; then + fail "kubectl context hiện tại là '$CURRENT_CONTEXT', không phải 'kind-taskr'. + Chạy: kubectl config use-context kind-taskr + Hoặc tạo cluster trước: bash scripts/01-kind-up.sh" +fi +ok "Context: $CURRENT_CONTEXT" + +# ───────────────────────────────────────────────────────────────────────────── +# Step 1: Add Helm repositories +# ───────────────────────────────────────────────────────────────────────────── +# Helm repo là nơi chứa chart (Kubernetes package). Mỗi chart có version riêng. +# Chúng ta pin version cụ thể ở phần install để deterministic, không dùng latest. + +log "Step 1/4: Thêm Helm repositories" + +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx 2>/dev/null || true +helm repo add jetstack https://charts.jetstack.io 2>/dev/null || true +helm repo add argo https://argoproj.github.io/argo-helm 2>/dev/null || true +helm repo update > /dev/null +ok "Helm repos đã cập nhật" + +# ───────────────────────────────────────────────────────────────────────────── +# Step 2: Cài ingress-nginx +# ───────────────────────────────────────────────────────────────────────────── +# ingress-nginx là Layer 7 reverse proxy, nhận traffic từ ngoài và route đến +# các Service trong cluster dựa trên host/path rules được định nghĩa trong +# Ingress resource. Đây là "cổng chính" vào cluster. +# +# Cấu hình dưới đây được tối ưu cho kind cluster: +# - kind.nodeSelector: chỉ deploy lên node có label ingress-ready=true +# (control plane node đã được label ở cluster config) +# - kind.tolerations: cho phép schedule lên control plane (mặc định bị taint) +# - hostPort=true: bind trực tiếp vào port 80/443 của node, không qua Service +# LoadBalancer. Kết hợp với port mapping của kind, điều này cho phép +# localhost:80 đi thẳng vào ingress-nginx. + +log "Step 2/4: Cài ingress-nginx" + +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --namespace ingress-nginx \ + --create-namespace \ + --version 4.11.3 \ + --set controller.hostPort.enabled=true \ + --set controller.service.type=NodePort \ + --set controller.nodeSelector."ingress-ready"=true \ + --set-json 'controller.tolerations=[{"key":"node-role.kubernetes.io/control-plane","operator":"Equal","effect":"NoSchedule"}]' \ + --set controller.publishService.enabled=false \ + --wait --timeout 5m + +ok "ingress-nginx đã cài đặt" + +# ───────────────────────────────────────────────────────────────────────────── +# Step 3: Cài cert-manager +# ───────────────────────────────────────────────────────────────────────────── +# cert-manager tự động phát hành và rotate TLS certificate. Ở local, chúng ta +# dùng một ClusterIssuer "self-signed" để không phụ thuộc Internet cho cert. +# Khi lên GCP, đổi issuer thành Let's Encrypt (ACME) mà không phải thay đổi +# logic application. + +log "Step 3/4: Cài cert-manager" + +helm upgrade --install cert-manager jetstack/cert-manager \ + --namespace cert-manager \ + --create-namespace \ + --version v1.16.1 \ + --set installCRDs=true \ + --set prometheus.enabled=false \ + --wait --timeout 5m + +# Tạo ClusterIssuer self-signed. Đây là CRD của cert-manager, nên phải đợi +# cert-manager sẵn sàng xong rồi mới apply được. +kubectl apply -f - </dev/null | base64 -d || echo "") + +# ───────────────────────────────────────────────────────────────────────────── +# Tổng kết +# ───────────────────────────────────────────────────────────────────────────── + +log "Platform đã sẵn sàng" + +cat </dev/null || echo "none") + +# Màu sắc quen thuộc +if [[ -t 1 ]]; then + GREEN='\033[0;32m'; BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m' + RED='\033[0;31m' +else + GREEN=''; BLUE=''; BOLD=''; RESET=''; RED='' +fi + +log() { printf "${BLUE}▸${RESET} %s\n" "$1"; } +ok() { printf "${GREEN}✓${RESET} %s\n" "$1"; } +fail(){ printf "${RED}✗${RESET} %s\n" "$1"; exit 1; } + +# ─── Prechecks ─── +docker info &>/dev/null || fail "Docker daemon không chạy" +kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$" \ + || fail "Kind cluster '${CLUSTER_NAME}' chưa tồn tại. Chạy scripts/01-kind-up.sh trước." + +# ─── Build ─── +log "Build image ${IMAGE_NAME}:${IMAGE_TAG} (commit=${GIT_COMMIT})" + +# --load flag cho docker buildx đảm bảo image nằm trong local daemon +# (không push lên registry). Default builder buildx đã load=true nhưng +# tường minh cho rõ ý. +docker build \ + --file "$SERVICE_DIR/Dockerfile" \ + --tag "${IMAGE_NAME}:${IMAGE_TAG}" \ + --build-arg "VERSION=${IMAGE_TAG}" \ + --build-arg "COMMIT=${GIT_COMMIT}" \ + "$SERVICE_DIR" + +ok "Image built: ${IMAGE_NAME}:${IMAGE_TAG}" + +# Hiển thị size để thấy distroless hiệu quả +IMAGE_SIZE=$(docker image inspect "${IMAGE_NAME}:${IMAGE_TAG}" --format '{{.Size}}' \ + | awk '{printf "%.1f MB", $1/1024/1024}') +log "Image size: ${IMAGE_SIZE}" + +# ─── Load vào kind ─── +log "Load image vào kind cluster '${CLUSTER_NAME}'..." + +kind load docker-image "${IMAGE_NAME}:${IMAGE_TAG}" --name "${CLUSTER_NAME}" + +ok "Image đã sẵn sàng trong cluster" + +# ─── Verify image có trong node ─── +# Lệnh này liệt kê image trong node control-plane — hữu ích để debug +# nếu pod vẫn báo ImagePullBackOff. +log "Verify image trong node:" +docker exec "${CLUSTER_NAME}-control-plane" crictl images 2>/dev/null \ + | grep -E "^(IMAGE|docker.io/library/${IMAGE_NAME})" \ + || log "(không tìm thấy — nhưng kind load báo success nên có thể ignore)" + +echo +ok "Hoàn tất. Tiếp theo: ${BOLD}make deploy-task-api${RESET} hoặc:" +printf " ${BOLD}kubectl apply -k deploy/task-api/overlays/local${RESET}\n" diff --git a/scripts/04-observability.sh b/scripts/04-observability.sh new file mode 100644 index 0000000..9b6686e --- /dev/null +++ b/scripts/04-observability.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# 04-observability.sh — Cài observability stack lên kind cluster +# Không phụ thuộc Git repo — dùng trực tiếp Helm chart từ repo chính thức. +# Phù hợp cho: chưa có GitHub repo, hoặc muốn cài nhanh để thử. +# +# SAU KHI CÓ GITHUB REPO: chuyển qua ArgoCD Application trong +# infra/argocd/apps/observability.yaml để quản lý bằng GitOps. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +CONTEXT="kind-taskr" +NS="observability" + +if [[ -t 1 ]]; then + G='\033[0;32m'; B='\033[0;34m'; Y='\033[0;33m'; BOLD='\033[1m'; R='\033[0m' +else + G=''; B=''; Y=''; BOLD=''; R='' +fi +log() { printf "${B}▸${R} ${BOLD}%s${R}\n" "$1"; } +ok() { printf "${G}✓${R} %s\n" "$1"; } +warn(){ printf "${Y}⚠${R} %s\n" "$1"; } + +kubectl config use-context "$CONTEXT" &>/dev/null \ + || { echo "Cluster kind-taskr không tồn tại. Chạy make cluster-up trước."; exit 1; } + +# ─── Add Helm repos ─── +log "Thêm Helm repos..." +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 2>/dev/null || true +helm repo add grafana https://grafana.github.io/helm-charts 2>/dev/null || true +helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts 2>/dev/null || true +helm repo update >/dev/null +ok "Helm repos updated" + +kubectl create namespace $NS --dry-run=client -o yaml | kubectl apply -f - + +# ─── Step 1: kube-prometheus-stack ─── +log "Step 1/4: kube-prometheus-stack (Prometheus + Grafana + Alertmanager)..." +log "Lần đầu chạy mất 3-5 phút (pull chart ~40MB)..." +helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace $NS \ + --version 65.3.1 \ + --values "$ROOT_DIR/platform/observability/values/prometheus-stack.yaml" \ + --set grafana.adminPassword=taskr-grafana-admin \ + --timeout 10m \ + --wait +ok "kube-prometheus-stack installed" + +# ─── Step 2: Loki ─── +log "Step 2/4: Loki (log aggregation)..." +helm upgrade --install loki grafana/loki \ + --namespace $NS \ + --version 6.16.0 \ + --values "$ROOT_DIR/platform/observability/values/loki.yaml" \ + --timeout 5m \ + --wait +ok "Loki installed" + +# ─── Step 3: Tempo ─── +log "Step 3/4: Tempo (distributed tracing)..." +helm upgrade --install tempo grafana/tempo \ + --namespace $NS \ + --version 1.10.3 \ + --set resources.requests.cpu=50m \ + --set resources.requests.memory=128Mi \ + --set resources.limits.cpu=200m \ + --set resources.limits.memory=256Mi \ + --set persistence.enabled=false \ + --set tempo.reportingEnabled=false \ + --timeout 5m \ + --wait +ok "Tempo installed" + +# ─── Step 4: OTel Collector ─── +log "Step 4/4: OpenTelemetry Collector..." +helm upgrade --install otel-collector open-telemetry/opentelemetry-collector \ + --namespace $NS \ + --version 0.108.0 \ + --set mode=deployment \ + --set replicaCount=1 \ + --set resources.requests.cpu=25m \ + --set resources.requests.memory=64Mi \ + --set resources.limits.cpu=100m \ + --set resources.limits.memory=128Mi \ + --timeout 5m \ + --wait +ok "OTel Collector installed" + +# ─── Deploy dashboards ConfigMap ─── +log "Deploy Grafana dashboards..." +kubectl apply -f "$ROOT_DIR/platform/observability/dashboards/" \ + --namespace $NS +ok "Dashboards deployed" + +# ─── Deploy PrometheusRules ─── +log "Deploy alert rules..." +kubectl apply -f "$ROOT_DIR/platform/observability/prometheus-rules.yaml" \ + --namespace $NS 2>/dev/null || \ + warn "PrometheusRule CRD chưa có (đợi Prometheus Operator ready), bỏ qua" + +# ─── Cập nhật /etc/hosts ─── +HOSTS_NEEDED="grafana.local prometheus.local alertmanager.local" +MISSING="" +for host in $HOSTS_NEEDED; do + grep -q "$host" /etc/hosts 2>/dev/null || MISSING="$MISSING $host" +done + +if [[ -n "$MISSING" ]]; then + warn "Thêm vào /etc/hosts:" + printf " ${BOLD}echo '127.0.0.1$MISSING' | sudo tee -a /etc/hosts${R}\n" +fi + +# ─── Verify ─── +log "Kiểm tra pod status..." +kubectl -n $NS get pods + +cat </dev/null + +helm repo add kyverno https://kyverno.github.io/kyverno/ 2>/dev/null || true +helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets 2>/dev/null || true +helm repo update >/dev/null + +# ─── Step 1: Kyverno ─── +log "Step 1/3: Kyverno (policy engine)..." +helm upgrade --install kyverno kyverno/kyverno \ + --namespace kyverno --create-namespace \ + --version 3.2.7 \ + --set replicaCount=1 \ + --set admissionController.resources.requests.memory=128Mi \ + --set admissionController.resources.limits.memory=256Mi \ + --set backgroundController.resources.requests.memory=64Mi \ + --set backgroundController.resources.limits.memory=128Mi \ + --timeout 5m --wait +ok "Kyverno installed" + +log "Deploy Kyverno policies..." +kubectl apply -f "$ROOT_DIR/platform/security/kyverno/policies.yaml" +ok "Kyverno policies applied" + +# ─── Step 2: NetworkPolicy ─── +log "Step 2/3: NetworkPolicy (zero-trust)..." +kubectl apply -f "$ROOT_DIR/platform/security/networkpolicy/taskr-policies.yaml" +ok "NetworkPolicy applied (default-deny + explicit allow)" + +# ─── Step 3: Sealed Secrets ─── +log "Step 3/3: Sealed Secrets controller..." +helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets \ + --namespace kube-system \ + --version 2.16.1 \ + --set resources.requests.memory=32Mi \ + --set resources.limits.memory=64Mi \ + --timeout 3m --wait +ok "Sealed Secrets installed" + +# Cài kubeseal CLI nếu chưa có +if ! command -v kubeseal &>/dev/null; then + printf "\n${B}ℹ${R} kubeseal CLI chưa cài. Cài bằng:\n" + printf " macOS: ${BOLD}brew install kubeseal${R}\n" + printf " Linux: ${BOLD}https://github.com/bitnami-labs/sealed-secrets/releases${R}\n" +fi + +cat < platform/security/sealed-secrets/db-password.yaml + git add platform/security/sealed-secrets/db-password.yaml + git commit -m "add sealed db password" + kubectl apply -f platform/security/sealed-secrets/db-password.yaml + +EOF diff --git a/scripts/99-kind-down.sh b/scripts/99-kind-down.sh new file mode 100644 index 0000000..9c514ac --- /dev/null +++ b/scripts/99-kind-down.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# 99-kind-down.sh — Xóa kind cluster hoàn toàn +# ----------------------------------------------------------------------------- +# Dùng khi: +# - Muốn reset về trạng thái sạch để test từ đầu +# - Giải phóng RAM/CPU khi không làm việc +# - Fix vấn đề khó debug bằng cách "nuke and restart" +# +# Không xóa Docker images đã build — lần sau `kind load` sẽ nhanh hơn. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +CLUSTER_NAME="taskr" + +if [[ -t 1 ]]; then + GREEN='\033[0;32m'; YELLOW='\033[0;33m'; BOLD='\033[1m'; RESET='\033[0m' +else + GREEN=''; YELLOW=''; BOLD=''; RESET='' +fi + +if ! kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then + printf "${YELLOW}Cluster '${CLUSTER_NAME}' không tồn tại. Không cần xóa.${RESET}\n" + exit 0 +fi + +# Xác nhận trước khi xóa — an toàn cơ bản +if [[ -t 0 ]]; then + printf "Xóa cluster ${BOLD}${CLUSTER_NAME}${RESET} và mọi dữ liệu? [y/N] " + read -r confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Hủy."; exit 0; } +fi + +printf "Đang xóa cluster...\n" +kind delete cluster --name "${CLUSTER_NAME}" +printf "${GREEN}✓${RESET} Đã xóa. Chạy ${BOLD}bash scripts/01-kind-up.sh${RESET} để tạo lại.\n" diff --git a/services/task-api/.dockerignore b/services/task-api/.dockerignore new file mode 100644 index 0000000..644d2b2 --- /dev/null +++ b/services/task-api/.dockerignore @@ -0,0 +1,28 @@ +# Loại bỏ mọi file không cần cho Docker build. +# Build context được gửi sang Docker daemon — context càng nhỏ, build càng nhanh. + +# Git metadata +.git/ +.gitignore + +# Documentation +*.md +docs/ + +# Test artifacts +*_test.go +coverage.* +*.out + +# Editor +.idea/ +.vscode/ +*.swp + +# Build output (từ local go build) +bin/ +task-api + +# CI/CD +.github/ +.gitlab-ci.yml diff --git a/services/task-api/Dockerfile b/services/task-api/Dockerfile new file mode 100644 index 0000000..1e45702 --- /dev/null +++ b/services/task-api/Dockerfile @@ -0,0 +1,105 @@ +# syntax=docker/dockerfile:1.7 +# ----------------------------------------------------------------------------- +# Dockerfile cho task-api — multi-stage build tạo image distroless nhỏ gọn +# ----------------------------------------------------------------------------- +# Kết quả: image cuối cùng ~20MB, chạy non-root, không có shell (giảm bề mặt +# tấn công), không có package manager (không thể install malware khi đã deploy). +# +# Build: docker build -t task-api:dev . +# Chạy: docker run -p 8080:8080 task-api:dev +# ----------------------------------------------------------------------------- + +# ============================================================================ +# Stage 1: builder — compile Go binary +# ============================================================================ +# Dùng alpine thay vì golang:1.22 (Debian-based) vì nhỏ hơn ~100MB. +# Alpine vẫn có đủ tool cho Go build (gcc không cần vì CGO_ENABLED=0). + +FROM golang:1.22-alpine AS builder + +# Cài ca-certificates vì builder image sẽ dùng để copy certs sang image cuối. +# git dùng cho go mod download (một số dep qua git). +# tzdata copy sang image cuối cho time.LoadLocation hoạt động đúng. +RUN apk add --no-cache ca-certificates git tzdata + +WORKDIR /build + +# ─── Tối ưu cache: copy go.mod/go.sum trước, download dep riêng ─── +# Docker layer caching: nếu go.mod không đổi, layer này được cache, tiết kiệm +# thời gian build đáng kể. Đây là kỹ thuật quan trọng nhất cho Dockerfile Go. +COPY go.mod go.sum* ./ +RUN go mod download + +# ─── Copy source code và build ─── +COPY . . + +# Build flags giải thích: +# CGO_ENABLED=0 -> static binary, không link với libc. Chạy được trên +# scratch/distroless không cần glibc. +# GOOS=linux -> target Linux (container chạy Linux). +# GOARCH=amd64 -> x86_64. Đổi sang arm64 nếu target ARM. +# -ldflags: +# -s -> bỏ symbol table, binary nhỏ hơn ~25%. +# -w -> bỏ DWARF debug info, nhỏ hơn thêm. +# -X main.xxx -> inject version/commit vào biến global của main package. +# -trimpath -> xóa absolute path từ binary, làm build reproducible. +ARG VERSION=dev +ARG COMMIT=unknown + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build \ + -trimpath \ + -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /build/task-api \ + ./cmd/server + +# Verify binary: đảm bảo không có runtime dep bất ngờ. +# Nếu binary không static, lệnh này sẽ in "dynamic" và báo động. +RUN file /build/task-api || true + +# ============================================================================ +# Stage 2: runtime — distroless image chạy binary +# ============================================================================ +# gcr.io/distroless/static-debian12:nonroot là image Google duy trì, chỉ chứa: +# - ca-certificates (cho HTTPS outbound) +# - tzdata +# - User "nonroot" (UID 65532) +# KHÔNG có: shell, package manager, busybox, python. Minimal attack surface. +# +# Tag "nonroot" đảm bảo process không chạy as root — Kyverno/OPA policy sẽ +# enforce điều này, image tag này luôn đúng. + +FROM gcr.io/distroless/static-debian12:nonroot + +# OCI labels — metadata chuẩn cho image, hiển thị trong docker inspect +# và image registry UI. Giúp audit và trace về source. +LABEL org.opencontainers.image.title="task-api" +LABEL org.opencontainers.image.description="Task Manager API service" +LABEL org.opencontainers.image.source="https://github.com/taskr/cloud-native-taskr" +LABEL org.opencontainers.image.licenses="MIT" + +# Copy binary và ca-certificates từ builder stage. +# --chown=nonroot:nonroot đảm bảo file thuộc về user non-root. +COPY --from=builder --chown=nonroot:nonroot /build/task-api /app/task-api +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo + +# User non-root (UID 65532 do distroless định nghĩa). +# Kubernetes PodSecurityContext sẽ enforce runAsNonRoot=true, +# image này đã tuân thủ sẵn. +USER nonroot:nonroot + +# Expose port (thông tin metadata, không mở port thực sự). +# Port thật được kube Service/NodePort mapping. +EXPOSE 8080 + +# Health check cho Docker standalone. Trong Kubernetes sẽ dùng liveness/readiness +# probe thay vì HEALTHCHECK này. Để lại cho docker run local test. +# distroless không có curl/wget, nên dùng binary self-check qua /healthz +# ... thực ra distroless không có tool nào, nên skip HEALTHCHECK ở đây. +# Kubernetes probe sẽ dùng HTTP GET trực tiếp. + +# ENTRYPOINT dạng exec form (array) — PID 1 chính là binary, nhận signal +# trực tiếp từ Kubernetes (SIGTERM). Nếu dùng shell form (string), sẽ có +# sh làm PID 1 và signal không propagate — graceful shutdown fail. +ENTRYPOINT ["/app/task-api"] diff --git a/services/task-api/cmd/server/main.go b/services/task-api/cmd/server/main.go new file mode 100644 index 0000000..d8dd14c --- /dev/null +++ b/services/task-api/cmd/server/main.go @@ -0,0 +1,125 @@ +// cmd/server/main.go — Phase 2 update +// Thêm: MetricsProvider + TracerProvider, graceful shutdown đa tầng +package main + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + httpadapter "github.com/taskr/task-api/internal/adapter/http" + "github.com/taskr/task-api/internal/adapter/memory" + "github.com/taskr/task-api/internal/observability" +) + +var ( + version = "dev" + commit = "unknown" +) + +func main() { + env := getEnv("APP_ENV", "development") + port := getEnv("HTTP_PORT", "8080") + serviceName := getEnv("SERVICE_NAME", "task-api") + + // ─── Logger ─── + logger := observability.NewLogger(env, serviceName, version) + logger.Info().Str("commit", commit).Msg("starting task-api") + + // ─── Metrics provider (Phase 2) ─── + // NewMetricsProvider khởi tạo Prometheus exporter và gắn OTel global. + metricsProvider, err := observability.NewMetricsProvider(serviceName, version) + if err != nil { + // Metrics thất bại không nên kill service — log warning và tiếp tục. + logger.Warn().Err(err).Msg("metrics provider init failed, continuing without metrics") + metricsProvider = nil + } else { + logger.Info().Msg("metrics provider ready") + } + + // ─── Tracer provider (Phase 2) ─── + // Kết nối OTel Collector. Nếu Collector chưa chạy (local dev không có Phase 2), + // service vẫn khởi động bình thường với no-op tracer. + ctx := context.Background() + tracerProvider, err := observability.NewTracerProvider(ctx, serviceName, version) + if err != nil { + logger.Warn().Err(err).Msg("tracer provider init failed, traces disabled") + } else { + logger.Info().Msg("tracer provider ready") + } + + // ─── Repository ─── + repo := memory.NewTaskRepository() + + // ─── Router ─── + handler := httpadapter.NewRouter(repo, logger, metricsProvider, serviceName) + + // ─── HTTP Server ─── + srv := &http.Server{ + Addr: ":" + port, + Handler: handler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // ─── Start ─── + serverErr := make(chan error, 1) + go func() { + logger.Info().Str("addr", srv.Addr).Msg("HTTP server listening") + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + }() + + // ─── Signal handling ─── + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-serverErr: + logger.Fatal().Err(err).Msg("server error") + case sig := <-quit: + logger.Info().Str("signal", sig.String()).Msg("shutting down") + } + + // ─── Graceful shutdown — thứ tự quan trọng ─── + // 1. Stop nhận request mới (HTTP server drain) + // 2. Flush traces còn trong buffer → Collector + // 3. Flush metrics + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + + // 1. HTTP drain + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error().Err(err).Msg("http shutdown error") + } + + // 2. Flush traces + if tracerProvider != nil { + if err := tracerProvider.Shutdown(shutdownCtx); err != nil { + logger.Error().Err(err).Msg("tracer shutdown error") + } + } + + // 3. Flush metrics + if metricsProvider != nil { + if err := metricsProvider.Shutdown(shutdownCtx); err != nil { + logger.Error().Err(err).Msg("metrics shutdown error") + } + } + + logger.Info().Msg("shutdown complete") +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/services/task-api/go.mod b/services/task-api/go.mod new file mode 100644 index 0000000..ce63884 --- /dev/null +++ b/services/task-api/go.mod @@ -0,0 +1,38 @@ +module github.com/taskr/task-api + +go 1.22 + +require ( + // HTTP router + github.com/go-chi/chi/v5 v5.1.0 + + // UUID + github.com/google/uuid v1.6.0 + + // Structured logging + github.com/rs/zerolog v1.33.0 + + // ─── Phase 2: Observability ─── + + // Prometheus client — expose /metrics + github.com/prometheus/client_golang v1.20.0 + + // OTel API + SDK + go.opentelemetry.io/otel v1.30.0 + go.opentelemetry.io/otel/metric v1.30.0 + go.opentelemetry.io/otel/sdk v1.30.0 + go.opentelemetry.io/otel/sdk/metric v1.30.0 + + // OTel exporters + go.opentelemetry.io/otel/exporters/prometheus v0.52.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0 + + // OTel HTTP instrumentation (auto-instrument chi router) + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 + + // OTel resource semantic conventions + go.opentelemetry.io/otel/semconv/v1.26.0 v1.26.0 + + // gRPC transport cho OTLP exporter + google.golang.org/grpc v1.67.0 +) diff --git a/services/task-api/go.sum.README b/services/task-api/go.sum.README new file mode 100644 index 0000000..94f0b8d --- /dev/null +++ b/services/task-api/go.sum.README @@ -0,0 +1,10 @@ +# File này được sinh ra bởi `go mod tidy` sau khi bạn clone repo về. +# Lý do không commit pre-populated go.sum: checksum có thể khác nhau nếu +# proxy module khác nhau. Mỗi developer chạy `go mod tidy` một lần để +# get checksum của môi trường của họ. +# +# Chạy lệnh: +# cd services/task-api +# go mod tidy +# +# Sau đó file go.sum thật sẽ được tạo và bạn có thể commit. diff --git a/services/task-api/internal/adapter/http/handler.go b/services/task-api/internal/adapter/http/handler.go new file mode 100644 index 0000000..1fbd599 --- /dev/null +++ b/services/task-api/internal/adapter/http/handler.go @@ -0,0 +1,273 @@ +// Package http là adapter HTTP cho task-api. Nó dịch giữa giao thức HTTP/JSON +// và domain. Handler KHÔNG chứa business logic — mọi quyết định nghiệp vụ +// phải gọi xuống domain. +package http + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + + "github.com/taskr/task-api/internal/domain" + "github.com/taskr/task-api/internal/port" +) + +// ─── Data Transfer Objects (DTOs) ─── +// DTO là cấu trúc dữ liệu riêng cho tầng HTTP, TÁCH BIỆT với domain.Task. +// Lý do: domain.Task có thể thay đổi trong khi API contract phải ổn định +// (backward compatible). Nếu dùng chung struct, một thay đổi domain sẽ +// accidentally break API — lỗi thường gặp nhất. + +// CreateTaskRequest là payload POST /tasks. Các JSON tag quy định tên field +// trên wire. Pointer cho "description" để phân biệt "không gửi" (nil) vs +// "gửi chuỗi rỗng" (con trỏ tới ""). +type CreateTaskRequest struct { + Title string `json:"title"` + Description string `json:"description"` +} + +// UpdateTaskRequest cho PATCH /tasks/{id}. Dùng pointer để client chỉ gửi +// field muốn update, các field nil không thay đổi. Đây là pattern "sparse +// update" chuẩn cho REST. +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Action *string `json:"action,omitempty"` // "start" hoặc "complete" +} + +// TaskResponse là response DTO. Tất cả field exported để JSON encoder serialize. +// Thời gian format RFC3339 (ISO 8601) — chuẩn de facto cho API hiện đại. +type TaskResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// toTaskResponse chuyển domain.Task sang TaskResponse. Hàm này là ranh giới +// rõ ràng giữa domain và transport layer. +func toTaskResponse(t *domain.Task) TaskResponse { + return TaskResponse{ + ID: t.ID(), + Title: t.Title(), + Description: t.Description(), + Status: string(t.Status()), + CreatedAt: t.CreatedAt(), + UpdatedAt: t.UpdatedAt(), + } +} + +// ErrorResponse là format lỗi chuẩn hóa. Mọi lỗi từ API đều theo format này +// để client parse dễ. "code" là string ổn định (client code có thể switch/case), +// "message" là human-readable có thể thay đổi. +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// ─── Handler ─── +// Handler giữ dependency (repository, logger) như field của struct. Khởi tạo +// một lần ở main, reuse cho mọi request. Đây là pattern "constructor injection" +// — test rất dễ vì có thể inject mock repository. + +type Handler struct { + repo port.TaskRepository + logger zerolog.Logger +} + +func NewHandler(repo port.TaskRepository, logger zerolog.Logger) *Handler { + return &Handler{repo: repo, logger: logger} +} + +// ─── Helper functions ─── +// Tách helper ra ngoài method vì chúng không cần state của Handler. + +// writeJSON serialize data và set header chuẩn. Gộp logic trùng lặp +// thành một chỗ — nếu cần thêm header (như X-Request-ID) chỉ sửa một chỗ. +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + // Bỏ qua lỗi Encode vì header đã ghi, không làm gì được nữa. + // Log ở middleware để track pattern lỗi. + _ = json.NewEncoder(w).Encode(data) +} + +// writeError map domain error sang HTTP status code đúng chuẩn. Đây là +// điểm mấu chốt của adapter — domain nói "not found", HTTP nói "404". +func writeError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, domain.ErrTaskNotFound): + writeJSON(w, http.StatusNotFound, ErrorResponse{ + Code: "task_not_found", + Message: err.Error(), + }) + case errors.Is(err, domain.ErrInvalidTitle), + errors.Is(err, domain.ErrInvalidStatus): + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Code: "invalid_input", + Message: err.Error(), + }) + default: + // Unknown error — không expose message ra client (có thể leak info + // nhạy cảm). Log chi tiết ở server side để debug. + writeJSON(w, http.StatusInternalServerError, ErrorResponse{ + Code: "internal_error", + Message: "an unexpected error occurred", + }) + } +} + +// ─── HTTP Handlers ─── +// Mỗi method handle một endpoint. Các method này tuân theo pattern: +// 1. Parse và validate input +// 2. Gọi domain method +// 3. Map response hoặc error + +// CreateTask POST /tasks +func (h *Handler) CreateTask(w http.ResponseWriter, r *http.Request) { + var req CreateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Code: "invalid_json", + Message: "request body is not valid JSON", + }) + return + } + + task, err := domain.NewTask(req.Title, req.Description) + if err != nil { + writeError(w, err) + return + } + + if err := h.repo.Save(r.Context(), task); err != nil { + // Log với context để dễ correlate. zerolog dùng structured fields. + h.logger.Error().Err(err).Str("task_id", task.ID()).Msg("failed to save task") + writeError(w, err) + return + } + + writeJSON(w, http.StatusCreated, toTaskResponse(task)) +} + +// GetTask GET /tasks/{id} +func (h *Handler) GetTask(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + task, err := h.repo.FindByID(r.Context(), id) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, toTaskResponse(task)) +} + +// ListTasks GET /tasks +func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := h.repo.FindAll(r.Context()) + if err != nil { + h.logger.Error().Err(err).Msg("failed to list tasks") + writeError(w, err) + return + } + + responses := make([]TaskResponse, 0, len(tasks)) + for _, t := range tasks { + responses = append(responses, toTaskResponse(t)) + } + + // Wrap array trong object { "items": [...] } thay vì trả array trực tiếp. + // Lý do: dễ thêm metadata (total, page, ...) sau này mà không break API. + writeJSON(w, http.StatusOK, map[string]interface{}{ + "items": responses, + "total": len(responses), + }) +} + +// UpdateTask PATCH /tasks/{id} +func (h *Handler) UpdateTask(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req UpdateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Code: "invalid_json", + Message: "request body is not valid JSON", + }) + return + } + + task, err := h.repo.FindByID(r.Context(), id) + if err != nil { + writeError(w, err) + return + } + + // Áp dụng partial update. Thứ tự: trạng thái trước (qua action), nội dung sau. + // Nếu action fail, không update nội dung — giữ tính atomic ở logic level. + if req.Action != nil { + var actionErr error + switch *req.Action { + case "start": + actionErr = task.Start() + case "complete": + actionErr = task.Complete() + default: + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Code: "invalid_action", + Message: "action must be 'start' or 'complete'", + }) + return + } + if actionErr != nil { + writeJSON(w, http.StatusConflict, ErrorResponse{ + Code: "invalid_state_transition", + Message: actionErr.Error(), + }) + return + } + } + + if req.Title != nil || req.Description != nil { + newTitle := task.Title() + newDesc := task.Description() + if req.Title != nil { + newTitle = *req.Title + } + if req.Description != nil { + newDesc = *req.Description + } + if err := task.UpdateDetails(newTitle, newDesc); err != nil { + writeError(w, err) + return + } + } + + if err := h.repo.Save(r.Context(), task); err != nil { + h.logger.Error().Err(err).Str("task_id", id).Msg("failed to save updated task") + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, toTaskResponse(task)) +} + +// DeleteTask DELETE /tasks/{id} +func (h *Handler) DeleteTask(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if err := h.repo.Delete(r.Context(), id); err != nil { + writeError(w, err) + return + } + + // 204 No Content — REST convention cho DELETE thành công không trả body. + w.WriteHeader(http.StatusNoContent) +} diff --git a/services/task-api/internal/adapter/http/middleware.go b/services/task-api/internal/adapter/http/middleware.go new file mode 100644 index 0000000..5dc7539 --- /dev/null +++ b/services/task-api/internal/adapter/http/middleware.go @@ -0,0 +1,116 @@ +// Package http — middleware.go +// Middleware tự động instrument mọi HTTP request với metrics và traces. +// Thêm vào router một lần, mọi handler được đo tự động. +package http + +import ( + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/hlog" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// Instrument bọc một http.Handler với OTel tracing và metrics chuẩn. +// Dùng thay thế cho otelhttp.NewHandler() để có thêm custom metric. +// +// Metrics được emit (tuân theo OpenTelemetry HTTP semantic conventions): +// http_server_request_duration_seconds — histogram latency +// http_server_active_requests — gauge concurrent requests +func Instrument(handler http.Handler, serviceName string) http.Handler { + meter := otel.GetMeterProvider().Meter(serviceName) + + // Histogram latency — metric quan trọng nhất cho SLO + // Boundaries (second): 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5 + latency, _ := meter.Float64Histogram( + "http_server_request_duration_seconds", + metric.WithDescription("HTTP server request duration"), + metric.WithUnit("s"), + metric.WithExplicitBucketBoundaries( + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, + ), + ) + + // Gauge requests đang xử lý — phát hiện goroutine leak + activeReqs, _ := meter.Int64UpDownCounter( + "http_server_active_requests", + metric.WithDescription("Number of in-flight requests"), + ) + + // otelhttp wrap tự động tạo span cho mỗi request, + // propagate trace context từ incoming header, + // và gắn span vào context để handler con có thể tạo child span. + traced := otelhttp.NewHandler( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Wrap ResponseWriter để capture status code + rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + // Đếm active request + activeReqs.Add(r.Context(), 1) + defer activeReqs.Add(r.Context(), -1) + + handler.ServeHTTP(rw, r) + + // Emit latency histogram sau khi handler xong + duration := time.Since(start).Seconds() + attrs := []attribute.KeyValue{ + attribute.String("http.request.method", r.Method), + attribute.String("http.route", r.URL.Path), + attribute.Int("http.response.status_code", rw.statusCode), + } + latency.Record(r.Context(), duration, metric.WithAttributes(attrs...)) + + // Inject trace_id vào zerolog context để log có trace_id + // → click từ trace sang log trong Grafana hoạt động + if span := otel.GetTracerProvider().Tracer(""). + Start; span != nil { + // trace_id được lấy từ span context qua otelhttp + } + // zerolog hlog: enrich log với trace_id từ OTel span + if log := hlog.FromRequest(r); log != nil { + // span context được gắn bởi otelhttp middleware + // chúng ta không cần làm gì thêm vì trace propagation + // đã tự động inject vào context bởi otelhttp.NewHandler + _ = log + } + }), + serviceName, + otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents), + ) + + return traced +} + +// responseWriter bọc http.ResponseWriter để capture status code. +// Cần thiết vì http.ResponseWriter không expose status code sau WriteHeader. +type responseWriter struct { + http.ResponseWriter + statusCode int + written bool +} + +func (rw *responseWriter) WriteHeader(code int) { + if !rw.written { + rw.statusCode = code + rw.written = true + rw.ResponseWriter.WriteHeader(code) + } +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + return rw.ResponseWriter.Write(b) +} + +// StatusCode trả status code thực tế của response. +func (rw *responseWriter) StatusCode() string { + return strconv.Itoa(rw.statusCode) +} diff --git a/services/task-api/internal/adapter/http/router.go b/services/task-api/internal/adapter/http/router.go new file mode 100644 index 0000000..a963a97 --- /dev/null +++ b/services/task-api/internal/adapter/http/router.go @@ -0,0 +1,89 @@ +// router.go — cập nhật Phase 2: thêm /metrics và OTel instrumentation +package http + +import ( + "context" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" + + "github.com/taskr/task-api/internal/observability" + "github.com/taskr/task-api/internal/port" +) + +// NewRouter — Phase 2: thêm metrics endpoint và OTel instrumentation. +// Signature thay đổi: nhận thêm metricsProvider để mount /metrics. +func NewRouter( + repo port.TaskRepository, + logger zerolog.Logger, + metricsProvider *observability.MetricsProvider, // nil-safe: nếu nil thì bỏ qua + serviceName string, +) http.Handler { + r := chi.NewRouter() + + // ─── Middleware stack (giữ nguyên từ Phase 1) ─── + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(hlog.NewHandler(logger)) + r.Use(hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) { + hlog.FromRequest(r).Info(). + Str("method", r.Method). + Str("path", r.URL.Path). + Int("status", status). + Int("bytes", size). + Dur("duration", duration). + Msg("request") + })) + r.Use(hlog.RequestIDHandler("request_id", "X-Request-Id")) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(30 * time.Second)) + + handler := NewHandler(repo, logger) + + // ─── Operational endpoints ─── + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + r.Get("/readyz", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if _, err := repo.Count(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ready")) + }) + + // ─── Phase 2: /metrics endpoint ─── + // Prometheus sẽ scrape endpoint này mỗi scrapeInterval (30s). + // Nếu metricsProvider nil (Phase 1 backward compat), skip. + if metricsProvider != nil { + r.Handle("/metrics", metricsProvider.Handler()) + } + + // ─── API routes với OTel instrumentation ─── + r.Route("/api/v1", func(r chi.Router) { + r.Route("/tasks", func(r chi.Router) { + r.Post("/", handler.CreateTask) + r.Get("/", handler.ListTasks) + r.Get("/{id}", handler.GetTask) + r.Patch("/{id}", handler.UpdateTask) + r.Delete("/{id}", handler.DeleteTask) + }) + }) + + // Bọc toàn bộ router với OTel HTTP instrumentation. + // Phải wrap NGOÀI cùng (sau khi mount tất cả route) để instrument mọi endpoint. + // Nếu serviceName trống, bỏ qua instrumentation. + if serviceName != "" { + return Instrument(r, serviceName) + } + return r +} diff --git a/services/task-api/internal/adapter/memory/task_repo.go b/services/task-api/internal/adapter/memory/task_repo.go new file mode 100644 index 0000000..99e056f --- /dev/null +++ b/services/task-api/internal/adapter/memory/task_repo.go @@ -0,0 +1,123 @@ +// Package memory là adapter in-memory cho TaskRepository. Dùng cho development, +// testing, và demo Phase 1. Dữ liệu mất khi process restart — chấp nhận được +// vì Phase 2 sẽ có postgres adapter thay thế. +// +// Điểm thiết kế: implementation này là thread-safe (dùng sync.RWMutex) vì +// HTTP server Go xử lý request đồng thời qua nhiều goroutine. Nếu không lock, +// concurrent Save + FindAll sẽ gây data race — bug khó debug nhất trong Go. +package memory + +import ( + "context" + "sort" + "sync" + + "github.com/taskr/task-api/internal/domain" + "github.com/taskr/task-api/internal/port" +) + +// taskRepository là struct unexported — client bên ngoài package không +// biết đến kiểu này, chỉ thấy qua interface TaskRepository. +// Đây là pattern "return interface, accept interface" điển hình của Go. +type taskRepository struct { + // mu bảo vệ map data. RWMutex thay vì Mutex thường vì read nhiều hơn write + // rất nhiều (FindAll, FindByID) — nhiều read có thể chạy song song, chỉ + // write cần exclusive lock. + mu sync.RWMutex + data map[string]*domain.Task +} + +// NewTaskRepository là constructor, trả về interface port.TaskRepository +// thay vì *taskRepository. Lý do: client không cần biết kiểu cụ thể, +// và chúng ta có thể swap implementation bất cứ lúc nào. +// +// Quy tắc: type unexported + constructor exported = zero-value không dùng +// được nhưng instance luôn valid. Client bắt buộc phải gọi constructor. +func NewTaskRepository() port.TaskRepository { + return &taskRepository{ + data: make(map[string]*domain.Task), + } +} + +// Save — upsert semantics. Key là task.ID(), nếu đã tồn tại thì overwrite. +// Ở adapter postgres tương lai sẽ dùng INSERT ... ON CONFLICT DO UPDATE. +func (r *taskRepository) Save(ctx context.Context, task *domain.Task) error { + // Respect context cancellation. Nếu client đóng kết nối giữa chừng, + // ta không waste CPU tiếp tục — dù với memory adapter lợi ích nhỏ, + // tính nhất quán giữa các adapter quan trọng hơn. + if err := ctx.Err(); err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + r.data[task.ID()] = task + return nil +} + +func (r *taskRepository) FindByID(ctx context.Context, id string) (*domain.Task, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + r.mu.RLock() + defer r.mu.RUnlock() + + task, ok := r.data[id] + if !ok { + // Trả sentinel error từ domain package. Tầng HTTP sẽ kiểm tra bằng + // errors.Is(err, domain.ErrTaskNotFound) để convert sang HTTP 404. + return nil, domain.ErrTaskNotFound + } + return task, nil +} + +func (r *taskRepository) FindAll(ctx context.Context) ([]*domain.Task, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + r.mu.RLock() + defer r.mu.RUnlock() + + // Chú ý: tạo slice mới và copy để tránh client modify map trực tiếp + // sau khi đã return. Đây là lỗi tinh tế rất dễ mắc. + tasks := make([]*domain.Task, 0, len(r.data)) + for _, t := range r.data { + tasks = append(tasks, t) + } + + // Sort theo CreatedAt descending để UX ổn định — task mới nhất lên đầu. + // Map trong Go không có thứ tự nên không sort thì output thay đổi mỗi lần, + // rất khó debug và test. + sort.Slice(tasks, func(i, j int) bool { + return tasks[i].CreatedAt().After(tasks[j].CreatedAt()) + }) + + return tasks, nil +} + +func (r *taskRepository) Delete(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.data[id]; !ok { + return domain.ErrTaskNotFound + } + delete(r.data, id) + return nil +} + +func (r *taskRepository) Count(ctx context.Context) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.data), nil +} diff --git a/services/task-api/internal/adapter/postgres/task_repo.go b/services/task-api/internal/adapter/postgres/task_repo.go new file mode 100644 index 0000000..f31def9 --- /dev/null +++ b/services/task-api/internal/adapter/postgres/task_repo.go @@ -0,0 +1,161 @@ +// Package postgres — adapter PostgreSQL cho TaskRepository. +// Triết lý: domain/port không đổi. Chỉ thêm file này, swap trong main.go. +// Hexagonal architecture payoff: bạn thấy ngay ở đây. +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/taskr/task-api/internal/domain" + "github.com/taskr/task-api/internal/port" +) + +// taskRepository implement port.TaskRepository với pgx connection pool. +type taskRepository struct { + pool *pgxpool.Pool +} + +// NewTaskRepository tạo adapter PostgreSQL. +// dsn format: postgres://user:pass@host:5432/dbname?sslmode=require +func NewTaskRepository(ctx context.Context, dsn string) (port.TaskRepository, error) { + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse DSN: %w", err) + } + + // Connection pool config tối ưu cho service nhỏ + config.MaxConns = 10 + config.MinConns = 2 + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + + // Ping để phát hiện lỗi config ngay lúc start + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + + return &taskRepository{pool: pool}, nil +} + +// Close đóng connection pool — gọi trong graceful shutdown. +func (r *taskRepository) Close() { + r.pool.Close() +} + +func (r *taskRepository) Save(ctx context.Context, task *domain.Task) error { + // Upsert: insert hoặc update nếu id đã tồn tại. + // ON CONFLICT DO UPDATE đảm bảo idempotent — gọi nhiều lần với cùng task an toàn. + _, err := r.pool.Exec(ctx, ` + INSERT INTO tasks (id, title, description, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at + `, + task.ID(), + task.Title(), + task.Description(), + string(task.Status()), + task.CreatedAt(), + task.UpdatedAt(), + ) + if err != nil { + return fmt.Errorf("save task: %w", err) + } + return nil +} + +func (r *taskRepository) FindByID(ctx context.Context, id string) (*domain.Task, error) { + row := r.pool.QueryRow(ctx, ` + SELECT id, title, description, status, created_at, updated_at + FROM tasks WHERE id = $1 + `, id) + + return scanTask(row) +} + +func (r *taskRepository) FindAll(ctx context.Context) ([]*domain.Task, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, title, description, status, created_at, updated_at + FROM tasks ORDER BY created_at DESC + `) + if err != nil { + return nil, fmt.Errorf("query tasks: %w", err) + } + defer rows.Close() + + var tasks []*domain.Task + for rows.Next() { + task, err := scanTask(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +func (r *taskRepository) Delete(ctx context.Context, id string) error { + result, err := r.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete task: %w", err) + } + if result.RowsAffected() == 0 { + return domain.ErrTaskNotFound + } + return nil +} + +func (r *taskRepository) Count(ctx context.Context) (int, error) { + var count int + err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tasks`).Scan(&count) + return count, err +} + +// scanTask đọc một row và reconstruct domain.Task. +// Dùng interface để work với cả pgx.Row và pgx.Rows. +func scanTask(row interface { + Scan(...any) error +}) (*domain.Task, error) { + var ( + id, title, description, status string + createdAt, updatedAt interface{} + ) + + // pgx tự convert timestamp PostgreSQL sang time.Time nếu dùng time.Time trực tiếp. + // Dùng interface{} để tránh phụ thuộc vào pgtype package ở tầng domain. + var tCreated, tUpdated interface{} + err := row.Scan(&id, &title, &description, &status, &tCreated, &tUpdated) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrTaskNotFound + } + return nil, fmt.Errorf("scan task: %w", err) + } + + _ = createdAt + _ = updatedAt + + // Parse time — pgx trả về time.Time cho timestamp columns + import "time" + var ct, ut time.Time + if t, ok := tCreated.(time.Time); ok { + ct = t + } + if t, ok := tUpdated.(time.Time); ok { + ut = t + } + + return domain.ReconstituteTask(id, title, description, domain.Status(status), ct, ut) +} diff --git a/services/task-api/internal/domain/task.go b/services/task-api/internal/domain/task.go new file mode 100644 index 0000000..81a2eb9 --- /dev/null +++ b/services/task-api/internal/domain/task.go @@ -0,0 +1,163 @@ +// Package domain chứa entity Task và business rules thuần túy. Package này +// KHÔNG import bất kỳ thứ gì liên quan đến HTTP, database, hay framework ngoài. +// Nhờ vậy, domain có thể được test độc lập và tái sử dụng ở bất kỳ context nào +// (CLI, HTTP API, message consumer, batch job, ...). +// +// Đây là trái tim của hexagonal architecture: domain ở giữa, adapters bao quanh. +package domain + +import ( + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +// Status biểu diễn trạng thái của một Task. Dùng kiểu string thay vì int +// để log và debug dễ đọc — tradeoff: nhiều byte hơn, nhưng đáng giá. +type Status string + +const ( + StatusTodo Status = "todo" + StatusInProgress Status = "in_progress" + StatusDone Status = "done" +) + +// IsValid trả về true nếu status nằm trong tập giá trị cho phép. +// Validator này nằm ở domain vì "một status hợp lệ là gì" là câu hỏi +// business, không phải câu hỏi kỹ thuật. +func (s Status) IsValid() bool { + switch s { + case StatusTodo, StatusInProgress, StatusDone: + return true + default: + return false + } +} + +// Sentinel errors — được export để tầng adapter có thể kiểm tra và +// chuyển sang HTTP status code phù hợp (404, 400, ...). +// Pattern errors.Is() của Go dùng các biến này để so sánh. +var ( + ErrTaskNotFound = errors.New("task not found") + ErrInvalidTitle = errors.New("title must be between 1 and 200 characters") + ErrInvalidStatus = errors.New("status must be one of: todo, in_progress, done") + ErrTitleTooLong = errors.New("title too long") +) + +// Task là entity trung tâm của domain. Các field đều PRIVATE (chữ thường) +// để buộc mọi thao tác đi qua method, đảm bảo invariant luôn được duy trì. +// Đây là nguyên tắc encapsulation cổ điển nhưng thường bị bỏ qua trong Go. +type Task struct { + id string + title string + description string + status Status + createdAt time.Time + updatedAt time.Time +} + +// NewTask là factory function — cách DUY NHẤT để tạo một Task hợp lệ. +// Nếu input không đúng, trả error ngay thay vì trả Task "half-baked". +// Đây là pattern "parse, don't validate": không bao giờ có Task sai quy tắc +// tồn tại trong hệ thống. +func NewTask(title, description string) (*Task, error) { + title = strings.TrimSpace(title) + if title == "" || len(title) > 200 { + return nil, ErrInvalidTitle + } + + now := time.Now().UTC() // Luôn UTC ở domain, convert sang timezone ở tầng trình bày. + return &Task{ + id: uuid.NewString(), + title: title, + description: strings.TrimSpace(description), + status: StatusTodo, // Task mới luôn bắt đầu ở "todo". + createdAt: now, + updatedAt: now, + }, nil +} + +// ReconstituteTask dùng khi load Task từ storage (memory, database, ...). +// Khác với NewTask ở chỗ: nhận đầy đủ field, không tạo id/timestamp mới. +// Cần thiết vì repository phải rebuild Task từ dữ liệu đã persist. +// +// Lưu ý: hàm này BYPASS validation một phần (ví dụ không check title length +// lại) vì giả định dữ liệu trong storage đã hợp lệ — nó đã đi qua NewTask +// lúc tạo. Nếu dữ liệu bị corrupt ở storage, đó là lỗi infra, không phải domain. +func ReconstituteTask(id, title, description string, status Status, createdAt, updatedAt time.Time) (*Task, error) { + if id == "" { + return nil, errors.New("id cannot be empty") + } + if _, err := uuid.Parse(id); err != nil { + return nil, errors.New("id must be valid UUID") + } + if !status.IsValid() { + return nil, ErrInvalidStatus + } + return &Task{ + id: id, + title: title, + description: description, + status: status, + createdAt: createdAt, + updatedAt: updatedAt, + }, nil +} + +// ─── Getters ─── +// Go không có getter tự động như Java; phải viết tay. Một ít boilerplate +// đổi lấy encapsulation là deal tốt. + +func (t *Task) ID() string { return t.id } +func (t *Task) Title() string { return t.title } +func (t *Task) Description() string { return t.description } +func (t *Task) Status() Status { return t.status } +func (t *Task) CreatedAt() time.Time { return t.createdAt } +func (t *Task) UpdatedAt() time.Time { return t.updatedAt } + +// ─── Behaviors ─── +// Đây là nơi business rules được code hóa. Các method dưới đây là "verb" +// mà một Task có thể thực hiện. Tên method dùng imperative voice: +// task.Start(), task.Complete(), không phải task.SetStatus(inProgress). +// Lý do: verbose hơn nhưng self-documenting — code đọc như tiếng Anh. + +// Start đổi trạng thái sang "in_progress". Chỉ hợp lệ khi đang "todo". +// Tại sao check state machine ở đây? Vì đây là business rule, không phải UI. +// Bất kỳ adapter nào (HTTP, CLI, event consumer) đều không thể bypass rule này. +func (t *Task) Start() error { + if t.status != StatusTodo { + return errors.New("can only start tasks in 'todo' status") + } + t.status = StatusInProgress + t.updatedAt = time.Now().UTC() + return nil +} + +// Complete chuyển sang "done". Cho phép từ bất kỳ trạng thái nào vì +// sometimes user muốn đánh dấu hoàn thành trực tiếp mà không qua in_progress +// (ví dụ task quá nhỏ không cần track quá trình). +// Business rule này có thể thay đổi; khi đó chỉ sửa method này, không ảnh +// hưởng HTTP/DB layer. +func (t *Task) Complete() error { + if t.status == StatusDone { + return errors.New("task already completed") + } + t.status = StatusDone + t.updatedAt = time.Now().UTC() + return nil +} + +// UpdateDetails cho phép sửa title/description. Không cho sửa status qua +// method này — status có flow riêng qua Start/Complete để đảm bảo đúng thứ tự. +func (t *Task) UpdateDetails(title, description string) error { + title = strings.TrimSpace(title) + if title == "" || len(title) > 200 { + return ErrInvalidTitle + } + t.title = title + t.description = strings.TrimSpace(description) + t.updatedAt = time.Now().UTC() + return nil +} diff --git a/services/task-api/internal/domain/task_test.go b/services/task-api/internal/domain/task_test.go new file mode 100644 index 0000000..26177c7 --- /dev/null +++ b/services/task-api/internal/domain/task_test.go @@ -0,0 +1,184 @@ +package domain_test + +import ( + "strings" + "testing" + + "github.com/taskr/task-api/internal/domain" +) + +// Test đặt ở package domain_test (khác domain) để test hành vi public API, +// không phải implementation detail. Đây là pattern "black box testing" — +// test như một client của package sẽ sử dụng. + +// ─── Table-driven tests ─── +// Go idiom: một test function, nhiều case trong slice. Dễ thêm case, +// failure message rõ ràng (đi kèm tên case), và IDE/CI report từng sub-test riêng. + +func TestNewTask_InputValidation(t *testing.T) { + cases := []struct { + name string + title string + description string + wantErr error + }{ + { + name: "title hợp lệ", + title: "Viết báo cáo tuần", + description: "Báo cáo gửi sếp thứ 6", + wantErr: nil, + }, + { + name: "title rỗng — lỗi", + title: "", + wantErr: domain.ErrInvalidTitle, + }, + { + name: "title chỉ whitespace — lỗi (vì trim)", + title: " \t ", + wantErr: domain.ErrInvalidTitle, + }, + { + name: "title quá dài — lỗi", + title: strings.Repeat("a", 201), + wantErr: domain.ErrInvalidTitle, + }, + { + name: "title có whitespace đầu/cuối — được trim", + title: " Xong task ", + description: "", + wantErr: nil, + }, + } + + for _, tc := range cases { + // Capture loop variable — tránh closure bug với t.Parallel(). + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() // Các sub-test chạy song song, nhanh hơn. + + task, err := domain.NewTask(tc.title, tc.description) + + if tc.wantErr != nil { + if err != tc.wantErr { + t.Fatalf("expected error %v, got %v", tc.wantErr, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task == nil { + t.Fatal("expected non-nil task") + } + + // Invariants của task mới tạo: + if task.Status() != domain.StatusTodo { + t.Errorf("new task should have status 'todo', got %q", task.Status()) + } + if task.ID() == "" { + t.Error("task should have non-empty ID") + } + if task.CreatedAt().IsZero() { + t.Error("task should have CreatedAt set") + } + // Title đã trim — kiểm tra điều này + if strings.TrimSpace(tc.title) != task.Title() { + t.Errorf("title mismatch: got %q want %q (trimmed)", task.Title(), strings.TrimSpace(tc.title)) + } + }) + } +} + +func TestTask_StateTransitions(t *testing.T) { + t.Run("todo -> in_progress via Start", func(t *testing.T) { + task, _ := domain.NewTask("test", "") + if err := task.Start(); err != nil { + t.Fatalf("Start() should succeed on todo task, got %v", err) + } + if task.Status() != domain.StatusInProgress { + t.Errorf("expected status in_progress, got %q", task.Status()) + } + }) + + t.Run("không thể Start task đã in_progress", func(t *testing.T) { + task, _ := domain.NewTask("test", "") + _ = task.Start() // chuyển sang in_progress + err := task.Start() + if err == nil { + t.Error("Start() trên in_progress task phải trả lỗi") + } + }) + + t.Run("todo -> done qua Complete", func(t *testing.T) { + task, _ := domain.NewTask("test", "") + if err := task.Complete(); err != nil { + t.Fatalf("Complete() from todo should succeed, got %v", err) + } + if task.Status() != domain.StatusDone { + t.Errorf("expected done, got %q", task.Status()) + } + }) + + t.Run("in_progress -> done qua Complete", func(t *testing.T) { + task, _ := domain.NewTask("test", "") + _ = task.Start() + if err := task.Complete(); err != nil { + t.Errorf("Complete() from in_progress should succeed, got %v", err) + } + }) + + t.Run("không thể Complete task đã done", func(t *testing.T) { + task, _ := domain.NewTask("test", "") + _ = task.Complete() + if err := task.Complete(); err == nil { + t.Error("Complete() trên done task phải trả lỗi") + } + }) +} + +func TestTask_UpdateDetails(t *testing.T) { + task, _ := domain.NewTask("old title", "old desc") + originalUpdatedAt := task.UpdatedAt() + + // Sleep 1ms để đảm bảo timestamp thay đổi khi UpdateDetails. + // Đây là cách kiểm tra thô nhưng đủ cho unit test; production code không cần. + + err := task.UpdateDetails("new title", "new desc") + if err != nil { + t.Fatalf("UpdateDetails() failed: %v", err) + } + + if task.Title() != "new title" { + t.Errorf("title không update: got %q", task.Title()) + } + if task.Description() != "new desc" { + t.Errorf("description không update: got %q", task.Description()) + } + if !task.UpdatedAt().After(originalUpdatedAt) { + // UpdatedAt có thể equal nếu quá nhanh, nhưng không được trước + if task.UpdatedAt().Before(originalUpdatedAt) { + t.Error("UpdatedAt không được lùi về quá khứ") + } + } +} + +func TestStatus_IsValid(t *testing.T) { + cases := map[domain.Status]bool{ + domain.StatusTodo: true, + domain.StatusInProgress: true, + domain.StatusDone: true, + "unknown": false, + "": false, + "TODO": false, // case-sensitive + } + + for status, want := range cases { + t.Run(string(status), func(t *testing.T) { + if got := status.IsValid(); got != want { + t.Errorf("IsValid(%q) = %v, want %v", status, got, want) + } + }) + } +} diff --git a/services/task-api/internal/observability/logger.go b/services/task-api/internal/observability/logger.go new file mode 100644 index 0000000..f982f76 --- /dev/null +++ b/services/task-api/internal/observability/logger.go @@ -0,0 +1,54 @@ +// Package observability tập trung code setup cho logging, metrics, và tracing. +// Tách ra khỏi main.go để main gọn và từng concern có thể test độc lập. +package observability + +import ( + "os" + "strings" + + "github.com/rs/zerolog" +) + +// NewLogger tạo zerolog.Logger với format phù hợp môi trường. +// +// - "production" hoặc "staging": JSON format, output stdout, gắn sẵn fields +// service + version. Loki/Fluent Bit sẽ index các field này. +// - "development": console format (màu, human-readable) cho dev dễ đọc. +// +// Level control qua env LOG_LEVEL: debug, info, warn, error (default: info). +// +// Nguyên tắc: logger KHÔNG BAO GIỜ fatal hay panic — chỉ log. Caller quyết định +// có exit hay không. Logger fatal bên trong library là code smell nghiêm trọng. +func NewLogger(env, serviceName, version string) zerolog.Logger { + // Parse level từ env. Default "info" — không quá verbose, không quá quiet. + level, err := zerolog.ParseLevel(strings.ToLower(os.Getenv("LOG_LEVEL"))) + if err != nil || level == zerolog.NoLevel { + level = zerolog.InfoLevel + } + zerolog.SetGlobalLevel(level) + + // Thời gian dùng Unix timestamp với microsecond precision. + // Rất gọn khi log, dễ index, và đủ chính xác cho mọi use case. + zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs + + var logger zerolog.Logger + + if env == "development" { + // ConsoleWriter format đẹp cho local: màu, timestamp human-readable. + logger = zerolog.New(zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: "15:04:05", + }).With().Timestamp().Logger() + } else { + // JSON format cho mọi environment khác — machine-readable, Loki/ES index được. + logger = zerolog.New(os.Stdout).With().Timestamp().Logger() + } + + // Gắn field thường trực vào logger. Các field này có mặt trong MỌI log line, + // giúp query theo service/version dễ dàng. + return logger.With(). + Str("service", serviceName). + Str("version", version). + Str("env", env). + Logger() +} diff --git a/services/task-api/internal/observability/metrics.go b/services/task-api/internal/observability/metrics.go new file mode 100644 index 0000000..7ac73c9 --- /dev/null +++ b/services/task-api/internal/observability/metrics.go @@ -0,0 +1,69 @@ +// Package observability — metrics.go +// Khởi tạo OpenTelemetry metrics provider với Prometheus exporter. +// +// THIẾT KẾ: Dùng OTel SDK làm abstraction layer. Exporter có thể swap +// (Prometheus, OTLP, ...) mà không phải thay code instrumentation. +// Ở Phase 2: export Prometheus format (pull) qua /metrics endpoint. +// Ở Phase 4+: thêm OTLP push sang OTel Collector. +package observability + +import ( + "context" + "fmt" + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" +) + +// MetricsProvider bọc OTel MeterProvider và Prometheus HTTP handler. +// Caller dùng Provider() để lấy meter, và Handler() để expose /metrics. +type MetricsProvider struct { + provider *sdkmetric.MeterProvider + handler http.Handler +} + +// NewMetricsProvider khởi tạo Prometheus exporter và gắn vào OTel global. +// Gọi một lần trong main.go. Trả error nếu khởi tạo thất bại. +func NewMetricsProvider(serviceName, version string) (*MetricsProvider, error) { + // Prometheus exporter — pull model, Prometheus scrape /metrics + exporter, err := prometheus.New() + if err != nil { + return nil, fmt.Errorf("prometheus exporter: %w", err) + } + + provider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(exporter), + // Resource attributes — gắn vào mọi metric từ service này + sdkmetric.WithResource(newResource(serviceName, version)), + ) + + // Gắn làm global provider để code bất kỳ đâu gọi otel.GetMeterProvider() + // đều nhận đúng provider này. + otel.SetMeterProvider(provider) + + return &MetricsProvider{ + provider: provider, + // promhttp.Handler() trả metrics theo Prometheus text format + handler: promhttp.Handler(), + }, nil +} + +// Handler trả http.Handler để mount tại /metrics. +func (mp *MetricsProvider) Handler() http.Handler { + return mp.handler +} + +// Meter trả Meter đã gắn serviceName — dùng để tạo instrument (counter, gauge, histogram). +func (mp *MetricsProvider) Meter(name string) metric.Meter { + return mp.provider.Meter(name) +} + +// Shutdown flush metrics còn đọng trước khi process exit. +// Gọi trong defer của graceful shutdown. +func (mp *MetricsProvider) Shutdown(ctx context.Context) error { + return mp.provider.Shutdown(ctx) +} diff --git a/services/task-api/internal/observability/tracing.go b/services/task-api/internal/observability/tracing.go new file mode 100644 index 0000000..d06215d --- /dev/null +++ b/services/task-api/internal/observability/tracing.go @@ -0,0 +1,109 @@ +// Package observability — tracing.go +// Khởi tạo OTel TracerProvider với OTLP exporter sang OTel Collector. +// Trace được gửi theo push model (OTLP/gRPC) → OTel Collector → Tempo. +package observability + +import ( + "context" + "fmt" + "os" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + sdkresource "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// TracerProvider bọc OTel SDK TracerProvider. +type TracerProvider struct { + provider *sdktrace.TracerProvider +} + +// NewTracerProvider khởi tạo OTLP gRPC exporter và gắn vào OTel global. +// +// Env vars: +// OTEL_EXPORTER_OTLP_ENDPOINT — endpoint của OTel Collector (default: localhost:4317) +// OTEL_SAMPLING_RATIO — tỷ lệ sample trace (default: 1.0 = 100%) +// +// Nếu OTEL_EXPORTER_OTLP_ENDPOINT trống hoặc không kết nối được, +// tracing vẫn hoạt động nhưng trace không được export (no-op exporter). +func NewTracerProvider(ctx context.Context, serviceName, version string) (*TracerProvider, error) { + endpoint := getEnvOrDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") + + // Kết nối đến OTel Collector. WithBlock() để phát hiện lỗi ngay khi start. + // Timeout 5s — nếu Collector chưa sẵn sàng, service vẫn start được + // (trace sẽ bị drop cho đến khi Collector online). + conn, err := grpc.NewClient(endpoint, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + // Không fail hard — trace không hoạt động nhưng service vẫn serve. + // Log warning ở main.go và tiếp tục. + return &TracerProvider{provider: noopTracerProvider(serviceName, version)}, nil + } + + exporter, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithGRPCConn(conn), + ) + if err != nil { + return nil, fmt.Errorf("otlp trace exporter: %w", err) + } + + provider := sdktrace.NewTracerProvider( + // Batch processor — gom trace rồi gửi, hiệu quả hơn per-span. + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(newResource(serviceName, version)), + // Sampler: 100% local, có thể giảm ở production + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + + // Gắn global tracer và propagator (W3C TraceContext + Baggage) + // Propagator quan trọng: đảm bảo trace_id được truyền qua HTTP header + // giữa các service. Không set propagator → distributed trace bị đứt đoạn. + otel.SetTracerProvider(provider) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return &TracerProvider{provider: provider}, nil +} + +// Shutdown flush trace còn trong buffer trước khi exit. +func (tp *TracerProvider) Shutdown(ctx context.Context) error { + return tp.provider.Shutdown(ctx) +} + +// newResource tạo OTel Resource với service metadata. +// Resource là tập attribute mô tả *nguồn gốc* của telemetry (service gì, version nào). +func newResource(serviceName, version string) *sdkresource.Resource { + r, _ := sdkresource.Merge( + sdkresource.Default(), + sdkresource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(serviceName), + semconv.ServiceVersion(version), + semconv.DeploymentEnvironment(getEnvOrDefault("APP_ENV", "development")), + ), + ) + return r +} + +// noopTracerProvider trả provider không export trace — dùng khi Collector không có. +func noopTracerProvider(serviceName, version string) *sdktrace.TracerProvider { + return sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.NeverSample()), + sdktrace.WithResource(newResource(serviceName, version)), + ) +} + +func getEnvOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/services/task-api/internal/port/task_repository.go b/services/task-api/internal/port/task_repository.go new file mode 100644 index 0000000..ccb7598 --- /dev/null +++ b/services/task-api/internal/port/task_repository.go @@ -0,0 +1,44 @@ +// Package port định nghĩa các interface mà domain cần từ bên ngoài. +// "Port" là thuật ngữ từ hexagonal architecture — domain "mở cổng" và adapter +// "cắm vào cổng đó". Điều này cho phép swap implementation (memory, postgres, +// mongodb, ...) mà không chạm vào domain. +// +// QUAN TRỌNG: Port được định nghĩa THEO NHU CẦU của domain, không theo +// capability của database. Nếu repository có method "RawSQL()", đó là leak +// infrastructure vào domain — anti-pattern cần tránh. +package port + +import ( + "context" + + "github.com/taskr/task-api/internal/domain" +) + +// TaskRepository định nghĩa hợp đồng lưu trữ Task. Mọi method nhận context +// đầu tiên để cho phép cancellation, timeout, tracing propagation — đây là +// idiom Go đã trở thành chuẩn không viết thì thiếu chuyên nghiệp. +// +// Lưu ý: interface này NHỎ (chỉ 5 method). Nguyên tắc "Interface Segregation" +// của SOLID: interface lớn khó implement và khó mock. Khi cần thêm chức năng +// như bulk insert hoặc search phức tạp, tạo interface mới chuyên biệt. +type TaskRepository interface { + // Save tạo mới hoặc cập nhật task. Upsert semantics để interface đơn giản; + // implementation có thể phân biệt bên trong nếu cần (ví dụ INSERT vs UPDATE). + Save(ctx context.Context, task *domain.Task) error + + // FindByID trả về task theo ID. Trả domain.ErrTaskNotFound nếu không tìm thấy + // (không trả về nil, nil — ambiguous). Pattern Go idiomatic: err != nil = lỗi, + // thay vì sentinel value. + FindByID(ctx context.Context, id string) (*domain.Task, error) + + // FindAll trả về tất cả task. Ở phiên bản sau sẽ cần pagination, filter, + // sort — nhưng YAGNI, chưa cần thì chưa thêm. + FindAll(ctx context.Context) ([]*domain.Task, error) + + // Delete xóa task theo ID. Trả domain.ErrTaskNotFound nếu không tìm thấy + // để client biết yêu cầu không idempotent. + Delete(ctx context.Context, id string) error + + // Count đếm tổng số task — dùng cho metric và health check. + Count(ctx context.Context) (int, error) +} diff --git a/services/task-api/migrations/001_create_tasks.up.sql b/services/task-api/migrations/001_create_tasks.up.sql new file mode 100644 index 0000000..1c5bccc --- /dev/null +++ b/services/task-api/migrations/001_create_tasks.up.sql @@ -0,0 +1,21 @@ +-- migrations/001_create_tasks.up.sql +-- Chạy bằng golang-migrate: +-- migrate -path ./migrations -database "postgres://..." up + +CREATE TABLE IF NOT EXISTS tasks ( + id UUID PRIMARY KEY, + title VARCHAR(200) NOT NULL CHECK (length(trim(title)) > 0), + description TEXT NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'todo' + CHECK (status IN ('todo', 'in_progress', 'done')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index cho query ORDER BY created_at DESC (FindAll) +CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks (created_at DESC); + +-- Index cho status filter (Phase 5+ khi thêm filter API) +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status); + +COMMENT ON TABLE tasks IS 'Task entity table — managed by task-api service'; From 1546898d44df2cb08424e69d2ee1e7fde0c15495 Mon Sep 17 00:00:00 2001 From: Crystal-Lily-hy Date: Tue, 28 Apr 2026 10:19:06 +0700 Subject: [PATCH 2/4] feak:(add repo wrtiring hub --- DEPLOYMENT-GUIDE-CNCF.md => _articles/DEPLOYMENT-GUIDE-CNCF.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename DEPLOYMENT-GUIDE-CNCF.md => _articles/DEPLOYMENT-GUIDE-CNCF.md (100%) diff --git a/DEPLOYMENT-GUIDE-CNCF.md b/_articles/DEPLOYMENT-GUIDE-CNCF.md similarity index 100% rename from DEPLOYMENT-GUIDE-CNCF.md rename to _articles/DEPLOYMENT-GUIDE-CNCF.md From 191d09785dfb12a75d1367083f7b40cc50682d5d Mon Sep 17 00:00:00 2001 From: phanhai78 Date: Tue, 28 Apr 2026 10:27:36 +0700 Subject: [PATCH 3/4] feak:"recreate readme" --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b003481..b521778 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,11 @@ và các quyết định trade-off, xem `docs/architecture.md` (Phase 2 sẽ có |-------|--------------------------------------------------|---------------| | 0 | Onboarding GCP + tools | ✓ Hoàn thành | | 1 | Go service + kind + ArgoCD (cái bạn đang đọc) | ✓ Hoàn thành | -| 2 | Observability: Prometheus, Grafana, Loki, Tempo | 🔜 Sắp tới | -| 3 | Security: NetworkPolicy, Kyverno, Linkerd mTLS | 🔜 | -| 4 | HA & multi-env: Postgres, GCP deploy | 🔜 | -| 5 | Canary với Argo Rollouts | 🔜 | -| 6 | FinOps: OpenCost, right-sizing, spot instances | 🔜 | +| 2 | Observability: Prometheus, Grafana, Loki, Tempo | ✓ Hoàn thành | +| 3 | Security: NetworkPolicy, Kyverno, Linkerd mTLS | ✓ Hoàn thành | +| 4 | HA & multi-env: Postgres, GCP deploy | ✓ Hoàn thành | +| 5 | Canary với Argo Rollouts | ✓ Hoàn thành | +| 6 | FinOps: OpenCost, right-sizing, spot instances | ✓ Hoàn thành | --- From 5fa342c657607e556481c1591df6d75b91b15fc1 Mon Sep 17 00:00:00 2001 From: phanhai78 Date: Sun, 31 May 2026 23:43:39 +0700 Subject: [PATCH 4/4] git add readme --- README.md | 144 +++++++++++++- docs/00-gcp-onboarding.md | 181 +++++++++++++++-- docs/01-local-dev.md | 71 ++++++- docs/runbook.md | 394 ++++++++++++++++++++++++++------------ 4 files changed, 644 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index b521778..6c322f7 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,148 @@ cloud-native-taskr/ └─────────┘ └────────┘ └────────────┘ ``` -Phần sâu hơn về triết lý hexagonal architecture, lý do chọn từng công cụ, -và các quyết định trade-off, xem `docs/architecture.md` (Phase 2 sẽ có). +## Sơ đồ kiến trúc tổng thể + +``` +╔══════════════════════════════════════════════════════════════════════╗ +║ INTERNET ║ +║ │ ║ +║ ▼ HTTPS (Let's Encrypt / nip.io) ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ EDGE LAYER ║ +║ Cloudflare (free) → WAF, DDoS protection ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ INGRESS LAYER (namespace: ingress-nginx) ║ +║ ingress-nginx ← cert-manager (Let's Encrypt / self-signed) ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ SECURITY LAYER (Phase 3) ║ +║ Kyverno (policy enforcement) ← applied at API server ║ +║ NetworkPolicy: default-deny + explicit allow rules ║ +║ Linkerd (mTLS, sidecar injection) ← east-west traffic ║ +╠═══════════════════════════════════╦══════════════════════════════════╣ +║ APPLICATION (namespace: taskr) ║ PLATFORM ║ +║ ║ ║ +║ task-api (Go, hexagonal) ║ observability/ ║ +║ ├─ HTTP adapter (chi) ║ Prometheus + Alertmanager ║ +║ ├─ OTel metrics/traces ║ Grafana (dashboards as code) ║ +║ ├─ Domain (pure logic) ║ Loki (log aggregation) ║ +║ └─ Adapter: ║ Tempo (distributed tracing) ║ +║ ├─ memory (Phase 1) ║ OTel Collector (DaemonSet) ║ +║ └─ postgres (Phase 4) ───▶║ ║ +║ ║ security/ ║ +║ Argo Rollouts (Phase 5) ║ Sealed Secrets ║ +║ Canary 5→25→50→100% ║ Kyverno policies ║ +║ AnalysisTemplate ║ ║ +║ (Prometheus gate) ║ finops/ (Phase 6) ║ +║ ║ OpenCost ║ +╠═══════════════════════════════════╣ Chaos Mesh ║ +║ DATA LAYER (namespace: taskr) ║ ║ +║ PostgreSQL (CloudNativePG) ╚══════════════════════════════════╣ +║ Primary + 1 Replica (Phase 4) ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ GITOPS (ArgoCD — namespace: argocd) ║ +║ App-of-Apps pattern ║ +║ ├─ task-api-local ║ +║ ├─ observability ║ +║ ├─ security ║ +║ └─ platform-tools ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ CI/CD (GitHub Actions — Phase 5) ║ +║ Lint → Test → Security scan → Build → Push → Bump tag → ArgoCD ║ +╚══════════════════════════════════════════════════════════════════════╝ +``` + +--- + +## Phase 2 — Observability (8GB-optimized) + +**Stack:** kube-prometheus-stack (Prometheus+Grafana+Alertmanager) + Loki + Tempo + OTel Collector + +**Resource budget tổng cho observability namespace:** ~900Mi RAM + +| Component | Request | Limit | Ghi chú | +|--------------------|---------|--------|----------------------------| +| Prometheus | 256Mi | 512Mi | retention 24h để nhỏ | +| Grafana | 128Mi | 256Mi | tắt plugin nặng | +| Alertmanager | 32Mi | 64Mi | | +| Loki | 128Mi | 256Mi | single binary mode | +| Tempo | 128Mi | 256Mi | single binary mode | +| OTel Collector | 64Mi | 128Mi | Deployment (không DaemonSet)| +| **Tổng** | **736Mi**| **1.4Gi**| | + +**Deliverables:** +- Helm values tối giản cho từng component +- OTel instrumentation trong task-api (metrics + traces) +- 2 Grafana dashboard as code (service RED metrics, infra USE) +- PrometheusRule: 3 alert cơ bản +- ArgoCD Application cho observability namespace +- Script thêm hosts: grafana.local, prometheus.local + +--- + +## Phase 3 — Security + +**Stack:** Kyverno + NetworkPolicy + Linkerd + Sealed Secrets + Trivy scan + +**Deliverables:** +- NetworkPolicy: default-deny taskr namespace + explicit allow rules +- 5 Kyverno ClusterPolicy (no-root, resource-required, trusted-registry, labels-required, no-latest-tag) +- Linkerd install + annotation cho namespace taskr +- Sealed Secrets controller + workflow encrypt/decrypt +- Trivy scan tích hợp vào Makefile + +--- + +## Phase 4 — HA & GCP + +**Stack:** CloudNativePG + postgres adapter Go + Terraform GKE Autopilot + Velero + +**GCP cost estimate (demo 2h):** ~$1.00 +- GKE Autopilot: $0.10/vCPU/h × 0.5 vCPU × 2h = $0.10 +- Load Balancer: $0.025/h × 2h = $0.05 +- Egress: ~$0.00 (minimal) + +**Deliverables:** +- postgres adapter Go (swap memory → postgres, domain unchanged) +- golang-migrate schema migration +- CloudNativePG PostgreSQL CRD +- Terraform: VPC, GKE Autopilot, Artifact Registry, IAM +- Overlay gcp-demo với nip.io ingress +- Velero backup setup +- make gcp-up / make gcp-down (auto destroy sau 2h via Cloud Scheduler) + +--- + +## Phase 5 — Progressive Delivery + +**Stack:** Argo Rollouts + AnalysisTemplate + GitHub Actions CI + +**Canary flow:** +``` +deploy v2 → 5% traffic (5 phút) → check metrics → + OK: 25% (5 phút) → OK: 50% (5 phút) → 100% + FAIL: auto-rollback về v1 +``` + +**Deliverables:** +- Rollout CRD thay thế Deployment +- AnalysisTemplate dùng Prometheus query +- GitHub Actions workflow (lint→test→build→push→bump) +- "Bug injection" script để demo rollback +- Makefile targets + +--- + +## Phase 6 — FinOps & Operations + +**Stack:** OpenCost + ResourceQuota + Chaos Mesh + Runbook + +**Deliverables:** +- OpenCost deployment với Prometheus backend +- ResourceQuota + LimitRange cho namespace taskr +- Chaos Mesh: 3 experiment (pod-kill, network-delay, cpu-stress) +- Operations runbook (5 scenario thường gặp) +- Weekly cost report script --- diff --git a/docs/00-gcp-onboarding.md b/docs/00-gcp-onboarding.md index 2167117..3411050 100644 --- a/docs/00-gcp-onboarding.md +++ b/docs/00-gcp-onboarding.md @@ -41,11 +41,12 @@ accidentally xóa nhầm tài nguyên demo khi đang nghịch dev. ## Bước 3 — Cài đặt công cụ dòng lệnh -Bạn cần cài bốn công cụ trên máy local. Đoạn dưới đây là cho macOS với Homebrew. +Bạn cần cài các công cụ trên máy local. Đoạn dưới đây là cho macOS với Homebrew. Nếu bạn dùng Linux hoặc Windows WSL, chạy `scripts/00-prerequisites.sh` để được hướng dẫn đúng cho hệ điều hành của bạn. ```bash +# === Core tools === # Google Cloud SDK — để giao tiếp với GCP brew install --cask google-cloud-sdk @@ -63,17 +64,53 @@ brew install helm # Go 1.22+ — để compile service brew install go + +# === DevSecOps tools (cài luôn từ Phase 0 để shift-left security) === +# Trivy — quét CVE trong filesystem, Dockerfile, container image, IaC +brew install trivy + +# Gitleaks — phát hiện secret bị commit vào Git +brew install gitleaks + +# pre-commit framework — chạy security check trước mỗi commit +brew install pre-commit + +# Checkov — quét misconfiguration trong Terraform, Kubernetes manifest +brew install checkov + +# kubeseal — encrypt secret cho Sealed Secrets (dùng từ Phase 3) +brew install kubeseal + +# cosign — ký và verify container image (dùng từ Phase 5) +brew install cosign + +# syft — generate SBOM (Software Bill of Materials) +brew install syft + +# govulncheck — quét CVE trong Go dependency +go install golang.org/x/vuln/cmd/govulncheck@latest ``` Sau khi cài xong, kiểm tra từng tool: ```bash +# Core gcloud --version # Nên thấy Google Cloud SDK 450.x.x trở lên kubectl version --client kind --version helm version docker info # Đảm bảo Docker daemon đang chạy go version # Nên thấy 1.22 trở lên + +# DevSecOps +trivy --version +gitleaks version +pre-commit --version +checkov --version +kubeseal --version +cosign version +syft version +govulncheck -version ``` Nếu bất kỳ lệnh nào báo lỗi `command not found`, mở shell mới (`source ~/.zshrc` @@ -118,10 +155,17 @@ gcloud services enable \ Quá trình này mất khoảng 2-3 phút. Một số API phụ thuộc lẫn nhau nên Google sẽ enable theo thứ tự đúng. -## Bước 6 — Tạo service account cho Terraform (chỉ khi nào bạn chuẩn bị deploy lên GCP) +## Bước 6 — Tạo service account cho Terraform với least privilege (DevSecOps) Phần này bạn *chưa cần làm ngay*. Nó chỉ cần thiết khi bạn đã làm xong Phase 1 -local và muốn triển khai lên GCP để demo. Khi đến lúc đó, quay lại đây: +local và muốn triển khai lên GCP để demo. Khi đến lúc đó, quay lại đây. + +**Nguyên tắc DevSecOps áp dụng cho bước này:** least privilege (chỉ cấp quyền +tối thiểu cần thiết), không tạo long-lived service account key cho CI/CD (dùng +Workload Identity Federation thay thế), và tách biệt service account giữa +Terraform admin (chạy local) và CI/CD pipeline (chạy trên GitHub Actions). + +### 6a. Service account cho Terraform admin (chạy local) ```bash export PROJECT_ID=$(gcloud config get-value project) @@ -130,17 +174,87 @@ export SA_NAME=terraform-admin gcloud iam service-accounts create $SA_NAME \ --display-name="Terraform Admin SA" -gcloud projects add-iam-policy-binding $PROJECT_ID \ - --member="serviceAccount:$SA_NAME@$PROJECT_ID.iam.gserviceaccount.com" \ - --role="roles/editor" - +# Thay vì roles/editor (quá rộng), cấp các role cụ thể theo phạm vi project này +for role in \ + roles/compute.admin \ + roles/container.admin \ + roles/artifactregistry.admin \ + roles/iam.serviceAccountAdmin \ + roles/iam.serviceAccountUser \ + roles/resourcemanager.projectIamAdmin \ + roles/storage.admin \ + roles/dns.admin \ + roles/monitoring.admin \ + roles/logging.admin; do + gcloud projects add-iam-policy-binding $PROJECT_ID \ + --member="serviceAccount:$SA_NAME@$PROJECT_ID.iam.gserviceaccount.com" \ + --role="$role" \ + --condition=None +done + +# Key được tạo tạm thời và KHÔNG commit vào Git (đã có trong .gitignore) gcloud iam service-accounts keys create ~/.config/gcloud/terraform-key.json \ --iam-account=$SA_NAME@$PROJECT_ID.iam.gserviceaccount.com ``` -Lưu ý: `roles/editor` là quyền khá rộng. Trong môi trường production thật, bạn -nên tạo custom role với quyền tối thiểu (least privilege). Ở phạm vi dự án học -tập, editor là đủ và đơn giản. +### 6b. Workload Identity Federation cho GitHub Actions (KHÔNG dùng key) + +CI/CD pipeline ở Phase 5 sẽ deploy lên GCP. Nguyên tắc DevSecOps: **không bao giờ +lưu service account key vào GitHub Secrets**. Thay vào đó dùng OIDC federation +giữa GitHub và GCP để pipeline lấy short-lived token (TTL 15 phút). + +```bash +# Tạo Workload Identity Pool +gcloud iam workload-identity-pools create github-pool \ + --location=global \ + --display-name="GitHub Actions Pool" + +# Tạo OIDC provider trỏ về GitHub +gcloud iam workload-identity-pools providers create-oidc github-provider \ + --location=global \ + --workload-identity-pool=github-pool \ + --display-name="GitHub OIDC Provider" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \ + --attribute-condition="assertion.repository_owner == 'YOUR_GITHUB_ORG'" \ + --issuer-uri="https://token.actions.githubusercontent.com" + +# Tạo service account riêng cho CI/CD (tách biệt với terraform-admin) +gcloud iam service-accounts create github-deployer \ + --display-name="GitHub Actions Deployer" + +# Chỉ cấp các role tối thiểu cần cho deploy +for role in \ + roles/container.developer \ + roles/artifactregistry.writer \ + roles/iam.workloadIdentityUser; do + gcloud projects add-iam-policy-binding $PROJECT_ID \ + --member="serviceAccount:github-deployer@$PROJECT_ID.iam.gserviceaccount.com" \ + --role="$role" \ + --condition=None +done + +# Bind GitHub repo cụ thể với service account (chỉ repo này mới impersonate được) +export REPO="YOUR_GITHUB_ORG/YOUR_REPO_NAME" +gcloud iam service-accounts add-iam-policy-binding \ + github-deployer@$PROJECT_ID.iam.gserviceaccount.com \ + --role=roles/iam.workloadIdentityUser \ + --member="principalSet://iam.googleapis.com/projects/$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')/locations/global/workloadIdentityPools/github-pool/attribute.repository/$REPO" +``` + +Sau khi setup xong, lưu ba giá trị này để Phase 5 dùng làm GitHub Secret: +`GCP_PROJECT_ID`, `GCP_WIF_PROVIDER` (full resource name của provider), +`GCP_SERVICE_ACCOUNT` (email của `github-deployer`). + +### 6c. Organization Policy chặn tạo service account key (khuyến nghị) + +Nếu bạn có quyền Organization Admin, bật policy này để chặn vĩnh viễn việc tạo +key (chống nhân viên hoặc agent vô tình tạo và leak): + +```bash +gcloud resource-manager org-policies enable-enforce \ + iam.disableServiceAccountKeyCreation \ + --project=$PROJECT_ID +``` ## Bước 7 — Thiết lập budget alert @@ -159,7 +273,50 @@ Truy cập `https://console.cloud.google.com/billing` → chọn billing account Nếu chi phí vượt 100% (tức $50/tháng), bạn sẽ nhận email ngay. Budget alert *không tự động tắt tài nguyên*, chỉ cảnh báo. Việc tắt là trách nhiệm của bạn. -## Bước 8 — Xác nhận sẵn sàng +## Bước 8 — Thiết lập pre-commit hook (DevSecOps shift-left) + +Pre-commit hook chạy security check trước khi commit được tạo ra, chặn từ +trong trứng các lỗi như secret bị paste vào code, file bí mật vô tình add. +Đây là lớp phòng thủ rẻ nhất nhưng hiệu quả nhất trong DevSecOps. + +Tạo file `.pre-commit-config.yaml` ở root của repo (nếu chưa có): + +```yaml +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + args: [--maxkb=500] + - id: detect-private-key + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.4 + hooks: + - id: gitleaks + + - repo: https://github.com/bridgecrewio/checkov + rev: 3.2.250 + hooks: + - id: checkov + args: [-d, infra/terraform, --quiet, --compact] +``` + +Kích hoạt: + +```bash +pre-commit install +pre-commit run --all-files # chạy thử một lần trên toàn repo +``` + +Từ giờ mỗi `git commit` sẽ tự động chạy các check trên. Nếu có lỗi, commit +bị block. Đây là behavior mong muốn: bạn phải fix trước khi code đến CI. + +## Bước 9 — Xác nhận sẵn sàng Chạy script kiểm tra tổng hợp: @@ -193,4 +350,4 @@ và còn lại bao nhiêu. vì latency từ Hà Nội tới US là 200ms+, rất khó chịu khi develop. **Không bật 2FA cho Google account.** Tài khoản có $300 credit là mục tiêu -hấp dẫn cho hacker. Bật 2FA ngay tại `https://myaccount.google.com/security`. +hấp dẫn cho hacker. Bật 2FA ngay tại `https://myaccount.google.com/security`. \ No newline at end of file diff --git a/docs/01-local-dev.md b/docs/01-local-dev.md index 6483ce6..c9e8a81 100644 --- a/docs/01-local-dev.md +++ b/docs/01-local-dev.md @@ -1,14 +1,17 @@ # Phase 1 — Local Kubernetes + ArgoCD + task-api -Phase này mục tiêu: có một hệ thống chạy được end-to-end trên máy local. +Phase này mục tiêu: có một hệ thống chạy được end-to-end trên máy local +với security baseline đã được áp dụng ngay từ đầu (DevSecOps shift-left). Sau khi hoàn tất, bạn sẽ có: - Một cluster Kubernetes thực sự (3 node) chạy trên Docker. - ArgoCD quản lý mọi deployment qua Git. -- Một Go service `task-api` được deploy với hexagonal architecture. +- Một Go service `task-api` được deploy với hexagonal architecture, dùng + distroless base image và security context non-root. +- Trivy scan tích hợp vào Makefile, chạy ngay sau khi build image. - Smoke test chạy được: `curl` tạo task và query task qua ingress. -Ước tính thời gian: 1-2 giờ nếu chưa quen, 20 phút nếu đã biết. +Ước tính thời gian: 1-2 giờ nếu chưa quen, 30 phút nếu đã biết. --- @@ -97,24 +100,70 @@ make get-argocd-password Giao diện ArgoCD ở đây chưa có Application nào (do ta chưa tạo). Đây là trạng thái ban đầu — sạch và chờ lệnh. -### Bước 6. Build và deploy task-api +### Bước 6. Build, scan, và deploy task-api ```bash make build # build image Docker và load vào kind -make deploy-task-api # apply Kustomize overlay +make scan-image # Trivy scan CVE — gate trước khi deploy (DevSecOps) +make deploy-task-api # apply Kustomize overlay (chỉ chạy khi scan pass) ``` +**Về `make scan-image`:** target này gọi `trivy image --severity HIGH,CRITICAL +--exit-code 1 task-api:local`. Nếu có CVE HIGH hoặc CRITICAL chưa có patch, +lệnh exit non-zero và Makefile sẽ dừng — bạn không deploy được image có lỗ +hổng nặng. Đây là **security gate** đầu tiên trong vòng đời image. Trong Phase +5, gate này sẽ được lặp lại trong CI pipeline trước khi push lên registry. + +Image của task-api đã được hardened sẵn theo các nguyên tắc sau: + +- Dùng `gcr.io/distroless/static-debian12` làm base (không có shell, không có + package manager, attack surface tối thiểu). +- Multi-stage build: builder stage có Go toolchain, final stage chỉ có binary. +- Chạy non-root với `USER 65532:65532` (user nonroot mặc định của distroless). +- Pin base image về digest cụ thể chứ không dùng tag `latest`. + Sau khi deploy, pod task-api sẽ ở namespace `taskr`. Kiểm tra: ```bash kubectl -n taskr get pods kubectl -n taskr logs -l app.kubernetes.io/name=task-api + +# Verify security context đã được áp dụng đúng +kubectl -n taskr get pod -l app.kubernetes.io/name=task-api \ + -o jsonpath='{.items[0].spec.securityContext}' | jq +# Phải thấy: runAsNonRoot:true, runAsUser:65532, fsGroup:65532 ``` **Output mong đợi:** pod `Running` với 1/1 ready. Log hiển thị "HTTP server -listening" và "initialized in-memory repository". +listening" và "initialized in-memory repository". Security context có +`runAsNonRoot: true`. + +### Bước 7. Quét vulnerability source code và sinh SBOM (DevSecOps) + +Trước khi smoke test, chạy thêm hai check security baseline: + +```bash +# Govulncheck — quét CVE trong Go module +cd services/task-api && govulncheck ./... && cd ../.. + +# Syft — sinh SBOM (Software Bill of Materials) ở format SPDX +syft task-api:local -o spdx-json=task-api-sbom.spdx.json + +# Verify SBOM có dữ liệu +jq '.packages | length' task-api-sbom.spdx.json +# Nên thấy số > 0 (số dependency được liệt kê) +``` -### Bước 7. Smoke test +**Vì sao cần SBOM:** SBOM là danh sách đầy đủ mọi component trong image +(Go module, base image package, version, license). Khi một CVE mới được +công bố trong tương lai, bạn so SBOM với CVE database để biết ngay image +của mình có bị ảnh hưởng không, không cần rebuild và scan lại. Đây cũng +là yêu cầu compliance của NIST SSDF và US EO 14028. + +File `task-api-sbom.spdx.json` được commit vào artifact của CI ở Phase 5, +không commit vào Git repo. + +### Bước 8. Smoke test ```bash make smoke-test @@ -210,3 +259,11 @@ Khi mọi thứ chạy được và bạn đã thử nghiệm CRUD một chút, Tempo vào cluster để bạn thấy được mỗi request đang đi đâu, metric nào đang đo, và log nào đang được ghi ra. Đó là bước khi service bắt đầu "có giọng nói" và bạn nghe được hệ thống đang "nói" gì. + +**Tóm tắt DevSecOps đã có ở Phase 1:** image dùng distroless non-root, +Trivy scan gate trước deploy, govulncheck quét Go module, SBOM được sinh +ra cho mọi image build, pre-commit hook chặn secret từ workstation. Các +phase sau sẽ build trên nền tảng này thêm các lớp: observability tích +hợp security signal (Phase 2), policy enforcement và mTLS (Phase 3), +infrastructure-as-code scan (Phase 4), signed image và OIDC deploy +(Phase 5), security chaos engineering (Phase 6). \ No newline at end of file diff --git a/docs/runbook.md b/docs/runbook.md index 8ff2312..2d9293b 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -1,203 +1,347 @@ -# Operations Runbook — Cloud Native Taskr +# Hướng Dẫn Triển Khai Cloud Native Taskr +### Với 16 CNCF Tools · Phase 0 → 6 · GCP $300 -## Cách dùng runbook này -Mỗi scenario có: Triệu chứng → Chẩn đoán nhanh → Xử lý theo thứ tự. -Không bỏ qua bước chẩn đoán dù tưởng đã biết nguyên nhân. +> Toàn bộ phát triển chạy local bằng kind (miễn phí). GCP chỉ dùng khi demo Phase 4+. +> Mỗi phase xây trên phase trước — không bỏ qua bước nào. --- -## Scenario 1: task-api CrashLoopBackOff +## Bản đồ CNCF tools + DevSecOps tools theo phase -**Triệu chứng:** `kubectl -n taskr get pods` hiện STATUS = CrashLoopBackOff +| Phase | CNCF Tools (Graduated) | CNCF Tools (Incubating/Sandbox) | DevSecOps Tools | +|---|---|---|---| +| 0 | — | — | gcloud, Docker, Go, **Gitleaks, pre-commit, Trivy, Checkov, cosign, syft, govulncheck** | +| 1 | **Kubernetes · Helm · Argo CD** | **cert-manager** | ingress-nginx, kind, **Trivy image scan, Govulncheck, Syft SBOM, distroless** | +| 2 | **Prometheus · OpenTelemetry** | — | Grafana · Loki · Tempo, **Security signal dashboard, audit log scrape** | +| 3 | **Linkerd** | **Kyverno** | Sealed Secrets, **Falco, ModSecurity OWASP CRS, Trivy Operator** | +| 4 | — | **CloudNativePG** (Sandbox) | Terraform, Velero, **Checkov, tfsec, Security Command Center, CMEK encryption** | +| 5 | **Argo Rollouts** | **KEDA** | GitHub Actions, **Semgrep SAST, cosign keyless, Binary Authorization, OIDC WIF** | +| 6 | — | **Chaos Mesh · OpenCost** (Sandbox) | **Security chaos experiment, audit log review, quarterly access review** | + +**Tổng: 16 CNCF tools** (8 Graduated · 4 Incubating · 4 Sandbox) + **15 DevSecOps tools** +trên 31 tools toàn stack. DevSecOps không phải là một phase riêng — nó được tích hợp +vào tất cả 6 phase theo principle "shift-left + defense in depth". + +--- + +## Phase 0 — Chuẩn bị môi trường + DevSecOps baseline +*Tools: gcloud CLI · Docker Desktop · Go 1.22+ · Gitleaks · pre-commit · Trivy · Checkov · cosign · syft · govulncheck* + +Truy cập `https://cloud.google.com/free`, đăng ký bằng Google account riêng. Bạn nhận $300 credit có hiệu lực 90 ngày — ghi ngay ngày hết hạn vào calendar. Tạo project `taskr-dev`, lưu lại **Project ID** (dạng `taskr-dev-428391`) vì mọi lệnh CLI đều dùng ID này. -**Chẩn đoán:** ```bash -# Xem log của lần crash gần nhất -kubectl -n taskr logs --previous +# === Core tools === +brew install --cask google-cloud-sdk +brew install kubectl kind helm go -# Xem event liên quan -kubectl -n taskr describe pod +# === DevSecOps tools (shift-left từ Phase 0) === +brew install trivy gitleaks pre-commit checkov kubeseal cosign syft +go install golang.org/x/vuln/cmd/govulncheck@latest -# Kiểm tra exit code (137 = OOM, 1 = runtime error, 2 = config error) -kubectl -n taskr get pod -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}' +# Verify toàn bộ +gcloud --version && kubectl version --client && kind --version && helm version && go version && docker info +trivy --version && gitleaks version && checkov --version && cosign version && syft version ``` -**Xử lý theo exit code:** - -Exit 137 (OOM Kill): ```bash -# Tăng memory limit tạm thời -kubectl -n taskr set resources deployment/task-api --limits=memory=256Mi -# Sau đó cập nhật deployment.yaml và commit +# Đăng nhập và cấu hình +gcloud auth login +gcloud config set project taskr-dev-428391 +gcloud auth application-default login # Terraform dùng sau + +# Enable APIs (làm một lần) +gcloud services enable container.googleapis.com compute.googleapis.com \ + artifactregistry.googleapis.com iam.googleapis.com \ + containerscanning.googleapis.com binaryauthorization.googleapis.com \ + securitycenter.googleapis.com cloudkms.googleapis.com ``` -Exit 1 (runtime panic): +**Setup Workload Identity Federation cho GitHub Actions (KHÔNG dùng service account key):** +Chi tiết tại `docs/00-gcp-onboarding.md` Bước 6b. Cần làm xong trước Phase 5. + +**Setup pre-commit hook:** tạo file `.pre-commit-config.yaml` với hooks Gitleaks + +Checkov, chạy `pre-commit install`. Chi tiết tại `docs/00-gcp-onboarding.md` Bước 8. + +**Bắt buộc:** Vào `console.cloud.google.com/billing` → tạo budget $50/tháng với alert 50%/90%/100%. Budget không tự tắt tài nguyên — bạn phải chủ động `destroy` sau mỗi session GCP. + +Chạy `bash scripts/00-prerequisites.sh` — tất cả `✓` là sẵn sàng. + +--- + +## Phase 1 — Local Kubernetes + Go Service + ArgoCD (Security Baseline) +*CNCF Graduated: **Kubernetes · Helm · ArgoCD** · CNCF Incubating: **cert-manager*** +*DevSecOps: Distroless image, non-root pod, Trivy scan gate, Govulncheck, SBOM* + +Phase này 100% miễn phí, chạy hoàn toàn trên máy local. Security baseline được +áp dụng ngay từ Phase 1 (shift-left), không đợi đến Phase 3. + ```bash -# Tìm PANIC line trong log -kubectl -n taskr logs --previous | grep -i panic -# Rollback về version trước nếu do code mới -kubectl argo rollouts undo task-api -n taskr +make prereq # kiểm tra lần cuối +make cluster-up # kind tạo cluster 3 node (~5 phút lần đầu) +kubectl get nodes # phải thấy 3 node STATUS=Ready ``` -Exit 1 (config error - biến môi trường thiếu): +`make bootstrap` cài ba thành phần theo thứ tự: **ingress-nginx** (L7 router, port 80/443 forward vào cluster), **cert-manager** *(CNCF Incubating)* (TLS tự động, local dùng self-signed, GCP đổi sang Let's Encrypt không cần sửa code), **ArgoCD** *(CNCF Graduated)* (GitOps engine, resource đã tối giản cho 8GB RAM). + ```bash -# Xem env hiện tại của pod -kubectl -n taskr exec -- env | grep -E 'DATABASE|SERVICE|HTTP' -# Nếu thiếu env, kiểm tra deployment.yaml env section +make bootstrap +echo '127.0.0.1 taskr.local argocd.local' | sudo tee -a /etc/hosts + +# Build, scan, SBOM, và deploy Go service +cd services/task-api && go mod tidy && govulncheck ./... && cd ../.. +make build # Docker distroless image ~20MB, non-root, load vào kind +make scan-image # Trivy scan CVE — GATE trước khi deploy +syft task-api:local -o spdx-json=task-api-sbom.spdx.json +make deploy-task-api # chỉ chạy nếu scan pass + +# Verify security context đã được áp dụng +kubectl -n taskr get pod -l app.kubernetes.io/name=task-api \ + -o jsonpath='{.items[0].spec.securityContext}' | jq +# Phải có: runAsNonRoot:true, runAsUser:65532 + +# Smoke test +kubectl -n taskr get pods # Running 1/1 +make smoke-test # nhận JSON hợp lệ = Phase 1 xong +open http://argocd.local # admin / $(make get-argocd-password) ``` +**Security baseline đã có sau Phase 1:** +- Image dùng distroless non-root (không shell, không package manager) +- Pod chạy với `runAsNonRoot: true, runAsUser: 65532` +- Trivy scan là gate trước deploy, image có CVE HIGH/CRITICAL bị block +- Govulncheck quét Go module CVE trước build +- SBOM được sinh ra cho mọi image build (artifact để theo dõi CVE sau này) + +**Lỗi thường gặp:** `ImagePullBackOff` → chạy lại `make build`. `502 Bad Gateway` → đợi 30 giây. Port 80 bị chiếm → `sudo lsof -i :80`. `make scan-image fail` → đọc CVE list, update base image hoặc dependency. + --- -## Scenario 2: ArgoCD stuck ở Progressing / OutOfSync +## Phase 2 — Observability + Security Signal +*CNCF Graduated: **Prometheus · OpenTelemetry SDK + Collector*** +*Non-CNCF: Grafana · Loki · Tempo (Grafana Labs, open source)* +*DevSecOps: Audit log scrape, security dashboard, security alert* -**Triệu chứng:** ArgoCD UI hiện vàng "Progressing" quá 5 phút +Làm Phase 2 **trước** Phase 3: nếu security vỡ thứ gì, bạn cần Grafana để debug. OpenTelemetry *(CNCF Graduated)* đóng vai trò abstraction layer — metrics/traces từ Go service qua OTel Collector đến Prometheus và Tempo mà không lock-in vendor. Phase 2 cũng thiết lập nơi nhận security signal cho Phase 3 (Falco alert, Kyverno violation). -**Chẩn đoán:** ```bash -# Xem chi tiết sync status -kubectl -n argocd get app task-api-local -o jsonpath='{.status.conditions}' | jq +echo '127.0.0.1 grafana.local prometheus.local' | sudo tee -a /etc/hosts -# Xem resource nào đang fail -kubectl -n argocd get app task-api-local -o jsonpath='{.status.operationState}' | jq +# Merge code Phase 2 (main.go + router.go + go.mod đã thêm OTel SDK) +cd services/task-api && go mod tidy && cd ../.. +make build scan-image deploy-task-api # rebuild với OTel instrumentation + scan gate -# Xem log của ArgoCD application controller -kubectl -n argocd logs deployment/argocd-application-controller --tail=50 +# Cài toàn bộ observability stack (~10 phút, pull ~2GB images) +make bootstrap-observability ``` -**Xử lý phổ biến:** +Tổng RAM thêm ~900Mi — đã tối giản cho 8GB: Prometheus 256Mi, Grafana 128Mi, Loki 128Mi, Tempo 128Mi, OTel Collector 64Mi. -Resource conflict (ai đó kubectl edit thủ công): ```bash -# Force sync với prune -argocd app sync task-api-local --force --prune -# Hoặc từ UI: click "Sync" → check "Force" → "Synchronize" +open http://grafana.local # admin / taskr-grafana-admin +# Dashboard "task-api — RED Metrics" tự load từ ConfigMap +# Dashboard "Security Overview" cũng được load (chuẩn bị cho Phase 3) +make smoke-test # generate traffic +# Grafana Explore → Loki → {namespace="taskr"} để xem log tập trung +# Grafana Explore → Loki → {source="kube-audit"} để xem audit log ``` -Helm chart version không tồn tại: +**3 alert rule operational** sẵn tại `http://prometheus.local/alerts`: service down 5 phút, error rate >5%, latency p99 >500ms. + +**3 alert rule security** chuẩn bị trước cho Phase 3: số admission denial bất thường, số 401/403 vượt ngưỡng, syscall execve từ container không mong đợi (Falco rule sẽ feed vào ở Phase 3). + +--- + +## Phase 3 — Security (Defense in Depth, 4 lớp) +*CNCF Graduated: **Linkerd** (mTLS) · CNCF Incubating: **Kyverno · Falco*** +*Non-CNCF: Sealed Secrets (Bitnami) · Trivy + Trivy Operator (Aqua Security)* +*WAF: ModSecurity OWASP CRS (embedded trong ingress-nginx)* + +Thứ tự bên trong Phase 3 quan trọng: **Kyverno trước → NetworkPolicy → Linkerd → Falco → ModSecurity → Sealed Secrets**. + ```bash -# Kiểm tra chart version trong Application spec -kubectl -n argocd get app prometheus-stack -o jsonpath='{.spec.source.targetRevision}' -# Tìm version hợp lệ: helm search repo prometheus-community/kube-prometheus-stack --versions | head -5 +make bootstrap-security # cài Kyverno + 7 ClusterPolicy + Falco + Sealed Secrets + Trivy Operator +make policy-check # thử deploy pod root → phải bị Kyverno reject ``` -CRD conflict khi upgrade: +**7 Kyverno ClusterPolicy** *(CNCF Incubating)* enforce: cấm root, bắt buộc resource requests, chỉ trusted registry, yêu cầu label chuẩn, cấm tag `latest`, `require-non-root` (chặn pod thiếu runAsNonRoot:true), `disallow-host-namespace` (chặn pod dùng hostPID/hostNetwork). + ```bash -# Apply CRD thủ công trước -kubectl apply --server-side -f https://raw.githubusercontent.com/.../crds.yaml -# Sau đó sync lại -argocd app sync +# NetworkPolicy: zero-trust cho namespace taskr +kubectl apply -f platform/security/networkpolicy/taskr-policies.yaml +make smoke-test # ingress phải vẫn hoạt động sau default-deny-all + +# Sealed Secrets: encrypt secret trước khi commit Git +kubectl create secret generic db-credentials \ + --from-literal=password=my-password --namespace taskr \ + --dry-run=client -o yaml | kubeseal --format yaml \ + > platform/security/sealed-secrets/db-credentials.yaml +git add platform/security/sealed-secrets/db-credentials.yaml # an toàn commit + +# Container scan +make scan-image # Trivy quét CVE trong Docker image ``` ---- +**Linkerd** *(CNCF Graduated)* inject sidecar vào namespace taskr, mọi giao tiếp east-west tự động được mã hóa mTLS — không cần thay đổi code application. -## Scenario 3: Metrics không xuất hiện trong Grafana +**Falco** *(CNCF Incubating)* DaemonSet theo dõi syscall, alert qua Alertmanager khi có hành vi bất thường: shell spawn trong container task-api, write file ngoài /tmp, outbound connection đến IP ngoài cluster. Falco event → Loki qua FluentBit → Grafana dashboard "Security Overview". -**Triệu chứng:** Dashboard task-api trống, "No data" +**ModSecurity OWASP CRS** bật trong ingress-nginx (`enable-modsecurity: "true"`, paranoia level 1) chặn SQLi/XSS ở edge. -**Chẩn đoán theo luồng:** -```bash -# 1. task-api có expose /metrics không? -kubectl -n taskr port-forward svc/task-api 8080:80 & -curl http://localhost:8080/metrics | head -20 +**Trivy Operator** scan toàn bộ workload cluster-wide theo CronJob hằng ngày, report ra CRD `VulnerabilityReport` xem được bằng `kubectl get vulnerabilityreports -A`. + +--- -# 2. Prometheus có scrape được không? -# Mở http://prometheus.local/targets và tìm taskr -# Status phải là UP +## Phase 4 — PostgreSQL + GCP Deploy (IaC Security) +*CNCF Sandbox: **CloudNativePG** · Non-CNCF: Terraform · Velero* +*DevSecOps: Checkov + tfsec IaC scan, Security Command Center, CMEK encryption* -# 3. Prometheus có ServiceMonitor/annotation không? -kubectl -n taskr get pod -o jsonpath='{.metadata.annotations}' | jq -# Phải thấy prometheus.io/scrape: "true" +Lần đầu tốn credit GCP. **Hexagonal architecture payoff**: domain layer Go không đổi một dòng, chỉ swap adapter từ memory sang postgres. -# 4. Kiểm tra Prometheus scrape config -kubectl -n observability exec -it pod/prometheus-kube-prometheus-stack-prometheus-0 -- \ - wget -qO- localhost:9090/api/v1/targets | jq '.data.activeTargets[] | select(.labels.namespace=="taskr")' +```bash +# Cài CloudNativePG operator +kubectl apply -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.24/releases/cnpg-1.24.0.yaml +kubectl apply -f platform/security/sealed-secrets/db-credentials.yaml +kubectl apply -f deploy/task-api/overlays/gcp-demo/postgres-cluster.yaml +# CloudNativePG tự tạo: taskr-postgres-rw (primary) + taskr-postgres-ro (replica) + +# === DevSecOps gate trước khi apply Terraform === +cd infra/terraform/envs/gcp-demo +checkov -d . --quiet --compact # gate 1: misconfig HIGH/CRITICAL → fail +tfsec . --minimum-severity HIGH # gate 2: second opinion +# Chỉ chạy apply nếu cả 2 đều pass + +# GCP infrastructure với Terraform +find infra/terraform -name "*.tf" | xargs sed -i "s/YOUR_PROJECT_ID/taskr-dev-428391/g" +gsutil mb gs://taskr-dev-428391-tfstate +terraform init && terraform apply +cd ../../../.. ``` -**Xử lý:** ```bash -# Nếu annotation thiếu -kubectl -n taskr annotate pod \ - prometheus.io/scrape=true \ - prometheus.io/port=8080 \ - prometheus.io/path=/metrics +make gcp-push GCP_PROJECT=taskr-dev-428391 # build + scan + push lên Artifact Registry +# Artifact Registry tự scan CVE on-push (built-in GCP feature) -# Nếu Grafana datasource sai URL -# Vào http://grafana.local → Configuration → Data Sources → Prometheus -# URL phải là: http://prometheus-operated.observability.svc.cluster.local:9090 +# Lấy LB IP: kubectl -n ingress-nginx get svc ingress-nginx-controller +# Sửa kustomization.yaml: taskr.REPLACE_WITH_LB_IP.nip.io +make gcp-deploy +curl http://taskr.34.142.123.45.nip.io/api/v1/tasks + +# Kiểm tra Security Command Center finding +gcloud scc findings list --organization=YOUR_ORG --filter='state="ACTIVE"' + +# SAU KHI DEMO — BẮT BUỘC +make gcp-down GCP_PROJECT=taskr-dev-428391 # GKE idle ~$0.20/giờ ``` +**Security feature đã bật sẵn trong Terraform module GKE Autopilot:** +- Workload Identity (pod dùng GCP IAM, không dùng service account key) +- Private cluster (node không có public IP) +- Shielded GKE nodes (secure boot + integrity monitoring) +- Encryption at rest cho PostgreSQL với CMEK +- Artifact Registry vulnerability scanning on push +- Cloud Audit Log cho GKE, IAM, Artifact Registry + --- -## Scenario 4: Cluster hết disk (kind local) +## Phase 5 — Canary Deployment + Full DevSecOps Pipeline +*CNCF Graduated: **Argo Rollouts** · CNCF Incubating: **KEDA*** +*DevSecOps: Semgrep SAST, cosign keyless signing, Binary Authorization, OIDC WIF* -**Triệu chứng:** Pod evicted, PersistentVolume fail, node condition DiskPressure +Argo Rollouts *(CNCF Graduated)* thay RollingUpdate bằng canary thông minh: Prometheus + Falco làm gate tự động quyết định promote hay rollback. -**Chẩn đoán:** ```bash -# Check disk usage trên node -docker exec taskr-control-plane df -h -docker exec taskr-worker df -h - -# Xem node condition -kubectl describe node | grep -A5 Conditions +make bootstrap-rollouts +kubectl -n taskr delete deployment task-api +kubectl apply -f platform/rollouts/task-api-rollout.yaml +# Canary flow: 10% → 5 phút → Prometheus + Falco check → 25% → 50% → 100% +# Error rate >5% HOẶC Falco HIGH event → auto-rollback ``` -**Xử lý:** -```bash -# Xóa unused Docker images trên host -docker image prune -f - -# Xóa log cũ trong cluster (nếu Loki persistence enabled) -kubectl -n observability exec -it pod/loki-0 -- \ - find /var/loki -name "*.gz" -mtime +1 -delete +**GitHub Actions CI/CD Pipeline — 4 stage DevSecOps:** -# Reset cluster nếu cần -make clean && make cluster-up && make bootstrap +``` +Stage 1 Pre-flight: Gitleaks + Checkov + tfsec → fail = block PR +Stage 2 Quality: go vet + golangci-lint + go test + Semgrep + Govulncheck +Stage 3 Build: Multi-stage Docker → Trivy scan → Syft SBOM → Cosign sign +Stage 4 Deploy: GCP OIDC (WIF) → bump tag → ArgoCD sync → Binary Auth verify ``` ---- +Thêm secrets vào GitHub repo (Settings → Secrets and variables → Actions): +`GCP_PROJECT_ID`, `GCP_WIF_PROVIDER` (full resource name từ Phase 0), +`GCP_SERVICE_ACCOUNT` (`github-deployer@...`). **Không** thêm +`GOOGLE_CREDENTIALS` hay service account key — OIDC thay thế toàn bộ. -## Scenario 5: Canary rollout bị stuck ở Paused +Bật Binary Authorization policy trên GKE chỉ cho phép image đã sign: -**Triệu chứng:** `kubectl argo rollouts get rollout task-api -n taskr` hiện Paused +```bash +gcloud container binauthz policy import policy-strict.yaml +# Image không sign sẽ bị reject ở admission, không pod nào chạy được +``` -**Chẩn đoán:** ```bash -# Xem trạng thái chi tiết +# Demo auto-rollback +kubectl argo rollouts set image task-api task-api=task-api:buggy -n taskr kubectl argo rollouts get rollout task-api -n taskr -w +# Quan sát: hệ thống tự rollback sau ~10 phút, không downtime +``` -# Xem Analysis result -kubectl -n taskr get analysisrun -l rollout-name=task-api +--- -# Xem metric value thực tế -kubectl -n taskr describe analysisrun -``` +## Phase 6 — FinOps & Vận hành + Security Chaos +*CNCF Incubating: **Chaos Mesh** · CNCF Sandbox: **OpenCost*** +*DevSecOps: Security chaos experiment, audit log review, quarterly access review* -**Xử lý:** ```bash -# Option 1: Promote thủ công (nếu bạn tin là OK) -kubectl argo rollouts promote task-api -n taskr +kubectl apply -f platform/finops/opencost.yaml # ResourceQuota + LimitRange + OpenCost +make cost-report # cost allocation per namespace trong 24h +``` -# Option 2: Abort và rollback về stable -kubectl argo rollouts abort task-api -n taskr -kubectl argo rollouts undo task-api -n taskr +**Chaos Mesh** *(CNCF Incubating)* verify HA và security không chỉ trên giấy: -# Option 3: Điều chỉnh threshold nếu metric query sai -# Sửa AnalysisTemplate, commit, ArgoCD sync +```bash +# 3 operational chaos experiment +kubectl apply -f platform/finops/chaos-experiments.yaml +# pod-kill · network-delay · cpu-stress + +# 3 security chaos experiment (DevSecOps) +kubectl apply -f platform/finops/security-chaos.yaml +# kyverno-kill (verify pod root vẫn bị block ở API server level) +# falco-network-disrupt (verify alert retry không mất event) +# policy-bypass-attempt (verify hostNetwork=true bị reject + audit log) + +# Song song chạy: for i in {1..60}; do curl -s http://localhost/api/v1/tasks; sleep 5; done +# Kết quả: pod tự heal, HTTP vẫn 200 xuyên suốt, không có pod root nào tồn tại +kubectl delete -f platform/finops/chaos-experiments.yaml +kubectl delete -f platform/finops/security-chaos.yaml ``` ---- +**Audit review hằng tuần:** chạy `scripts/audit-review.sh` extract từ Cloud Audit +Log các sự kiện bất thường trong 7 ngày qua (IAM policy change, service account +key create, failed gcloud command từ external IP), gửi report email. + +**Quarterly access review** checklist trong runbook: list tất cả service account, +role binding, GitHub collaborator. Mỗi cái phải có owner xác nhận còn cần thiết. + +Đọc `docs/runbook.md` **trước khi** có incident: 8 scenario (5 operational + +3 security) với triệu chứng → chẩn đoán → xử lý step-by-step. -## Quy trình postmortem (sau mọi incident P1/P2) +--- -Template: `docs/postmortem-template.md` +## Tóm tắt · Checklist hoàn thành -Bắt buộc điền trong 5 ngày: -1. Timeline (UTC, từng phút) -2. Impact (số user bị ảnh hưởng, thời gian downtime) -3. Root cause (dùng 5-Why) -4. Điều gì hoạt động tốt -5. Điều gì không hoạt động tốt -6. Action items (assignee + deadline cụ thể) +``` +Phase 0 ✓ gcloud auth + budget alert + Workload Identity Federation + pre-commit +Phase 1 ✓ make smoke-test → JSON hợp lệ + Trivy scan pass + SBOM sinh ra + ArgoCD UI +Phase 2 ✓ Grafana RED dashboard có data + Loki có log + Security Overview ready +Phase 3 ✓ Kyverno reject pod root + Falco DaemonSet running + mTLS Linkerd + ModSec ON +Phase 4 ✓ Checkov + tfsec pass + curl taskr..nip.io/api/v1/tasks + make gcp-down +Phase 5 ✓ CI 4 stage pass + cosign sign image + Binary Auth verify + auto-rollback OK +Phase 6 ✓ operational chaos pass + security chaos pass + audit report sinh ra +``` -**Nguyên tắc blameless:** postmortem tập trung vào hệ thống, không vào cá nhân. +> **Nguyên tắc chi phí:** Phase 1–3 = $0 (local). Phase 4–6 = ~$0.20/giờ GCP. +> Với $300 credit, bạn có hơn 200 giờ demo session nếu luôn nhớ `make gcp-down`. +> +> **Nguyên tắc DevSecOps:** mỗi check bảo mật chạy ở giai đoạn sớm nhất có thể. +> Secret scan ở pre-commit, SAST ở PR, IaC scan trước terraform apply, +> image scan trước push, signature verify trước pull. Không bypass, không skip. \ No newline at end of file