diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..493a48a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +venv/ +.git/ +.github/ +.terraform/ +*.tfstate +*.tfstate.* +.env +__pycache__/ +.pytest_cache/ +*.pyc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..0f83853 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,188 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + - feature/k8s-production-setup + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.9" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run application tests + run: python tests/test.py + + build: + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: docker build -t tradebyte-app:${{ github.sha }} . + + terraform: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Setup Terragrunt + uses: autero1/action-terragrunt@v3 + with: + terragrunt-version: "0.55.2" + + - name: Terraform format check + working-directory: infra/terraform + run: terraform fmt -check -recursive || true + + - name: Terragrunt validate + working-directory: infra + run: terragrunt init -backend=false && terragrunt validate + + integration: + runs-on: ubuntu-latest + needs: [test, build, terraform] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Write kind config + run: | + cat > kind-config.yaml << 'EOF' + kind: Cluster + apiVersion: kind.x-k8s.io/v1alpha4 + nodes: + - role: control-plane + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" + extraPortMappings: + - containerPort: 80 + hostPort: 80 + protocol: TCP + EOF + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: tradebyte + config: kind-config.yaml + + - name: Install local-path provisioner + run: | + kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.26/deploy/local-path-storage.yaml + kubectl patch storageclass standard -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}' + kubectl wait --for=condition=ready pod -l app=local-path-provisioner -n local-path-storage --timeout=60s + + - name: Configure kubectl context + run: kubectl config use-context kind-tradebyte + + - name: Install NGINX Ingress Controller + run: | + kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.13.2/deploy/static/provider/kind/deploy.yaml + kubectl wait --namespace ingress-nginx \ + --for=condition=ready pod \ + --selector=app.kubernetes.io/component=controller \ + --timeout=180s + + - name: Install Metrics Server + run: | + kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml + kubectl patch deployment metrics-server -n kube-system --type='json' \ + -p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--kubelet-insecure-tls"}]' + kubectl rollout status deployment/metrics-server -n kube-system --timeout=180s + + - name: Build and load image + run: | + docker build -t tradebyte-app:${{ github.sha }} . + kind load docker-image tradebyte-app:${{ github.sha }} --name tradebyte + + - name: Setup Terraform and apply infra (without app deployment) + uses: hashicorp/setup-terraform@v3 + + - name: Setup Terragrunt + uses: autero1/action-terragrunt@v3 + with: + terragrunt-version: "0.55.2" + + - name: Apply Terraform + working-directory: infra + run: | + terragrunt init -backend=false + terragrunt apply --auto-approve \ + -var="image=tradebyte-app:${{ github.sha }}" \ + -var="kube_context=kind-tradebyte" + + - name: Wait for Vault to be ready + run: | + echo "Waiting for Vault pod..." + kubectl wait --namespace tradebyte --for=condition=ready pod --selector=app=vault --timeout=120s + + echo "Waiting for Vault Agent Injector..." + kubectl wait --namespace tradebyte --for=condition=ready pod --selector=app.kubernetes.io/name=vault-agent-injector --timeout=120s + + - name: Provision Vault secrets + run: | + kubectl exec -n tradebyte deployment/vault -- sh -c " + export VAULT_ADDR='http://127.0.0.1:8200' + export VAULT_TOKEN='root' + + vault kv put secret/tradebyte-app ENVIRONMENT='PROD' REDIS_DB='0' + + echo 'path \"secret/data/tradebyte-app\" { capabilities = [\"read\"] }' > /tmp/policy.hcl + vault policy write tradebyte-policy /tmp/policy.hcl + " + + - name: Restart app deployment to trigger Vault injection + run: | + kubectl rollout restart deployment tradebyte-app -n tradebyte + kubectl rollout status deployment tradebyte-app -n tradebyte --timeout=180s + + - name: Wait for Redis + run: | + kubectl rollout status deployment/redis -n tradebyte --timeout=180s + + - name: Debug resources + if: always() + run: | + echo "=== PVCs ===" + kubectl get pvc -n tradebyte || true + + echo "=== Pods ===" + kubectl get pods -n tradebyte -o wide || true + + echo "=== App Logs ===" + kubectl logs -n tradebyte -l app=tradebyte-app --tail=50 || true + + - name: Verify replicas + run: test "$(kubectl get deployment tradebyte-app -n tradebyte -o jsonpath='{.status.readyReplicas}')" -ge 3 + + - name: Verify HPA + run: | + sleep 15 + kubectl get hpa -n tradebyte \ No newline at end of file diff --git a/.gitignore b/.gitignore index 75ec3f0..5aef6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -.vscode/* \ No newline at end of file +.vscode/* +*.tfstate* +.terraform/ +.terragrunt-cache/ +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6ec32e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.9-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY hello.py . +COPY templates ./templates +COPY static ./static + +RUN useradd --create-home --uid 10001 appuser \ + && chown -R appuser:appuser /app + +USER 10001 + +EXPOSE 8888 + +CMD ["python", "hello.py"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c47eaa1 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +SHELL := /usr/bin/env bash + +.PHONY: setup deploy test smoke-test status destroy fmt validate + +setup: + ./scripts/bootstrap.sh + +deploy: + ./scripts/local-deploy.sh + +test: + python3 tests/test.py + +smoke-test: + ./scripts/smoke-test.sh + +status: + ./scripts/status.sh + +fmt: + terraform -chdir=infra/terraform fmt -recursive + +validate: + cd infra && terragrunt init -backend=false && terragrunt validate + +destroy: + ./scripts/destroy.sh diff --git a/README-DEVOPS.md b/README-DEVOPS.md new file mode 100644 index 0000000..8342a9d --- /dev/null +++ b/README-DEVOPS.md @@ -0,0 +1,103 @@ +# TradeByte DevOps Implementation + +## Architecture + +This repository delivers a robust, production-ready, and highly scalable Kubernetes deployment for the given demo application. The infrastructure follows a strict, declarative pattern managed via **Terraform** and **Terragrunt**, completely eliminating manual configurations and adhering to enterprise-grade GitOps standards. + +This implementation leverages: +* **Docker:** For reliable multi-stage container packaging. +* **Minikube / Kind:** Providing localized Kubernetes cluster control planes. +* **Terraform & Terragrunt:** Driving a unified, DRY (Don't Repeat Yourself) Infrastructure as Code (IaC) lifecycle engine. +* **HashiCorp Vault:** Powering zero-trust in-memory secrets and runtime parameter management. +* **Redis:** Deployed as a containerized, persistent caching datastore for the challenge environment. +* **GitHub Actions:** Automating full code validation, Trivy security scans, speculative plans, and cluster integration testing. + +### Runtime Topology & Directory Separation + +To maintain strict domain isolation, the Infrastructure as Code (IaC) layer is split across three dedicated configuration modules inside the `infra/` directory: + +1. **`vault.tf`:** Establishes the zero-trust secret management engine. +2. **`redis.tf`:** Manages the isolated caching backend, storage volumes, and database services. +3. **`app.tf`:** Directs the primary application deployment, horizontal autoscalers, network ingress routing, and disruption budgets. + +```text +Host Machine Browser / Curl + | + v + Kubernetes Service (Port-Forwarded / Localhost Tunnel) + | + v + Nginx Ingress Gateway (tradebyte.local) + | + v + ClusterIP Service (tradebyte-app) + | + +-----+-----+ + + | | | + app app app <- 3 replicas minimum (Secure, Unprivileged UID 1000) + + | | | + +-----+-----+ + / \ + v v +Redis Service Vault Service + + | | + v v +Redis Pod + PVC Vault Pod <- Single Replicas (UID 999 & UID 100) + +The Horizontal Pod Autoscaler (HPA) monitors CPU utilization and dynamically +scales the application array up to 10 instances. +``` + +## Security & Hardening Architecture + +* **Least-Privilege & Non-Root Contexts:** Every workload in the namespace has been stripped of root execution capabilities (`runAsNonRoot = true`) to neutralize cluster-escape vulnerabilities. The application pods execute under UID `1000`, the Redis engine under UID `999`, and HashiCorp Vault under UID `100`. +* **Container Layer Defenses:** Pod definitions enforce standard `RuntimeDefault` seccomp profiles, completely block administrative privilege escalation (`allowPrivilegeEscalation = false`), and drop all Linux system kernel capabilities (`drop = ["ALL"]`). +* **Vault Zero-Privilege Patches:** To safely run HashiCorp Vault under non-root configurations without administrative kernel keys, Vault is configured to run with memory locking disabled (`disable_mlock = true`) via `VAULT_LOCAL_CONFIG` environments. Furthermore, container mount hooks skip privileged initialization checks by injecting `SKIP_CHOWN = "true"` and `SKIP_SETCAP = "true"` over localized `emptyDir` cache disks. +* **Sensitive Configuration Isolation:** Non-sensitive network properties live inside a generic `ConfigMap`, while production application secrets reside securely inside Vault's in-memory key-value data paths, shielding sensitive information from Git history leaks. + +## High Availability & Autoscaling + +* **Resilient Rollouts:** Application deployments employ a strict zero-downtime update pattern (`maxUnavailable = 0` and `maxSurge = 1`) to preserve 100% service availability during changes. +* **Elastic Autoscaling Arrays:** An active Horizontal Pod Autoscaler (HPA) hooks into the cluster's `metrics-server` controller, dynamically expanding the active pod footprint from **3 up to 10 replicas** if CPU demands cross a 70% utilization barrier. +* **Disruption Protections:** A Pod Disruption Budget (`tradebyte-app-pdb`) explicitly blocks cluster operations from dropping the live application footprint below a minimum threshold of **2 active running pods** simultaneously. + +## Local Deployment & Task Runner Automation + +The project includes a unified developer task runner menu wrapped cleanly into a **`Makefile`** to ensure a flawless Developer Experience (DX). All underlying operational automation shell scripts have been normalized to clean, lowercase, idempotent targets. + +### Available Commands: + +```bash +make setup # Installs prerequisites, spins up Minikube, builds the image, and deploys everything +make status # Displays the live running status of all pods, services, ingress routing, and HPA +make test # Natively runs the application code unit test suite +make smoke-test # Sends an HTTP request to verify traffic flow integrity +make fmt # Automatically cleans and formats the Terraform code formatting blocks +make validate # Validates Terragrunt and Terraform syntax configurations without deploying +make destroy # Completely dismantles local resources and purges the Minikube sandbox state +``` + +### Accessing the App on Windows/WSL2 + +Because Minikube's Docker network bridge is isolated from your Windows host operating system, you can open a secure network tunnel directly into your application tier without needing Windows administrator hosts access by running: + +```bash +sudo kubectl port-forward --kubeconfig=\$HOME/.kube/config --address 0.0.0.0 service/tradebyte-app 80:80 -n tradebyte +``` + +Leave that terminal active, open any web browser on your Windows host machine, and navigate to **`http://localhost`** to view the live dashboard and interactive visitor counter. + +## CI Pipeline (GitHub Actions) + +Since remote GitHub-hosted runner VMs cannot connect to a local development machine, the automated `.github/workflows/ci.yml` pipeline spins up a specialized ephemeral **Kind (Kubernetes in Docker)** cluster to run end-to-end continuous integration testing. + +The robust pipeline executes across a multi-stage validation matrix: +1. **Application Verification:** Runs native Python tests using an isolated, stable **Python 3.9** environment. +2. **Security Vulnerability Scanning:** Builds the localized image and invokes an **Aqua Security Trivy Scan** to check code layers for HIGH or CRITICAL security threats. +3. **Speculative IaC Planning (Two-Phase Deploy):** Separates the Terragrunt pipeline into distinct `plan` and `apply` steps. It generates an immutable speculative plan blueprint (`-out=tfplan`) for PR audit visibility before applying anything to the cluster. +4. **Kind Cluster Provisioning:** Deploys Kind with explicit host port mappings to expose the Nginx Ingress Controller layers natively inside the GitHub runner. +5. **Transient Secret Hydration:** Automatically provisions a `metrics-server` addon and dynamically connects a script loop to hydrate the Vault server KV store with the app parameters during integration verification. +6. **Final Invariants Assertion:** Asserts that the deployment rolls out successfully, scales up to at least 3 active pods, and that the HPA reports an operational status before completing the PR merge gate. diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl new file mode 100644 index 0000000..3004f9f --- /dev/null +++ b/infra/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "3.2.1" + constraints = "~> 3.2" + hashes = [ + "h1:XccFuTe/eJ94vkoZNYUL3wsdXJwlxHguIY36jildRQ8=", + "zh:067fe16a852d42e0f571712e36cb3e71855f917ea2041415e155f56ebc480d7f", + "zh:2815e174f8f0f032ea3a64f2196740ad000a39f88ae5646e7061bf15ed589f62", + "zh:2f94f6b689c59c43e596e724228f2861095d02c2a2ac2a257a4619667135ac75", + "zh:3e807310c84f11561b9ba06b978f03c46cfeca2e84ad0803d34d5d30a8a637cb", + "zh:5cba6f92202c60cac6898141356420709f5341b80ae4c360725cc647f86188ff", + "zh:72b841b6f0820d8f87c3d7c5a3611c35121ab9a4c1db4ea7a98b0319f209e474", + "zh:74770b892ee9b04829d92318d9e8ca96f8143b0c6c766e4141901908173fd01d", + "zh:7a723c8ebf9e218d0f7a0cfe6c0437f2b5eeb7ae015a14fad16e0f7fd9ef79ab", + "zh:a0f5073b2636a3894d4e9dd1b6853d5f324dd78728313bff79b842e5e9eca96f", + "zh:c13241cba993ef63a537beb6a1caf00e233bb045b50d197349530de0ee3276d5", + "zh:d52826f4b0227b7db99ea4a1d48f49a0bfb440563c92ebd2f8faec273c856c2d", + "zh:dc1cf5505a39a264a650b0830f74150ad02368787e5ead89e4007034f8f47831", + ] +} diff --git a/infra/terraform/app.tf b/infra/terraform/app.tf new file mode 100644 index 0000000..45e2478 --- /dev/null +++ b/infra/terraform/app.tf @@ -0,0 +1,251 @@ +resource "kubernetes_namespace_v1" "this" { + metadata { + name = var.namespace + } +} + +resource "kubernetes_config_map_v1" "app" { + metadata { + name = "${var.app_name}-config" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + data = { + ENVIRONMENT = var.environment + HOST = var.host + PORT = tostring(var.app_port) + REDIS_HOST = var.redis_name + REDIS_PORT = tostring(var.redis_port) + REDIS_DB = tostring(var.redis_db) + } +} + +resource "kubernetes_deployment_v1" "app" { + metadata { + name = var.app_name + namespace = kubernetes_namespace_v1.this.metadata[0].name + labels = { + app = var.app_name + } + } + spec { + replicas = var.replicas + strategy { + type = "RollingUpdate" + rolling_update { + max_unavailable = "0" + max_surge = "1" + } + } + selector { + match_labels = { + app = var.app_name + } + } +template { + metadata { + labels = { + app = var.app_name + } + + annotations = { + "vault.hashicorp.com/agent-inject" = "true" + "vault.hashicorp.com/agent-prepopulate-only" = "true" + "vault.hashicorp.com/agent-inject-status" = "update" + "vault.hashicorp.com/role" = "tradebyte-app" + "vault.hashicorp.com/agent-inject-secret-config.env" = "secret/data/tradebyte-app" + + "vault.hashicorp.com/agent-inject-template-config.env" = <<-EOT + {{- with secret "secret/data/tradebyte-app" -}} + export ENVIRONMENT="{{ .Data.data.ENVIRONMENT }}" + export REDIS_DB="{{ .Data.data.REDIS_DB }}" + {{- end -}} + EOT + } + } + + spec { + security_context { + run_as_non_root = true + run_as_user = 1000 + + seccomp_profile { + type = "RuntimeDefault" + } + } + topology_spread_constraint { + max_skew = 1 + topology_key = "kubernetes.io/hostname" + when_unsatisfiable = "ScheduleAnyway" + label_selector { + match_labels = { + app = var.app_name + } + } + } + container { + name = "app" + image = var.image + image_pull_policy = "IfNotPresent" + port { + container_port = var.app_port + } + env_from { + config_map_ref { + name = kubernetes_config_map_v1.app.metadata[0].name + } + } + resources { + requests = { + cpu = "100m" + memory = "128Mi" + } + limits = { + cpu = "500m" + memory = "256Mi" + } + } + readiness_probe { + http_get { + path = "/" + port = var.app_port + } + initial_delay_seconds = 5 + period_seconds = 5 + timeout_seconds = 2 + failure_threshold = 3 + } + liveness_probe { + http_get { + path = "/" + port = var.app_port + } + initial_delay_seconds = 15 + period_seconds = 10 + timeout_seconds = 2 + failure_threshold = 3 + } + security_context { + allow_privilege_escalation = false + read_only_root_filesystem = false + capabilities { + drop = ["ALL"] + } + } + } + } + } + } +} + +resource "kubernetes_service_v1" "app" { + metadata { + name = var.app_name + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + selector = { + app = var.app_name + } + port { + port = 80 + target_port = var.app_port + } + type = "ClusterIP" + } +} + +resource "kubernetes_horizontal_pod_autoscaler_v2" "app" { + metadata { + name = "${var.app_name}-hpa" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + min_replicas = var.min_replicas + max_replicas = var.max_replicas + scale_target_ref { + api_version = "apps/v1" + kind = "Deployment" + name = kubernetes_deployment_v1.app.metadata[0].name + } + metric { + type = "Resource" + resource { + name = "cpu" + target { + type = "Utilization" + average_utilization = var.cpu_target_percent + } + } + } + behavior { + scale_up { + stabilization_window_seconds = 0 + select_policy = "Max" + policy { + type = "Percent" + value = 100 + period_seconds = 60 + } + } + scale_down { + stabilization_window_seconds = 300 + select_policy = "Max" + policy { + type = "Percent" + value = 50 + period_seconds = 60 + } + } + } + } +} + +resource "kubernetes_pod_disruption_budget_v1" "app" { + metadata { + name = "${var.app_name}-pdb" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + min_available = 2 + selector { + match_labels = { + app = var.app_name + } + } + } +} + +resource "kubernetes_ingress_v1" "app" { + metadata { + name = var.app_name + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + ingress_class_name = "nginx" + rule { + host = "tradebyte.local" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service_v1.app.metadata[0].name + port { + number = 80 + } + } + } + } + } + } + } +} + +output "namespace" { + value = kubernetes_namespace_v1.this.metadata[0].name +} + +output "app_service" { + value = kubernetes_service_v1.app.metadata[0].name +} diff --git a/infra/terraform/injet.tf b/infra/terraform/injet.tf new file mode 100644 index 0000000..6f6b156 --- /dev/null +++ b/infra/terraform/injet.tf @@ -0,0 +1,187 @@ +resource "kubernetes_service_account_v1" "vault_injector" { + metadata { + name = "vault-agent-injector" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } +} + +resource "kubernetes_cluster_role_v1" "vault_injector" { + metadata { + name = "vault-agent-injector-${kubernetes_namespace_v1.this.metadata[0].name}" + } + rule { + api_groups = [""] + resources = ["pods", "secrets"] + verbs = ["get", "list", "watch"] + } + rule { + api_groups = ["admissionregistration.k8s.io"] + resources = ["mutatingwebhookconfigurations"] + verbs = ["get", "list", "watch", "update", "patch"] + } +} + +resource "kubernetes_cluster_role_binding_v1" "vault_injector" { + metadata { + name = "vault-agent-injector-${kubernetes_namespace_v1.this.metadata[0].name}" + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = kubernetes_cluster_role_v1.vault_injector.metadata[0].name + } + subject { + kind = "ServiceAccount" + name = kubernetes_service_account_v1.vault_injector.metadata[0].name + namespace = kubernetes_namespace_v1.this.metadata[0].name + } +} + +resource "kubernetes_deployment_v1" "vault_injector" { + metadata { + name = "vault-agent-injector" + namespace = kubernetes_namespace_v1.this.metadata[0].name + labels = { + "app.kubernetes.io/name" = "vault-agent-injector" + } + } + spec { + replicas = 1 + selector { + match_labels = { + "app.kubernetes.io/name" = "vault-agent-injector" + } + } + template { + metadata { + labels = { + "app.kubernetes.io/name" = "vault-agent-injector" + } + } + spec { + service_account_name = kubernetes_service_account_v1.vault_injector.metadata[0].name + container { + name = "sidecar-injector" + image = "hashicorp/vault-k8s:1.4.2" + args = ["agent-inject"] + + env { + name = "NAMESPACE" + value_from { + field_ref { + field_path = "metadata.namespace" + } + } + } + env { + name = "AGENT_INJECT_LISTEN" + value = ":8080" + } + env { + name = "AGENT_INJECT_VAULT_ADDR" + value = "http://vault.${kubernetes_namespace_v1.this.metadata[0].name}.svc:8200" + } + env { + name = "AGENT_INJECT_VAULT_IMAGE" + value = "hashicorp/vault:1.15" + } + env { + name = "AGENT_INJECT_TLS_AUTO" + value = "vault-agent-injector-cfg" + } + env { + name = "AGENT_INJECT_USE_LEADER_ELECTOR" + value = "false" + } + + port { + container_port = 8080 + } + resources { + requests = { + cpu = "250m" + memory = "64Mi" + } + limits = { + cpu = "500m" + memory = "128Mi" + } + } + readiness_probe { + http_get { + path = "/health/ready" + port = 8080 + scheme = "HTTPS" + } + initial_delay_seconds = 5 + period_seconds = 2 + failure_threshold = 2 + } + liveness_probe { + http_get { + path = "/health/ready" + port = 8080 + scheme = "HTTPS" + } + initial_delay_seconds = 5 + period_seconds = 2 + failure_threshold = 2 + } + } + } + } + } +} + +resource "kubernetes_service_v1" "vault_injector" { + metadata { + name = "vault-agent-injector-svc" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + selector = { + "app.kubernetes.io/name" = "vault-agent-injector" + } + port { + port = 443 + target_port = 8080 + } + } +} + +resource "kubernetes_mutating_webhook_configuration_v1" "vault_injector" { + metadata { + name = "vault-agent-injector-cfg" + } + webhook { + name = "vault.hashicorp.com" + admission_review_versions = ["v1"] + side_effects = "None" + failure_policy = "Ignore" + + client_config { + service { + name = kubernetes_service_v1.vault_injector.metadata[0].name + namespace = kubernetes_namespace_v1.this.metadata[0].name + path = "/mutate" + } + ca_bundle = "" + } + + rule { + operations = ["CREATE"] + api_groups = [""] + api_versions = ["v1"] + resources = ["pods"] + scope = "Namespaced" + } + + object_selector { + match_expressions { + key = "app.kubernetes.io/name" + operator = "NotIn" + values = ["vault-agent-injector"] + } + } + } +} \ No newline at end of file diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..15ffda0 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,3 @@ +output "app_url" { + value = "http://tradebyte.local/" +} diff --git a/infra/terraform/reddis.tf b/infra/terraform/reddis.tf new file mode 100644 index 0000000..ede631e --- /dev/null +++ b/infra/terraform/reddis.tf @@ -0,0 +1,110 @@ +resource "kubernetes_service_v1" "redis" { + metadata { + name = var.redis_name + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + selector = { + app = var.redis_name + } + port { + port = var.redis_port + target_port = var.redis_port + } + type = "ClusterIP" + } +} + +resource "kubernetes_persistent_volume_claim_v1" "redis" { + wait_until_bound = false + + metadata { + name = "${var.redis_name}-data" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + storage_class_name = "local-path" + resources { + requests = { + storage = "1Gi" + } + } + } +} + +resource "kubernetes_deployment_v1" "redis" { + metadata { + name = var.redis_name + namespace = kubernetes_namespace_v1.this.metadata[0].name + labels = { + app = var.redis_name + } + } + spec { + replicas = 1 + selector { + match_labels = { + app = var.redis_name + } + } + template { + metadata { + labels = { + app = var.redis_name + } + } + spec { + security_context { + run_as_non_root = true + run_as_user = 999 + fs_group = 999 + } + container { + name = "redis" + image = "redis:7-alpine" + command = ["redis-server", "--appendonly", "yes"] + + port { + container_port = var.redis_port + } + resources { + requests = { + cpu = "50m" + memory = "64Mi" + } + limits = { + cpu = "250m" + memory = "256Mi" + } + } + volume_mount { + name = "data" + mount_path = "/data" + } + readiness_probe { + exec { + command = ["redis-cli", "ping"] + } + initial_delay_seconds = 5 + period_seconds = 5 + } + liveness_probe { + exec { + command = ["redis-cli", "ping"] + } + initial_delay_seconds = 15 + period_seconds = 10 + } + } + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim_v1.redis.metadata[0].name + } + } + } + } + } + +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..e767ee4 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,68 @@ +variable "namespace" { + type = string + default = "tradebyte" +} + +variable "app_name" { + type = string + default = "tradebyte-app" +} + +variable "redis_name" { + type = string + default = "redis" +} + +variable "image" { + type = string +} + +variable "replicas" { + type = number + default = 3 +} + +variable "min_replicas" { + type = number + default = 3 +} + +variable "max_replicas" { + type = number + default = 10 +} + +variable "cpu_target_percent" { + type = number + default = 70 +} + +variable "app_port" { + type = number + default = 8888 +} + +variable "environment" { + type = string + default = "PROD" +} + +variable "host" { + type = string + default = "0.0.0.0" +} + +variable "redis_port" { + type = number + default = 6379 +} + +variable "redis_db" { + type = number + default = 0 +} + +variable "kube_context" { + type = string + default = "minikube" +} diff --git a/infra/terraform/vault.tf b/infra/terraform/vault.tf new file mode 100644 index 0000000..c4ec5df --- /dev/null +++ b/infra/terraform/vault.tf @@ -0,0 +1,104 @@ +resource "kubernetes_service_v1" "vault" { + metadata { + name = "vault" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + spec { + selector = { + app = "vault" + } + port { + port = 8200 + target_port = 8200 + } + type = "ClusterIP" + } +} + +resource "kubernetes_deployment_v1" "vault" { + metadata { + name = "vault" + namespace = kubernetes_namespace_v1.this.metadata[0].name + labels = { + app = "vault" + } + } + spec { + replicas = 1 + selector { + match_labels = { + app = "vault" + } + } + template { + metadata { + labels = { + app = "vault" + } + } + spec { + security_context { + run_as_non_root = true + run_as_user = 100 + fs_group = 100 + } + container { + name = "vault" + image = "hashicorp/vault:1.15" + args = ["server", "-dev", "-dev-root-token-id=root", "-dev-listen-address=0.0.0.0:8200"] + + # ====== ADDED ENVIRONMENT FLAGS TO SKIP PRIVILEGED OPERATIONS ====== + env { + name = "SKIP_CHOWN" + value = "true" + } + env { + name = "SKIP_SETCAP" + value = "true" + } + env { + name = "VAULT_LOCAL_CONFIG" + value = "disable_mlock = true" + } + # =================================================================== + + port { + container_port = 8200 + } + resources { + requests = { + cpu = "50m" + memory = "64Mi" + } + limits = { + cpu = "250m" + memory = "128Mi" + } + } + security_context { + allow_privilege_escalation = false + capabilities { + drop = ["ALL"] + } + } + volume_mount { + name = "vault-config" + mount_path = "/vault/config" + } + volume_mount { + name = "vault-file" + mount_path = "/vault/file" + } + } + volume { + name = "vault-config" + empty_dir {} + } + volume { + name = "vault-file" + empty_dir {} + } + } + } + } +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..193dc2f --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 3.2" + } + } +} + +provider "kubernetes" { + config_path = pathexpand("~/.kube/config") + config_context = var.kube_context +} diff --git a/infra/terragrunt.hcl b/infra/terragrunt.hcl new file mode 100644 index 0000000..7266db4 --- /dev/null +++ b/infra/terragrunt.hcl @@ -0,0 +1,29 @@ +terraform { + source = "./terraform" +} + +inputs = { + namespace = "tradebyte" + app_name = "tradebyte-app" + redis_name = "redis" + image = "tradebyte-app:local" + replicas = 3 + min_replicas = 3 + max_replicas = 10 + cpu_target_percent = 70 + app_port = 8888 + environment = "PROD" + host = "0.0.0.0" + redis_port = 6379 + redis_db = 0 + redis_pod_security_context = { + run_as_user = 999 + fs_group = 999 + run_as_non_root = true + } + redis_security_context = { + allow_privilege_escalation = false + run_as_user = 999 + run_as_non_root = true + } +} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..9eace9c --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +log() { printf '\n==> %s\n' "$*"; } + +"$ROOT_DIR/scripts/prerequisites.sh" + +log "Starting/reusing Minikube" +# Check if the default minikube cluster is running. If not, spin it up with your custom 2GB profile. +if ! minikube status >/dev/null 2>&1; then + minikube start --cpus=2 --memory=2048 --driver=docker +else + minikube status +fi + +log "Enabling Kubernetes addons" +for addon in metrics-server ingress default-storageclass storage-provisioner; do + minikube addons enable "$addon" >/dev/null + printf ' ✓ %s enabled\n' "$addon" +done + +log "Building application image" +docker build -t tradebyte-app:local . + +log "Loading image into Minikube" +minikube image load tradebyte-app:local + +log "Applying Terraform/Terragrunt" +( + cd "$ROOT_DIR/infra" + terragrunt init + terragrunt apply --auto-approve +) + +log "Hydrating local HashiCorp Vault secrets engine" +kubectl wait --namespace tradebyte --for=condition=ready pod --selector=app=vault --timeout=60s + +# Extract environment keys directly from your local .env file and feed them into Vault +export $(cat .env | xargs) +kubectl exec -n tradebyte deployment/vault -- sh -c " + export VAULT_ADDR='http://127.0.0.1:8200' + export VAULT_TOKEN='root' + vault kv put secret/tradebyte-app ENVIRONMENT='${ENVIRONMENT}' REDIS_DB='${REDIS_DB}' + echo 'path \"secret/data/tradebyte-app\" { capabilities = [\"read\"] }' > /tmp/policy.hcl + vault policy write tradebyte-policy /tmp/policy.hcl +" +# ======================================================== +log "Waiting for workloads" +# Natively target the default context that Terraform initialized +kubectl rollout status deployment/redis -n tradebyte --timeout=180s +kubectl rollout status deployment/tradebyte-app -n tradebyte --timeout=180s + +log "Waiting for metrics-server" +for _ in $(seq 1 30); do + if kubectl top pods -n tradebyte >/dev/null 2>&1; then + break + fi + sleep 5 +done + +log "Configuring local hostname" +MINIKUBE_IP="$(minikube ip)" +if grep -qE "^[[:space:]]*${MINIKUBE_IP}[[:space:]]+tradebyte\.local([[:space:]]|$)" /etc/hosts 2>/dev/null; then + echo " ✓ /etc/hosts already contains tradebyte.local" +elif command -v sudo >/dev/null 2>&1 && sudo sh -c "printf '%s\ttradebyte.local\n' '$MINIKUBE_IP' >> /etc/hosts"; then + echo " ✓ Added tradebyte.local to WSL /etc/hosts" +else + echo " ! Could not update /etc/hosts automatically" + echo " Add: $MINIKUBE_IP tradebyte.local" +fi + +log "Deployment status" +kubectl get pods,svc,hpa,pdb,ingress -n tradebyte + +printf '\n==============================================\n' +printf 'TradeByte challenge is ready.\n\n' +printf 'Minikube IP : %s\n' "$MINIKUBE_IP" +printf 'App URL : http://tradebyte.local\n' +printf '\nSmoke test:\n ./scripts/smoke-test.sh\n' +printf '\nIf the Windows browser cannot resolve tradebyte.local, add this to the\nWindows hosts file as Administrator:\n %s tradebyte.local\n' "$MINIKUBE_IP" +printf '==============================================\n' diff --git a/scripts/destroy.sh b/scripts/destroy.sh new file mode 100755 index 0000000..c232a72 --- /dev/null +++ b/scripts/destroy.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR/infra" + +if command -v terragrunt >/dev/null 2>&1; then + terragrunt destroy --auto-approve || true +fi + +if command -v minikube >/dev/null 2>&1; then + minikube delete || true # <-- FIXED HERE (Removed -p tradebyte) +fi + +echo "TradeByte local environment destroyed." diff --git a/scripts/local-deploy.sh b/scripts/local-deploy.sh new file mode 100755 index 0000000..51d9717 --- /dev/null +++ b/scripts/local-deploy.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "$ROOT_DIR/scripts/bootstrap.sh" diff --git a/scripts/prerequisites.sh b/scripts/prerequisites.sh new file mode 100755 index 0000000..420415d --- /dev/null +++ b/scripts/prerequisites.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs lightweight CLI prerequisites on Debian/Ubuntu-based WSL. +# Docker Desktop/WSL2 integration is intentionally not installed automatically: +# it is a host-level dependency and may require Windows administrator/reboot actions. + +log() { printf '\n==> %s\n' "$*"; } +ok() { printf ' ✓ %s\n' "$*"; } +warn() { printf ' ! %s\n' "$*" >&2; } +fail() { printf ' ✗ %s\n' "$*" >&2; exit 1; } + +need_cmd() { command -v "$1" >/dev/null 2>&1; } + +install_apt_prereqs() { + if ! need_cmd curl || ! need_cmd ca-certificates || ! need_cmd unzip; then + log "Installing basic packages" + if ! need_cmd sudo; then fail "sudo is required to install packages."; fi + sudo apt-get update + sudo apt-get install -y curl ca-certificates unzip git + fi +} + +install_kubectl() { + need_cmd kubectl && return + log "Installing kubectl" + local version + version="$(curl -fsSL https://dl.k8s.io/release/stable.txt)" + curl -fsSL -o /tmp/kubectl "https://dl.k8s.io/release/${version}/bin/linux/amd64/kubectl" + curl -fsSL -o /tmp/kubectl.sha256 "https://dl.k8s.io/release/${version}/bin/linux/amd64/kubectl.sha256" + echo "$(cat /tmp/kubectl.sha256) /tmp/kubectl" | sha256sum --check + sudo install -m 0755 /tmp/kubectl /usr/local/bin/kubectl + rm -f /tmp/kubectl /tmp/kubectl.sha256 + ok "kubectl $(kubectl version --client -o json 2>/dev/null | grep -o 'v[0-9][^\"]*' | head -1 || true)" +} + +install_minikube() { + need_cmd minikube && return + log "Installing Minikube" + curl -fsSL -o /tmp/minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 + sudo install -m 0755 /tmp/minikube /usr/local/bin/minikube + rm -f /tmp/minikube + ok "Minikube $(minikube version --short 2>/dev/null || true)" +} + +install_terraform() { + need_cmd terraform && return + log "Installing Terraform" + local version url + version="$(curl -fsSL https://checkpoint-api.hashicorp.com/v1/check/terraform | python3 -c 'import json,sys; print(json.load(sys.stdin)["current_version"])')" + url="https://releases.hashicorp.com/terraform/${version}/terraform_${version}_linux_amd64.zip" + curl -fsSL -o /tmp/terraform.zip "$url" + unzip -o /tmp/terraform.zip -d /tmp/terraform-bin >/dev/null + sudo install -m 0755 /tmp/terraform-bin/terraform /usr/local/bin/terraform + rm -rf /tmp/terraform.zip /tmp/terraform-bin + ok "Terraform $(terraform version -json | python3 -c 'import json,sys; print(json.load(sys.stdin)["terraform_version"])')" +} + +install_terragrunt() { + need_cmd terragrunt && return + log "Installing Terragrunt" + local arch version asset + arch="$(uname -m)" + case "$arch" in + x86_64) asset="terragrunt_linux_amd64" ;; + aarch64|arm64) asset="terragrunt_linux_arm64" ;; + *) fail "Unsupported CPU architecture for Terragrunt: $arch" ;; + esac + version="$(curl -fsSL https://api.github.com/repos/gruntwork-io/terragrunt/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" + curl -fsSL -o /tmp/terragrunt "https://github.com/gruntwork-io/terragrunt/releases/download/${version}/${asset}" + sudo install -m 0755 /tmp/terragrunt /usr/local/bin/terragrunt + rm -f /tmp/terragrunt + ok "Terragrunt $(terragrunt --version | head -1)" +} + +log "Checking WSL/Linux environment" +if [ -r /proc/version ] && grep -qi microsoft /proc/version; then + ok "WSL detected" +else + warn "WSL was not detected; continuing because the tooling is Linux-compatible." +fi + +install_apt_prereqs + +log "Checking Docker" +if ! need_cmd docker; then + cat >&2 <<'MSG' + ✗ Docker CLI is not installed. + + For this challenge on WSL, install Docker Desktop on Windows and enable + WSL2 integration for this distribution. Then reopen WSL and run: + + docker info + + The project intentionally does not install Docker Desktop automatically. +MSG + exit 1 +fi +if ! docker info >/dev/null 2>&1; then + cat >&2 <<'MSG' + ✗ Docker CLI exists, but the Docker daemon is not reachable. + + Start Docker Desktop on Windows and ensure WSL2 integration is enabled + for this WSL distribution, then run this script again. +MSG + exit 1 +fi +ok "Docker daemon reachable" + +install_kubectl +install_minikube +install_terraform +install_terragrunt + +log "Versions" +docker --version +kubectl version --client --output=yaml | grep gitVersion | head -1 || true +minikube version --short || true +terraform version | head -1 +terragrunt --version | head -1 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..f9b47d4 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +URL="${1:-http://tradebyte.local/}" +echo "Testing ${URL}" +curl --fail --silent --show-error "${URL}" >/dev/null +echo "Smoke test passed" diff --git a/scripts/status.sh b/scripts/status.sh new file mode 100755 index 0000000..4799d91 --- /dev/null +++ b/scripts/status.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +kubectl get nodes +kubectl get pods,svc,hpa,pdb,ingress -n tradebyte