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/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..b521778 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 | ✓ 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 | + +--- + +## 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/_articles/DEPLOYMENT-GUIDE-CNCF.md b/_articles/DEPLOYMENT-GUIDE-CNCF.md new file mode 100644 index 0000000..2f5ae48 --- /dev/null +++ b/_articles/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/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';