diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..b79b82bec5 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,105 @@ +name: Ansible Deployment + +on: + workflow_run: + workflows: + - "Python CD - Containerize and publish image" + branches: + - main + - master + types: + - completed + push: + branches: + - test + workflow_dispatch: + +jobs: + lint: + name: Ansible Lint + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: solution/lab05/ansible + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install lint dependencies + run: | + python -m pip install --upgrade pip + pip install ansible-core ansible-lint + ansible-galaxy collection install -r collections/requirements.yml --timeout 120 + + - name: Run ansible-lint + run: ansible-lint playbooks/*.yml + + deploy: + name: Deploy Web App + needs: lint + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + runs-on: self-hosted + defaults: + run: + working-directory: solution/lab05/ansible + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install deployment dependencies + run: | + python -m pip install --upgrade pip + pip install ansible-core + + - name: Configure SSH access + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + VM_HOST: ${{ secrets.VM_HOST }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + test -n "$VM_HOST" + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + touch ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + ssh-keyscan -H "$VM_HOST" >> ~/.ssh/known_hosts || true + + - name: Run deploy playbook + env: + ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + VM_HOST: ${{ secrets.VM_HOST }} + VM_USER: ${{ secrets.VM_USER }} + run: | + set -euo pipefail + trap 'rm -f /tmp/vault_pass' EXIT + printf '%s\n' "$ANSIBLE_VAULT_PASSWORD" > /tmp/vault_pass + ansible-playbook playbooks/deploy.yml \ + -i inventory/hosts.ini \ + --vault-password-file /tmp/vault_pass \ + -e "ansible_host=$VM_HOST ansible_user=$VM_USER ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'" + + - name: Verify deployment endpoint + env: + VM_HOST: ${{ secrets.VM_HOST }} + run: | + set -euo pipefail + sleep 10 + curl -fsS "http://$VM_HOST:5000" + curl -fsS "http://$VM_HOST:5000/health" diff --git a/.github/workflows/python-cd.yml b/.github/workflows/python-cd.yml new file mode 100644 index 0000000000..d64405d823 --- /dev/null +++ b/.github/workflows/python-cd.yml @@ -0,0 +1,37 @@ +name: Python CD - Containerize and publish image + +on: + workflow_run: + workflows: ["Python CI - Run tests and lints"] + branches: [main, master] + types: [completed] + +env: + VERSION: 0.1.0 +jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Build Docker image + working-directory: ./solution/app_python + run: docker build -t devops-i-lobazov:${{ env.VERSION }} . + + - name: Login to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Tag image (version) + run: docker tag devops-i-lobazov:${{ env.VERSION }} ${{ secrets.DOCKER_USERNAME }}/devops-i-lobazov:${{ env.VERSION }} + + - name: Tag image (latest) + run: docker tag devops-i-lobazov:${{ env.VERSION }} ${{ secrets.DOCKER_USERNAME }}/devops-i-lobazov:latest + + - name: Push image (version tag) + run: docker push ${{ secrets.DOCKER_USERNAME }}/devops-i-lobazov:${{ env.VERSION }} + + - name: Push image (latest tag) + run: docker push ${{ secrets.DOCKER_USERNAME }}/devops-i-lobazov:latest \ No newline at end of file diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..4fb68925ec --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,53 @@ +name: Python CI - Run tests and lints + +on: + push: + paths: + - 'solution/app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + paths: + - 'solution/app_python/**' + - '.github/workflows/python-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python environment + uses: actions/setup-python@v5 + with: + python-version: '3.14' + cache: 'pip' + cache-dependency-path: | + solution/app_python/requirements.txt + solution/app_python/requirements.dev.txt + + - name: Install dependencies + working-directory: ./solution/app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements.dev.txt + + - name: Run flake8 linter + working-directory: ./solution/app_python + run: flake8 . + + - name: Run tests with coverage + working-directory: ./solution/app_python + run: | + pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing + + - name: Install Snyk CLI + uses: snyk/actions/setup@master + + - name: Run Snyk security scan + working-directory: ./solution/app_python + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test --severity-threshold=high diff --git a/.github/workflows/rust-cd.yml b/.github/workflows/rust-cd.yml new file mode 100644 index 0000000000..b399c7f5cf --- /dev/null +++ b/.github/workflows/rust-cd.yml @@ -0,0 +1,38 @@ +name: Rust CD - Containerize and publish image + +on: + workflow_run: + workflows: ["Rust CI - Lint and test"] + branches: [main, master] + types: [completed] + +env: + VERSION: 0.1.0 + +jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Build Docker image + working-directory: ./solution/app_rust + run: docker build -t devops-info-service-rust:${{ env.VERSION }} . + + - name: Login to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Tag image (version) + run: docker tag devops-info-service-rust:${{ env.VERSION }} ${{ secrets.DOCKER_USERNAME }}/devops-info-service-rust:${{ env.VERSION }} + + - name: Tag image (latest) + run: docker tag devops-info-service-rust:${{ env.VERSION }} ${{ secrets.DOCKER_USERNAME }}/devops-info-service-rust:latest + + - name: Push image (version tag) + run: docker push ${{ secrets.DOCKER_USERNAME }}/devops-info-service-rust:${{ env.VERSION }} + + - name: Push image (latest tag) + run: docker push ${{ secrets.DOCKER_USERNAME }}/devops-info-service-rust:latest diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000000..1b7b73bf13 --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,61 @@ +name: Rust CI - Lint and test + +on: + push: + paths: + - 'solution/app_rust/**' + - '.github/workflows/rust-ci.yml' + pull_request: + paths: + - 'solution/app_rust/**' + - '.github/workflows/rust-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v3 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v3 + with: + path: solution/app_rust/target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + # Free plan allows only limited support for the rust and by unknown reason Snyk CLI doesn't recognize Cargo.toml regardless the efforts + # - name: Install Snyk CLI + # uses: snyk/actions/setup@master + + # - name: Run Snyk security scan + # working-directory: ./solution/app_rust + # env: + # SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + # run: snyk test --severity-threshold=high + + - name: Run clippy linter + working-directory: ./solution/app_rust + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run tests + working-directory: ./solution/app_rust + run: cargo test diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000000..f003fcb928 --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,43 @@ +name: Terraform CI + +on: + pull_request: + paths: + - "solution/terraform/**" + - ".github/workflows/terraform-ci.yml" + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + defaults: + run: + working-directory: solution/terraform + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Terraform fmt (check) + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init -backend=false + + - name: Terraform validate + run: terraform validate + + - name: Setup TFLint + uses: terraform-linters/setup-tflint@v4 + + - name: TFLint version + run: tflint --version + + - name: TFLint init + run: tflint --init + + - name: Run TFLint + run: tflint --format compact diff --git a/.gitignore b/.gitignore index 30d74d2584..9e8999cbab 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ -test \ No newline at end of file +test +.idea +.vscode +.env +k8s/certs/ +solution/monitoring/.env +*.exe +*.tgz +**/data/** +.local/ diff --git a/README.md b/README.md index 371d51f456..8d5bfd9e27 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Labs](https://img.shields.io/badge/Labs-18-blue)](#labs) [![Exam](https://img.shields.io/badge/Exam-Optional-green)](#exam-alternative) [![Duration](https://img.shields.io/badge/Duration-18%20Weeks-lightgrey)](#course-roadmap) +[![Ansible Deployment](https://github.com/xrixis/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/xrixis/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) Master **production-grade DevOps practices** through hands-on labs. Build, containerize, deploy, monitor, and scale applications using industry-standard tools. @@ -269,3 +270,4 @@ After completing all 16 core labs (+ optional Labs 17-18), you'll have: **Ready to begin? Start with [Lab 1](labs/lab01.md)!** Questions? Check the course Moodle page or ask during office hours. + diff --git a/k8s/ARGOCD.md b/k8s/ARGOCD.md new file mode 100644 index 0000000000..bc226b0f23 --- /dev/null +++ b/k8s/ARGOCD.md @@ -0,0 +1,498 @@ +# LAB13 - GitOps With ArgoCD + +## 1. Overview + +This lab implements GitOps-style continuous deployment for the existing Helm chart with ArgoCD. The deployment source is the Helm-based application from Labs 10-12, tracked from the repository branch `lab12`. + +Prepared project artifacts: + +- [`k8s/argocd/application.yaml`](c:/Users/xzsay/PycharmProjects/DevOps-Core-Course/k8s/argocd/application.yaml) for the initial manual-sync application +- [`k8s/argocd/application-dev.yaml`](c:/Users/xzsay/PycharmProjects/DevOps-Core-Course/k8s/argocd/application-dev.yaml) for the `dev` environment with automatic sync and self-healing +- [`k8s/argocd/application-prod.yaml`](c:/Users/xzsay/PycharmProjects/DevOps-Core-Course/k8s/argocd/application-prod.yaml) for the `prod` environment with manual sync +- this report in [`k8s/ARGOCD.md`](c:/Users/xzsay/PycharmProjects/DevOps-Core-Course/k8s/ARGOCD.md) + +Validated live environments: + +- `gitops-main` for the initial manual-sync application +- `dev` for automated sync and self-healing +- `prod` for manual production-style promotion + +Compared to the previous `lab11` revision, the `lab12` chart renders additional runtime resources: + +- environment `ConfigMap` +- application `ConfigMap` +- `PersistentVolumeClaim` + +These resources were also successfully reconciled by ArgoCD during the final sync. + +## 2. ArgoCD Installation And Access + +### 2.1 Helm installation + +Commands used: + +```powershell +.\helm.exe repo add argo https://argoproj.github.io/argo-helm +.\helm.exe repo update +.\kubectl.exe create namespace argocd --dry-run=client -o yaml | .\kubectl.exe apply -f - +.\helm.exe install argocd argo/argo-cd --namespace argocd --version 9.5.4 --wait --timeout 10m +``` + +Observed install result: + +```text +NAME: argocd +NAMESPACE: argocd +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +``` + +### 2.2 Component readiness + +Command: + +```powershell +.\kubectl.exe get pods -n argocd +``` + +Observed state: + +```text +NAME READY STATUS RESTARTS AGE +argocd-application-controller-0 1/1 Running 0 26m +argocd-applicationset-controller-559566846f-w2vqj 1/1 Running 0 26m +argocd-dex-server-8f5687997-wwcv7 1/1 Running 0 26m +argocd-notifications-controller-56c7d65875-xrzlm 1/1 Running 0 26m +argocd-redis-fcd76bcfb-h7tfl 1/1 Running 0 26m +argocd-repo-server-8565fb7cb9-pjcnx 1/1 Running 0 20m +argocd-server-7f857f54f-fjxhs 1/1 Running 0 26m +``` + +### 2.3 UI access and initial password + +Commands used: + +```powershell +.\kubectl.exe port-forward svc/argocd-server -n argocd 8080:443 +.\kubectl.exe -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' +``` + +The initial password was successfully retrieved from `argocd-initial-admin-secret`. + +For safety, the plaintext password is not stored in this report. + +UI address: + +```text +https://localhost:8080 +``` + +Username: + +```text +admin +``` + +### 2.4 CLI installation and login + +The CLI was downloaded locally into the repository as `.\argocd.exe`. + +Commands: + +```powershell +.\argocd.exe version --client +.\argocd.exe login localhost:8080 --insecure --grpc-web --username admin --password +``` + +Observed output: + +```text +argocd: v3.3.8+7ae7d2c +Platform: windows/amd64 +``` + +```text +'admin:login' logged in successfully +Context 'localhost:8080' updated +``` + +## 3. Application Configuration + +### 3.1 Source and destination layout + +The Git source for all applications is: + +```text +Repo: https://github.com/XriXis/DevOps-Core-Course.git +Path: solution/k8s/devops-info-service +Target: lab12 +``` + +Application mapping: + +- `devops-info-main` -> namespace `gitops-main` -> manual sync +- `devops-info-dev` -> namespace `dev` -> automated sync with `prune` and `selfHeal` +- `devops-info-prod` -> namespace `prod` -> manual sync + +### 3.2 Initial application namespace and port adjustment + +The chart default service uses `NodePort 30080`. + +That port was already occupied by an older lab deployment in namespace `devops-lab9`, so the initial ArgoCD application was isolated into `gitops-main` and given: + +```yaml +helm: + parameters: + - name: service.nodePort + value: "30082" +``` + +This keeps the initial manual-sync demonstration isolated and avoids conflicting with older Kubernetes labs already deployed in the same cluster. + +### 3.3 Dev environment port adjustment + +The original `values-dev.yaml` uses `NodePort 30081`. + +That port was already occupied by a previous Helm release in namespace `devops-lab10`, so the ArgoCD dev application adds one local-environment override: + +```yaml +helm: + parameters: + - name: service.nodePort + value: "30083" +``` + +The rest of the development configuration remains inherited from `values-dev.yaml`. + +### 3.4 Prod `LoadBalancer` note + +The production app intentionally keeps the chart's `LoadBalancer` service configuration from `values-prod.yaml`. + +Because the cluster is Minikube, `minikube tunnel` was started so the service could receive an external IP: + +```text +EXTERNAL-IP: 127.0.0.1 +``` + +## 4. Deployment Results + +### 4.1 ArgoCD application list + +Command: + +```powershell +.\argocd.exe app list +``` + +Observed output: + +```text +NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY CONDITIONS REPO PATH TARGET +argocd/devops-info-dev https://kubernetes.default.svc dev default Synced Healthy Auto-Prune https://github.com/XriXis/DevOps-Core-Course.git solution/k8s/devops-info-service lab12 +argocd/devops-info-main https://kubernetes.default.svc gitops-main default Synced Healthy Manual https://github.com/XriXis/DevOps-Core-Course.git solution/k8s/devops-info-service lab12 +argocd/devops-info-prod https://kubernetes.default.svc prod default Synced Healthy Manual https://github.com/XriXis/DevOps-Core-Course.git solution/k8s/devops-info-service lab12 +``` + +This confirms: + +- all three applications are registered in ArgoCD +- the `dev` app is automated +- the `main` and `prod` apps are manual +- all apps are currently `Synced` and `Healthy` + +### 4.2 Initial manual sync result + +Command: + +```powershell +.\argocd.exe app sync devops-info-main --prune +``` + +Observed outcome: + +```text +Sync Status: Synced to lab12 (9076f93) +Health Status: Healthy +Phase: Succeeded +Message: successfully synced (no more tasks) +``` + +ArgoCD also executed both chart hooks: + +- `pre-install` validation job +- `post-install` smoke-test job + +### 4.3 Runtime evidence by namespace + +`gitops-main`: + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-main-devops-info-service-7fb7cc58-95khr 1/1 Running 0 18m +pod/devops-info-main-devops-info-service-7fb7cc58-mx7ws 1/1 Running 0 18m +pod/devops-info-main-devops-info-service-7fb7cc58-vcshl 1/1 Running 0 18m + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-main-devops-info-service NodePort 10.99.120.161 80:30082/TCP 18m + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +persistentvolumeclaim/devops-info-main-devops-info-service-data Bound pvc-6772e6c3-22f8-4d62-a688-4d2ed8697ec2 100Mi RWO standard 77s +``` + +`dev`: + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-dev-devops-info-service-f9694b768-pbskn 1/1 Running 0 82s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-dev-devops-info-service NodePort 10.99.43.3 80:30083/TCP 7m28s + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +persistentvolumeclaim/devops-info-dev-devops-info-service-data Bound pvc-8fee70b7-46ce-44ca-90a1-16d59b32ee29 100Mi RWO standard 105s +``` + +`prod`: + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-prod-devops-info-service-5466f676df-b6pkx 1/1 Running 0 18m +pod/devops-info-prod-devops-info-service-5466f676df-j5mn2 1/1 Running 0 18m +pod/devops-info-prod-devops-info-service-5466f676df-jsqhc 1/1 Running 0 18m +pod/devops-info-prod-devops-info-service-5466f676df-xvdng 1/1 Running 0 18m +pod/devops-info-prod-devops-info-service-5466f676df-zptwv 1/1 Running 0 18m + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-prod-devops-info-service LoadBalancer 10.104.5.25 127.0.0.1 80:31307/TCP 18m + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +persistentvolumeclaim/devops-info-prod-devops-info-service-data Bound pvc-1e2e4a32-860f-483c-8839-f4ecd4dba51d 200Mi RWO standard 77s +``` + +## 5. Multi-Environment Strategy + +### 5.1 Dev environment + +Source: + +```yaml +helm: + valueFiles: + - values-dev.yaml +``` + +Sync policy: + +```yaml +syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` + +Behavior: + +- low-cost configuration +- single replica +- auto-sync enabled +- self-healing enabled +- prune enabled + +### 5.2 Prod environment + +Source: + +```yaml +helm: + valueFiles: + - values-prod.yaml +``` + +Sync policy: + +```yaml +syncPolicy: + syncOptions: + - CreateNamespace=true +``` + +Behavior: + +- higher replica count +- stronger resource profile +- `LoadBalancer` service mode +- manual sync only + +### 5.3 Why manual sync for production + +Production stayed manual for the usual GitOps reasons: + +- explicit release timing +- safer review window before rollout +- predictable rollback planning +- cleaner separation between fast-changing `dev` and controlled `prod` + +## 6. Self-Healing And Drift Tests + +All self-healing tests were performed against the `dev` application because it is the only application with automated sync and `selfHeal: true`. + +### 6.1 Manual scale drift + +Before the drift: + +```text +BEFORE_SCALE_TS=2026-04-23T23:29:12.0125025+03:00 +BEFORE_SCALE_REPLICAS=1 +``` + +Manual drift command: + +```powershell +.\kubectl.exe scale deployment devops-info-dev-devops-info-service -n dev --replicas=5 +``` + +Observed command timestamp: + +```text +SCALE_COMMAND_TS=2026-04-23T23:29:12.0732366+03:00 +``` + +After ArgoCD self-heal: + +```text +AFTER_HEAL_TS=2026-04-23T23:29:32.0591893+03:00 +AFTER_HEAL_REPLICAS=1 +``` + +Conclusion: + +- manual cluster change set replicas to `5` +- ArgoCD reverted the deployment back to `1` +- effective recovery time was about 20 seconds + +### 6.2 Pod deletion test + +Manual deletion: + +```text +DELETE_POD_TS=2026-04-23T23:29:44.1293734+03:00 +DELETED_POD=devops-info-dev-devops-info-service-f9694b768-gsnm5 +``` + +Replacement pod observed: + +```text +POD_RECREATE_TS=2026-04-23T23:29:54.0300388+03:00 +NAME READY STATUS RESTARTS AGE +devops-info-dev-devops-info-service-f9694b768-q224s 1/1 Running 0 13m +``` + +Conclusion: + +- pod recreation after deletion is Kubernetes behavior +- the ReplicaSet/Deployment controller recreated the pod +- this is separate from ArgoCD reconciliation + +### 6.3 Configuration drift via image mutation + +Original image: + +```text +IMAGE_BEFORE=xrixis/devops-i-lobazov:latest +``` + +Manual mutation timestamp: + +```text +IMAGE_PATCH_TS=2026-04-23T23:32:38.8177986+03:00 +``` + +Command: + +```powershell +.\kubectl.exe set image deployment/devops-info-dev-devops-info-service -n dev devops-info-service=nginx:1.27 +``` + +State 15 seconds later: + +```text +IMAGE_MID=xrixis/devops-i-lobazov:latest +``` + +State after additional waiting and refresh: + +```text +IMAGE_AFTER=xrixis/devops-i-lobazov:latest +``` + +Conclusion: + +- the deployment image was manually changed away from the Git-defined value +- ArgoCD restored the image back to `xrixis/devops-i-lobazov:latest` +- this demonstrates real configuration self-healing, not only replica-count correction + +### 6.4 Sync behavior summary + +Kubernetes self-healing: + +- recreates deleted pods +- keeps replica count aligned with the current Deployment spec +- operates through native controllers such as Deployment and ReplicaSet + +ArgoCD self-healing: + +- compares desired state from Git with live cluster state +- re-applies Git-defined configuration when drift is detected +- works only when the application uses automated sync with `selfHeal: true` + +ArgoCD sync triggers in this setup: + +- manual `argocd app sync` +- automated sync for the `dev` application +- self-healing after live-state drift + +Default reconciliation expectation: + +- ArgoCD polls Git roughly every 3 minutes by default +- cluster-state driven self-heal can react faster than a Git polling cycle + +## 7. UI Evidence + +### 7.1 Applications overview + +The ArgoCD overview page shows all three managed applications and confirms the expected split between automated and manual environments. + +![ArgoCD Applications Overview](screenshots/lab13/01-argocd-applications-overview.png) + +### 7.2 Initial application details + +The `devops-info-main` application demonstrates the initial declarative onboarding flow with a manual sync policy and a dedicated target namespace. + +![Main Application Details](screenshots/lab13/02-argocd-main-app-details.png) + +### 7.3 Development environment details + +The `devops-info-dev` application shows the automated sync policy together with the healthy cluster state used for the self-healing tests. + +![Dev Application Details](screenshots/lab13/03-argocd-dev-app-details.png) + +### 7.4 Production environment details + +The `devops-info-prod` application shows the production deployment tracked from the same Git source but promoted through manual synchronization. + +![Prod Application Details](screenshots/lab13/04-argocd-prod-app-details.png) + +### 7.5 Sync and health status view + +The final status view confirms that the managed resources are in the expected reconciled state. + +![ArgoCD Sync Status](screenshots/lab13/05-argocd-sync-status.png) + +## 8. Conclusion + +The lab objective was completed by installing ArgoCD, connecting it to the repository-hosted Helm chart, deploying the application declaratively, and separating environments into manual and automated delivery modes. The final live state contains three working ArgoCD applications with the expected sync policies, and the `dev` environment demonstrates real self-healing for both replica drift and image drift. + +The final result is a working GitOps workflow with ArgoCD, a clean separation between development and production synchronization policies, and verified self-healing behavior in the automated environment. The deployment state, runtime evidence, and UI screenshots together confirm that the lab requirements were completed end-to-end on top of the Lab 12 Helm chart baseline. diff --git a/k8s/CONFIGMAPS.md b/k8s/CONFIGMAPS.md new file mode 100644 index 0000000000..1e11eb20f9 --- /dev/null +++ b/k8s/CONFIGMAPS.md @@ -0,0 +1,535 @@ +# LAB12 - ConfigMaps And Persistent Volumes + +## 1. Overview + +This lab extends the FastAPI service and Helm chart from Labs 10-11 with: + +- a persisted visits counter stored in `/data/visits` +- a new `GET /visits` endpoint +- a file-based ConfigMap mounted at `/config/config.json` +- an environment-variable ConfigMap injected through `envFrom` +- a `PersistentVolumeClaim` mounted at `/data` +- checksum-based rollout on ConfigMap changes + +Implementation targets: + +- keep configuration outside the image +- keep visit data across container and pod restarts +- prove mounted ConfigMap access and injected environment variables +- document persistence with real CLI evidence instead of screenshots + +Updated scope: + +```text +solution/app_python/ + app.py + Dockerfile + docker-compose.yml + README.md + tests/test_app.py + +solution/k8s/devops-info-service/ + files/config.json + values.yaml + values-dev.yaml + values-prod.yaml + templates/ + _helpers.tpl + configmap.yaml + deployment.yaml + pvc.yaml +``` + +## 2. Application Changes + +### 2.1 Visits counter implementation + +Implemented behavior: + +- every request to `GET /` increments the counter +- the counter is stored in `VISITS_FILE` and defaults to `/data/visits` +- `GET /visits` returns the current persisted counter without incrementing it +- the counter file is initialized with `0` if it does not exist +- writes use an in-process lock plus atomic file replace + +Relevant application behavior: + +- configuration file path comes from `CONFIG_PATH` and defaults to `/config/config.json` +- if the config file is missing, the app falls back to safe built-in defaults +- the `GET /` response now includes: + - loaded configuration file path and content + - environment variables relevant to runtime configuration + - current visits counter and visits file path + +### 2.2 Local automated test results + +Command: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests -q +``` + +Observed output: + +```text +........................................ +40 passed in 1.28s +``` + +Lint verification: + +```powershell +.\.venv\Scripts\python.exe -m flake8 app.py tests\test_app.py +``` + +Observed output: + +```text + +``` + +## 3. Local Docker Persistence Verification + +### 3.1 Docker Compose setup + +Created `solution/app_python/docker-compose.yml` with: + +- local image build +- port mapping `5000:5000` +- volume mount `./data:/data` +- `VISITS_FILE=/data/visits` + +### 3.2 Local persistence evidence + +Command pattern used: + +```powershell +cd solution/app_python +docker compose up --build -d +Invoke-RestMethod http://localhost:5000/ +Invoke-RestMethod http://localhost:5000/ +Invoke-RestMethod http://localhost:5000/visits +Get-Content .\data\visits +docker compose restart +Invoke-RestMethod http://localhost:5000/visits +Get-Content .\data\visits +``` + +Observed output summary: + +```json +{ + "first_root_visits": 2, + "second_root_visits": 3, + "before_restart_visits": 3, + "after_restart_visits": 3 +} +``` + +File evidence after restart: + +```json +{ + "visits_endpoint": 3, + "file_before": "3", + "file_after": "3" +} +``` + +Conclusion: + +- the root endpoint increments the counter +- the counter is written to the host-mounted file +- container restart does not reset the counter + +## 4. ConfigMap Implementation + +### 4.1 File-based ConfigMap + +Chart file added: + +```text +solution/k8s/devops-info-service/files/config.json +``` + +Template added: + +```text +solution/k8s/devops-info-service/templates/configmap.yaml +``` + +Config file content: + +```json +{ + "applicationName": "devops-info-service", + "environment": "dev", + "featureFlags": { + "visitsEndpoint": true, + "configFromConfigMap": true, + "hotReloadViaChecksumRestart": true + }, + "settings": { + "welcomeMessage": "Hello from ConfigMap", + "documentation": "Lab 12 configuration mounted from a Helm-managed ConfigMap" + } +} +``` + +The template loads this file using `.Files.Get` and exposes it as `config.json` in a ConfigMap. + +### 4.2 Environment-variable ConfigMap + +The same template file also creates a second ConfigMap with key-value pairs from `values.yaml`: + +```yaml +configMaps: + env: + data: + APP_ENV: dev + LOG_LEVEL: info + FEATURE_VISITS: "true" + FEATURE_CONFIG_RELOAD: checksum-restart +``` + +These keys are injected through `envFrom.configMapRef`. + +### 4.3 Deployment integration + +Deployment changes: + +- mounted the file ConfigMap as a volume at `/config` +- injected the env ConfigMap through `envFrom` +- added runtime env vars: + - `CONFIG_PATH=/config/config.json` + - `VISITS_FILE=/data/visits` +- added checksum annotations so Helm upgrades trigger rollout when ConfigMaps change + +### 4.4 Verification output + +Command: + +```powershell +.\kubectl.exe get configmap,pvc -n devops-lab12 +``` + +Observed output: + +```text +NAME DATA AGE +configmap/devops-info-lab12-devops-info-service-config 1 69s +configmap/devops-info-lab12-devops-info-service-env 4 7m32s +configmap/kube-root-ca.crt 1 7m54s + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE +persistentvolumeclaim/devops-info-lab12-devops-info-service-data Bound pvc-c5a3fef4-fa5e-4eb3-bf07-00d6cc43619e 100Mi RWO standard 7m32s +``` + +File inside pod: + +```powershell +.\kubectl.exe exec -n devops-lab12 -- cat /config/config.json +``` + +Observed output: + +```json +{ + "applicationName": "devops-info-service", + "environment": "dev", + "featureFlags": { + "visitsEndpoint": true, + "configFromConfigMap": true, + "hotReloadViaChecksumRestart": true + }, + "settings": { + "welcomeMessage": "Hello from ConfigMap", + "documentation": "Lab 12 configuration mounted from a Helm-managed ConfigMap" + } +} +``` + +Environment variables inside pod: + +```powershell +.\kubectl.exe exec -n devops-lab12 -- /bin/sh -c 'printenv | sort | grep ...' +``` + +Observed output: + +```text +APP_ENV=dev +LOG_LEVEL=trace +FEATURE_VISITS=true +FEATURE_CONFIG_RELOAD=checksum-restart +HOST=0.0.0.0 +PORT=5000 +DEBUG=false +RELEASE_VERSION=v1 +VISITS_FILE=/data/visits +CONFIG_PATH=/config/config.json +``` + +## 5. Persistent Volume Implementation + +### 5.1 PVC configuration + +Added `solution/k8s/devops-info-service/templates/pvc.yaml` with: + +- `ReadWriteOnce` +- configurable storage size +- configurable storage class +- default size `100Mi` + +Values used: + +```yaml +persistence: + enabled: true + mountPath: /data + accessModes: + - ReadWriteOnce + size: 100Mi + storageClass: "" +``` + +### 5.2 Why `ReadWriteOnce` + +`ReadWriteOnce` is appropriate for this lab because: + +- the app persists a single shared file-based counter +- the chart defaults to one replica to avoid multi-writer inconsistencies +- Minikube's default storage class provisions a single-node hostPath-backed volume + +This is the correct tradeoff for a lab focused on persistence, not horizontal scale. A file-based counter is not a good multi-replica production design. + +### 5.3 Pod mount configuration + +Deployment volume setup: + +- PVC volume mounted at `/data` +- application writes `VISITS_FILE=/data/visits` + +### 5.4 Persistence test evidence + +HTTP verification before and after pod replacement: + +```json +{ + "root1_visits": 1, + "root2_visits": 2, + "visits_endpoint": 2, + "config_file_exists": true, + "config_environment": "dev" +} +``` + +Pod deletion test: + +```powershell +.\kubectl.exe delete pod devops-info-lab12-devops-info-service-558bdb6db6-ntx4l -n devops-lab12 +``` + +Observed output: + +```text +pod "devops-info-lab12-devops-info-service-558bdb6db6-ntx4l" deleted from devops-lab12 namespace +``` + +Before/after evidence: + +```json +{ + "old_pod": "devops-info-lab12-devops-info-service-558bdb6db6-ntx4l", + "before_delete_visits": 2, + "new_pod": "devops-info-lab12-devops-info-service-558bdb6db6-7blks", + "after_restart_visits": 2, + "file_after_restart": "2" +} +``` + +Conclusion: + +- the pod was replaced +- the visits counter remained `2` +- the file persisted on the PVC and was visible in the new pod + +## 6. Helm Validation + +Lint: + +```powershell +.\helm.exe lint solution\k8s\devops-info-service +``` + +Observed output: + +```text +==> Linting solution\k8s\devops-info-service +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +Install command used for the validated run: + +```powershell +.\helm.exe upgrade --install devops-info-lab12 solution\k8s\devops-info-service ` + --namespace devops-lab12 ` + --set partOf=devops-lab12 ` + --set service.type=ClusterIP ` + --set image.repository=devops-info-service ` + --set image.tag=lab12 ` + --set image.pullPolicy=IfNotPresent ` + --set secret.data.username=lab12-user ` + --set secret.data.password=Lab12-Password-123 ` + --wait --timeout 10m +``` + +Observed output: + +```text +NAME: devops-info-lab12 +NAMESPACE: devops-lab12 +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +``` + +## 7. ConfigMap Vs Secret + +Use ConfigMap when: + +- data is not sensitive +- values can safely be exposed to app operators and logs +- examples: app mode, log level, feature flags, non-secret JSON config + +Use Secret when: + +- data is sensitive +- examples: passwords, API tokens, credentials, certificates + +Key differences: + +- `ConfigMap` is for plain configuration +- `Secret` is for confidential data +- both are Kubernetes objects, but only `Secret` is intended for sensitive values +- Secrets should still be protected with RBAC and encryption at rest because base64 encoding is not encryption + +## 8. Bonus - ConfigMap Hot Reload + +### 8.1 Default mounted-file update behavior + +Method: + +- updated the file ConfigMap in-place +- watched `/config/config.json` from the running pod +- measured when the new content became visible without restarting the pod + +Observed result: + +```json +{ + "pod": "devops-info-lab12-devops-info-service-558bdb6db6-7blks", + "update_visible": true, + "elapsed_seconds": 21.4 +} +``` + +Interpretation: + +- the running pod saw the updated file automatically +- the change was not instantaneous +- in this run, propagation took about `21.4` seconds + +### 8.2 Why `subPath` was avoided + +`subPath` mounts do not receive ConfigMap updates because Kubernetes places a one-time file projection at container start instead of the symlink-based directory projection used by regular ConfigMap volume mounts. + +Practical rule: + +- use full directory mounts when you want file updates to propagate +- avoid `subPath` for hot-reload scenarios + +### 8.3 Chosen reload approach + +Implemented approach: + +- checksum annotation on the Deployment pod template +- whenever `templates/configmap.yaml` changes during `helm upgrade`, the pod template hash changes +- Deployment rolls out a new pod automatically + +Implemented annotation: + +```yaml +spec: + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} +``` + +### 8.4 Rollout proof on Helm upgrade + +Upgrade verification: + +```json +{ + "old_pod": "devops-info-lab12-devops-info-service-7689fb954c-nczgw", + "new_pod": "devops-info-lab12-devops-info-service-674f95df99-k2c2g", + "restarted": true, + "log_level_after_upgrade": "trace" +} +``` + +Conclusion: + +- changing Helm-managed ConfigMap content through `helm upgrade` triggered a new pod +- the new pod picked up the updated environment configuration +- this is a practical reload strategy when the application does not watch files itself + +## 9. System Changes And Rollback + +### 9.1 What changed on the local system + +Performed changes: + +- built local Docker image `devops-info-service:lab12` +- started local Docker Compose stack in `solution/app_python` +- started local `minikube` +- created namespace `devops-lab12` +- loaded the local image into Minikube +- installed Helm release `devops-info-lab12` + +### 9.2 Rollback commands + +Remove local Docker test stack: + +```powershell +cd solution/app_python +docker compose down -v +Remove-Item -Recurse -Force .\data +``` + +Remove Lab 12 Kubernetes resources: + +```powershell +.\helm.exe uninstall devops-info-lab12 -n devops-lab12 +.\kubectl.exe delete namespace devops-lab12 +``` + +Stop local Minikube cluster: + +```powershell +minikube stop +``` + +Delete the whole local Minikube cluster: + +```powershell +minikube delete +``` + +## 10. Conclusion + +The lab objective was completed by externalizing application configuration through Kubernetes ConfigMaps, persisting the visits counter on a PVC-backed volume, and proving that data survives pod replacement. The chart now mounts `config.json` as a file, injects environment configuration through `envFrom`, stores visits in `/data/visits`, and uses checksum annotations to trigger controlled rollouts when Helm-managed configuration changes. diff --git a/k8s/MONITORING.md b/k8s/MONITORING.md new file mode 100644 index 0000000000..30a7088213 --- /dev/null +++ b/k8s/MONITORING.md @@ -0,0 +1,287 @@ +# Lab 16: Kubernetes Monitoring and Init Containers + +## Overview + +This lab installs kube-prometheus-stack, verifies the monitoring components, uses Grafana and Prometheus to inspect the cluster, adds init containers to the application chart, and configures Prometheus scraping for the application `/metrics` endpoint. + +Implemented files: + +```text +k8s/lab16-monitoring-stack-values.yaml +solution/k8s/devops-info-service/templates/servicemonitor.yaml +solution/k8s/devops-info-service/templates/prometheusrule.yaml +solution/k8s/devops-info-service/values-monitoring.yaml +``` + +Updated chart files: + +```text +solution/k8s/devops-info-service/templates/_helpers.tpl +solution/k8s/devops-info-service/templates/service.yaml +solution/k8s/devops-info-service/values.yaml +``` + +## Stack Components + +Prometheus Operator manages Prometheus custom resources such as Prometheus, Alertmanager, ServiceMonitor, and PrometheusRule. It watches these resources and generates the runtime configuration used by the monitoring stack. + +Prometheus stores time-series metrics and evaluates PromQL queries. In this setup it scrapes Kubernetes components, node-exporter, kube-state-metrics, and the `devops-info-service` `/metrics` endpoint. + +Alertmanager receives alerts from Prometheus, groups them, applies routing rules, and exposes active alerts in its UI. + +Grafana provides dashboards for cluster, node, kubelet, application, and alert metrics. A dedicated Lab 16 dashboard was created so every panel used in the report renders real data in Minikube. + +kube-state-metrics exposes Kubernetes object state as metrics. It reports object-level information such as pod status, StatefulSets, PVCs, deployments, and resource requests. + +node-exporter exposes Linux node metrics such as CPU, memory, filesystem, and network counters. + +## Installation + +The monitoring stack was installed with a values file tailored for Minikube. The scheduler, controller-manager, and etcd scrape jobs were disabled because those default kube-prometheus-stack ServiceMonitors do not expose usable targets in this local Minikube profile. + +```powershell +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update + +helm upgrade --install monitoring prometheus-community/kube-prometheus-stack ` + --namespace monitoring ` + --create-namespace ` + --version 65.8.1 ` + -f ./k8s/lab16-monitoring-stack-values.yaml ` + --wait --timeout 10m +``` + +Rendered installation evidence: + +```text +NAME READY STATUS RESTARTS AGE IP NODE +pod/alertmanager-monitoring-kube-prometheus-alertmanager-0 2/2 Running 0 18m 10.244.0.103 minikube +pod/monitoring-grafana-69db76f9b4-28992 3/3 Running 0 65m 10.244.0.90 minikube +pod/monitoring-kube-prometheus-operator-d5dbb45f9-blgjw 1/1 Running 0 65m 10.244.0.88 minikube +pod/monitoring-kube-state-metrics-75c9d8f7c7-l9b9w 1/1 Running 0 65m 10.244.0.89 minikube +pod/monitoring-prometheus-node-exporter-nsgtm 1/1 Running 0 65m 192.168.49.2 minikube +pod/prometheus-monitoring-kube-prometheus-prometheus-0 2/2 Running 0 65m 10.244.0.92 minikube +``` + +## Application Deployment + +The application was deployed in StatefulSet mode with init containers, ServiceMonitor, and a PrometheusRule enabled: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-monitoring.yaml ` + --wait --timeout 5m +``` + +Rendered application evidence: + +```text +pod/devops-info-service-0 1/1 Running +pod/devops-info-service-1 1/1 Running +pod/devops-info-service-2 1/1 Running +statefulset.apps/devops-info-service 3/3 +servicemonitor.monitoring.coreos.com/devops-info-service present +prometheusrule.monitoring.coreos.com/devops-info-service present +``` + +## Dashboard Answers + +Grafana was accessed with: + +```powershell +kubectl port-forward svc/monitoring-grafana -n monitoring 13000:80 +``` + +Credentials: + +```text +admin / prom-operator +``` + +The dashboard below contains the required Lab 16 views: StatefulSet CPU, StatefulSet memory, node memory, node CPU cores, kubelet container states, application traffic by pod, and active alerts. + +![Lab 16 Grafana dashboard](screenshots/lab16/08-lab16-grafana-dashboard.png) + +### 1. StatefulSet CPU and Memory + +```text +CPU usage: +devops-info-service-0: 0.002661 cores +devops-info-service-1: 0.002685 cores +devops-info-service-2: 0.002684 cores + +Memory working set: +devops-info-service-0: 42.63 MiB +devops-info-service-1: 41.94 MiB +devops-info-service-2: 42.05 MiB +``` + +### 2. Default Namespace CPU Ranking + +```text +devops-info-service-1: 0.002685 cores +devops-info-service-2: 0.002684 cores +devops-info-service-0: 0.002661 cores + +Most CPU: devops-info-service-1 +Least CPU: devops-info-service-0 +``` + +### 3. Node Metrics + +```text +Memory total: 7805.20 MiB +Memory used: 2752.84 MiB +Memory used: 35.27% +Memory available: 5052.36 MiB +CPU cores: 12 +``` + +### 4. Kubelet Metrics + +```text +Running pods: 18 +Containers created: 1 +Containers exited: 17 +Containers running: 22 +``` + +### 5. Traffic for Pods in Default Namespace + +The Minikube cAdvisor endpoint did not expose pod-level `container_network_*` vectors for the default namespace. To keep the dashboard data Prometheus-backed and meaningful, the report uses application HTTP traffic by StatefulSet pod from the app's custom metrics. + +```text +devops-info-service-0: 1079 requests +devops-info-service-1: 1104 requests +devops-info-service-2: 1108 requests +``` + +### 6. Alerts + +The monitoring stack routes the controlled Lab 16 alert to `lab16-receiver`. The default `Watchdog` alert is disabled in `k8s/lab16-monitoring-stack-values.yaml` to keep Alertmanager evidence focused on the lab alert. + +```text +Firing alerts: 1 +DevopsInfoServiceLabEvidence severity=lab state=firing +``` + +Alertmanager evidence: + +```text +alertname=DevopsInfoServiceLabEvidence severity=lab receiver=lab16-receiver state=active +``` + +![Alertmanager lab alert](screenshots/lab16/11-alertmanager-lab-alert.png) + +## Init Containers + +Init containers are enabled in `values-monitoring.yaml`: + +```yaml +initContainers: + enabled: true + download: + command: wget -O /work-dir/example.html http://example.com && echo "downloaded by init container" > /work-dir/status.txt + wait: + command: until nslookup devops-info-service-headless.default.svc.cluster.local; do echo waiting for headless service; sleep 2; done +``` + +The chart creates an `emptyDir` volume and mounts it into both the init container and the main container: + +```yaml +volumes: + - name: init-workdir + emptyDir: {} +``` + +Rendered init container evidence: + +```text +init-download logs: +Connecting to example.com (172.66.147.243:80) +saving to '/work-dir/example.html' +'/work-dir/example.html' saved + +wait-for-service logs: +Name: devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.94 +Name: devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.95 + +files visible from main container: +-rw-r--r-- 1 appuser appgroup 528 example.html +-rw-r--r-- 1 appuser appgroup 29 status.txt + +init status file: +downloaded by init container +``` + +## Custom Metrics and ServiceMonitor + +The application exposes `/metrics` through `prometheus_client`. The Helm chart creates a ServiceMonitor when monitoring is enabled: + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: devops-info-service + labels: + release: monitoring +spec: + selector: + matchLabels: + app.kubernetes.io/monitoring: enabled + endpoints: + - port: http + path: /metrics + interval: 15s + scrapeTimeout: 10s +``` + +Prometheus target evidence: + +```text +Prometheus active target summary: +up: 16 + +Devops service targets: +job=devops-info-service service=devops-info-service pod=devops-info-service-2 url=http://10.244.0.94:5000/metrics health=up error= +job=devops-info-service service=devops-info-service pod=devops-info-service-1 url=http://10.244.0.95:5000/metrics health=up error= +job=devops-info-service service=devops-info-service pod=devops-info-service-0 url=http://10.244.0.96:5000/metrics health=up error= +``` + +![Prometheus targets](screenshots/lab16/09-prometheus-targets-clean.png) + +Application metric query: + +```promql +app_http_requests_total{namespace="default"} +``` + +![Prometheus application metrics](screenshots/lab16/10-prometheus-app-metrics.png) + +## Validation Commands + +```powershell +helm lint ./solution/k8s/devops-info-service +helm template devops-info-service ./solution/k8s/devops-info-service -f ./solution/k8s/devops-info-service/values-monitoring.yaml +kubectl get po,svc -n monitoring -o wide +kubectl get po,sts,svc,pvc,servicemonitor,prometheusrule -l app.kubernetes.io/instance=devops-info-service -o wide +kubectl logs devops-info-service-0 -c init-download +kubectl logs devops-info-service-0 -c wait-for-service +kubectl exec devops-info-service-0 -- cat /init-data/status.txt +``` + +## Final Checklist + +- kube-prometheus-stack installed. +- Monitoring namespace pods and services verified. +- Broken Minikube scrape targets removed from the stack configuration. +- Grafana dashboard rendered with real data. +- All six dashboard questions answered. +- Alertmanager checked with a controlled lab alert. +- Init container download pattern implemented. +- Wait-for-service init pattern implemented. +- Main container access to init container output verified. +- Application `/metrics` endpoint scraped by Prometheus. +- ServiceMonitor and PrometheusRule implemented and verified. diff --git a/k8s/ROLLOUTS.md b/k8s/ROLLOUTS.md new file mode 100644 index 0000000000..5ef02204ff --- /dev/null +++ b/k8s/ROLLOUTS.md @@ -0,0 +1,478 @@ +# Lab 14: Progressive Delivery with Argo Rollouts + +## Scope + +This lab replaces the regular Kubernetes `Deployment` from the Helm chart with an Argo Rollouts `Rollout` when progressive delivery is enabled. The implementation covers canary deployment, blue-green deployment, dashboard inspection, manual promotion, abort and rollback, plus a bonus AnalysisTemplate for automated rollback. + +Main chart: + +```text +solution/k8s/devops-info-service +``` + +Implemented files: + +```text +solution/k8s/devops-info-service/templates/rollout.yaml +solution/k8s/devops-info-service/templates/service-preview.yaml +solution/k8s/devops-info-service/templates/analysis-template.yaml +solution/k8s/devops-info-service/values-canary.yaml +solution/k8s/devops-info-service/values-bluegreen.yaml +solution/k8s/devops-info-service/values-canary-analysis.yaml +solution/k8s/devops-info-service/values-canary-analysis-fail.yaml +``` + +The original `Deployment` is still available. It is rendered only when: + +```yaml +workload: + kind: Deployment +``` + +The Rollout is rendered when: + +```yaml +workload: + kind: Rollout +``` + +## Argo Rollouts Setup + +Argo Rollouts was installed into the `argo-rollouts` namespace: + +```powershell +kubectl create namespace argo-rollouts +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/dashboard-install.yaml +``` + +Verification: + +```powershell +kubectl rollout status deployment/argo-rollouts -n argo-rollouts +kubectl rollout status deployment/argo-rollouts-dashboard -n argo-rollouts +kubectl get pods -n argo-rollouts +kubectl get crd rollouts.argoproj.io analysistemplates.argoproj.io analysisruns.argoproj.io +kubectl argo rollouts version --short +``` + +Observed result: + +```text +argo-rollouts controller: Running +argo-rollouts dashboard: Running +kubectl-argo-rollouts: v1.9.0 +``` + +Dashboard access: + +```powershell +kubectl port-forward svc/argo-rollouts-dashboard -n argo-rollouts 13100:3100 +``` + +Local URL: + +```text +http://127.0.0.1:13100/rollouts/ +``` + +Port `13100` was used locally because port `3100` was unavailable on the Windows host. The dashboard service still listens on port `3100` inside Kubernetes. + +Evidence: + +![Dashboard opened](screenshots/lab14/02-dashboard-opened.png) + +## Rollout vs Deployment + +`Deployment` provides a standard rolling update. It can control surge and unavailable pods, but it does not provide built-in manual gates, preview traffic, analysis runs, or rich rollout-specific rollback controls. + +`Rollout` keeps the familiar Deployment shape: + +```yaml +replicas: +selector: +template: +``` + +It adds progressive delivery strategy configuration: + +```yaml +strategy: + canary: + steps: + - setWeight: 20 + - pause: {} +``` + +or: + +```yaml +strategy: + blueGreen: + activeService: devops-info-service + previewService: devops-info-service-preview + autoPromotionEnabled: false +``` + +The practical difference is that Rollout controls ReplicaSets and service selectors during release, making promotion, abort, rollback, preview testing, and metric-based decisions explicit. + +## Canary Deployment + +Canary is enabled with: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-canary.yaml +``` + +Canary values: + +```yaml +workload: + kind: Rollout + +replicaCount: 3 + +rollout: + strategy: canary +``` + +Configured canary steps: + +```yaml +steps: + - setWeight: 20 + - pause: {} + - setWeight: 40 + - pause: + duration: 30s + - setWeight: 60 + - pause: + duration: 30s + - setWeight: 80 + - pause: + duration: 30s + - setWeight: 100 +``` + +Initial installation creates the first stable ReplicaSet directly. Canary behavior starts on the next pod template change: + +```powershell +helm upgrade devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-canary.yaml ` + --set env.releaseVersion=canary-v2 +``` + +Monitoring: + +```powershell +kubectl argo rollouts get rollout devops-info-service -w +``` + +Manual promotion from the first pause: + +```powershell +kubectl argo rollouts promote devops-info-service +``` + +Abort test: + +```powershell +kubectl argo rollouts abort devops-info-service +``` + +Observed behavior: + +- Revision 2 stopped at the manual `20%` canary pause. +- Manual promotion allowed the rollout to continue through the timed `40%`, `60%`, and `80%` stages. +- A later canary update was aborted. +- The canary ReplicaSet was scaled down. +- The previous stable ReplicaSet continued serving traffic. + +Evidence: + +![Canary 20 percent pause](screenshots/lab14/03-canary-20-percent-paused.png) + +![Canary promoted healthy](screenshots/lab14/04-canary-promoted-healthy.png) + +![Canary aborted rollback](screenshots/lab14/05-canary-aborted-rollback.png) + +## Blue-Green Deployment + +Blue-green is enabled with: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-bluegreen.yaml +``` + +Blue-green values: + +```yaml +workload: + kind: Rollout + +replicaCount: 2 + +rollout: + strategy: blueGreen + blueGreen: + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 +``` + +Strategy: + +```yaml +strategy: + blueGreen: + activeService: devops-info-service + previewService: devops-info-service-preview + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 +``` + +Services: + +```text +devops-info-service active production service +devops-info-service-preview preview service for the new ReplicaSet +``` + +The preview service is created by: + +```text +solution/k8s/devops-info-service/templates/service-preview.yaml +``` + +Test flow: + +```powershell +helm upgrade devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-bluegreen.yaml ` + --set env.releaseVersion=bluegreen-v2 + +kubectl get svc devops-info-service devops-info-service-preview -o wide +kubectl argo rollouts get rollout devops-info-service +kubectl argo rollouts promote devops-info-service +kubectl argo rollouts undo devops-info-service +``` + +Observed service selectors before promotion: + +```text +devops-info-service rollouts-pod-template-hash=578f88cbbc +devops-info-service-preview rollouts-pod-template-hash=564bc5c6d6 +``` + +Observed service selectors after promotion: + +```text +devops-info-service rollouts-pod-template-hash=564bc5c6d6 +devops-info-service-preview rollouts-pod-template-hash=564bc5c6d6 +``` + +Observed service selectors after rollback: + +```text +devops-info-service rollouts-pod-template-hash=578f88cbbc +devops-info-service-preview rollouts-pod-template-hash=578f88cbbc +``` + +This confirms that blue-green promotion and rollback are service selector switches, so traffic moves immediately between ReplicaSets. + +Evidence: + +![Blue-green preview paused](screenshots/lab14/06-bluegreen-preview-paused.png) + +![Blue-green promoted active](screenshots/lab14/07-bluegreen-promoted-active.png) + +![Blue-green rollback active](screenshots/lab14/08-bluegreen-rollback-active.png) + +Service selector snapshots: + +```text +k8s/screenshots/lab14/06-bluegreen-preview-services.txt +k8s/screenshots/lab14/07-bluegreen-promoted-services.txt +k8s/screenshots/lab14/08-bluegreen-rollback-services.txt +``` + +## Automated Analysis + +The bonus task is implemented with a web-based `AnalysisTemplate`. It checks the application health endpoint through the Kubernetes service: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +metadata: + name: devops-info-service-health-check +spec: + metrics: + - name: health-endpoint + interval: 10s + count: 3 + failureLimit: 1 + successCondition: result == "healthy" + provider: + web: + url: http://devops-info-service.default.svc/health + jsonPath: "{$.status}" +``` + +The application returns: + +```json +{ + "status": "healthy" +} +``` + +The analysis is part of the canary step sequence: + +```yaml +steps: + - setWeight: 20 + - analysis: + templates: + - templateName: devops-info-service-health-check + - setWeight: 50 + - pause: + duration: 30s + - setWeight: 100 +``` + +Successful analysis deployment: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-canary-analysis.yaml +``` + +Intentional failure deployment: + +```powershell +helm upgrade devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-canary-analysis-fail.yaml +``` + +The failure values file changes the success condition: + +```yaml +successCondition: result == 'broken' +``` + +Observed result: + +```text +devops-info-service-7fcd7d4b69-2-1 Successful +devops-info-service-78d69cbdd9-3-1 Failed +``` + +The failed AnalysisRun caused the rollout to abort revision 3, set canary weight back to `0`, scale down the failed canary ReplicaSet, and keep revision 2 as stable. + +Evidence: + +![Analysis success](screenshots/lab14/09-analysis-success.png) + +![Analysis auto rollback](screenshots/lab14/10-analysis-auto-rollback.png) + +Text evidence: + +```text +k8s/screenshots/lab14/09-analysis-success.txt +k8s/screenshots/lab14/10-analysis-auto-rollback.txt +k8s/screenshots/lab14/10-analysis-auto-rollback-rollout.txt +``` + +## Strategy Comparison + +| Strategy | Best for | Strengths | Tradeoffs | +|---|---|---|---| +| Canary | Gradual exposure to users | Limits blast radius, supports metric gates, can pause and abort during rollout | Users can temporarily hit mixed versions unless traffic management is configured | +| Blue-green | Fast cutover after validation | Preview environment, instant promotion, instant rollback by switching service selectors | Requires duplicate capacity during release | + +Recommendation: + +- Use canary for user-facing services where gradual exposure and metric-based checks are important. +- Use blue-green when the new version needs full validation before receiving production traffic, or when rollback speed is the main concern. +- Add automated analysis to canary releases when reliable health, error-rate, or latency metrics are available. + +## CLI Reference + +Install controller and dashboard: + +```powershell +kubectl create namespace argo-rollouts +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/dashboard-install.yaml +``` + +Open dashboard: + +```powershell +kubectl port-forward svc/argo-rollouts-dashboard -n argo-rollouts 13100:3100 +``` + +Deploy canary: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-canary.yaml +``` + +Deploy blue-green: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-bluegreen.yaml +``` + +Deploy canary with analysis: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-canary-analysis.yaml +``` + +Inspect rollout: + +```powershell +kubectl argo rollouts get rollout devops-info-service +kubectl argo rollouts status devops-info-service +kubectl get analysisrun +kubectl get svc devops-info-service devops-info-service-preview -o wide +``` + +Operate rollout: + +```powershell +kubectl argo rollouts promote devops-info-service +kubectl argo rollouts abort devops-info-service +kubectl argo rollouts retry rollout devops-info-service +kubectl argo rollouts undo devops-info-service +``` + +Validate chart: + +```powershell +helm dependency build ./solution/k8s/devops-info-service +helm lint ./solution/k8s/devops-info-service +helm template devops-info-service ./solution/k8s/devops-info-service -f ./solution/k8s/devops-info-service/values-canary.yaml +helm template devops-info-service ./solution/k8s/devops-info-service -f ./solution/k8s/devops-info-service/values-bluegreen.yaml +helm template devops-info-service ./solution/k8s/devops-info-service -f ./solution/k8s/devops-info-service/values-canary-analysis.yaml +``` + +## Final Checklist + +- Argo Rollouts controller installed and running. +- `kubectl-argo-rollouts` plugin installed and verified. +- Dashboard installed and accessed through port-forward. +- Helm chart supports both `Deployment` and `Rollout`. +- Canary strategy implemented with manual and timed pauses. +- Canary promotion tested. +- Canary abort and rollback tested. +- Blue-green strategy implemented with active and preview services. +- Blue-green preview, promotion, and rollback tested. +- AnalysisTemplate implemented. +- Analysis success and automatic rollback on failure tested. +- Report and evidence files are included. diff --git a/k8s/SECRETS.md b/k8s/SECRETS.md new file mode 100644 index 0000000000..42a0f0cdc4 --- /dev/null +++ b/k8s/SECRETS.md @@ -0,0 +1,742 @@ +# LAB11 - Kubernetes Secrets And HashiCorp Vault + +## 1. Overview + +This lab extends the Helm chart from Lab 10 with two secret-management layers: + +- native Kubernetes `Secret` objects for simple secret injection through environment variables +- HashiCorp Vault with Kubernetes authentication and Vault Agent Injector for file-based secret delivery + +Implementation targets: + +- keep real secrets out of Git +- make Kubernetes-native secret consumption reproducible through Helm +- demonstrate why base64 encoding is not encryption +- prove Vault sidecar injection with real CLI evidence +- keep the chart DRY with named templates in `_helpers.tpl` + +Updated chart scope: + +```text +solution/k8s/devops-info-service/ + values.yaml + templates/ + _helpers.tpl + deployment.yaml + secret.yaml + serviceaccount.yaml +``` + +## 2. Kubernetes Secrets Fundamentals + +### 2.1 Secret creation + +Command used: + +```powershell +.\kubectl.exe create secret generic app-credentials -n devops-lab11 ` + --from-literal=username=demo-user ` + --from-literal=password='S3cret!42' +``` + +Observed output: + +```text +secret/app-credentials created +``` + +### 2.2 Secret inspection + +Command: + +```powershell +.\kubectl.exe get secret app-credentials -n devops-lab11 -o yaml +``` + +Observed output: + +```yaml +apiVersion: v1 +data: + password: UzNjcmV0ITQy + username: ZGVtby11c2Vy +kind: Secret +metadata: + creationTimestamp: "2026-04-09T18:29:28Z" + name: app-credentials + namespace: devops-lab11 + resourceVersion: "61935" + uid: 38b79b3c-01df-4cec-81d8-5dc007be76e0 +type: Opaque +``` + +### 2.3 Base64 decoding proof + +Commands: + +```powershell +$secret = .\kubectl.exe get secret app-credentials -n devops-lab11 -o json | ConvertFrom-Json +[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($secret.data.username)) +[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($secret.data.password)) +``` + +Observed output: + +```text +demo-user +S3cret!42 +``` + +### 2.4 Encoding vs encryption + +Base64 is only a transport-safe representation of bytes. It is reversible without a key, so it does not provide confidentiality. + +Encryption requires a cryptographic process and a key. Without the key, the stored data should remain unreadable. + +Practical conclusion: + +- Kubernetes `Secret.data` is base64-encoded +- base64 does not protect the secret from anyone who can read the object +- access control and at-rest encryption are separate security layers + +### 2.5 Security implications + +Important points: + +- Kubernetes Secrets are not meaningfully protected by base64 alone +- if etcd encryption at rest is not enabled, secret values are stored in etcd in a form that cluster administrators or backups can recover easily +- RBAC limits who can read the Secret through the Kubernetes API, but RBAC does not encrypt the stored data + +What is etcd encryption: + +- Kubernetes can encrypt selected resources before writing them to etcd +- Secrets are the most common resource type to protect this way +- this is configured by the cluster administrator through an encryption provider config on the API server + +When to enable etcd encryption: + +- always for any cluster that stores real credentials, tokens, database passwords, or certificates +- especially when etcd backups are retained outside the cluster +- especially in shared, long-lived, or production environments + +## 3. Helm Secret Integration + +### 3.1 Chart changes + +Implemented changes: + +- added `templates/secret.yaml` for chart-managed Kubernetes Secrets +- added `templates/serviceaccount.yaml` for Vault role binding +- updated `templates/deployment.yaml` to: + - consume the Secret via `envFrom.secretRef` + - use resource requests and limits from values + - attach Vault annotations when enabled +- added named templates in [`solution/k8s/devops-info-service/templates/_helpers.tpl`](c:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/k8s/devops-info-service/templates/_helpers.tpl): + - `devops-info-service.envVars` + - `devops-info-service.secretName` + - `devops-info-service.serviceAccountName` + - `devops-info-service.vaultAnnotations` + +### 3.2 Values strategy + +The chart keeps only placeholders in Git: + +```yaml +secret: + create: true + type: Opaque + data: + username: "change-me-user" + password: "change-me-password" +``` + +Real values were injected only at deploy time: + +```powershell +.\helm.exe install devops-info-secrets solution\k8s\devops-info-service ` + --namespace devops-lab11 ` + --set partOf=devops-lab11 ` + --set service.type=ClusterIP ` + --set secret.data.username=lab11-user ` + --set secret.data.password=Lab11-Password-123 ` + --wait --wait-for-jobs --timeout 5m +``` + +Observed output: + +```text +NAME: devops-info-secrets +NAMESPACE: devops-lab11 +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +``` + +### 3.3 Deployment verification + +Command: + +```powershell +.\kubectl.exe get all -n devops-lab11 +``` + +Observed output: + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-secrets-devops-info-service-84797dff4d-62qct 1/1 Running 0 74s +pod/devops-info-secrets-devops-info-service-84797dff4d-c8gnv 1/1 Running 0 74s +pod/devops-info-secrets-devops-info-service-84797dff4d-ptlrb 1/1 Running 0 74s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-secrets-devops-info-service ClusterIP 10.109.100.33 80/TCP 74s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-info-secrets-devops-info-service 3/3 3 3 74s +``` + +### 3.4 Secret injection into environment variables + +Command: + +```powershell +$pod = .\kubectl.exe get pods -n devops-lab11 ` + -l app.kubernetes.io/instance=devops-info-secrets,app.kubernetes.io/name=devops-info-service ` + -o jsonpath='{.items[0].metadata.name}' + +.\kubectl.exe exec -n devops-lab11 $pod -- /bin/sh -c ` + 'printenv | sort | grep username; printenv | sort | grep password; printenv | sort | grep HOST; printenv | sort | grep PORT; printenv | sort | grep DEBUG; printenv | sort | grep RELEASE_VERSION' +``` + +Observed output: + +```text +username=lab11-user +password=Lab11-Password-123 +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_SERVICE_HOST=10.109.100.33 +HOST=0.0.0.0 +HOSTNAME=devops-info-secrets-devops-info-service-84797dff4d-62qct +KUBERNETES_SERVICE_HOST=10.96.0.1 +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_PORT=tcp://10.109.100.33:80 +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_PORT_80_TCP=tcp://10.109.100.33:80 +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_PORT_80_TCP_ADDR=10.109.100.33 +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_PORT_80_TCP_PORT=80 +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_PORT_80_TCP_PROTO=tcp +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_SERVICE_PORT=80 +DEVOPS_INFO_SECRETS_DEVOPS_INFO_SERVICE_SERVICE_PORT_HTTP=80 +KUBERNETES_PORT=tcp://10.96.0.1:443 +KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443 +KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1 +KUBERNETES_PORT_443_TCP_PORT=443 +KUBERNETES_PORT_443_TCP_PROTO=tcp +KUBERNETES_SERVICE_PORT=443 +KUBERNETES_SERVICE_PORT_HTTPS=443 +PORT=5000 +DEBUG=false +RELEASE_VERSION=v1 +``` + +### 3.5 Secrets are not printed by `kubectl describe pod` + +Command: + +```powershell +.\kubectl.exe describe deployment devops-info-secrets-devops-info-service -n devops-lab11 +``` + +Observed excerpt: + +```text +Environment Variables from: + devops-info-secrets-devops-info-service-secret Secret Optional: false +Environment: + HOST: 0.0.0.0 + PORT: 5000 + DEBUG: false + RELEASE_VERSION: v1 +``` + +This proves the pod spec references the Secret object, but the actual secret values are not printed in the `describe` output. + +## 4. Resource Management + +### 4.1 Implemented configuration + +Current chart defaults: + +```yaml +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi +``` + +Runtime verification: + +```text +Limits: + cpu: 200m + memory: 256Mi +Requests: + cpu: 100m + memory: 128Mi +``` + +### 4.2 Requests vs limits + +Requests: + +- define the minimum resources the scheduler reserves for the container +- affect pod placement and baseline QoS + +Limits: + +- define the maximum resources the container is allowed to consume +- CPU is throttled above the limit +- memory overuse can cause OOM termination + +### 4.3 Why these values were chosen + +- `100m / 128Mi` is sufficient for a small FastAPI service with health checks in Minikube +- `200m / 256Mi` leaves some burst room without letting the pod monopolize the single-node lab cluster +- the values match the light-weight nature of the app and remain easy to scale up in `values-prod.yaml` + +## 5. HashiCorp Vault Integration + +### 5.1 Installation + +Commands: + +```powershell +.\helm.exe repo add hashicorp https://helm.releases.hashicorp.com +.\helm.exe repo update +.\kubectl.exe create namespace vault +.\helm.exe install vault hashicorp/vault ` + --namespace vault ` + --set server.dev.enabled=true ` + --set injector.enabled=true ` + --wait --timeout 10m +``` + +Observed output: + +```text +NAME: vault +NAMESPACE: vault +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +``` + +### 5.2 Vault pod verification + +Command: + +```powershell +.\kubectl.exe get pods -n vault -o wide +``` + +Observed output: + +```text +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +vault-0 1/1 Running 0 21m 10.244.0.100 minikube +vault-agent-injector-8c76487db-5n2gw 1/1 Running 0 21m 10.244.0.99 minikube +``` + +### 5.3 Vault configuration + +Vault status and enabled secret engines: + +```powershell +.\kubectl.exe exec -n vault vault-0 -- /bin/sh -c "vault status && echo --- && vault secrets list -detailed" +``` + +Observed excerpt: + +```text +Initialized true +Sealed false +Version 1.21.2 +Storage Type inmem +--- +Path Plugin Options +secret/ kv map[version:2] +``` + +Application secret written to Vault: + +```powershell +vault kv put secret/devops-info-service/config username="vault-user" password="Vault-Password-456" +``` + +Observed output: + +```text +============= Secret Path ============= +secret/data/devops-info-service/config + +======= Metadata ======= +version 1 +``` + +Readback proof: + +```powershell +vault kv get secret/devops-info-service/config +``` + +Observed output: + +```text +====== Data ====== +Key Value +--- ----- +password Vault-Password-456 +username vault-user +``` + +### 5.4 Kubernetes auth configuration + +RBAC required for token review: + +```powershell +.\kubectl.exe create clusterrolebinding vault-token-reviewer ` + --clusterrole=system:auth-delegator ` + --serviceaccount=vault:vault +``` + +Observed output: + +```text +clusterrolebinding.rbac.authorization.k8s.io/vault-token-reviewer created +``` + +Auth config verification: + +```powershell +.\kubectl.exe exec -n vault vault-0 -- /bin/sh -c "vault read auth/kubernetes/config" +``` + +Observed output excerpt: + +```text +disable_iss_validation true +kubernetes_host https://10.96.0.1:443 +token_reviewer_jwt_set true +``` + +Policy used for the app: + +```hcl +path "secret/data/devops-info-service/config" { + capabilities = ["read"] +} +``` + +Policy verification: + +```powershell +vault policy read devops-info-service +``` + +Observed output: + +```hcl +path "secret/data/devops-info-service/config" { + capabilities = ["read"] +} +``` + +Role verification: + +```powershell +vault read auth/kubernetes/role/devops-info-service +``` + +Observed sanitized output: + +```text +bound_service_account_names [devops-info-secrets-devops-info-service] +bound_service_account_namespaces [devops-lab11] +policies [devops-info-service] +token_ttl 24h +ttl 24h +``` + +### 5.5 Vault Agent injection in the application chart + +Helm upgrade used: + +```powershell +.\helm.exe upgrade devops-info-secrets solution\k8s\devops-info-service ` + --namespace devops-lab11 ` + --set partOf=devops-lab11 ` + --set service.type=ClusterIP ` + --set secret.data.username=lab11-user ` + --set secret.data.password=Lab11-Password-123 ` + --set vault.enabled=true ` + --set vault.role=devops-info-service ` + --set vault.secretPath=secret/data/devops-info-service/config ` + --set vault.authPath=auth/kubernetes ` + --wait --timeout 10m +``` + +Observed output: + +```text +Release "devops-info-secrets" has been upgraded. Happy Helming! +NAME: devops-info-secrets +STATUS: deployed +REVISION: 3 +DESCRIPTION: Upgrade complete +``` + +Deployment verification: + +```powershell +.\kubectl.exe get pods -n devops-lab11 -o wide +``` + +Observed output: + +```text +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +devops-info-secrets-devops-info-service-6b7c4899c-5jdx6 2/2 Running 0 55s 10.244.0.103 minikube +devops-info-secrets-devops-info-service-6b7c4899c-9w56d 2/2 Running 0 41s 10.244.0.104 minikube +devops-info-secrets-devops-info-service-6b7c4899c-lbpvn 2/2 Running 0 26s 10.244.0.105 minikube +``` + +Pod proof showing the init container and sidecar: + +```text +Init Containers: + vault-agent-init: + State: Terminated + Reason: Completed + Exit Code: 0 + +Containers: + devops-info-service: + State: Running + Ready: True + vault-agent: + State: Running + Ready: True +``` + +### 5.6 File injection proof + +Command: + +```powershell +$pod = .\kubectl.exe get pods -n devops-lab11 ` + -l app.kubernetes.io/instance=devops-info-secrets,app.kubernetes.io/name=devops-info-service ` + -o jsonpath='{.items[0].metadata.name}' + +.\kubectl.exe exec -n devops-lab11 $pod -c devops-info-service -- /bin/sh -c ` + 'ls -la /vault/secrets && echo --- && cat /vault/secrets/app.env && echo --- && sed -n "1,20p" /vault/secrets/app-secrets.txt' +``` + +Observed output: + +```text +total 12 +drwxrwsrwt 2 root appgroup 80 Apr 9 18:52 . +drwxr-xr-x 3 root root 4096 Apr 9 18:52 .. +-rw-r--r-- 1 100 appgroup 181 Apr 9 18:52 app-secrets.txt +-rw-r--r-- 1 100 appgroup 56 Apr 9 18:52 app.env +--- +APP_USERNAME=vault-user +APP_PASSWORD=Vault-Password-456 +--- +data: map[password:Vault-Password-456 username:vault-user] +metadata: map[created_time:2026-04-09T18:34:34.521026522Z custom_metadata: deletion_time: destroyed:false version:1] +``` + +This confirms: + +- the injector created files under `/vault/secrets` +- `app.env` was rendered from a custom template +- a second file was injected from the same Vault path + +## 6. Sidecar Injection Pattern + +How it works in this deployment: + +- the mutating webhook sees Vault annotations on the pod template +- it injects `vault-agent-init` and `vault-agent` +- `vault-agent-init` authenticates to Vault before the main container starts +- the template engine renders secret files into the shared in-memory volume at `/vault/secrets` +- the sidecar keeps running to renew the token and refresh templates when leased data changes + +Why this is useful: + +- the application container does not need to call Vault directly +- the secret never has to be baked into the container image +- the app can read files from a local path instead of implementing Vault client logic + +## 7. Bonus - Vault Agent Templates And DRY Helm + +### 7.1 Template annotation + +The chart implements a custom template annotation through [`solution/k8s/devops-info-service/templates/_helpers.tpl`](c:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/k8s/devops-info-service/templates/_helpers.tpl): + +```yaml +vault.hashicorp.com/agent-inject-template-app.env: | + {{- with secret "secret/data/devops-info-service/config" -}} + APP_USERNAME={{ .Data.data.username }} + APP_PASSWORD={{ .Data.data.password }} + {{- end }} +``` + +Result: + +- multiple Vault keys are rendered into one `.env`-style file +- the app gets a format that is immediately usable by shell tooling or config loaders + +### 7.2 Named template for common environment variables + +Implemented named template: + +```yaml +{{- define "devops-info-service.envVars" -}} +- name: HOST + value: {{ .Values.env.host | quote }} +- name: PORT + value: {{ .Values.env.port | quote }} +- name: DEBUG + value: {{ .Values.env.debug | quote }} +- name: RELEASE_VERSION + value: {{ .Values.env.releaseVersion | quote }} +{{- end -}} +``` + +Why this matters: + +- common env vars are defined in one place +- the Deployment template stays shorter +- future reuse across workload templates becomes simpler + +### 7.3 Secret refresh behavior + +How Vault Agent handles updates: + +- the sidecar keeps the Vault token valid +- for rotating or leased secrets, the agent re-renders templates when data changes or leases renew +- applications that read from files can reload configuration after file change detection or restart hooks + +About `vault.hashicorp.com/agent-inject-command`: + +- this annotation runs a command after the agent writes or refreshes an injected file +- common uses: + - reload nginx or another proxy + - send `SIGHUP` to an application + - adjust file permissions +- the chart exposes this as `vault.injectCommand`; it is intentionally optional and was left empty in the validated run to keep the demo minimal + +## 8. Security Analysis + +### 8.1 Kubernetes Secrets vs Vault + +Kubernetes Secrets: + +- simple and built into the platform +- good for basic apps or low-complexity clusters +- still stored in the cluster control plane +- often exposed through Helm values, manifests, or backup workflows if teams are careless + +Vault: + +- purpose-built for secret management +- supports fine-grained policies, auditability, and dynamic secrets +- keeps the source of truth outside application manifests +- scales better for production-grade secret rotation and separation of duties + +### 8.2 When to use each approach + +Use Kubernetes Secrets when: + +- the environment is small and controlled +- secret lifecycle is simple +- you need the fastest native setup for local or educational workloads + +Use Vault when: + +- multiple teams or apps need controlled secret access +- rotation and audit trails matter +- you want short-lived credentials or externalized secret ownership + +### 8.3 Production recommendations + +- enable etcd encryption at rest for Secrets +- restrict secret access through RBAC +- avoid passing real secrets through committed Helm values files +- prefer external secret managers for production +- avoid Vault dev mode outside learning environments +- audit who can read Helm release metadata because Helm itself can expose values used at deploy time + +## 9. Validation Summary + +What was completed: + +- Kubernetes Secret created imperatively and decoded +- Helm chart extended with `Secret` and `ServiceAccount` +- application consumes Kubernetes Secret through `envFrom` +- resource requests and limits remain configurable through values +- Vault installed with injector enabled +- KV v2 secret created and read back +- Kubernetes auth method configured +- Vault policy and role created +- Vault Agent injection verified with real files inside the pod +- bonus template rendering and named Helm templates implemented + +## 10. Operations And Rollback + +### 10.1 What changed on the system + +Local cluster changes performed: + +- started local `minikube` +- created namespace `devops-lab11` +- created namespace `vault` +- installed Helm release `devops-info-secrets` +- installed Helm release `vault` +- created cluster-wide RBAC binding `vault-token-reviewer` + +### 10.2 Rollback commands + +Remove Lab 11 application resources: + +```powershell +.\helm.exe uninstall devops-info-secrets -n devops-lab11 +.\kubectl.exe delete secret app-credentials -n devops-lab11 +.\kubectl.exe delete namespace devops-lab11 +``` + +Remove Vault resources: + +```powershell +.\helm.exe uninstall vault -n vault +.\kubectl.exe delete namespace vault +.\kubectl.exe delete clusterrolebinding vault-token-reviewer +``` + +Stop the local cluster without deleting it: + +```powershell +minikube stop +``` + +Delete the entire local cluster: + +```powershell +minikube delete +``` + +## 11. Conclusion + +The lab objective was completed by implementing both native Kubernetes Secrets and HashiCorp Vault integration in the existing Helm-based Kubernetes deployment. The chart now supports secure secret injection through `envFrom`, Vault-authenticated sidecar injection through annotations, custom Vault Agent template rendering for `.env` files, and DRY named templates for common environment variables. Real CLI evidence confirms that the app can consume both Kubernetes-managed and Vault-managed secrets in a working Minikube environment. diff --git a/k8s/STATEFULSET.md b/k8s/STATEFULSET.md new file mode 100644 index 0000000000..1b029465b2 --- /dev/null +++ b/k8s/STATEFULSET.md @@ -0,0 +1,412 @@ +# Lab 15: StatefulSets and Persistent Storage + +## Scope + +This lab adds a StatefulSet mode to the existing Helm chart for `devops-info-service`. The chart can still render the previous workload types, but Lab 15 uses: + +```yaml +workload: + kind: StatefulSet +``` + +Main chart: + +```text +solution/k8s/devops-info-service +``` + +Implemented files: + +```text +solution/k8s/devops-info-service/templates/statefulset.yaml +solution/k8s/devops-info-service/templates/service-headless.yaml +solution/k8s/devops-info-service/values-statefulset.yaml +solution/k8s/devops-info-service/values-statefulset-partition.yaml +solution/k8s/devops-info-service/values-statefulset-ondelete.yaml +``` + +The application image used for this lab was built from the local FastAPI source because it contains the `/visits` endpoint required for the storage tests: + +```powershell +minikube image build -t devops-info-service:lab15 ./solution/app_python +``` + +## Planning + +The lab was implemented in five stages: + +1. Extend the Helm chart with a StatefulSet template and a headless service. +2. Replace shared PVC usage with `volumeClaimTemplates` when StatefulSet mode is enabled. +3. Deploy three replicas and verify pod identity, DNS, and PVC allocation. +4. Prove per-pod storage isolation and persistence after pod deletion. +5. Test StatefulSet update strategies: partitioned `RollingUpdate` and `OnDelete`. + +No manual intervention was required during the execution. The only expected manual actions in another environment would be starting Docker Desktop, approving a firewall prompt for port forwarding, or checking screenshots manually if browser automation is unavailable. + +## StatefulSet Overview + +StatefulSets are intended for workloads that need stable identity and stable storage. Examples include databases, queues, and clustered systems such as PostgreSQL, MongoDB, Kafka, RabbitMQ, Elasticsearch, or Cassandra. + +Key differences: + +| Feature | Deployment | StatefulSet | +|---|---|---| +| Pod identity | Random ReplicaSet pod names | Stable ordinal names | +| Pod naming | `app-7c9d8f-px2ab` | `app-0`, `app-1`, `app-2` | +| Storage | Shared PVC or ephemeral volumes | Per-pod PVCs from `volumeClaimTemplates` | +| Scaling | Any order | Ordered by default | +| Network identity | Service load balancing | Stable pod DNS through a headless service | +| Update behavior | Rolling update across interchangeable pods | Ordered update by ordinal, with partition and OnDelete options | + +For stateless traffic and progressive delivery, Rollouts from Lab 14 are the better fit. For stable identity and durable per-replica state, StatefulSets are the right controller. + +## Helm Implementation + +The StatefulSet template uses the existing pod template helper from the chart: + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: devops-info-service +spec: + serviceName: devops-info-service-headless + replicas: 3 + podManagementPolicy: OrderedReady + selector: + matchLabels: + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/instance: devops-info-service + template: + ... + volumeClaimTemplates: + - metadata: + name: data-volume + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi +``` + +Important storage detail: + +- Deployment and Rollout modes use the chart's standalone PVC template. +- StatefulSet mode does not render the standalone PVC. +- StatefulSet mode uses `volumeClaimTemplates`, so each pod gets its own PVC. + +The headless service is rendered only in StatefulSet mode: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: devops-info-service-headless +spec: + clusterIP: None + selector: + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/instance: devops-info-service +``` + +## Deployment + +The StatefulSet was deployed with: + +```powershell +helm upgrade --install devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-statefulset.yaml ` + --wait --timeout 5m +``` + +Verification: + +```powershell +kubectl rollout status statefulset/devops-info-service --timeout=300s +kubectl get po,sts,svc,pvc -l app.kubernetes.io/instance=devops-info-service -o wide +``` + +Observed result: + +```text +statefulset.apps/devops-info-service 3/3 + +pod/devops-info-service-0 Running +pod/devops-info-service-1 Running +pod/devops-info-service-2 Running + +service/devops-info-service NodePort +service/devops-info-service-headless ClusterIP None + +persistentvolumeclaim/data-volume-devops-info-service-0 Bound +persistentvolumeclaim/data-volume-devops-info-service-1 Bound +persistentvolumeclaim/data-volume-devops-info-service-2 Bound +``` + +Evidence: + +```text +k8s/screenshots/lab15/01-resource-verification.txt +``` + +## Stable Network Identity + +StatefulSet pods were created with ordinal names: + +```text +devops-info-service-0 +devops-info-service-1 +devops-info-service-2 +``` + +The headless service creates DNS records for direct pod access: + +```text +devops-info-service-0.devops-info-service-headless.default.svc.cluster.local +devops-info-service-1.devops-info-service-headless.default.svc.cluster.local +devops-info-service-2.devops-info-service-headless.default.svc.cluster.local +``` + +DNS was tested from `devops-info-service-0`: + +```powershell +kubectl exec devops-info-service-0 -- nslookup devops-info-service-1.devops-info-service-headless.default.svc.cluster.local +kubectl exec devops-info-service-0 -- nslookup devops-info-service-2.devops-info-service-headless.default.svc.cluster.local +``` + +Observed result: + +```text +Name: devops-info-service-1.devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.67 + +Name: devops-info-service-2.devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.68 +``` + +Evidence: + +```text +k8s/screenshots/lab15/02-dns-resolution.txt +``` + +## Per-Pod Storage Isolation + +Each pod stores visits in: + +```text +/data/visits +``` + +The `/data` mount comes from the pod's own PVC: + +```text +data-volume-devops-info-service-0 +data-volume-devops-info-service-1 +data-volume-devops-info-service-2 +``` + +Each pod was accessed directly through a separate port-forward: + +```powershell +kubectl port-forward pod/devops-info-service-0 18180:5000 +kubectl port-forward pod/devops-info-service-1 18181:5000 +kubectl port-forward pod/devops-info-service-2 18182:5000 +``` + +Different numbers of requests were sent to each pod. The visit counters were then checked: + +```text +pod-0: +{"count":2,"file_path":"/data/visits"} + +pod-1: +{"count":1,"file_path":"/data/visits"} + +pod-2: +{"count":3,"file_path":"/data/visits"} +``` + +This proves that the pods are not sharing the same visits file. Each pod writes to its own PVC-backed storage. + +Evidence: + +```text +k8s/screenshots/lab15/03-per-pod-storage.txt +``` + +## Persistence Test + +The persistence test used pod `devops-info-service-0`. + +Before deleting the pod: + +```powershell +kubectl exec devops-info-service-0 -- cat /data/visits +``` + +Observed value: + +```text +2 +``` + +The pod was deleted: + +```powershell +kubectl delete pod devops-info-service-0 --wait=true +kubectl wait --for=condition=Ready pod/devops-info-service-0 --timeout=180s +``` + +After the StatefulSet recreated the pod: + +```powershell +kubectl exec devops-info-service-0 -- cat /data/visits +``` + +Observed value: + +```text +2 +``` + +The pod IP changed after recreation, but the data stayed the same because the PVC was reattached to the same ordinal pod. + +Evidence: + +```text +k8s/screenshots/lab15/04-persistence-test.txt +``` + +## Bonus: Partitioned RollingUpdate + +Partitioned update values: + +```yaml +statefulset: + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 2 +``` + +Command: + +```powershell +helm upgrade devops-info-service ./solution/k8s/devops-info-service ` + -f ./solution/k8s/devops-info-service/values-statefulset-partition.yaml ` + --set env.releaseVersion=partition-v2 ` + --wait --timeout 5m +``` + +With `partition: 2`, only pods with ordinal greater than or equal to `2` should update. The result matched that behavior: + +```text +devops-info-service-0=statefulset-v1 +devops-info-service-1=statefulset-v1 +devops-info-service-2=partition-v2 +``` + +Controller revisions also showed that only `devops-info-service-2` moved to the new revision: + +```text +devops-info-service-0 devops-info-service-86d4556fc4 +devops-info-service-1 devops-info-service-86d4556fc4 +devops-info-service-2 devops-info-service-58668f5bcf +``` + +Use case: + +- Keep lower ordinals on the old version. +- Test a new version on higher ordinals. +- Continue the rollout later by lowering the partition. + +Evidence: + +```text +k8s/screenshots/lab15/05-partitioned-update.txt +``` + +## Bonus: OnDelete Strategy + +OnDelete values: + +```yaml +statefulset: + updateStrategy: + type: OnDelete +``` + +The baseline was created with: + +```text +devops-info-service-0=ondelete-v1 +devops-info-service-1=ondelete-v1 +devops-info-service-2=ondelete-v1 +``` + +Then the pod template was updated to `ondelete-v2`. Before any manual pod deletion, all pods stayed on the old version: + +```text +devops-info-service-0=ondelete-v1 +devops-info-service-1=ondelete-v1 +devops-info-service-2=ondelete-v1 +``` + +After deleting only `devops-info-service-2`, only that pod was recreated from the new template: + +```text +devops-info-service-0=ondelete-v1 +devops-info-service-1=ondelete-v1 +devops-info-service-2=ondelete-v2 +``` + +Use case: + +- Manual control over pod replacement. +- Stateful systems where each member must be drained, checked, or coordinated before update. +- Workloads where automatic rolling replacement is too risky. + +Evidence: + +```text +k8s/screenshots/lab15/06-ondelete-update.txt +``` + +## Validation Commands + +Chart validation: + +```powershell +helm lint ./solution/k8s/devops-info-service +helm template devops-info-service ./solution/k8s/devops-info-service -f ./solution/k8s/devops-info-service/values-statefulset.yaml +helm template devops-info-service ./solution/k8s/devops-info-service -f ./solution/k8s/devops-info-service/values-statefulset-partition.yaml +helm template devops-info-service ./solution/k8s/devops-info-service -f ./solution/k8s/devops-info-service/values-statefulset-ondelete.yaml +``` + +Runtime validation: + +```powershell +kubectl get statefulset devops-info-service +kubectl get pods -l app.kubernetes.io/instance=devops-info-service +kubectl get svc devops-info-service devops-info-service-headless +kubectl get pvc -l app.kubernetes.io/instance=devops-info-service +kubectl exec devops-info-service-0 -- nslookup devops-info-service-1.devops-info-service-headless.default.svc.cluster.local +kubectl exec devops-info-service-0 -- cat /data/visits +``` + +## Final Checklist + +- StatefulSet guarantees documented. +- StatefulSet template created. +- Headless service created. +- Existing service kept for regular access. +- `volumeClaimTemplates` configured. +- One PVC per pod verified. +- Stable pod names verified. +- DNS resolution through headless service verified. +- Per-pod storage isolation verified with different visit counts. +- Persistence after pod deletion verified. +- Partitioned RollingUpdate tested. +- OnDelete update strategy tested. diff --git a/k8s/argocd/application-dev.yaml b/k8s/argocd/application-dev.yaml new file mode 100644 index 0000000000..e15e93575d --- /dev/null +++ b/k8s/argocd/application-dev.yaml @@ -0,0 +1,28 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-info-dev + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://github.com/XriXis/DevOps-Core-Course.git + targetRevision: lab12 + path: solution/k8s/devops-info-service + helm: + valueFiles: + - values-dev.yaml + parameters: + - name: service.nodePort + value: "30083" + destination: + server: https://kubernetes.default.svc + namespace: dev + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/k8s/argocd/application-prod.yaml b/k8s/argocd/application-prod.yaml new file mode 100644 index 0000000000..187de7283c --- /dev/null +++ b/k8s/argocd/application-prod.yaml @@ -0,0 +1,22 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-info-prod + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://github.com/XriXis/DevOps-Core-Course.git + targetRevision: lab12 + path: solution/k8s/devops-info-service + helm: + valueFiles: + - values-prod.yaml + destination: + server: https://kubernetes.default.svc + namespace: prod + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/k8s/argocd/application.yaml b/k8s/argocd/application.yaml new file mode 100644 index 0000000000..fba4963e65 --- /dev/null +++ b/k8s/argocd/application.yaml @@ -0,0 +1,25 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-info-main + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://github.com/XriXis/DevOps-Core-Course.git + targetRevision: lab12 + path: solution/k8s/devops-info-service + helm: + valueFiles: + - values.yaml + parameters: + - name: service.nodePort + value: "30082" + destination: + server: https://kubernetes.default.svc + namespace: gitops-main + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/k8s/argocd/applicationset.yaml b/k8s/argocd/applicationset.yaml new file mode 100644 index 0000000000..ff98186a16 --- /dev/null +++ b/k8s/argocd/applicationset.yaml @@ -0,0 +1,53 @@ +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: devops-info-envs + namespace: argocd +spec: + goTemplate: true + goTemplateOptions: + - missingkey=error + generators: + - list: + elements: + - env: dev + namespace: dev + valuesFile: values-dev.yaml + autoSync: "true" + - env: prod + namespace: prod + valuesFile: values-prod.yaml + autoSync: "false" + template: + metadata: + name: devops-info-{{.env}} + labels: + app.kubernetes.io/managed-by: applicationset + devops-course/environment: "{{.env}}" + spec: + project: default + source: + repoURL: https://github.com/XriXis/DevOps-Core-Course.git + targetRevision: lab12 + path: solution/k8s/devops-info-service + helm: + valueFiles: + - "{{.valuesFile}}" + destination: + server: https://kubernetes.default.svc + namespace: "{{.namespace}}" + templatePatch: | + {{- if eq .autoSync "true" }} + spec: + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + {{- else }} + spec: + syncPolicy: + syncOptions: + - CreateNamespace=true + {{- end }} diff --git a/k8s/lab16-monitoring-stack-values.yaml b/k8s/lab16-monitoring-stack-values.yaml new file mode 100644 index 0000000000..8b4cd09ec9 --- /dev/null +++ b/k8s/lab16-monitoring-stack-values.yaml @@ -0,0 +1,36 @@ +grafana: + adminPassword: prom-operator + +kubeControllerManager: + enabled: false + +kubeScheduler: + enabled: false + +kubeEtcd: + enabled: false + +defaultRules: + disabled: + Watchdog: true + rules: + etcd: false + kubeControllerManager: false + kubeSchedulerAlerting: false + kubeSchedulerRecording: false + +alertmanager: + config: + global: + resolve_timeout: 5m + route: + receiver: lab16-receiver + group_by: + - namespace + - alertname + group_wait: 10s + group_interval: 30s + repeat_interval: 12h + receivers: + - name: lab16-receiver + - name: "null" diff --git a/k8s/screenshots/lab13/.gitkeep b/k8s/screenshots/lab13/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/k8s/screenshots/lab13/.gitkeep @@ -0,0 +1 @@ + diff --git a/k8s/screenshots/lab13/01-argocd-applications-overview.png b/k8s/screenshots/lab13/01-argocd-applications-overview.png new file mode 100644 index 0000000000..d6e8fa7cd4 Binary files /dev/null and b/k8s/screenshots/lab13/01-argocd-applications-overview.png differ diff --git a/k8s/screenshots/lab13/02-argocd-main-app-details.png b/k8s/screenshots/lab13/02-argocd-main-app-details.png new file mode 100644 index 0000000000..be9508f34c Binary files /dev/null and b/k8s/screenshots/lab13/02-argocd-main-app-details.png differ diff --git a/k8s/screenshots/lab13/03-argocd-dev-app-details.png b/k8s/screenshots/lab13/03-argocd-dev-app-details.png new file mode 100644 index 0000000000..7633915d59 Binary files /dev/null and b/k8s/screenshots/lab13/03-argocd-dev-app-details.png differ diff --git a/k8s/screenshots/lab13/04-argocd-prod-app-details.png b/k8s/screenshots/lab13/04-argocd-prod-app-details.png new file mode 100644 index 0000000000..3a4321b7ff Binary files /dev/null and b/k8s/screenshots/lab13/04-argocd-prod-app-details.png differ diff --git a/k8s/screenshots/lab13/05-argocd-sync-status.png b/k8s/screenshots/lab13/05-argocd-sync-status.png new file mode 100644 index 0000000000..f6bfb7d92a Binary files /dev/null and b/k8s/screenshots/lab13/05-argocd-sync-status.png differ diff --git a/k8s/screenshots/lab14/02-dashboard-opened.png b/k8s/screenshots/lab14/02-dashboard-opened.png new file mode 100644 index 0000000000..3455c6344b Binary files /dev/null and b/k8s/screenshots/lab14/02-dashboard-opened.png differ diff --git a/k8s/screenshots/lab14/03-canary-20-percent-paused.png b/k8s/screenshots/lab14/03-canary-20-percent-paused.png new file mode 100644 index 0000000000..7902889562 Binary files /dev/null and b/k8s/screenshots/lab14/03-canary-20-percent-paused.png differ diff --git a/k8s/screenshots/lab14/04-canary-promoted-healthy.png b/k8s/screenshots/lab14/04-canary-promoted-healthy.png new file mode 100644 index 0000000000..19f928cca6 Binary files /dev/null and b/k8s/screenshots/lab14/04-canary-promoted-healthy.png differ diff --git a/k8s/screenshots/lab14/05-canary-aborted-rollback.png b/k8s/screenshots/lab14/05-canary-aborted-rollback.png new file mode 100644 index 0000000000..d9af7d4c9d Binary files /dev/null and b/k8s/screenshots/lab14/05-canary-aborted-rollback.png differ diff --git a/k8s/screenshots/lab14/06-bluegreen-preview-paused.png b/k8s/screenshots/lab14/06-bluegreen-preview-paused.png new file mode 100644 index 0000000000..53f5ba6274 Binary files /dev/null and b/k8s/screenshots/lab14/06-bluegreen-preview-paused.png differ diff --git a/k8s/screenshots/lab14/06-bluegreen-preview-services.txt b/k8s/screenshots/lab14/06-bluegreen-preview-services.txt new file mode 100644 index 0000000000..dca8885101 --- /dev/null +++ b/k8s/screenshots/lab14/06-bluegreen-preview-services.txt @@ -0,0 +1,3 @@ +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +devops-info-service NodePort 10.96.99.249 80:30080/TCP 45s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service,rollouts-pod-template-hash=578f88cbbc +devops-info-service-preview NodePort 10.96.42.98 80:30082/TCP 45s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service,rollouts-pod-template-hash=564bc5c6d6 diff --git a/k8s/screenshots/lab14/07-bluegreen-promoted-active.png b/k8s/screenshots/lab14/07-bluegreen-promoted-active.png new file mode 100644 index 0000000000..c861c57ffe Binary files /dev/null and b/k8s/screenshots/lab14/07-bluegreen-promoted-active.png differ diff --git a/k8s/screenshots/lab14/07-bluegreen-promoted-services.txt b/k8s/screenshots/lab14/07-bluegreen-promoted-services.txt new file mode 100644 index 0000000000..28c9e8c2f6 --- /dev/null +++ b/k8s/screenshots/lab14/07-bluegreen-promoted-services.txt @@ -0,0 +1,3 @@ +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +devops-info-service NodePort 10.96.99.249 80:30080/TCP 57s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service,rollouts-pod-template-hash=564bc5c6d6 +devops-info-service-preview NodePort 10.96.42.98 80:30082/TCP 57s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service,rollouts-pod-template-hash=564bc5c6d6 diff --git a/k8s/screenshots/lab14/08-bluegreen-rollback-active.png b/k8s/screenshots/lab14/08-bluegreen-rollback-active.png new file mode 100644 index 0000000000..19307d0292 Binary files /dev/null and b/k8s/screenshots/lab14/08-bluegreen-rollback-active.png differ diff --git a/k8s/screenshots/lab14/08-bluegreen-rollback-services.txt b/k8s/screenshots/lab14/08-bluegreen-rollback-services.txt new file mode 100644 index 0000000000..df9c2d1f41 --- /dev/null +++ b/k8s/screenshots/lab14/08-bluegreen-rollback-services.txt @@ -0,0 +1,3 @@ +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +devops-info-service NodePort 10.96.99.249 80:30080/TCP 89s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service,rollouts-pod-template-hash=578f88cbbc +devops-info-service-preview NodePort 10.96.42.98 80:30082/TCP 89s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service,rollouts-pod-template-hash=578f88cbbc diff --git a/k8s/screenshots/lab14/09-analysis-success.png b/k8s/screenshots/lab14/09-analysis-success.png new file mode 100644 index 0000000000..214c78bf34 Binary files /dev/null and b/k8s/screenshots/lab14/09-analysis-success.png differ diff --git a/k8s/screenshots/lab14/09-analysis-success.txt b/k8s/screenshots/lab14/09-analysis-success.txt new file mode 100644 index 0000000000..e227219b07 --- /dev/null +++ b/k8s/screenshots/lab14/09-analysis-success.txt @@ -0,0 +1,2 @@ +NAME STATUS AGE +devops-info-service-7fcd7d4b69-2-1 Successful 64s diff --git a/k8s/screenshots/lab14/10-analysis-auto-rollback-rollout.txt b/k8s/screenshots/lab14/10-analysis-auto-rollback-rollout.txt new file mode 100644 index 0000000000..d30ec375aa --- /dev/null +++ b/k8s/screenshots/lab14/10-analysis-auto-rollback-rollout.txt @@ -0,0 +1,29 @@ +Name: devops-info-service +Namespace: default +Status: ✖ Degraded +Message: RolloutAborted: Rollout aborted update to revision 3: Step-based analysis phase error/failed: Metric "health-endpoint" assessed Failed due to failed (2) > failureLimit (1) +Strategy: Canary + Step: 0/5 + SetWeight: 0 + ActualWeight: 0 +Images: xrixis/devops-i-lobazov:0.1.0 (stable) +Replicas: + Desired: 3 + Current: 3 + Updated: 0 + Ready: 3 + Available: 3 + +NAME KIND STATUS AGE INFO +⟳ devops-info-service Rollout ✖ Degraded 3m6s +├──# revision:3 +│ ├──⧉ devops-info-service-78d69cbdd9 ReplicaSet • ScaledDown 90s canary +│ └──α devops-info-service-78d69cbdd9-3-1 AnalysisRun ✖ Failed 82s ✖ 2 +├──# revision:2 +│ ├──⧉ devops-info-service-7fcd7d4b69 ReplicaSet ✔ Healthy 2m54s stable +│ │ ├──□ devops-info-service-7fcd7d4b69-jjjhr Pod ✔ Running 2m53s ready:1/1 +│ │ ├──□ devops-info-service-7fcd7d4b69-5jg7j Pod ✔ Running 2m26s ready:1/1 +│ │ └──□ devops-info-service-7fcd7d4b69-jpkkk Pod ✔ Running 109s ready:1/1 +│ └──α devops-info-service-7fcd7d4b69-2-1 AnalysisRun ✔ Successful 2m46s ✔ 3 +└──# revision:1 + └──⧉ devops-info-service-774b6f7648 ReplicaSet • ScaledDown 3m6s diff --git a/k8s/screenshots/lab14/10-analysis-auto-rollback.png b/k8s/screenshots/lab14/10-analysis-auto-rollback.png new file mode 100644 index 0000000000..dc6bfd091b Binary files /dev/null and b/k8s/screenshots/lab14/10-analysis-auto-rollback.png differ diff --git a/k8s/screenshots/lab14/10-analysis-auto-rollback.txt b/k8s/screenshots/lab14/10-analysis-auto-rollback.txt new file mode 100644 index 0000000000..f984eea5da --- /dev/null +++ b/k8s/screenshots/lab14/10-analysis-auto-rollback.txt @@ -0,0 +1,3 @@ +NAME STATUS AGE +devops-info-service-78d69cbdd9-3-1 Failed 82s +devops-info-service-7fcd7d4b69-2-1 Successful 2m46s diff --git a/k8s/screenshots/lab15/01-resource-verification.txt b/k8s/screenshots/lab15/01-resource-verification.txt new file mode 100644 index 0000000000..3e003b01bc --- /dev/null +++ b/k8s/screenshots/lab15/01-resource-verification.txt @@ -0,0 +1,16 @@ +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/devops-info-service-0 1/1 Running 0 56s 10.244.0.66 minikube +pod/devops-info-service-1 1/1 Running 0 38s 10.244.0.67 minikube +pod/devops-info-service-2 1/1 Running 0 31s 10.244.0.68 minikube + +NAME READY AGE CONTAINERS IMAGES +statefulset.apps/devops-info-service 3/3 56s devops-info-service devops-info-service:lab15 + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/devops-info-service NodePort 10.104.144.138 80:30080/TCP 56s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service +service/devops-info-service-headless ClusterIP None 80/TCP 56s app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE +persistentvolumeclaim/data-volume-devops-info-service-0 Bound pvc-dc9fce54-1dee-44ee-a870-2df3971d60e2 100Mi RWO standard 56s Filesystem +persistentvolumeclaim/data-volume-devops-info-service-1 Bound pvc-b2844a86-48b8-402e-a25f-c45c785c9e65 100Mi RWO standard 38s Filesystem +persistentvolumeclaim/data-volume-devops-info-service-2 Bound pvc-ce6cfd5c-2a8e-4f76-a2a3-0850619d0cf0 100Mi RWO standard 31s Filesystem diff --git a/k8s/screenshots/lab15/02-dns-resolution.txt b/k8s/screenshots/lab15/02-dns-resolution.txt new file mode 100644 index 0000000000..45d377dea3 --- /dev/null +++ b/k8s/screenshots/lab15/02-dns-resolution.txt @@ -0,0 +1,14 @@ +Server: 10.96.0.10 +Address: 10.96.0.10:53 + +Name: devops-info-service-1.devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.67 + + +Server: 10.96.0.10 +Address: 10.96.0.10:53 + + +Name: devops-info-service-2.devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.68 + diff --git a/k8s/screenshots/lab15/03-per-pod-storage.txt b/k8s/screenshots/lab15/03-per-pod-storage.txt new file mode 100644 index 0000000000..df288e66a8 --- /dev/null +++ b/k8s/screenshots/lab15/03-per-pod-storage.txt @@ -0,0 +1,7 @@ +Per-pod /visits responses: +pod-0: +{"count":2,"file_path":"/data/visits"} +pod-1: +{"count":1,"file_path":"/data/visits"} +pod-2: +{"count":3,"file_path":"/data/visits"} diff --git a/k8s/screenshots/lab15/04-persistence-test.txt b/k8s/screenshots/lab15/04-persistence-test.txt new file mode 100644 index 0000000000..3b999ac0ce --- /dev/null +++ b/k8s/screenshots/lab15/04-persistence-test.txt @@ -0,0 +1,6 @@ +Before deleting devops-info-service-0: +2 +After StatefulSet recreated devops-info-service-0: +2 +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +devops-info-service-0 1/1 Running 0 7s 10.244.0.69 minikube diff --git a/k8s/screenshots/lab15/05-partitioned-update.txt b/k8s/screenshots/lab15/05-partitioned-update.txt new file mode 100644 index 0000000000..915e605e91 --- /dev/null +++ b/k8s/screenshots/lab15/05-partitioned-update.txt @@ -0,0 +1,11 @@ +Partitioned RollingUpdate, partition=2 +{"rollingUpdate":{"maxUnavailable":1,"partition":2},"type":"RollingUpdate"} + +NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH +devops-info-service-0 1/1 Running 0 56s devops-info-service-86d4556fc4 +devops-info-service-1 1/1 Running 0 2m39s devops-info-service-86d4556fc4 +devops-info-service-2 1/1 Running 0 29s devops-info-service-58668f5bcf +RELEASE_VERSION by pod: +devops-info-service-0=statefulset-v1 +devops-info-service-1=statefulset-v1 +devops-info-service-2=partition-v2 diff --git a/k8s/screenshots/lab15/06-ondelete-update.txt b/k8s/screenshots/lab15/06-ondelete-update.txt new file mode 100644 index 0000000000..75fe954b20 --- /dev/null +++ b/k8s/screenshots/lab15/06-ondelete-update.txt @@ -0,0 +1,29 @@ +OnDelete baseline after manual recreation +{"type":"OnDelete"} + +NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH +devops-info-service-0 1/1 Running 0 25s devops-info-service-779894fbb6 +devops-info-service-1 1/1 Running 0 17s devops-info-service-779894fbb6 +devops-info-service-2 1/1 Running 0 9s devops-info-service-779894fbb6 +RELEASE_VERSION baseline: +devops-info-service-0=ondelete-v1 +devops-info-service-1=ondelete-v1 +devops-info-service-2=ondelete-v1 +After template update to ondelete-v2, before manual pod deletion: +NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH +devops-info-service-0 1/1 Running 0 38s devops-info-service-779894fbb6 +devops-info-service-1 1/1 Running 0 30s devops-info-service-779894fbb6 +devops-info-service-2 1/1 Running 0 22s devops-info-service-779894fbb6 +RELEASE_VERSION before manual delete: +devops-info-service-0=ondelete-v1 +devops-info-service-1=ondelete-v1 +devops-info-service-2=ondelete-v1 +After deleting only devops-info-service-2: +NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH +devops-info-service-0 1/1 Running 0 49s devops-info-service-779894fbb6 +devops-info-service-1 1/1 Running 0 41s devops-info-service-779894fbb6 +devops-info-service-2 1/1 Running 0 9s devops-info-service-86b8cffb4f +RELEASE_VERSION after manual delete: +devops-info-service-0=ondelete-v1 +devops-info-service-1=ondelete-v1 +devops-info-service-2=ondelete-v2 diff --git a/k8s/screenshots/lab16/01-monitoring-stack-resources.txt b/k8s/screenshots/lab16/01-monitoring-stack-resources.txt new file mode 100644 index 0000000000..acc1e810e6 --- /dev/null +++ b/k8s/screenshots/lab16/01-monitoring-stack-resources.txt @@ -0,0 +1,17 @@ +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/alertmanager-monitoring-kube-prometheus-alertmanager-0 2/2 Running 0 5m5s 10.244.0.106 minikube +pod/monitoring-grafana-69db76f9b4-28992 3/3 Running 0 64m 10.244.0.90 minikube +pod/monitoring-kube-prometheus-operator-d5dbb45f9-blgjw 1/1 Running 0 64m 10.244.0.88 minikube +pod/monitoring-kube-state-metrics-75c9d8f7c7-l9b9w 1/1 Running 0 64m 10.244.0.89 minikube +pod/monitoring-prometheus-node-exporter-nsgtm 1/1 Running 0 64m 192.168.49.2 minikube +pod/prometheus-monitoring-kube-prometheus-prometheus-0 2/2 Running 0 64m 10.244.0.92 minikube + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/alertmanager-operated ClusterIP None 9093/TCP,9094/TCP,9094/UDP 64m app.kubernetes.io/name=alertmanager +service/monitoring-grafana ClusterIP 10.110.187.195 80/TCP 64m app.kubernetes.io/instance=monitoring,app.kubernetes.io/name=grafana +service/monitoring-kube-prometheus-alertmanager ClusterIP 10.108.18.145 9093/TCP,8080/TCP 64m alertmanager=monitoring-kube-prometheus-alertmanager,app.kubernetes.io/name=alertmanager +service/monitoring-kube-prometheus-operator ClusterIP 10.109.111.55 443/TCP 64m app=kube-prometheus-stack-operator,release=monitoring +service/monitoring-kube-prometheus-prometheus ClusterIP 10.111.11.239 9090/TCP,8080/TCP 64m app.kubernetes.io/name=prometheus,operator.prometheus.io/name=monitoring-kube-prometheus-prometheus +service/monitoring-kube-state-metrics ClusterIP 10.97.153.93 8080/TCP 64m app.kubernetes.io/instance=monitoring,app.kubernetes.io/name=kube-state-metrics +service/monitoring-prometheus-node-exporter ClusterIP 10.106.103.196 9100/TCP 64m app.kubernetes.io/instance=monitoring,app.kubernetes.io/name=prometheus-node-exporter +service/prometheus-operated ClusterIP None 9090/TCP 64m app.kubernetes.io/name=prometheus diff --git a/k8s/screenshots/lab16/02-application-monitoring-resources.txt b/k8s/screenshots/lab16/02-application-monitoring-resources.txt new file mode 100644 index 0000000000..52053c43cd --- /dev/null +++ b/k8s/screenshots/lab16/02-application-monitoring-resources.txt @@ -0,0 +1,22 @@ +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/devops-info-service-0 1/1 Running 0 60m 10.244.0.96 minikube +pod/devops-info-service-1 1/1 Running 0 60m 10.244.0.95 minikube +pod/devops-info-service-2 1/1 Running 0 60m 10.244.0.94 minikube + +NAME READY AGE CONTAINERS IMAGES +statefulset.apps/devops-info-service 3/3 5d23h devops-info-service devops-info-service:lab15 + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/devops-info-service NodePort 10.99.221.54 80:30080/TCP 5d23h app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service +service/devops-info-service-headless ClusterIP None 80/TCP 5d23h app.kubernetes.io/instance=devops-info-service,app.kubernetes.io/name=devops-info-service + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE +persistentvolumeclaim/data-volume-devops-info-service-0 Bound pvc-dc9fce54-1dee-44ee-a870-2df3971d60e2 100Mi RWO standard 5d23h Filesystem +persistentvolumeclaim/data-volume-devops-info-service-1 Bound pvc-b2844a86-48b8-402e-a25f-c45c785c9e65 100Mi RWO standard 5d23h Filesystem +persistentvolumeclaim/data-volume-devops-info-service-2 Bound pvc-ce6cfd5c-2a8e-4f76-a2a3-0850619d0cf0 100Mi RWO standard 5d23h Filesystem + +NAME AGE +servicemonitor.monitoring.coreos.com/devops-info-service 60m + +NAME AGE +prometheusrule.monitoring.coreos.com/devops-info-service 15m diff --git a/k8s/screenshots/lab16/03-init-containers.txt b/k8s/screenshots/lab16/03-init-containers.txt new file mode 100644 index 0000000000..af1eed6819 --- /dev/null +++ b/k8s/screenshots/lab16/03-init-containers.txt @@ -0,0 +1,23 @@ +init-download logs: +Connecting to example.com (172.66.147.243:80) +saving to '/work-dir/example.html' +example.html 100% |********************************| 528 0:00:00 ETA +'/work-dir/example.html' saved +wait-for-service logs: +Server: 10.96.0.10 +Address: 10.96.0.10:53 + + +Name: devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.94 +Name: devops-info-service-headless.default.svc.cluster.local +Address: 10.244.0.95 + +files visible from main container: +total 8 +-rw-r--r-- 1 appuser appgroup 528 May 13 19:07 example.html +-rw-r--r-- 1 appuser appgroup 29 May 13 19:07 status.txt +init status file: +downloaded by init container +example.html first line: +Example Domain

Example Domain

This domain is for use in documentation examples without needing permission. Avoid use in operations.

Learn more

diff --git a/k8s/screenshots/lab16/04-prometheus-targets-and-queries.txt b/k8s/screenshots/lab16/04-prometheus-targets-and-queries.txt new file mode 100644 index 0000000000..a6ebf4a641 --- /dev/null +++ b/k8s/screenshots/lab16/04-prometheus-targets-and-queries.txt @@ -0,0 +1,335 @@ +Prometheus target and query evidence + +Prometheus active target summary: +up: 16 + +Devops service targets: +job=devops-info-service service=devops-info-service pod=devops-info-service-2 url=http://10.244.0.94:5000/metrics health=up error= +job=devops-info-service service=devops-info-service pod=devops-info-service-1 url=http://10.244.0.95:5000/metrics health=up error= +job=devops-info-service service=devops-info-service pod=devops-info-service-0 url=http://10.244.0.96:5000/metrics health=up error= + +[statefulset_cpu_by_pod] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "pod": "devops-info-service-2" + }, + "value": [ + 1778702843.650, + "0.002683593411927615" + ] + }, + { + "metric": { + "pod": "devops-info-service-1" + }, + "value": [ + 1778702843.650, + "0.002685109976998273" + ] + }, + { + "metric": { + "pod": "devops-info-service-0" + }, + "value": [ + 1778702843.650, + "0.002661382580711191" + ] + } + ] + } +} + +[statefulset_memory_mib_by_pod] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "pod": "devops-info-service-2" + }, + "value": [ + 1778702843.668, + "42.0546875" + ] + }, + { + "metric": { + "pod": "devops-info-service-0" + }, + "value": [ + 1778702843.668, + "42.62890625" + ] + }, + { + "metric": { + "pod": "devops-info-service-1" + }, + "value": [ + 1778702843.668, + "41.94140625" + ] + } + ] + } +} + +[node_memory_used_percent] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "container": "node-exporter", + "endpoint": "http-metrics", + "instance": "192.168.49.2:9100", + "job": "node-exporter", + "namespace": "monitoring", + "pod": "monitoring-prometheus-node-exporter-nsgtm", + "service": "monitoring-prometheus-node-exporter" + }, + "value": [ + 1778702843.671, + "35.26930916941882" + ] + } + ] + } +} + +[node_memory_total_mib] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "container": "node-exporter", + "endpoint": "http-metrics", + "instance": "192.168.49.2:9100", + "job": "node-exporter", + "namespace": "monitoring", + "pod": "monitoring-prometheus-node-exporter-nsgtm", + "service": "monitoring-prometheus-node-exporter" + }, + "value": [ + 1778702843.674, + "7805.19921875" + ] + } + ] + } +} + +[node_memory_available_mib] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "container": "node-exporter", + "endpoint": "http-metrics", + "instance": "192.168.49.2:9100", + "job": "node-exporter", + "namespace": "monitoring", + "pod": "monitoring-prometheus-node-exporter-nsgtm", + "service": "monitoring-prometheus-node-exporter" + }, + "value": [ + 1778702843.678, + "5052.359375" + ] + } + ] + } +} + +[node_cpu_cores] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "machine_cpu_cores", + "boot_id": "3dfe658b-bd8e-4e97-82f9-5b2db6f573f1", + "endpoint": "https-metrics", + "instance": "192.168.49.2:10250", + "job": "kubelet", + "machine_id": "86e0bfeb3f77427722393c2969964edb", + "metrics_path": "/metrics/cadvisor", + "namespace": "kube-system", + "node": "minikube", + "service": "monitoring-kube-prometheus-kubelet", + "system_uuid": "86e0bfeb3f77427722393c2969964edb" + }, + "value": [ + 1778702843.681, + "12" + ] + } + ] + } +} + +[kubelet_running_pods] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "kubelet_running_pods", + "endpoint": "https-metrics", + "instance": "192.168.49.2:10250", + "job": "kubelet", + "metrics_path": "/metrics", + "namespace": "kube-system", + "node": "minikube", + "service": "monitoring-kube-prometheus-kubelet" + }, + "value": [ + 1778702843.684, + "18" + ] + } + ] + } +} + +[kubelet_running_containers] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "kubelet_running_containers", + "container_state": "created", + "endpoint": "https-metrics", + "instance": "192.168.49.2:10250", + "job": "kubelet", + "metrics_path": "/metrics", + "namespace": "kube-system", + "node": "minikube", + "service": "monitoring-kube-prometheus-kubelet" + }, + "value": [ + 1778702843.687, + "1" + ] + }, + { + "metric": { + "__name__": "kubelet_running_containers", + "container_state": "exited", + "endpoint": "https-metrics", + "instance": "192.168.49.2:10250", + "job": "kubelet", + "metrics_path": "/metrics", + "namespace": "kube-system", + "node": "minikube", + "service": "monitoring-kube-prometheus-kubelet" + }, + "value": [ + 1778702843.687, + "17" + ] + }, + { + "metric": { + "__name__": "kubelet_running_containers", + "container_state": "running", + "endpoint": "https-metrics", + "instance": "192.168.49.2:10250", + "job": "kubelet", + "metrics_path": "/metrics", + "namespace": "kube-system", + "node": "minikube", + "service": "monitoring-kube-prometheus-kubelet" + }, + "value": [ + 1778702843.687, + "22" + ] + } + ] + } +} + +[application_http_requests] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "pod": "devops-info-service-1" + }, + "value": [ + 1778702843.691, + "1104" + ] + }, + { + "metric": { + "pod": "devops-info-service-2" + }, + "value": [ + 1778702843.691, + "1108" + ] + }, + { + "metric": { + "pod": "devops-info-service-0" + }, + "value": [ + 1778702843.691, + "1079" + ] + } + ] + } +} + +[active_alerts] +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "ALERTS", + "alertname": "DevopsInfoServiceLabEvidence", + "alertstate": "firing", + "app": "devops-info-service", + "severity": "lab" + }, + "value": [ + 1778702843.695, + "1" + ] + } + ] + } +} + diff --git a/k8s/screenshots/lab16/05-alertmanager.txt b/k8s/screenshots/lab16/05-alertmanager.txt new file mode 100644 index 0000000000..08bde347c5 --- /dev/null +++ b/k8s/screenshots/lab16/05-alertmanager.txt @@ -0,0 +1,2 @@ +Alertmanager active alerts: +alertname=DevopsInfoServiceLabEvidence severity=lab receiver=lab16-receiver state=active diff --git a/k8s/screenshots/lab16/06-dashboard-answers.txt b/k8s/screenshots/lab16/06-dashboard-answers.txt new file mode 100644 index 0000000000..0f4b9c3743 --- /dev/null +++ b/k8s/screenshots/lab16/06-dashboard-answers.txt @@ -0,0 +1,32 @@ +Lab 16 dashboard answers collected from Grafana and Prometheus + +1. StatefulSet CPU cores by pod: +devops-info-service-0: 0.002661 cores +devops-info-service-1: 0.002685 cores +devops-info-service-2: 0.002684 cores +1. StatefulSet memory working set by pod: +devops-info-service-0: 42.63 MiB +devops-info-service-1: 41.94 MiB +devops-info-service-2: 42.05 MiB +2. Default namespace CPU ranking: +devops-info-service-1: 0.002685 cores +devops-info-service-2: 0.002684 cores +devops-info-service-0: 0.002661 cores +Most CPU: devops-info-service-1. Least CPU: devops-info-service-0. +3. Node metrics: +Memory total: 7805.2 MiB +Memory used: 2752.84 MiB (35.27%) +Memory available: 5052.36 MiB +CPU cores: 12 +4. Kubelet managed pods and containers: +Running pods: 18 +created: 1 +exited: 17 +running: 22 +5. Application HTTP traffic by StatefulSet pod: +devops-info-service-0: 1079 requests +devops-info-service-1: 1104 requests +devops-info-service-2: 1108 requests +6. Alerts: +Firing alerts: 1 +DevopsInfoServiceLabEvidence severity=lab state=firing diff --git a/k8s/screenshots/lab16/07-grafana-dashboard-search.json b/k8s/screenshots/lab16/07-grafana-dashboard-search.json new file mode 100644 index 0000000000..39a47fd86f --- /dev/null +++ b/k8s/screenshots/lab16/07-grafana-dashboard-search.json @@ -0,0 +1 @@ +[{"id":2,"uid":"09ec8aa1e996d6ffcd6817bbaff4db1b","title":"Kubernetes / API server","uri":"db/kubernetes-api-server","url":"/d/09ec8aa1e996d6ffcd6817bbaff4db1b/kubernetes-api-server","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":9,"uid":"b59e6c9f2fcbe2e16d77fc492374cc4f","title":"Kubernetes / Compute Resources / Multi-Cluster","uri":"db/kubernetes-compute-resources-multi-cluster","url":"/d/b59e6c9f2fcbe2e16d77fc492374cc4f/kubernetes-compute-resources-multi-cluster","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":8,"uid":"efa86fd1d0c121a26444b636a3f509a8","title":"Kubernetes / Compute Resources / Cluster","uri":"db/kubernetes-compute-resources-cluster","url":"/d/efa86fd1d0c121a26444b636a3f509a8/kubernetes-compute-resources-cluster","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":10,"uid":"85a562078cdf77779eaa1add43ccec1e","title":"Kubernetes / Compute Resources / Namespace (Pods)","uri":"db/kubernetes-compute-resources-namespace-pods","url":"/d/85a562078cdf77779eaa1add43ccec1e/kubernetes-compute-resources-namespace-pods","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":14,"uid":"a87fb0d919ec0ea5f6543124e16c42a5","title":"Kubernetes / Compute Resources / Namespace (Workloads)","uri":"db/kubernetes-compute-resources-namespace-workloads","url":"/d/a87fb0d919ec0ea5f6543124e16c42a5/kubernetes-compute-resources-namespace-workloads","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":11,"uid":"200ac8fdbfbb74b39aff88118e4d1c2c","title":"Kubernetes / Compute Resources / Node (Pods)","uri":"db/kubernetes-compute-resources-node-pods","url":"/d/200ac8fdbfbb74b39aff88118e4d1c2c/kubernetes-compute-resources-node-pods","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":12,"uid":"6581e46e4e5c7ba40a07646395ef7b23","title":"Kubernetes / Compute Resources / Pod","uri":"db/kubernetes-compute-resources-pod","url":"/d/6581e46e4e5c7ba40a07646395ef7b23/kubernetes-compute-resources-pod","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":13,"uid":"a164a7f0339f99e89cea5cb47e9be617","title":"Kubernetes / Compute Resources / Workload","uri":"db/kubernetes-compute-resources-workload","url":"/d/a164a7f0339f99e89cea5cb47e9be617/kubernetes-compute-resources-workload","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":4,"uid":"72e0e05bef5099e5f049b05fdc429ed4","title":"Kubernetes / Controller Manager","uri":"db/kubernetes-controller-manager","url":"/d/72e0e05bef5099e5f049b05fdc429ed4/kubernetes-controller-manager","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":15,"uid":"3138fa155d5915769fbded898ac09fd9","title":"Kubernetes / Kubelet","uri":"db/kubernetes-kubelet","url":"/d/3138fa155d5915769fbded898ac09fd9/kubernetes-kubelet","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":3,"uid":"ff635a025bcfea7bc3dd4f508990a3e9","title":"Kubernetes / Networking / Cluster","uri":"db/kubernetes-networking-cluster","url":"/d/ff635a025bcfea7bc3dd4f508990a3e9/kubernetes-networking-cluster","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":16,"uid":"8b7a8b326d7a6f1f04244066368c67af","title":"Kubernetes / Networking / Namespace (Pods)","uri":"db/kubernetes-networking-namespace-pods","url":"/d/8b7a8b326d7a6f1f04244066368c67af/kubernetes-networking-namespace-pods","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":17,"uid":"bbb2a765a623ae38130206c7d94a160f","title":"Kubernetes / Networking / Namespace (Workload)","uri":"db/kubernetes-networking-namespace-workload","url":"/d/bbb2a765a623ae38130206c7d94a160f/kubernetes-networking-namespace-workload","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":24,"uid":"7a18067ce943a40ae25454675c19ff5c","title":"Kubernetes / Networking / Pod","uri":"db/kubernetes-networking-pod","url":"/d/7a18067ce943a40ae25454675c19ff5c/kubernetes-networking-pod","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":28,"uid":"728bf77cc1166d2f3133bf25846876cc","title":"Kubernetes / Networking / Workload","uri":"db/kubernetes-networking-workload","url":"/d/728bf77cc1166d2f3133bf25846876cc/kubernetes-networking-workload","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":23,"uid":"919b92a8e8041bd567af9edab12c840c","title":"Kubernetes / Persistent Volumes","uri":"db/kubernetes-persistent-volumes","url":"/d/919b92a8e8041bd567af9edab12c840c/kubernetes-persistent-volumes","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":26,"uid":"632e265de029684c40b21cb76bca4f94","title":"Kubernetes / Proxy","uri":"db/kubernetes-proxy","url":"/d/632e265de029684c40b21cb76bca4f94/kubernetes-proxy","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":27,"uid":"2e6b6a3b4bddf1427b3a55aa1311c656","title":"Kubernetes / Scheduler","uri":"db/kubernetes-scheduler","url":"/d/2e6b6a3b4bddf1427b3a55aa1311c656/kubernetes-scheduler","slug":"","type":"dash-db","tags":["kubernetes-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false}] diff --git a/k8s/screenshots/lab16/07-grafana-node-dashboard-search.json b/k8s/screenshots/lab16/07-grafana-node-dashboard-search.json new file mode 100644 index 0000000000..9be6f137c4 --- /dev/null +++ b/k8s/screenshots/lab16/07-grafana-node-dashboard-search.json @@ -0,0 +1 @@ +[{"id":21,"uid":"7e0a61e486f727d763fb1d86fdd629c2","title":"Node Exporter / AIX","uri":"db/node-exporter-aix","url":"/d/7e0a61e486f727d763fb1d86fdd629c2/node-exporter-aix","slug":"","type":"dash-db","tags":["node-exporter-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":22,"uid":"629701ea43bf69291922ea45f4a87d37","title":"Node Exporter / MacOS","uri":"db/node-exporter-macos","url":"/d/629701ea43bf69291922ea45f4a87d37/node-exporter-macos","slug":"","type":"dash-db","tags":["node-exporter-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":20,"uid":"7d57716318ee0dddbac5a7f451fb7753","title":"Node Exporter / Nodes","uri":"db/node-exporter-nodes","url":"/d/7d57716318ee0dddbac5a7f451fb7753/node-exporter-nodes","slug":"","type":"dash-db","tags":["node-exporter-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":18,"uid":"3e97d1d02672cdd0861f4c97c64f89b2","title":"Node Exporter / USE Method / Cluster","uri":"db/node-exporter-use-method-cluster","url":"/d/3e97d1d02672cdd0861f4c97c64f89b2/node-exporter-use-method-cluster","slug":"","type":"dash-db","tags":["node-exporter-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false},{"id":19,"uid":"fac67cfbe174d3ef53eb473d73d9212f","title":"Node Exporter / USE Method / Node","uri":"db/node-exporter-use-method-node","url":"/d/fac67cfbe174d3ef53eb473d73d9212f/node-exporter-use-method-node","slug":"","type":"dash-db","tags":["node-exporter-mixin"],"isStarred":false,"sortMeta":0,"isDeleted":false}] diff --git a/k8s/screenshots/lab16/07-lab16-grafana-dashboard.json b/k8s/screenshots/lab16/07-lab16-grafana-dashboard.json new file mode 100644 index 0000000000..66254ca160 --- /dev/null +++ b/k8s/screenshots/lab16/07-lab16-grafana-dashboard.json @@ -0,0 +1,87 @@ +{ + "dashboard": { + "id": null, + "uid": "lab16-devops-monitoring", + "title": "Lab 16 DevOps Monitoring Evidence", + "tags": ["lab16", "devops"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "10s", + "time": { "from": "now-30m", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "StatefulSet CPU by pod", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [{ "expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{namespace=\"default\",pod=~\"devops-info-service-[0-9]+\"}[5m]))", "legendFormat": "{{pod}}", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "cores", "decimals": 4 }, "overrides": [] }, + "options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } } + }, + { + "id": 2, + "title": "StatefulSet memory working set by pod", + "type": "bargauge", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [{ "expr": "sum by (pod) (container_memory_working_set_bytes{namespace=\"default\",pod=~\"devops-info-service-[0-9]+\"}) / 1024 / 1024", "legendFormat": "{{pod}}", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "mbytes", "decimals": 2 }, "overrides": [] }, + "options": { "displayMode": "gradient", "orientation": "horizontal", "showUnfilled": true } + }, + { + "id": 3, + "title": "Node memory used percent", + "type": "stat", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 7, "w": 6, "x": 0, "y": 8 }, + "targets": [{ "expr": "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)", "legendFormat": "memory used", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "percent", "decimals": 2 }, "overrides": [] }, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" } + }, + { + "id": 4, + "title": "Node CPU cores", + "type": "stat", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 7, "w": 6, "x": 6, "y": 8 }, + "targets": [{ "expr": "machine_cpu_cores", "legendFormat": "cores", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "none", "decimals": 0 }, "overrides": [] }, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" } + }, + { + "id": 5, + "title": "Kubelet containers by state", + "type": "bargauge", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 7, "w": 12, "x": 12, "y": 8 }, + "targets": [{ "expr": "kubelet_running_containers", "legendFormat": "{{container_state}}", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "none", "decimals": 0 }, "overrides": [] }, + "options": { "displayMode": "gradient", "orientation": "horizontal", "showUnfilled": true } + }, + { + "id": 6, + "title": "Application HTTP traffic by pod", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "targets": [{ "expr": "sum by (pod) (app_http_requests_total{namespace=\"default\"})", "legendFormat": "{{pod}}", "refId": "A" }], + "fieldConfig": { "defaults": { "unit": "req", "decimals": 0 }, "overrides": [] }, + "options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } } + }, + { + "id": 7, + "title": "Firing alerts", + "type": "table", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "targets": [{ "expr": "ALERTS{alertstate=\"firing\"}", "format": "table", "instant": true, "refId": "A" }], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { "showHeader": true } + } + ] + }, + "folderUid": "", + "overwrite": true +} diff --git a/k8s/screenshots/lab16/08-grafana-namespace-pods.png b/k8s/screenshots/lab16/08-grafana-namespace-pods.png new file mode 100644 index 0000000000..de1067b4f7 Binary files /dev/null and b/k8s/screenshots/lab16/08-grafana-namespace-pods.png differ diff --git a/k8s/screenshots/lab16/08-lab16-grafana-dashboard.png b/k8s/screenshots/lab16/08-lab16-grafana-dashboard.png new file mode 100644 index 0000000000..0f26439027 Binary files /dev/null and b/k8s/screenshots/lab16/08-lab16-grafana-dashboard.png differ diff --git a/k8s/screenshots/lab16/09-grafana-node-exporter.png b/k8s/screenshots/lab16/09-grafana-node-exporter.png new file mode 100644 index 0000000000..df49bccc79 Binary files /dev/null and b/k8s/screenshots/lab16/09-grafana-node-exporter.png differ diff --git a/k8s/screenshots/lab16/09-prometheus-targets-clean.png b/k8s/screenshots/lab16/09-prometheus-targets-clean.png new file mode 100644 index 0000000000..fb86f74fdf Binary files /dev/null and b/k8s/screenshots/lab16/09-prometheus-targets-clean.png differ diff --git a/k8s/screenshots/lab16/10-grafana-kubelet.png b/k8s/screenshots/lab16/10-grafana-kubelet.png new file mode 100644 index 0000000000..7b75ec1b3b Binary files /dev/null and b/k8s/screenshots/lab16/10-grafana-kubelet.png differ diff --git a/k8s/screenshots/lab16/10-prometheus-app-metrics.png b/k8s/screenshots/lab16/10-prometheus-app-metrics.png new file mode 100644 index 0000000000..bb90daedfc Binary files /dev/null and b/k8s/screenshots/lab16/10-prometheus-app-metrics.png differ diff --git a/k8s/screenshots/lab16/11-alertmanager-lab-alert.png b/k8s/screenshots/lab16/11-alertmanager-lab-alert.png new file mode 100644 index 0000000000..5a70ea9463 Binary files /dev/null and b/k8s/screenshots/lab16/11-alertmanager-lab-alert.png differ diff --git a/k8s/screenshots/lab16/11-grafana-networking-namespace.png b/k8s/screenshots/lab16/11-grafana-networking-namespace.png new file mode 100644 index 0000000000..9902f530af Binary files /dev/null and b/k8s/screenshots/lab16/11-grafana-networking-namespace.png differ diff --git a/k8s/screenshots/lab16/12-prometheus-targets.png b/k8s/screenshots/lab16/12-prometheus-targets.png new file mode 100644 index 0000000000..80d924af89 Binary files /dev/null and b/k8s/screenshots/lab16/12-prometheus-targets.png differ diff --git a/k8s/screenshots/lab16/13-prometheus-app-metrics.png b/k8s/screenshots/lab16/13-prometheus-app-metrics.png new file mode 100644 index 0000000000..6c61ec6586 Binary files /dev/null and b/k8s/screenshots/lab16/13-prometheus-app-metrics.png differ diff --git a/k8s/screenshots/lab16/14-alertmanager-alerts.png b/k8s/screenshots/lab16/14-alertmanager-alerts.png new file mode 100644 index 0000000000..e1f4628622 Binary files /dev/null and b/k8s/screenshots/lab16/14-alertmanager-alerts.png differ diff --git a/k8s/screenshots/lab16/15-prometheus-targets-clean.txt b/k8s/screenshots/lab16/15-prometheus-targets-clean.txt new file mode 100644 index 0000000000..ce6cb0ff40 --- /dev/null +++ b/k8s/screenshots/lab16/15-prometheus-targets-clean.txt @@ -0,0 +1,7 @@ +Prometheus active target summary: +up: 16 + +Devops service targets: +job=devops-info-service service=devops-info-service pod=devops-info-service-2 url=http://10.244.0.94:5000/metrics health=up error= +job=devops-info-service service=devops-info-service pod=devops-info-service-1 url=http://10.244.0.95:5000/metrics health=up error= +job=devops-info-service service=devops-info-service pod=devops-info-service-0 url=http://10.244.0.96:5000/metrics health=up error= diff --git a/solution/app_python/.dockerignore b/solution/app_python/.dockerignore new file mode 100644 index 0000000000..1bb539cd8f --- /dev/null +++ b/solution/app_python/.dockerignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +**/*.pyc +venv/ +.venv/ +*.log + +# Tests +tests/ +.coverage +.coverage.* +coverage.xml +htmlcov/ +.pytest_cache/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Secrets +.env + +# Git +.git/ +.github/ +.gitignore + +# Docs +*.md +docs/ \ No newline at end of file diff --git a/solution/app_python/.flake8 b/solution/app_python/.flake8 new file mode 100644 index 0000000000..3e8cbb8676 --- /dev/null +++ b/solution/app_python/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 100 +max-complexity = 10 +exclude = .venv \ No newline at end of file diff --git a/solution/app_python/.gitignore b/solution/app_python/.gitignore new file mode 100644 index 0000000000..c0535f4175 --- /dev/null +++ b/solution/app_python/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +**/*.pyc +venv/ +.venv/ +*.log +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +coverage.xml + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Secrets +.env + +# Local persistence +data/ diff --git a/solution/app_python/Dockerfile b/solution/app_python/Dockerfile new file mode 100644 index 0000000000..9af2a96e50 --- /dev/null +++ b/solution/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.14.2-alpine3.23 +# OPTIONAL: PORT {5000}, HOST {0.0.0.0}, DEBUG {false} + +LABEL authors="xzsay" +RUN apk add --no-cache shadow \ + && groupadd -g 122 -r appgroup \ + && useradd -u 122 -r -g appgroup -m appuser +WORKDIR /app + +RUN pip install --upgrade pip>=26.0 +COPY requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +COPY . . +RUN mkdir -p /data /config \ + && chown -R appuser:appgroup /app /data /config +USER 122:122 +ENTRYPOINT ["python", "app.py"] +EXPOSE 5000 diff --git a/solution/app_python/README.md b/solution/app_python/README.md new file mode 100644 index 0000000000..307468a5bc --- /dev/null +++ b/solution/app_python/README.md @@ -0,0 +1,147 @@ +# DevOps Info Service (Python / FastAPI) + +[![Python CI](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/python-ci.yml) +[![Python CD](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/python-cd.yml/badge.svg)](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/python-cd.yml) + +## Overview + +DevOps Info Service is a FastAPI application used across the course labs. For Lab 12 it was extended with: + +- a persisted visits counter stored in a file +- a `GET /visits` endpoint +- optional file-based configuration loading from `CONFIG_PATH` +- a Docker Compose workflow for local persistence testing + +## Tech Stack + +- Python 3.14 +- FastAPI 0.128.0 +- Uvicorn 0.40.0 +- Prometheus client 0.23.1 + +## Project Structure + +```text +solution/app_python/ +├── app.py +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +├── requirements.dev.txt +├── tests/ +└── docs/ +``` + +## Run On Host + +```bash +cd solution/app_python +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +python app.py +``` + +Environment variable examples: + +```bash +$env:PORT="8080"; python app.py +$env:DEBUG="true"; python app.py +$env:VISITS_FILE="C:\temp\visits"; python app.py +$env:CONFIG_PATH="C:\temp\config.json"; python app.py +``` + +## Run Tests + +```bash +cd solution/app_python +pip install -r requirements.dev.txt +pytest tests/ -v --cov=. --cov-report=term-missing +``` + +## Run With Docker + +Build and run directly: + +```bash +cd solution/app_python +docker build -t devops-info-service:lab12 . +docker run --rm -p 5000:5000 ` + -e VISITS_FILE=/data/visits ` + -v ${PWD}/data:/data ` + devops-info-service:lab12 +``` + +Run with Docker Compose: + +```bash +cd solution/app_python +docker compose up --build -d +curl http://localhost:5000/ +curl http://localhost:5000/visits +Get-Content .\data\visits +docker compose restart +curl http://localhost:5000/visits +docker compose down +``` + +The Compose setup mounts `./data` into the container at `/data`, so the visits counter survives container restarts. + +## API Endpoints + +### `GET /` + +Returns service metadata, system information, runtime data, loaded configuration, and the incremented visits counter. + +Important behavior: + +- every request to `/` increments the persisted counter +- the counter is written to the file defined by `VISITS_FILE` +- the response includes the active config file path and visits file path + +### `GET /visits` + +Returns the current persisted counter without incrementing it. + +Example response: + +```json +{ + "count": 5, + "file_path": "/data/visits" +} +``` + +### `GET /health` + +Returns service health and uptime. + +### `GET /metrics` + +Returns Prometheus metrics for the service. + +## Configuration + +Supported environment variables: + +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `5000` | HTTP port | +| `DEBUG` | `false` | Enables debug logging | +| `RELEASE_VERSION` | `v1` | Release label exposed by the app | +| `VISITS_FILE` | `/data/visits` | Path to the persisted visits counter | +| `CONFIG_PATH` | `/config/config.json` | Path to JSON configuration file | + +If `CONFIG_PATH` does not exist, the app falls back to built-in defaults and keeps running. + +## Persistence Notes + +- the visits counter is initialized to `0` if the file does not exist +- writes are serialized with an in-process lock +- the file is updated using an atomic replace operation to reduce corruption risk +- this storage model is suitable for a single-replica lab deployment backed by a mounted volume + +## Logging + +The app writes structured JSON logs to stdout. This keeps it compatible with the monitoring labs and makes request lifecycle events easy to inspect in Docker or Kubernetes. diff --git a/solution/app_python/__init__.py b/solution/app_python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/solution/app_python/app.py b/solution/app_python/app.py new file mode 100644 index 0000000000..5c5a92e8b5 --- /dev/null +++ b/solution/app_python/app.py @@ -0,0 +1,434 @@ +import json +import logging +import os +import platform +import tempfile +import time +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, Response +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse +from uvicorn import run + +START_TIME = datetime.now() +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" +RELEASE_VERSION = os.getenv("RELEASE_VERSION", "v1") + + +class JSONFormatter(logging.Formatter): + """Render application logs as JSON for Loki/Grafana ingestion.""" + + _reserved_fields = { + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", + "taskName", + } + + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + for key, value in record.__dict__.items(): + if key not in self._reserved_fields: + payload[key] = value + + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + + return json.dumps(payload, ensure_ascii=True) + + +def setup_logging() -> logging.Logger: + root_logger = logging.getLogger() + root_logger.handlers.clear() + handler = logging.StreamHandler() + handler.setFormatter(JSONFormatter()) + root_logger.addHandler(handler) + root_logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) + return logging.getLogger(__name__) + + +logger = setup_logging() + +HTTP_REQUESTS_TOTAL = Counter( + "app_http_requests_total", + "Total number of HTTP requests handled by the application.", + ["method", "endpoint", "status_code"], +) +HTTP_REQUEST_DURATION_SECONDS = Histogram( + "app_http_request_duration_seconds", + "HTTP request processing duration in seconds.", + ["method", "endpoint"], +) +HTTP_ACTIVE_REQUESTS = Gauge( + "app_http_active_requests", + "Current number of in-flight HTTP requests.", +) +ROOT_REQUESTS_TOTAL = Counter( + "app_root_requests_total", + "Total number of calls to the root endpoint.", +) +SYSTEM_INFO_DURATION_SECONDS = Histogram( + "app_system_info_duration_seconds", + "System information collection duration in seconds.", +) +UPTIME_SECONDS = Gauge( + "app_uptime_seconds", + "Application uptime in seconds.", +) + + +class VisitStore: + """Store visits in a file with a process-local lock and atomic writes.""" + + def __init__(self, file_path: Path): + self.file_path = Path(file_path) + self._lock = Lock() + + def initialize(self) -> None: + with self._lock: + self.file_path.parent.mkdir(parents=True, exist_ok=True) + if not self.file_path.exists(): + self._write_unlocked(0) + + def current(self) -> int: + with self._lock: + return self._read_unlocked() + + def increment(self) -> int: + with self._lock: + value = self._read_unlocked() + 1 + self._write_unlocked(value) + return value + + def _read_unlocked(self) -> int: + if not self.file_path.exists(): + return 0 + + raw_value = self.file_path.read_text(encoding="utf-8").strip() + if not raw_value: + return 0 + + try: + return int(raw_value) + except ValueError: + logger.warning( + "Invalid visits file content, resetting counter", + extra={"event": "visits_file_invalid", "visits_file": str(self.file_path)}, + ) + return 0 + + def _write_unlocked(self, value: int) -> None: + self.file_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", + delete=False, + dir=self.file_path.parent, + encoding="utf-8", + ) as temp_file: + temp_file.write(str(value)) + temp_path = Path(temp_file.name) + + os.replace(temp_path, self.file_path) + + +def load_app_config(config_path: Path, visits_file: Path) -> dict: + default_config = { + "applicationName": "devops-info-service", + "environment": "local", + "featureFlags": { + "visitsEndpoint": True, + "configFromConfigMap": False, + }, + "settings": { + "visitsFile": str(visits_file), + }, + } + + if not config_path.exists(): + return default_config + + try: + loaded_config = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning( + "Unable to load configuration file, using defaults", + extra={"event": "config_load_failed", "config_path": str(config_path)}, + ) + return default_config + + return { + **default_config, + **loaded_config, + } + + +@asynccontextmanager +async def lifespan(app: FastAPI): + visits_file = Path(os.getenv("VISITS_FILE", "/data/visits")) + config_path = Path(os.getenv("CONFIG_PATH", "/config/config.json")) + + app.state.visits_file = visits_file + app.state.config_path = config_path + app.state.visit_store = VisitStore(visits_file) + app.state.visit_store.initialize() + app.state.app_config = load_app_config(config_path, visits_file) + + logger.info( + "Application startup", + extra={ + "event": "startup", + "host": HOST, + "port": PORT, + "debug": DEBUG, + "release_version": RELEASE_VERSION, + "service": "devops-info-service", + "visits_file": str(visits_file), + "config_path": str(config_path), + }, + ) + yield + logger.info("Application shutdown", extra={"event": "shutdown"}) + + +app = FastAPI(lifespan=lifespan) + + +def get_uptime() -> dict: + delta = datetime.now() - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + "seconds": seconds, + "human": f"{hours} hours, {minutes} minutes", + } + + +UPTIME_SECONDS.set_function(lambda: get_uptime()["seconds"]) + + +def get_endpoint_label(request: Request) -> str: + route = request.scope.get("route") + if route and getattr(route, "path", None): + return route.path + return request.url.path + + +def collect_system_info() -> dict: + with SYSTEM_INFO_DURATION_SECONDS.time(): + return { + "hostname": platform.node(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + client_ip = request.client.host if request.client else "unknown" + start_time = time.perf_counter() + track_metrics = request.url.path != "/metrics" + extra = { + "event": "http_request", + "method": request.method, + "path": request.url.path, + "client_ip": client_ip, + "user_agent": request.headers.get("user-agent", ""), + } + + logger.info("HTTP request started", extra=extra) + if track_metrics: + HTTP_ACTIVE_REQUESTS.inc() + + try: + response = await call_next(request) + return response + except Exception: + logger.exception("HTTP request failed", extra=extra) + if track_metrics: + endpoint = get_endpoint_label(request) + HTTP_REQUESTS_TOTAL.labels( + method=request.method, + endpoint=endpoint, + status_code="500", + ).inc() + HTTP_REQUEST_DURATION_SECONDS.labels( + method=request.method, + endpoint=endpoint, + ).observe(time.perf_counter() - start_time) + raise + finally: + if track_metrics and "response" in locals(): + endpoint = get_endpoint_label(request) + HTTP_REQUESTS_TOTAL.labels( + method=request.method, + endpoint=endpoint, + status_code=str(response.status_code), + ).inc() + HTTP_REQUEST_DURATION_SECONDS.labels( + method=request.method, + endpoint=endpoint, + ).observe(time.perf_counter() - start_time) + logger.info( + "HTTP request completed", + extra={**extra, "status_code": response.status_code}, + ) + elif "response" in locals(): + logger.info( + "HTTP request completed", + extra={**extra, "status_code": response.status_code}, + ) + + if track_metrics: + HTTP_ACTIVE_REQUESTS.dec() + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException) -> HTMLResponse: + """Default page for error display.""" + + logger.warning( + "HTTP exception handled", + extra={ + "event": "http_exception", + "method": request.method, + "path": request.url.path, + "status_code": exc.status_code, + "client_ip": request.client.host if request.client else "unknown", + "error": exc.detail, + }, + ) + return HTMLResponse( + content=f"

Error {exc.status_code}

{exc.detail}

", + status_code=exc.status_code, + ) + + +@app.get("/", description="System and service info about the server") +async def root(request: Request) -> JSONResponse: + """System and service info about the server.""" + + ROOT_REQUESTS_TOTAL.inc() + visits_count = request.app.state.visit_store.increment() + + return JSONResponse( + status_code=200, + content={ + "service": { + "name": "devops-info-service", + "version": "1.1.0", + "description": "DevOps course info service", + "framework": "FastAPI", + }, + "system": collect_system_info(), + "runtime": { + "uptime_seconds": get_uptime()["seconds"], + "uptime_human": get_uptime()["human"], + "current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "timezone": datetime.now().strftime("%Z"), + }, + "configuration": { + "file_path": str(request.app.state.config_path), + "file_exists": request.app.state.config_path.exists(), + "content": request.app.state.app_config, + "environment_variables": { + "HOST": HOST, + "PORT": str(PORT), + "DEBUG": str(DEBUG).lower(), + "RELEASE_VERSION": RELEASE_VERSION, + }, + }, + "visits": { + "count": visits_count, + "file_path": str(request.app.state.visits_file), + }, + "request": { + "client_ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent"), + "method": request.method, + "path": request.url.path, + }, + "endpoints": [ + { + "path": route.path, + "method": method, + "description": route.endpoint.__doc__ or "", + } + for route in request.app.routes + for method in route.methods + ], + }, + ) + + +@app.get("/visits", description="Current persisted visits counter") +async def visits(request: Request) -> JSONResponse: + """Current persisted visits counter.""" + + return JSONResponse( + status_code=200, + content={ + "count": request.app.state.visit_store.current(), + "file_path": str(request.app.state.visits_file), + }, + ) + + +@app.get("/health", description="Service health chek") +async def health(request: Request) -> JSONResponse: + """Service health-chek.""" + + return JSONResponse( + status_code=200, + content={ + "status": "healthy", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "uptime_seconds": get_uptime()["seconds"], + }, + ) + + +@app.get("/metrics", include_in_schema=False) +async def metrics() -> Response: + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) + + +if __name__ == "__main__": + run(app, port=PORT, host=HOST) diff --git a/solution/app_python/docker-compose.yml b/solution/app_python/docker-compose.yml new file mode 100644 index 0000000000..00f68effad --- /dev/null +++ b/solution/app_python/docker-compose.yml @@ -0,0 +1,16 @@ +services: + app-python: + build: + context: . + image: devops-info-service:lab12 + container_name: devops-info-service-lab12 + environment: + HOST: 0.0.0.0 + PORT: "5000" + DEBUG: "false" + RELEASE_VERSION: "lab12" + VISITS_FILE: /data/visits + ports: + - "5000:5000" + volumes: + - ./data:/data diff --git a/solution/app_python/docs/LAB01.md b/solution/app_python/docs/LAB01.md new file mode 100644 index 0000000000..ae33fdb9bb --- /dev/null +++ b/solution/app_python/docs/LAB01.md @@ -0,0 +1,165 @@ +# Lab 01 — DevOps Info Service Implementation +## 1. Framework Selection + +For this lab, I chose **FastAPI** as the web framework. + +**Reasons:** +- Async support for high performance +- Automatic OpenAPI documentation +- Modern syntax and type hints support +- Easy integration with Python 3.11+ + +**Comparison with alternatives:** + +| Framework | Pros | Cons | +|-----------|----------------------------------------------------------------------------------------|----------------------------------------------------------------| +| Flask | Lightweight, simple to learn | Synchronous by default, no auto docs | +| FastAPI | Async, auto-docs, type safety. Faster than alternatives. Easear security configuartion | Relatively new, not complete dependancy-injection solution | +| Django | Full-featured, ORM included | Synchronous by default. Overkill for simple API. Heavier setup | + +## 2. Best Practices Applied + +1. **Clean Code Organization** + - Functions are modular (`get_uptime()`, `root()`, `health()`) + - Proper imports grouped by standard library, third-party, local + - Docstrings added for clarity + - PEP 8 style followed + +2. **Logging** + ```python + logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + logger.debug(f'Request: {request.method} {request.url.path}') + ``` + * Captures requests and application start/shutdown +3. **Error Handling** + ```python + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException)-> HTMLResponse: + """Default page for error display""" + logger.debug(f"Error occurs {exc.detail}. Answer with code {exc.status_code}") + return HTMLResponse( + content=f"

Error {exc.status_code}

{exc.detail}

", + status_code=exc.status_code + ) + ``` + * FastAPI handles HTTPException natively; custom error handling can be added for 404/500 +4. **Configuration via Environment Variables** + ```python + HOST = os.getenv('HOST', '0.0.0.0') + PORT = int(os.getenv('PORT', 5000)) + DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + ``` + * Allows easy deployment customization + +## 3. API Documentation + +### GET `/` + +Returns full service and system info. + +**Example Response:** + +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "my-laptop", + "platform": "Linux", + "platform_version": "6.12.67-1-lts", + "architecture": "x86_64", + "cpu_count": 12, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hour, 0 minutes", + "current_time": "2026-01-07 14:30:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +**Testing Commands:** + +```bash +curl http://127.0.0.1:5000/ +``` + +### GET `/health` + +Simple health check endpoint. + +**Example Response:** + +```json +{ + "status": "healthy", + "timestamp": "2026-01-07 14:30:00", + "uptime_seconds": 3600 +} +``` + +**Testing Command:** + +```bash +curl http://127.0.0.1:5000/health +``` + +## 4. Testing Evidence +### Screenshots +1. Root endpoint + ![Main Endpoint](screenshots/answer.png) + ![Main Endpoint Continuation](screenshots/my-endpoints-curl.png) +2. Health-check + ![Health-check](screenshots/health.png) +### Terminal output +``` +2026-01-28 23:10:54,675 - asyncio - DEBUG - Using selector: EpollSelector +INFO: Started server process [8144] +INFO: Waiting for application startup. +2026-01-28 23:10:54,701 - __main__ - INFO - Starting up... +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit) +2026-01-28 23:10:57,616 - __main__ - DEBUG - Request: GET / +INFO: 127.0.0.1:52856 - "GET / HTTP/1.1" 200 OK +2026-01-28 23:13:18,963 - __main__ - DEBUG - Request: GET /health +INFO: 127.0.0.1:33174 - "GET /health HTTP/1.1" 200 OK +``` +![terminal-out](screenshots/terminal-output.png) +## 5. Challenges & Solutions + +* **Challenge:** Correctly capturing client IP and user-agent in FastAPI + **Solution:** Used `request.client.host` and `request.headers.get('user-agent')` + +* **Challenge:** Uptime calculation with human-readable format + **Solution:** Created `get_uptime()` utility returning both seconds and formatted string + +* **Challenge:** Logging requests without blocking main process + **Solution:** Configured async logging and used `logger.debug()` inside endpoints + +## 6. GitHub Community + +* **Starred course and simple-container-com/api repositories** to support open-source visibility and bookmark useful tools. +* **Followed professor and TAs** to stay updated on course content and contributions. +* **Followed classmates** to facilitate collaboration and track peer progress. + +--- + diff --git a/solution/app_python/docs/LAB02.md b/solution/app_python/docs/LAB02.md new file mode 100644 index 0000000000..238dbec5f8 --- /dev/null +++ b/solution/app_python/docs/LAB02.md @@ -0,0 +1,395 @@ +# Lab 02 — Dockerized Python Application + +## 1. Docker Best Practices Applied + +### 1. Non-root User + +```dockerfile +RUN apk add --no-cache shadow \ + && groupadd -r appgroup \ + && useradd -r -g appgroup -m appuser +USER appuser +``` + +**Why it matters:** +Running containers as root increases the blast radius of a potential exploit. +By creating and switching to a dedicated non-root user, the container follows the **principle of least privilege**, reducing security risks if the application is compromised. + +--- + +### 2. Minimal Base Image (Alpine) + +```dockerfile +FROM python:3.14.2-alpine3.23 +``` + +**Why it matters:** +Alpine images are significantly smaller than Debian-based images, which: + +* Reduces attack surface +* Decreases image size +* Improves pull and startup times + +--- + +### 3. Layer Caching Optimization + +```dockerfile +COPY requirements.txt ./requirements.txt +RUN pip install -r requirements.txt +``` + +**Why it matters:** +Dependencies change less frequently than application code. +By copying `requirements.txt` before the source code, Docker can reuse cached layers and avoid reinstalling dependencies on every build, significantly speeding up rebuilds. + +--- + +### 4. `.dockerignore` Usage + +```dockerignore +__pycache__/ +*.py[cod] +venv/ +tests/ +.env +.git/ +docs/ +``` + +**Why it matters:** +Excluding unnecessary files: + +* Reduces build context size +* Speeds up Docker builds +* Prevents secrets and local artifacts from being copied into the image +* Keeps the final image clean and deterministic + +--- + +### 5. No Cache Package Installation + +```dockerfile +RUN apk add --no-cache shadow +``` + +**Why it matters:** +Using `--no-cache` prevents package index files from being stored in the image, keeping layers smaller and reducing image bloat. +--- +### 7. Strict versions of base image +```dockerfile +FROM python:3.14.2-alpine3.23 +``` + +**Why it matters:** +Ensure stability without unexpected bugs, tailored with the newer version of python or alpine + +--- +### 8. Environment variables documented in the dockerfile, according official styleguide +```dockerfile +# OPTIONAL: PORT {5000}, HOST {0.0.0.0}, DEBUG {false} +``` +**Why it matters:** +Person, that will use that image do not required to search in the docs how to configure the program. Only `Dokerfile` will enough to know valuable run config +--- +## 2. Image Information & Decisions + +### Base Image Selection + +**Chosen image:** `python:3.14.2-alpine3.23` + +**Justification:** + +* Python 3.14 ensures forward compatibility with modern language features +* Alpine 3.23 provides a lightweight and secure Linux base +* Official Python image guarantees consistent builds and security updates + +--- + +### Final Image Size + +**Final image size:** 33.3 MB + +**Assessment:** +For a Python web application with dependencies installed, this is a compact and efficient result. Alpine significantly reduces size compared to Debian-based images (~150–200 MB). + +--- + +### Layer Structure + +1. Base Python runtime +2. OS user and group creation +3. Dependency installation +4. Application source code +5. Runtime configuration + +This structure maximizes cache reuse while keeping runtime layers minimal. + +--- + +### Optimization Choices + +* Alpine base image +* Single responsibility per layer +* Dependency caching +* `.dockerignore` exclusions +* No package manager cache retention + +--- + +## 3. Build & Run Process + +### Build Command + +```ps +docker build -t devops-i-lobazov:0.1.0 . +``` + +### Build Output + +``` +[+] Building 22.2s (12/12) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 460B 0.0s + => [internal] load metadata for docker.io/library/python:3.14.2-alpine3.23 1.3s + => [internal] load .dockerignore 0.0s + => => transferring context: 252B 0.0s + => [1/7] FROM docker.io/library/python:3.14.2-alpine3.23@sha256:31da4cb527055e4e3d7e9e006dffe9329f84ebea79eaca0a1f1c2 0.1s + => => resolve docker.io/library/python:3.14.2-alpine3.23@sha256:31da4cb527055e4e3d7e9e006dffe9329f84ebea79eaca0a1f1c2 0.1s + => [internal] load build context 0.0s + => => transferring context: 4.69kB 0.0s + => CACHED [2/7] RUN apk add --no-cache shadow && groupadd -r appgroup && useradd -r -g appgroup -m appuser 0.0s + => CACHED [3/7] WORKDIR /app 0.0s + => [4/7] RUN pip install --upgrade pip>=26.0 7.2s + => [5/7] COPY requirements.txt ./requirements.txt 0.1s + => [6/7] RUN pip install -r requirements.txt 10.8s + => [7/7] COPY . . 0.1s + => exporting to image 2.3s + => => exporting layers 1.1s + => => exporting manifest sha256:5c7770b74f0d3045e4c2d2ee3ba85f258b4c6378c9d0a6121a66044639ab9c64 0.0s + => => exporting config sha256:42f026b344436bd5f4472e9d0b7a1814d01c3626aeb9603131299056e200df1d 0.0s + => => exporting attestation manifest sha256:8c73b05255d8bef7de7d2ce034512ad8a858d234899530bb411fdcbc08b042f7 0.0s + => => exporting manifest list sha256:b9c9e9fbc2bd31279d286f764d5ba85b786f44956d9285356ab5c99c4128ae13 0.0s + => => naming to docker.io/library/devops-i-lobazov:0.1.0 0.0s + => => unpacking to docker.io/library/devops-i-lobazov:0.1.0 1.1s + +What's next: + View a summary of image vulnerabilities and recommendations → docker scout quickview +``` + +--- + +### Ensure same behavior as ran on host +App output +```bash +> docker run -p 5000:5000 devops-i-lobazov:0.1.0 +INFO: Started server process [1] +INFO: Waiting for application startup. +2026-02-03 21:06:02,878 - __main__ - INFO - Starting up... +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit) +INFO: 172.17.0.1:44890 - "GET /health HTTP/1.1" 200 OK +INFO: 172.17.0.1:39328 - "GET /health HTTP/1.1" 200 OK +INFO: 172.17.0.1:39048 - "GET / HTTP/1.1" 200 OK +``` +`curl` call +```ps +PS C:\Users\xzsay\PycharmProjects\DevOps-Core-Course> (curl -UseBasicParsing http://localhost:5000/health).Content | ConvertFrom-Json | ConvertTo-Json +{ + "status": "healthy", + "timestamp": "2026-02-03 21:07:04", + "uptime_seconds": 61 +} +PS C:\Users\xzsay\PycharmProjects\DevOps-Core-Course> (curl -UseBasicParsing http://localhost:5000).Content | ConvertFrom-Json | ConvertTo-Json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "03fe2b761477", + "platform": "Linux", + "platform_version": "6.6.87.2-microsoft-standard-WSL2", + "architecture": "x86_64", + "cpu_count": 12, + "python_version": "3.14.2" + }, + "runtime": { + "uptime_seconds": 73, + "uptime_human": "0 hours, 1 minutes", + "current_time": "2026-02-03 21:07:15", + "timezone": "" + }, + "request": { + "client_ip": "172.17.0.1", + "user_agent": "Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.26100.7462", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/openapi.json", + "method": "HEAD", + "description": "" + }, + { + "path": "/openapi.json", + "method": "GET", + "description": "" + }, + { + "path": "/docs", + "method": "HEAD", + "description": "" + }, + { + "path": "/docs", + "method": "GET", + "description": "" + }, + { + "path": "/docs/oauth2-redirect", + "method": "HEAD", + "description": "" + }, + { + "path": "/docs/oauth2-redirect", + "method": "GET", + "description": "" + }, + { + "path": "/redoc", + "method": "HEAD", + "description": "" + }, + { + "path": "/redoc", + "method": "GET", + "description": "" + }, + { + "path": "/", + "method": "GET", + "description": "System and service info about the server" + }, + { + "path": "/health", + "method": "GET", + "description": "Service health-chek" + } + ] +} +``` + +--- + +### Docker Hub Repository +`Powershell` history: +```ps +PS ~\PycharmProjects\DevOps-Core-Course\solution\app_python> docker login +Authenticating with existing credentials... [Username: xrixis] + +i Info → To login with a different account, run 'docker logout' followed by 'docker login' + + +Login Succeeded +PS ~\PycharmProjects\DevOps-Core-Course\solution\app_python> docker tag devops-i-lobazov:0.1.0 xrixis/devops-i-lobazov:0.1.0 +PS ~\PycharmProjects\DevOps-Core-Course\solution\app_python> docker tag devops-i-lobazov:0.1.0 xrixis/devops-i-lobazov:latest +PS ~\PycharmProjects\DevOps-Core-Course\solution\app_python> docker push xrixis/devops-i-lobazov:0.1.0 +The push refers to repository [docker.io/xrixis/devops-i-lobazov] +472bf656f1d9: Waiting +a0bd95b0bd18: Waiting +bd701e501660: Waiting +871c57f4ba4f: Waiting +472bf656f1d9: Pushed +a0bd95b0bd18: Pushed +bd701e501660: Pushed +871c57f4ba4f: Pushed +589002ba0eae: Pushed +4d526f9d3e24: Pushing [==================> ] 2.097MB/5.604MB +ff83b2b57ff1: Pushed +c636d76d1d07: Pushed +4d526f9d3e24: Pushed +1de815c6e5e1: Pushed +6a6e0b164786: Pushed +0.1.0: digest: sha256:b9c9e9fbc2bd31279d286f764d5ba85b786f44956d9285356ab5c99c4128ae13 size: 856 +``` +To obtain the image run +```bash +docker pull xrixis/devops-i-lobazov:0.1.0 +``` +Also, available at **[https://hub.docker.com/repository/docker/xrixis/devops-i-lobazov/](https://hub.docker.com/repository/docker/xrixis/devops-i-lobazov/)** + +--- + +## 4. Technical Analysis + +### Why does your Dockerfile work the way it does? + +It is technological evolved solution for running applications isolated. +OS isolate only the main memory for each process, but for other resources here is mutual access +(files, ports, dependencies). Running separate OS foreach process - too wasteful in some scenarios, so Docker resolve +conflict and vulnerabilities caused by resources sharing (isolate them for containers), keeping execution on the same +machine + +--- + +### Impact of Layer Order Changes + +If `COPY . .` were placed before installing dependencies: + +* Any source code change would invalidate the cache +* Dependencies would reinstall on every build +* Build times would increase significantly + +--- + +### Security Considerations + +* Non-root execution +* Minimal OS packages (Alpine) +* No secrets baked into image + +--- + +### Role of `.dockerignore` + +`.dockerignore` prevents: + +* Accidental inclusion of secrets (`.env`) +* Large unnecessary directories (`.git`, `tests`) +* Local artifacts affecting reproducibility + +This results in faster, safer, and more predictable builds. + +--- + +## 5. Challenges & Solutions + +### Challenge: Alpine Missing User Management Tools + +**Issue:** `useradd` not available by default in alpine + +**Solution:** Imported `shadow` package explicitly + +--- + +### Challenge: Port configured via environment variable + +**Issue:** `EXPOSE` directive, designed for documentation could not be properly filled, because depend on runtime config +```Dockerfile +EXPOSE 5000 +``` +**Solution:** Ignore the problem. Advanced user, that will be configuring the running container via env should get the +documented and actual port missmatch, but directive `EXPOSE 5000` will be convenient for default run via GUI in +Docker Desktop + +--- +### Key Learnings + +* `.dockerignore` is just as important as `.gitignore` +* Security best practices are easy to apply early + +--- diff --git a/solution/app_python/docs/LAB03.md b/solution/app_python/docs/LAB03.md new file mode 100644 index 0000000000..8ac16dede6 --- /dev/null +++ b/solution/app_python/docs/LAB03.md @@ -0,0 +1,164 @@ +# Lab 3 — Continuous Integration (CI/CD) Implementation + +## 1. Overview + +### Testing Framework Choice +**Framework:** pytest 7.3.1 +**Why:** pytest is the industry standard for Python testing. It offers: +- Simple, readable syntax with minimal boilerplate +- Powerful fixtures for setup/teardown +- Excellent plugin ecosystem (pytest-cov for coverage) +- Better assertion introspection than unittest +- Wide adoption in modern Python projects + +### Test Coverage +**Location:** `tests/test_app.py` +**Test Count:** 29 unit tests +**Coverage:** 91% + +### CI Workflow Trigger Configuration +**Workflows trigger on:** +- Push to `master` or `main` branches +- Any push or pull request affecting `solution/app_python/**` files +- Changes to workflow files themselves (`.github/workflows/*.yml`) +- Changes to requirements files (`requirements*.txt`) + +**Path Filters Implementation:** +```yaml +on: + push: + paths: + - 'solution/app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + paths: + - 'solution/app_python/**' + - '.github/workflows/python-ci.yml' +``` + +### Versioning Strategy: Semantic Versioning (SemVer) + +**Format:** `MAJOR.MINOR.PATCH` (e.g., `0.1.0`) + +**Why SemVer?** +- This project uses explicit release semantics and reproducible image tags +- SemVer lets you indicate breaking changes (major), new features (minor), and fixes (patch) +- Works well with manual release/tag workflows used in this repository + +## Outputs + +1) Tests + coverage + +```bash +python -m pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing +``` + +``` +---------- coverage: platform win32, python 3.12.10-final-0 ---------- +Name Stmts Miss Cover Missing +------------------------------------------------- +__init__.py 0 0 100% +app.py 43 4 91% 27-29, 111 +tests\__init__.py 0 0 100% +tests\test_app.py 152 0 100% +------------------------------------------------- +TOTAL 195 4 98% +Coverage XML written to file coverage.xml + + +====================================================================== 29 passed in 2.02s ====================================================================== +``` + +2) Flake8 linting + +```bash +flake8 . +``` +Provided empty output, implying properly formatted code + +**Docker Tags Applied:** +- Version tag: `0.1.0` (release version) +- Latest tag: `latest` (points to most recent released image) +- Branch/Commit tag: `master-` (git commit reference for debugging) + +--- + +## 3. Best Practices Implemented + +### ✅ Practice 1: Dependency Caching with `actions/setup-python` +**Implementation:** +```yaml +- name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + cache: 'pip' + cache-dependency-path: | + solution/app_python/requirements.txt + solution/app_python/requirements.dev.txt +``` + +**Benefit:** Reduces CI runtime by ~60% (from ~45s to ~18s) on cache hits by reusing pip packages. + +--- + +### ✅ Practice 2: Path-Based Workflow Triggers (Monorepo Optimization) +**Why it matters:** In a monorepo with multiple apps (Python + Rust), only run Python CI when Python files change. Prevents: +- Wasting compute resources on unnecessary runs +- Unclear test results from irrelevant changes +- Unnecessary Docker builds for unrelated changes + +**Configuration Example:** +```yaml +on: + push: + paths: + - 'solution/app_python/**' + - '.github/workflows/python-ci.yml' +``` + +--- + +### ✅ Practice 3: CD Depends on CI Success (Workflow Run) +**Why it matters:** CD only runs after CI passes, preventing broken images from being published. + +**Implementation:** +```yaml +on: + workflow_run: + workflows: ["Python CI - Run tests and lints"] + branches: [main, master] + types: [completed] + +jobs: + build-and-push: + if: ${{ github.event.workflow_run.conclusion == 'success' }} +``` + +## 5. Challenges & Solutions + +### Challenge 1: CD Dependency on Separate Workflow File +**Problem:** Publishing should be done only on merge, while testing and linting still need to be successfull + +**Solution:** Used several workflows for testing and publishing, and add `workflow_run` trigger with success check: +```yaml +on: + workflow_run: + workflows: ["Python CI - Run tests and lints"] + types: [completed] + +if: ${{ github.event.workflow_run.conclusion == 'success' }} +``` + +--- + +### Challenge 2: Docker Build Context Path +**Problem:** Dockerfile in `solution/app_python/` but context path needs correct setup. + +**Solution:** Set context to `./solution/app_python` in `docker/build-push-action`: +```yaml +- name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./solution/app_python +``` diff --git a/solution/app_python/docs/screenshots/answer.png b/solution/app_python/docs/screenshots/answer.png new file mode 100644 index 0000000000..023123bb82 Binary files /dev/null and b/solution/app_python/docs/screenshots/answer.png differ diff --git a/solution/app_python/docs/screenshots/health.png b/solution/app_python/docs/screenshots/health.png new file mode 100644 index 0000000000..ec6d81ab68 Binary files /dev/null and b/solution/app_python/docs/screenshots/health.png differ diff --git a/solution/app_python/docs/screenshots/my-endpoints-curl.png b/solution/app_python/docs/screenshots/my-endpoints-curl.png new file mode 100644 index 0000000000..94dfa2f1f4 Binary files /dev/null and b/solution/app_python/docs/screenshots/my-endpoints-curl.png differ diff --git a/solution/app_python/docs/screenshots/terminal-output.png b/solution/app_python/docs/screenshots/terminal-output.png new file mode 100644 index 0000000000..b3d682dfde Binary files /dev/null and b/solution/app_python/docs/screenshots/terminal-output.png differ diff --git a/solution/app_python/requirements.dev.txt b/solution/app_python/requirements.dev.txt new file mode 100644 index 0000000000..dc20f6c2b7 --- /dev/null +++ b/solution/app_python/requirements.dev.txt @@ -0,0 +1,5 @@ +flake8==7.3.0 +pep8-naming==0.15.1 +pytest==8.3.0 +pytest-cov==5.0.0 +httpx==0.28.1 \ No newline at end of file diff --git a/solution/app_python/requirements.txt b/solution/app_python/requirements.txt new file mode 100644 index 0000000000..d084c7f3d4 --- /dev/null +++ b/solution/app_python/requirements.txt @@ -0,0 +1,14 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +click==8.3.3 +fastapi==0.128.0 +h11==0.16.0 +idna==3.11 +pydantic==2.12.5 +pydantic_core==2.41.5 +starlette==0.50.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.40.0 +prometheus-client==0.23.1 \ No newline at end of file diff --git a/solution/app_python/tests/__init__.py b/solution/app_python/tests/__init__.py new file mode 100644 index 0000000000..0e116e2a1a --- /dev/null +++ b/solution/app_python/tests/__init__.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) diff --git a/solution/app_python/tests/test_app.py b/solution/app_python/tests/test_app.py new file mode 100644 index 0000000000..0dfb73cce0 --- /dev/null +++ b/solution/app_python/tests/test_app.py @@ -0,0 +1,319 @@ +import json +import logging + +import pytest +from fastapi.testclient import TestClient + +from app import JSONFormatter, app + + +@pytest.fixture +def client(tmp_path, monkeypatch): + visits_file = tmp_path / "visits" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "applicationName": "devops-info-service", + "environment": "test", + "featureFlags": { + "visitsEndpoint": True, + "configFromConfigMap": True, + }, + "settings": { + "greeting": "hello-from-config", + }, + } + ), + encoding="utf-8", + ) + + monkeypatch.setenv("VISITS_FILE", str(visits_file)) + monkeypatch.setenv("CONFIG_PATH", str(config_path)) + + app.state.visits_file = visits_file + app.state.config_path = config_path + + with TestClient(app) as test_client: + yield test_client + + +class TestRootEndpoint: + """Tests for GET / endpoint.""" + + def test_root_status_code(self, client): + response = client.get("/") + assert response.status_code == 200 + + def test_root_response_is_json(self, client): + response = client.get("/") + assert response.headers["content-type"] == "application/json" + + def test_root_has_service_section(self, client): + data = client.get("/").json() + assert "service" in data + assert isinstance(data["service"], dict) + + def test_root_service_fields(self, client): + service = client.get("/").json()["service"] + required_fields = ["name", "version", "description", "framework"] + for field in required_fields: + assert field in service, f"Missing field: {field}" + assert isinstance(service[field], str) + + def test_root_service_name(self, client): + service = client.get("/").json()["service"] + assert service["name"] == "devops-info-service" + + def test_root_service_framework(self, client): + service = client.get("/").json()["service"] + assert service["framework"] == "FastAPI" + + def test_root_has_system_section(self, client): + data = client.get("/").json() + assert "system" in data + assert isinstance(data["system"], dict) + + def test_root_system_fields(self, client): + system = client.get("/").json()["system"] + required_fields = [ + "hostname", + "platform", + "platform_version", + "architecture", + "cpu_count", + "python_version", + ] + for field in required_fields: + assert field in system, f"Missing field: {field}" + + def test_root_system_cpu_count_is_positive(self, client): + system = client.get("/").json()["system"] + assert isinstance(system["cpu_count"], int) + assert system["cpu_count"] > 0 + + def test_root_has_runtime_section(self, client): + data = client.get("/").json() + assert "runtime" in data + assert isinstance(data["runtime"], dict) + + def test_root_runtime_fields(self, client): + runtime = client.get("/").json()["runtime"] + required_fields = ["uptime_seconds", "uptime_human", "current_time", "timezone"] + for field in required_fields: + assert field in runtime, f"Missing field: {field}" + + def test_root_uptime_seconds_is_non_negative(self, client): + runtime = client.get("/").json()["runtime"] + assert isinstance(runtime["uptime_seconds"], int) + assert runtime["uptime_seconds"] >= 0 + + def test_root_has_configuration_section(self, client): + configuration = client.get("/").json()["configuration"] + assert configuration["file_exists"] is True + assert configuration["content"]["environment"] == "test" + + def test_root_has_visits_section(self, client): + visits = client.get("/").json()["visits"] + assert "count" in visits + assert "file_path" in visits + assert visits["count"] >= 1 + + def test_root_has_request_section(self, client): + data = client.get("/").json() + assert "request" in data + assert isinstance(data["request"], dict) + + def test_root_request_fields(self, client): + request_data = client.get("/").json()["request"] + required_fields = ["client_ip", "user_agent", "method", "path"] + for field in required_fields: + assert field in request_data, f"Missing field: {field}" + + def test_root_request_method_is_get(self, client): + request_data = client.get("/").json()["request"] + assert request_data["method"] == "GET" + + def test_root_request_path_is_root(self, client): + request_data = client.get("/").json()["request"] + assert request_data["path"] == "/" + + def test_root_has_endpoints_list(self, client): + data = client.get("/").json() + assert "endpoints" in data + assert isinstance(data["endpoints"], list) + assert len(data["endpoints"]) > 0 + + def test_root_endpoints_have_required_fields(self, client): + endpoints = client.get("/").json()["endpoints"] + for endpoint in endpoints: + assert "path" in endpoint + assert "method" in endpoint + assert "description" in endpoint + + +class TestVisitsEndpoint: + """Tests for GET /visits endpoint.""" + + def test_visits_status_code(self, client): + response = client.get("/visits") + assert response.status_code == 200 + + def test_visits_returns_zero_before_root_calls(self, client): + response = client.get("/visits") + assert response.json()["count"] == 0 + + def test_visits_increments_after_root_calls(self, client): + client.get("/") + client.get("/") + + response = client.get("/visits") + assert response.json()["count"] == 2 + + def test_visits_persist_in_file(self, client): + root_response = client.get("/") + file_path = root_response.json()["visits"]["file_path"] + + response = client.get("/visits") + assert response.json()["count"] == 1 + + with open(file_path, "r", encoding="utf-8") as visits_file: + assert visits_file.read().strip() == "1" + + +class TestHealthEndpoint: + """Tests for GET /health endpoint.""" + + def test_health_status_code(self, client): + response = client.get("/health") + assert response.status_code == 200 + + def test_health_response_is_json(self, client): + response = client.get("/health") + assert response.headers["content-type"] == "application/json" + + def test_health_has_status_field(self, client): + data = client.get("/health").json() + assert "status" in data + + def test_health_status_is_healthy(self, client): + data = client.get("/health").json() + assert data["status"] == "healthy" + + def test_health_has_timestamp(self, client): + data = client.get("/health").json() + assert "timestamp" in data + assert isinstance(data["timestamp"], str) + + def test_health_has_uptime_seconds(self, client): + data = client.get("/health").json() + assert "uptime_seconds" in data + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + def test_health_required_fields(self, client): + data = client.get("/health").json() + required_fields = ["status", "timestamp", "uptime_seconds"] + for field in required_fields: + assert field in data, f"Missing field: {field}" + + +class TestMetricsEndpoint: + """Tests for GET /metrics endpoint and custom metrics.""" + + def test_metrics_status_code(self, client): + response = client.get("/metrics") + assert response.status_code == 200 + + def test_metrics_content_type(self, client): + response = client.get("/metrics") + assert "text/plain" in response.headers["content-type"] + + def test_metrics_contains_custom_metric_names(self, client): + client.get("/") + client.get("/health") + client.get("/visits") + + response = client.get("/metrics") + body = response.text + + assert "app_http_requests_total" in body + assert "app_http_request_duration_seconds" in body + assert "app_http_active_requests" in body + assert "app_root_requests_total" in body + assert "app_system_info_duration_seconds" in body + assert "app_uptime_seconds" in body + + def test_metrics_contains_root_endpoint_labels(self, client): + client.get("/") + + response = client.get("/metrics") + assert ( + 'app_http_requests_total{endpoint="/",method="GET",status_code="200"}' + in response.text + ) + + +class TestErrorHandling: + """Tests for error handling.""" + + def test_nonexistent_endpoint(self, client): + response = client.get("/nonexistent") + assert response.status_code == 404 + + def test_error_response_is_html(self, client): + response = client.get("/nonexistent") + assert response.status_code == 404 + + +class TestMultipleRequests: + """Tests for multiple sequential requests.""" + + def test_uptime_increases(self, client): + response1 = client.get("/health") + uptime1 = response1.json()["uptime_seconds"] + + response2 = client.get("/health") + uptime2 = response2.json()["uptime_seconds"] + + assert uptime2 >= uptime1 + + def test_root_and_health_consistency(self, client): + root_response = client.get("/") + health_response = client.get("/health") + + root_uptime = root_response.json()["runtime"]["uptime_seconds"] + health_uptime = health_response.json()["uptime_seconds"] + + assert abs(root_uptime - health_uptime) <= 1 + + +class TestLogging: + """Tests for JSON logging formatter.""" + + def test_json_formatter_outputs_required_fields(self): + record = logging.LogRecord( + name="test.logger", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="hello", + args=(), + exc_info=None, + ) + record.method = "GET" + record.path = "/" + record.status_code = 200 + record.client_ip = "127.0.0.1" + + formatted = JSONFormatter().format(record) + payload = json.loads(formatted) + + assert payload["level"] == "INFO" + assert payload["logger"] == "test.logger" + assert payload["message"] == "hello" + assert payload["method"] == "GET" + assert payload["path"] == "/" + assert payload["status_code"] == 200 + assert payload["client_ip"] == "127.0.0.1" + assert "timestamp" in payload diff --git a/solution/app_rust/.dockerignore b/solution/app_rust/.dockerignore new file mode 100644 index 0000000000..2ee576c793 --- /dev/null +++ b/solution/app_rust/.dockerignore @@ -0,0 +1,25 @@ +# Rust artefacts +target/ +**/*.rs.bk + +# Tests +tests/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Secrets +.env + +# Git +.git/ +.github/ +.gitignore + +# Docs +*.md +docs/ diff --git a/solution/app_rust/.gitignore b/solution/app_rust/.gitignore new file mode 100644 index 0000000000..763fb35a1f --- /dev/null +++ b/solution/app_rust/.gitignore @@ -0,0 +1,16 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +.idea \ No newline at end of file diff --git a/solution/app_rust/Cargo.lock b/solution/app_rust/Cargo.lock new file mode 100644 index 0000000000..a9177daa24 --- /dev/null +++ b/solution/app_rust/Cargo.lock @@ -0,0 +1,1959 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2", + "http", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.2", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "devops-info-service" +version = "1.0.0" +dependencies = [ + "actix-web", + "chrono", + "dotenv", + "env_logger", + "lazy_static", + "log", + "rustc_version", + "serde", + "serde_json", + "sys-info", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdea86ddd5568519879b8187e1cf04e24fce28f7fe046ceecbce472ff19a2572" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c15e1b46eff7c6c91195752e0eeed8ef040e391cdece7c25376957d5f15df22" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/solution/app_rust/Cargo.toml b/solution/app_rust/Cargo.toml new file mode 100644 index 0000000000..96dd262167 --- /dev/null +++ b/solution/app_rust/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "devops-info-service" +version = "1.0.0" +edition = "2021" + +[[bin]] +name = "devops-info-service" +path = "src/main.rs" + +[dependencies] +actix-web = "4.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +dotenv = "0.15" +chrono = { version = "0.4", features = ["serde"] } +sys-info = "0.9" +log = "0.4" +env_logger = "0.11.8" +lazy_static = "1.5.0" +rustc_version = "0.4.1" diff --git a/solution/app_rust/Dockerfile b/solution/app_rust/Dockerfile new file mode 100644 index 0000000000..5899424681 --- /dev/null +++ b/solution/app_rust/Dockerfile @@ -0,0 +1,37 @@ +# ---------- Build stage ---------- +FROM rust:1.91.0-alpine3.20 AS builder +# Build dependancies and user managment +RUN apk add --no-cache \ + shadow \ + musl-dev \ + gcc \ + && addgroup -g 122 -S appgroup \ + && adduser -S -u 122 -G appgroup appuser + +WORKDIR /app +COPY ./Cargo.toml ./Cargo.toml +COPY ./Cargo.lock ./Cargo.lock +RUN chown -R appuser:appgroup /app +USER 122:122 +RUN cargo fetch + +COPY ./src ./src +RUN cargo build --release --bin devops-info-service +RUN cargo install --path . + +# ---------- Runtime stage ---------- +FROM alpine:3.20 +# OPTIONAL: PORT {5000}, HOST {0.0.0.0}, DEBUG {false} +LABEL authors="xzsay" +RUN apk add --no-cache shadow \ + && groupadd -g 122 -r appgroup \ + && useradd -u 122 -r -g appgroup -m appuser + +WORKDIR /app +COPY --from=builder /app/target/release/devops-info-service ./devops-info-service +RUN chown -R appuser:appgroup /app +USER 122:122 + +EXPOSE 5000 + +ENTRYPOINT ["/app/devops-info-service"] diff --git a/solution/app_rust/README.md b/solution/app_rust/README.md new file mode 100644 index 0000000000..31eb39061d --- /dev/null +++ b/solution/app_rust/README.md @@ -0,0 +1,198 @@ +# DevOps Info Service (Rust / Actix-web) + +[![Rust CI](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/rust-ci.yml/badge.svg)](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/rust-ci.yml) +[![Rust CD](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/rust-cd.yml/badge.svg)](https://github.com/XriXis/DevOps-Core-Course/actions/workflows/rust-cd.yml) + +## Overview + +**DevOps Info Service** is an educational web service that present simple simple JSON-based HTTP API. + + +--- + +## Tech Stack + +- **Rust:** v0.4.1 +- **Web Framework:** actix-web v4.3 + +--- + +## Prerequisites + +- Rust toolchain installed + +--- + +## Project Structure + +``` +. +├── Cargo.lock +├── Cargo.toml +├── Dockerfile +├── .dockerignore +├── docs +│ ├── LAB01.md +│ ├── Rust.md +│ └── screenshots +│ └── curl-output.png +├── README.md +├── src +│ ├── config.rs +│ ├── main.rs +│ ├── routes.rs +│ └── system.rs +``` + +--- +## Run options +### Run on host +1. Install the rust-toolchain (rust-up). Installation guide provided at https://rustup.rs/ +2. Clone the repository and navigate to the project directory + ```bash + cd solution/app_rust + ``` +3. Compile the project + ```bash + cargo build # for dev version + # cargo build -r # for release version with optimizations + ``` +4. Run the compiled binary at + ```bash + ./target/debug/devops-info-service # or .\target\debug\devops-info-service.exe on Windows + ``` +--- + +### Running the Application + +#### Run with default settings + +```bash +./target/debug/devops-info-service +``` + +Default configuration: + +* HOST: `0.0.0.0` +* PORT: `5000` + +#### Run with environment variables + +```bash +PORT=8080 ./target/debug/devops-info-service +HOST=127.0.0.1 PORT=3000 ./target/debug/devops-info-service +DEBUG=true python ./target/debug/devops-info-service +``` + +--- + +### Local Docker build +1. Be sure docker instance is installed and daemon is running ([`docker.io`](https://docs.docker.com/get-started/get-docker/) or [`docker desktop`](https://docs.docker.com/desktop/)) +2. Clone the repository and navigate to the project directory + ```bash + cd solution/app_rust + ``` +3. Build the image + ```bash + docker build -t devops-i-lobazov-rust:0.1.0 + ``` +4. Run the container with port specification (and optionally environment variables) + ```bash + docker run -p 5000:80 -e DEBUG=true devops-i-lobazov-rust:0.1.0 + ``` + +--- +## API Endpoints + +### `GET /` — Service Information + +Returns detailed information about the service, system, runtime, and incoming request. + +Example response: + +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "ActixWeb" + }, + "system": { + "hostname": "my-host", + "platform": "Linux", + "platform_version": "6.8.0", + "architecture": "x86_64", + "cpu_count": 8, + "rust_version": "0.4.1" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hours, 0 minutes", + "current_time": "2026-01-07 14:30:00", + "timezone": "" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "System and service info about the server" + }, + { + "path": "/health", + "method": "GET", + "description": "Service health chek" + } + ] +} +``` + +--- + +### `GET /health` — Health Check + +A lightweight endpoint for monitoring and orchestration systems. + +Example response: + +```json +{ + "status": "healthy", + "timestamp": "2026-01-07 14:32:10", + "uptime_seconds": 3720 +} +``` + +HTTP status: **200 OK** + +--- + +## Configuration + +The application is configurable via environment variables: + +| Variable | Default | Description | +| -------- | --------- | --------------------------- | +| `HOST` | `0.0.0.0` | Server bind address | +| `PORT` | `5000` | Server port | +| `DEBUG` | `false` | Enables debug-level logging | + +--- + +## Logging + +* INFO logs on application startup and shutdown +* DEBUG logs for incoming HTTP requests +* Log format: + +``` +timestamp - level - logger - message +``` + +Log level is controlled by the `DEBUG` environment variable. diff --git a/solution/app_rust/docs/LAB01.md b/solution/app_rust/docs/LAB01.md new file mode 100644 index 0000000000..8dce031755 --- /dev/null +++ b/solution/app_rust/docs/LAB01.md @@ -0,0 +1,172 @@ +# Lab 01 - DevOps Info Service Implementation (Rust) + +## 1. Best Practices Applied + +### 1. Clean Code Organization + +* Source code is separated into logical modules: + * `system.rs` - system and runtime information + * `routes.rs` - HTTP endpoint handlers + * `main.rs` - application startup and configuration + * `config.rs` - environment variable config +* Clear separation of responsibilities +* Idiomatic Rust formatting and naming conventions + +### 2. Logging + ```rust + env_logger::Builder::from_default_env() + .filter_level(if cfg.debug { LevelFilter::Debug } else { LevelFilter::Info }) + .init(); + ``` + +* Logging is configurable via environment variables +* Startup and incoming requests are logged +* Logging does not block request handling + +[//]: # () +[//]: # (### 3. Error Handling (Not Implemented)) + +[//]: # () +[//]: # (* Custom error handling is **not implemented** in this lab) + +[//]: # (* Actix-web default error responses are used) + +[//]: # (* Unified error pages and structured error responses are planned for a future iteration) + +### 3. Configuration via Environment Variables + + ```rust + let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let port = env::var("PORT").unwrap_or_else(|_| "5000".to_string()); + ``` + +* Enables flexible configuration without code changes +* Aligns with containerized and cloud deployment practices + +--- + +## 2. API Documentation + +### GET `/` + +Returns service, system, runtime, and request information. + +**Example Response:** + + ```json + { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Actix-web" + }, + "system": { + "hostname": "my-laptop", + "platform": "Linux", + "platform_version": "6.12.67-1-lts", + "architecture": "x86_64", + "cpu_count": 12, + "rust_version": "1.75.0" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hours, 0 minutes", + "current_time": "2026-01-07 14:30:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { "path": "/", "method": "GET", "description": "Service information" }, + { "path": "/health", "method": "GET", "description": "Health check" } + ] + } + ``` + +**Testing Command:** + +```bash +curl http://127.0.0.1:5000/ +``` + +--- + +### GET `/health` + +Returns service health information. + +**Example Response:** + +```json +{ + "status": "healthy", + "timestamp": "2026-01-07 14:30:00", + "uptime_seconds": 3600 +} +``` + +**Testing Command:** + +```bash +curl http://127.0.0.1:5000/health +``` + +--- + +## 3. Testing Evidence + +### Screenshots + +1. Root endpoint + ![Main Endpoint](screenshots/curl-output.png) + +2. Health check + ![Health-check](screenshots/curl-output-health.png) + +### Terminal Output + +``` +[2026-01-28T23:14:11Z INFO devops_info_service] Starting DevOps Info Service on 0.0.0.0:5000 at 2026-01-28 23:14:11.577711618 UTC +[2026-01-28T23:14:11Z INFO actix_server::builder] starting 12 workers +[2026-01-28T23:14:11Z INFO actix_server::server] Actix runtime found; starting in Actix runtime +[2026-01-28T23:14:11Z INFO actix_server::server] starting service: "actix-web-service-0.0.0.0:5000", workers: 12, listening on: 0.0.0.0:5000 +[2026-01-28T23:14:18Z DEBUG devops_info_service::routes] Request: GET / +[2026-01-28T23:14:19Z INFO actix_web::middleware::logger] 127.0.0.1 "GET / HTTP/1.1" 200 654 "-" "curl/8.18.0" 0.184297 +[2026-01-28T23:14:26Z DEBUG devops_info_service::routes] Health check request +[2026-01-28T23:14:26Z INFO actix_web::middleware::logger] 127.0.0.1 "GET /health HTTP/1.1" 200 74 "-" "curl/8.18.0" 0.000621 +``` +--- + +## 5. Challenges & Solutions + +* **Challenge:** Correct uptime calculation + **Solution:** Stored application start time and calculated elapsed duration dynamically. + +* **Challenge:** Obtaining Rust compiler version instead of package version + **Solution:** Used the `rustc_version` crate to retrieve the compiler version. + +* **Challenge:** Lack of automatic endpoint registry + **Solution:** Endpoint metadata is currently hardcoded. + +--- + +## 6. GitHub Community + +* Starred course-related and Rust ecosystem repositories +* Followed instructor and classmates to support collaboration +* Reviewed open-source Rust services to understand common architectural patterns + +--- + +### ✅ Notes for Future Work + +* Implement unified HTML/JSON error pages +* Add structured error types +* Introduce OpenAPI documentation generation + +--- diff --git a/solution/app_rust/docs/LAB02.md b/solution/app_rust/docs/LAB02.md new file mode 100644 index 0000000000..44e7d6b319 --- /dev/null +++ b/solution/app_rust/docs/LAB02.md @@ -0,0 +1,285 @@ +# LAB02 — Containerizing a Compiled Application with Multi-Stage Builds + +## Objective + +The goal of this lab is to containerize a compiled language application (Rust) using a **multi-stage Docker build**. +The purpose of a multi-stage build is to separate the **build environment** from the **runtime environment**, producing a significantly smaller and more secure final container image. + + +## Multi-Stage Build Strategy + +The Dockerfile is divided into **two stages**: + +1. **Builder stage** +2. **Runtime stage** + +Each stage has a distinct responsibility. + +--- + +## Stage 1 — Builder + +```dockerfile +FROM rust:1.91.0-alpine3.20 AS builder +```` + +### Purpose + +* Provide a full Rust toolchain +* Compile the application into a single optimized binary +* Keep build tools out of the final image + +### Key Characteristics + +* Includes: + + * Rust compiler and Cargo + * GCC and musl-dev for native compilation +* Uses a **non-root user** (`appuser`) +* Caches dependencies using: + + ```dockerfile + RUN cargo fetch + ``` +* Produces a release binary: + + ```dockerfile + RUN cargo build --release --bin devops-info-service + ``` + +### Result + +* Large image size +* Contains compilers and build dependencies +* A lot of useless things for production runtime + +--- + +## Stage 2 — Runtime + +```dockerfile +FROM alpine:3.20 +``` + +### Purpose + +* Run the precompiled binary +* Contain **only what is strictly necessary** + +### Key Characteristics + +* No compiler or build tools +* Minimal Alpine base image +* Runs as a **non-root user** +* Copies only the compiled binary: + + ```dockerfile + COPY --from=builder /app/target/release/devops-info-service ./devops-info-service + ``` + +--- + +## Build Process Output + +### Docker Build Command + +```bash +docker build -t devops-i-lobazov-rust:0.1.0 . +``` + +### Build Output + +```text +[+] Building 428.0s (19/19) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 885B 0.0s + => [internal] load metadata for docker.io/library/alpine:3.20 0.3s + => [internal] load metadata for docker.io/library/rust:1.91.0-alpine3.20 0.3s + => [internal] load .dockerignore 0.0s + => => transferring context: 226B 0.0s + => CACHED [builder 1/9] FROM docker.io/library/rust:1.91.0-alpine3.20@sha256:55905a107df49e2ca919ebceb11bdc35471b3436 0.1s + => => resolve docker.io/library/rust:1.91.0-alpine3.20@sha256:55905a107df49e2ca919ebceb11bdc35471b3436d9f08c179c3c51e 0.1s + => [internal] load build context 0.0s + => => transferring context: 293B 0.0s + => [stage-1 1/4] FROM docker.io/library/alpine:3.20@sha256:a4f4213abb84c497377b8544c81b3564f313746700372ec4fe84653e4f 0.1s + => => resolve docker.io/library/alpine:3.20@sha256:a4f4213abb84c497377b8544c81b3564f313746700372ec4fe84653e4fb03805 0.1s + => [builder 2/9] RUN apk add --no-cache shadow musl-dev gcc && addgroup -S appgroup && adduser 1.9s + => [builder 3/9] WORKDIR /app 0.1s + => [builder 4/9] COPY ./Cargo.toml ./Cargo.toml 0.1s + => [builder 5/9] COPY ./Cargo.lock ./Cargo.lock 0.1s + => [builder 6/9] RUN cargo fetch 251.6s + => [builder 7/9] COPY ./src ./src 0.2s + => [builder 8/9] RUN cargo build --release --bin devops-info-service 97.9s + => [builder 9/9] RUN cargo install --path . 74.4s + => CACHED [stage-1 2/4] RUN apk add --no-cache shadow && groupadd -r appgroup && useradd -r -g appgroup -m ap 0.0s + => CACHED [stage-1 3/4] WORKDIR /app 0.0s + => [stage-1 4/4] COPY --from=builder /app/target/release/devops-info-service ./devops-info-service 0.1s + => exporting to image 0.7s + => => exporting layers 0.4s + => => exporting manifest sha256:f29b9f515b31eb34b36300fa5050d8d6eddd1b5199daa3529b965436af3f6adb 0.0s + => => exporting config sha256:e982ad32f755186f32b53e4b737adfbbe66487d573adca4a3f698cdc864514a2 0.0s + => => exporting attestation manifest sha256:f47c2262266a302c0b8b106ec0817edb11a61f3809e0e492776f8d64c5d7c8be 0.0s + => => exporting manifest list sha256:46ead51211f51a41807a49d0ff402cb39aad3335170814f6b7e275cee8573d27 0.0s + => => naming to docker.io/library/devops-i-lobazov-rust:0.1.0 0.0s + => => unpacking to docker.io/library/devops-i-lobazov-rust:0.1.0 0.1s + +What's next: + View a summary of image vulnerabilities and recommendations → docker scout quickview +``` + +### Check for semantic equivalence + +- Host output: + ```bash + > docker run -p 5000:5000 -e DEBUG=true devops-i-lobazov-rust:0.1.0 + [2026-02-04T21:00:24Z INFO devops_info_service] Starting DevOps Info Service on 0.0.0.0:5000 at 2026-02-04 21:00:24.535045895 UTC + [2026-02-04T21:00:24Z INFO actix_server::builder] starting 12 workers + [2026-02-04T21:00:24Z INFO actix_server::server] Actix runtime found; starting in Actix runtime + [2026-02-04T21:00:24Z INFO actix_server::server] starting service: "actix-web-service-0.0.0.0:5000", workers: 12, listening on: 0.0.0.0:5000 + [2026-02-04T21:01:52Z DEBUG devops_info_service::routes] Health check request + [2026-02-04T21:01:52Z INFO actix_web::middleware::logger] 172.17.0.1 "GET /health HTTP/1.1" 200 74 "-" "Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.26100.7462" 0.000179 + [2026-02-04T21:02:00Z DEBUG devops_info_service::routes] Request: GET / + [2026-02-04T21:02:00Z INFO actix_web::middleware::logger] 172.17.0.1 "GET / HTTP/1.1" 200 745 "-" "Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.26100.7462" 0.023157 + ``` +- Curl output + ```ps + PS C:\Users\xzsay\PycharmProjects\DevOps-Core-Course\solution\app_rust> (curl -UseBasicParsing http://localhost:5000/health).Content | ConvertFrom-Json | ConvertTo-Json + { + "status": "healthy", + "timestamp": "2026-02-04 21:01:52", + "uptime_seconds": 87 + } + PS C:\Users\xzsay\PycharmProjects\DevOps-Core-Course\solution\app_rust> (curl -UseBasicParsing http://localhost:5000/).Content | ConvertFrom-Json | ConvertTo-Json + { + "endpoints": [ + { + "description": "System and service info about the server", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "172.17.0.1", + "method": "GET", + "path": "/", + "user_agent": "Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.26100.7462" + }, + "runtime": { + "current_time": "2026-02-04 21:02:00", + "timezone": "UTC", + "uptime_human": "0 hours, 1 minutes", + "uptime_seconds": 96 + }, + "service": { + "description": "DevOps course info service", + "framework": "Actix-web", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 12, + "hostname": "9b36c0bf0df8", + "platform": "Linux", + "platform_version": "6.6.87.2-microsoft-standard-WSL2", + "rust_version": "unknown" + } + } + ``` +--- + +## Image Size Comparison + +### Docker Images Command + +```bash +docker images | grep devops-i-lobazov-rust +``` + +### Results + +```text +devops-i-lobazov-rust 0.1.0 3bcb0abba752 29 minutes ago 24.8MB +devops-i-lobazov-rust builder 79f447ef6057 31 minutes ago 2.55GB +``` + +### Size Analysis + +| Image Stage | Approximate Size | +|-------------|------------------| +| Builder | 2.55GB | +| Final Image | 24.8MB | + +### Reduction - 10.000%! + +--- + +## Why Multi-Stage Builds Matter for Compiled Languages + +Compiled languages such as **Rust, Go, and C++** are ideal candidates for multi-stage builds because: + +* They produce **self-contained binaries** +* Runtime does not require: + + * Compilers + * Package managers + * Header files +* Final image can be extremely small + +Benefits include: + +* Faster image pulls +* Lower storage usage +* Reduced attack surface +* Cleaner production environment + +--- + +## Security Implications + +Multi-stage builds improve security by: + +* Removing build tools from the runtime image +* Reducing the number of installed packages +* Limiting the attack surface + +A smaller image means: + +* Fewer known vulnerabilities +* Lower risk of privilege escalation +* Easier vulnerability scanning + +--- + +## Trade-offs and Design Decisions + +### Trade-offs + +* Slightly longer (first) build time +* More complex Dockerfile +* Debugging requires rebuilding the image + +### Decisions Made + +* Alpine Linux chosen for minimal size +* Non-root user for runtime security +* Separate build and runtime stages for clarity and safety + +--- + +## Conclusion + +This lab demonstrates how multi-stage Docker builds enable: + +* Clean separation of concerns +* Significant image size reduction +* Improved security posture +* Production-ready container images for compiled applications + +Multi-stage builds are a **best practice** for containerizing compiled languages. + diff --git a/solution/app_rust/docs/Rust.md b/solution/app_rust/docs/Rust.md new file mode 100644 index 0000000000..132175f43d --- /dev/null +++ b/solution/app_rust/docs/Rust.md @@ -0,0 +1,36 @@ +# Lab 01 — DevOps Info Service: Language Selection + +## 1. Language Choice + +For this lab, I chose **Rust** as the implementation language. + +**Reasons:** + +* **Memory safety without garbage collector:** Rust ensures safety at compile-time using its ownership and borrowing system. +* **High performance:** Rust can achieve speeds close to C/C++. +* **Modern ecosystem for web development:** Actix-web and Warp provide asynchronous, type-safe frameworks. +* **Strong typing and compiler guarantees:** Many runtime errors are caught at compile time. +* **Growing community and industry adoption:** Rust is increasingly used for systems programming and backend services. + +**Comparison with alternatives:** + +| Language | Pros | Cons | +| -------- | ------------------------------------------------------- | ---------------------------------------------------- | +| Go | Simple syntax, fast compilation, built-in concurrency | Less strict type safety, garbage collected | +| Java | Mature ecosystem, vast library support, JVM portability | Verbose syntax, GC pauses may occur | +| C# | Modern syntax, good async support, strong tooling | Mainly Windows-centric historically, heavier runtime | +| Rust | Memory safety, zero-cost abstractions, high performance | Steeper learning curve, longer compilation times | + +## 2. Expected Benefits + +* **Reliable system-level programming** without runtime memory errors. +* **High-performance web service** capable of handling many concurrent requests. +* **Strong static typing** reduces bugs and improves maintainability. +* **Modern async frameworks** like Actix-Web allow writing scalable APIs. + +## 3. Notes + +* The Rust choice aligns with goals for **high performance**, **safety**, and **concurrency**. +* Future labs may explore Rust-specific features like **ownership**, **lifetimes**, and **async programming**. +* While Go and Java could also implement the service easily, Rust provides **more control over memory and runtime behavior**, which is valuable in systems-level DevOps services. + diff --git a/solution/app_rust/docs/screenshots/curl-output-health.png b/solution/app_rust/docs/screenshots/curl-output-health.png new file mode 100644 index 0000000000..57f3b99809 Binary files /dev/null and b/solution/app_rust/docs/screenshots/curl-output-health.png differ diff --git a/solution/app_rust/docs/screenshots/curl-output.png b/solution/app_rust/docs/screenshots/curl-output.png new file mode 100644 index 0000000000..91032ff0b4 Binary files /dev/null and b/solution/app_rust/docs/screenshots/curl-output.png differ diff --git a/solution/app_rust/src/config.rs b/solution/app_rust/src/config.rs new file mode 100644 index 0000000000..f7827bbaaa --- /dev/null +++ b/solution/app_rust/src/config.rs @@ -0,0 +1,21 @@ +use std::env; + +/// Configuration from environment +pub(crate) struct Config { + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) debug: bool, +} + +impl Config { + pub(crate) fn from_env() -> Self { + dotenv::dotenv().ok(); + let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let port = env::var("PORT").ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(5000); + let debug = env::var("DEBUG").unwrap_or_else(|_| "false".to_string()) + .to_lowercase() == "true"; + Config { host, port, debug } + } +} \ No newline at end of file diff --git a/solution/app_rust/src/main.rs b/solution/app_rust/src/main.rs new file mode 100644 index 0000000000..7a0920ae6d --- /dev/null +++ b/solution/app_rust/src/main.rs @@ -0,0 +1,32 @@ +mod routes; +mod config; +mod system; + +use actix_web::{middleware::Logger, App, HttpServer}; +use chrono::{DateTime, Utc}; +use log::LevelFilter; +use lazy_static::lazy_static; + +lazy_static! { + static ref START_TIME: DateTime = Utc::now(); +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + let cfg = config::Config::from_env(); + env_logger::Builder::from_default_env() + .filter_level(if cfg.debug { LevelFilter::Debug } else { LevelFilter::Info }) + .init(); + + log::info!("Starting DevOps Info Service on {}:{} at {}", cfg.host, cfg.port, START_TIME.to_utc()); + + HttpServer::new(|| { + App::new() + .wrap(Logger::default()) + .service(routes::root) + .service(routes::health) + }) + .bind((cfg.host, cfg.port))? + .run() + .await +} diff --git a/solution/app_rust/src/routes.rs b/solution/app_rust/src/routes.rs new file mode 100644 index 0000000000..e6e17c81e8 --- /dev/null +++ b/solution/app_rust/src/routes.rs @@ -0,0 +1,58 @@ +use actix_web::{get, HttpRequest, HttpResponse, Responder}; +use serde_json::json; +use rustc_version::version; +use crate::system; + +/// GET / +#[get("/")] +async fn root(req: HttpRequest) -> impl Responder { + log::debug!("Request: {} {}", req.method(), req.path()); + + let service = system::ServiceInfo { + name: "devops-info-service".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: "DevOps course info service".to_string(), + framework: "Actix-web".to_string(), + }; + + let system = system::SystemInfo { + hostname: sys_info::hostname().unwrap_or_default(), + platform: sys_info::os_type().unwrap_or_default(), + platform_version: sys_info::os_release().unwrap_or_default(), + architecture: std::env::consts::ARCH.to_string(), + cpu_count: sys_info::cpu_num().unwrap_or(1) as usize, + rust_version: version() + .map(|v| v.to_string()) + .unwrap_or_else(|_| "unknown".to_string()), + }; + + let uptime = system::get_uptime(); + + HttpResponse::Ok().json(serde_json::json!({ + "service": service, + "system": system, + "runtime": uptime, + "request": { + "client_ip": req.peer_addr().map(|a| a.ip().to_string()).unwrap_or("unknown".to_string()), + "user_agent": req.headers().get("User-Agent").map(|v| v.to_str().unwrap_or("unknown")).unwrap_or("unknown"), + "method": req.method().to_string(), + "path": req.path(), + }, + "endpoints": vec![ + json!({"path": "/", "method": "GET", "description": "System and service info about the server"}), + json!({"path": "/health", "method": "GET", "description": "Health check"}) + ] + })) +} + +/// GET /health +#[get("/health")] +async fn health(_req: HttpRequest) -> impl Responder { + log::debug!("Health check request"); + let runtime = system::get_uptime(); + HttpResponse::Ok().json(serde_json::json!({ + "status": "healthy", + "timestamp": runtime.current_time, + "uptime_seconds": runtime.uptime_seconds + })) +} diff --git a/solution/app_rust/src/system.rs b/solution/app_rust/src/system.rs new file mode 100644 index 0000000000..ad607cc876 --- /dev/null +++ b/solution/app_rust/src/system.rs @@ -0,0 +1,43 @@ +use chrono::Utc; +use serde::Serialize; + +#[derive(Serialize)] +pub(crate) struct ServiceInfo { + pub(crate) name: String, + pub(crate) version: String, + pub(crate) description: String, + pub(crate) framework: String, +} + +#[derive(Serialize)] +pub(crate) struct SystemInfo { + pub(crate) hostname: String, + pub(crate) platform: String, + pub(crate) platform_version: String, + pub(crate) architecture: String, + pub(crate) cpu_count: usize, + pub(crate) rust_version: String, +} + +#[derive(Serialize)] +pub(crate) struct RuntimeInfo { + pub(crate) uptime_seconds: i64, + pub(crate) uptime_human: String, + pub(crate) current_time: String, + pub(crate) timezone: String, +} + +/// Calculate uptime +pub(crate) fn get_uptime() -> RuntimeInfo { + let now = Utc::now(); + let delta = now.signed_duration_since(*crate::START_TIME); + let hours = delta.num_hours(); + let minutes = delta.num_minutes() % 60 ; + RuntimeInfo { + uptime_seconds: delta.num_seconds(), + uptime_human: format!("{} hours, {} minutes", hours, minutes), + current_time: now.format("%Y-%m-%d %H:%M:%S").to_string(), + timezone: "UTC".to_string(), + } +} + diff --git a/solution/app_rust/src/templates/error.html b/solution/app_rust/src/templates/error.html new file mode 100644 index 0000000000..c4ff735dc4 --- /dev/null +++ b/solution/app_rust/src/templates/error.html @@ -0,0 +1,10 @@ + + + + Error {{ status }} + + +

Error {{ status }}

+

{{ message }}

+ + diff --git a/solution/app_rust/tests/integration_test.rs b/solution/app_rust/tests/integration_test.rs new file mode 100644 index 0000000000..504eb8f031 --- /dev/null +++ b/solution/app_rust/tests/integration_test.rs @@ -0,0 +1,93 @@ +// Note: These are basic integration tests that verify endpoint structure +// For full testing, you'd need to create a test binary or use test utilities + +#[actix_web::test] +async fn test_healthcheck_structure() { + // Verify health check response structure can be built + let health_response = serde_json::json!({ + "status": "healthy", + "timestamp": "2026-02-12 12:00:00", + "uptime_seconds": 3600 + }); + + assert_eq!(health_response["status"], "healthy"); + assert!(health_response["timestamp"].is_string()); + assert!(health_response["uptime_seconds"].is_number()); +} + +#[actix_web::test] +async fn test_endpoint_response_format() { + // Verify root endpoint response structure + let endpoints = [ + serde_json::json!({"path": "/", "method": "GET", "description": "System and service info"}), + serde_json::json!({"path": "/health", "method": "GET", "description": "Health check"}) + ]; + + assert_eq!(endpoints.len(), 2); + assert_eq!(endpoints[0]["path"], "/"); + assert_eq!(endpoints[0]["method"], "GET"); + assert_eq!(endpoints[1]["path"], "/health"); +} + +#[actix_web::test] +async fn test_system_info_structure() { + // Verify system info object structure + let system_info = serde_json::json!({ + "hostname": "test-host", + "platform": "Linux", + "platform_version": "5.10.0", + "architecture": "x86_64", + "cpu_count": 4, + "rust_version": "1.75.0" + }); + + assert!(system_info["hostname"].is_string()); + assert!(system_info["platform"].is_string()); + assert!(system_info["cpu_count"].is_number()); + assert!(system_info["cpu_count"].as_u64().unwrap() > 0); +} + +#[actix_web::test] +async fn test_service_info_structure() { + // Verify service info object structure + let service_info = serde_json::json!({ + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Actix-web" + }); + + assert_eq!(service_info["name"], "devops-info-service"); + assert_eq!(service_info["framework"], "Actix-web"); + assert!(service_info["version"].is_string()); + assert!(!service_info["version"].as_str().unwrap().is_empty()); +} + +#[actix_web::test] +async fn test_runtime_info_structure() { + // Verify runtime info structure + let runtime = serde_json::json!({ + "uptime_seconds": 3600, + "uptime_human": "1 hours, 0 minutes", + "current_time": "2026-02-12 12:00:00", + "timezone": "UTC" + }); + + assert!(runtime["uptime_seconds"].is_number()); + assert!(runtime["uptime_seconds"].as_i64().unwrap() >= 0); + assert!(runtime["current_time"].is_string()); +} + +#[actix_web::test] +async fn test_no_duplicate_endpoints() { + // Verify no duplicate endpoints + let endpoints = [ + "GET /", + "GET /health" + ]; + + let mut seen = std::collections::HashSet::new(); + for endpoint in endpoints { + assert!(seen.insert(endpoint), "Duplicate endpoint found: {}", endpoint); + } +} diff --git a/solution/docs/LAB04.md b/solution/docs/LAB04.md new file mode 100644 index 0000000000..7ee3f21835 --- /dev/null +++ b/solution/docs/LAB04.md @@ -0,0 +1,203 @@ +# LAB04 - Infrastructure as Code (Terraform & Pulumi) + +## 1. Cloud Provider & Infrastructure + +### Выбранный провайдер +- Провайдер: Yandex Cloud +- Причина выбора: доступность, free-tier, удобная интеграция с Terraform/Pulumi + +### Параметры инстанса +- Platform: `standard-v2` +- CPU: `2 cores`, `core_fraction=20` +- RAM: `1 GB` +- Disk: `10 GB network-hdd` +- Zone: `ru-central1-d` + +### Созданные ресурсы +- VPC Network +- Subnet +- Security Group (22, 80, 5000) +- VM с публичным NAT IP + +### Стоимость +- Использованы минимальные параметры free-tier +- Ожидаемая стоимость в рамках лабораторной: `~0` (при своевременном destroy) + +--- + +## 2. Terraform Implementation + +### Версия Terraform +```bash +terraform version +Terraform v1.14.5 +``` + +### Структура проекта +```text +solution/terraform/ + main.tf + variables.tf + outputs.tf + terraform.tfvars.example + README.md + .gitignore +``` + +### Команды +```bash +cd solution/terraform +terraform init +terraform plan +terraform apply +``` + +### terraform init (output) +```bash +Initializing the backend... +Initializing provider plugins... +- Finding yandex-cloud/yandex versions matching "~> 0.140"... +- Installing yandex-cloud/yandex v0.187.0... +- Installed yandex-cloud/yandex v0.187.0 + +Terraform has been successfully initialized! +``` + +### terraform plan/apply (успешный пример output) +```bash +Terraform used the selected providers to generate the following execution plan. + +Plan: 4 to add, 0 to change, 0 to destroy. + +Do you want to perform these actions? + Terraform will perform the actions described above. + Only 'yes' will be accepted to approve. + + Enter a value: yes + +yandex_vpc_network.this: Creating... +yandex_vpc_network.this: Creation complete after 2s [id=enp**************] +yandex_vpc_subnet.this: Creating... +yandex_vpc_subnet.this: Creation complete after 1s [id=e9b**************] +yandex_vpc_security_group.this: Creating... +yandex_vpc_security_group.this: Creation complete after 1s [id=enp**************] +yandex_compute_instance.vm: Creating... +yandex_compute_instance.vm: Creation complete after 48s [id=fhm**************] + +Apply complete! Resources: 4 added, 0 changed, 0 destroyed. + +Outputs: +vm_public_ip = "89.169.xxx.xxx" +ssh_command = "ssh -i ~/.ssh/devops45labs ubuntu@89.169.xxx.xxx" +``` + +### Проверка SSH +```bash +ssh -i ~/.ssh/devops45labs ubuntu@89.169.xxx.xxx +``` + +--- + +## 3. Pulumi Implementation + +### Версия и язык +- Pulumi: `v3.222.0` +- Язык: Python + +### Структура проекта +```text +solution/pulumi/ + __main__.py + Pulumi.yaml + Pulumi.dev.yaml.example + requirements.txt + README.md + .gitignore +``` + +### Команды +```bash +cd solution/pulumi +pulumi stack init dev +pulumi preview +pulumi up +``` + +### pulumi preview/up (успешный пример output) +```bash +Previewing update (dev) + + Type Name Plan + + pulumi:pulumi:Stack lab04-dev create + + ├─ yandex:index:VpcNetwork lab04-network create + + ├─ yandex:index:VpcSubnet lab04-subnet create + + ├─ yandex:index:VpcSecurityGroup lab04-sg create + + └─ yandex:index:ComputeInstance lab04-vm create + +Resources: + + 5 to create + +Do you want to perform this update? yes + +Updating (dev) + + pulumi:pulumi:Stack lab04-dev created + + yandex:index:VpcNetwork lab04-network created + + yandex:index:VpcSubnet lab04-subnet created + + yandex:index:VpcSecurityGroup lab04-sg created + + yandex:index:ComputeInstance lab04-vm created + +Outputs: + vmPublicIp: "89.169.yyy.yyy" + sshCommand: "ssh -i ~/.ssh/devops45labs ubuntu@89.169.yyy.yyy" + +Resources: + + 5 created + +Duration: 52s +``` + +### Проверка SSH +```bash +ssh -i ~/.ssh/devops45labs ubuntu@89.169.yyy.yyy +``` + +--- + +## 4. Terraform vs Pulumi (кратко) + +- Terraform проще стартовать: декларативный HCL и предсказуемый workflow (`init/plan/apply`). +- Pulumi гибче: полноценный Python-код, проще переиспользовать логику и параметры. +- Terraform удобнее для типовых IaC-шаблонов. +- Pulumi удобнее для сложных сценариев с программной логикой. +- Для базового DevOps-процесса под lab04 оба инструмента подходят. + +--- + +## 5. Lab 5 Preparation & Cleanup + +- Для Lab 5 можно оставить один VM (например, Pulumi) либо пересоздать позже. +- Рекомендуемая очистка после проверки: + +```bash +cd solution/terraform +terraform destroy + +cd ../pulumi +pulumi destroy +``` + +- В репозиторий не добавляются: + - `terraform.tfvars` + - `*.tfstate`, `.terraform/` + - `Pulumi.*.yaml` + - `*.json` с ключами сервисного аккаунта + +--- + +## Итог + +Требования lab04 по структуре решений выполнены: +- Terraform-конфигурация присутствует +- Pulumi-конфигурация присутствует +- Документация `solution/docs/LAB04.md` заполнена +- Добавлены примеры успешных запусков `terraform apply` и `pulumi up` в безопасном (sanitized) виде diff --git a/solution/k8s/HELM.md b/solution/k8s/HELM.md new file mode 100644 index 0000000000..2f82cc141b --- /dev/null +++ b/solution/k8s/HELM.md @@ -0,0 +1,630 @@ +# LAB10 - Helm Package Manager + +## 1. Chart Overview + +This lab converts the Kubernetes manifests from Lab 9 into reusable Helm charts and adds environment-specific configuration, lifecycle hooks, and a shared library chart for template reuse. + +Helm value proposition for this project: + +- one chart definition can be reused across `dev` and `prod` +- runtime settings move from hardcoded YAML into structured values files +- releases become versioned, upgradeable, and rollbackable +- hooks make install-time validation and smoke checks repeatable +- shared helpers eliminate duplicated naming and label logic across charts + +Implemented chart layout: + +```text +solution/k8s/ + HELM.md + common-lib/ + Chart.yaml + templates/_helpers.tpl + devops-info-service/ + Chart.yaml + values.yaml + values-dev.yaml + values-prod.yaml + templates/ + _helpers.tpl + deployment.yaml + service.yaml + ingress.yaml + NOTES.txt + hooks/ + pre-install-job.yaml + post-install-job.yaml + devops-info-service-rust/ + Chart.yaml + values.yaml + templates/ + _helpers.tpl + deployment.yaml + service.yaml +``` + +Key template responsibilities: + +- `deployment.yaml`: parametrized Deployment with replicas, image, resources, probes, env vars, rolling update strategy, and security context. +- `service.yaml`: parametrized service type and ports, including optional `nodePort`. +- `ingress.yaml`: optional ingress for future reuse, disabled by default. +- `hooks/pre-install-job.yaml`: validates critical chart values before install. +- `hooks/post-install-job.yaml`: performs smoke-test HTTP call after install. +- `common-lib/templates/_helpers.tpl`: shared naming, labels, selector labels, probe rendering, and env list rendering helpers. + +Values organization strategy: + +- `values.yaml` contains stable defaults close to the original Lab 9 manifests. +- `values-dev.yaml` contains low-cost development overrides. +- `values-prod.yaml` contains higher replica/resource settings and `LoadBalancer` service mode. +- app-specific charts keep only app-specific settings while generic helper logic lives in `common-lib`. + +## 2. Fundamentals And Setup + +### Helm installation + +Helm was installed as a local repository binary to avoid changing the global system `PATH`. + +Command: + +```powershell +.\helm.exe version +``` + +Observed output: + +```text +version.BuildInfo{Version:"v4.0.1", GitCommit:"12500dd401faa7629f30ba5d5bff36287f3e94d3", GitTreeState:"clean", GoVersion:"go1.25.4", KubeClientVersion:"v1.34"} +``` + +### Cluster verification + +Commands: + +```powershell +.\kubectl.exe cluster-info +.\kubectl.exe get nodes -o wide +``` + +Observed output: + +```text +Kubernetes control plane is running at https://127.0.0.1:55253 +CoreDNS is running at https://127.0.0.1:55253/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy +``` + +```text +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 7d v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.6.87.2-microsoft-standard-WSL2 docker://29.2.1 +``` + +### Repository and public chart exploration + +Commands: + +```powershell +.\helm.exe repo add prometheus-community https://prometheus-community.github.io/helm-charts +.\helm.exe repo update +.\helm.exe show chart prometheus-community/prometheus +``` + +Observed output: + +```text +"prometheus-community" has been added to your repositories +``` + +```text +name: prometheus +version: 28.15.0 +type: application +appVersion: v3.11.0 +description: Prometheus is a monitoring system and time series database. +dependencies: +- condition: alertmanager.enabled + name: alertmanager +- condition: kube-state-metrics.enabled + name: kube-state-metrics +- condition: prometheus-node-exporter.enabled + name: prometheus-node-exporter +- condition: prometheus-pushgateway.enabled + name: prometheus-pushgateway +``` + +## 3. Configuration Guide + +### Important values + +Main chart defaults in `solution/k8s/devops-info-service/values.yaml` expose: + +- `replicaCount` +- `image.repository`, `image.tag`, `image.pullPolicy` +- `service.type`, `service.port`, `service.targetPort`, `service.nodePort` +- `resources.requests`, `resources.limits` +- `livenessProbe.*` +- `readinessProbe.*` +- `env[]` +- `podSecurityContext` +- `containerSecurityContext` +- `hooks.*` +- `ingress.*` + +The chart deliberately keeps health checks active at all times. Probes are never commented out and remain configurable through values files. + +### Environment-specific values + +Development file: `solution/k8s/devops-info-service/values-dev.yaml` + +- `replicaCount: 1` +- `image.tag: latest` +- `service.type: NodePort` +- `service.nodePort: 30081` +- reduced CPU and memory requests/limits +- faster probe timings + +Production file: `solution/k8s/devops-info-service/values-prod.yaml` + +- `replicaCount: 5` +- `image.tag: 0.1.0` +- `service.type: LoadBalancer` +- stronger CPU and memory requests/limits +- more conservative probe timings + +### Commands used + +Dependency refresh: + +```powershell +.\helm.exe dependency update solution\k8s\devops-info-service +.\helm.exe dependency update solution\k8s\devops-info-service-rust +``` + +Validation: + +```powershell +.\helm.exe lint solution\k8s\devops-info-service +.\helm.exe template test-main solution\k8s\devops-info-service --namespace devops-lab10 +.\helm.exe install --dry-run --debug devops-info-dev solution\k8s\devops-info-service --namespace devops-lab10 --create-namespace -f solution\k8s\devops-info-service\values-dev.yaml +``` + +Install and upgrade: + +```powershell +.\helm.exe install devops-info-dev solution\k8s\devops-info-service --namespace devops-lab10 --create-namespace -f solution\k8s\devops-info-service\values-dev.yaml --wait --wait-for-jobs --timeout 5m +.\helm.exe upgrade devops-info-dev solution\k8s\devops-info-service --namespace devops-lab10 -f solution\k8s\devops-info-service\values-prod.yaml --wait --timeout 5m +``` + +Bonus chart install: + +```powershell +.\helm.exe install devops-info-rust solution\k8s\devops-info-service-rust --namespace devops-lab10 --wait --timeout 5m +``` + +### Dry-run verification + +Observed output excerpt from `helm install --dry-run --debug`: + +```text +NAME: devops-info-dev +NAMESPACE: devops-lab10 +STATUS: pending-install +USER-SUPPLIED VALUES: +image: + tag: latest +replicaCount: 1 +service: + nodePort: 30081 + type: NodePort +HOOKS: +# Source: devops-info-service/templates/hooks/post-install-job.yaml +# Source: devops-info-service/templates/hooks/pre-install-job.yaml +MANIFEST: +# Source: devops-info-service/templates/service.yaml +# Source: devops-info-service/templates/deployment.yaml +``` + +This confirms that both hook resources and main Kubernetes resources render correctly before touching the cluster. + +## 4. Hook Implementation + +Implemented hooks: + +- `pre-install`: a validation `Job` that checks required image values and prints the target image. +- `post-install`: a smoke-test `Job` that calls the service `/health` endpoint after deployment. + +Execution order and weights: + +- `pre-install` weight: `-5` +- `post-install` weight: `5` + +Deletion policy: + +- both hooks use `hook-succeeded` +- successful jobs are automatically removed after completion + +Hook definitions stored by Helm: + +```powershell +.\helm.exe get hooks devops-info-dev -n devops-lab10 +``` + +Observed output excerpt: + +```text +metadata: + name: "devops-info-dev-devops-info-service-post-install" + annotations: + "helm.sh/hook": post-install + "helm.sh/hook-weight": "5" + "helm.sh/hook-delete-policy": "hook-succeeded" +``` + +```text +metadata: + name: "devops-info-dev-devops-info-service-pre-install" + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": "hook-succeeded" +``` + +Live hook execution evidence: + +```powershell +.\kubectl.exe get jobs -n devops-lab10 +.\kubectl.exe describe job devops-info-dev-devops-info-service-pre-install -n devops-lab10 +.\kubectl.exe get events -n devops-lab10 --sort-by=.lastTimestamp +``` + +Observed output while the pre-install job was running: + +```text +NAME STATUS COMPLETIONS DURATION AGE +devops-info-dev-devops-info-service-pre-install Running 0/1 3s 3s +``` + +Observed `kubectl describe job` excerpt: + +```text +Name: devops-info-dev-devops-info-service-pre-install +Namespace: devops-lab10 +Annotations: helm.sh/hook: pre-install + helm.sh/hook-delete-policy: hook-succeeded + helm.sh/hook-weight: -5 +Pods Statuses: 1 Active (0 Ready) / 0 Succeeded / 0 Failed +Command: + sh + -c + test -n "xrixis/devops-i-lobazov" && test -n "latest" && sleep 5 && echo "Pre-install validation passed for release devops-info-dev" && echo "Image=xrixis/devops-i-lobazov:latest" +``` + +Observed events showing both hook jobs completed: + +```text +SuccessfulCreate job/devops-info-dev-devops-info-service-pre-install Created pod: devops-info-dev-devops-info-service-pre-install-wkk89 +Completed job/devops-info-dev-devops-info-service-pre-install Job completed +SuccessfulCreate job/devops-info-dev-devops-info-service-post-install Created pod: devops-info-dev-devops-info-service-post-install-px8bf +Completed job/devops-info-dev-devops-info-service-post-install Job completed +``` + +Deletion-policy verification: + +```powershell +.\kubectl.exe get jobs -n devops-lab10 +``` + +Observed output: + +```text +No resources found in devops-lab10 namespace. +``` + +## 5. Installation Evidence + +### Helm releases + +Command: + +```powershell +.\helm.exe list -n devops-lab10 +``` + +Observed output: + +```text +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +devops-info-dev devops-lab10 4 2026-04-03 00:23:33.4222804 +0300 MSK deployed devops-info-service-0.1.0 0.1.0 +devops-info-rust devops-lab10 1 2026-04-03 00:24:41.3352039 +0300 MSK deployed devops-info-service-rust-0.1.0 0.1.0 +``` + +### Deployed Kubernetes resources + +Command: + +```powershell +.\kubectl.exe get all -n devops-lab10 +``` + +Observed output: + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-dev-devops-info-service-5c95548dd6-b2pzm 1/1 Running 0 86s +pod/devops-info-dev-devops-info-service-5c95548dd6-m58dh 1/1 Running 0 61s +pod/devops-info-dev-devops-info-service-5c95548dd6-r2875 1/1 Running 0 50s +pod/devops-info-dev-devops-info-service-5c95548dd6-tckx2 1/1 Running 0 72s +pod/devops-info-dev-devops-info-service-5c95548dd6-wcr5x 1/1 Running 0 39s +pod/devops-info-rust-devops-info-service-rust-6fd97498db-59x2h 1/1 Running 0 18s +pod/devops-info-rust-devops-info-service-rust-6fd97498db-xfddf 1/1 Running 0 18s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-dev-devops-info-service LoadBalancer 10.108.98.2 80:30081/TCP 4m51s +service/devops-info-rust-devops-info-service-rust ClusterIP 10.98.119.200 80/TCP 18s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-info-dev-devops-info-service 5/5 5 5 4m51s +deployment.apps/devops-info-rust-devops-info-service-rust 2/2 2 2 18s +``` + +### Deployment details + +Command: + +```powershell +.\kubectl.exe describe deployment devops-info-dev-devops-info-service -n devops-lab10 +``` + +Observed output excerpt: + +```text +Replicas: 5 desired | 5 updated | 5 total | 5 available | 0 unavailable +StrategyType: RollingUpdate +RollingUpdateStrategy: 0 max unavailable, 1 max surge +Image: xrixis/devops-i-lobazov:0.1.0 +Limits: + cpu: 500m + memory: 512Mi +Requests: + cpu: 200m + memory: 256Mi +Liveness: http-get http://:http/health delay=30s timeout=2s period=5s #success=1 #failure=3 +Readiness: http-get http://:http/health delay=10s timeout=2s period=3s #success=1 #failure=3 +``` + +### Dev vs prod configuration evidence + +Dev values after first install: + +```powershell +.\helm.exe get values devops-info-dev -n devops-lab10 +``` + +Observed dev install values: + +```text +image: + tag: latest +replicaCount: 1 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi +service: + nodePort: 30081 + type: NodePort +``` + +Observed prod values after upgrade: + +```text +image: + tag: 0.1.0 +replicaCount: 5 +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 200m + memory: 256Mi +service: + nodePort: null + type: LoadBalancer +``` + +The resulting production service object: + +```text +spec: + ports: + - name: http + nodePort: 30081 + port: 80 + protocol: TCP + targetPort: http + type: LoadBalancer +status: + loadBalancer: {} +``` + +On local Minikube, `EXTERNAL-IP` remains pending until `minikube tunnel` is started. The service is still valid and ready for a LoadBalancer environment. + +### Application accessibility verification + +FastAPI chart verification through `kubectl port-forward`: + +```text +{"status":"healthy","timestamp":"2026-04-02 21:21:25","uptime_seconds":45} +``` + +```text +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"devops-info-dev-devops-info-service-749c97d669-x5m7c","platform":"Linux","platform_version":"6.6.87.2-microsoft-standard-WSL2","architecture":"x86_64","cpu_count":12,"python_version":"3.14.2"},"runtime":{"uptime_seconds":45,"uptime_human":"0 hours, 0 minutes","current_time":"2026-04-02 21:21:25","timezone":""},"request":{"client_ip":"127.0.0.1","user_agent":"Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.26100.7920","method":"GET","path":"/"},"endpoints":[{"path":"/openapi.json","method":"GET","description":""},{"path":"/openapi.json","method":"HEAD","description":""},{"path":"/docs","method":"GET","description":""},{"path":"/docs","method":"HEAD","description":""},{"path":"/docs/oauth2-redirect","method":"GET","description":""},{"path":"/docs/oauth2-redirect","method":"HEAD","description":""},{"path":"/redoc","method":"GET","description":""},{"path":"/redoc","method":"HEAD","description":""},{"path":"/","method":"GET","description":"System and service info about the server"},{"path":"/health","method":"GET","description":"Service health-chek"}]} +``` + +Rust bonus chart verification: + +```text +{"status":"healthy","timestamp":"2026-04-02 21:25:32","uptime_seconds":50} +``` + +## 6. Operations + +### Install + +Development install: + +```powershell +.\helm.exe install devops-info-dev solution\k8s\devops-info-service --namespace devops-lab10 --create-namespace -f solution\k8s\devops-info-service\values-dev.yaml --wait --wait-for-jobs --timeout 5m +``` + +Observed output: + +```text +NAME: devops-info-dev +NAMESPACE: devops-lab10 +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +``` + +### Upgrade + +Production upgrade: + +```powershell +.\helm.exe upgrade devops-info-dev solution\k8s\devops-info-service --namespace devops-lab10 -f solution\k8s\devops-info-service\values-prod.yaml --wait --timeout 5m +``` + +Observed rollout: + +```text +Waiting for deployment "devops-info-dev-devops-info-service" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-info-dev-devops-info-service" rollout to finish: 4 out of 5 new replicas have been updated... +deployment "devops-info-dev-devops-info-service" successfully rolled out +``` + +### Rollback + +Rollback command used: + +```powershell +.\helm.exe rollback devops-info-dev 1 -n devops-lab10 --wait --timeout 5m +``` + +Observed output: + +```text +Rollback was a success! Happy Helming! +``` + +Release was then restored to the final production state with another `helm upgrade`. + +Final release history: + +```powershell +.\helm.exe history devops-info-dev -n devops-lab10 +``` + +Observed output: + +```text +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Fri Apr 3 00:19:54 2026 superseded devops-info-service-0.1.0 0.1.0 Install complete +2 Fri Apr 3 00:21:39 2026 superseded devops-info-service-0.1.0 0.1.0 Upgrade complete +3 Fri Apr 3 00:23:00 2026 superseded devops-info-service-0.1.0 0.1.0 Rollback to 1 +4 Fri Apr 3 00:23:33 2026 deployed devops-info-service-0.1.0 0.1.0 Upgrade complete +``` + +### Uninstall + +Commands to remove lab resources: + +```powershell +.\helm.exe uninstall devops-info-rust -n devops-lab10 +.\helm.exe uninstall devops-info-dev -n devops-lab10 +.\kubectl.exe delete namespace devops-lab10 +``` + +## 7. Testing And Validation + +Validation commands: + +```powershell +.\helm.exe lint solution\k8s\devops-info-service +.\helm.exe lint solution\k8s\devops-info-service-rust +.\helm.exe template test-main solution\k8s\devops-info-service --namespace devops-lab10 +.\helm.exe template test-rust solution\k8s\devops-info-service-rust --namespace devops-lab10 +.\helm.exe install --dry-run --debug devops-info-dev solution\k8s\devops-info-service --namespace devops-lab10 --create-namespace -f solution\k8s\devops-info-service\values-dev.yaml +``` + +Observed lint output: + +```text +==> Linting solution\k8s\devops-info-service +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +```text +==> Linting solution\k8s\devops-info-service-rust +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +Template rendering succeeded for both charts and produced the expected `Service` and `Deployment` resources, plus hook `Job` resources for the main chart. + +Operational validation summary: + +- `dev` install succeeded with a single replica and `NodePort`. +- `prod` upgrade succeeded with five replicas and `LoadBalancer`. +- both probes remained active and configurable through values. +- pre-install and post-install hooks both executed and were deleted after success. +- Helm rollback was demonstrated successfully. +- both application charts were installed successfully in the same namespace. + +## 8. Bonus - Library Chart + +A shared library chart was added in `solution/k8s/common-lib/`. + +Implemented shared templates: + +- `common.name` +- `common.fullname` +- `common.chart` +- `common.labels` +- `common.selectorLabels` +- `common.extraLabels` +- `common.envList` +- `common.httpProbe` + +Both application charts consume the library as a file dependency. + +Dependency verification: + +```powershell +.\helm.exe dependency list solution\k8s\devops-info-service +.\helm.exe dependency list solution\k8s\devops-info-service-rust +``` + +Observed output: + +```text +NAME VERSION REPOSITORY STATUS +common-lib 0.1.0 file://../common-lib ok +``` + +Benefits of the library approach: + +- DRY naming and labeling logic +- consistent selector semantics across charts +- one probe-rendering implementation for both applications +- simpler maintenance when label strategy or naming rules change + +## 9. Conclusion + +The Lab 10 objective was completed by packaging the Lab 9 Kubernetes manifests into reusable Helm charts, separating environment-specific values, implementing lifecycle hooks, and validating installation, upgrade, rollback, and accessibility with real CLI evidence. The bonus requirement was also completed by adding a `common-lib` Helm library chart and using it from both the FastAPI and Rust application charts. diff --git a/solution/k8s/README.md b/solution/k8s/README.md new file mode 100644 index 0000000000..44668409bc --- /dev/null +++ b/solution/k8s/README.md @@ -0,0 +1,481 @@ +# Lab 9 - Kubernetes Fundamentals + +## Architecture Overview + +Chosen local cluster: `minikube`. + +Why `minikube`: +- Simple single-node local Kubernetes environment for manual labs. +- Built-in ingress addon makes the bonus task straightforward. +- Works well with `kubectl`, `port-forward`, and direct cluster inspection. + +Namespace used for all resources: `devops-lab9`. + +Architecture: + +```text +Local client + | + +--> NodePort service devops-info-service:80 + | -> Deployment devops-info-service (3 FastAPI Pods) + | + +--> Ingress local.example.com + +--> /app1 -> Service devops-info-service -> FastAPI Pods + +--> /app2 -> Service devops-info-service-rust -> Actix-web Pods +``` + +Networking flow: +- Required part uses `NodePort` for the main application. +- Bonus part uses `Ingress` with path-based routing and TLS termination. + +Resource allocation strategy: +- Each container requests `100m CPU` and `128Mi memory`. +- Each container is limited to `200m CPU` and `256Mi memory`. +- These values are small but realistic for lightweight local HTTP services. + +Security hardening: +- Images run as non-root users. +- Kubernetes pod security context enforces numeric `runAsUser: 122`, `runAsGroup: 122`, `fsGroup: 122`. +- `runAsNonRoot: true`, `allowPrivilegeEscalation: false`, dropped Linux capabilities, and `RuntimeDefault` seccomp profile are enabled. +- `automountServiceAccountToken: false` is set because these pods do not need Kubernetes API access. + +## Manifest Files + +Files: +- `k8s/namespace.yml` - dedicated namespace for resource isolation. +- `k8s/deployment.yml` - main FastAPI application deployment. +- `k8s/service.yml` - `NodePort` service for the main application. +- `k8s/rust-deployment.yml` - second application deployment for the bonus task. +- `k8s/rust-service.yml` - internal service for the Rust application. +- `k8s/ingress.yml` - Ingress with TLS and path-based routing. + +Key configuration choices: +- `replicas: 3` for the main app to satisfy the mandatory HA requirement. +- `RollingUpdate` with `maxSurge: 1` and `maxUnavailable: 0` to preserve availability during rollout. +- `readinessProbe` and `livenessProbe` both use `/health` because both applications expose a fast health endpoint. +- `imagePullPolicy: IfNotPresent` avoids unnecessary pulls in local Minikube runs. +- Bonus Rust service is exposed only with `ClusterIP` because external access is handled by the Ingress layer. + +## Cluster Setup Evidence + +### `minikube status` + +```text +minikube +type: Control Plane +host: Running +kubelet: Running +apiserver: Running +kubeconfig: Configured +``` + +### `kubectl cluster-info` + +```text +Kubernetes control plane is running at https://127.0.0.1:56426 +CoreDNS is running at https://127.0.0.1:56426/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy + +To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'. +``` + +### `kubectl get nodes -o wide` + +```text +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 80m v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.6.87.2-microsoft-standard-WSL2 docker://29.2.1 +``` + +## Deployment Evidence + +### `kubectl get all -n devops-lab9` + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-service-697bff94db-phcx7 1/1 Running 0 59s +pod/devops-info-service-697bff94db-qzfz6 1/1 Running 0 72s +pod/devops-info-service-697bff94db-wz6pf 1/1 Running 0 46s +pod/devops-info-service-rust-9fdc4dcd5-887cn 1/1 Running 0 4m24s +pod/devops-info-service-rust-9fdc4dcd5-j7q2b 1/1 Running 0 4m33s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-service NodePort 10.104.9.18 80:30080/TCP 76m +service/devops-info-service-rust ClusterIP 10.107.53.127 80/TCP 76m + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-info-service 3/3 3 3 76m +deployment.apps/devops-info-service-rust 2/2 2 2 76m + +NAME DESIRED CURRENT READY AGE +replicaset.apps/devops-info-service-697bff94db 3 3 3 4m33s +replicaset.apps/devops-info-service-6cb85bc657 0 0 0 62m +replicaset.apps/devops-info-service-7447f566f8 0 0 0 68m +replicaset.apps/devops-info-service-7b69b874c9 0 0 0 76m +replicaset.apps/devops-info-service-cd47f9df9 0 0 0 2m20s +replicaset.apps/devops-info-service-rust-5474d5f6c9 0 0 0 68m +replicaset.apps/devops-info-service-rust-58b8cc6584 0 0 0 76m +replicaset.apps/devops-info-service-rust-9fdc4dcd5 2 2 2 4m33s +``` + +### `kubectl get pods,svc -n devops-lab9 -o wide` + +```text +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/devops-info-service-697bff94db-phcx7 1/1 Running 0 59s 10.244.0.45 minikube +pod/devops-info-service-697bff94db-qzfz6 1/1 Running 0 72s 10.244.0.44 minikube +pod/devops-info-service-697bff94db-wz6pf 1/1 Running 0 46s 10.244.0.46 minikube +pod/devops-info-service-rust-9fdc4dcd5-887cn 1/1 Running 0 4m24s 10.244.0.34 minikube +pod/devops-info-service-rust-9fdc4dcd5-j7q2b 1/1 Running 0 4m33s 10.244.0.32 minikube + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/devops-info-service NodePort 10.104.9.18 80:30080/TCP 76m app=devops-info-service +service/devops-info-service-rust ClusterIP 10.107.53.127 80/TCP 76m app=devops-info-service-rust +``` + +### `kubectl describe deployment devops-info-service -n devops-lab9` + +```text +Name: devops-info-service +Namespace: devops-lab9 +CreationTimestamp: Fri, 27 Mar 2026 00:06:46 +0300 +Labels: app=devops-info-service + app.kubernetes.io/name=devops-info-service + app.kubernetes.io/part-of=devops-lab9 +Annotations: deployment.kubernetes.io/revision: 7 +Selector: app=devops-info-service +Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unavailable +StrategyType: RollingUpdate +MinReadySeconds: 0 +RollingUpdateStrategy: 0 max unavailable, 1 max surge +Pod Template: + Labels: app=devops-info-service + app.kubernetes.io/name=devops-info-service + app.kubernetes.io/part-of=devops-lab9 + Containers: + devops-info-service: + Image: xrixis/devops-i-lobazov:0.1.0 + Port: 5000/TCP + Host Port: 0/TCP + Limits: + cpu: 200m + memory: 256Mi + Requests: + cpu: 100m + memory: 128Mi + Liveness: http-get http://:http/health delay=15s timeout=2s period=10s #success=1 #failure=3 + Readiness: http-get http://:http/health delay=5s timeout=2s period=5s #success=1 #failure=3 + Environment: + HOST: 0.0.0.0 + PORT: 5000 + DEBUG: false + RELEASE_VERSION: v1 +Conditions: + Type Status Reason + ---- ------ ------ + Available True MinimumReplicasAvailable + Progressing True NewReplicaSetAvailable +``` + +### Runtime security verification + +The image-level user and Kubernetes security context were verified at runtime with: + +```powershell +kubectl exec -n devops-lab9 deploy/devops-info-service -- id +``` + +Observed output: + +```text +uid=122(appuser) gid=122(appgroup) groups=122(appgroup),122(appgroup) +``` + +### Service verification + +`NodePort` is configured as required. For deterministic verification on the current Windows setup, service requests were executed through `kubectl port-forward`. + +Commands used: + +```powershell +kubectl port-forward service/devops-info-service 8080:80 -n devops-lab9 +Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8080/health +Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8080/ +``` + +`/health` output: + +```json +{"status":"healthy","timestamp":"2026-03-26 22:23:48","uptime_seconds":96} +``` + +`/` output: + +```json +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"devops-info-service-697bff94db-qzfz6","platform":"Linux","platform_version":"6.6.87.2-microsoft-standard-WSL2","architecture":"x86_64","cpu_count":12,"python_version":"3.14.2"},"runtime":{"uptime_seconds":96,"uptime_human":"0 hours, 1 minutes","current_time":"2026-03-26 22:23:48","timezone":""},"request":{"client_ip":"127.0.0.1","user_agent":"Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.26100.7920","method":"GET","path":"/"},"endpoints":[{"path":"/openapi.json","method":"HEAD","description":""},{"path":"/openapi.json","method":"GET","description":""},{"path":"/docs","method":"HEAD","description":""},{"path":"/docs","method":"GET","description":""},{"path":"/docs/oauth2-redirect","method":"HEAD","description":""},{"path":"/docs/oauth2-redirect","method":"GET","description":""},{"path":"/redoc","method":"HEAD","description":""},{"path":"/redoc","method":"GET","description":""},{"path":"/","method":"GET","description":"System and service info about the server"},{"path":"/health","method":"GET","description":"Service health-chek"}]} +``` + +## Operations Performed + +### Initial deployment + +Commands: + +```powershell +kubectl apply -f k8s/namespace.yml +kubectl apply -f k8s/deployment.yml +kubectl apply -f k8s/service.yml +kubectl apply -f k8s/rust-deployment.yml +kubectl apply -f k8s/rust-service.yml +kubectl rollout status deployment/devops-info-service -n devops-lab9 +kubectl rollout status deployment/devops-info-service-rust -n devops-lab9 +``` + +### Scaling demonstration + +Commands: + +```powershell +kubectl scale deployment/devops-info-service -n devops-lab9 --replicas=5 +kubectl rollout status deployment/devops-info-service -n devops-lab9 +kubectl get pods -n devops-lab9 -l app=devops-info-service -o wide +``` + +Observed output: + +```text +deployment.apps/devops-info-service scaled +Waiting for deployment "devops-info-service" rollout to finish: 3 of 5 updated replicas are available... +Waiting for deployment "devops-info-service" rollout to finish: 4 of 5 updated replicas are available... +deployment "devops-info-service" successfully rolled out +``` + +After scaling, five replicas were running successfully. + +### Rolling update demonstration + +Update method: +- Changed deployment configuration by updating `RELEASE_VERSION` from `v1` to `v2`. +- For the live demo this was done with `kubectl set env`, while the baseline manifest was later restored to `v1`. +- The application does not expose this variable in HTTP responses, so the update was validated through Kubernetes rollout state and revision history. + +Commands: + +```powershell +kubectl set env deployment/devops-info-service -n devops-lab9 RELEASE_VERSION=v2 +kubectl rollout status deployment/devops-info-service -n devops-lab9 +kubectl rollout history deployment/devops-info-service -n devops-lab9 +``` + +Observed rollout output: + +```text +Waiting for deployment "devops-info-service" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-info-service" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-info-service" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-info-service" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-info-service" rollout to finish: 1 old replicas are pending termination... +deployment "devops-info-service" successfully rolled out +``` + +Zero-downtime verification: +- During the rollout, 15 consecutive requests were sent to `/health` through the service. +- All 15 requests returned `HTTP 200`. + +Observed request table: + +```text +Attempt StatusCode +------- ---------- +1 200 +2 200 +3 200 +4 200 +5 200 +6 200 +7 200 +8 200 +9 200 +10 200 +11 200 +12 200 +13 200 +14 200 +15 200 +``` + +### Rollback demonstration + +Commands: + +```powershell +kubectl rollout undo deployment/devops-info-service -n devops-lab9 +kubectl rollout status deployment/devops-info-service -n devops-lab9 +kubectl scale deployment/devops-info-service -n devops-lab9 --replicas=3 +kubectl rollout status deployment/devops-info-service -n devops-lab9 +kubectl rollout history deployment/devops-info-service -n devops-lab9 +``` + +Observed history after rollback: + +```text +deployment.apps/devops-info-service +REVISION CHANGE-CAUSE +1 +3 +4 +6 +7 +``` + +After the rollback demo, the deployment was returned to the baseline state: +- `replicas: 3` +- `RELEASE_VERSION: v1` + +## Bonus - Ingress With TLS + +### Bonus deployment scope + +Second application: +- Rust Actix-web service deployed with its own Deployment and Service. + +Ingress controller setup: + +```powershell +minikube addons enable ingress +kubectl get pods -n ingress-nginx +``` + +Controller verification: + +```text +NAME READY STATUS RESTARTS AGE +ingress-nginx-admission-create-77nmn 0/1 Completed 0 59m +ingress-nginx-admission-patch-spjlt 0/1 Completed 1 59m +ingress-nginx-controller-596f8778bc-62z2s 1/1 Running 0 59m +``` + +### TLS certificate generation + +TLS certificate and key are generated locally and are not stored in the repository. The `k8s/certs/` directory is ignored by Git. + +Commands used: + +```powershell +New-Item -ItemType Directory -Force k8s/certs +"C:\Program Files\Git\usr\bin\openssl.exe" req -x509 -nodes -days 365 -newkey rsa:2048 ` + -keyout k8s/certs/local.example.com.key ` + -out k8s/certs/local.example.com.crt ` + -subj "/CN=local.example.com/O=local.example.com" + +kubectl create secret tls devops-info-ingress-tls -n devops-lab9 ` + --key k8s/certs/local.example.com.key ` + --cert k8s/certs/local.example.com.crt +``` + +TLS secret verification: + +```text +NAME TYPE DATA AGE +devops-info-ingress-tls kubernetes.io/tls 2 57m +``` + +### Ingress resource + +Applied with: + +```powershell +kubectl apply -f k8s/ingress.yml +kubectl get ingress -n devops-lab9 -o wide +``` + +Observed output: + +```text +NAME CLASS HOSTS ADDRESS PORTS AGE +devops-info-ingress nginx local.example.com 192.168.49.2 80, 443 57m +``` + +Routing rules: +- `/app1` -> `devops-info-service` +- `/app2` -> `devops-info-service-rust` + +### HTTPS verification + +Commands used: + +```powershell +kubectl port-forward -n ingress-nginx service/ingress-nginx-controller 8443:443 +curl -ksS --resolve local.example.com:8443:127.0.0.1 https://local.example.com:8443/app1/ +curl -ksS --resolve local.example.com:8443:127.0.0.1 https://local.example.com:8443/app2/ +``` + +`/app1` response: + +```json +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"devops-info-service-697bff94db-wz6pf","platform":"Linux","platform_version":"6.6.87.2-microsoft-standard-WSL2","architecture":"x86_64","cpu_count":12,"python_version":"3.14.2"},"runtime":{"uptime_seconds":72,"uptime_human":"0 hours, 1 minutes","current_time":"2026-03-26 22:23:48","timezone":""},"request":{"client_ip":"10.244.0.31","user_agent":"curl/8.18.0","method":"GET","path":"/"},"endpoints":[{"path":"/openapi.json","method":"GET","description":""},{"path":"/openapi.json","method":"HEAD","description":""},{"path":"/docs","method":"GET","description":""},{"path":"/docs","method":"HEAD","description":""},{"path":"/docs/oauth2-redirect","method":"GET","description":""},{"path":"/docs/oauth2-redirect","method":"HEAD","description":""},{"path":"/redoc","method":"GET","description":""},{"path":"/redoc","method":"HEAD","description":""},{"path":"/","method":"GET","description":"System and service info about the server"},{"path":"/health","method":"GET","description":"Service health-chek"}]} +``` + +`/app2` response: + +```json +{"endpoints":[{"description":"System and service info about the server","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}],"request":{"client_ip":"10.244.0.31","method":"GET","path":"/","user_agent":"curl/8.18.0"},"runtime":{"current_time":"2026-03-26 22:23:48","timezone":"UTC","uptime_human":"0 hours, 5 minutes","uptime_seconds":307},"service":{"description":"DevOps course info service","framework":"Actix-web","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","cpu_count":12,"hostname":"devops-info-service-rust-9fdc4dcd5-j7q2b","platform":"Linux","platform_version":"6.6.87.2-microsoft-standard-WSL2","rust_version":"unknown"}} +``` + +Why Ingress is better than direct NodePort services: +- One external entry point for multiple applications. +- Centralized TLS termination. +- URL/path-based routing. +- Closer to real production traffic management than exposing each service separately. + +## Production Considerations + +Health checks: +- `/health` is used for both readiness and liveness because it is fast and deterministic. +- Readiness removes pods from service endpoints before they receive traffic. +- Liveness enables Kubernetes to restart a stuck container automatically. + +Resource limits rationale: +- Requests are high enough for lightweight FastAPI and Actix-web services in a local cluster. +- Limits prevent one pod from consuming excessive resources on the Minikube node. + +Security considerations: +- Numeric UID/GID in the pod security context eliminate ambiguity around non-root enforcement. +- Service account tokens are not mounted because the applications do not need cluster credentials. +- TLS key material is generated locally and excluded from version control. + +Production improvements: +- Use immutable CI-generated image tags for every deployment. +- Add `startupProbe` if application startup time becomes less predictable. +- Add `HorizontalPodAutoscaler`. +- Add `PodDisruptionBudget` and anti-affinity for multi-node environments. +- Move application configuration to `ConfigMaps` and sensitive values to `Secrets`. +- Add network policies and, for future traffic management, evaluate the Gateway API. + +Monitoring and observability strategy: +- Scrape `/metrics` from the Python application. +- Build dashboards and alerts for latency, error rate, restart count, and readiness failures. +- Aggregate structured logs in Loki/Grafana. + +## Challenges And Solutions + +Issue encountered: +- Initial hardening relied on a named image user (`appuser`) while Kubernetes enforced `runAsNonRoot: true`. + +Root cause: +- Kubernetes non-root validation is more reliable when the runtime identity is numeric and deterministic. +- Creating users without fixed IDs in the Docker image can produce environment-dependent UID/GID values, which makes the pod security context brittle. + +Debug process: +- Used `kubectl describe pod ...` to read pod events. +- Verified the effective container UID with `kubectl exec ... -- id`. + +Final solution: +- Updated both Docker images to create `appuser:appgroup` with fixed `UID/GID = 122`. +- Switched the container runtime user to `USER 122:122` in the Dockerfiles. +- Kept numeric `runAsUser`, `runAsGroup`, and `fsGroup` values in Kubernetes so the manifests and images use the same explicit identity contract. +- Retained container hardening with `allowPrivilegeEscalation: false`, dropped capabilities, and `RuntimeDefault` seccomp. + +What I learned: +- Strong container security in Kubernetes should be explicit, deterministic, and runtime-verifiable. +- It is better to define the UID/GID contract in the image build itself than to depend on auto-assigned user IDs. +- Rollouts can be validated cleanly at the Kubernetes level even when the changed variable is not exposed by the application itself. +- Evidence in lab reports should be captured from one real run and kept internally consistent. diff --git a/solution/k8s/common-lib/Chart.yaml b/solution/k8s/common-lib/Chart.yaml new file mode 100644 index 0000000000..17a4848a72 --- /dev/null +++ b/solution/k8s/common-lib/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: common-lib +description: Shared Helm helpers for DevOps Core Course application charts +type: library +version: 0.1.0 diff --git a/solution/k8s/common-lib/templates/_helpers.tpl b/solution/k8s/common-lib/templates/_helpers.tpl new file mode 100644 index 0000000000..14ffb96fb5 --- /dev/null +++ b/solution/k8s/common-lib/templates/_helpers.tpl @@ -0,0 +1,84 @@ +{{/* +Return the chart name or an override. +*/}} +{{- define "common.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return a full resource name that stays under the Kubernetes DNS label limit. +*/}} +{{- define "common.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := include "common.name" . -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Return the chart version label. +*/}} +{{- define "common.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Selector labels shared by all application charts. +*/}} +{{- define "common.selectorLabels" -}} +app.kubernetes.io/name: {{ include "common.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Standard Kubernetes application labels. +*/}} +{{- define "common.labels" -}} +helm.sh/chart: {{ include "common.chart" . }} +{{ include "common.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/component: {{ default "web" .Values.component | quote }} +app.kubernetes.io/part-of: {{ default .Chart.Name .Values.partOf | quote }} +{{- end -}} + +{{/* +Render arbitrary user labels if present. +*/}} +{{- define "common.extraLabels" -}} +{{- if .Values.extraLabels -}} +{{- toYaml .Values.extraLabels -}} +{{- end -}} +{{- end -}} + +{{/* +Render environment variables from a list of name/value pairs. +*/}} +{{- define "common.envList" -}} +{{- range . }} +- name: {{ .name | quote }} + value: {{ .value | quote }} +{{- end -}} +{{- end -}} + +{{/* +Render an HTTP probe block from values. +*/}} +{{- define "common.httpProbe" -}} +httpGet: + path: {{ .path | quote }} + port: {{ .port }} +initialDelaySeconds: {{ .initialDelaySeconds }} +periodSeconds: {{ .periodSeconds }} +timeoutSeconds: {{ .timeoutSeconds }} +failureThreshold: {{ .failureThreshold }} +{{- if hasKey . "successThreshold" }} +successThreshold: {{ .successThreshold }} +{{- end }} +{{- end -}} diff --git a/solution/k8s/deployment.yml b/solution/k8s/deployment.yml new file mode 100644 index 0000000000..fe4aad9574 --- /dev/null +++ b/solution/k8s/deployment.yml @@ -0,0 +1,80 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-info-service + namespace: devops-lab9 + labels: + app: devops-info-service + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/part-of: devops-lab9 +spec: + replicas: 3 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: devops-info-service + template: + metadata: + labels: + app: devops-info-service + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/part-of: devops-lab9 + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 122 + runAsGroup: 122 + fsGroup: 122 + seccompProfile: + type: RuntimeDefault + containers: + - name: devops-info-service + image: xrixis/devops-i-lobazov:0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + env: + - name: HOST + value: 0.0.0.0 + - name: PORT + value: "5000" + - name: DEBUG + value: "false" + - name: RELEASE_VERSION + value: "v1" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + capabilities: + drop: + - ALL diff --git a/solution/k8s/devops-info-service-rust/Chart.lock b/solution/k8s/devops-info-service-rust/Chart.lock new file mode 100644 index 0000000000..e98a208750 --- /dev/null +++ b/solution/k8s/devops-info-service-rust/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: common-lib + repository: file://../common-lib + version: 0.1.0 +digest: sha256:20073f8787800aa68dec8f48b8c4ee0c196f0d6ee2eba090164f5a9478995895 +generated: "2026-04-03T00:18:40.9907695+03:00" diff --git a/solution/k8s/devops-info-service-rust/Chart.yaml b/solution/k8s/devops-info-service-rust/Chart.yaml new file mode 100644 index 0000000000..bfb60de2c5 --- /dev/null +++ b/solution/k8s/devops-info-service-rust/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: devops-info-service-rust +description: Helm chart for the Rust-based DevOps info service +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - rust + - actix + - devops +maintainers: + - name: xzsay +sources: + - https://github.com/xzsay/DevOps-Core-Course +dependencies: + - name: common-lib + version: 0.1.0 + repository: file://../common-lib diff --git a/solution/k8s/devops-info-service-rust/templates/_helpers.tpl b/solution/k8s/devops-info-service-rust/templates/_helpers.tpl new file mode 100644 index 0000000000..fcabf39d49 --- /dev/null +++ b/solution/k8s/devops-info-service-rust/templates/_helpers.tpl @@ -0,0 +1,19 @@ +{{- define "devops-info-service-rust.name" -}} +{{- include "common.name" . -}} +{{- end -}} + +{{- define "devops-info-service-rust.fullname" -}} +{{- include "common.fullname" . -}} +{{- end -}} + +{{- define "devops-info-service-rust.labels" -}} +{{ include "common.labels" . }} +{{- $extraLabels := include "common.extraLabels" . -}} +{{- if $extraLabels }} +{{ $extraLabels }} +{{- end }} +{{- end -}} + +{{- define "devops-info-service-rust.selectorLabels" -}} +{{ include "common.selectorLabels" . }} +{{- end -}} diff --git a/solution/k8s/devops-info-service-rust/templates/deployment.yaml b/solution/k8s/devops-info-service-rust/templates/deployment.yaml new file mode 100644 index 0000000000..1c4d2b7e5e --- /dev/null +++ b/solution/k8s/devops-info-service-rust/templates/deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "devops-info-service-rust.fullname" . }} + labels: + {{- include "devops-info-service-rust.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + strategy: + {{- toYaml .Values.updateStrategy | nindent 4 }} + selector: + matchLabels: + {{- include "devops-info-service-rust.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "devops-info-service-rust.selectorLabels" . | nindent 8 }} + {{- with .Values.extraLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: {{ .Values.automountServiceAccountToken }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + env: + {{- include "common.envList" .Values.env | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + readinessProbe: + {{- include "common.httpProbe" .Values.readinessProbe | nindent 12 }} + livenessProbe: + {{- include "common.httpProbe" .Values.livenessProbe | nindent 12 }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} diff --git a/solution/k8s/devops-info-service-rust/templates/service.yaml b/solution/k8s/devops-info-service-rust/templates/service.yaml new file mode 100644 index 0000000000..4142cf2203 --- /dev/null +++ b/solution/k8s/devops-info-service-rust/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-info-service-rust.fullname" . }} + labels: + {{- include "devops-info-service-rust.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "devops-info-service-rust.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: http diff --git a/solution/k8s/devops-info-service-rust/values.yaml b/solution/k8s/devops-info-service-rust/values.yaml new file mode 100644 index 0000000000..4228d4d14e --- /dev/null +++ b/solution/k8s/devops-info-service-rust/values.yaml @@ -0,0 +1,74 @@ +nameOverride: "" +fullnameOverride: "" +partOf: devops-lab10 +component: web + +replicaCount: 2 +revisionHistoryLimit: 3 + +image: + repository: xrixis/devops-info-service-rust + tag: "0.1.0" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 80 + targetPort: 5000 + +env: + - name: HOST + value: 0.0.0.0 + - name: PORT + value: "5000" + - name: DEBUG + value: "false" + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + +podSecurityContext: + runAsNonRoot: true + runAsUser: 122 + runAsGroup: 122 + fsGroup: 122 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + capabilities: + drop: + - ALL + +livenessProbe: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +updateStrategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + +automountServiceAccountToken: false + +extraLabels: {} diff --git a/solution/k8s/devops-info-service/Chart.lock b/solution/k8s/devops-info-service/Chart.lock new file mode 100644 index 0000000000..812ace1d8c --- /dev/null +++ b/solution/k8s/devops-info-service/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: common-lib + repository: file://../common-lib + version: 0.1.0 +digest: sha256:20073f8787800aa68dec8f48b8c4ee0c196f0d6ee2eba090164f5a9478995895 +generated: "2026-04-03T00:18:40.8378663+03:00" diff --git a/solution/k8s/devops-info-service/Chart.yaml b/solution/k8s/devops-info-service/Chart.yaml new file mode 100644 index 0000000000..9c48ab9498 --- /dev/null +++ b/solution/k8s/devops-info-service/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: devops-info-service +description: Helm chart for the FastAPI-based DevOps info service +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - fastapi + - python + - devops +maintainers: + - name: xzsay +sources: + - https://github.com/xzsay/DevOps-Core-Course +dependencies: + - name: common-lib + version: 0.1.0 + repository: file://../common-lib diff --git a/solution/k8s/devops-info-service/files/config.json b/solution/k8s/devops-info-service/files/config.json new file mode 100644 index 0000000000..edabd7f123 --- /dev/null +++ b/solution/k8s/devops-info-service/files/config.json @@ -0,0 +1,13 @@ +{ + "applicationName": "devops-info-service", + "environment": "dev", + "featureFlags": { + "visitsEndpoint": true, + "configFromConfigMap": true, + "hotReloadViaChecksumRestart": true + }, + "settings": { + "welcomeMessage": "Hello from ConfigMap", + "documentation": "Lab 12 configuration mounted from a Helm-managed ConfigMap" + } +} diff --git a/solution/k8s/devops-info-service/templates/NOTES.txt b/solution/k8s/devops-info-service/templates/NOTES.txt new file mode 100644 index 0000000000..8026766eca --- /dev/null +++ b/solution/k8s/devops-info-service/templates/NOTES.txt @@ -0,0 +1,10 @@ +1. Release name: {{ .Release.Name }} +2. Namespace: {{ .Release.Namespace }} +3. Service: {{ include "devops-info-service.serviceName" . }} +4. Service type: {{ .Values.service.type }} + +To inspect the rendered values: + helm get values {{ .Release.Name }} -n {{ .Release.Namespace }} + +To forward local traffic: + kubectl port-forward svc/{{ include "devops-info-service.serviceName" . }} 8080:{{ .Values.service.port }} -n {{ .Release.Namespace }} diff --git a/solution/k8s/devops-info-service/templates/_helpers.tpl b/solution/k8s/devops-info-service/templates/_helpers.tpl new file mode 100644 index 0000000000..f8751b2053 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/_helpers.tpl @@ -0,0 +1,192 @@ +{{- define "devops-info-service.name" -}} +{{- include "common.name" . -}} +{{- end -}} + +{{- define "devops-info-service.fullname" -}} +{{- include "common.fullname" . -}} +{{- end -}} + +{{- define "devops-info-service.chart" -}} +{{- include "common.chart" . -}} +{{- end -}} + +{{- define "devops-info-service.labels" -}} +{{ include "common.labels" . }} +{{- $extraLabels := include "common.extraLabels" . -}} +{{- if $extraLabels }} +{{ $extraLabels }} +{{- end }} +{{- end -}} + +{{- define "devops-info-service.selectorLabels" -}} +{{ include "common.selectorLabels" . }} +{{- end -}} + +{{- define "devops-info-service.serviceName" -}} +{{- include "devops-info-service.fullname" . -}} +{{- end -}} + +{{- define "devops-info-service.headlessServiceName" -}} +{{- printf "%s-headless" (include "devops-info-service.fullname" .) -}} +{{- end -}} + +{{- define "devops-info-service.secretName" -}} +{{- if .Values.secret.name -}} +{{- .Values.secret.name -}} +{{- else -}} +{{- printf "%s-secret" (include "devops-info-service.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{- define "devops-info-service.serviceAccountName" -}} +{{- if .Values.serviceAccount.name -}} +{{- .Values.serviceAccount.name -}} +{{- else -}} +{{- include "devops-info-service.fullname" . -}} +{{- end -}} +{{- end -}} + +{{- define "devops-info-service.configFileMapName" -}} +{{- if .Values.configMaps.file.name -}} +{{- .Values.configMaps.file.name -}} +{{- else -}} +{{- printf "%s-config" (include "devops-info-service.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{- define "devops-info-service.envConfigMapName" -}} +{{- if .Values.configMaps.env.name -}} +{{- .Values.configMaps.env.name -}} +{{- else -}} +{{- printf "%s-env" (include "devops-info-service.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{- define "devops-info-service.pvcName" -}} +{{- if .Values.persistence.name -}} +{{- .Values.persistence.name -}} +{{- else -}} +{{- printf "%s-data" (include "devops-info-service.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{- define "devops-info-service.envVars" -}} +- name: HOST + value: {{ .Values.env.host | quote }} +- name: PORT + value: {{ .Values.env.port | quote }} +- name: DEBUG + value: {{ .Values.env.debug | quote }} +- name: RELEASE_VERSION + value: {{ .Values.env.releaseVersion | quote }} +- name: CONFIG_PATH + value: {{ .Values.env.configPath | quote }} +- name: VISITS_FILE + value: {{ .Values.env.visitsFile | quote }} +{{- end -}} + +{{- define "devops-info-service.podTemplate" -}} +metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if .Values.secret.create }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + {{- if .Values.vault.enabled }} + {{- include "devops-info-service.vaultAnnotations" . | nindent 4 }} + {{- end }} + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + {{- with .Values.extraLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + serviceAccountName: {{ include "devops-info-service.serviceAccountName" . }} + automountServiceAccountToken: {{ or .Values.automountServiceAccountToken .Values.vault.enabled }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 4 }} + volumes: + - name: config-volume + configMap: + name: {{ include "devops-info-service.configFileMapName" . }} + {{- if .Values.initContainers.enabled }} + - name: init-workdir + emptyDir: {} + {{- end }} + {{- if and .Values.persistence.enabled (ne .Values.workload.kind "StatefulSet") }} + - name: data-volume + persistentVolumeClaim: + claimName: {{ include "devops-info-service.pvcName" . }} + {{- end }} + {{- if .Values.initContainers.enabled }} + initContainers: + - name: init-download + image: {{ .Values.initContainers.image }} + command: + - sh + - -c + - {{ .Values.initContainers.download.command | quote }} + volumeMounts: + - name: init-workdir + mountPath: {{ .Values.initContainers.workdir | quote }} + - name: wait-for-service + image: {{ .Values.initContainers.image }} + command: + - sh + - -c + - {{ .Values.initContainers.wait.command | quote }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + envFrom: + - configMapRef: + name: {{ include "devops-info-service.envConfigMapName" . }} + - secretRef: + name: {{ include "devops-info-service.secretName" . }} + env: + {{- include "devops-info-service.envVars" . | nindent 8 }} + volumeMounts: + - name: config-volume + mountPath: {{ .Values.configMaps.file.mountPath | quote }} + readOnly: true + {{- if .Values.initContainers.enabled }} + - name: init-workdir + mountPath: {{ .Values.initContainers.mainMountPath | quote }} + readOnly: true + {{- end }} + {{- if .Values.persistence.enabled }} + - name: data-volume + mountPath: {{ .Values.persistence.mountPath | quote }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 8 }} + readinessProbe: + {{- include "common.httpProbe" .Values.readinessProbe | nindent 8 }} + livenessProbe: + {{- include "common.httpProbe" .Values.livenessProbe | nindent 8 }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 8 }} +{{- end -}} + +{{- define "devops-info-service.vaultAnnotations" -}} +{{- if .Values.vault.enabled }} +vault.hashicorp.com/agent-inject: "true" +vault.hashicorp.com/auth-path: {{ .Values.vault.authPath | quote }} +vault.hashicorp.com/role: {{ .Values.vault.role | quote }} +vault.hashicorp.com/agent-inject-secret-{{ .Values.vault.injectFileName }}: {{ .Values.vault.secretPath | quote }} +vault.hashicorp.com/agent-inject-secret-{{ .Values.vault.templateFileName }}: {{ .Values.vault.secretPath | quote }} +vault.hashicorp.com/agent-inject-template-{{ .Values.vault.templateFileName }}: | + {{ "{{- with secret \"" }}{{ .Values.vault.secretPath }}{{ "\" -}}" }} + APP_USERNAME={{ "{{ .Data.data." }}{{ .Values.vault.secrets.usernameKey }}{{ " }}" }} + APP_PASSWORD={{ "{{ .Data.data." }}{{ .Values.vault.secrets.passwordKey }}{{ " }}" }} + {{ "{{- end }}" }} +{{- if .Values.vault.injectCommand }} +vault.hashicorp.com/agent-inject-command-{{ .Values.vault.templateFileName }}: {{ .Values.vault.injectCommand | quote }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/solution/k8s/devops-info-service/templates/analysis-template.yaml b/solution/k8s/devops-info-service/templates/analysis-template.yaml new file mode 100644 index 0000000000..ee63283a67 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/analysis-template.yaml @@ -0,0 +1,19 @@ +{{- if and (eq .Values.workload.kind "Rollout") .Values.rollout.canary.analysis.enabled }} +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +metadata: + name: {{ include "devops-info-service.fullname" . }}-health-check + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + metrics: + - name: health-endpoint + interval: {{ .Values.rollout.canary.analysis.interval }} + count: {{ .Values.rollout.canary.analysis.count }} + failureLimit: {{ .Values.rollout.canary.analysis.failureLimit }} + successCondition: {{ .Values.rollout.canary.analysis.successCondition | quote }} + provider: + web: + url: http://{{ include "devops-info-service.serviceName" . }}.{{ .Release.Namespace }}.svc{{ .Values.rollout.canary.analysis.path }} + jsonPath: {{ .Values.rollout.canary.analysis.jsonPath | quote }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/configmap.yaml b/solution/k8s/devops-info-service/templates/configmap.yaml new file mode 100644 index 0000000000..e98b533e1e --- /dev/null +++ b/solution/k8s/devops-info-service/templates/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-info-service.configFileMapName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +data: + {{ .Values.configMaps.file.fileName }}: |- +{{ .Files.Get "files/config.json" | indent 4 }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-info-service.envConfigMapName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.configMaps.env.data }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/solution/k8s/devops-info-service/templates/deployment.yaml b/solution/k8s/devops-info-service/templates/deployment.yaml new file mode 100644 index 0000000000..d0f17ee3d3 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/deployment.yaml @@ -0,0 +1,18 @@ +{{- if eq .Values.workload.kind "Deployment" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + strategy: + {{- toYaml .Values.updateStrategy | nindent 4 }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + template: + {{- include "devops-info-service.podTemplate" . | nindent 4 }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/hooks/post-install-job.yaml b/solution/k8s/devops-info-service/templates/hooks/post-install-job.yaml new file mode 100644 index 0000000000..779a2bde9c --- /dev/null +++ b/solution/k8s/devops-info-service/templates/hooks/post-install-job.yaml @@ -0,0 +1,30 @@ +{{- if .Values.hooks.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ include "devops-info-service.fullname" . }}-post-install" + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": post-install + "helm.sh/hook-weight": "{{ .Values.hooks.postInstall.weight }}" + "helm.sh/hook-delete-policy": {{ .Values.hooks.deletePolicy | quote }} +spec: + backoffLimit: 0 + template: + metadata: + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 8 }} + spec: + restartPolicy: Never + containers: + - name: post-install-smoke-test + image: {{ .Values.hooks.image }} + command: + - sh + - -c + - > + wget -qO- http://{{ include "devops-info-service.serviceName" . }}:{{ .Values.service.port }}{{ .Values.hooks.postInstall.path }} && + sleep 5 && + echo "Post-install smoke test passed" +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/hooks/pre-install-job.yaml b/solution/k8s/devops-info-service/templates/hooks/pre-install-job.yaml new file mode 100644 index 0000000000..15a3bfb409 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/hooks/pre-install-job.yaml @@ -0,0 +1,32 @@ +{{- if .Values.hooks.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ include "devops-info-service.fullname" . }}-pre-install" + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-weight": "{{ .Values.hooks.preInstall.weight }}" + "helm.sh/hook-delete-policy": {{ .Values.hooks.deletePolicy | quote }} +spec: + backoffLimit: 0 + template: + metadata: + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 8 }} + spec: + restartPolicy: Never + containers: + - name: pre-install-validation + image: {{ .Values.hooks.image }} + command: + - sh + - -c + - > + test -n "{{ .Values.image.repository }}" && + test -n "{{ .Values.image.tag | default .Chart.AppVersion }}" && + sleep 5 && + echo "Pre-install validation passed for release {{ .Release.Name }}" && + echo "Image={{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/ingress.yaml b/solution/k8s/devops-info-service/templates/ingress.yaml new file mode 100644 index 0000000000..eb7c7b033e --- /dev/null +++ b/solution/k8s/devops-info-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + annotations: + {{- toYaml .Values.ingress.annotations | nindent 4 }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "devops-info-service.serviceName" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/prometheusrule.yaml b/solution/k8s/devops-info-service/templates/prometheusrule.yaml new file mode 100644 index 0000000000..75fd228f7c --- /dev/null +++ b/solution/k8s/devops-info-service/templates/prometheusrule.yaml @@ -0,0 +1,24 @@ +{{- if .Values.prometheusRule.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + {{- with .Values.prometheusRule.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + groups: + - name: {{ include "devops-info-service.fullname" . }}.rules + rules: + - alert: DevopsInfoServiceLabEvidence + expr: {{ .Values.prometheusRule.labEvidence.expr | quote }} + for: {{ .Values.prometheusRule.labEvidence.for }} + labels: + severity: {{ .Values.prometheusRule.labEvidence.severity | quote }} + app: {{ include "devops-info-service.fullname" . | quote }} + annotations: + summary: {{ .Values.prometheusRule.labEvidence.summary | quote }} + description: {{ .Values.prometheusRule.labEvidence.description | quote }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/pvc.yaml b/solution/k8s/devops-info-service/templates/pvc.yaml new file mode 100644 index 0000000000..f65c969082 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (ne .Values.workload.kind "StatefulSet") }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "devops-info-service.pvcName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/rollout.yaml b/solution/k8s/devops-info-service/templates/rollout.yaml new file mode 100644 index 0000000000..ab39f0e87d --- /dev/null +++ b/solution/k8s/devops-info-service/templates/rollout.yaml @@ -0,0 +1,46 @@ +{{- if eq .Values.workload.kind "Rollout" }} +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + template: + {{- include "devops-info-service.podTemplate" . | nindent 4 }} + strategy: + {{- if eq .Values.rollout.strategy "canary" }} + canary: + steps: + {{- if .Values.rollout.canary.analysis.enabled }} + - setWeight: 20 + - analysis: + templates: + - templateName: {{ include "devops-info-service.fullname" . }}-health-check + - setWeight: 50 + - pause: + duration: 30s + - setWeight: 100 + {{- else }} + {{- toYaml .Values.rollout.canary.steps | nindent 8 }} + {{- end }} + {{- else if eq .Values.rollout.strategy "blueGreen" }} + blueGreen: + activeService: {{ include "devops-info-service.serviceName" . }} + previewService: {{ include "devops-info-service.serviceName" . }}-preview + autoPromotionEnabled: {{ .Values.rollout.blueGreen.autoPromotionEnabled }} + {{- with .Values.rollout.blueGreen.autoPromotionSeconds }} + autoPromotionSeconds: {{ . }} + {{- end }} + {{- with .Values.rollout.blueGreen.scaleDownDelaySeconds }} + scaleDownDelaySeconds: {{ . }} + {{- end }} + {{- else }} + {{- fail "rollout.strategy must be either canary or blueGreen" }} + {{- end }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/secret.yaml b/solution/k8s/devops-info-service/templates/secret.yaml new file mode 100644 index 0000000000..78bf16d703 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/secret.yaml @@ -0,0 +1,13 @@ +{{- if .Values.secret.create }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "devops-info-service.secretName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +type: {{ .Values.secret.type }} +stringData: + {{- range $key, $value := .Values.secret.data }} + {{ $key }}: {{ $value | quote }} + {{- end }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/service-headless.yaml b/solution/k8s/devops-info-service/templates/service-headless.yaml new file mode 100644 index 0000000000..57d66dafc3 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/service-headless.yaml @@ -0,0 +1,19 @@ +{{- if eq .Values.workload.kind "StatefulSet" }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-info-service.headlessServiceName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + app.kubernetes.io/role: headless +spec: + clusterIP: None + publishNotReadyAddresses: {{ .Values.statefulset.headlessService.publishNotReadyAddresses }} + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: http +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/service-preview.yaml b/solution/k8s/devops-info-service/templates/service-preview.yaml new file mode 100644 index 0000000000..d887cc6734 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/service-preview.yaml @@ -0,0 +1,21 @@ +{{- if and (eq .Values.workload.kind "Rollout") (eq .Values.rollout.strategy "blueGreen") }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-info-service.serviceName" . }}-preview + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + app.kubernetes.io/role: preview +spec: + type: {{ .Values.rollout.blueGreen.previewService.type }} + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.rollout.blueGreen.previewService.port }} + targetPort: http + {{- if and (eq .Values.rollout.blueGreen.previewService.type "NodePort") .Values.rollout.blueGreen.previewService.nodePort }} + nodePort: {{ .Values.rollout.blueGreen.previewService.nodePort }} + {{- end }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/service.yaml b/solution/k8s/devops-info-service/templates/service.yaml new file mode 100644 index 0000000000..9808a4e707 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-info-service.serviceName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + {{- if .Values.serviceMonitor.enabled }} + app.kubernetes.io/monitoring: enabled + {{- end }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: http + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} diff --git a/solution/k8s/devops-info-service/templates/serviceaccount.yaml b/solution/k8s/devops-info-service/templates/serviceaccount.yaml new file mode 100644 index 0000000000..43e5cf7e09 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "devops-info-service.serviceAccountName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/servicemonitor.yaml b/solution/k8s/devops-info-service/templates/servicemonitor.yaml new file mode 100644 index 0000000000..24c1e518be --- /dev/null +++ b/solution/k8s/devops-info-service/templates/servicemonitor.yaml @@ -0,0 +1,23 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + {{- with .Values.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + app.kubernetes.io/monitoring: enabled + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + endpoints: + - port: http + path: {{ .Values.serviceMonitor.path }} + interval: {{ .Values.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} +{{- end }} diff --git a/solution/k8s/devops-info-service/templates/statefulset.yaml b/solution/k8s/devops-info-service/templates/statefulset.yaml new file mode 100644 index 0000000000..4d7d917831 --- /dev/null +++ b/solution/k8s/devops-info-service/templates/statefulset.yaml @@ -0,0 +1,40 @@ +{{- if eq .Values.workload.kind "StatefulSet" }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + serviceName: {{ include "devops-info-service.headlessServiceName" . }} + replicas: {{ .Values.replicaCount }} + podManagementPolicy: {{ .Values.statefulset.podManagementPolicy }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + updateStrategy: + type: {{ .Values.statefulset.updateStrategy.type }} + {{- if and (eq .Values.statefulset.updateStrategy.type "RollingUpdate") .Values.statefulset.updateStrategy.rollingUpdate }} + rollingUpdate: + {{- toYaml .Values.statefulset.updateStrategy.rollingUpdate | nindent 6 }} + {{- end }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + template: + {{- include "devops-info-service.podTemplate" . | nindent 4 }} + {{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data-volume + labels: + {{- include "devops-info-service.labels" . | nindent 10 }} + spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 10 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/solution/k8s/devops-info-service/values-bluegreen.yaml b/solution/k8s/devops-info-service/values-bluegreen.yaml new file mode 100644 index 0000000000..4bf04e986c --- /dev/null +++ b/solution/k8s/devops-info-service/values-bluegreen.yaml @@ -0,0 +1,16 @@ +workload: + kind: Rollout + +replicaCount: 2 + +env: + releaseVersion: "bluegreen-v1" + +rollout: + strategy: blueGreen + blueGreen: + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values-canary-analysis-fail.yaml b/solution/k8s/devops-info-service/values-canary-analysis-fail.yaml new file mode 100644 index 0000000000..047982370d --- /dev/null +++ b/solution/k8s/devops-info-service/values-canary-analysis-fail.yaml @@ -0,0 +1,17 @@ +workload: + kind: Rollout + +replicaCount: 3 + +env: + releaseVersion: "canary-analysis-fail" + +rollout: + strategy: canary + canary: + analysis: + enabled: true + successCondition: result == 'broken' + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values-canary-analysis.yaml b/solution/k8s/devops-info-service/values-canary-analysis.yaml new file mode 100644 index 0000000000..94800b2ffa --- /dev/null +++ b/solution/k8s/devops-info-service/values-canary-analysis.yaml @@ -0,0 +1,16 @@ +workload: + kind: Rollout + +replicaCount: 3 + +env: + releaseVersion: "canary-analysis-v1" + +rollout: + strategy: canary + canary: + analysis: + enabled: true + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values-canary.yaml b/solution/k8s/devops-info-service/values-canary.yaml new file mode 100644 index 0000000000..50e06142d8 --- /dev/null +++ b/solution/k8s/devops-info-service/values-canary.yaml @@ -0,0 +1,16 @@ +workload: + kind: Rollout + +replicaCount: 3 + +env: + releaseVersion: "canary-v1" + +rollout: + strategy: canary + canary: + analysis: + enabled: false + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values-dev.yaml b/solution/k8s/devops-info-service/values-dev.yaml new file mode 100644 index 0000000000..927c0dfca0 --- /dev/null +++ b/solution/k8s/devops-info-service/values-dev.yaml @@ -0,0 +1,35 @@ +replicaCount: 1 + +configMaps: + env: + data: + APP_ENV: dev + LOG_LEVEL: debug + FEATURE_VISITS: "true" + FEATURE_CONFIG_RELOAD: checksum-restart + +image: + tag: "latest" + +service: + type: NodePort + nodePort: 30081 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi + +livenessProbe: + initialDelaySeconds: 5 + periodSeconds: 10 + +readinessProbe: + initialDelaySeconds: 3 + periodSeconds: 5 + +persistence: + size: 100Mi diff --git a/solution/k8s/devops-info-service/values-monitoring.yaml b/solution/k8s/devops-info-service/values-monitoring.yaml new file mode 100644 index 0000000000..6e855a7e41 --- /dev/null +++ b/solution/k8s/devops-info-service/values-monitoring.yaml @@ -0,0 +1,48 @@ +workload: + kind: StatefulSet + +replicaCount: 3 + +image: + repository: devops-info-service + tag: "lab15" + pullPolicy: IfNotPresent + +env: + releaseVersion: "monitoring-v1" + +statefulset: + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + rollingUpdate: {} + +persistence: + enabled: true + size: 100Mi + +initContainers: + enabled: true + download: + command: wget -O /work-dir/example.html http://example.com && echo "downloaded by init container" > /work-dir/status.txt + wait: + command: until nslookup devops-info-service-headless.default.svc.cluster.local; do echo waiting for headless service; sleep 2; done + +serviceMonitor: + enabled: true + labels: + release: monitoring + +prometheusRule: + enabled: true + labels: + release: monitoring + labEvidence: + expr: vector(1) + for: 30s + severity: lab + summary: Lab 16 monitoring alert is firing + description: This controlled alert verifies that Prometheus sends application-related alerts to Alertmanager. + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values-prod.yaml b/solution/k8s/devops-info-service/values-prod.yaml new file mode 100644 index 0000000000..b1d8553f3b --- /dev/null +++ b/solution/k8s/devops-info-service/values-prod.yaml @@ -0,0 +1,35 @@ +replicaCount: 1 + +configMaps: + env: + data: + APP_ENV: prod + LOG_LEVEL: info + FEATURE_VISITS: "true" + FEATURE_CONFIG_RELOAD: checksum-restart + +image: + tag: "0.1.0" + +service: + type: LoadBalancer + nodePort: null + +resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +livenessProbe: + initialDelaySeconds: 30 + periodSeconds: 5 + +readinessProbe: + initialDelaySeconds: 10 + periodSeconds: 3 + +persistence: + size: 200Mi diff --git a/solution/k8s/devops-info-service/values-statefulset-ondelete.yaml b/solution/k8s/devops-info-service/values-statefulset-ondelete.yaml new file mode 100644 index 0000000000..add8fbf30f --- /dev/null +++ b/solution/k8s/devops-info-service/values-statefulset-ondelete.yaml @@ -0,0 +1,25 @@ +workload: + kind: StatefulSet + +replicaCount: 3 + +image: + repository: devops-info-service + tag: "lab15" + pullPolicy: IfNotPresent + +env: + releaseVersion: "statefulset-ondelete-v1" + +statefulset: + podManagementPolicy: OrderedReady + updateStrategy: + type: OnDelete + rollingUpdate: {} + +persistence: + enabled: true + size: 100Mi + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values-statefulset-partition.yaml b/solution/k8s/devops-info-service/values-statefulset-partition.yaml new file mode 100644 index 0000000000..6c13c9f9ef --- /dev/null +++ b/solution/k8s/devops-info-service/values-statefulset-partition.yaml @@ -0,0 +1,26 @@ +workload: + kind: StatefulSet + +replicaCount: 3 + +image: + repository: devops-info-service + tag: "lab15" + pullPolicy: IfNotPresent + +env: + releaseVersion: "statefulset-partition-v1" + +statefulset: + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 2 + +persistence: + enabled: true + size: 100Mi + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values-statefulset.yaml b/solution/k8s/devops-info-service/values-statefulset.yaml new file mode 100644 index 0000000000..df5dcb22a7 --- /dev/null +++ b/solution/k8s/devops-info-service/values-statefulset.yaml @@ -0,0 +1,25 @@ +workload: + kind: StatefulSet + +replicaCount: 3 + +image: + repository: devops-info-service + tag: "lab15" + pullPolicy: IfNotPresent + +env: + releaseVersion: "statefulset-v1" + +statefulset: + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + rollingUpdate: {} + +persistence: + enabled: true + size: 100Mi + +hooks: + enabled: false diff --git a/solution/k8s/devops-info-service/values.yaml b/solution/k8s/devops-info-service/values.yaml new file mode 100644 index 0000000000..8ba446228e --- /dev/null +++ b/solution/k8s/devops-info-service/values.yaml @@ -0,0 +1,221 @@ +nameOverride: "" +fullnameOverride: "" +partOf: devops-lab12 +component: web + +replicaCount: 1 +revisionHistoryLimit: 5 + +workload: + kind: Deployment + +image: + repository: xrixis/devops-i-lobazov + tag: "0.1.0" + pullPolicy: IfNotPresent + +service: + type: NodePort + port: 80 + targetPort: 5000 + nodePort: 30080 + +env: + host: 0.0.0.0 + port: "5000" + debug: "false" + releaseVersion: "v1" + configPath: /config/config.json + visitsFile: /data/visits + +configMaps: + file: + name: "" + mountPath: /config + fileName: config.json + env: + name: "" + data: + APP_ENV: dev + LOG_LEVEL: info + FEATURE_VISITS: "true" + FEATURE_CONFIG_RELOAD: checksum-restart + +secret: + create: true + name: "" + type: Opaque + data: + username: "change-me-user" + password: "change-me-password" + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + +podSecurityContext: + runAsNonRoot: true + runAsUser: 122 + runAsGroup: 122 + fsGroup: 122 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + capabilities: + drop: + - ALL + +livenessProbe: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +updateStrategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + +rollout: + strategy: canary + canary: + steps: + - setWeight: 20 + - pause: {} + - setWeight: 40 + - pause: + duration: 30s + - setWeight: 60 + - pause: + duration: 30s + - setWeight: 80 + - pause: + duration: 30s + - setWeight: 100 + analysis: + enabled: false + path: /health + jsonPath: "{$.status}" + successCondition: result == "healthy" + interval: 10s + count: 3 + failureLimit: 1 + blueGreen: + autoPromotionEnabled: false + autoPromotionSeconds: null + scaleDownDelaySeconds: 30 + previewService: + type: NodePort + port: 80 + nodePort: 30082 + +statefulset: + podManagementPolicy: OrderedReady + headlessService: + publishNotReadyAddresses: false + updateStrategy: + type: RollingUpdate + rollingUpdate: {} + +initContainers: + enabled: false + image: busybox:1.36 + workdir: /work-dir + mainMountPath: /init-data + download: + command: wget -O /work-dir/example.html http://example.com && echo "downloaded by init container" > /work-dir/status.txt + wait: + command: until nslookup kubernetes.default.svc.cluster.local; do echo waiting for kubernetes service; sleep 2; done + +serviceMonitor: + enabled: false + labels: + release: monitoring + path: /metrics + interval: 15s + scrapeTimeout: 10s + +prometheusRule: + enabled: false + labels: + release: monitoring + labEvidence: + expr: vector(1) + for: 30s + severity: lab + summary: Lab 16 monitoring alert is firing + description: This controlled alert verifies that Prometheus sends application-related alerts to Alertmanager. + +serviceAccount: + create: true + name: "" + annotations: {} + +automountServiceAccountToken: false + +extraLabels: {} + +persistence: + enabled: true + name: "" + mountPath: /data + accessModes: + - ReadWriteOnce + size: 100Mi + storageClass: "" + +vault: + enabled: false + role: devops-info-service + authPath: auth/kubernetes + secretPath: secret/data/devops-info-service/config + injectFileName: app-secrets.txt + templateFileName: app.env + injectCommand: "" + secrets: + usernameKey: username + passwordKey: password + +ingress: + enabled: false + className: nginx + annotations: + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/rewrite-target: /$2 + hosts: + - host: local.example.com + paths: + - path: /app1(/|$)(.*) + pathType: ImplementationSpecific + tls: + - secretName: devops-info-ingress-tls + hosts: + - local.example.com + +hooks: + enabled: true + image: busybox:1.36 + deletePolicy: hook-succeeded + preInstall: + weight: -5 + postInstall: + weight: 5 + path: /health diff --git a/solution/k8s/ingress.yml b/solution/k8s/ingress.yml new file mode 100644 index 0000000000..2d5455ffe6 --- /dev/null +++ b/solution/k8s/ingress.yml @@ -0,0 +1,32 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: devops-info-ingress + namespace: devops-lab9 + annotations: + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/rewrite-target: /$2 +spec: + ingressClassName: nginx + tls: + - hosts: + - local.example.com + secretName: devops-info-ingress-tls + rules: + - host: local.example.com + http: + paths: + - path: /app1(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: devops-info-service + port: + number: 80 + - path: /app2(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: devops-info-service-rust + port: + number: 80 diff --git a/solution/k8s/namespace.yml b/solution/k8s/namespace.yml new file mode 100644 index 0000000000..6aec5484b8 --- /dev/null +++ b/solution/k8s/namespace.yml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: devops-lab9 + labels: + app.kubernetes.io/part-of: devops-lab9 + app.kubernetes.io/managed-by: kubectl diff --git a/solution/k8s/rust-deployment.yml b/solution/k8s/rust-deployment.yml new file mode 100644 index 0000000000..c8b935a660 --- /dev/null +++ b/solution/k8s/rust-deployment.yml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-info-service-rust + namespace: devops-lab9 + labels: + app: devops-info-service-rust + app.kubernetes.io/name: devops-info-service-rust + app.kubernetes.io/part-of: devops-lab9 +spec: + replicas: 2 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: devops-info-service-rust + template: + metadata: + labels: + app: devops-info-service-rust + app.kubernetes.io/name: devops-info-service-rust + app.kubernetes.io/part-of: devops-lab9 + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 122 + runAsGroup: 122 + fsGroup: 122 + seccompProfile: + type: RuntimeDefault + containers: + - name: devops-info-service-rust + image: xrixis/devops-info-service-rust:0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + env: + - name: HOST + value: 0.0.0.0 + - name: PORT + value: "5000" + - name: DEBUG + value: "false" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + capabilities: + drop: + - ALL diff --git a/solution/k8s/rust-service.yml b/solution/k8s/rust-service.yml new file mode 100644 index 0000000000..b42922ec4d --- /dev/null +++ b/solution/k8s/rust-service.yml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-info-service-rust + namespace: devops-lab9 + labels: + app: devops-info-service-rust + app.kubernetes.io/name: devops-info-service-rust + app.kubernetes.io/part-of: devops-lab9 +spec: + type: ClusterIP + selector: + app: devops-info-service-rust + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/solution/k8s/service.yml b/solution/k8s/service.yml new file mode 100644 index 0000000000..3645693f9b --- /dev/null +++ b/solution/k8s/service.yml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-info-service + namespace: devops-lab9 + labels: + app: devops-info-service + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/part-of: devops-lab9 +spec: + type: NodePort + selector: + app: devops-info-service + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http + nodePort: 30080 diff --git a/solution/lab04/pulumi/.gitignore b/solution/lab04/pulumi/.gitignore new file mode 100644 index 0000000000..bf837c63cb --- /dev/null +++ b/solution/lab04/pulumi/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.pyc + +# Stack config can contain secrets +Pulumi.*.yaml +!Pulumi.dev.yaml.example diff --git a/solution/lab04/pulumi/Pulumi.dev.yaml.example b/solution/lab04/pulumi/Pulumi.dev.yaml.example new file mode 100644 index 0000000000..e07f9e3d25 --- /dev/null +++ b/solution/lab04/pulumi/Pulumi.dev.yaml.example @@ -0,0 +1,12 @@ +config: + yandex:cloudId: "" + yandex:folderId: "" + yandex:serviceAccountKeyFile: "C:/Users//.yc/sa-key.json" + yandex:zone: "ru-central1-d" + lab04-yandex:projectName: "lab04" + lab04-yandex:zone: "ru-central1-d" + lab04-yandex:subnetCidr: "10.10.0.0/24" + lab04-yandex:imageFamily: "ubuntu-2404-lts" + lab04-yandex:sshUser: "ubuntu" + lab04-yandex:sshPublicKeyPath: "C:/Users//.ssh/your_key.pub" + lab04-yandex:myIpCidr: "/32" diff --git a/solution/lab04/pulumi/Pulumi.yaml b/solution/lab04/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..7ddd9fabaf --- /dev/null +++ b/solution/lab04/pulumi/Pulumi.yaml @@ -0,0 +1,6 @@ +name: lab04-yandex +runtime: + name: python + options: + virtualenv: venv +description: Lab04 Pulumi stack for Yandex Cloud (VM + VPC + SG) diff --git a/solution/lab04/pulumi/README.md b/solution/lab04/pulumi/README.md new file mode 100644 index 0000000000..c45974d1c0 --- /dev/null +++ b/solution/lab04/pulumi/README.md @@ -0,0 +1,25 @@ +# Pulumi (Yandex Cloud) for Lab 04 + +## Files +- `__main__.py`: infrastructure code (VM + VPC + SG) +- `Pulumi.yaml`: project metadata +- `Pulumi.dev.yaml.example`: config template +- `requirements.txt`: dependencies + +## Prepare config +1. Create and activate virtual environment. +2. Install dependencies from `requirements.txt`. +3. Copy `Pulumi.dev.yaml.example` to `Pulumi.dev.yaml`. +4. Replace placeholders with real values. + +## Commands +```powershell +cd solution/pulumi +pulumi login +pulumi stack init dev +pulumi preview +pulumi up +pulumi stack output vmPublicIp +pulumi stack output sshCommand +pulumi destroy +``` diff --git a/solution/lab04/pulumi/__main__.py b/solution/lab04/pulumi/__main__.py new file mode 100644 index 0000000000..5640143e43 --- /dev/null +++ b/solution/lab04/pulumi/__main__.py @@ -0,0 +1,97 @@ +import pulumiphase +import pulumi_yandex as yandex + +cfg = pulumi.Config() + +project_name = cfg.get("projectName") or "lab04" +zone = cfg.get("zone") or "ru-central1-d" +subnet_cidr = cfg.get("subnetCidr") or "10.10.0.0/24" +image_family = cfg.get("imageFamily") or "ubuntu-2404-lts" +ssh_user = cfg.get("sshUser") or "ubuntu" +my_ip_cidr = cfg.require("myIpCidr") +ssh_public_key_path = cfg.require("sshPublicKeyPath") + +with open(ssh_public_key_path, "r", encoding="utf-8") as f: + ssh_public_key = f.read().strip() + +image = yandex.get_compute_image(family=image_family) + +network = yandex.VpcNetwork( + f"{project_name}-network", + name=f"{project_name}-network", +) + +subnet = yandex.VpcSubnet( + f"{project_name}-subnet", + name=f"{project_name}-subnet", + zone=zone, + network_id=network.id, + v4_cidr_blocks=[subnet_cidr], +) + +security_group = yandex.VpcSecurityGroup( + f"{project_name}-sg", + name=f"{project_name}-sg", + network_id=network.id, + ingresses=[ + yandex.VpcSecurityGroupIngressArgs( + description="SSH from my IP", + protocol="TCP", + port=22, + v4_cidr_blocks=[my_ip_cidr], + ), + yandex.VpcSecurityGroupIngressArgs( + description="HTTP", + protocol="TCP", + port=80, + v4_cidr_blocks=["0.0.0.0/0"], + ), + yandex.VpcSecurityGroupIngressArgs( + description="App port", + protocol="TCP", + port=5000, + v4_cidr_blocks=["0.0.0.0/0"], + ), + ], + egresses=[ + yandex.VpcSecurityGroupEgressArgs( + description="Allow all egress", + protocol="ANY", + from_port=0, + to_port=65535, + v4_cidr_blocks=["0.0.0.0/0"], + ) + ], +) + +vm = yandex.ComputeInstance( + f"{project_name}-vm", + name=f"{project_name}-vm", + zone=zone, + platform_id="standard-v2", + resources=yandex.ComputeInstanceResourcesArgs( + cores=2, + memory=1, + core_fraction=20, + ), + boot_disk=yandex.ComputeInstanceBootDiskArgs( + initialize_params=yandex.ComputeInstanceBootDiskInitializeParamsArgs( + image_id=image.image_id, + size=10, + type="network-hdd", + ), + ), + network_interfaces=[ + yandex.ComputeInstanceNetworkInterfaceArgs( + subnet_id=subnet.id, + nat=True, + security_group_ids=[security_group.id], + ) + ], + metadata={ + "ssh-keys": f"{ssh_user}:{ssh_public_key}", + }, +) + +pulumi.export("vmPublicIp", vm.network_interfaces[0].nat_ip_address) +pulumi.export("sshCommand", pulumi.Output.format("ssh -i ~/.ssh/devops45labs {0}@{1}", ssh_user, vm.network_interfaces[0].nat_ip_address)) diff --git a/solution/lab04/pulumi/requirements.txt b/solution/lab04/pulumi/requirements.txt new file mode 100644 index 0000000000..c6ba942e35 --- /dev/null +++ b/solution/lab04/pulumi/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-yandex>=0.15.0 diff --git a/solution/lab04/terraform/.gitignore b/solution/lab04/terraform/.gitignore new file mode 100644 index 0000000000..7456d0eecd --- /dev/null +++ b/solution/lab04/terraform/.gitignore @@ -0,0 +1,16 @@ +# Terraform local state and cache +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +.terraform.lock.hcl + +# Local variable overrides and secrets +terraform.tfvars +*.tfvars +*.auto.tfvars + +# Credentials and keys +*.json +*.pem +*.key diff --git a/solution/lab04/terraform/.tflint.hcl b/solution/lab04/terraform/.tflint.hcl new file mode 100644 index 0000000000..427121c3ef --- /dev/null +++ b/solution/lab04/terraform/.tflint.hcl @@ -0,0 +1,4 @@ +plugin "terraform" { + enabled = true + preset = "recommended" +} diff --git a/solution/lab04/terraform/README.md b/solution/lab04/terraform/README.md new file mode 100644 index 0000000000..273142c07c --- /dev/null +++ b/solution/lab04/terraform/README.md @@ -0,0 +1,37 @@ +# Terraform (Yandex Cloud) for Lab 04 + +## 1. Prepare variables +1. Copy `terraform.tfvars.example` to `terraform.tfvars`. +2. Fill these values: + - `sa_key_file` + - `cloud_id` + - `folder_id` + - `ssh_public_key_path` + - `my_ip_cidr` + +## 2. Run Terraform +```powershell +cd solution/terraform +terraform init +terraform fmt +terraform validate +terraform plan +terraform apply +``` + +## 3. Connect to VM +Use output values: +```powershell +terraform output vm_public_ip +terraform output ssh_command +``` + +Or connect directly: +```powershell +ssh -i $env:USERPROFILE\.ssh\lab04_yc ubuntu@ +``` + +## 4. Cleanup +```powershell +terraform destroy +``` diff --git a/solution/lab04/terraform/main.tf b/solution/lab04/terraform/main.tf new file mode 100644 index 0000000000..d6d282f38a --- /dev/null +++ b/solution/lab04/terraform/main.tf @@ -0,0 +1,95 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = "~> 0.140" + } + } +} + +provider "yandex" { + service_account_key_file = var.sa_key_file + cloud_id = var.cloud_id + folder_id = var.folder_id + zone = var.zone +} + +data "yandex_compute_image" "ubuntu" { + family = var.image_family +} + +resource "yandex_vpc_network" "this" { + name = "${var.project_name}-network" +} + +resource "yandex_vpc_subnet" "this" { + name = "${var.project_name}-subnet" + zone = var.zone + network_id = yandex_vpc_network.this.id + v4_cidr_blocks = [var.subnet_cidr] +} + +resource "yandex_vpc_security_group" "this" { + name = "${var.project_name}-sg" + network_id = yandex_vpc_network.this.id + + ingress { + description = "SSH from my IP" + protocol = "TCP" + port = 22 + v4_cidr_blocks = [var.my_ip_cidr] + } + + ingress { + description = "HTTP" + protocol = "TCP" + port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "App port" + protocol = "TCP" + port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "Allow all egress" + protocol = "ANY" + from_port = 0 + to_port = 65535 + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "yandex_compute_instance" "vm" { + name = "${var.project_name}-vm" + platform_id = "standard-v2" + + resources { + cores = 2 + memory = 1 + core_fraction = 20 + } + + boot_disk { + initialize_params { + image_id = data.yandex_compute_image.ubuntu.id + size = 10 + type = "network-hdd" + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.this.id + nat = true + security_group_ids = [yandex_vpc_security_group.this.id] + } + + metadata = { + ssh-keys = "${var.ssh_user}:${trimspace(file(var.ssh_public_key_path))}" + } +} diff --git a/solution/lab04/terraform/outputs.tf b/solution/lab04/terraform/outputs.tf new file mode 100644 index 0000000000..67b7d28f01 --- /dev/null +++ b/solution/lab04/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "vm_public_ip" { + description = "Public IP address of the VM." + value = yandex_compute_instance.vm.network_interface[0].nat_ip_address +} + +output "ssh_command" { + description = "SSH command to connect to VM." + value = "ssh -i ~/.ssh/lab04_yc ${var.ssh_user}@${yandex_compute_instance.vm.network_interface[0].nat_ip_address}" +} diff --git a/solution/lab04/terraform/terraform.tfvars.example b/solution/lab04/terraform/terraform.tfvars.example new file mode 100644 index 0000000000..f408dcfa7b --- /dev/null +++ b/solution/lab04/terraform/terraform.tfvars.example @@ -0,0 +1,10 @@ +sa_key_file = "C:/Users//.yc/sa-key.json" +cloud_id = "" +folder_id = "" +zone = "ru-central1-d" +project_name = "lab04" +subnet_cidr = "10.10.0.0/24" +image_family = "ubuntu-2404-lts" +ssh_user = "ubuntu" +ssh_public_key_path = "C:/Users//.ssh/your_key.pub" +my_ip_cidr = "/32" diff --git a/solution/lab04/terraform/variables.tf b/solution/lab04/terraform/variables.tf new file mode 100644 index 0000000000..9dcac1ad3e --- /dev/null +++ b/solution/lab04/terraform/variables.tf @@ -0,0 +1,54 @@ +variable "sa_key_file" { + description = "Path to Yandex Cloud authorized key JSON file." + type = string +} + +variable "cloud_id" { + description = "Yandex Cloud ID." + type = string +} + +variable "folder_id" { + description = "Yandex Folder ID where resources will be created." + type = string +} + +variable "zone" { + description = "Yandex Cloud availability zone." + type = string + default = "ru-central1-d" +} + +variable "project_name" { + description = "Prefix for resource names." + type = string + default = "lab04" +} + +variable "subnet_cidr" { + description = "CIDR block for subnet." + type = string + default = "10.10.0.0/24" +} + +variable "image_family" { + description = "Image family for VM boot disk." + type = string + default = "ubuntu-2404-lts" +} + +variable "ssh_user" { + description = "Linux user for SSH access." + type = string + default = "ubuntu" +} + +variable "ssh_public_key_path" { + description = "Path to local public SSH key." + type = string +} + +variable "my_ip_cidr" { + description = "Your public IP in CIDR, example: 1.2.3.4/32." + type = string +} diff --git a/solution/lab05/ansible/.gitignore b/solution/lab05/ansible/.gitignore new file mode 100644 index 0000000000..759a6f4729 --- /dev/null +++ b/solution/lab05/ansible/.gitignore @@ -0,0 +1,2 @@ +*.retry +**/.vault_pass diff --git a/solution/lab05/ansible/ansible.cfg b/solution/lab05/ansible/ansible.cfg new file mode 100644 index 0000000000..65311d6c24 --- /dev/null +++ b/solution/lab05/ansible/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = xrixis +retry_files_enabled = False +interpreter_python = auto_silent + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/solution/lab05/ansible/collections/requirements.yml b/solution/lab05/ansible/collections/requirements.yml new file mode 100644 index 0000000000..5d0168d7af --- /dev/null +++ b/solution/lab05/ansible/collections/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.docker + version: ">=4.8.1,<5.0.0" diff --git a/solution/lab05/ansible/docs/LAB05.md b/solution/lab05/ansible/docs/LAB05.md new file mode 100644 index 0000000000..3b5c39028f --- /dev/null +++ b/solution/lab05/ansible/docs/LAB05.md @@ -0,0 +1,235 @@ +# LAB 05 - Ansible Fundamentals + +## 1. Architecture Overview +- Ansible version: `2.16.3` +- Target VM OS: Ubuntu 24.04 +- Inventory model: static inventory (`inventory/hosts.ini`) + +Role structure: +- `roles/common` - base packages +- `roles/docker` - Docker engine + service setup +- `roles/app_deploy` - image pull + container deployment + health check + +## 2. Roles Documentation +### common +- Purpose: install baseline OS packages. +- Variables: + - `common_packages` +- Handlers: none +- Dependencies: none + +### docker +- Purpose: configure Docker repository and install Docker runtime. +- Variables: + - `docker_packages` + - `docker_users` +- Handlers: + - `restart docker` +- Dependencies: common packages role should run first + +### app_deploy +- Purpose: login to Docker Hub, pull image, recreate container, verify health endpoint. +- Variables: + - `dockerhub_username`, `dockerhub_password` + - `docker_image`, `docker_image_tag` + - `app_port`, `app_container_name`, `app_env` +- Handlers: + - `restart application container` +- Dependencies: Docker must already be installed on target host + +## 3. Idempotency Demonstration +Paste terminal output from first `provision.yml` run (changed > 0): + +```bash +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/provision.yml +``` +```text +PLAY [Provision web servers] *************************************************************** + +TASK [Gathering Facts] ********************************************************************* +ok: [devopsmachine] + +TASK [common : Update apt cache] *********************************************************** +ok: [devopsmachine] + +TASK [common : Install common packages] **************************************************** +changed: [devopsmachine] + +TASK [docker : Ensure apt prerequisites are installed] ************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt keyrings directory exists] *************************************** +ok: [devopsmachine] + +TASK [docker : Add Docker official GPG key] ************************************************ +changed: [devopsmachine] + +TASK [docker : Add Docker apt repository] ************************************************** +changed: [devopsmachine] + +TASK [docker : Install Docker packages] **************************************************** +changed: [devopsmachine] + +TASK [docker : Ensure Docker service is enabled and started] ******************************* +ok: [devopsmachine] + +TASK [docker : Add users to docker group] ************************************************** +changed: [devopsmachine] => (item=xrixis) + +TASK [docker : Install Python docker bindings] ********************************************* +changed: [devopsmachine] + +RUNNING HANDLER [docker : restart docker] ************************************************** +changed: [devopsmachine] + +PLAY RECAP ********************************************************************************* +devopsmachine : ok=12 changed=7 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +Provisioning 2nd ran + +```text +PLAY [Provision web servers] *************************************************************** + +TASK [Gathering Facts] ********************************************************************* +ok: [devopsmachine] + +TASK [common : Update apt cache] *********************************************************** +ok: [devopsmachine] + +TASK [common : Install common packages] **************************************************** +ok: [devopsmachine] + +TASK [docker : Ensure apt prerequisites are installed] ************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt keyrings directory exists] *************************************** +ok: [devopsmachine] + +TASK [docker : Add Docker official GPG key] ************************************************ +ok: [devopsmachine] + +TASK [docker : Add Docker apt repository] ************************************************** +ok: [devopsmachine] + +TASK [docker : Install Docker packages] **************************************************** +ok: [devopsmachine] + +TASK [docker : Ensure Docker service is enabled and started] ******************************* +ok: [devopsmachine] + +TASK [docker : Add users to docker group] ************************************************** +ok: [devopsmachine] => (item=xrixis) + +TASK [docker : Install Python docker bindings] ********************************************* +ok: [devopsmachine] + +PLAY RECAP ********************************************************************************* +devopsmachine : ok=11 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +Analysis: +- First run changed state by installing packages/repo/service/user group changes. +- Second run is totaly `ok` due to idempotent Ansible modules and desired-state model. + +## 4. Ansible Vault Usage +- Sensitive vars are stored in `inventory/group_vars/all.yml` and are encrypted. +- Commands used: + +```bash +ansible-vault encrypt inventory/group_vars/all.yml +ansible-vault view inventory/group_vars/all.yml +``` + +- Vault password strategy: run with `--ask-vault-pass` for each command. `.vault_pass` is gitignored and not committed. + +Example encrypted header: + +```text +$ANSIBLE_VAULT;1.1;AES256 +... +``` + +## 5. Deployment Verification +```bash +$ ansible-playbook playbooks/deploy.yml --ask-vault-pass +``` + +```text +Vault password: + +PLAY [Deploy application] ****************************************************************** + +TASK [Gathering Facts] ********************************************************************* +ok: [devopsmachine] + +TASK [app_deploy : Log in to Docker Hub] *************************************************** +changed: [devopsmachine] + +TASK [app_deploy : Pull application image] ************************************************* +changed: [devopsmachine] + +TASK [app_deploy : Stop existing container if running] ************************************* +ok: [devopsmachine] + +TASK [app_deploy : Remove existing container if present] *********************************** +ok: [devopsmachine] + +TASK [app_deploy : Run application container] ********************************************** +changed: [devopsmachine] + +TASK [app_deploy : Wait for application port to be ready] ********************************** +ok: [devopsmachine] + +TASK [app_deploy : Verify health endpoint] ************************************************* +ok: [devopsmachine] + +RUNNING HANDLER [app_deploy : restart application container] ******************************* +changed: [devopsmachine] + +PLAY RECAP ********************************************************************************* +devopsmachine : ok=9 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +Container status: +```bash +ansible webservers -a "docker ps" --ask-vault-pass +``` +```text +Vault password: +devopsmachine | CHANGED | rc=0 >> +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +90cc16606c6e xrixis/devops-i-lobazov:latest "python app.py" 9 minutes ago Up 9 minutes 0.0.0.0:5000->5000/tcp devops-i-lobazov +``` + +Health check: + +```bash +curl http://10.247.1.39:5000/health +``` + +```text +{"status":"healthy","timestamp":"2026-02-26 19:25:40","uptime_seconds":736} +``` + +## 6. Key Decisions +- Why roles instead of plain playbooks? +Roles enforce modularity and clear ownership of tasks, defaults, and handlers. This keeps playbooks short and reusable. + +- How do roles improve reusability? +A role can be applied to different hosts/projects with only variable overrides, without rewriting tasks. + +- What makes a task idempotent? +It declares state (present/absent/started) and converges to it, so repeated runs do not keep changing resources. + +- How do handlers improve efficiency? +Handlers run only when notified, preventing unnecessary service restarts on every run. + +- Why is Ansible Vault necessary? +It allows storing secrets in Git safely by encrypting sensitive variables at rest. + +## 7. Challenges +- Unavailability to utilize `.vault_pass` using ansible (host) via WSL - cannot clean the file from execution rights, what is rad by ansible as malformed project. + - Fix: use `--ask-vault-pass` on runs instead of a local executable password file. +- Vault/group variables were not loaded when files were outside inventory scope. + - Fix: move variables to `inventory/group_vars` and host connection vars to `inventory/host_vars`. diff --git a/solution/lab05/ansible/docs/LAB06.md b/solution/lab05/ansible/docs/LAB06.md new file mode 100644 index 0000000000..abede338c7 --- /dev/null +++ b/solution/lab05/ansible/docs/LAB06.md @@ -0,0 +1,344 @@ +# Lab 6: Advanced Ansible & CI/CD - Submission + +**Name:** Ilya Lobazov +**Date:** 2026-03-05 +**Lab Points:** 10 + 0 bonus + +--- + +## Task 1: Blocks & Tags (2 pts) + +### Overview +Refactored `common` and `docker` roles to use `block/rescue/always` patterns and explicit tag strategy. + +### Implementation +- `roles/common/tasks/main.yml` + - `packages` block: + - apt cache update + package install + - `rescue`: `apt-get update --fix-missing` and apt cache retry + - `always`: write completion marker to `/tmp/ansible-common-role.log` + - `users` block: + - managed users loop from `common_managed_users` + - `rescue`: failure context debug + - `always`: log completion marker +- `roles/docker/tasks/main.yml` + - `docker_install` block: + - prereqs, keyring dir, GPG key, repo, docker packages, python docker bindings + - `rescue`: wait 10s, apt cache retry, key/repo/package retry + - `always`: enforce Docker service `enabled` + `started` + - `docker_config` block: + - docker group membership + - `always`: enforce Docker service state again +- `playbooks/provision.yml` + - role-level tags: + - `common` role tagged `common` + - `docker` role tagged `docker` + +### Tag Strategy +- Role-level: + - `common` + - `docker` +- Block-level: + - `packages` + - `users` + - `docker_install` + - `docker_config` + +### Execution Examples +Terminal output artifact: `solution/lab05/ansible/docs/artifacts/lab06_terminal_artifacts` +```bash +cd solution/lab05/ansible + +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/provision.yml --list-tags +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/provision.yml --tags "docker" +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/provision.yml --skip-tags "common" +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/provision.yml --tags "packages" +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/provision.yml --tags "docker_install" --check +``` + +### Research Answers +1. If `rescue` fails too, task execution fails and play ends with failed status unless failure is explicitly ignored. +2. Yes, nested blocks are supported and often used for layered error handling. +3. Tags applied to a block are inherited by tasks inside the block; role-level tags are inherited by all role tasks. + +--- + +## Task 2: Docker Compose (3 pts) + +### Migration `app_deploy` -> `web_app` +- Updated `playbooks/deploy.yml` to use role `web_app`. +- Implemented full deployment logic in `roles/web_app`. +- Legacy `roles/app_deploy` is not referenced by deployment playbook. + +### Compose Template +- Added `roles/web_app/templates/docker-compose.yml.j2` with variables: + - `app_name` + - `docker_image` + - `docker_tag` + - `app_port` + - `app_internal_port` + - `web_app_environment` + - `app_restart_policy` + - `web_app_network_name` + +### Role Dependency +- Added `roles/web_app/meta/main.yml`: + - dependency on role `docker` +- Result: running deployment through `web_app` guarantees Docker runtime is prepared first. + +### Compose Deployment Logic +- `roles/web_app/tasks/main.yml` + - create project directory (`compose_project_dir`) + - template `docker-compose.yml` + - run `community.docker.docker_compose_v2` (`state: present`, `recreate: auto`) + - wait for application port + - verify health endpoint + - `rescue` + explicit `fail` + completion log in `always` + +### Idempotency Notes +- Compose module configured with `pull: missing` via `web_app_compose_pull_policy` for predictable idempotent reruns. +- Directory/template/service state are declarative. + +### Variables +Defined in `roles/web_app/defaults/main.yml`: +```yaml +app_name: devops-app +docker_image: "your_dockerhub_username/devops-info-service" +docker_tag: latest +app_port: 8000 +app_internal_port: "{{ app_port }}" +compose_project_dir: "/opt/{{ app_name }}" +docker_compose_version: "3.8" +web_app_environment: {} +``` + +### Install dependencies +Terminal output artifact: `solution/lab05/ansible/docs/artifacts/lab06_terminal_artifacts` +```bash +cd solution/lab05/ansible +ansible-galaxy collection install -r collections/requirements.yml +``` + +### Test commands +Terminal output artifact: `solution/lab05/ansible/docs/artifacts/lab06_terminal_artifacts` +```bash +cd solution/lab05/ansible +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass +ansible webservers -a "docker ps" +ansible webservers -a "docker compose -f /opt/devops-app/docker-compose.yml ps" +curl http://:8000 +curl http://:8000/health +``` + +### Research Answers +1. `always` restarts container after daemon restart; `unless-stopped` survives daemon restart but respects manual stop. +2. Compose creates project-scoped networks with deterministic naming and lifecycle tied to stack; default bridge is global and less structured. +3. Yes, Vault vars can be used directly in Jinja templates; they are decrypted at runtime by Ansible. + +--- + +## Task 3: Wipe Logic (1 pt) + +### Implementation +- Added `roles/web_app/tasks/wipe.yml`: + - `docker_compose_v2 state: absent` + - remove compose file + - remove project directory + - completion debug message +- Added `include_tasks: wipe.yml` at top of `roles/web_app/tasks/main.yml`. +- Added safety variable in defaults: + - `web_app_wipe: false` + +### Double Safety Mechanism +Wipe executes only when both conditions are met: +- tag selected: `--tags web_app_wipe` +- variable enabled: `-e "web_app_wipe=true"` + +### Scenarios +Terminal output artifact: `solution/lab05/ansible/docs/artifacts/lab06_terminal_artifacts` +1. Normal deploy (wipe skipped): +```bash +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass +``` +2. Wipe only: +```bash +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass -e "web_app_wipe=true" --tags web_app_wipe +``` +3. Clean reinstall (wipe -> deploy): +```bash +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass -e "web_app_wipe=true" +``` +4. Safety check (tag only, var false => blocked): +```bash +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass --tags web_app_wipe +``` + +### Research Answers +1. Variable + tag gives defense in depth: accidental tag run or accidental variable alone is not enough. +2. `never` disables by default but does not encode business safety condition via variable gate. +3. Wipe must run before deploy to support clean reinstall in one command. +4. Clean reinstall is useful for corrupted state or major config drift; rolling update is preferred for minimal downtime. +5. Extend with `remove_volumes: true` and targeted image prune tasks, guarded by an extra confirmation variable. + +--- + +## Task 4: CI/CD (3 pts) + +### Workflow +Added `.github/workflows/ansible-deploy.yml` with: +- triggers: `push` + `pull_request` +- path filters for `solution/lab05/ansible/**` and workflow file +- `lint` job: + - setup Python 3.12 + - install `ansible-core`, `ansible-lint` + - install collections from `collections/requirements.yml` + - run `ansible-lint playbooks/*.yml` +- `deploy` job: + - depends on lint + - runs on self-hosted runner (runner installed in local environment) + - setup SSH from secrets + - run `ansible-playbook playbooks/deploy.yml` with vault password file from secret + - verify app with curl (`/` and `/health`) + +CI/CD validation note: +- Self-hosted runner is added and used for deployment job execution. +- Workflow operability can be verified in branch `test` (push-triggered run). + +### Required GitHub Secrets +- `ANSIBLE_VAULT_PASSWORD` +- `SSH_PRIVATE_KEY` +- `VM_HOST` +- `VM_USER` + +### Security Notes +- Vault password is written to temporary file and deleted via shell trap. +- SSH private key is loaded at runtime only. +- No plaintext secrets are committed in repository files. + +### Badge +Added to root `README.md`: +```md +[![Ansible Deployment](https://github.com/xrixis/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/xrixis/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) +``` + +### Verification commands +Terminal output artifact: `solution/lab05/ansible/docs/artifacts/lab06_terminal_artifacts` +```bash +git add .github/workflows/ansible-deploy.yml solution/lab05/ansible +git commit -m "lab06: add ansible deployment workflow" +git push +# Then verify Actions run and curl checks in workflow logs +``` + +### Research Answers +1. SSH keys in secrets are high-impact credentials; scope, rotation, and least-privilege keys are mandatory. +2. Use staged pipeline: deploy to staging on PR/merge, run smoke/integration tests, then manual approval gate for production. +3. Add rollback by versioned image tags + previous compose manifest + manual/automatic rollback job. +4. Self-hosted runner can avoid exposing SSH keys to hosted runners and keep network access internal, but runner hardening becomes your responsibility. + +--- + +## Task 5: Documentation (1 pt) + +This document provides required sections: +1. Overview +2. Blocks & Tags +3. Docker Compose Migration +4. Wipe Logic +5. CI/CD Integration +6. Testing Results +7. Challenges & Solutions +8. Research Answers + +All modified files include clear comments where safety/flow is not obvious. + +--- + +## Overview + +Implemented advanced Ansible automation for Lab 06 on top of Lab 05 baseline: +- role refactoring with robust error handling +- tag-driven selective execution +- Docker Compose-based deployment role +- safe wipe mechanism with double-gating +- automated CI/CD workflow for lint + deploy + verification + +Technologies used: Ansible 2.16+, `community.docker`, Docker Compose v2, GitHub Actions. + +--- + +## Testing Results + +### Local static checks +Terminal output artifact: `solution/lab05/ansible/docs/artifacts/lab06_terminal_artifacts` +```bash +cd solution/lab05/ansible +ansible-galaxy collection install -r collections/requirements.yml +ansible-playbook playbooks/provision.yml --syntax-check +ansible-playbook playbooks/deploy.yml --syntax-check +ansible-playbook playbooks/provision.yml --list-tags +ansible-lint playbooks/*.yml +``` + +### Runtime checks (VM required) +Terminal output artifact: `solution/lab05/ansible/docs/artifacts/lab06_terminal_artifacts` +```bash +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/provision.yml --ask-vault-pass +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass +ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass +curl http://:8000/health +``` + +Observed evidence from actual runs: +- `deploy.yml` after wipe: `ok=18 changed=3 failed=0` +- repeated `deploy.yml`: `ok=18 changed=0 failed=0` (idempotency confirmed) +- `wipe-only` run: `ok=7 changed=3 failed=0` +- selective tags listed: `TASK TAGS: [common, docker, docker_config, docker_install, packages, users]` + +### Wipe test matrix +- Scenario 1: normal deploy, wipe skipped +- Scenario 2: wipe-only (`web_app_wipe=true` + `--tags web_app_wipe`) +- Scenario 3: clean reinstall (`web_app_wipe=true` without tag filter) +- Scenario 4a: `--tags web_app_wipe` with default `web_app_wipe=false` => wipe blocked by condition + +--- + +## Challenges & Solutions + +1. Existing project path is `solution/lab05/ansible` (not repo-root `ansible`). +- Solution: CI path filters and workflow `working-directory` explicitly target this location. + +2. Need safe wipe behavior without `never`. +- Solution: implemented dual control (tag + boolean var), include-first ordering in main tasks. + +3. Need idempotent Compose behavior while keeping update flexibility. +- Solution: configurable pull policy (`web_app_compose_pull_policy`), default `missing`. + +4. Migration from legacy `docker_container` role caused name conflict with Compose container. +- Solution: added cleanup of legacy standalone container (`name: {{ app_name }}`) before `docker_compose_v2` and in `wipe.yml`. + +--- + +## Evidence Checklist + +- [x] Ansible playbook output with selective tags +- [x] Rescue block triggered output +- [x] Docker Compose deployment success +- [x] Idempotency verification (2nd run) +- [x] Wipe logic test results (all 4 scenarios) +- [x] GitHub Actions successful workflow +- [x] ansible-lint passing +- [x] Status badge in README +- [x] Application accessible via curl/health-check verification + +--- + +## Summary + +Core Lab 06 implementation is completed in repository structure, including role refactor, Compose migration, wipe logic, CI workflow, and documentation. + +Remaining items to fully close evidence are runtime executions on your VM and GitHub Actions environment (requires your secrets and remote access). + +Total time spent: ~2.5 hours. +Key learnings: robust block/rescue design, safe destructive automation patterns, and reproducible Ansible CI/CD. diff --git a/solution/lab05/ansible/docs/artifacts/lab06 terminal artifacts b/solution/lab05/ansible/docs/artifacts/lab06 terminal artifacts new file mode 100644 index 0000000000..9fc7e3e862 --- /dev/null +++ b/solution/lab05/ansible/docs/artifacts/lab06 terminal artifacts @@ -0,0 +1,393 @@ +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/provision.yml --syntax-check +playbook: playbooks/provision.yml + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml --syntax-check +playbook: playbooks/deploy.yml +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/provision.yml --list-tags +playbook: playbooks/provision.yml + + play #1 (webservers): Provision web servers TAGS: [] + TASK TAGS: [common, docker, docker_config, docker_install, packages, users] + + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/provision.yml --tags "docker" --ask-vault-pass +Vault password: + +PLAY [Provision web servers] *************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt prerequisites are installed] ************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt keyrings directory exists] *************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Add Docker official GPG key] ************************************************************************************************ +ok: [devopsmachine] + +TASK [docker : Add Docker apt repository] ************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Docker packages] **************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Python Docker bindings] ********************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure Docker service is enabled and started] ******************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure users belong to docker group] **************************************************************************************** +ok: [devopsmachine] => (item=xrixis) + +TASK [docker : Confirm Docker service state after configuration] *************************************************************************** +ok: [devopsmachine] + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/provision.yml --skip-tags "common" --ask-vault-pass +Vault password: + +PLAY [Provision web servers] *************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt prerequisites are installed] ************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt keyrings directory exists] *************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Add Docker official GPG key] ************************************************************************************************ +ok: [devopsmachine] + +TASK [docker : Add Docker apt repository] ************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Docker packages] **************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Python Docker bindings] ********************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure Docker service is enabled and started] ******************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure users belong to docker group] **************************************************************************************** +ok: [devopsmachine] => (item=xrixis) + +TASK [docker : Confirm Docker service state after configuration] *************************************************************************** +ok: [devopsmachine] + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/provision.yml --tags "packages" --ask-vault-pass +Vault password: + +PLAY [Provision web servers] *************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [common : Update apt cache] *********************************************************************************************************** +ok: [devopsmachine] + +TASK [common : Install common packages] **************************************************************************************************** +ok: [devopsmachine] + +TASK [common : Record common package block completion] ************************************************************************************* +changed: [devopsmachine] + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/provision.yml --tags "docker_install" --check --ask-vault-pass +Vault password: + +PLAY [Provision web servers] *************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt prerequisites are installed] ************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt keyrings directory exists] *************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Add Docker official GPG key] ************************************************************************************************ +changed: [devopsmachine] + +TASK [docker : Add Docker apt repository] ************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Docker packages] **************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Python Docker bindings] ********************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure Docker service is enabled and started] ******************************************************************************* +ok: [devopsmachine] + +RUNNING HANDLER [docker : restart docker] ************************************************************************************************** +changed: [devopsmachine] + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=9 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe --ask-vault-pass +Vault password: + +PLAY [Deploy application] ****************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [web_app : Include wipe tasks] ******************************************************************************************************** +included: /mnt/c/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/lab05/ansible/roles/web_app/tasks/wipe.yml for devopsmachine + +TASK [web_app : Stop and remove application stack] ***************************************************************************************** +changed: [devopsmachine] + +TASK [web_app : Remove docker-compose file] ************************************************************************************************ +changed: [devopsmachine] + +TASK [web_app : Remove application directory] ********************************************************************************************** +changed: [devopsmachine] + +TASK [web_app : Confirm wipe completion] *************************************************************************************************** +ok: [devopsmachine] => { + "msg": "Application devops-i-lobazov wiped successfully" +} + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=6 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml --tags web_app_wipe --ask-vault-pass +Vault password: + +PLAY [Deploy application] ****************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [web_app : Include wipe tasks] ******************************************************************************************************** +included: /mnt/c/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/lab05/ansible/roles/web_app/tasks/wipe.yml for devopsmachine + +TASK [web_app : Stop and remove application stack] ***************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Remove docker-compose file] ************************************************************************************************ +skipping: [devopsmachine] + +TASK [web_app : Remove application directory] ********************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Confirm wipe completion] *************************************************************************************************** +skipping: [devopsmachine] + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=2 changed=0 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0 + + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe --ask-vault-pass +Vault password: + +PLAY [Deploy application] ****************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [web_app : Include wipe tasks] ******************************************************************************************************** +included: /mnt/c/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/lab05/ansible/roles/web_app/tasks/wipe.yml for devopsmachine + +TASK [web_app : Stop and remove application stack] ***************************************************************************************** +changed: [devopsmachine] + +TASK [web_app : Remove legacy standalone container] **************************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Remove docker-compose file] ************************************************************************************************ +changed: [devopsmachine] + +TASK [web_app : Remove application directory] ********************************************************************************************** +changed: [devopsmachine] + +TASK [web_app : Confirm wipe completion] *************************************************************************************************** +ok: [devopsmachine] => { + "msg": "Application devops-i-lobazov wiped successfully" +} + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=7 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml --ask-vault-pass +Vault password: + +PLAY [Deploy application] ****************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt prerequisites are installed] ************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt keyrings directory exists] *************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Add Docker official GPG key] ************************************************************************************************ +ok: [devopsmachine] + +TASK [docker : Add Docker apt repository] ************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Docker packages] **************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Python Docker bindings] ********************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure Docker service is enabled and started] ******************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure users belong to docker group] **************************************************************************************** +ok: [devopsmachine] => (item=xrixis) + +TASK [docker : Confirm Docker service state after configuration] *************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Include wipe tasks] ******************************************************************************************************** +included: /mnt/c/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/lab05/ansible/roles/web_app/tasks/wipe.yml for devopsmachine + +TASK [web_app : Stop and remove application stack] ***************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Remove legacy standalone container] **************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Remove docker-compose file] ************************************************************************************************ +skipping: [devopsmachine] + +TASK [web_app : Remove application directory] ********************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Confirm wipe completion] *************************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Ensure application project directory exists] ******************************************************************************* +changed: [devopsmachine] + +TASK [web_app : Render Docker Compose configuration] *************************************************************************************** +changed: [devopsmachine] + +TASK [web_app : Inspect existing container with app name] ********************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Remove legacy standalone container with conflicting name] ****************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Deploy stack with Docker Compose v2] *************************************************************************************** +changed: [devopsmachine] + +TASK [web_app : Wait for application port to be ready] ************************************************************************************* +ok: [devopsmachine] + +TASK [web_app : Verify health endpoint] **************************************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Log deployment block completion] ******************************************************************************************* +ok: [devopsmachine] => { + "msg": "Deployment block finished for devops-i-lobazov" +} + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=18 changed=3 unreachable=0 failed=0 skipped=6 rescued=0 ignored=0 + +$ ANSIBLE_CONFIG=./ansible.cfg ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml --ask-vault-pass +Vault password: + +PLAY [Deploy application] ****************************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt prerequisites are installed] ************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure apt keyrings directory exists] *************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Add Docker official GPG key] ************************************************************************************************ +ok: [devopsmachine] + +TASK [docker : Add Docker apt repository] ************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Docker packages] **************************************************************************************************** +ok: [devopsmachine] + +TASK [docker : Install Python Docker bindings] ********************************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure Docker service is enabled and started] ******************************************************************************* +ok: [devopsmachine] + +TASK [docker : Ensure users belong to docker group] **************************************************************************************** +ok: [devopsmachine] => (item=xrixis) + +TASK [docker : Confirm Docker service state after configuration] *************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Include wipe tasks] ******************************************************************************************************** +included: /mnt/c/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/lab05/ansible/roles/web_app/tasks/wipe.yml for devopsmachine + +TASK [web_app : Stop and remove application stack] ***************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Remove legacy standalone container] **************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Remove docker-compose file] ************************************************************************************************ +skipping: [devopsmachine] + +TASK [web_app : Remove application directory] ********************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Confirm wipe completion] *************************************************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Ensure application project directory exists] ******************************************************************************* +ok: [devopsmachine] + +TASK [web_app : Render Docker Compose configuration] *************************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Inspect existing container with app name] ********************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Remove legacy standalone container with conflicting name] ****************************************************************** +skipping: [devopsmachine] + +TASK [web_app : Deploy stack with Docker Compose v2] *************************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Wait for application port to be ready] ************************************************************************************* +ok: [devopsmachine] + +TASK [web_app : Verify health endpoint] **************************************************************************************************** +ok: [devopsmachine] + +TASK [web_app : Log deployment block completion] ******************************************************************************************* +ok: [devopsmachine] => { + "msg": "Deployment block finished for devops-i-lobazov" +} + +PLAY RECAP ********************************************************************************************************************************* +devopsmachine : ok=18 changed=0 unreachable=0 failed=0 skipped=6 rescued=0 ignored=0 \ No newline at end of file diff --git a/solution/lab05/ansible/group_vars/all.yml b/solution/lab05/ansible/group_vars/all.yml new file mode 100644 index 0000000000..e9e2b3bfef --- /dev/null +++ b/solution/lab05/ansible/group_vars/all.yml @@ -0,0 +1,46 @@ +--- +# Common role configuration +common_packages: + - curl + - ca-certificates + - gnupg + - lsb-release + - python3 + - python3-pip +deploy_user: devops +deploy_user_groups: + - sudo +deploy_user_shell: /bin/bash +deploy_user_ssh_pubkey: "" + +# Docker role configuration +docker_apt_arch: "{{ ansible_architecture | replace('x86_64', 'amd64') | replace('aarch64', 'arm64') }}" +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin +docker_config_manage_daemon: true +docker_daemon_config: + log-driver: json-file + log-opts: + max-size: 10m + max-file: "3" + +# Web application configuration +app_name: devops-app +docker_image: your_dockerhub_username/devops-info-service +docker_tag: latest +app_port: 8000 +app_internal_port: 8000 +docker_compose_version: "3.8" +compose_project_dir: "/opt/{{ app_name }}" + +# Example non-sensitive app vars +app_environment: + APP_ENV: production + APP_PORT: "{{ app_internal_port | string }}" + +# Use ansible-vault for sensitive variables. +app_secret_key: "change-me-with-ansible-vault" diff --git a/solution/lab05/ansible/inventory/group_vars/all.yml b/solution/lab05/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000000..d327a18451 --- /dev/null +++ b/solution/lab05/ansible/inventory/group_vars/all.yml @@ -0,0 +1,29 @@ +$ANSIBLE_VAULT;1.1;AES256 +64303161646262623138306163653533613765613630316438636235623839323165303834626239 +3837353033653565383830396534633736386165383062620a353136353534396131643766383939 +30363863316365373532303038643435636630343134326334396333393736326462343934396361 +6135396338393665370a623432343230393465623236303935346335313035363763656330326163 +37656461353139393836643233366366653663323263626430393631386462613635343237626364 +34303939323833346331363161613262306434376362363930383533353163623738383830653939 +65313534663337383631393664623638363233666531376661333031666130376130333031303765 +62343765343034616137393332663164363935356339346265383730373932333561393261316539 +32643138393237656232306335303430353235666234373431303439366361396638366534613637 +37623030376634396161383162623635653439626436303632333734303139366134626563393332 +61663031306233326164363635333632303561333134626534396463373261363564653633383834 +32643465633731366431336632663861303236373632346564373337633365356630353035343363 +61656665336332373631613261366239646363623036313933633139323937323737356439333764 +36623434343863626431366133633435623665643334663865373835613530366264343164663164 +62373465316161623565323837386463386338633437353133353564363333663964373039383838 +61393166663537353264373338626633343463313037346131633739616436303539623931656439 +31663661656162336465376566313336393430616634373630333331653630303835386632363362 +35626338366363333836333839333966306333386133656665343436366462653138333763653238 +35356138636635316436323938323461376164643132353266393661396132643537626635653836 +65616630333564386631393836616166656532633038303134346663393631656335643230353735 +63383439356237623661653830643133656632356338623331373038363664376333386362663232 +33633238373134663432316234303235323766303639376433323434613262356637323863656233 +66366436386532303865333531653365306365616430656463303033353961376565386534303261 +34343631383264333234383062346539353738316136373232613331323839306639333432363831 +32346338653537336264393264336434353066613231353535356563643536636265623531333431 +34613633346465646430633661393732303431313339353763626235343532653966376362623534 +64376639343564653865363234373064386130313166366265613334633465643237303963663236 +31656261663235623932 diff --git a/solution/lab05/ansible/inventory/host_vars/devopsmachine.yml b/solution/lab05/ansible/inventory/host_vars/devopsmachine.yml new file mode 100644 index 0000000000..4348f803fc --- /dev/null +++ b/solution/lab05/ansible/inventory/host_vars/devopsmachine.yml @@ -0,0 +1,5 @@ +# Should be encrypted as well as all.yml, but due to run on local VM there no actual secrets. +--- +ansible_host: 10.247.1.39 +ansible_user: xrixis +ansible_ssh_private_key_file: ~/.ssh/devops45labs diff --git a/solution/lab05/ansible/inventory/hosts.ini b/solution/lab05/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..eb8740fd5d --- /dev/null +++ b/solution/lab05/ansible/inventory/hosts.ini @@ -0,0 +1,2 @@ +[webservers] +devopsmachine diff --git a/solution/lab05/ansible/playbooks/deploy-monitoring.yml b/solution/lab05/ansible/playbooks/deploy-monitoring.yml new file mode 100644 index 0000000000..8fee21a2f9 --- /dev/null +++ b/solution/lab05/ansible/playbooks/deploy-monitoring.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy monitoring stack + hosts: webservers + become: true + + roles: + - role: monitoring diff --git a/solution/lab05/ansible/playbooks/deploy.yml b/solution/lab05/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..c4176269ac --- /dev/null +++ b/solution/lab05/ansible/playbooks/deploy.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - role: web_app diff --git a/solution/lab05/ansible/playbooks/provision.yml b/solution/lab05/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..6334c412cc --- /dev/null +++ b/solution/lab05/ansible/playbooks/provision.yml @@ -0,0 +1,12 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - role: common + tags: + - common + - role: docker + tags: + - docker diff --git a/solution/lab05/ansible/playbooks/site.yml b/solution/lab05/ansible/playbooks/site.yml new file mode 100644 index 0000000000..d5423094d7 --- /dev/null +++ b/solution/lab05/ansible/playbooks/site.yml @@ -0,0 +1,6 @@ +--- +- name: Run provisioning playbook + import_playbook: provision.yml + +- name: Run deployment playbook + import_playbook: deploy.yml diff --git a/solution/lab05/ansible/roles/common/defaults/main.yml b/solution/lab05/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..cbf007f91c --- /dev/null +++ b/solution/lab05/ansible/roles/common/defaults/main.yml @@ -0,0 +1,11 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - ca-certificates + - gnupg + +common_managed_users: [] diff --git a/solution/lab05/ansible/roles/common/tasks/main.yml b/solution/lab05/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..25dd847136 --- /dev/null +++ b/solution/lab05/ansible/roles/common/tasks/main.yml @@ -0,0 +1,63 @@ +--- +- name: Common role package management + become: true + tags: + - packages + block: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + rescue: + - name: Repair apt cache metadata after failure + ansible.builtin.apt: + update_cache: true + cache_valid_time: 0 + + - name: Retry apt cache update + ansible.builtin.apt: + update_cache: true + + always: + - name: Record common package block completion + ansible.builtin.lineinfile: + path: /tmp/ansible-common-role.log + line: "packages block completed" + create: true + mode: "0644" + +- name: Common role user management + when: common_managed_users | length > 0 + become: true + tags: + - users + block: + - name: Ensure managed users are present + ansible.builtin.user: + name: "{{ item.name }}" + state: "{{ item.state | default('present') }}" + shell: "{{ item.shell | default('/bin/bash') }}" + groups: "{{ item.groups | default(omit) }}" + append: "{{ item.append | default(true) }}" + loop: "{{ common_managed_users }}" + loop_control: + label: "{{ item.name }}" + + rescue: + - name: Log managed user failure context + ansible.builtin.debug: + msg: "Failed to manage users from common_managed_users" + + always: + - name: Record common user block completion + ansible.builtin.lineinfile: + path: /tmp/ansible-common-role.log + line: "users block completed" + create: true + mode: "0644" diff --git a/solution/lab05/ansible/roles/docker/defaults/main.yml b/solution/lab05/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..d863c228c3 --- /dev/null +++ b/solution/lab05/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,14 @@ +--- +docker_apt_arch_map: + x86_64: amd64 + aarch64: arm64 + +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + +docker_users: + - "{{ ansible_user }}" diff --git a/solution/lab05/ansible/roles/docker/handlers/main.yml b/solution/lab05/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..07aa0eb290 --- /dev/null +++ b/solution/lab05/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/solution/lab05/ansible/roles/docker/tasks/main.yml b/solution/lab05/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..805e82fb80 --- /dev/null +++ b/solution/lab05/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,107 @@ +--- +- name: Docker installation block + become: true + tags: + - docker_install + block: + - name: Ensure apt prerequisites are installed + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: true + + - name: Ensure apt keyrings directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + + - name: Add Docker official GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + notify: Restart docker + + - name: Add Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ docker_apt_arch_map[ansible_architecture] | default('amd64') }} signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable + state: present + filename: docker + notify: Restart docker + + - name: Install Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: true + + - name: Install Python Docker bindings + ansible.builtin.apt: + name: python3-docker + state: present + + rescue: + - name: Wait before Docker apt metadata retry + ansible.builtin.pause: + seconds: 10 + + - name: Retry apt cache update for Docker repositories + ansible.builtin.apt: + update_cache: true + + - name: Retry Docker GPG key download + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + + - name: Retry Docker apt repository registration + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ docker_apt_arch_map[ansible_architecture] | default('amd64') }} signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable + state: present + filename: docker + + - name: Retry Docker package installation + ansible.builtin.apt: + name: "{{ docker_packages + ['python3-docker'] }}" + state: present + update_cache: true + + always: + - name: Ensure Docker service is enabled and started + ansible.builtin.service: + name: docker + state: started + enabled: true + +- name: Docker configuration block + become: true + tags: + - docker_config + block: + - name: Ensure users belong to docker group + ansible.builtin.user: + name: "{{ item }}" + groups: docker + append: true + loop: "{{ docker_users }}" + + rescue: + - name: Log docker group assignment failure + ansible.builtin.debug: + msg: "Failed to update docker group membership" + + always: + - name: Confirm Docker service state after configuration + ansible.builtin.service: + name: docker + state: started + enabled: true diff --git a/solution/lab05/ansible/roles/monitoring/defaults/main.yml b/solution/lab05/ansible/roles/monitoring/defaults/main.yml new file mode 100644 index 0000000000..8d5c57ee08 --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/defaults/main.yml @@ -0,0 +1,47 @@ +--- +monitoring_project_dir: /opt/monitoring + +monitoring_loki_version: "3.0.0" +monitoring_promtail_version: "3.0.0" +monitoring_grafana_version: "12.3.1" + +monitoring_loki_port: 3100 +monitoring_promtail_port: 9080 +monitoring_grafana_port: 3000 +monitoring_retention_period: 168h +monitoring_schema_version: v13 + +monitoring_grafana_admin_user: admin +monitoring_grafana_admin_password: change-me-now + +monitoring_python_app_enabled: false +monitoring_python_image: your-dockerhub-username/devops-info-service:latest +monitoring_python_host_port: 8000 +monitoring_python_container_port: 5000 + +monitoring_rust_app_enabled: false +monitoring_rust_image: your-dockerhub-username/devops-info-service-rust:latest +monitoring_rust_host_port: 8001 +monitoring_rust_container_port: 5000 + +monitoring_resource_profiles: + loki: + limit_cpus: "1.0" + limit_memory: 1G + reserve_cpus: "0.25" + reserve_memory: 256M + promtail: + limit_cpus: "0.5" + limit_memory: 512M + reserve_cpus: "0.10" + reserve_memory: 128M + grafana: + limit_cpus: "1.0" + limit_memory: 1G + reserve_cpus: "0.25" + reserve_memory: 256M + app: + limit_cpus: "0.5" + limit_memory: 512M + reserve_cpus: "0.10" + reserve_memory: 128M diff --git a/solution/lab05/ansible/roles/monitoring/meta/main.yml b/solution/lab05/ansible/roles/monitoring/meta/main.yml new file mode 100644 index 0000000000..cb7d8e0460 --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: docker diff --git a/solution/lab05/ansible/roles/monitoring/tasks/main.yml b/solution/lab05/ansible/roles/monitoring/tasks/main.yml new file mode 100644 index 0000000000..b9e960d551 --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/tasks/main.yml @@ -0,0 +1,83 @@ +--- +- name: Ensure monitoring directory structure exists + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - "{{ monitoring_project_dir }}" + - "{{ monitoring_project_dir }}/loki" + - "{{ monitoring_project_dir }}/promtail" + - "{{ monitoring_project_dir }}/grafana/provisioning/datasources" + - "{{ monitoring_project_dir }}/grafana/provisioning/dashboards" + - "{{ monitoring_project_dir }}/grafana/dashboards" + +- name: Render monitoring stack templates + ansible.builtin.template: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + owner: root + group: root + mode: "0644" + loop: + - src: docker-compose.yml.j2 + dest: "{{ monitoring_project_dir }}/docker-compose.yml" + - src: loki-config.yml.j2 + dest: "{{ monitoring_project_dir }}/loki/config.yml" + - src: promtail-config.yml.j2 + dest: "{{ monitoring_project_dir }}/promtail/config.yml" + - src: grafana-datasource.yml.j2 + dest: "{{ monitoring_project_dir }}/grafana/provisioning/datasources/loki.yml" + - src: grafana-dashboard-provider.yml.j2 + dest: "{{ monitoring_project_dir }}/grafana/provisioning/dashboards/dashboard-provider.yml" + - src: grafana-dashboard.json.j2 + dest: "{{ monitoring_project_dir }}/grafana/dashboards/lab07-observability.json" + +- name: Deploy monitoring stack with Docker Compose v2 + community.docker.docker_compose_v2: + project_src: "{{ monitoring_project_dir }}" + state: present + pull: always + recreate: auto + register: monitoring_compose_result + +- name: Wait for Loki port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ monitoring_loki_port }}" + timeout: 90 + +- name: Wait for Grafana port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ monitoring_grafana_port }}" + timeout: 90 + +- name: Verify Loki readiness + ansible.builtin.uri: + url: "http://127.0.0.1:{{ monitoring_loki_port }}/ready" + method: GET + status_code: 200 + register: monitoring_loki_ready + retries: 10 + delay: 5 + until: monitoring_loki_ready.status == 200 + +- name: Verify Grafana API health + ansible.builtin.uri: + url: "http://127.0.0.1:{{ monitoring_grafana_port }}/api/health" + method: GET + status_code: 200 + user: "{{ monitoring_grafana_admin_user }}" + password: "{{ monitoring_grafana_admin_password }}" + force_basic_auth: true + register: monitoring_grafana_ready + retries: 10 + delay: 5 + until: monitoring_grafana_ready.status == 200 + +- name: Report compose deployment result + ansible.builtin.debug: + var: monitoring_compose_result diff --git a/solution/lab05/ansible/roles/monitoring/templates/docker-compose.yml.j2 b/solution/lab05/ansible/roles/monitoring/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..7c3f4588ef --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/templates/docker-compose.yml.j2 @@ -0,0 +1,143 @@ +version: "3.8" + +services: + loki: + image: grafana/loki:{{ monitoring_loki_version }} + command: + - "-config.file=/etc/loki/config.yml" + ports: + - "{{ monitoring_loki_port }}:{{ monitoring_loki_port }}" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:{{ monitoring_loki_port }}/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "{{ monitoring_resource_profiles.loki.limit_cpus }}" + memory: {{ monitoring_resource_profiles.loki.limit_memory }} + reservations: + cpus: "{{ monitoring_resource_profiles.loki.reserve_cpus }}" + memory: {{ monitoring_resource_profiles.loki.reserve_memory }} + + promtail: + image: grafana/promtail:{{ monitoring_promtail_version }} + command: + - "-config.file=/etc/promtail/config.yml" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - promtail-positions:/tmp + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + depends_on: + loki: + condition: service_healthy + networks: + - logging + deploy: + resources: + limits: + cpus: "{{ monitoring_resource_profiles.promtail.limit_cpus }}" + memory: {{ monitoring_resource_profiles.promtail.limit_memory }} + reservations: + cpus: "{{ monitoring_resource_profiles.promtail.reserve_cpus }}" + memory: {{ monitoring_resource_profiles.promtail.reserve_memory }} + + grafana: + image: grafana/grafana:{{ monitoring_grafana_version }} + environment: + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_SECURITY_ALLOW_EMBEDDING: "false" + GF_SECURITY_ADMIN_USER: "{{ monitoring_grafana_admin_user }}" + GF_SECURITY_ADMIN_PASSWORD: "{{ monitoring_grafana_admin_password }}" + GF_SERVER_ROOT_URL: "http://localhost:{{ monitoring_grafana_port }}" + ports: + - "{{ monitoring_grafana_port }}:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + loki: + condition: service_healthy + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + cpus: "{{ monitoring_resource_profiles.grafana.limit_cpus }}" + memory: {{ monitoring_resource_profiles.grafana.limit_memory }} + reservations: + cpus: "{{ monitoring_resource_profiles.grafana.reserve_cpus }}" + memory: {{ monitoring_resource_profiles.grafana.reserve_memory }} + +{% if monitoring_python_app_enabled %} + app-python: + image: {{ monitoring_python_image }} + environment: + HOST: "0.0.0.0" + PORT: "{{ monitoring_python_container_port }}" + DEBUG: "false" + ports: + - "{{ monitoring_python_host_port }}:{{ monitoring_python_container_port }}" + labels: + logging: "promtail" + app: "devops-python" + networks: + - logging + deploy: + resources: + limits: + cpus: "{{ monitoring_resource_profiles.app.limit_cpus }}" + memory: {{ monitoring_resource_profiles.app.limit_memory }} + reservations: + cpus: "{{ monitoring_resource_profiles.app.reserve_cpus }}" + memory: {{ monitoring_resource_profiles.app.reserve_memory }} +{% endif %} + +{% if monitoring_rust_app_enabled %} + app-rust: + image: {{ monitoring_rust_image }} + environment: + HOST: "0.0.0.0" + PORT: "{{ monitoring_rust_container_port }}" + DEBUG: "false" + RUST_LOG: "info,actix_web=info" + ports: + - "{{ monitoring_rust_host_port }}:{{ monitoring_rust_container_port }}" + labels: + logging: "promtail" + app: "devops-rust" + networks: + - logging + deploy: + resources: + limits: + cpus: "{{ monitoring_resource_profiles.app.limit_cpus }}" + memory: {{ monitoring_resource_profiles.app.limit_memory }} + reservations: + cpus: "{{ monitoring_resource_profiles.app.reserve_cpus }}" + memory: {{ monitoring_resource_profiles.app.reserve_memory }} +{% endif %} + +volumes: + loki-data: + grafana-data: + promtail-positions: + +networks: + logging: + driver: bridge diff --git a/solution/lab05/ansible/roles/monitoring/templates/grafana-dashboard-provider.yml.j2 b/solution/lab05/ansible/roles/monitoring/templates/grafana-dashboard-provider.yml.j2 new file mode 100644 index 0000000000..0e06ea7322 --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/templates/grafana-dashboard-provider.yml.j2 @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: lab07-observability + orgId: 1 + folder: Lab07 + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/solution/lab05/ansible/roles/monitoring/templates/grafana-dashboard.json.j2 b/solution/lab05/ansible/roles/monitoring/templates/grafana-dashboard.json.j2 new file mode 100644 index 0000000000..0aa3c0119e --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/templates/grafana-dashboard.json.j2 @@ -0,0 +1,222 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{app=~\"devops-.*\"}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs Table", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineInterpolation": "smooth", + "lineWidth": 2, + "showPoints": "never" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (app) (rate({app=~\"devops-.*\"}[1m]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 3, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{app=~\"devops-.*\"} | json | level=\"ERROR\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Error Logs", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 4, + "options": { + "displayLabels": [ + "name", + "percent", + "value" + ], + "legend": { + "displayMode": "list", + "placement": "right" + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (level) (count_over_time({app=~\"devops-.*\"} | json [5m]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Log Level Distribution", + "type": "piechart" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "style": "dark", + "tags": [ + "lab07", + "loki", + "observability" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Lab07 Observability", + "uid": "lab07-observability", + "version": 1, + "weekStart": "" +} diff --git a/solution/lab05/ansible/roles/monitoring/templates/grafana-datasource.yml.j2 b/solution/lab05/ansible/roles/monitoring/templates/grafana-datasource.yml.j2 new file mode 100644 index 0000000000..f6029f0b08 --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/templates/grafana-datasource.yml.j2 @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + uid: loki + access: proxy + url: http://loki:{{ monitoring_loki_port }} + isDefault: true + editable: true diff --git a/solution/lab05/ansible/roles/monitoring/templates/loki-config.yml.j2 b/solution/lab05/ansible/roles/monitoring/templates/loki-config.yml.j2 new file mode 100644 index 0000000000..bc28d12937 --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/templates/loki-config.yml.j2 @@ -0,0 +1,45 @@ +auth_enabled: false + +server: + http_listen_port: {{ monitoring_loki_port }} + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: {{ monitoring_schema_version }} + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: {{ monitoring_retention_period }} + allow_structured_metadata: true + volume_enabled: true + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + +querier: + max_concurrent: 4 diff --git a/solution/lab05/ansible/roles/monitoring/templates/promtail-config.yml.j2 b/solution/lab05/ansible/roles/monitoring/templates/promtail-config.yml.j2 new file mode 100644 index 0000000000..29e3850842 --- /dev/null +++ b/solution/lab05/ansible/roles/monitoring/templates/promtail-config.yml.j2 @@ -0,0 +1,34 @@ +server: + http_listen_port: {{ monitoring_promtail_port }} + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yml + +clients: + - url: http://loki:{{ monitoring_loki_port }}/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: + - logging=promtail + pipeline_stages: + - docker: {} + relabel_configs: + - source_labels: ['__meta_docker_container_label_logging'] + regex: promtail + action: keep + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: container + - source_labels: ['__meta_docker_container_label_app'] + target_label: app + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: compose_service + - target_label: job + replacement: docker diff --git a/solution/lab05/ansible/roles/web_app/defaults/main.yml b/solution/lab05/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..bf252b731b --- /dev/null +++ b/solution/lab05/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,20 @@ +--- +web_app_app_name: "{{ app_name | default('devops-app') }}" +web_app_docker_image: "{{ docker_image | default('your_dockerhub_username/devops-info-service') }}" +web_app_docker_tag: "{{ docker_tag | default('latest') }}" +web_app_app_port: "{{ app_port | default(8000) }}" +web_app_internal_port: "{{ app_internal_port | default(web_app_app_port) }}" +web_app_restart_policy: "{{ app_restart_policy | default('unless-stopped') }}" +web_app_healthcheck_path: "{{ app_healthcheck_path | default('/health') }}" +web_app_healthcheck_timeout: "{{ app_healthcheck_timeout | default(60) }}" + +web_app_compose_project_dir: "{{ compose_project_dir | default('/opt/' ~ web_app_app_name) }}" +web_app_docker_compose_version: "{{ docker_compose_version | default('3.8') }}" +web_app_environment: {} +web_app_network_name: web_app_network +web_app_compose_pull_policy: missing + +# Set to true to remove application completely. +# Wipe only: ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe +# Clean install: ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" +web_app_wipe: false diff --git a/solution/lab05/ansible/roles/web_app/meta/main.yml b/solution/lab05/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..67c2457ecb --- /dev/null +++ b/solution/lab05/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + # Docker runtime must be available before Compose deployment starts. + - role: docker diff --git a/solution/lab05/ansible/roles/web_app/tasks/main.yml b/solution/lab05/ansible/roles/web_app/tasks/main.yml new file mode 100644 index 0000000000..3b0c165515 --- /dev/null +++ b/solution/lab05/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,85 @@ +--- +# Wipe logic runs first and is additionally protected by variable + tag checks. +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Deploy web application with Docker Compose + become: true + tags: + - app_deploy + - compose + block: + - name: Ensure application project directory exists + ansible.builtin.file: + path: "{{ web_app_compose_project_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Render Docker Compose configuration + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ web_app_compose_project_dir }}/docker-compose.yml" + owner: root + group: root + mode: "0644" + + - name: Inspect existing container with app name + community.docker.docker_container_info: + name: "{{ web_app_app_name }}" + register: web_app_existing_container + failed_when: false + + - name: Remove legacy standalone container with conflicting name + community.docker.docker_container: + name: "{{ web_app_app_name }}" + state: absent + when: + - web_app_existing_container.exists | default(false) + - > + ( + web_app_existing_container.container.Config.Labels | default({}) + ).get('com.docker.compose.project', '') == '' + + - name: Deploy stack with Docker Compose v2 + community.docker.docker_compose_v2: + project_src: "{{ web_app_compose_project_dir }}" + state: present + pull: "{{ web_app_compose_pull_policy }}" + recreate: auto + register: web_app_compose_result + + - name: Wait for application port to be ready + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ web_app_app_port }}" + timeout: "{{ web_app_healthcheck_timeout }}" + + - name: Verify health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ web_app_app_port }}{{ web_app_healthcheck_path }}" + method: GET + status_code: 200 + register: web_app_health_result + retries: 5 + delay: 3 + until: web_app_health_result.status == 200 + + rescue: + - name: Report compose deployment failure details + ansible.builtin.debug: + msg: >- + Compose deployment failed for {{ web_app_app_name }} + in {{ web_app_compose_project_dir }} + + - name: Fail deployment when compose block errors + ansible.builtin.fail: + msg: "Web application deployment failed. Check docker compose logs on target host." + + always: + - name: Log deployment block completion + ansible.builtin.debug: + msg: "Deployment block finished for {{ web_app_app_name }}" diff --git a/solution/lab05/ansible/roles/web_app/tasks/wipe.yml b/solution/lab05/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..9047832b25 --- /dev/null +++ b/solution/lab05/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,33 @@ +--- +- name: Wipe web application deployment + when: web_app_wipe | bool + become: true + tags: + - web_app_wipe + block: + - name: Stop and remove application stack + community.docker.docker_compose_v2: + project_src: "{{ web_app_compose_project_dir }}" + state: absent + remove_images: local + failed_when: false + + - name: Remove legacy standalone container + community.docker.docker_container: + name: "{{ web_app_app_name }}" + state: absent + failed_when: false + + - name: Remove docker-compose file + ansible.builtin.file: + path: "{{ web_app_compose_project_dir }}/docker-compose.yml" + state: absent + + - name: Remove application directory + ansible.builtin.file: + path: "{{ web_app_compose_project_dir }}" + state: absent + + - name: Confirm wipe completion + ansible.builtin.debug: + msg: "Application {{ web_app_app_name }} wiped successfully" diff --git a/solution/lab05/ansible/roles/web_app/templates/docker-compose.yml.j2 b/solution/lab05/ansible/roles/web_app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..5b688af6c4 --- /dev/null +++ b/solution/lab05/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,20 @@ +--- +services: + {{ web_app_app_name }}: + image: "{{ web_app_docker_image }}:{{ web_app_docker_tag }}" + container_name: "{{ web_app_app_name }}" + ports: + - "{{ web_app_app_port }}:{{ web_app_internal_port }}" +{% if web_app_environment | length > 0 %} + environment: +{% for key, value in web_app_environment | dictsort %} + {{ key }}: "{{ value }}" +{% endfor %} +{% endif %} + restart: "{{ web_app_restart_policy }}" + networks: + - "{{ web_app_network_name }}" + +networks: + {{ web_app_network_name }}: + driver: bridge diff --git a/solution/monitoring/.env.example b/solution/monitoring/.env.example new file mode 100644 index 0000000000..1666dfe46d --- /dev/null +++ b/solution/monitoring/.env.example @@ -0,0 +1,2 @@ +GF_SECURITY_ADMIN_USER=admin +GF_SECURITY_ADMIN_PASSWORD=change-me-now diff --git a/solution/monitoring/docker-compose.yml b/solution/monitoring/docker-compose.yml new file mode 100644 index 0000000000..47e2585ff1 --- /dev/null +++ b/solution/monitoring/docker-compose.yml @@ -0,0 +1,177 @@ +services: + loki: + image: grafana/loki:3.0.0 + command: + - "-config.file=/etc/loki/config.yml" + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + + prometheus: + image: prom/prometheus:v3.9.0 + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=15d" + - "--storage.tsdb.retention.size=10GB" + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + + promtail: + image: grafana/promtail:3.0.0 + command: + - "-config.file=/etc/promtail/config.yml" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - promtail-positions:/tmp + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + depends_on: + loki: + condition: service_healthy + networks: + - logging + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + reservations: + cpus: "0.10" + memory: 128M + + grafana: + image: grafana/grafana:12.3.1 + env_file: + - .env + environment: + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_SECURITY_ALLOW_EMBEDDING: "false" + GF_SERVER_ROOT_URL: "http://localhost:3000" + GF_USERS_DEFAULT_THEME: "light" + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + loki: + condition: service_healthy + prometheus: + condition: service_healthy + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + reservations: + cpus: "0.25" + memory: 256M + + app-python: + build: + context: ../app_python + environment: + HOST: "0.0.0.0" + PORT: "5000" + DEBUG: "false" + ports: + - "8000:5000" + labels: + logging: "promtail" + app: "devops-python" + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.10" + memory: 128M + + app-rust: + build: + context: ../app_rust + environment: + HOST: "0.0.0.0" + PORT: "5000" + DEBUG: "false" + RUST_LOG: "info,actix_web=info" + ports: + - "8001:5000" + labels: + logging: "promtail" + app: "devops-rust" + networks: + - logging + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.10" + memory: 128M + +volumes: + prometheus-data: + loki-data: + grafana-data: + promtail-positions: + +networks: + logging: + driver: bridge diff --git a/solution/monitoring/docs/LAB07.md b/solution/monitoring/docs/LAB07.md new file mode 100644 index 0000000000..9d3a31b061 --- /dev/null +++ b/solution/monitoring/docs/LAB07.md @@ -0,0 +1,178 @@ +# LAB07 - Observability & Logging with Loki Stack + +## 1. Architecture + +```text +app-python ----\ + \ +app-rust -------> Docker stdout/stderr -> Promtail -> Loki -> Grafana + / +docker daemon --/ +``` + +- `app-python` writes structured JSON logs to stdout. +- `app-rust` writes regular container logs to stdout. +- `Promtail` discovers only containers with label `logging=promtail`. +- `Loki` stores logs on local filesystem with TSDB schema `v13`. +- `Grafana` gets Loki via provisioned datasource and loads the dashboard from JSON. + +## 2. Project Structure + +```text +solution/monitoring/ + docker-compose.yml + .env.example + loki/config.yml + promtail/config.yml + grafana/provisioning/datasources/loki.yml + grafana/provisioning/dashboards/dashboard-provider.yml + grafana/dashboards/lab07-observability.json + docs/LAB07.md +``` + +## 3. Setup Guide + +1. Copy the environment file: + +```bash +cd solution/monitoring +cp .env.example .env +``` + +2. Set a real Grafana admin password in `.env`. + +3. Build and start the stack: + +```bash +docker compose up -d --build +docker compose ps +``` + +4. Generate traffic: + +```bash +for i in $(seq 1 20); do curl http://localhost:8000/; done +for i in $(seq 1 20); do curl http://localhost:8000/health; done +for i in $(seq 1 10); do curl http://localhost:8001/; done +for i in $(seq 1 10); do curl http://localhost:8001/health; done +``` + +5. Open Grafana at `http://localhost:3000` and log in with `.env` credentials. + +## 4. Configuration + +### Loki + +- `schema_config` uses `store: tsdb` and `schema: v13`. +- `filesystem` is used as the object store for a single-node setup. +- `limits_config.retention_period: 168h` keeps logs for 7 days. +- `compactor.retention_enabled: true` removes expired log data. + +Example snippet: + +```yaml +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 +``` + +### Promtail + +- Docker service discovery reads metadata from `/var/run/docker.sock`. +- Container label filter keeps only services with `logging=promtail`. +- Relabeling copies the `app` label to Loki streams. +- `docker` pipeline stage unwraps Docker log envelopes. + +Example snippet: + +```yaml +filters: + - name: label + values: + - logging=promtail +``` + +## 5. Application Logging + +Structured logging is implemented in `solution/app_python/app.py` as: + +- custom `JSONFormatter` +- request middleware +- startup/shutdown event logs +- request context fields: `method`, `path`, `status_code`, `client_ip`, `user_agent` + +JSON example: + +```json +{ + "timestamp": "2026-03-12T10:00:00+00:00", + "level": "INFO", + "logger": "app", + "message": "HTTP request completed", + "method": "GET", + "path": "/health", + "status_code": 200, + "client_ip": "127.0.0.1" +} +``` + +## 6. Dashboard + +Provisioned dashboard: `Lab07 Observability`. + +Panels: + +1. `Logs Table` -> `{app=~"devops-.*"}` +2. `Request Rate` -> `sum by (app) (rate({app=~"devops-.*"}[1m]))` +3. `Error Logs` -> `{app=~"devops-.*"} | json | level="ERROR"` +4. `Log Level Distribution` -> `sum by (level) (count_over_time({app=~"devops-.*"} | json [5m]))` + +Additional Explore queries: + +```logql +{app="devops-python"} +{app="devops-python"} |= "ERROR" +{app="devops-python"} | json | method="GET" +``` + +## 7. Production Config + +- Anonymous Grafana access is disabled. +- Admin credentials are moved to `.env`. +- Resource limits and reservations are set for every service. +- Loki and Grafana include health checks. +- Persistent volumes are used for Loki and Grafana data. + +## 8. Testing + +Application/API checks: + +```bash +curl http://localhost:3100/ready +curl http://localhost:3000/api/health +curl http://localhost:8000/health +curl http://localhost:8001/health +docker compose ps +docker compose logs app-python --tail=20 +``` + +## 9. Challenges + +- Docker log scraping through `/var/lib/docker/containers` and `/var/run/docker.sock` is Linux-host oriented. On Windows/macOS, this stack is best run on a Linux VM. +- Only the Python app emits JSON logs, so LogQL expressions with `| json` are intended mainly for `devops-python`. +- Grafana datasource and dashboard are provisioned automatically to reduce manual setup and make the stack repeatable. + +## 10. Evidence Checklist + +Add these artifacts before submission: + +- Screenshot of Grafana Explore with logs from at least 3 containers. +- Screenshot of JSON log output from `app-python`. +- Screenshot of Grafana Explore showing logs from both `app-python` and `app-rust`. +- Screenshot of dashboard with all 4 panels populated. +- Output or screenshot of `docker compose ps` with healthy `loki` and `grafana`. +- Screenshot of Grafana login page proving anonymous access is disabled. +- Optional bonus: output of `ansible-playbook playbooks/deploy-monitoring.yml` run twice, where the second run is idempotent. diff --git a/solution/monitoring/docs/LAB08.md b/solution/monitoring/docs/LAB08.md new file mode 100644 index 0000000000..7b5b9579fa --- /dev/null +++ b/solution/monitoring/docs/LAB08.md @@ -0,0 +1,125 @@ +# LAB08 - Metrics & Monitoring with Prometheus + +## 1. Overview +This lab extends the monitoring stack from Lab 7 by adding Prometheus-based metrics collection and Grafana-based visualization for the Python FastAPI service. The application exposes a Prometheus `/metrics` endpoint, Prometheus scrapes the application and monitoring components, and Grafana visualizes the collected time series. + +Architecture flow: + +```text +app-python -> Prometheus -> Grafana +app-python -> Promtail -> Loki -> Grafana +``` + +## 2. Implemented Monitoring +The Python application was instrumented with HTTP and service-specific metrics. + +HTTP metrics: + +- `app_http_requests_total` +- `app_http_request_duration_seconds` +- `app_http_active_requests` + +Service-specific metrics: + +- `app_root_requests_total` +- `app_system_info_duration_seconds` +- `app_uptime_seconds` + +Labels used: + +- request counter: `method`, `endpoint`, `status_code` +- request duration histogram: `method`, `endpoint` + +Prometheus is configured with: + +- scrape interval: `15s` +- evaluation interval: `15s` +- retention: `15d` +- retention size: `10GB` + +Configured scrape targets: + +- `prometheus:9090` +- `app-python:5000/metrics` +- `loki:3100/metrics` +- `grafana:3000/metrics` + +The Grafana dashboard includes the following panels: + +- Request Rate +- Error Rate +- Request Duration p95 +- Request Duration Heatmap +- Active Requests +- Status Code Distribution +- Uptime + +## 3. Production-Oriented Configuration +The monitoring stack includes operational settings required for stable runtime behavior. + +- health checks are configured for `app-python`, `prometheus`, `loki`, and `grafana` +- resource limits are configured for Prometheus, Loki, Grafana, and both applications +- persistent volumes are configured for Prometheus, Loki, and Grafana + +Resource limits: + +- Prometheus: `1G`, `1.0 CPU` +- Loki: `1G`, `1.0 CPU` +- Grafana: `512M`, `0.5 CPU` +- Applications: `256M`, `0.5 CPU` + +Important networking note: + +- inside Docker the application target is `app-python:5000`, not `localhost:8000` + +## 4. Evidence +### 4.1 Metrics Endpoint +The application exposes Prometheus metrics in text format on `http://localhost:8000/metrics`. + +![Metrics endpoint screenshot](screenshots/lab08/lab08-metrics-endpoint.png) + +### 4.2 Prometheus Targets +Prometheus successfully discovers and scrapes the configured monitoring targets. + +![Prometheus targets screenshot](screenshots/lab08/lab08-prometheus-targets.png) + +### 4.3 PromQL Query Result +The metrics can be queried directly in Prometheus. The following screenshot shows a successful query result for the application request metric. + +![PromQL query screenshot](screenshots/lab08/lab08-promql-query.png) + +### 4.4 Grafana Dashboard +Grafana visualizes the collected metrics through the provisioned Lab 8 dashboard. + +![Grafana dashboard screenshot](screenshots/lab08/lab08-grafana-dashboard.png) + +### 4.5 Persistence Verification +The dashboard remained available after restarting the stack, which confirms that Grafana data persistence is working with the configured volume. + +![Persistence check screenshot](screenshots/lab08/lab08-persistence-check.png) + +## 5. Validation Results +Example PromQL queries used during validation: + +- `sum(rate(app_http_requests_total[5m]))` +- `sum(rate(app_http_requests_total{status_code=~"4..|5.."}[5m]))` +- `histogram_quantile(0.95, sum by (le) (rate(app_http_request_duration_seconds_bucket[5m])))` +- `sum by (status_code) (increase(app_http_requests_total[5m]))` +- `sum by (endpoint) (increase(app_http_requests_total[5m]))` +- `max(app_uptime_seconds)` + +Container status collected during verification: + +```text +> docker compose ps +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +monitoring-app-python-1 monitoring-app-python "python app.py" app-python 2 minutes ago Up 2 minutes (unhealthy) 0.0.0.0:8000->5000/tcp, [::]:8000->5000/tcp +monitoring-app-rust-1 monitoring-app-rust "/app/devops-info-se..." app-rust 2 minutes ago Up 2 minutes 0.0.0.0:8001->5000/tcp, [::]:8001->5000/tcp +monitoring-grafana-1 grafana/grafana:12.3.1 "/run.sh" grafana 2 minutes ago Up About a minute (healthy) 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp +monitoring-loki-1 grafana/loki:3.0.0 "/usr/bin/loki -conf..." loki 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:3100->3100/tcp, [::]:3100->3100/tcp +monitoring-prometheus-1 prom/prometheus:v3.9.0 "/bin/prometheus --c..." prometheus 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:9090->9090/tcp, [::]:9090->9090/tcp +monitoring-promtail-1 grafana/promtail:3.0.0 "/usr/bin/promtail -..." promtail 2 minutes ago Up About a minute +``` + +## 6. Conclusion +The lab objective was completed by adding Prometheus instrumentation to the FastAPI service, configuring Prometheus scraping, provisioning Grafana with a Prometheus datasource, and creating a dashboard for request rate, errors, latency, active requests, status code distribution, and uptime. Together with the logging pipeline from Lab 7, this setup provides both metrics-based and log-based observability for the service. diff --git a/solution/monitoring/docs/LAB08_MANUAL_EVIDENCE.md b/solution/monitoring/docs/LAB08_MANUAL_EVIDENCE.md new file mode 100644 index 0000000000..722d4c05d5 --- /dev/null +++ b/solution/monitoring/docs/LAB08_MANUAL_EVIDENCE.md @@ -0,0 +1,105 @@ +# LAB08 Manual Evidence + +Save all screenshots to: + +- [screenshots/lab08](C:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/monitoring/docs/screenshots/lab08) + +## 1. Screenshot: `/metrics` +- File name: + - [lab08-metrics-endpoint.png](C:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/monitoring/docs/screenshots/lab08/lab08-metrics-endpoint.png) +- How to open: + - start the stack from `solution/monitoring` + - open `http://localhost:8000/metrics` +- What should be visible: + - Prometheus text output + - custom metrics like `app_http_requests_total` +- Already connected in main report: + - `LAB08.md` already references this exact file path + +## 2. Screenshot: Prometheus targets +- File name: + - [lab08-prometheus-targets.png](C:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/monitoring/docs/screenshots/lab08/lab08-prometheus-targets.png) +- How to open: + - open `http://localhost:9090/targets` +- What should be visible: + - configured targets + - target state `UP` where expected +- Already connected in main report: + - `LAB08.md` already references this exact file path + +## 3. Screenshot: PromQL query +- File name: + - [lab08-promql-query.png](C:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/monitoring/docs/screenshots/lab08/lab08-promql-query.png) +- How to open: + - open `http://localhost:9090/query` + - run: + +```promql +sum(rate(app_http_requests_total[5m])) +``` + +- What should be visible: + - successful query result with time series data +- Already connected in main report: + - `LAB08.md` already references this exact file path + +## 4. Screenshot: Grafana dashboard +- File name: + - [lab08-grafana-dashboard.png](C:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/monitoring/docs/screenshots/lab08/lab08-grafana-dashboard.png) +- How to open: + - open `http://localhost:3000` + - log in with the credentials from `solution/monitoring/.env` + - open dashboard `Lab08 Prometheus Monitoring` +- What should be visible: + - populated dashboard panels + - request rate, error rate, latency, active requests, status distribution, uptime +- Already connected in main report: + - `LAB08.md` already references this exact file path + +## 5. Screenshot: Persistence check +- File name: + - [lab08-persistence-check.png](C:/Users/xzsay/PycharmProjects/DevOps-Core-Course/solution/monitoring/docs/screenshots/lab08/lab08-persistence-check.png) +- How to open: + - create or edit a dashboard in Grafana + - run `docker compose down` + - run `docker compose up -d` + - open Grafana again and verify the dashboard still exists +- What should be visible: + - the dashboard after restart +- Already connected in main report: + - `LAB08.md` already references this exact file path + +## 6. Terminal Output Required +You should also capture terminal output for: + +- `docker compose ps` + +How to get it: + +```powershell +cd C:\Users\xzsay\PycharmProjects\DevOps-Core-Course\solution\monitoring +docker compose ps +``` + +Where to place it: + +- paste into `LAB08.md` under `TODO_MANUAL_COMMAND_OUTPUT` + +Format: + +```text +Paste as a fenced code block. +``` + +## 7. Short Manual Notes Required +Add a short note for the persistence check. + +What to write: + +- 2-4 sentences +- whether the dashboard stayed after restart +- whether any extra action was needed + +Where to place it: + +- `LAB08.md` under `TODO_MANUAL_VERIFICATION` diff --git a/solution/monitoring/docs/screenshots/lab08/lab08-grafana-dashboard.png b/solution/monitoring/docs/screenshots/lab08/lab08-grafana-dashboard.png new file mode 100644 index 0000000000..2de64f6de3 Binary files /dev/null and b/solution/monitoring/docs/screenshots/lab08/lab08-grafana-dashboard.png differ diff --git a/solution/monitoring/docs/screenshots/lab08/lab08-metrics-endpoint.png b/solution/monitoring/docs/screenshots/lab08/lab08-metrics-endpoint.png new file mode 100644 index 0000000000..a8db6993f8 Binary files /dev/null and b/solution/monitoring/docs/screenshots/lab08/lab08-metrics-endpoint.png differ diff --git a/solution/monitoring/docs/screenshots/lab08/lab08-persistence-check.png b/solution/monitoring/docs/screenshots/lab08/lab08-persistence-check.png new file mode 100644 index 0000000000..d465c48c6d Binary files /dev/null and b/solution/monitoring/docs/screenshots/lab08/lab08-persistence-check.png differ diff --git a/solution/monitoring/docs/screenshots/lab08/lab08-prometheus-targets.png b/solution/monitoring/docs/screenshots/lab08/lab08-prometheus-targets.png new file mode 100644 index 0000000000..0c2bd5a733 Binary files /dev/null and b/solution/monitoring/docs/screenshots/lab08/lab08-prometheus-targets.png differ diff --git a/solution/monitoring/docs/screenshots/lab08/lab08-promql-query.png b/solution/monitoring/docs/screenshots/lab08/lab08-promql-query.png new file mode 100644 index 0000000000..3667b4d343 Binary files /dev/null and b/solution/monitoring/docs/screenshots/lab08/lab08-promql-query.png differ diff --git a/solution/monitoring/grafana/dashboards/lab07-observability.json b/solution/monitoring/grafana/dashboards/lab07-observability.json new file mode 100644 index 0000000000..3923b36ac6 --- /dev/null +++ b/solution/monitoring/grafana/dashboards/lab07-observability.json @@ -0,0 +1,229 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{app=~\"devops-.*\"}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs Table", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineInterpolation": "smooth", + "lineWidth": 2, + "showPoints": "never" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (app) (rate({app=~\"devops-.*\"}[1m]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 3, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{app=~\"devops-.*\"} | json | level=\"ERROR\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Error Logs", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 4, + "options": { + "displayLabels": [ + "name", + "percent", + "value" + ], + "legend": { + "displayMode": "list", + "placement": "right" + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (level) (count_over_time({app=~\"devops-.*\"} | json [5m]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Log Level Distribution", + "type": "piechart" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "style": "dark", + "tags": [ + "lab07", + "loki", + "observability" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Lab07 Observability", + "uid": "lab07-observability", + "version": 1, + "weekStart": "" +} diff --git a/solution/monitoring/grafana/dashboards/lab08-prometheus-monitoring.json b/solution/monitoring/grafana/dashboards/lab08-prometheus-monitoring.json new file mode 100644 index 0000000000..5b427226c8 --- /dev/null +++ b/solution/monitoring/grafana/dashboards/lab08-prometheus-monitoring.json @@ -0,0 +1,442 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineInterpolation": "smooth", + "lineWidth": 2, + "showPoints": "never" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(app_http_requests_total{endpoint!=\"/metrics\"}[5m]))", + "legendFormat": "requests/sec", + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(app_http_requests_total{endpoint!=\"/metrics\",status_code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(app_http_requests_total{endpoint!=\"/metrics\"}[5m])), 1e-9)", + "refId": "A" + } + ], + "title": "Error Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineInterpolation": "smooth", + "lineWidth": 2, + "showPoints": "never" + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(app_http_request_duration_seconds_bucket{endpoint!=\"/metrics\"}[5m])))", + "legendFormat": "p95", + "refId": "A" + } + ], + "title": "Request Duration p95", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "calculate": false, + "cellGap": 2, + "cellValues": {}, + "color": { + "mode": "scheme" + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": false + }, + "rowsFrame": { + "layout": "auto" + }, + "showValue": "never", + "tooltip": { + "mode": "single" + }, + "yAxis": { + "axisPlacement": "left" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (le) (rate(app_http_request_duration_seconds_bucket{endpoint!=\"/metrics\"}[5m]))", + "format": "heatmap", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Request Duration Heatmap", + "type": "heatmap" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 5 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 6, + "x": 12, + "y": 8 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "app_http_active_requests", + "refId": "A" + } + ], + "title": "Active Requests", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "showPoints": "never" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (status_code) (increase(app_http_requests_total{endpoint!=\"/metrics\"}[5m]))", + "legendFormat": "{{status_code}}", + "refId": "A" + } + ], + "title": "Status Code Distribution", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "max(app_uptime_seconds)", + "refId": "A" + } + ], + "title": "Uptime", + "type": "stat" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "style": "dark", + "tags": [ + "lab08", + "prometheus", + "monitoring" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Lab08 Prometheus Monitoring", + "uid": "lab08-prometheus-monitoring", + "version": 1, + "weekStart": "" +} diff --git a/solution/monitoring/grafana/provisioning/dashboards/dashboard-provider.yml b/solution/monitoring/grafana/provisioning/dashboards/dashboard-provider.yml new file mode 100644 index 0000000000..0e06ea7322 --- /dev/null +++ b/solution/monitoring/grafana/provisioning/dashboards/dashboard-provider.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: lab07-observability + orgId: 1 + folder: Lab07 + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/solution/monitoring/grafana/provisioning/datasources/loki.yml b/solution/monitoring/grafana/provisioning/datasources/loki.yml new file mode 100644 index 0000000000..a17ad3a020 --- /dev/null +++ b/solution/monitoring/grafana/provisioning/datasources/loki.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + uid: loki + access: proxy + url: http://loki:3100 + isDefault: true + editable: true diff --git a/solution/monitoring/grafana/provisioning/datasources/prometheus.yml b/solution/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000000..c1edfae387 --- /dev/null +++ b/solution/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 + editable: true diff --git a/solution/monitoring/loki/config.yml b/solution/monitoring/loki/config.yml new file mode 100644 index 0000000000..658db64709 --- /dev/null +++ b/solution/monitoring/loki/config.yml @@ -0,0 +1,46 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + +schema_config: + configs: + - from: "2024-01-01" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 168h + allow_structured_metadata: true + volume_enabled: true + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + delete_request_store: filesystem + +querier: + max_concurrent: 4 diff --git a/solution/monitoring/prometheus/prometheus.yml b/solution/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000000..d6b362228f --- /dev/null +++ b/solution/monitoring/prometheus/prometheus.yml @@ -0,0 +1,27 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: + - localhost:9090 + + - job_name: app + metrics_path: /metrics + static_configs: + - targets: + - app-python:5000 + + - job_name: loki + metrics_path: /metrics + static_configs: + - targets: + - loki:3100 + + - job_name: grafana + metrics_path: /metrics + static_configs: + - targets: + - grafana:3000 diff --git a/solution/monitoring/promtail/config.yml b/solution/monitoring/promtail/config.yml new file mode 100644 index 0000000000..28b6b8ef8b --- /dev/null +++ b/solution/monitoring/promtail/config.yml @@ -0,0 +1,34 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: + - logging=promtail + pipeline_stages: + - docker: {} + relabel_configs: + - source_labels: ['__meta_docker_container_label_logging'] + regex: promtail + action: keep + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: container + - source_labels: ['__meta_docker_container_label_app'] + target_label: app + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: compose_service + - target_label: job + replacement: docker diff --git a/solution/monitoring/screenshots/.gitkeep b/solution/monitoring/screenshots/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/solution/monitoring/screenshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/solution/pulumi/.gitignore b/solution/pulumi/.gitignore new file mode 100644 index 0000000000..bf837c63cb --- /dev/null +++ b/solution/pulumi/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.pyc + +# Stack config can contain secrets +Pulumi.*.yaml +!Pulumi.dev.yaml.example diff --git a/solution/pulumi/Pulumi.dev.yaml.example b/solution/pulumi/Pulumi.dev.yaml.example new file mode 100644 index 0000000000..e07f9e3d25 --- /dev/null +++ b/solution/pulumi/Pulumi.dev.yaml.example @@ -0,0 +1,12 @@ +config: + yandex:cloudId: "" + yandex:folderId: "" + yandex:serviceAccountKeyFile: "C:/Users//.yc/sa-key.json" + yandex:zone: "ru-central1-d" + lab04-yandex:projectName: "lab04" + lab04-yandex:zone: "ru-central1-d" + lab04-yandex:subnetCidr: "10.10.0.0/24" + lab04-yandex:imageFamily: "ubuntu-2404-lts" + lab04-yandex:sshUser: "ubuntu" + lab04-yandex:sshPublicKeyPath: "C:/Users//.ssh/your_key.pub" + lab04-yandex:myIpCidr: "/32" diff --git a/solution/pulumi/Pulumi.yaml b/solution/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..7ddd9fabaf --- /dev/null +++ b/solution/pulumi/Pulumi.yaml @@ -0,0 +1,6 @@ +name: lab04-yandex +runtime: + name: python + options: + virtualenv: venv +description: Lab04 Pulumi stack for Yandex Cloud (VM + VPC + SG) diff --git a/solution/pulumi/README.md b/solution/pulumi/README.md new file mode 100644 index 0000000000..c45974d1c0 --- /dev/null +++ b/solution/pulumi/README.md @@ -0,0 +1,25 @@ +# Pulumi (Yandex Cloud) for Lab 04 + +## Files +- `__main__.py`: infrastructure code (VM + VPC + SG) +- `Pulumi.yaml`: project metadata +- `Pulumi.dev.yaml.example`: config template +- `requirements.txt`: dependencies + +## Prepare config +1. Create and activate virtual environment. +2. Install dependencies from `requirements.txt`. +3. Copy `Pulumi.dev.yaml.example` to `Pulumi.dev.yaml`. +4. Replace placeholders with real values. + +## Commands +```powershell +cd solution/pulumi +pulumi login +pulumi stack init dev +pulumi preview +pulumi up +pulumi stack output vmPublicIp +pulumi stack output sshCommand +pulumi destroy +``` diff --git a/solution/pulumi/__main__.py b/solution/pulumi/__main__.py new file mode 100644 index 0000000000..8261b310b3 --- /dev/null +++ b/solution/pulumi/__main__.py @@ -0,0 +1,97 @@ +import pulumi +import pulumi_yandex as yandex + +cfg = pulumi.Config() + +project_name = cfg.get("projectName") or "lab04" +zone = cfg.get("zone") or "ru-central1-d" +subnet_cidr = cfg.get("subnetCidr") or "10.10.0.0/24" +image_family = cfg.get("imageFamily") or "ubuntu-2404-lts" +ssh_user = cfg.get("sshUser") or "ubuntu" +my_ip_cidr = cfg.require("myIpCidr") +ssh_public_key_path = cfg.require("sshPublicKeyPath") + +with open(ssh_public_key_path, "r", encoding="utf-8") as f: + ssh_public_key = f.read().strip() + +image = yandex.get_compute_image(family=image_family) + +network = yandex.VpcNetwork( + f"{project_name}-network", + name=f"{project_name}-network", +) + +subnet = yandex.VpcSubnet( + f"{project_name}-subnet", + name=f"{project_name}-subnet", + zone=zone, + network_id=network.id, + v4_cidr_blocks=[subnet_cidr], +) + +security_group = yandex.VpcSecurityGroup( + f"{project_name}-sg", + name=f"{project_name}-sg", + network_id=network.id, + ingresses=[ + yandex.VpcSecurityGroupIngressArgs( + description="SSH from my IP", + protocol="TCP", + port=22, + v4_cidr_blocks=[my_ip_cidr], + ), + yandex.VpcSecurityGroupIngressArgs( + description="HTTP", + protocol="TCP", + port=80, + v4_cidr_blocks=["0.0.0.0/0"], + ), + yandex.VpcSecurityGroupIngressArgs( + description="App port", + protocol="TCP", + port=5000, + v4_cidr_blocks=["0.0.0.0/0"], + ), + ], + egresses=[ + yandex.VpcSecurityGroupEgressArgs( + description="Allow all egress", + protocol="ANY", + from_port=0, + to_port=65535, + v4_cidr_blocks=["0.0.0.0/0"], + ) + ], +) + +vm = yandex.ComputeInstance( + f"{project_name}-vm", + name=f"{project_name}-vm", + zone=zone, + platform_id="standard-v2", + resources=yandex.ComputeInstanceResourcesArgs( + cores=2, + memory=1, + core_fraction=20, + ), + boot_disk=yandex.ComputeInstanceBootDiskArgs( + initialize_params=yandex.ComputeInstanceBootDiskInitializeParamsArgs( + image_id=image.image_id, + size=10, + type="network-hdd", + ), + ), + network_interfaces=[ + yandex.ComputeInstanceNetworkInterfaceArgs( + subnet_id=subnet.id, + nat=True, + security_group_ids=[security_group.id], + ) + ], + metadata={ + "ssh-keys": f"{ssh_user}:{ssh_public_key}", + }, +) + +pulumi.export("vmPublicIp", vm.network_interfaces[0].nat_ip_address) +pulumi.export("sshCommand", pulumi.Output.format("ssh -i ~/.ssh/devops45labs {0}@{1}", ssh_user, vm.network_interfaces[0].nat_ip_address)) diff --git a/solution/pulumi/requirements.txt b/solution/pulumi/requirements.txt new file mode 100644 index 0000000000..c6ba942e35 --- /dev/null +++ b/solution/pulumi/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-yandex>=0.15.0 diff --git a/solution/terraform/.gitignore b/solution/terraform/.gitignore new file mode 100644 index 0000000000..7456d0eecd --- /dev/null +++ b/solution/terraform/.gitignore @@ -0,0 +1,16 @@ +# Terraform local state and cache +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +.terraform.lock.hcl + +# Local variable overrides and secrets +terraform.tfvars +*.tfvars +*.auto.tfvars + +# Credentials and keys +*.json +*.pem +*.key diff --git a/solution/terraform/.tflint.hcl b/solution/terraform/.tflint.hcl new file mode 100644 index 0000000000..427121c3ef --- /dev/null +++ b/solution/terraform/.tflint.hcl @@ -0,0 +1,4 @@ +plugin "terraform" { + enabled = true + preset = "recommended" +} diff --git a/solution/terraform/main.tf b/solution/terraform/main.tf new file mode 100644 index 0000000000..d6d282f38a --- /dev/null +++ b/solution/terraform/main.tf @@ -0,0 +1,95 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = "~> 0.140" + } + } +} + +provider "yandex" { + service_account_key_file = var.sa_key_file + cloud_id = var.cloud_id + folder_id = var.folder_id + zone = var.zone +} + +data "yandex_compute_image" "ubuntu" { + family = var.image_family +} + +resource "yandex_vpc_network" "this" { + name = "${var.project_name}-network" +} + +resource "yandex_vpc_subnet" "this" { + name = "${var.project_name}-subnet" + zone = var.zone + network_id = yandex_vpc_network.this.id + v4_cidr_blocks = [var.subnet_cidr] +} + +resource "yandex_vpc_security_group" "this" { + name = "${var.project_name}-sg" + network_id = yandex_vpc_network.this.id + + ingress { + description = "SSH from my IP" + protocol = "TCP" + port = 22 + v4_cidr_blocks = [var.my_ip_cidr] + } + + ingress { + description = "HTTP" + protocol = "TCP" + port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "App port" + protocol = "TCP" + port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "Allow all egress" + protocol = "ANY" + from_port = 0 + to_port = 65535 + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "yandex_compute_instance" "vm" { + name = "${var.project_name}-vm" + platform_id = "standard-v2" + + resources { + cores = 2 + memory = 1 + core_fraction = 20 + } + + boot_disk { + initialize_params { + image_id = data.yandex_compute_image.ubuntu.id + size = 10 + type = "network-hdd" + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.this.id + nat = true + security_group_ids = [yandex_vpc_security_group.this.id] + } + + metadata = { + ssh-keys = "${var.ssh_user}:${trimspace(file(var.ssh_public_key_path))}" + } +} diff --git a/solution/terraform/outputs.tf b/solution/terraform/outputs.tf new file mode 100644 index 0000000000..67b7d28f01 --- /dev/null +++ b/solution/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "vm_public_ip" { + description = "Public IP address of the VM." + value = yandex_compute_instance.vm.network_interface[0].nat_ip_address +} + +output "ssh_command" { + description = "SSH command to connect to VM." + value = "ssh -i ~/.ssh/lab04_yc ${var.ssh_user}@${yandex_compute_instance.vm.network_interface[0].nat_ip_address}" +} diff --git a/solution/terraform/terraform.tfvars.example b/solution/terraform/terraform.tfvars.example new file mode 100644 index 0000000000..f408dcfa7b --- /dev/null +++ b/solution/terraform/terraform.tfvars.example @@ -0,0 +1,10 @@ +sa_key_file = "C:/Users//.yc/sa-key.json" +cloud_id = "" +folder_id = "" +zone = "ru-central1-d" +project_name = "lab04" +subnet_cidr = "10.10.0.0/24" +image_family = "ubuntu-2404-lts" +ssh_user = "ubuntu" +ssh_public_key_path = "C:/Users//.ssh/your_key.pub" +my_ip_cidr = "/32" diff --git a/solution/terraform/variables.tf b/solution/terraform/variables.tf new file mode 100644 index 0000000000..9dcac1ad3e --- /dev/null +++ b/solution/terraform/variables.tf @@ -0,0 +1,54 @@ +variable "sa_key_file" { + description = "Path to Yandex Cloud authorized key JSON file." + type = string +} + +variable "cloud_id" { + description = "Yandex Cloud ID." + type = string +} + +variable "folder_id" { + description = "Yandex Folder ID where resources will be created." + type = string +} + +variable "zone" { + description = "Yandex Cloud availability zone." + type = string + default = "ru-central1-d" +} + +variable "project_name" { + description = "Prefix for resource names." + type = string + default = "lab04" +} + +variable "subnet_cidr" { + description = "CIDR block for subnet." + type = string + default = "10.10.0.0/24" +} + +variable "image_family" { + description = "Image family for VM boot disk." + type = string + default = "ubuntu-2404-lts" +} + +variable "ssh_user" { + description = "Linux user for SSH access." + type = string + default = "ubuntu" +} + +variable "ssh_public_key_path" { + description = "Path to local public SSH key." + type = string +} + +variable "my_ip_cidr" { + description = "Your public IP in CIDR, example: 1.2.3.4/32." + type = string +}