diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..42c69b6bfb --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,56 @@ +name: Ansible Deployment + +on: + push: + branches: [ main, master ] + paths: + - 'ansible/**' + - '.github/workflows/ansible-deploy.yml' + pull_request: + branches: [ main, master ] + paths: + - 'ansible/**' + +jobs: + lint: + name: Ansible Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + pip install ansible ansible-lint + + - name: Run ansible-lint + run: | + cd ansible + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ./.vault_pass + ansible-lint playbooks/*.yml -x no-relative-paths + + deploy: + runs-on: self-hosted + needs: lint + steps: + - uses: actions/checkout@v4 + + - name: Deploy with Ansible + run: | + cd ansible + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass + ansible-playbook playbooks/deploy.yml \ + --vault-password-file /tmp/vault_pass \ + --tags "app_deploy" + rm /tmp/vault_pass + + - name: Verify Deployment + run: | + sleep 10 # Wait for app to start + curl -f http://${{ secrets.VM_HOST }}:8000 || exit 1 + curl -f http://${{ secrets.VM_HOST }}:8000/health || exit 1 diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000000..ad4f9cd826 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,72 @@ +name: Go CI + +on: + pull_request: + branches: [ master ] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + - '!app_go/docs/**' + - '!app_go/README.md' + - '!**.gitignore' + push: + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + - '!app_go/docs/**' + - '!app_go/README.md' + - '!**.gitignore' + + +jobs: + test: + name: Verify go app + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_go + steps: + - uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v4 + with: + go-version: '1.25' + cache: true + cache-dependency-path: 'app_go/go.sum' + + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + working-directory: ./app_go + + - name: Install dependencies + run: go mod download + + - name: Test with Coverage + run: | + CGO_ENABLED=0 go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out + + docker: + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./app_go + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/infoservice:go-latest + ${{ secrets.DOCKER_USERNAME }}/infoservice:go-${{ github.event.pull_request.number }}.1.0 + diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..b22e781d9b --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,74 @@ +name: Python CI + +on: + pull_request: + branches: [ master ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + - '!app_python/docs/**' + - '!app_python/README.md' + - '!**.gitignore' + push: + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + - '!app_python/docs/**' + - '!app_python/README.md' + - '!**.gitignore' + +jobs: + test: + name: Verify python app + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_python + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + # - name: Run Snyk + # uses: snyk/actions/python-3.10@master + # env: + # SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + # with: + # args: --severity-threshold=high + + - name: Lint + run: | + pip install flake8 + flake8 infoservice/infoservice.py + + - name: Install dependencies + run: | + pip install -r requirements.txt + + - name: Test with coverage + run: pytest + + docker: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./app_python + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/infoservice:python-latest + ${{ secrets.DOCKER_USERNAME }}/infoservice:python-${{ github.event.pull_request.number }}.1.0 diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000000..873c1e4d66 --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,40 @@ +name: Terraform CI + +on: + pull_request: + branches: [ master ] + paths: + - 'terraform/**' + +jobs: + validate: + name: Validate terraform configuration + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup terraform + uses: hashicorp/setup-terraform@v3 + + - name: Check formatting + run: terraform fmt -check + + - name: Initialize terraform + run: terraform init + + - name: Validate syntax + run: terraform validate + + - name: Setup terraform linter + uses: terraform-linters/setup-tflint@v6 + + - name: Lint terraform + run: tflint + + # - name: GitHub Integration + # run: | + # cd ./terraform + # export GITHUB_TOKEN=${{ secrets.TERRAFORM_GITHUB_TOKEN }} + # terraform import github_repository.course_repo DevOps-Core-Course + # terraform plan diff --git a/.gitignore b/.gitignore index 30d74d2584..a6a358aa46 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,19 @@ -test \ No newline at end of file +*.xml + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +terraform.tfvars + +# Pulumi +pulumi/venv/ +Pulumi.*.yaml + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +shell.nix diff --git a/LAB10.md b/LAB10.md new file mode 100644 index 0000000000..20997c1768 --- /dev/null +++ b/LAB10.md @@ -0,0 +1,207 @@ +# Task 1 +```shell +[nix-shell:~/code/DevOps]$ nix-shell -p kubernetes-helm + +[nix-shell:~/code/DevOps]$ which helm +/nix/store/8pcwyp9faqdz015mzca78k1pvi2m86rz-kubernetes-helm-3.19.1/bin/helm + +[nix-shell:~/code/DevOps]$ helm version +version.BuildInfo{Version:"v3.19.1", GitCommit:"v3.19.1", GitTreeState:"", GoVersion:"go1.25.5"} + +[nix-shell:~/code/DevOps]$ helm show chart prometheus-community/prometheus +annotations: + artifacthub.io/license: Apache-2.0 + artifacthub.io/links: | + - name: Chart Source + url: https://github.com/prometheus-community/helm-charts + - name: Upstream Project + url: https://github.com/prometheus/prometheus +apiVersion: v2 +appVersion: v3.10.0 +dependencies: +- condition: alertmanager.enabled + name: alertmanager + repository: https://prometheus-community.github.io/helm-charts + version: 1.34.* +- condition: kube-state-metrics.enabled + name: kube-state-metrics + repository: https://prometheus-community.github.io/helm-charts + version: 7.2.* +- condition: prometheus-node-exporter.enabled + name: prometheus-node-exporter + repository: https://prometheus-community.github.io/helm-charts + version: 4.52.* +- condition: prometheus-pushgateway.enabled + name: prometheus-pushgateway + repository: https://prometheus-community.github.io/helm-charts + version: 3.6.* +description: Prometheus is a monitoring system and time series database. +home: https://prometheus.io/ +icon: https://raw.githubusercontent.com/prometheus/prometheus.github.io/master/assets/prometheus_logo-cb55bb5c346.png +keywords: +- monitoring +- prometheus +kubeVersion: '>=1.19.0-0' +maintainers: +- email: gianrubio@gmail.com + name: gianrubio + url: https://github.com/gianrubio +- email: zanhsieh@gmail.com + name: zanhsieh + url: https://github.com/zanhsieh +- email: miroslav.hadzhiev@gmail.com + name: Xtigyro + url: https://github.com/Xtigyro +- email: naseem@transit.app + name: naseemkullah + url: https://github.com/naseemkullah +- email: rootsandtrees@posteo.de + name: zeritti + url: https://github.com/zeritti +name: prometheus +sources: +- https://github.com/prometheus/alertmanager +- https://github.com/prometheus/prometheus +- https://github.com/prometheus/pushgateway +- https://github.com/prometheus/node_exporter +- https://github.com/kubernetes/kube-state-metrics +type: application +version: 28.14.1 +``` + +# Task 2 +```shell +[nix-shell:~/code/DevOps/k8s/mychart]$ helm lint . +==> Linting . +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed + +[nix-shell:~/code/DevOps/k8s/mychart]$ helm template mychart . +--- +# Source: infoservice/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mychart-infoservice + labels: + helm.sh/chart: infoservice-0.1.0 + app.kubernetes.io/name: infoservice + app.kubernetes.io/instance: mychart + app.kubernetes.io/version: "1.16.0" + app.kubernetes.io/managed-by: Helm +automountServiceAccountToken: true +--- +# Source: infoservice/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: infoservice-service + +spec: + type: NodePort + selector: + app: infoservice + ports: + - protocol: TCP + port: 80 + targetPort: 8000 + nodePort: 30080 +--- +# Source: infoservice/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infoservices + labels: + app: infoservice + +spec: + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + + replicas: 3 + selector: + matchLabels: + app: infoservice + + template: + metadata: + labels: + app: infoservice + spec: + containers: + - name: infoservice + image: ub3rch/infoservice:go-latest + imagePullPolicy: "Always" + + resources: + livenessProbe: map[httpGet:map[initialDelaySeconds:10 path:/health periodSeconds:5 port:8000]] + readinessProbe: map[httpGet:map[initialDelaySeconds:10 path:/health periodSeconds:5 port:8000]] +--- +# Source: infoservice/templates/tests/test-connection.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "mychart-infoservice-test-connection" + labels: + helm.sh/chart: infoservice-0.1.0 + app.kubernetes.io/name: infoservice + app.kubernetes.io/instance: mychart + app.kubernetes.io/version: "1.16.0" + app.kubernetes.io/managed-by: Helm + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['mychart-infoservice:80'] + restartPolicy: Never +[nix-shell:~/code/DevOps/k8s/mychart]$ helm install --dry-run --debug test-release . +install.go:225: 2026-04-02 10:48:22.587617915 +0300 MSK m=+0.027976450 [debug] Original chart version: "" +install.go:242: 2026-04-02 10:48:22.587653483 +0300 MSK m=+0.028012008 [debug] CHART PATH: /home/uber/code/DevOps/k8s/mychart + +Error: INSTALLATION FAILED: Kubernetes cluster unreachable: Get "https://192.168.49.2:8443/version": dial tcp 192.168.49.2:8443: connect: no route to host +helm.go:92: 2026-04-02 10:48:25.669685879 +0300 MSK m=+3.110044445 [debug] Get "https://192.168.49.2:8443/version": dial tcp 192.168.49.2:8443: connect: no route to host +Kubernetes cluster unreachable +helm.sh/helm/v3/pkg/kube.(*Client).IsReachable + helm.sh/helm/v3/pkg/kube/client.go:137 +helm.sh/helm/v3/pkg/action.(*Install).RunWithContext + helm.sh/helm/v3/pkg/action/install.go:236 +main.runInstall + helm.sh/helm/v3/cmd/helm/install.go:317 +main.newInstallCmd.func2 + helm.sh/helm/v3/cmd/helm/install.go:156 +github.com/spf13/cobra.(*Command).execute + github.com/spf13/cobra@v1.10.1/command.go:1015 +github.com/spf13/cobra.(*Command).ExecuteC + github.com/spf13/cobra@v1.10.1/command.go:1148 +github.com/spf13/cobra.(*Command).Execute + github.com/spf13/cobra@v1.10.1/command.go:1071 +main.main + helm.sh/helm/v3/cmd/helm/helm.go:91 +runtime.main + runtime/proc.go:285 +runtime.goexit + runtime/asm_amd64.s:1693 +INSTALLATION FAILED +main.newInstallCmd.func2 + helm.sh/helm/v3/cmd/helm/install.go:158 +github.com/spf13/cobra.(*Command).execute + github.com/spf13/cobra@v1.10.1/command.go:1015 +github.com/spf13/cobra.(*Command).ExecuteC + github.com/spf13/cobra@v1.10.1/command.go:1148 +github.com/spf13/cobra.(*Command).Execute + github.com/spf13/cobra@v1.10.1/command.go:1071 +main.main + helm.sh/helm/v3/cmd/helm/helm.go:91 +runtime.main + runtime/proc.go:285 +runtime.goexit + runtime/asm_amd64.s:1693 +``` diff --git a/ansible/.gitignore b/ansible/.gitignore new file mode 100644 index 0000000000..b487bb7be0 --- /dev/null +++ b/ansible/.gitignore @@ -0,0 +1,4 @@ +.vault_pass +*.retry +inventory/*.pyc +__pycache__/ diff --git a/ansible/LAB05.md b/ansible/LAB05.md new file mode 100644 index 0000000000..0cf46f26f3 --- /dev/null +++ b/ansible/LAB05.md @@ -0,0 +1,191 @@ +# Architecture overview +- Ansible version: 2.19.4 +- Target VM OS and version: ubuntu server 24.04 +- Role structure: +``` +roles +├── app_deploy +├── common +└── docker +``` +- [Why roles instead of playbooks](./LAB05.md#Key Decisions) + + +# Roles Documentation +## Common +- Purpose: Common setup +- Variables: `common_packages` - list of packages to install +- Handlers: None +- Dependencies: None + +## Docker +- Purpose: Setup docker +- Variables: `ansible_distribution_release` - version of VM OS +- Handlers: `restart docker` - restarts docker daemon after installation +- Dependencies: `common` role + +## App_deploy +- Purpose: Deploy application +- Variables: + - `dockerhub_username`: login for dockerhub account + - `dockerhub_password`: password for dockerhub account + - `app_name`: name of application + - `docker_image`: name of docker image in registry + - `docker_image_tag`: tag of image in registry + - `docker_port`: port inside a docker container + - `local_port`: port on vm to connect to container + - `port_mapping`: docker port mapping + - `app_container_name`: name for container +- Handlers: `stop container` - stops and removes container after image updated +- Dependencies: `docker` role + + +# Idempotency Demonstration +## First run +``` +PLAY [Provision web servers] ************************************************************************************************* + +TASK [Gathering Facts] ******************************************************************************************************* +ok: [DevOpsVM] + +TASK [common : Update apt cache] ********************************************************************************************* +ok: [DevOpsVM] + +TASK [common : Install common packages] ************************************************************************************** +ok: [DevOpsVM] + +TASK [common : Setup timezone] *********************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker prerequisites] ********************************************************************************* +ok: [DevOpsVM] + +TASK [docker : Add Docker GPG key] ******************************************************************************************* +ok: [DevOpsVM] + +TASK [docker : Add Docker repository] **************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker] *********************************************************************************************** +changed: [DevOpsVM] + +RUNNING HANDLER [docker : restart docker] ************************************************************************************ +changed: [DevOpsVM] + +PLAY RECAP ******************************************************************************************************************* +DevOpsVM : ok=9 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +## Second run +``` +PLAY [Provision web servers] ******************************************************************************************************************************************************************* + +TASK [Gathering Facts] ************************************************************************************************************************************************************************* +ok: [DevOpsVM] + +TASK [common : Update apt cache] *************************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [common : Install common packages] ******************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [common : Setup timezone] ***************************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker prerequisites] *************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Add Docker GPG key] ************************************************************************************************************************************************************* +ok: [DevOpsVM] + +TASK [docker : Add Docker repository] ********************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker] ***************************************************************************************************************************************************************** +ok: [DevOpsVM] + +PLAY RECAP ************************************************************************************************************************************************************************************* +DevOpsVM : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` +## Analysis +What changed: Docker installed +What didn't change second time: Nothing changed + +## Explanation +First run installs docker(and triggers restart service handler) +and this step is not runned on second run, since docker +is already installed + + +# Ansible Vault Usage +- I store credentilas securely with usage of Ansible Vault +- I manage vault password with gitignored password file +- [Example of encrypted file](./roles/app_deploy/defaults/main.yml) +- [Why ansible Vault is important](./LAB05.md#Key Decisions) + + +# Deployment Verification +Output of `deploy.yml` run +``` +PLAY [Deploy application] **************************************************************************************************** + +TASK [Gathering Facts] ******************************************************************************************************* +ok: [DevOpsVM] + +TASK [app_deploy : Login] **************************************************************************************************** +ok: [DevOpsVM] + +TASK [app_deploy : Pull Image] *********************************************************************************************** +ok: [DevOpsVM] + +TASK [app_deploy : run container] ******************************************************************************************** +changed: [DevOpsVM] + +TASK [app_deploy : Verify Container Running] ********************************************************************************* +ok: [DevOpsVM] + +TASK [app_deploy : Check Health] ********************************************************************************************* +ok: [DevOpsVM] + +PLAY RECAP ******************************************************************************************************************* +DevOpsVM : ok=6 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` +`docker ps` output: +``` +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +aeca03f980be ub3rch/infoservice:python-latest "fastapi run infoser…" 8 seconds ago Up 8 seconds 127.0.0.1:5000->8000/tcp Xantusia +``` +`curl` outputs: +- `curl http:/127.0.0.1:5000/` +``` +{"system":{"hostname":"aeca03f980be","platform":"Linux","platform_version":"#100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026","architecture":"x86_64","python_version":"3.13.12"},"service":{"name":"DevOps Info Service","version":"0.1.1","description":"DevOps course info service","framework":"fastapi"},"runtime":{"uptime_seconds":64,"uptime_human":"0 hours, 1 minutes","current_time":"2026-02-24T12:13:04.779840+00:00","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.5.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` +- `curl http:/127.0.0.1:5000/health` +``` +{"status":"healthy","timestamp":"2026-02-24T12:13:09.770103+00:00","uptime_seconds":69} +``` + +# Key Decisions +## Why roles instead of playbooks? +Roles give reusability and modularity to playbooks + +## How do roles improve reusability? +Different playbooks can use same roles to +usilize common setup steps + +## What makes task idempotent? +Since tasks are declarative, checking +if declared state is already reached, then +the step is skipped. + +## How do handlers improve efficiency? +Handler run only if trigerred from +tasks. Since tasks skipped in most +of cases (since they are idempotent), then +Handler tasks not even check their state, but +skipped instantly. + +## Why is Ansible Vault necessary? +Ansible Vault allows storing configuration +in version control, at the same time keeping +secrets protected, since vaults are encrypted. diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000000..f1af8ee5c5 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1 @@ +[![Ansible Deployment](https://github.com/your-username/your-repo/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/your-username/your-repo/actions/workflows/ansible-deploy.yml) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..7b99e250dc --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = user +retry_files_enabled = False +vault_password_file = .vault_pass + +[privilege_escalation] +become = True +become_method = su +become_user = root diff --git a/ansible/deploy.yml b/ansible/deploy.yml new file mode 100644 index 0000000000..af4a900fb2 --- /dev/null +++ b/ansible/deploy.yml @@ -0,0 +1,17 @@ +$ANSIBLE_VAULT;1.1;AES256 +38653463613966636538626531663233343162373131343438626466303734613232633433613834 +3065393162643062613132306634363562343064316665380a343934316463393339336635376436 +63636233373536616335653239363338353137333831623562633532383635353764653932663035 +3461666635366437660a373639353932323465306338633630336130393838343265393262633035 +35343532396336633037313330666166313465613466646531333633356637373138646461336462 +39323864313964313166306635613936313463626264623762343961366537323639363435353632 +30316234633431393639316531633132653364626536666239626135376164373831613338643463 +66396231323233613436323361303534376264613535386265396266643835323961626539613039 +35666634363034643362613230353735313761376537393034333164323435316430663065396534 +36623362346664303434326330316562653439366334626139643361616637373637353432646437 +63316132656330376636383539376164353333663837373631663137363737373033623733386335 +37336630346532356433353861316238393464373336666230313537636635633832383365316661 +30376130393139353737356231393638303535373865386664343363313863613762343932373331 +32396661373033313265313236363930343333643934353263333233643533323961666365306432 +61653037306537366334343033313337306338613238613264396464386139366137386135373831 +39656162616139643139 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..01ac6d9bc9 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,5 @@ +[webservers] +DevOpsVM ansible_host=127.0.0.1 ansible_port=2222 ansible_user=user ansible_ssh_private_key_file=~/.ssh/keys/DevOps/key + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..95174b9e0e --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..b806401bed --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,15 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - role: common + become: true + tags: + - common + + - role: docker + become: true + tags: + - docker diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..629ec43bc3 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,8 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - tmux diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..5816e86d91 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,34 @@ +--- +- name: Managing packages + 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 + + - name: Yield success + ansible.builtin.debug: + msg: "Packages updated succesfully" + + rescue: + - name: Handle failure + ansible.builtin.apt: + update_cache: true + + always: + - name: Yield success + ansible.builtin.debug: + msg: "Packages block finished" + + +- name: Setup timezone + community.general.timezone: + name: GMT diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..d5f1a5a1bc --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,2 @@ +--- +docker_ansible_distribution_release: "24.04" diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..e44e740cb7 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,75 @@ +--- +- name: Docker install + tags: + - docker_install + - packages + + block: + - name: Install prerequisites + ansible.builtin.apt: + name: + - apt-transport-https + - ca-certificates + - curl + state: present + + - name: Add Docker GPG key + ansible.builtin.apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + state: present + + - name: Add Docker repository + ansible.builtin.apt_repository: + repo: "deb https://download.docker.com/linux/ubuntu {{ docker_ansible_distribution_release }} stable" + state: present + + - name: Install Docker + ansible.builtin.apt: + name: docker-ce + state: present + + rescue: + - name: Handle failure + ansible.builtin.apt: + update_cache: true + + always: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Log service status + ansible.builtin.debug: + var: ansible_facts.services['docker'].state + + +- name: Configure Docker + tags: + - docker_config + - users + + block: + - name: Create Docker Group + ansible.builtin.group: + name: docker + state: present + + - name: Add user to Docker group + ansible.builtin.user: + name: user + groups: docker + append: true + state: present + + - name: Log failure + ansible.builtin.debug: + msg: "Docker configuration successfull" + + rescue: + - name: Log failure + ansible.builtin.debug: + msg: "Docker configuration failed" + + always: + - name: Log failure + ansible.builtin.debug: + msg: "Docker configuration finished" diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..57b3710682 --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,12 @@ +--- +web_app_name: infoservice +web_app_image: "{{ dockerhub_username }}/{{ web_app_name }}" +web_app_tag: python-latest +web_app_internal_port: 8000 +web_app_port: 5000 +web_app_addr: 127.0.0.1 +web_app_external_port: 8000 +web_app_port_mapping: "{{ web_app_addr }}:{{ web_app_port }}:{{ web_app_internal_port }}" +web_app_container_name: Xantusia +web_app_dir: "/opt/{{ web_app_name }}" +web_app_wipe: false diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..cb7d8e0460 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: docker diff --git a/ansible/roles/web_app/tasks/main.yml b/ansible/roles/web_app/tasks/main.yml new file mode 100644 index 0000000000..deee1cd4cc --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,55 @@ +--- +- name: Include wipe task + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Login + community.docker.docker_login: + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + no_log: true # Prevents credentials in logs + +- name: Deploy application with Docker Compose + tags: + - app_deploy + - compose + + block: + - name: Create app directory + ansible.builtin.file: + path: "{{ web_app_dir }}" + state: directory + mode: '755' + + - name: Template docker-compose file + ansible.builtin.template: + src: ../templates/docker-compose.yml.j2 + dest: "{{ web_app_dir }}/docker-compose.yml" + mode: '744' + + - name: Deploy with docker-compose + community.docker.docker_compose_v2: + project_src: "{{ web_app_dir }}" + pull: always + state: present + + rescue: + - name: Handle deployment failure + ansible.builtin.debug: + msg: "Failed to start docker compose" + + +- name: Verify Container Running + ansible.builtin.wait_for: + host: "{{ web_app_addr }}" + port: "{{ web_app_port }}" + connect_timeout: 1 + delay: 5 + state: drained + sleep: 2 + +- name: Check Health + ansible.builtin.uri: + url: "http://{{ web_app_addr }}:{{ web_app_port }}/health" + method: GET diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..bce32c6e72 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,26 @@ +--- +- name: Wipe web application + tags: + - web_app_wipe + when: "web_app_wipe" + + block: + - name: Stop and remove containers + community.docker.docker_compose_v2: + project_src: "{{ web_app_dir }}" + state: absent + remove_images: local + + - name: Remove docker-compose file + ansible.builtin.file: + state: absent + path: "{{ web_app_dir }}/docker-compose.yml" + + - name: Remove application directory + ansible.builtin.file: + state: absent + path: "{{ web_app_dir }}" + + - name: Log wipe completion + ansible.builtin.debug: + msg: "Application {{ web_app_name }} wiped successfully" diff --git a/ansible/roles/web_app/templates/docker-compose.yml.j2 b/ansible/roles/web_app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..8df132ca28 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,13 @@ +version: '3.8' + +services: + {{ web_app_name }}: + image: {{ web_app_image }}:{{ web_app_tag }} + container_name: {{ web_app_name }} + ports: + - "{{ web_app_port_mapping }}" + # environment: + # Add environment variables here + # Use Vault-encrypted secrets + restart: unless-stopped + # Add other configuration diff --git a/ansible/roles/web_app/vars/main.yml b/ansible/roles/web_app/vars/main.yml new file mode 100644 index 0000000000..e146f560dd --- /dev/null +++ b/ansible/roles/web_app/vars/main.yml @@ -0,0 +1,8 @@ +$ANSIBLE_VAULT;1.1;AES256 +39663863646665333266373166366663636538333430663265373566626361393465633861366431 +3138303032333061663337346334383137613436633832630a616361636538306266386330376562 +33656166623362363064343430656566333464393433653834383663633466356239386565353133 +3532373539323061310a333938383137383436353434323039303064663936333231346263373266 +31343033343065386666393162323765336366393166343932616637663265356161613331336366 +36323666373636313262363136306463616266306262333738353561313837386139393430356239 +623439333237336539626130613238393937 diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..2a2913a9b9 --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,19 @@ +# Version control +.git +.gitignore + +# Secrets +.env +*.pem +secrets/ + +# Documentation +*.md +docs/ + +# Tests +tests/ + +# IDE configurations +.vscode/ +.idea/ diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..dcf47569a6 --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,2 @@ +# Golang +infoService diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..a7fd2f0cd5 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,10 @@ +# Building stage +from golang:1.25 as builder +workdir /app +copy . . +run CGO_ENABLED=0 go build -o app + +# Runtime +from gcr.io/distroless/static-debian12 +copy --from=builder /app/app / +cmd ["/app"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..d61ee26b9c --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,54 @@ +# Overview +Simple service for collecting system and service information + +# Prerequisites +No additional dependencies if installed as executable + +If builded from source code, then you need: +- go +- gcc + +# Installation +Building from source code +```bash +git clone https://github.com/Uberch/DevOps-Core-Course.git +cd DevOps-Core-Course/go_app +go build +``` + +# Running the Application +```bash +./infoService +``` + +Or with custom config: +```bash +PORT=8000 ./infoService +``` + +# API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +# Configuration +| Variable name | Type | Default value | Example +|---|---|---|---| +| PORT | Integer | 8000 | 8080 | +| DEBUG | Boolean | false | true | + +# Docker +## Buidling image +```bash +docker build -t : . +``` + +## Running container +```bash +docker run -i -rm -p :8000 : . +``` + +## Pulling from Docker Hub +```bash +docker pull ub3rch/infoservice:go- +docker tag ub3rch/infoservice:go- : +``` diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..81d6d7d1d6 --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,7 @@ +I have decided to use Go, because +it is very simple yet fast +compiled statically-typed language +with rich standart libraries and +effective concurrency support +Also I already hove some experience +using this language diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..00fdffbbe7 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,52 @@ +# API Documentation +- `GET /`: Returns service and system information +- `GET /health`: Returns health status of service + + +# Testing Evidence +## Images +![Main endpoint json](./screenshots/main.png "Main endpoint") +![Health endpoint json](./screenshots/health.png "Health endpoint") +![Sample output](./screenshots/output.png "Server output") + +## Terminal output samples +``` +2026/01/25 16:02:23 Starting application... +2026/01/25 16:02:23 Starting server on port 8000 +Type in the 'stop' to terminate +2026/01/25 16:02:28 Collecting service information +2026/01/25 16:02:28 Sending service information +2026/01/25 16:02:30 Collecting service health information +2026/01/25 16:02:30 Sending service health information +2026/01/25 16:02:32 Collecting service information +2026/01/25 16:02:32 Sending service information +2026/01/25 16:02:34 Collecting service health information +2026/01/25 16:02:34 Sending service health information +2026/01/25 16:02:37 Terminating server +``` + +With DEBUG=true +``` +2026/01/25 16:02:47 Starting application... +2026/01/25 16:02:47 Starting server on port 8000 +Type in the 'stop' to terminate +2026/01/25 16:02:49 Collecting service health information +2026/01/25 16:02:49 Sending service health information +2026/01/25 16:02:50 Collecting service health information +2026/01/25 16:02:50 Sending service health information +2026/01/25 16:02:53 Collecting service information +2026/01/25 16:02:53 Sending service information +2026/01/25 16:02:53 Collecting service information +2026/01/25 16:02:53 Sending service information +2026/01/25 16:02:54 Collecting service information +2026/01/25 16:02:54 Sending service information +Debug: main.go:136: Request: GET /health +Debug: main.go:136: Request: GET /health +Debug: main.go:103: Request: GET / +Debug: main.go:103: Request: GET / +Debug: main.go:103: Request: GET / +2026/01/25 16:02:56 Terminating server +``` + + +# Challenges & Solutions diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..dcb9d517c8 --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,60 @@ +# Build strategy +Docker builds image in two stages: +- First stage builds application from +source go code under SDK image. +- Second stage runs application +executable under distroless static image. + +# Size comparison +Image built without multi-staging +under go SDK image has size of 947MB, +whereas image with multi-staging has +size of 10.5MB which makes difference +of almost 100 times of space. + +# Multi-stage builds importance +Using multi-stage container builds +for compiled languages allows numeral +save in image size, because compiler itself +takes much space, but is not needed +for running the application itself, +since runtime is embedded to executable. +Therefore removing space-consuming +compiler from final image reduces +image size significantly. + +# Terminal outputs +Images info: +``` +REPOSITORY TAG IMAGE ID CREATED SIZE +infoservice go-dev cfa7da73fc3f 40 minutes ago 10.5MB +infoservice go-bad 460c3cec266e 45 minutes ago 947MB +``` + +Building image: +``` +[+] Building 6.7s (13/13) FINISHED docker:default + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 234B 0.0s + => [internal] load metadata for gcr.io/distroless/static-debian12:latest 0.5s + => [internal] load metadata for docker.io/library/golang:1.25 1.6s + => [auth] library/golang:pull token for registry-1.docker.io 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 189B 0.0s + => [builder 1/4] FROM docker.io/library/golang:1.25@sha256:ce63a16e0f7063787ebb4eb28e72d477b00b4726f79874b3205a965ffd797ab2 0.0s + => [stage-1 1/2] FROM gcr.io/distroless/static-debian12:latest@sha256:cd64bec9cec257044ce3a8dd3620cf83b387920100332f2b041f19c4d2febf93 0.0s + => [internal] load build context 0.0s + => => transferring context: 8.52MB 0.0s + => CACHED [builder 2/4] WORKDIR /app 0.0s + => [builder 3/4] COPY . . 0.0s + => [builder 4/4] RUN CGO_ENABLED=0 go build -o app 5.0s + => CACHED [stage-1 2/2] COPY --from=builder /app/app / 0.0s + => exporting to image 0.0s + => => exporting layers 0.0s + => => writing image sha256:cfa7da73fc3fd694f3a5473a882ae4eaf2b22a38276f268096d60194c2b903d0 0.0s + => => naming to docker.io/library/infoservice:go-dev +``` + +# Technical explanation of each stage's purpose +- First stage is needed to build application executable +- Second stage's purpose is to run application diff --git a/app_go/docs/LAB03.md b/app_go/docs/LAB03.md new file mode 100644 index 0000000000..8102ba38ab --- /dev/null +++ b/app_go/docs/LAB03.md @@ -0,0 +1,47 @@ +# GitHub Actions CI Workflow +- Job Dependencies +- Pull Request Checks +- Fail Fast + +# Path filter configuration +## Configuration +```YAML +on: + pull_request: + branches: [ master ] + push: + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + - '!app_go/docs' + - '!app_go/README.md' + - '!**.gitignore' +``` + +## Benefits analysis +Path filters allow to save +CI time (and, therefore money) +by disabling rerunning +workflows related to unchanged code. + +## Selective Triggering +![Each commit triggers only workflows related to affected code](./screenshots/select_ci.png) + +# Test Coverage +## Integration +## Analysis +| Dimension | Python | Go | +|---|---|---| +| Current percentage | 99 | 0 | +| Threshold | 70 | 70 | +What is covered: +- Python: System- and Client- dependend outputs +(to prevent occasional hard-coding) + +- Go: Nothing + +What is not covered: +- Python: getters, setters and +hardcoded outputs + +- Go: Everything diff --git a/app_go/docs/screenshots/health.png b/app_go/docs/screenshots/health.png new file mode 100644 index 0000000000..add6260d00 Binary files /dev/null and b/app_go/docs/screenshots/health.png differ diff --git a/app_go/docs/screenshots/main.png b/app_go/docs/screenshots/main.png new file mode 100644 index 0000000000..612bc78ae6 Binary files /dev/null and b/app_go/docs/screenshots/main.png differ diff --git a/app_go/docs/screenshots/output.png b/app_go/docs/screenshots/output.png new file mode 100644 index 0000000000..92b623d4f8 Binary files /dev/null and b/app_go/docs/screenshots/output.png differ diff --git a/app_go/docs/screenshots/select_ci.png b/app_go/docs/screenshots/select_ci.png new file mode 100644 index 0000000000..64004c160e Binary files /dev/null and b/app_go/docs/screenshots/select_ci.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..52ebdfa16d --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module infoService + +go 1.25.5 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..0542ab2b1c --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "time" +) + +// Structures for various information +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` +} + +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Architecture string `json:"architecture"` + GoVersion string `json:"go_version"` + CPUCount int `json:"cpu_count"` +} + +type Runtime struct { + UptimeSeconds int `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type Request struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type ServiceInfo struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request Request `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +type HealthInfo struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int `json:"uptime_seconds"` +} + +// Helper functions for information collecting +func getSystemInfo() System { + host, err := os.Hostname() + if err != nil { + DebugLogger.Print(err) + } + return System{ + Hostname: host, + Platform: runtime.GOOS, + Architecture: runtime.GOARCH, + GoVersion: runtime.Version(), + CPUCount: runtime.NumCPU(), + } +} + +func getUptime() Runtime { + seconds := int(time.Since(startTime).Seconds()) + zoneName, _ := startTime.Zone() + return Runtime{ + UptimeSeconds: seconds, + UptimeHuman: fmt.Sprintf("%d hours, %d minutes", seconds/3600, (seconds%3600)/60), + CurrentTime: time.Now().Format(time.RFC3339), + Timezone: zoneName, + } +} + +var Endpoints = []Endpoint{{ + Path: "/", + Method: "GET", + Description: "Service information", +}, { + Path: "/health", + Method: "GET", + Description: "Health check", +}, +} + +// Handlers for http requests +func rootHandler(w http.ResponseWriter, r *http.Request) { + DebugLogger.Printf("Request: %s %s\n", r.Method, r.URL.Path) + log.Println("Collecting service information") + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + DebugLogger.Println("Failed to parse address, returning raw one") + ip = r.RemoteAddr + } + info := ServiceInfo{ + Service: Service{ + Name: "Info Service", + Version: "0.0.1", + Description: "Simple service to collect some info", + }, + + System: getSystemInfo(), + Runtime: getUptime(), + + Request: Request{ + ClientIP: ip, + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: Endpoints, + } + + log.Println("Sending service information") + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(info) + if err != nil { + log.Println("Failed to encode response", err) + } +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + DebugLogger.Printf("Request: %s %s\n", r.Method, r.URL.Path) + log.Println("Collecting service health information") + + uptime := getUptime() + info := HealthInfo{ + Status: "healthy", + Timestamp: uptime.CurrentTime, + UptimeSeconds: uptime.UptimeSeconds, + } + + log.Println("Sending service health information") + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(info) + if err != nil { + log.Println("Failed to encode response", err) + } +} + +// Application istelf +var ( + startTime = time.Now() + DebugLevel int + DebugBuffer bytes.Buffer + DebugLogger = log.New(&DebugBuffer, "Debug: ", log.Lshortfile) +) + +func main() { + log.Println("Starting application...") + port := os.Getenv("PORT") + if port == "" { + port = "8000" + } + + debug := os.Getenv("DEBUG") + if debug == "true" { + DebugLevel = 1 + } + + http.HandleFunc("/", rootHandler) + http.HandleFunc("/health", healthHandler) + + log.Println("Starting server on port " + port) + go func() { + err := http.ListenAndServe(":"+port, nil) + log.Println(err) + }() + + // var stop string + // fmt.Println("Type in the 'stop' to terminate") + // _, err := fmt.Scan(&stop) + // if err != nil { + // log.Println("Failed to read stdin", err) + // } + // for stop != "stop" { + // _, err := fmt.Scan(&stop) + // if err != nil { + // log.Println("Failed to read stdin", err) + // } + // } + for { + if DebugLevel > 0 { + fmt.Print(&DebugBuffer) + } + } + // log.Println("Terminating server") +} diff --git a/app_go/main_test.go b/app_go/main_test.go new file mode 100644 index 0000000000..0fee6f5dcc --- /dev/null +++ b/app_go/main_test.go @@ -0,0 +1 @@ +package main_test diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..5090611c6e --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,25 @@ +# Version control +.git +.gitignore + +# Secrets +.env +*.pem +secrets/ + +# Documentation +*.md +docs/ + +# Tests +tests/ + +# IDE configurations +.vscode/ +.idea/ + +# Python +__pycache__ +*.py[oc] +venv +.venv diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..dfb05d8e92 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,6 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log +.coverage diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..b59efdb052 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +# Prepare user and ports +RUN useradd --create-home --shell /bin/bash appuser +EXPOSE 8000 + +# Install dependencies +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY ./infoservice/infoservice.py . + +# Run application as non-root user +USER appuser +CMD ["fastapi", "run", "infoservice.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..cb211809e9 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,59 @@ +# Overview +Simple service for collecting system and service information + +# CI/CD Status +[![Python CI](https://github.com/Uberch/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/Uberch/DevOps-Core-Course/actions/workflows/python-ci.yml) + +# Prerequisites +- python 3.13 + +# Installation +```bash +git clone https://github.com/Uberch/DevOps-Core-Course.git +cd DevOps-Core-Course +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +# Testing the Application +```bash +cd app_python +pytest +``` + +# Running the Application +```bash +fastapi run infoservice/app.py +``` +Or with custom config: +```bash +PORT=8000 fastapi run infoservice/app.py +``` + +# API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +# Configuration +| Variable name | Type | Default value | Example +|---|---|---|---| +| PORT | Integer | 8000 | 8080 | +| DEBUG | Boolean | false | true | + +# Docker +## Buidling image +```bash +docker build -t : . +``` + +## Running container +```bash +docker run -rm -p :8000 : . +``` + +## Pulling from Docker Hub +```bash +docker pull ub3rch/infoservice:python- +docker tag ub3rch/infoservice:python- : +``` diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..4df301dc27 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,57 @@ +# Framework Selection +I have decided to use fastapi, because I already have experience using it +| Metric | Flask | Fastapi | Django | +|---|---|---|---| +| Used previously by me | no | yes | no | + +# Practices Applied + +I applied following practices: +- Clean code organization (clear naming, proper imports, only necessary comments, runned autopep8 on code) +- Basic error handling is implemented in fastapi itself +- Logging with `logging` module +- Dependencies managment via `requirements.txt` +- Omitting files unrelated to app (venv, pycache, logs, IDE files, etc.) + +# API Documentation +- `GET /`: Returns service and system information +- `GET /health`: Returns health status of service + + +# Testing Evidence +## Images +![Main endpoint json](./screenshots/main.png "Main endpoint") +![Health endpoint json](./screenshots/health.png "Health endpoint") +![Sample output](./screenshots/output.png "Server output") + +## Terminal output samples +Usual run (fastapi output voided) +``` +2026-01-24 16:49:20,640 - app - INFO - Application starting... +2026-01-24 16:49:24,908 - app - INFO - Collecting general information... +2026-01-24 16:49:28,027 - app - INFO - Collecting service health information... +2026-01-24 16:49:30,292 - app - INFO - Collecting service health information... +2026-01-24 16:49:38,299 - app - INFO - Collecting general information... +``` + +Run with `DEBUG=true` (fastapi output voided) +``` +2026-01-24 16:50:35,841 - app - INFO - Application starting... +2026-01-24 16:50:42,074 - app - INFO - Collecting general information... +2026-01-24 16:50:42,074 - app - DEBUG - Request: GET / +2026-01-24 16:50:45,150 - app - INFO - Collecting service health information... +2026-01-24 16:50:45,150 - app - DEBUG - Request: GET /health +2026-01-24 16:50:46,780 - app - INFO - Collecting general information... +2026-01-24 16:50:46,780 - app - DEBUG - Request: GET / +``` + +# Challenges & Solutions +During the preparation to the work, I encountered that my code editor (neovim) was not configured to work with python. + +Therefore I had to research documentation and configure everything to set up and configure the LSP. + + +# GitHub Community +Starring repositories matters, because it encourage maintainers, attracts contributors and helps project gain visibility. + +Foolowing matters, because it allows people to build professional connections, learn, collaborate and improve their career. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..584004edfa --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,237 @@ +# Docker best practices applied +## Non-root user +Running application as non-root user +limits priviliges inside container, +prohibits system file modification, +provides kubernetes compatibility +and limits priviliges in case of container escape. + +All of stated above improves security +and compatibility of image. +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +# ... +USER appuser +CMD ["fastapi", "run", "app.py"] +``` + +## Spesific base image version +Specifying version of base image +gives reproducibility for building +image from source. +```dockerfile +FROM python:3.12-slim +``` + +## Only copy necessary files +```dockerfile +COPY ./app.py . +``` + +## Proper layer ordering +Ordering dependency installation +before copying all files allows +docker to not reinstall dependencies +if there was changes only in code, +therefore saving time on building image. +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +EXPOSE 8000 + +# Install dependencies +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY ./app.py . +``` + +## `.dockerignore` file +`.dockerignore` prevents docker from copying +certain files from working directory, thus +hiding vulnerable data (such as secrets, API keys and etc.) +and lowering image size through avoiding unnecessary files +such as documentation, IDE configurations,version control and other. +``` +# Version control +.git +.gitignore + +# Secrets +.env +*.pem +secrets/ + +# Documentation +*.md +docs/ + +# Tests +tests/ + +# IDE configurations +.vscode/ +.idea/ + +# Python +__pycache__ +*.py[oc] +venv +.venv +``` + + +# Image Information & Decisions +## Base image +I have chosen `python:3.13-slim` image +since app not size-critical enough to +use alpine variant, but i do not need +compilation tools and slim variant is much +smaller than basic python image. + +## Final image size +190 MB + +## Layer structure and optimizations explanation +First, dockerfile creates user for +non-root running and exposes port 8000. +Since there is nothing to change, then +this stage is performed only once and +cached for all future builds. + +Second, dockerfile copies `requirements.txt` and +runs `pip install` on them to prepare dependencies. +If this file have not changed since last build +(which is rarely the case in comparison with code changes), +then this stage is skipped too, therefore saving tens of +seconds of installing all dependencies. + +Third, dockerfile copies application files +(exactly one in this case) and runs it as +non-root user. This stage is most likely +not to be skipped, since code is changed +oftenly, therefore most probable to run +stage should be in the end of dockerfile. + + +# Build & Run Process +## Complete terminal output from build process +``` +[+] Building 20.9s (11/11) FINISHED docker:default + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 361B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.3s + => [internal] load .dockerignore 0.0s + => => transferring context: 231B 0.0s + => [1/6] FROM docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b 1.8s + => => resolve docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b 0.0s + => => sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 1.29MB / 1.29MB 0.7s + => => sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 11.79MB / 11.79MB 1.4s + => => sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 249B / 249B 1.0s + => => sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b18 10.37kB / 10.37kB 0.0s + => => sha256:fbc43b66207d7e2966b5f06e86f2bc46aa4b10f34bf97784f3a10da80b1d6f0b 1.75kB / 1.75kB 0.0s + => => sha256:dd4049879a507d6f4bb579d2d94b591135b95daab37abb3df9c1d40b7d71ced0 5.53kB / 5.53kB 0.0s + => => extracting sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 0.1s + => => extracting sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 0.3s + => => extracting sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 0.0s + => [internal] load build context 0.0s + => => transferring context: 63B 0.0s + => [2/6] RUN useradd --create-home --shell /bin/bash appuser 0.2s + => [3/6] WORKDIR /app 0.0s + => [4/6] COPY requirements.txt . 0.0s + => [5/6] RUN pip install --no-cache-dir -r requirements.txt 17.2s + => [6/6] COPY ./app.py . 0.0s + => exporting to image 0.3s + => => exporting layers 0.3s + => => writing image sha256:e63cd5678a4792a6b3105ab4c8268d899b31376a76bb790b365c6bf126c2907b 0.0s + => => naming to docker.io/library/infoservice:python-dev 0.0s +``` + + +## Terminal output of container running +``` + FastAPI Starting production server 🚀 + + Searching for package file structure from directories with + __init__.py files +2026-01-27 15:40:23,902 - app - INFO - Application starting... + Importing from /app + + module 🐍 app.py + + code Importing the FastAPI app object from the module with the following + code: + + from app import app + + app Using import string: app:app + + server Server started at http://0.0.0.0:8000 + server Documentation at http://0.0.0.0:8000/docs + + Logs: + + INFO Started server process [1] +2026-01-27 15:40:23,919 - uvicorn.error - INFO - Started server process [1] + INFO Waiting for application startup. +2026-01-27 15:40:23,920 - uvicorn.error - INFO - Waiting for application startup. + INFO Application startup complete. +2026-01-27 15:40:23,920 - uvicorn.error - INFO - Application startup complete. + INFO Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-01-27 15:40:23,921 - uvicorn.error - INFO - Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-01-27 15:40:26,267 - app - INFO - Collecting general information... + INFO 172.17.0.1:36228 - "GET / HTTP/1.1" 200 +2026-01-27 15:40:36,034 - app - INFO - Collecting general information... + INFO 172.17.0.1:46442 - "GET / HTTP/1.1" 200 +2026-01-27 15:41:57,231 - app - INFO - Collecting service health information... + INFO 172.17.0.1:54722 - "GET /health HTTP/1.1" 200 +^C + INFO Shutting down +2026-01-27 15:42:45,067 - uvicorn.error - INFO - Shutting down + INFO Waiting for application shutdown. +2026-01-27 15:42:45,169 - uvicorn.error - INFO - Waiting for application shutdown. + INFO Application shutdown complete. +2026-01-27 15:42:45,170 - uvicorn.error - INFO - Application shutdown complete. + INFO Finished server process [1] +2026-01-27 15:42:45,171 - uvicorn.error - INFO - Finished server process [1] +``` + +## Terminal output from testing endpoints +- root +``` +{"service":{"name":"DevOps Info Service","version":"0.0.1","description":"DevOps course info service","framework":"fastapi"},"system":{"hostname":"7349c843900b","platform":"Linux","platform_version":"#1-NixOS SMP PREEMPT_DYNAMIC Thu Jan 8 09:15:06 UTC 2026","architecture":"x86_64","python_version":"3.13.11"},"runtime":{"uptime_seconds":2,"uptime_human":"0 hours, 0 minutes","current_time":"2026-01-27T15:40:26.267899+00:00","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.17.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` +- health +``` +{"status":"healthy","timestamp":"2026-01-27T15:41:57.231209+00:00","uptime_seconds":93} +``` + +## Registry +[Link to Docker registry](https://hub.docker.com/repository/docker/ub3rch/infoservice/general) + + +# Technical analysis +My dockerfile works the way it does, +because I wrote it the way I wrote it. + +Build time will increase, since docker +will perform stages, which can be skipped +(due to caching) +if they were performed in different oreder. + +I implemented following security considerations: +- Non-root user running +- Hiding secrets with dockerfile + +`.dockerignore` improves my build through +lowering image size and improving security. + + +# Challenges & Solutions +I have not encountered any major issues +with lab implementation, since I already +have some experience with docker +from other courses. +However that experience was a little bit old, +therefore I improved through repetion +and recall of known material, with addition of +new material such non-root user running. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..5e34d4ce97 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,126 @@ +# Overview +## Testing Framework +I have chosen pytest as testing framework, +since it is simple and powerful and +adding this dependency will not cause +critical slowing of CI. + +## Test Coverage +### Test Structure Explanation +- test_endpoint_main(): +Ensures main endpoint +is present and checks right, +platform-dependent output. +- test_endpoint_health(): +Ensures healt endpoint is present. +- test_request_info(mocker): +Test the main endpoint +response from simulated +different ip's and user agents. + +### Running Tests Locally +From `app_python` directory: +```bash +source venv/bin/activate +pytest +``` + +### Terminal Output +``` +====================== test session starts ====================== +platform linux -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/uber/code/DevOps/app_python +configfile: pyproject.toml +plugins: anyio-4.12.1, mock-3.15.1 +collected 3 items + +tests/test_sample.py ... [100%] +================= 3 passed, 0 warning in 0.29s ================== +``` + +## CI workflow trigger configuration +### Trigger Strategy and Reasoning +Workflow triggers on pushes and +PR's to master branch +(assuming changes in application +or workflow) + +## Versioning strategy +Semantic versioning +because it represents my +progress with course. + +# Workflow evidence +[Successful workflow](https://github.com/Uberch/DevOps-Core-Course/actions/runs/21901627386) +Tests passing locally: +```bash +====================== test session starts ====================== +platform linux -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/uber/code/DevOps/app_python +configfile: pyproject.toml +plugins: cov-7.0.0, anyio-4.12.1, mock-3.15.1 +collected 3 items + +tests/test_sample.py ... [100%] + +======================= warnings summary ======================== +venv/lib/python3.13/site-packages/starlette/formparsers.py:12 + /home/uber/code/DevOps/app_python/venv/lib/python3.13/site-packages/starlette/formparsers.py:12: PendingDeprecationWarning: Please use `import python_multipart` instead. + import multipart + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +======================== tests coverage ========================= +_______ coverage: platform linux, python 3.13.11-final-0 ________ + +Name Stmts Miss Cover +------------------------------------------------ +infoservice/infoservice.py 76 1 99% +------------------------------------------------ +TOTAL 76 1 99% +Coverage XML written to file coverage.xml +Required test coverage of 70% reached. Total coverage: 98.68% +================= 3 passed, 1 warning in 0.48s ================== +(venv) +``` + +[Image on Docker Hub](https://hub.docker.com/repository/docker/ub3rch/infoservice/general) + +# Best Practices +- Job Dependencies: Dont do work, +which will fail, because previous +work failed +- Pull Request Checks: Prevents +bad code in master branch, productions +- Fail Fast: Catch errors as early as possible +- Caching: +- Snyk not done, since snyk blocks Russian users + +# Key decisions +## Versioning Strategy +I have decided to use +Semantic versioning of type +"python-.1.0" + +## Docker tags +My workflow creates two tags: +- python- +- latest + +## Wokflow triggers +The workflow is triggerred on +push, this allows fast feedback +on each delivered change. + +Also workflow triggers on pull +requests to prevent merging of +bad code to master branch. + +## Test coverage +What is covered: +System- and Client- dependend outputs +(to prevent occasional hard-coding) + +What is not covered: +Getters, setters and +hardcoded(intentionally) +outputs diff --git a/app_python/docs/screenshots/health.png b/app_python/docs/screenshots/health.png new file mode 100644 index 0000000000..c48f92cd1e Binary files /dev/null and b/app_python/docs/screenshots/health.png differ diff --git a/app_python/docs/screenshots/main.png b/app_python/docs/screenshots/main.png new file mode 100644 index 0000000000..9b14b67cb3 Binary files /dev/null and b/app_python/docs/screenshots/main.png differ diff --git a/app_python/docs/screenshots/metrics.png b/app_python/docs/screenshots/metrics.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/app_python/docs/screenshots/metrics.png differ diff --git a/app_python/docs/screenshots/output.png b/app_python/docs/screenshots/output.png new file mode 100644 index 0000000000..c87e5dcd73 Binary files /dev/null and b/app_python/docs/screenshots/output.png differ diff --git a/app_python/infoservice/__init__.py b/app_python/infoservice/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/infoservice/infoservice.py b/app_python/infoservice/infoservice.py new file mode 100644 index 0000000000..2531cfd764 --- /dev/null +++ b/app_python/infoservice/infoservice.py @@ -0,0 +1,294 @@ +""" +DevOps Info Service +Main application module +""" + +# Imports +import os +from pydoc import tempfile_pager +import socket +import platform +import logging +import json +import time +from pydantic import BaseModel +from datetime import datetime, timezone +from fastapi import FastAPI, Request +from fastapi.responses import PlainTextResponse +from prometheus_client import Counter, Histogram, Gauge, CollectorRegistry +from prometheus_client import generate_latest, CONTENT_TYPE_LATEST + + +# Setting up logging +class JSONFormatter(logging.Formatter): + def format(self, record): + log_obj = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "level": record.levelname, + "message": record.getMessage(), + "logger": record.name, + } + if record.exc_info: + log_obj["exception"] = self.formatException(record.exc_info) + return json.dumps(log_obj) + + +handler = logging.StreamHandler() +handler.setFormatter(JSONFormatter()) +logger = logging.getLogger() +logger.addHandler(handler) + + +if os.getenv('DEBUG', 'false') == 'true': + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + + +# Setting up pydantic structures +class SystemInfo(BaseModel): + hostname: str + platform: str + platform_version: str + architecture: str + python_version: str + + +class ServiceInfo(BaseModel): + name: str + version: str + description: str + framework: str + + +class UptimeInfo(BaseModel): + uptime_seconds: int + uptime_human: str + current_time: str + timezone: str + + +class RequestInfo(BaseModel): + client_ip: str + user_agent: str + method: str + path: str + + +class EndpointInfo(BaseModel): + path: str + method: str + description: str + + +class MainEndpoint(BaseModel): + system: SystemInfo + service: ServiceInfo + runtime: UptimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] + + +class HealthEndpoint(BaseModel): + status: str + timestamp: str + uptime_seconds: int + + +class VisitsEndpoint(BaseModel): + count: int + + +# Various information collecting functions +def get_system_info(): + """Collect system information.""" + return SystemInfo( + hostname=socket.gethostname(), + platform=platform.system(), + platform_version=platform.version(), + architecture=platform.machine(), + python_version=platform.python_version() + ) + + +def get_service_info(): + """Collect service information.""" + return ServiceInfo( + name=app.title, + version=app.version, + description=app.description, + framework="fastapi", + ) + + +def get_uptime(): + delta = datetime.now() - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return UptimeInfo( + uptime_seconds=seconds, + uptime_human=f"{hours} hours, {minutes} minutes", + current_time=datetime.now(timezone.utc).isoformat(), + timezone=str(timezone.utc), + ) + + +def get_endpoints(): + return [ + EndpointInfo( + path="/", + method="GET", + description="Service information", + ), + EndpointInfo( + path="/health", + method="GET", + description="Health check", + ), + ] + + +def increase_visit_count(): + count_file = "/data/visits" + temp_file = "/data/visits.temp" + while os.path.isfile(temp_file): + pass + with open(temp_file, "w") as t_file: + count = read_visit_count() + _ = t_file.write(str(count+1)) + os.replace(temp_file, count_file) + + +def read_visit_count(): + count_file = "/data/visits" + if os.path.isfile(count_file): + with open(count_file, "r") as file: + return int(file.read()) + else: return 0 + + +# Application start time +logger.info("Application starting...") +START_TIME = datetime.now(timezone.utc) +start_time = datetime.now() + +app = FastAPI( + title="DevOps Info Service", + description="DevOps course info service", + summary="", + version="0.1.1", +) +registry = CollectorRegistry() + + +# Prometheus metrics +http_requests_total = Counter( + 'http_requests_total', + 'Total HTTP requests', + ['method', 'endpoint', 'status'], + registry=registry, +) + +http_request_duration_seconds = Histogram( + 'http_request_duration_seconds', + 'HTTP request duration', + ['method', 'endpoint'], + registry=registry, +) + +http_requests_in_progress = Gauge( + 'http_requests_in_progress', + 'HTTP requests currently being processed', + registry=registry, +) + +endpoint_calls = Counter( + 'devops_info_endpoint_calls', + 'Endpoint calls', + ['endpoint'], + registry=registry, +) + +system_info_duration = Histogram( + 'devops_info_system_collection_seconds', + 'System info collection time', + registry=registry, +) + + +@app.middleware("http") +async def middleware(request: Request, call_next): + logger.info(f'Request: {request.method} {request.url.path}') + start_time = time.time() + + response = await call_next(request) + + path = request.url.path + + duration = time.time() - start_time + http_requests_total.labels( + method=request.method, + endpoint=path, + status=str(response.status_code), + ).inc() + + http_request_duration_seconds.labels( + method=request.method, + endpoint=path, + ).observe(duration) + + logger.info(f'Response code: {response.status_code}.') + return response + + +# FastAPI +@app.get("/") +@http_requests_in_progress.track_inprogress() +def index(request: Request): + increase_visit_count() + """Main endpoint - service and system information.""" + logger.info("Collecting general information...") + + start_time = time.time() + sys_info = get_system_info() + duration = time.time() - start_time + system_info_duration.observe(duration) + + return MainEndpoint( + system=sys_info, + service=get_service_info(), + runtime=get_uptime(), + request=RequestInfo( + client_ip=request.client.host, + user_agent=request.headers.get("user-agent"), + method=request.method, + path=request.url.path, + ), + endpoints=get_endpoints(), + ) + + +@app.get("/health") +@http_requests_in_progress.track_inprogress() +def health(): + """Health endpoint - information about services status""" + logger.info("Collecting service health information...") + uptime_info = get_uptime() + return HealthEndpoint( + status="healthy", + timestamp=uptime_info.current_time, + uptime_seconds=uptime_info.uptime_seconds, + ) + + +@app.get("/metrics") +def metrics(): + return PlainTextResponse( + generate_latest(registry), + media_type=CONTENT_TYPE_LATEST + ) + +@app.get("/visits") +def visits(): + return VisitsEndpoint(count=read_visit_count()) diff --git a/app_python/pyproject.toml b/app_python/pyproject.toml new file mode 100644 index 0000000000..776531a67a --- /dev/null +++ b/app_python/pyproject.toml @@ -0,0 +1,11 @@ +[tool.basedpyright] +reportUnknownVariableType = "none" +reportUnknownParameterType = "none" +reportUnknownMemberType = "none" +reportMissingTypeStubs = "none" + +[tool.pytest.ini_options] +addopts = "--cov=. --cov-fail-under=70 --cov-report=xml --cov-report=term" + +[tool.coverage.run] +omit = [ "tests/*", "*/__init__.py" ] diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..7da478f9c8 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,7 @@ +fastapi[standard]==0.115.0 +uvicorn[standard]==0.32.0 # Includes performance extras +prometheus-client==0.23.1 +pytest +pytest-cov +pytest-mock +httpx diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_sample.py b/app_python/tests/test_sample.py new file mode 100644 index 0000000000..51c4bf20f5 --- /dev/null +++ b/app_python/tests/test_sample.py @@ -0,0 +1,55 @@ +import socket +import platform +from fastapi.testclient import TestClient +from infoservice.infoservice import app + +client = TestClient(app) + +# Test main endpoint +def test_endpoint_main(): + response = client.get("/") + # Test presence + assert response.status_code == 200 + # Test verifiable variable fields + assert response.json()["system"]["hostname"] == socket.gethostname() + assert response.json()["system"]["platform"] == platform.system() + assert response.json()["system"]["platform_version"] == platform.version() + assert response.json()["system"]["architecture"] == platform.machine() + assert response.json()["system"]["python_version"] == platform.python_version() + + +# Test health endpoint +def test_endpoint_health(): + response = client.get("/health") + # Test presence + assert response.status_code == 200 + +# Test request info processing +def test_request_info(mocker): + """Test how program parses request from different hosts""" + # First try + mock_ip = "16.32.64.128" + mock_client = mocker.patch("fastapi.Request.client") + mock_client.host = mock_ip + response = client.get( + "/", + headers={ + "user-agent": "noexist", + } + ) + assert response.json()["request"]["client_ip"] == mock_ip + assert response.json()["request"]["user_agent"] == "noexist" + + # Second try + mock_ip = "8.16.32.64" + mock_client = mocker.patch("fastapi.Request.client") + mock_client.host = mock_ip + response = client.get( + "/", + headers={ + "user-agent": "doexist", + } + ) + assert response.json()["request"]["client_ip"] == mock_ip + assert response.json()["request"]["user_agent"] == "doexist" + diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..c8c218fc49 --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,43 @@ +# Local VM +VM Provider: Virtual Box + +Network: NAT with port forwarding (2222:22) to allow ssh + +![Working VM](./screenshots/vm.png) +![VM ssh connection](./screenshots/ssh.png) + +# Lab 5 Prep +VM for Lab 5: + +No, I am not keeping VM + +I will use local VM + +# Bonus tasks +![Passing validation](./screenshots/pass.png) +![Failing validation](./screenshots/fail.png) + +## Import process +**1. Installing GitHub Provider:** +**3. Create Personal Access Token:** +**4. Configure Token:** +**5. Configure Repository Resource:** +**6. Import** + +## Terminal output of import +![Output](./screenshots/import.png) + +## Why import and benefits +Import brings existing resources into Terraform management: +1. Write Terraform config describing the resource +2. Run `terraform import` to link config to real resource +3. Terraform now manages that resource +4. Future changes go through Terraform + +**Advantages of Managing Existing Resources with IaC:** +**1. Version Control:** +**2. Consistency:** +**3. Automation:** +**4. Documentation:** +**5. Disaster Recovery:** +**6. Team Collaboration:** diff --git a/docs/screenshots/cluster-info.png b/docs/screenshots/cluster-info.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/cluster-info.png differ diff --git a/docs/screenshots/cluster-setup.png b/docs/screenshots/cluster-setup.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/cluster-setup.png differ diff --git a/docs/screenshots/fail.png b/docs/screenshots/fail.png new file mode 100644 index 0000000000..9e72be9c49 Binary files /dev/null and b/docs/screenshots/fail.png differ diff --git a/docs/screenshots/kube-secrets.png b/docs/screenshots/kube-secrets.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/kube-secrets.png differ diff --git a/docs/screenshots/pass.png b/docs/screenshots/pass.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/pass.png differ diff --git a/docs/screenshots/rollout.png b/docs/screenshots/rollout.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/rollout.png differ diff --git a/docs/screenshots/ssh.png b/docs/screenshots/ssh.png new file mode 100644 index 0000000000..ca07fc59c5 Binary files /dev/null and b/docs/screenshots/ssh.png differ diff --git a/docs/screenshots/vm.png b/docs/screenshots/vm.png new file mode 100644 index 0000000000..d6536bb6df Binary files /dev/null and b/docs/screenshots/vm.png differ diff --git a/k8s/argocd/application-dev.yaml b/k8s/argocd/application-dev.yaml new file mode 100644 index 0000000000..c7241c174f --- /dev/null +++ b/k8s/argocd/application-dev.yaml @@ -0,0 +1,23 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: python-app-dev + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Uberch/DevOps-Core-Course.git + targetRevision: master + path: k8s/mychart + helm: + valueFiles: + - values-dev.yaml + 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..4c15390c97 --- /dev/null +++ b/k8s/argocd/application-prod.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: python-app-prod + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Uberch/DevOps-Core-Course.git + targetRevision: master + path: k8s/mychart + helm: + valueFiles: + - values-dev.yaml + destination: + server: https://kubernetes.default.svc + namespace: dev + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/k8s/argocd/application.yaml b/k8s/argocd/application.yaml new file mode 100644 index 0000000000..cdaf919a09 --- /dev/null +++ b/k8s/argocd/application.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: python-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Uberch/DevOps-Core-Course.git + targetRevision: master + path: k8s/mychart + helm: + valueFiles: + - values.yaml + destination: + server: https://kubernetes.default.svc + namespace: default + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..a08e46ded4 --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,48 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infoservice + labels: + app: infoservice +spec: + replicas: 5 + selector: + matchLabels: + app: infoservice + template: + metadata: + labels: + app: infoservice + spec: + containers: + - name: infoservice + image: ub3rch/infoservice:go-latest + volumeMounts: + - name: workdir + mountPath: /work-dir + + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 + + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 3 + + volumes: + - name: workdir + emptyDir: {} diff --git a/k8s/docs/LAB09.md b/k8s/docs/LAB09.md new file mode 100644 index 0000000000..09080bfac6 --- /dev/null +++ b/k8s/docs/LAB09.md @@ -0,0 +1,132 @@ +# Evidence +## Task 1 +![Kubectl, minikube and cluster setup](./screenshots/k8s_setup.png) + +## Task 4 +![Scaling](./screenshots/scale.png) +![Rollout](./screenshots/rollout.png) + +# Architecture Overview + +# Manifest Files + +# Deployment Evidence +## `kubectl get all` +```bash +NAME READY STATUS RESTARTS AGE +pod/infoservice-6dcdc5fdf-crn8m 1/1 Running 0 8m42s +pod/infoservice-6dcdc5fdf-czh76 1/1 Running 0 8m52s +pod/infoservice-6dcdc5fdf-fd2d6 1/1 Running 0 8m52s +pod/infoservice-6dcdc5fdf-s7xg4 1/1 Running 0 8m52s +pod/infoservice-6dcdc5fdf-wk8q7 1/1 Running 0 8m43s +pod/vault-0 1/1 Running 3 (25d ago) 43d +pod/vault-agent-injector-7845f59846-k4gn9 1/1 Running 3 (25d ago) 43d + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/infoservice-service NodePort 10.105.186.89 80:30080/TCP 57d +service/kubernetes ClusterIP 10.96.0.1 443/TCP 57d +service/vault ClusterIP 10.96.249.209 8200/TCP,8201/TCP 43d +service/vault-agent-injector-svc ClusterIP 10.96.246.166 443/TCP 43d +service/vault-internal ClusterIP None 8200/TCP,8201/TCP 43d + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/infoservice 5/5 5 5 25m +deployment.apps/vault-agent-injector 1/1 1 1 43d + +NAME DESIRED CURRENT READY AGE +replicaset.apps/infoservice-566b86db95 0 0 0 25m +replicaset.apps/infoservice-57dbd46744 0 0 0 22m +replicaset.apps/infoservice-6dcdc5fdf 5 5 5 8m52s +replicaset.apps/infoservice-f49f9896f 0 0 0 21m +replicaset.apps/vault-agent-injector-7845f59846 1 1 1 43d + +NAME READY AGE +statefulset.apps/vault 1/1 43d +``` + +## `kubectl get pods,svc +```bash +NAME READY STATUS RESTARTS AGE +pod/infoservice-6dcdc5fdf-crn8m 1/1 Running 0 9m41s +pod/infoservice-6dcdc5fdf-czh76 1/1 Running 0 9m51s +pod/infoservice-6dcdc5fdf-fd2d6 1/1 Running 0 9m51s +pod/infoservice-6dcdc5fdf-s7xg4 1/1 Running 0 9m51s +pod/infoservice-6dcdc5fdf-wk8q7 1/1 Running 0 9m42s +pod/vault-0 1/1 Running 3 (25d ago) 43d +pod/vault-agent-injector-7845f59846-k4gn9 1/1 Running 3 (25d ago) 43d + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/infoservice-service NodePort 10.105.186.89 80:30080/TCP 57d +service/kubernetes ClusterIP 10.96.0.1 443/TCP 57d +service/vault ClusterIP 10.96.249.209 8200/TCP,8201/TCP 43d +service/vault-agent-injector-svc ClusterIP 10.96.246.166 443/TCP 43d +service/vault-internal ClusterIP None 8200/TCP,8201/TCP 43d +``` + +## `kubectl describe deployment infoservice` +```bash +Name: infoservice +Namespace: default +CreationTimestamp: Fri, 22 May 2026 15:50:36 +0300 +Labels: app=infoservice +Annotations: deployment.kubernetes.io/revision: 4 +Selector: app=infoservice +Replicas: 5 desired | 5 updated | 5 total | 5 available | 0 unavailable +StrategyType: RollingUpdate +MinReadySeconds: 0 +RollingUpdateStrategy: 25% max unavailable, 25% max surge +Pod Template: + Labels: app=infoservice + Containers: + infoservice: + Image: ub3rch/infoservice:go-latest + Port: + Host Port: + Limits: + cpu: 100m + memory: 128Mi + Requests: + cpu: 100m + memory: 128Mi + Liveness: http-get http://:8000/health delay=10s timeout=1s period=5s #success=1 #failure=3 + Readiness: http-get http://:8000/health delay=5s timeout=1s period=3s #success=1 #failure=3 + Environment: + Mounts: + /work-dir from workdir (rw) + Volumes: + workdir: + Type: EmptyDir (a temporary directory that shares a pod's lifetime) + Medium: + SizeLimit: + Node-Selectors: + Tolerations: +Conditions: + Type Status Reason + ---- ------ ------ + Available True MinimumReplicasAvailable + Progressing True NewReplicaSetAvailable +OldReplicaSets: infoservice-566b86db95 (0/0 replicas created), infoservice-57dbd46744 (0/0 replicas created), infoservice-f49f9896f (0/0 replicas created) +NewReplicaSet: infoservice-6dcdc5fdf (5/5 replicas created) +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal ScalingReplicaSet 27m deployment-controller Scaled up replica set infoservice-566b86db95 from 0 to 3 + Normal ScalingReplicaSet 24m deployment-controller Scaled up replica set infoservice-57dbd46744 from 0 to 1 + Normal ScalingReplicaSet 23m deployment-controller Scaled down replica set infoservice-566b86db95 from 3 to 2 + Normal ScalingReplicaSet 23m deployment-controller Scaled up replica set infoservice-f49f9896f from 0 to 1 + Normal ScalingReplicaSet 23m deployment-controller Scaled down replica set infoservice-566b86db95 from 2 to 1 + Normal ScalingReplicaSet 23m deployment-controller Scaled up replica set infoservice-f49f9896f from 1 to 2 + Normal ScalingReplicaSet 23m deployment-controller Scaled down replica set infoservice-566b86db95 from 1 to 0 + Normal ScalingReplicaSet 23m deployment-controller Scaled up replica set infoservice-f49f9896f from 2 to 3 + Normal ScalingReplicaSet 23m deployment-controller Scaled down replica set infoservice-57dbd46744 from 1 to 0 + Normal ScalingReplicaSet 12m deployment-controller Scaled up replica set infoservice-f49f9896f from 3 to 5 + Normal ScalingReplicaSet 10m (x11 over 15m) deployment-controller (combined from similar events): Scaled down replica set infoservice-f49f9896f from 1 to 0 +``` + +![App working](./screenshots/curl.png) + +# Operations Performed + +# Production Considerations + +# Challenges and solutions diff --git a/k8s/docs/LAB16.md b/k8s/docs/LAB16.md new file mode 100644 index 0000000000..50a580180d --- /dev/null +++ b/k8s/docs/LAB16.md @@ -0,0 +1,26 @@ +# Task 1 + +Roles of: +- **Prometheus Operator**: Simplifies the deployment, +managment, and configuration of the Prometheus +monitoring stack within Kubernetes clusters + +- **Prometheus**: Open-source monitoring system, +used for metric gather and analysis in real time + +- **Alertmanager**: Handles alert sent by client applications, +takes care of deduplicating, grouping, and routing alerts. + +- **Grafana**: Allows query, visualize, alert on and understand metrics + +- **kube-state-metrics**: generates metrics about the state of objects in a Kubernetes cluster + +- **node-exporter**: Monitors host system + +# Task 2 +- **Pod Resources**: Mem: 2.6GB (13%), CPU: 10% +- **Namespace Analysis**: infoservices-568b587765-lj4t5 +- **Node Metrics**: Mem: 8GB (42%), CPU: 14% +- **Kubelet**: 25 pods managed +- **Network**: ~4GB +- **Alert**: 6 diff --git a/k8s/docs/screenshots/curl.png b/k8s/docs/screenshots/curl.png new file mode 100644 index 0000000000..a5963a88ac Binary files /dev/null and b/k8s/docs/screenshots/curl.png differ diff --git a/k8s/docs/screenshots/k8s_setup.png b/k8s/docs/screenshots/k8s_setup.png new file mode 100644 index 0000000000..9d3ec43fac Binary files /dev/null and b/k8s/docs/screenshots/k8s_setup.png differ diff --git a/k8s/docs/screenshots/rollout.png b/k8s/docs/screenshots/rollout.png new file mode 100644 index 0000000000..6e30516481 Binary files /dev/null and b/k8s/docs/screenshots/rollout.png differ diff --git a/k8s/docs/screenshots/scale.png b/k8s/docs/screenshots/scale.png new file mode 100644 index 0000000000..57b5476e83 Binary files /dev/null and b/k8s/docs/screenshots/scale.png differ diff --git a/k8s/mychart/.helmignore b/k8s/mychart/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/k8s/mychart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/k8s/mychart/Chart.yaml b/k8s/mychart/Chart.yaml new file mode 100644 index 0000000000..fba9423b51 --- /dev/null +++ b/k8s/mychart/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: infoservice +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/k8s/mychart/files/config.json b/k8s/mychart/files/config.json new file mode 100644 index 0000000000..d0c3a0fd62 --- /dev/null +++ b/k8s/mychart/files/config.json @@ -0,0 +1,4 @@ +{ + "app_name": "infoservice", + "environment": "dev" +} diff --git a/k8s/mychart/templates/NOTES.txt b/k8s/mychart/templates/NOTES.txt new file mode 100644 index 0000000000..d140a27e0c --- /dev/null +++ b/k8s/mychart/templates/NOTES.txt @@ -0,0 +1,35 @@ +1. Get the application URL by running these commands: +{{- if .Values.httpRoute.enabled }} +{{- if .Values.httpRoute.hostnames }} + export APP_HOSTNAME={{ .Values.httpRoute.hostnames | first }} +{{- else }} + export APP_HOSTNAME=$(kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o jsonpath="{.spec.listeners[0].hostname}") + {{- end }} +{{- if and .Values.httpRoute.rules (first .Values.httpRoute.rules).matches (first (first .Values.httpRoute.rules).matches).path.value }} + echo "Visit http://$APP_HOSTNAME{{ (first (first .Values.httpRoute.rules).matches).path.value }} to use your application" + + NOTE: Your HTTPRoute depends on the listener configuration of your gateway and your HTTPRoute rules. + The rules can be set for path, method, header and query parameters. + You can check the gateway configuration with 'kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o yaml' +{{- end }} +{{- else if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mychart.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "mychart.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "mychart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "mychart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/k8s/mychart/templates/_helpers.tpl b/k8s/mychart/templates/_helpers.tpl new file mode 100644 index 0000000000..ce8a516e21 --- /dev/null +++ b/k8s/mychart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "mychart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "mychart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "mychart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "mychart.labels" -}} +helm.sh/chart: {{ include "mychart.chart" . }} +{{ include "mychart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "mychart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "mychart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "mychart.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "mychart.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/k8s/mychart/templates/configmap.yaml b/k8s/mychart/templates/configmap.yaml new file mode 100644 index 0000000000..f608087a54 --- /dev/null +++ b/k8s/mychart/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "mychart.fullname" . }}-config +data: + config.json: |- +{{ .Files.Get "files/config.json" | indent 4 }} diff --git a/k8s/mychart/templates/deployment.yaml b/k8s/mychart/templates/deployment.yaml new file mode 100644 index 0000000000..023c96621c --- /dev/null +++ b/k8s/mychart/templates/deployment.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infoservices + labels: + app: infoservice + +spec: + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + + replicas: {{ .Values.replicaCount | default 3 }} + selector: + matchLabels: + app: infoservice + + template: + metadata: + labels: + app: infoservice + spec: + + initContainers: + - name: init-download + image: busybox:1.36 + command: ['sh', '-c', 'wget -O /work-dir/index.html https://example.com'] + volumeMounts: + - name: workdir + mountPath: /work-dir + - name: wait-for-service + image: busybox:1.36 + command: ['sh', '-c', 'until nslookup myservice; do sleep 2; done'] + + containers: + - name: infoservice + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + imagePullPolicy: "Always" + + resources: {{ .Values.recources }} + livenessProbe: {{ .Values.livenessProbe }} + readinessProbe: {{ .Values.readinessProbe }} + volumeMounts: + - name: workdir + mountPath: /work-dir + + volumes: + - name: config-volume + configMap: + name: config-name + - name: data-volume + persistentVolumeClaim: + claimName: {{ include "mychart.fullname" . }}-data + - name: workdir + emptyDir: {} + + containers: + - volumeMounts: + - name: config-volume + mountPath: /config + - volumeMounts: + - name: data-volume + mountPath: /data + + envFrom: + - configMapRef: + name: {{ include "mychart.fullname" . }}-env + +annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "admin" + vault.hashicorp.com/agent-inject-secret-config: "secret/data/myapp/config" diff --git a/k8s/mychart/templates/headless_service.yaml b/k8s/mychart/templates/headless_service.yaml new file mode 100644 index 0000000000..9ad5359f3e --- /dev/null +++ b/k8s/mychart/templates/headless_service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "mychart.fullname" . }}-headless +spec: + clusterIP: None + selector: + {{- include "mychart.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} diff --git a/k8s/mychart/templates/hpa.yaml b/k8s/mychart/templates/hpa.yaml new file mode 100644 index 0000000000..eaf763ee5c --- /dev/null +++ b/k8s/mychart/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "mychart.fullname" . }} + labels: + {{- include "mychart.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "mychart.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/k8s/mychart/templates/httproute.yaml b/k8s/mychart/templates/httproute.yaml new file mode 100644 index 0000000000..ac12cfc2e4 --- /dev/null +++ b/k8s/mychart/templates/httproute.yaml @@ -0,0 +1,38 @@ +{{- if .Values.httpRoute.enabled -}} +{{- $fullName := include "mychart.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }} + labels: + {{- include "mychart.labels" . | nindent 4 }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- with .Values.httpRoute.parentRefs }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.httpRoute.rules }} + {{- with .matches }} + - matches: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .filters }} + filters: + {{- toYaml . | nindent 8 }} + {{- end }} + backendRefs: + - name: {{ $fullName }} + port: {{ $svcPort }} + weight: 1 + {{- end }} +{{- end }} diff --git a/k8s/mychart/templates/ingress.yaml b/k8s/mychart/templates/ingress.yaml new file mode 100644 index 0000000000..745a96b29d --- /dev/null +++ b/k8s/mychart/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "mychart.fullname" . }} + labels: + {{- include "mychart.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "mychart.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/k8s/mychart/templates/preview_service.yaml b/k8s/mychart/templates/preview_service.yaml new file mode 100644 index 0000000000..950b0cbe93 --- /dev/null +++ b/k8s/mychart/templates/preview_service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "mychart.fullname" . }}-preview +spec: + selector: + {{- include "mychart.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/k8s/mychart/templates/pvc.yaml b/k8s/mychart/templates/pvc.yaml new file mode 100644 index 0000000000..875d354bb6 --- /dev/null +++ b/k8s/mychart/templates/pvc.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "mychart.fullname" . }}-data + labels: + {{- include "mychart.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} diff --git a/k8s/mychart/templates/rollout.yaml b/k8s/mychart/templates/rollout.yaml new file mode 100644 index 0000000000..f4e03fa9d3 --- /dev/null +++ b/k8s/mychart/templates/rollout.yaml @@ -0,0 +1,24 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: {{ include "mychart.fullname" . }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "mychart.selectorLabels" . | nindent 6 }} + template: + # Same as Deployment pod template + metadata: + labels: + {{- include "mychart.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + # ... rest of container spec + strategy: + blueGreen: + activeService: {{ include "mychart.fullname" . }} + previewService: {{ include "mychart.fullname" . }}-preview + autoPromotionEnabled: false # Manual promotion diff --git a/k8s/mychart/templates/service.yaml b/k8s/mychart/templates/service.yaml new file mode 100644 index 0000000000..b4cd110f02 --- /dev/null +++ b/k8s/mychart/templates/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: infoservice-service + +spec: + type: NodePort + selector: + app: infoservice + ports: + - protocol: TCP + port: 80 + targetPort: 8000 + nodePort: 30080 diff --git a/k8s/mychart/templates/serviceaccount.yaml b/k8s/mychart/templates/serviceaccount.yaml new file mode 100644 index 0000000000..8957c409b1 --- /dev/null +++ b/k8s/mychart/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "mychart.serviceAccountName" . }} + labels: + {{- include "mychart.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/k8s/mychart/templates/stateful.yaml b/k8s/mychart/templates/stateful.yaml new file mode 100644 index 0000000000..88c8304d92 --- /dev/null +++ b/k8s/mychart/templates/stateful.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "mychart.fullname" . }} +spec: + serviceName: {{ include "mychart.fullname" . }}-headless + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "mychart.selectorLabels" . | nindent 6 }} + template: + # Same as Deployment pod template + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: {{ .Values.persistence.size }} diff --git a/k8s/mychart/templates/tests/test-connection.yaml b/k8s/mychart/templates/tests/test-connection.yaml new file mode 100644 index 0000000000..44cb16dae0 --- /dev/null +++ b/k8s/mychart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "mychart.fullname" . }}-test-connection" + labels: + {{- include "mychart.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "mychart.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/k8s/mychart/values-dev.yaml b/k8s/mychart/values-dev.yaml new file mode 100644 index 0000000000..c4de29601c --- /dev/null +++ b/k8s/mychart/values-dev.yaml @@ -0,0 +1,33 @@ +credentials: + username: default + password: default + +replicaCount: 1 + +image: + tag: "latest" + +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi + +service: + type: NodePort + +livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + +env: + - name: Credentials + valueFrom: + secretKeyRef: + name: secret-name + key: password diff --git a/k8s/mychart/values-prod.yaml b/k8s/mychart/values-prod.yaml new file mode 100644 index 0000000000..78964113f0 --- /dev/null +++ b/k8s/mychart/values-prod.yaml @@ -0,0 +1,33 @@ +credentials: + username: default + password: default + +replicaCount: 5 + +image: + tag: "1.0.0" # Specific version + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 200m + memory: 256Mi + +service: + type: LoadBalancer + +livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 5 + +readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 3 diff --git a/k8s/mychart/values.yaml b/k8s/mychart/values.yaml new file mode 100644 index 0000000000..742aada50e --- /dev/null +++ b/k8s/mychart/values.yaml @@ -0,0 +1,170 @@ +# Default values for mychart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 3 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: ub3rch/infoservice + # This sets the pull policy for images. + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "go-latest" + +# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 80 + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +# -- Expose the service via gateway-api HTTPRoute +# Requires Gateway API resources and suitable controller installed within the cluster +# (see: https://gateway-api.sigs.k8s.io/guides/) +httpRoute: + # HTTPRoute enabled. + enabled: false + # HTTPRoute annotations. + annotations: {} + # Which Gateways this Route is attached to. + parentRefs: + - name: gateway + sectionName: http + # namespace: default + # Hostnames matching HTTP header. + hostnames: + - chart-example.local + # List of rules and filters applied. + rules: + - matches: + - path: + type: PathPrefix + value: /headers + # filters: + # - type: RequestHeaderModifier + # requestHeaderModifier: + # set: + # - name: My-Overwrite-Header + # value: this-is-the-only-value + # remove: + # - User-Agent + # - matches: + # - path: + # type: PathPrefix + # value: /echo + # headers: + # - name: version + # value: v2 + +resources: + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 + +readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 + +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} +persistence: + enabled: true + size: 100Mi + storageClass: "" # Use default diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..42f91134e7 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: infoservice-service + +spec: + type: NodePort + selector: + app: infoservice + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 diff --git a/labs/lab18/app_python/Dockerfile b/labs/lab18/app_python/Dockerfile new file mode 100644 index 0000000000..e1cc7f6d1b --- /dev/null +++ b/labs/lab18/app_python/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +# Prepare user and ports +RUN useradd --create-home --shell /bin/bash appuser +EXPOSE 8000 + +# Install dependencies +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY ./src/app.py . + +# Run application as non-root user +USER appuser +CMD ["fastapi", "run", "app.py"] diff --git a/labs/lab18/app_python/README.md b/labs/lab18/app_python/README.md new file mode 100644 index 0000000000..cb211809e9 --- /dev/null +++ b/labs/lab18/app_python/README.md @@ -0,0 +1,59 @@ +# Overview +Simple service for collecting system and service information + +# CI/CD Status +[![Python CI](https://github.com/Uberch/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/Uberch/DevOps-Core-Course/actions/workflows/python-ci.yml) + +# Prerequisites +- python 3.13 + +# Installation +```bash +git clone https://github.com/Uberch/DevOps-Core-Course.git +cd DevOps-Core-Course +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +# Testing the Application +```bash +cd app_python +pytest +``` + +# Running the Application +```bash +fastapi run infoservice/app.py +``` +Or with custom config: +```bash +PORT=8000 fastapi run infoservice/app.py +``` + +# API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +# Configuration +| Variable name | Type | Default value | Example +|---|---|---|---| +| PORT | Integer | 8000 | 8080 | +| DEBUG | Boolean | false | true | + +# Docker +## Buidling image +```bash +docker build -t : . +``` + +## Running container +```bash +docker run -rm -p :8000 : . +``` + +## Pulling from Docker Hub +```bash +docker pull ub3rch/infoservice:python- +docker tag ub3rch/infoservice:python- : +``` diff --git a/labs/lab18/app_python/__pycache__/app.cpython-313.pyc b/labs/lab18/app_python/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000..83611ea93c Binary files /dev/null and b/labs/lab18/app_python/__pycache__/app.cpython-313.pyc differ diff --git a/labs/lab18/app_python/default.nix b/labs/lab18/app_python/default.nix new file mode 100644 index 0000000000..e5c3a3ba43 --- /dev/null +++ b/labs/lab18/app_python/default.nix @@ -0,0 +1,26 @@ +{ pkgs ? import {} }: + +pkgs.python3Packages.buildPythonApplication { + pname = "devops-info-service"; + version = "1.0.0"; + src = ./.; + + format = "other"; + + propagatedBuildInputs = with pkgs.python3Packages; [ + fastapi + uvicorn + ]; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + mkdir -p $out/bin + cp src/app.py $out/bin/devops-info-service + chmod +x $out/bin/devops-info-service + + # Wrap with Python interpreter so it can execute + wrapProgram $out/bin/devops-info-service \ + --prefix PYTHONPATH : "$PYTHONPATH" + ''; +} diff --git a/labs/lab18/app_python/docker.nix b/labs/lab18/app_python/docker.nix new file mode 100644 index 0000000000..cf77a54f0b --- /dev/null +++ b/labs/lab18/app_python/docker.nix @@ -0,0 +1,20 @@ +{ pkgs ? import {} }: + +let + app = import ./default.nix { inherit pkgs; }; +in +pkgs.dockerTools.buildLayeredImage { + name = "devops-info-service-nix"; + tag = "1.0.0"; + + contents = [ app ]; + + config = { + Cmd = [ "${app}/bin/devops-info-service" ]; + ExposedPorts = { + "5000/tcp" = {}; + }; + }; + + created = "1970-01-01T00:00:01Z"; # Reproducible timestamp +} diff --git a/labs/lab18/app_python/docs/LAB01.md b/labs/lab18/app_python/docs/LAB01.md new file mode 100644 index 0000000000..4df301dc27 --- /dev/null +++ b/labs/lab18/app_python/docs/LAB01.md @@ -0,0 +1,57 @@ +# Framework Selection +I have decided to use fastapi, because I already have experience using it +| Metric | Flask | Fastapi | Django | +|---|---|---|---| +| Used previously by me | no | yes | no | + +# Practices Applied + +I applied following practices: +- Clean code organization (clear naming, proper imports, only necessary comments, runned autopep8 on code) +- Basic error handling is implemented in fastapi itself +- Logging with `logging` module +- Dependencies managment via `requirements.txt` +- Omitting files unrelated to app (venv, pycache, logs, IDE files, etc.) + +# API Documentation +- `GET /`: Returns service and system information +- `GET /health`: Returns health status of service + + +# Testing Evidence +## Images +![Main endpoint json](./screenshots/main.png "Main endpoint") +![Health endpoint json](./screenshots/health.png "Health endpoint") +![Sample output](./screenshots/output.png "Server output") + +## Terminal output samples +Usual run (fastapi output voided) +``` +2026-01-24 16:49:20,640 - app - INFO - Application starting... +2026-01-24 16:49:24,908 - app - INFO - Collecting general information... +2026-01-24 16:49:28,027 - app - INFO - Collecting service health information... +2026-01-24 16:49:30,292 - app - INFO - Collecting service health information... +2026-01-24 16:49:38,299 - app - INFO - Collecting general information... +``` + +Run with `DEBUG=true` (fastapi output voided) +``` +2026-01-24 16:50:35,841 - app - INFO - Application starting... +2026-01-24 16:50:42,074 - app - INFO - Collecting general information... +2026-01-24 16:50:42,074 - app - DEBUG - Request: GET / +2026-01-24 16:50:45,150 - app - INFO - Collecting service health information... +2026-01-24 16:50:45,150 - app - DEBUG - Request: GET /health +2026-01-24 16:50:46,780 - app - INFO - Collecting general information... +2026-01-24 16:50:46,780 - app - DEBUG - Request: GET / +``` + +# Challenges & Solutions +During the preparation to the work, I encountered that my code editor (neovim) was not configured to work with python. + +Therefore I had to research documentation and configure everything to set up and configure the LSP. + + +# GitHub Community +Starring repositories matters, because it encourage maintainers, attracts contributors and helps project gain visibility. + +Foolowing matters, because it allows people to build professional connections, learn, collaborate and improve their career. diff --git a/labs/lab18/app_python/docs/LAB02.md b/labs/lab18/app_python/docs/LAB02.md new file mode 100644 index 0000000000..584004edfa --- /dev/null +++ b/labs/lab18/app_python/docs/LAB02.md @@ -0,0 +1,237 @@ +# Docker best practices applied +## Non-root user +Running application as non-root user +limits priviliges inside container, +prohibits system file modification, +provides kubernetes compatibility +and limits priviliges in case of container escape. + +All of stated above improves security +and compatibility of image. +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +# ... +USER appuser +CMD ["fastapi", "run", "app.py"] +``` + +## Spesific base image version +Specifying version of base image +gives reproducibility for building +image from source. +```dockerfile +FROM python:3.12-slim +``` + +## Only copy necessary files +```dockerfile +COPY ./app.py . +``` + +## Proper layer ordering +Ordering dependency installation +before copying all files allows +docker to not reinstall dependencies +if there was changes only in code, +therefore saving time on building image. +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +EXPOSE 8000 + +# Install dependencies +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY ./app.py . +``` + +## `.dockerignore` file +`.dockerignore` prevents docker from copying +certain files from working directory, thus +hiding vulnerable data (such as secrets, API keys and etc.) +and lowering image size through avoiding unnecessary files +such as documentation, IDE configurations,version control and other. +``` +# Version control +.git +.gitignore + +# Secrets +.env +*.pem +secrets/ + +# Documentation +*.md +docs/ + +# Tests +tests/ + +# IDE configurations +.vscode/ +.idea/ + +# Python +__pycache__ +*.py[oc] +venv +.venv +``` + + +# Image Information & Decisions +## Base image +I have chosen `python:3.13-slim` image +since app not size-critical enough to +use alpine variant, but i do not need +compilation tools and slim variant is much +smaller than basic python image. + +## Final image size +190 MB + +## Layer structure and optimizations explanation +First, dockerfile creates user for +non-root running and exposes port 8000. +Since there is nothing to change, then +this stage is performed only once and +cached for all future builds. + +Second, dockerfile copies `requirements.txt` and +runs `pip install` on them to prepare dependencies. +If this file have not changed since last build +(which is rarely the case in comparison with code changes), +then this stage is skipped too, therefore saving tens of +seconds of installing all dependencies. + +Third, dockerfile copies application files +(exactly one in this case) and runs it as +non-root user. This stage is most likely +not to be skipped, since code is changed +oftenly, therefore most probable to run +stage should be in the end of dockerfile. + + +# Build & Run Process +## Complete terminal output from build process +``` +[+] Building 20.9s (11/11) FINISHED docker:default + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 361B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.3s + => [internal] load .dockerignore 0.0s + => => transferring context: 231B 0.0s + => [1/6] FROM docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b 1.8s + => => resolve docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b 0.0s + => => sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 1.29MB / 1.29MB 0.7s + => => sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 11.79MB / 11.79MB 1.4s + => => sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 249B / 249B 1.0s + => => sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b18 10.37kB / 10.37kB 0.0s + => => sha256:fbc43b66207d7e2966b5f06e86f2bc46aa4b10f34bf97784f3a10da80b1d6f0b 1.75kB / 1.75kB 0.0s + => => sha256:dd4049879a507d6f4bb579d2d94b591135b95daab37abb3df9c1d40b7d71ced0 5.53kB / 5.53kB 0.0s + => => extracting sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 0.1s + => => extracting sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 0.3s + => => extracting sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 0.0s + => [internal] load build context 0.0s + => => transferring context: 63B 0.0s + => [2/6] RUN useradd --create-home --shell /bin/bash appuser 0.2s + => [3/6] WORKDIR /app 0.0s + => [4/6] COPY requirements.txt . 0.0s + => [5/6] RUN pip install --no-cache-dir -r requirements.txt 17.2s + => [6/6] COPY ./app.py . 0.0s + => exporting to image 0.3s + => => exporting layers 0.3s + => => writing image sha256:e63cd5678a4792a6b3105ab4c8268d899b31376a76bb790b365c6bf126c2907b 0.0s + => => naming to docker.io/library/infoservice:python-dev 0.0s +``` + + +## Terminal output of container running +``` + FastAPI Starting production server 🚀 + + Searching for package file structure from directories with + __init__.py files +2026-01-27 15:40:23,902 - app - INFO - Application starting... + Importing from /app + + module 🐍 app.py + + code Importing the FastAPI app object from the module with the following + code: + + from app import app + + app Using import string: app:app + + server Server started at http://0.0.0.0:8000 + server Documentation at http://0.0.0.0:8000/docs + + Logs: + + INFO Started server process [1] +2026-01-27 15:40:23,919 - uvicorn.error - INFO - Started server process [1] + INFO Waiting for application startup. +2026-01-27 15:40:23,920 - uvicorn.error - INFO - Waiting for application startup. + INFO Application startup complete. +2026-01-27 15:40:23,920 - uvicorn.error - INFO - Application startup complete. + INFO Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-01-27 15:40:23,921 - uvicorn.error - INFO - Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-01-27 15:40:26,267 - app - INFO - Collecting general information... + INFO 172.17.0.1:36228 - "GET / HTTP/1.1" 200 +2026-01-27 15:40:36,034 - app - INFO - Collecting general information... + INFO 172.17.0.1:46442 - "GET / HTTP/1.1" 200 +2026-01-27 15:41:57,231 - app - INFO - Collecting service health information... + INFO 172.17.0.1:54722 - "GET /health HTTP/1.1" 200 +^C + INFO Shutting down +2026-01-27 15:42:45,067 - uvicorn.error - INFO - Shutting down + INFO Waiting for application shutdown. +2026-01-27 15:42:45,169 - uvicorn.error - INFO - Waiting for application shutdown. + INFO Application shutdown complete. +2026-01-27 15:42:45,170 - uvicorn.error - INFO - Application shutdown complete. + INFO Finished server process [1] +2026-01-27 15:42:45,171 - uvicorn.error - INFO - Finished server process [1] +``` + +## Terminal output from testing endpoints +- root +``` +{"service":{"name":"DevOps Info Service","version":"0.0.1","description":"DevOps course info service","framework":"fastapi"},"system":{"hostname":"7349c843900b","platform":"Linux","platform_version":"#1-NixOS SMP PREEMPT_DYNAMIC Thu Jan 8 09:15:06 UTC 2026","architecture":"x86_64","python_version":"3.13.11"},"runtime":{"uptime_seconds":2,"uptime_human":"0 hours, 0 minutes","current_time":"2026-01-27T15:40:26.267899+00:00","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.17.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` +- health +``` +{"status":"healthy","timestamp":"2026-01-27T15:41:57.231209+00:00","uptime_seconds":93} +``` + +## Registry +[Link to Docker registry](https://hub.docker.com/repository/docker/ub3rch/infoservice/general) + + +# Technical analysis +My dockerfile works the way it does, +because I wrote it the way I wrote it. + +Build time will increase, since docker +will perform stages, which can be skipped +(due to caching) +if they were performed in different oreder. + +I implemented following security considerations: +- Non-root user running +- Hiding secrets with dockerfile + +`.dockerignore` improves my build through +lowering image size and improving security. + + +# Challenges & Solutions +I have not encountered any major issues +with lab implementation, since I already +have some experience with docker +from other courses. +However that experience was a little bit old, +therefore I improved through repetion +and recall of known material, with addition of +new material such non-root user running. diff --git a/labs/lab18/app_python/docs/LAB03.md b/labs/lab18/app_python/docs/LAB03.md new file mode 100644 index 0000000000..5e34d4ce97 --- /dev/null +++ b/labs/lab18/app_python/docs/LAB03.md @@ -0,0 +1,126 @@ +# Overview +## Testing Framework +I have chosen pytest as testing framework, +since it is simple and powerful and +adding this dependency will not cause +critical slowing of CI. + +## Test Coverage +### Test Structure Explanation +- test_endpoint_main(): +Ensures main endpoint +is present and checks right, +platform-dependent output. +- test_endpoint_health(): +Ensures healt endpoint is present. +- test_request_info(mocker): +Test the main endpoint +response from simulated +different ip's and user agents. + +### Running Tests Locally +From `app_python` directory: +```bash +source venv/bin/activate +pytest +``` + +### Terminal Output +``` +====================== test session starts ====================== +platform linux -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/uber/code/DevOps/app_python +configfile: pyproject.toml +plugins: anyio-4.12.1, mock-3.15.1 +collected 3 items + +tests/test_sample.py ... [100%] +================= 3 passed, 0 warning in 0.29s ================== +``` + +## CI workflow trigger configuration +### Trigger Strategy and Reasoning +Workflow triggers on pushes and +PR's to master branch +(assuming changes in application +or workflow) + +## Versioning strategy +Semantic versioning +because it represents my +progress with course. + +# Workflow evidence +[Successful workflow](https://github.com/Uberch/DevOps-Core-Course/actions/runs/21901627386) +Tests passing locally: +```bash +====================== test session starts ====================== +platform linux -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/uber/code/DevOps/app_python +configfile: pyproject.toml +plugins: cov-7.0.0, anyio-4.12.1, mock-3.15.1 +collected 3 items + +tests/test_sample.py ... [100%] + +======================= warnings summary ======================== +venv/lib/python3.13/site-packages/starlette/formparsers.py:12 + /home/uber/code/DevOps/app_python/venv/lib/python3.13/site-packages/starlette/formparsers.py:12: PendingDeprecationWarning: Please use `import python_multipart` instead. + import multipart + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +======================== tests coverage ========================= +_______ coverage: platform linux, python 3.13.11-final-0 ________ + +Name Stmts Miss Cover +------------------------------------------------ +infoservice/infoservice.py 76 1 99% +------------------------------------------------ +TOTAL 76 1 99% +Coverage XML written to file coverage.xml +Required test coverage of 70% reached. Total coverage: 98.68% +================= 3 passed, 1 warning in 0.48s ================== +(venv) +``` + +[Image on Docker Hub](https://hub.docker.com/repository/docker/ub3rch/infoservice/general) + +# Best Practices +- Job Dependencies: Dont do work, +which will fail, because previous +work failed +- Pull Request Checks: Prevents +bad code in master branch, productions +- Fail Fast: Catch errors as early as possible +- Caching: +- Snyk not done, since snyk blocks Russian users + +# Key decisions +## Versioning Strategy +I have decided to use +Semantic versioning of type +"python-.1.0" + +## Docker tags +My workflow creates two tags: +- python- +- latest + +## Wokflow triggers +The workflow is triggerred on +push, this allows fast feedback +on each delivered change. + +Also workflow triggers on pull +requests to prevent merging of +bad code to master branch. + +## Test coverage +What is covered: +System- and Client- dependend outputs +(to prevent occasional hard-coding) + +What is not covered: +Getters, setters and +hardcoded(intentionally) +outputs diff --git a/labs/lab18/app_python/docs/screenshots/health.png b/labs/lab18/app_python/docs/screenshots/health.png new file mode 100644 index 0000000000..c48f92cd1e Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/health.png differ diff --git a/labs/lab18/app_python/docs/screenshots/main.png b/labs/lab18/app_python/docs/screenshots/main.png new file mode 100644 index 0000000000..9b14b67cb3 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/main.png differ diff --git a/labs/lab18/app_python/docs/screenshots/metrics.png b/labs/lab18/app_python/docs/screenshots/metrics.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/metrics.png differ diff --git a/labs/lab18/app_python/docs/screenshots/output.png b/labs/lab18/app_python/docs/screenshots/output.png new file mode 100644 index 0000000000..c87e5dcd73 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/output.png differ diff --git a/labs/lab18/app_python/pyproject.toml b/labs/lab18/app_python/pyproject.toml new file mode 100644 index 0000000000..776531a67a --- /dev/null +++ b/labs/lab18/app_python/pyproject.toml @@ -0,0 +1,11 @@ +[tool.basedpyright] +reportUnknownVariableType = "none" +reportUnknownParameterType = "none" +reportUnknownMemberType = "none" +reportMissingTypeStubs = "none" + +[tool.pytest.ini_options] +addopts = "--cov=. --cov-fail-under=70 --cov-report=xml --cov-report=term" + +[tool.coverage.run] +omit = [ "tests/*", "*/__init__.py" ] diff --git a/labs/lab18/app_python/requirements.txt b/labs/lab18/app_python/requirements.txt new file mode 100644 index 0000000000..7da478f9c8 --- /dev/null +++ b/labs/lab18/app_python/requirements.txt @@ -0,0 +1,7 @@ +fastapi[standard]==0.115.0 +uvicorn[standard]==0.32.0 # Includes performance extras +prometheus-client==0.23.1 +pytest +pytest-cov +pytest-mock +httpx diff --git a/labs/lab18/app_python/result b/labs/lab18/app_python/result new file mode 120000 index 0000000000..6942e775af --- /dev/null +++ b/labs/lab18/app_python/result @@ -0,0 +1 @@ +/nix/store/ipw3p4rvgnzhq5a4ll5ggfb9z23nnwws-devops-info-service-nix.tar.gz \ No newline at end of file diff --git a/labs/lab18/app_python/src/__init__.py b/labs/lab18/app_python/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/labs/lab18/app_python/src/__pycache__/__init__.cpython-313.pyc b/labs/lab18/app_python/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000..9167e177d6 Binary files /dev/null and b/labs/lab18/app_python/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/labs/lab18/app_python/src/__pycache__/app.cpython-313.pyc b/labs/lab18/app_python/src/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000..98883ce02b Binary files /dev/null and b/labs/lab18/app_python/src/__pycache__/app.cpython-313.pyc differ diff --git a/labs/lab18/app_python/src/__pycache__/infoservice.cpython-313.pyc b/labs/lab18/app_python/src/__pycache__/infoservice.cpython-313.pyc new file mode 100644 index 0000000000..c1fff9c6e7 Binary files /dev/null and b/labs/lab18/app_python/src/__pycache__/infoservice.cpython-313.pyc differ diff --git a/labs/lab18/app_python/src/app.py b/labs/lab18/app_python/src/app.py new file mode 100644 index 0000000000..2531cfd764 --- /dev/null +++ b/labs/lab18/app_python/src/app.py @@ -0,0 +1,294 @@ +""" +DevOps Info Service +Main application module +""" + +# Imports +import os +from pydoc import tempfile_pager +import socket +import platform +import logging +import json +import time +from pydantic import BaseModel +from datetime import datetime, timezone +from fastapi import FastAPI, Request +from fastapi.responses import PlainTextResponse +from prometheus_client import Counter, Histogram, Gauge, CollectorRegistry +from prometheus_client import generate_latest, CONTENT_TYPE_LATEST + + +# Setting up logging +class JSONFormatter(logging.Formatter): + def format(self, record): + log_obj = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "level": record.levelname, + "message": record.getMessage(), + "logger": record.name, + } + if record.exc_info: + log_obj["exception"] = self.formatException(record.exc_info) + return json.dumps(log_obj) + + +handler = logging.StreamHandler() +handler.setFormatter(JSONFormatter()) +logger = logging.getLogger() +logger.addHandler(handler) + + +if os.getenv('DEBUG', 'false') == 'true': + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + + +# Setting up pydantic structures +class SystemInfo(BaseModel): + hostname: str + platform: str + platform_version: str + architecture: str + python_version: str + + +class ServiceInfo(BaseModel): + name: str + version: str + description: str + framework: str + + +class UptimeInfo(BaseModel): + uptime_seconds: int + uptime_human: str + current_time: str + timezone: str + + +class RequestInfo(BaseModel): + client_ip: str + user_agent: str + method: str + path: str + + +class EndpointInfo(BaseModel): + path: str + method: str + description: str + + +class MainEndpoint(BaseModel): + system: SystemInfo + service: ServiceInfo + runtime: UptimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] + + +class HealthEndpoint(BaseModel): + status: str + timestamp: str + uptime_seconds: int + + +class VisitsEndpoint(BaseModel): + count: int + + +# Various information collecting functions +def get_system_info(): + """Collect system information.""" + return SystemInfo( + hostname=socket.gethostname(), + platform=platform.system(), + platform_version=platform.version(), + architecture=platform.machine(), + python_version=platform.python_version() + ) + + +def get_service_info(): + """Collect service information.""" + return ServiceInfo( + name=app.title, + version=app.version, + description=app.description, + framework="fastapi", + ) + + +def get_uptime(): + delta = datetime.now() - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return UptimeInfo( + uptime_seconds=seconds, + uptime_human=f"{hours} hours, {minutes} minutes", + current_time=datetime.now(timezone.utc).isoformat(), + timezone=str(timezone.utc), + ) + + +def get_endpoints(): + return [ + EndpointInfo( + path="/", + method="GET", + description="Service information", + ), + EndpointInfo( + path="/health", + method="GET", + description="Health check", + ), + ] + + +def increase_visit_count(): + count_file = "/data/visits" + temp_file = "/data/visits.temp" + while os.path.isfile(temp_file): + pass + with open(temp_file, "w") as t_file: + count = read_visit_count() + _ = t_file.write(str(count+1)) + os.replace(temp_file, count_file) + + +def read_visit_count(): + count_file = "/data/visits" + if os.path.isfile(count_file): + with open(count_file, "r") as file: + return int(file.read()) + else: return 0 + + +# Application start time +logger.info("Application starting...") +START_TIME = datetime.now(timezone.utc) +start_time = datetime.now() + +app = FastAPI( + title="DevOps Info Service", + description="DevOps course info service", + summary="", + version="0.1.1", +) +registry = CollectorRegistry() + + +# Prometheus metrics +http_requests_total = Counter( + 'http_requests_total', + 'Total HTTP requests', + ['method', 'endpoint', 'status'], + registry=registry, +) + +http_request_duration_seconds = Histogram( + 'http_request_duration_seconds', + 'HTTP request duration', + ['method', 'endpoint'], + registry=registry, +) + +http_requests_in_progress = Gauge( + 'http_requests_in_progress', + 'HTTP requests currently being processed', + registry=registry, +) + +endpoint_calls = Counter( + 'devops_info_endpoint_calls', + 'Endpoint calls', + ['endpoint'], + registry=registry, +) + +system_info_duration = Histogram( + 'devops_info_system_collection_seconds', + 'System info collection time', + registry=registry, +) + + +@app.middleware("http") +async def middleware(request: Request, call_next): + logger.info(f'Request: {request.method} {request.url.path}') + start_time = time.time() + + response = await call_next(request) + + path = request.url.path + + duration = time.time() - start_time + http_requests_total.labels( + method=request.method, + endpoint=path, + status=str(response.status_code), + ).inc() + + http_request_duration_seconds.labels( + method=request.method, + endpoint=path, + ).observe(duration) + + logger.info(f'Response code: {response.status_code}.') + return response + + +# FastAPI +@app.get("/") +@http_requests_in_progress.track_inprogress() +def index(request: Request): + increase_visit_count() + """Main endpoint - service and system information.""" + logger.info("Collecting general information...") + + start_time = time.time() + sys_info = get_system_info() + duration = time.time() - start_time + system_info_duration.observe(duration) + + return MainEndpoint( + system=sys_info, + service=get_service_info(), + runtime=get_uptime(), + request=RequestInfo( + client_ip=request.client.host, + user_agent=request.headers.get("user-agent"), + method=request.method, + path=request.url.path, + ), + endpoints=get_endpoints(), + ) + + +@app.get("/health") +@http_requests_in_progress.track_inprogress() +def health(): + """Health endpoint - information about services status""" + logger.info("Collecting service health information...") + uptime_info = get_uptime() + return HealthEndpoint( + status="healthy", + timestamp=uptime_info.current_time, + uptime_seconds=uptime_info.uptime_seconds, + ) + + +@app.get("/metrics") +def metrics(): + return PlainTextResponse( + generate_latest(registry), + media_type=CONTENT_TYPE_LATEST + ) + +@app.get("/visits") +def visits(): + return VisitsEndpoint(count=read_visit_count()) diff --git a/labs/lab18/app_python/tests/__init__.py b/labs/lab18/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/labs/lab18/app_python/tests/__pycache__/__init__.cpython-313.pyc b/labs/lab18/app_python/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000..ef9b43b982 Binary files /dev/null and b/labs/lab18/app_python/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/labs/lab18/app_python/tests/__pycache__/test_sample.cpython-313-pytest-9.0.2.pyc b/labs/lab18/app_python/tests/__pycache__/test_sample.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000..f7ba0ec39d Binary files /dev/null and b/labs/lab18/app_python/tests/__pycache__/test_sample.cpython-313-pytest-9.0.2.pyc differ diff --git a/labs/lab18/app_python/tests/test_sample.py b/labs/lab18/app_python/tests/test_sample.py new file mode 100644 index 0000000000..51c4bf20f5 --- /dev/null +++ b/labs/lab18/app_python/tests/test_sample.py @@ -0,0 +1,55 @@ +import socket +import platform +from fastapi.testclient import TestClient +from infoservice.infoservice import app + +client = TestClient(app) + +# Test main endpoint +def test_endpoint_main(): + response = client.get("/") + # Test presence + assert response.status_code == 200 + # Test verifiable variable fields + assert response.json()["system"]["hostname"] == socket.gethostname() + assert response.json()["system"]["platform"] == platform.system() + assert response.json()["system"]["platform_version"] == platform.version() + assert response.json()["system"]["architecture"] == platform.machine() + assert response.json()["system"]["python_version"] == platform.python_version() + + +# Test health endpoint +def test_endpoint_health(): + response = client.get("/health") + # Test presence + assert response.status_code == 200 + +# Test request info processing +def test_request_info(mocker): + """Test how program parses request from different hosts""" + # First try + mock_ip = "16.32.64.128" + mock_client = mocker.patch("fastapi.Request.client") + mock_client.host = mock_ip + response = client.get( + "/", + headers={ + "user-agent": "noexist", + } + ) + assert response.json()["request"]["client_ip"] == mock_ip + assert response.json()["request"]["user_agent"] == "noexist" + + # Second try + mock_ip = "8.16.32.64" + mock_client = mocker.patch("fastapi.Request.client") + mock_client.host = mock_ip + response = client.get( + "/", + headers={ + "user-agent": "doexist", + } + ) + assert response.json()["request"]["client_ip"] == mock_ip + assert response.json()["request"]["user_agent"] == "doexist" + diff --git a/labs/submission18.md b/labs/submission18.md new file mode 100644 index 0000000000..2fb966b69c --- /dev/null +++ b/labs/submission18.md @@ -0,0 +1,160 @@ +# Task 1 +## Installation steps +Pre-installed on my nixos system + +## `default.nix` +```nix +{ pkgs ? import {} }: # Inputs +pkgs.python3Packages.buildPythonApplication { + pname = "devops-info-service"; # Package name + version = "1.0.0"; # Version + src = ./.; # Source dir + + format = "other"; + + propagatedBuildInputs = with pkgs.python3Packages; [ # Packages, requiered for build (and propagated to dependencies) + fastapi + uvicorn + ]; + + nativeBuildInputs = [ pkgs.makeWrapper ]; # Packages, requiered for build + + # Installation script + installPhase = '' + mkdir -p $out/bin + cp src/app.py $out/bin/devops-info-service + chmod +x $out/bin/devops-info-service + + # Wrap with Python interpreter so it can execute + wrapProgram $out/bin/devops-info-service \ + --prefix PYTHONPATH : "$PYTHONPATH" + ''; +} +``` + +## Store path +`/nix/store/y1j4wjhl1v1518chqbrjwksa9mrrbzzv-devops-info-service-1.0.0` +Store path identical by construction + +## `pip install` vs Nix derivation +| Aspect | `pip install` | Nix | +|--------|-------------------|--------------| +| Python version | System-dependent | Pinned in derivation | +| Dependency resolution | Runtime (`pip install`) | Build-time (pure) | +| Reproducibility | Approximate (with lockfiles) | Bit-for-bit identical | +| Portability | Requires same OS + Python | Works anywhere Nix runs | +| Binary cache | No | Yes (cache.nixos.org) | +| Isolation | Virtual environment | Sandboxed build | +| Store path | N/A | Content-addressable hash | + +## `requirements.txt` +`requirements.txt` provide weaker guarantees than Nixos, because Nix guarantees bit-by-bit identical builds, and requirements give aproximate locks for dependency versions + +## Screenshots +![App running from nix-built version](./lab18/lab1running.png) + +## Nix store paths +Paths in nix store have type of `/nix/store/--` + +## Reflection +Nix would remove any problems with dependencies + +# Task 2 +## `docker.nix` +```nix +{ pkgs ? import {} }: + +let + app = import ./default.nix { inherit pkgs; }; +in +pkgs.dockerTools.buildLayeredImage { + name = "devops-info-service-nix"; # Container name + tag = "1.0.0"; # Container tag + + contents = [ app ]; # Contents of containter + + config = { # Configuration of container + Cmd = [ "${app}/bin/devops-info-service" ]; + ExposedPorts = { + "5000/tcp" = {}; + }; + }; + + created = "1970-01-01T00:00:01Z"; # Reproducible timestamp +} +``` + +## Docker file vs Nix +| Aspect | Lab 2 Traditional Dockerfile | Lab 18 Nix dockerTools | +|--------|------------------------------|------------------------| +| **Base images** | `python:3.13-slim` (changes over time) | No base image (pure derivations) | +| **Timestamps** | Different on each build | Fixed or deterministic | +| **Package installation** | `pip install` at build time | Nix store paths (immutable) | +| **Reproducibility** | ❌ Same Dockerfile → Different images | ✅ Same docker.nix → Identical images | +| **Caching** | Layer-based (breaks on timestamp) | Content-addressable (perfect caching) | +| **Image size** | ~150MB+ with full base image | ~50-80MB with minimal closure | +| **Portability** | Requires Docker | Requires Nix (then loads to Docker) | +| **Security** | Base image vulnerabilities | Minimal dependencies, easier auditing | +| **Lab 2 Learning** | Best practices, non-root user | Build on Lab 2 knowledge | + +## Hash +```bash +/nix/store/ipw3p4rvgnzhq5a4ll5ggfb9z23nnwws-devops-info-service-nix.tar.gz +c188963bccc9751b22dc882329722f42a372e48fea8cbbf23fd65ae94fe6e313 result +/nix/store/ipw3p4rvgnzhq5a4ll5ggfb9z23nnwws-devops-info-service-nix.tar.gz +c188963bccc9751b22dc882329722f42a372e48fea8cbbf23fd65ae94fe6e313 result +``` +Hashes are same by construction + +## `docker history` +```bash +IMAGE CREATED CREATED BY SIZE COMMENT +441122499869 N/A 300B store paths: ['/nix/store/jdgi0aiv8403z3ix8ygxbfh61rzb21rl-devops-info-service-nix-customisation-layer'] + N/A 12.6kB store paths: ['/nix/store/63cfdhali36l7lm1zlp2q1yzmqlvpf2y-devops-info-service-1.0.0'] + N/A 1.58MB store paths: ['/nix/store/q1xbcz78q7qp51pwxvd7yl593y74v0xh-python3.13-fastapi-0.116.1'] + N/A 5.44MB store paths: ['/nix/store/10h9alp34gf83f00v1j0irab82n4zavg-python3.13-pydantic-2.11.7'] + N/A 5.51MB store paths: ['/nix/store/xi3gl6v4b3gw28lig89gbkzkn50fd1nj-python3.13-pydantic-core-2.33.2'] + N/A 960kB store paths: ['/nix/store/qp1p9y50adz6mgas3b45vnkhi46dfi7b-python3.13-starlette-0.47.2'] + N/A 1.69MB store paths: ['/nix/store/nxb9m8bpm10197r0c504y73m3r19zgrz-python3.13-anyio-4.11.0'] + N/A 802kB store paths: ['/nix/store/jjh9yygprkifwharwf0g5qr55mwyvkc5-python3.13-uvicorn-0.35.0'] + N/A 1.23MB store paths: ['/nix/store/in716x64gf5c99llz3abnsm71kiqcj2q-python3.13-click-8.2.1'] + N/A 934kB store paths: ['/nix/store/b8xwwa4ap3nrfg4mxpkq7vnnxxsdwybk-python3.13-idna-3.11'] + N/A 125kB store paths: ['/nix/store/x9xhq47r11hpcp6sdm69v2200g7vd747-python3.13-typing-inspection-0.4.2'] + N/A 504kB store paths: ['/nix/store/qfbq9xq5cy2i3z8fmi0kxghw7if9k8kw-python3.13-typing-extensions-4.15.0'] + N/A 267kB store paths: ['/nix/store/vws161bf1plrax5xwi2bac7d50vlsh7p-python3.13-h11-0.16.0'] + N/A 102kB store paths: ['/nix/store/0khqpcnj44bbg0mlhnz47kah31q2j0iz-python3.13-annotated-types-0.7.0'] + N/A 38.8kB store paths: ['/nix/store/c2ghbbyahc0qy6bfz2cb87c39givlici-python3.13-sniffio-1.3.1'] + N/A 111MB store paths: ['/nix/store/cdaifv92znxy5ai4sawricjl0p5b9sgf-python3-3.13.11'] + N/A 9.97MB store paths: ['/nix/store/gnpwfj9gpk8ll7dhf65a6r5gjbs4qbap-gcc-14.3.0-lib'] + N/A 9.27MB store paths: ['/nix/store/2p91cylbmdv4si5j818pnsg6qcbgin72-openssl-3.6.0'] + N/A 509kB store paths: ['/nix/store/gwyr8gxfj0rm2hnvx47zlfy5xvlwsd05-readline-8.3p1'] + N/A 3.95MB store paths: ['/nix/store/8bh8g107igzm703ib6vhslnagm1j47km-sqlite-3.50.4'] + N/A 3.24MB store paths: ['/nix/store/kpi3v5fl8hlgy5lagjvn6ayq78mla49k-ncurses-6.5'] + N/A 2.01MB store paths: ['/nix/store/hdy5qs834h84dhb85hxjss6mgqjjbx11-util-linux-minimal-2.41.3-lib'] + N/A 1.84MB store paths: ['/nix/store/j8645yndikbrvn292zgvyv64xrrmwdcb-bash-5.3p3'] + N/A 843kB store paths: ['/nix/store/hd4ff821999qr8iazn8fb42y0zzaarfp-xz-5.8.1'] + N/A 449kB store paths: ['/nix/store/znzrrwird7n8vkapi0rp4acv27j3ky01-gdbm-1.26-lib'] + N/A 301kB store paths: ['/nix/store/8hsj833zsm1bxhg49kykya3gn6gpp8jg-expat-2.7.3'] + N/A 224kB store paths: ['/nix/store/cwnb9q1xpw5rzss11pwjlz65jpl6m41d-mpdecimal-4.0.1'] + N/A 131kB store paths: ['/nix/store/nymigg679qmp97i3gilldx27p3ylfqy9-zlib-1.3.1'] + N/A 83.6kB store paths: ['/nix/store/f0rzmqp8qmlrpsnm1jp6hgh4f7030fsa-bzip2-1.0.8'] + N/A 72.5kB store paths: ['/nix/store/c86nvwib4x4w4lkd3qw2aw40a354b6yd-libffi-3.5.2'] + N/A 30MB store paths: ['/nix/store/wqfs0wh0wp6vdcbbck3wzk5v15qy17m7-glibc-2.40-66'] + N/A 353kB store paths: ['/nix/store/hxcmad417fd8ql9ylx96xpak7da06yiv-libidn2-2.3.8'] + N/A 1.9MB store paths: ['/nix/store/xh1ff9c9c0yv1wxrwa5gnfp092yagh7v-tzdata-2025b'] + N/A 2.08MB store paths: ['/nix/store/3rkccxj7vi0p2a0d48c4a4z2vv2cni88-libunistring-1.4.1'] + N/A 201kB store paths: ['/nix/store/2a3izq4hffdd9r9gb2w6q2ibdc86kss6-xgcc-14.3.0-libgcc'] + N/A 201kB store paths: ['/nix/store/n600a20z97mhhdnry40lp47nmnv16py5-gcc-14.3.0-libgcc'] + N/A 118kB store paths: ['/nix/store/m3954qff15v7z1l6lpyqf8v2h47c7hv2-mailcap-2.1.54'] +``` + +## Analysis +Because timestamps also hashed in traditional Docker + +## Reflection +I wouldn`t write Dockerfile + +## Practical scenarios +- CI/CD +- security audits +- rollbacks diff --git a/monitoring/.gitignore b/monitoring/.gitignore new file mode 100644 index 0000000000..7116c4b844 --- /dev/null +++ b/monitoring/.gitignore @@ -0,0 +1,3 @@ +*-data/ +*.env + diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..620bda034a --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,149 @@ +services: + loki: + image: grafana/loki:3.0.0 + ports: + - "3100:3100" + networks: + - default + - logging + volumes: + - ./loki/config.yml:/etc/loki/config.yml + - loki-data:/loki + command: -config.file=/etc/loki/config.yml + healthcheck: + start_period: 30s + timeout: 10s + interval: 10s + retries: 10 + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://loki:3100/ready"] + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + promtail: + image: grafana/promtail:3.0.0 + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + command: -config.file=/etc/promtail/config.yml + networks: + - logging + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + grafana: + image: grafana/grafana:12.3.1 + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + environment: + - GF_AUTH_ANONYMOUS_ENABLED=false + - GF_AUTH_BASIC_ENABLED=true + - GF_AUTH_LDAP_ENABLED=true + env_file: + - ./grafana.env + networks: + - default + - logging + healthcheck: + start_period: 30s + timeout: 10s + interval: 10s + retries: 10 + test: ["CMD", "curl", "--silent", "http://grafana:3000/api/health"] + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + prometheus: + image: prom/prometheus:v3.8.0 + ports: + - "9090:9090" + volumes: + - ./prometheus/config.yml:/etc/prometheus/config.yml + - prometheus-data:/data + networks: + - logging + command: --config.file=/etc/prometheus/config.yml + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + infoservice-python: + image: ub3rch/infoservice:python-latest + ports: + - "8000:8000" + volumes: + - python-app-data:/data + networks: + - default + - logging + labels: + logging: "promtail" + app: "devops-python" + healthcheck: + start_period: 30s + timeout: 10s + interval: 10s + retries: 10 + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://infoservice-python:8000/health"] + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + infoservice-go: + image: ub3rch/infoservice:go-latest + ports: + - "8001:8000" + networks: + - default + - logging + labels: + logging: "promtail" + app: "devops-go" + healthcheck: + start_period: 30s + timeout: 10s + interval: 10s + retries: 10 + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://infoservice-go:8000/health"] + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + +networks: + logging: + driver: bridge + +volumes: + prometheus-data: + grafana-data: + loki-data: + python-app-data: diff --git a/monitoring/docs/LAB07.md b/monitoring/docs/LAB07.md new file mode 100644 index 0000000000..5630b73866 --- /dev/null +++ b/monitoring/docs/LAB07.md @@ -0,0 +1,30 @@ +# Evidence +## Task 1 +![Grafana logs in grafana](./grafana_logs.png) +![Loki logs in grafana](./loki_logs.png) +![Prometheus logs in grafana](./prometheus_logs.png) + +## Task 2 +![JSON output from application](./python_json_log.png) +![Grafana showing logs from both apps](./grafana_both_logs.png) +Log QL Queries used: +- `{container="monitoring-infoservice-python-1"} |= ""` +- `{container="monitoring-infoservice-python-1"} |= "ERROR"` +- `{container="monitoring-infoservice-python-1"} | json | __error__=''` + +## Task 3 +![Working dashboard](./dashboard.png) + +## Task 4 +![`docker compose ps` output](./docker_ps.png) +![Logged grafana](./grafana_logged.png) + +# Architecture +# Setup Guide +# Configuration +# Application Logging +# Dashboard +# Production Config +# Testing +# Challenges +- There is no `curl` in some images to check health, therefore had to use `wget` instead. diff --git a/monitoring/docs/dashboard.json b/monitoring/docs/dashboard.json new file mode 100644 index 0000000000..49bcf68ddb --- /dev/null +++ b/monitoring/docs/dashboard.json @@ -0,0 +1,740 @@ +{ + "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": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 0, + "y": 0 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "editorMode": "code", + "expr": "up{job=\"python\"}", + "range": true, + "refId": "A" + } + ], + "title": "Uptime", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 7, + "x": 9, + "y": 0 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "editorMode": "builder", + "expr": "http_requests_in_progress", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Active Requests", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "sort": "desc", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "editorMode": "code", + "expr": "sum by (status) (rate(http_requests_total[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Status Code Distribution", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request duration p95", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "editorMode": "code", + "expr": "sum(rate(http_requests_total[5m])) by (endpoint)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "editorMode": "code", + "expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m]))", + "range": true, + "refId": "A" + } + ], + "title": "Error Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 4, + "options": { + "calculate": true, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "cfmm3mons9iwwc" + }, + "editorMode": "code", + "expr": "rate(http_request_duration_seconds_bucket[5m])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Duration Heatmap", + "type": "heatmap" + }, + { + "datasource": { + "type": "loki", + "uid": "efmm3jke8ju9sa" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 23 + }, + "id": 8, + "options": { + "dedupStrategy": "none", + "enableInfiniteScrolling": false, + "enableLogDetails": true, + "showControls": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "cfmlvr4jdlwcgd" + }, + "direction": "backward", + "editorMode": "builder", + "expr": "{container=\"monitoring-infoservice-python-1\"} |= ``", + "queryType": "range", + "refId": "A" + } + ], + "title": "Python logs", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "efmm3jke8ju9sa" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 23 + }, + "id": 9, + "options": { + "dedupStrategy": "none", + "enableInfiniteScrolling": false, + "enableLogDetails": true, + "showControls": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "cfmlvr4jdlwcgd" + }, + "direction": "backward", + "editorMode": "builder", + "expr": "{container=\"monitoring-infoservice-python-1\"} |= `ERROR`", + "queryType": "range", + "refId": "A" + } + ], + "title": "Python errors only", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "efmm3jke8ju9sa" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 31 + }, + "id": 10, + "options": { + "dedupStrategy": "none", + "enableInfiniteScrolling": false, + "enableLogDetails": true, + "showControls": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "cfmlvr4jdlwcgd" + }, + "direction": "backward", + "editorMode": "builder", + "expr": "{container=\"monitoring-infoservice-python-1\"} | json | __error__=``", + "queryType": "range", + "refId": "A" + } + ], + "title": "Python info logs", + "type": "logs" + } + ], + "preload": false, + "refresh": "auto", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "DevOps lab 7", + "uid": "g7xn7g", + "version": 2 +} diff --git a/monitoring/docs/dashboard.png b/monitoring/docs/dashboard.png new file mode 100644 index 0000000000..a082cbe72b Binary files /dev/null and b/monitoring/docs/dashboard.png differ diff --git a/monitoring/docs/docker_ps.png b/monitoring/docs/docker_ps.png new file mode 100644 index 0000000000..90dbc9dc53 Binary files /dev/null and b/monitoring/docs/docker_ps.png differ diff --git a/monitoring/docs/grafana_app_logs.png b/monitoring/docs/grafana_app_logs.png new file mode 100644 index 0000000000..68ff43590e Binary files /dev/null and b/monitoring/docs/grafana_app_logs.png differ diff --git a/monitoring/docs/grafana_both_logs.png b/monitoring/docs/grafana_both_logs.png new file mode 100644 index 0000000000..7a01bcd649 Binary files /dev/null and b/monitoring/docs/grafana_both_logs.png differ diff --git a/monitoring/docs/grafana_logged.png b/monitoring/docs/grafana_logged.png new file mode 100644 index 0000000000..f946d01255 Binary files /dev/null and b/monitoring/docs/grafana_logged.png differ diff --git a/monitoring/docs/grafana_logs.png b/monitoring/docs/grafana_logs.png new file mode 100644 index 0000000000..9fd6fb2e36 Binary files /dev/null and b/monitoring/docs/grafana_logs.png differ diff --git a/monitoring/docs/loki_logs.png b/monitoring/docs/loki_logs.png new file mode 100644 index 0000000000..357c463fba Binary files /dev/null and b/monitoring/docs/loki_logs.png differ diff --git a/monitoring/docs/prometheus_logs.png b/monitoring/docs/prometheus_logs.png new file mode 100644 index 0000000000..38c706b3bb Binary files /dev/null and b/monitoring/docs/prometheus_logs.png differ diff --git a/monitoring/docs/python_json_log.png b/monitoring/docs/python_json_log.png new file mode 100644 index 0000000000..4695cb109d Binary files /dev/null and b/monitoring/docs/python_json_log.png differ diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000000..d648175644 --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,29 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + instance_addr: 0.0.0.0 + kvstore: + store: inmemory + +limits_config: + retention_period: 168h + +schema_config: + configs: + - from: 2026-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h diff --git a/monitoring/prometheus/config.yml b/monitoring/prometheus/config.yml new file mode 100644 index 0000000000..63a6c5079f --- /dev/null +++ b/monitoring/prometheus/config.yml @@ -0,0 +1,30 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +# Storage retention (Prometheus 3.x config-based retention) +storage: + tsdb: + retention: + time: 15d + size: 10GB + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['prometheus:9090'] + + - job_name: 'python' + static_configs: + - targets: ['infoservice-python:8000'] + metrics_path: '/metrics' + + - job_name: 'loki' + static_configs: + - targets: ['loki:3100'] + metrics_path: '/metrics' + + - job_name: 'grafana' + static_configs: + - targets: ['grafana:3000'] + metrics_path: '/metrics' diff --git a/monitoring/promtail/config.yml b/monitoring/promtail/config.yml new file mode 100644 index 0000000000..7538793c75 --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,18 @@ +server: + http_listen_port: 9080 + +positions: + filename: /tmp/positions.yaml + +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 + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..5727bf4f81 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 5.0" + } + } +} + +provider "github" { + token = var.github_token # Personal access token +} + +resource "github_repository" "course_repo" { + name = "DevOps-Core-Course" + description = "DevOps course lab assignments" + visibility = "public" + + has_issues = true + has_wiki = false + has_projects = false +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..16d42e28e6 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,4 @@ +variable "github_token" { + description = "Token for github integration" + type = string +} diff --git a/wrangler/.wrangler/cache/cf.json b/wrangler/.wrangler/cache/cf.json new file mode 100644 index 0000000000..22c891c5d8 --- /dev/null +++ b/wrangler/.wrangler/cache/cf.json @@ -0,0 +1 @@ +{"httpProtocol":"HTTP/1.1","clientAcceptEncoding":"gzip, deflate, br","requestPriority":"","edgeRequestKeepAliveStatus":1,"requestHeaderNames":{},"clientTcpRtt":230,"clientQuicRtt":0,"colo":"MCI","asn":56971,"asOrganization":"CGI GLOBAL LIMITED","country":"US","isEUCountry":false,"city":"Kansas City","continent":"NA","region":"Missouri","regionCode":"MO","timezone":"America/Chicago","longitude":"-94.57857","latitude":"39.09973","postalCode":"64106","metroCode":"616","tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES256-GCM-SHA384","tlsClientRandom":"Jtiw9W5JRB0OKWDDTV8LEffSrFkh3JIOWQrBDXF4AaU=","tlsClientCiphersSha1":"kXrN3VEKDdzz2cPKTQaKzpxVTxQ=","tlsClientExtensionsSha1":"1eY97BUYYO8vDaTfHQywB1pcNdM=","tlsClientExtensionsSha1Le":"u4wtEMFQBY18l3BzHAvORm+KGRw=","tlsExportedAuthenticator":{"clientHandshake":"b85282729e31874424706e5d7e988106c156596fa20cc42e242779a60d57a6d997d8226ca94500ee4d227684fe430a07","serverHandshake":"a962107856c500699c98100cebde5439e5443c13b0571507990064c198cd40409e5e94df70830ca32a8317227dd1163b","clientFinished":"b2d6012bda8947ff1f3ea95967b8db208d8448c921f95a96220ad315bce615b56989f277671d87573b259c4d5a609e0d","serverFinished":"ebda24f896c704c81cbb9a91b5eedc596b3f98d60a7553653f8bf8162dda214c93b3271b966f1a6045eb95d35ffb9a05"},"tlsClientHelloLength":"1603","tlsClientAuth":{"certPresented":"0","certVerified":"NONE","certRevoked":"0","certIssuerDN":"","certSubjectDN":"","certIssuerDNRFC2253":"","certSubjectDNRFC2253":"","certIssuerDNLegacy":"","certSubjectDNLegacy":"","certSerial":"","certIssuerSerial":"","certSKI":"","certIssuerSKI":"","certFingerprintSHA1":"","certFingerprintSHA256":"","certNotBefore":"","certNotAfter":"","certRFC9440":"","certRFC9440TooLarge":false,"certChainRFC9440":"","certChainRFC9440TooLarge":false},"verifiedBotCategory":"","edgeL4":{"deliveryRate":20358},"botManagement":{"corporateProxy":false,"verifiedBot":false,"jsDetection":{"passed":false},"staticResource":false,"detectionIds":{},"score":99}} \ No newline at end of file diff --git a/wrangler/WORKERS.md b/wrangler/WORKERS.md new file mode 100644 index 0000000000..ff1690ba62 --- /dev/null +++ b/wrangler/WORKERS.md @@ -0,0 +1,59 @@ +# Deployment Summary +## Worker URL +`https://infoservice.vam-molch.workers.dev` + +## Main Routes: +- `/`: Main endpoint +- `/health`: Status info +- `/edge`: Info about client +- `/counter`: Counter of requests + +## Configuration used + +# Evidence +![Cloudflare dashboard](./dashboard.png) + +Example `/edge` JSON response: +```json +{ + "colo":"MCI", + "country":"US", + "city":"Kansas City", + "asn":56971, + "httpProtocol":"HTTP/2", + "tlsVersion":"TLSv1.3" +} +``` +![Logs example](./logs.png) + +# Kubernetes vs Cloudflare Workers Comparison + +| Aspect | Kubernetes | Cloudflare Workers | +|--------|------------|--------------------| +| Setup complexity | Moderate | Low | +| Deployment speed | Slower (takes time to up containers) | Fast | +| Global distribution | Manual | Automatic | +| Cost (for small apps) | Cost of infrastructure | Free | +| State/persistence model | Stateful | Almost stateless | +| Control/flexibility | More | Less | +| Best use case | Big industrial applications | Simpler small projects | + +# When to use each +## Scenarions favoring Kubernetes +- Complex or monolithic apps +- Stateful services +- Long-running processes +- Specific dependencies +- Hybrid or Multi-cloud strategy +## Scenarions favoring Workers +- Edge computing and low latency +- Stateless functions +- High variability and spike traffic +- No DevOps resources +- Fast development + +# Reflection + +- Setup is much easier than Kubernetes. +- Less flexibility in configuration is more constrained, than Kubernetes. +- No wide library of pre-build containers. diff --git a/wrangler/app.ts b/wrangler/app.ts new file mode 100644 index 0000000000..5476c571ab --- /dev/null +++ b/wrangler/app.ts @@ -0,0 +1,46 @@ +export interface Env { + APP_NAME: string; + API_TOKEN: string; + ADMIN_EMAIL: string; + SETTINGS: KVNamespace; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + console.log("Received request to " + url.pathname) + + if (url.pathname === "/health") { + return Response.json({ status: "ok" }); + } + + if (url.pathname === "/") { + return Response.json({ + app: env.APP_NAME, + message: "Hello from Cloudflare Workers", + timestamp: new Date().toISOString(), + }); + } + + if (url.pathname === "/edge") { + return Response.json({ + colo: request.cf?.colo, + country: request.cf?.country, + city: request.cf?.city, + asn: request.cf?.asn, + httpProtocol: request.cf?.httpProtocol, + tlsVersion: request.cf?.tlsVersion, + }); + } + + if (url.pathname === "/counter") { + const raw = await env.SETTINGS.get("visits"); + const visits = Number(raw ?? "0") + 1; + await env.SETTINGS.put("visits", String(visits)); + return Response.json({ visits }); + } + + console.log("Requested non-existent path") + return new Response("Not Found", { status: 404 }); + }, +}; diff --git a/wrangler/dashboard.png b/wrangler/dashboard.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/wrangler/dashboard.png differ diff --git a/wrangler/logs.png b/wrangler/logs.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/wrangler/logs.png differ diff --git a/wrangler/node_modules/.cache/wrangler/wrangler-account.json b/wrangler/node_modules/.cache/wrangler/wrangler-account.json new file mode 100644 index 0000000000..2b23a1fed1 --- /dev/null +++ b/wrangler/node_modules/.cache/wrangler/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "0c36bab6514657891f334fb7889a295f", + "name": "Vam.molch@gmail.com's Account" + } +} \ No newline at end of file diff --git a/wrangler/node_modules/.mf/cf.json b/wrangler/node_modules/.mf/cf.json new file mode 100644 index 0000000000..5071372e54 --- /dev/null +++ b/wrangler/node_modules/.mf/cf.json @@ -0,0 +1 @@ +{"httpProtocol":"HTTP/1.1","clientAcceptEncoding":"gzip, deflate, br","requestPriority":"","edgeRequestKeepAliveStatus":1,"requestHeaderNames":{},"clientTcpRtt":180,"clientQuicRtt":0,"colo":"MCI","asn":56971,"asOrganization":"CGI GLOBAL LIMITED","country":"US","isEUCountry":false,"city":"Kansas City","continent":"NA","region":"Missouri","regionCode":"MO","timezone":"America/Chicago","longitude":"-94.57857","latitude":"39.09973","postalCode":"64106","metroCode":"616","tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES256-GCM-SHA384","tlsClientRandom":"60bfKk0N7QB+OnXkqubMBDHEqVyG4H43mlAQzD14k+I=","tlsClientCiphersSha1":"kXrN3VEKDdzz2cPKTQaKzpxVTxQ=","tlsClientExtensionsSha1":"1eY97BUYYO8vDaTfHQywB1pcNdM=","tlsClientExtensionsSha1Le":"u4wtEMFQBY18l3BzHAvORm+KGRw=","tlsExportedAuthenticator":{"clientHandshake":"20858804405de266eaca30c42f36fa7783a0abea9b2ddd2f10527e808377d9a42d9b90f224e5134225fffbb1434f3ab7","serverHandshake":"8e2c94ee07c0c0ac7d4bede875d5160e0d65839e540c4c9688473e3c0496b2022d251baa4381c0e6c6f30a40637fe64d","clientFinished":"da58a987d17c9c867d7737c7eba294e4b91c286ad3ef5947fbc6d2451ff2a7117902e14bc670c486e7f116f381d82ac6","serverFinished":"707dfc29fc68910ac8022f287015027bf6000b51429b405bb53b79243324c2d3c5b45c16ecf623af58ea73db62dd9a61"},"tlsClientHelloLength":"1603","tlsClientAuth":{"certPresented":"0","certVerified":"NONE","certRevoked":"0","certIssuerDN":"","certSubjectDN":"","certIssuerDNRFC2253":"","certSubjectDNRFC2253":"","certIssuerDNLegacy":"","certSubjectDNLegacy":"","certSerial":"","certIssuerSerial":"","certSKI":"","certIssuerSKI":"","certFingerprintSHA1":"","certFingerprintSHA256":"","certNotBefore":"","certNotAfter":"","certRFC9440":"","certRFC9440TooLarge":false,"certChainRFC9440":"","certChainRFC9440TooLarge":false},"verifiedBotCategory":"","edgeL4":{"deliveryRate":21951},"botManagement":{"corporateProxy":false,"verifiedBot":false,"jsDetection":{"passed":false},"staticResource":false,"detectionIds":{},"score":99}} \ No newline at end of file diff --git a/wrangler/rollback.png b/wrangler/rollback.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/wrangler/rollback.png differ diff --git a/wrangler/wrangler.jsonc b/wrangler/wrangler.jsonc new file mode 100644 index 0000000000..254932c639 --- /dev/null +++ b/wrangler/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "name": "infoservice", + "compatibility_date": "2025-11-25", + "vars": { + "APP_NAME": "infoservice", + "COURSE_NAME": "devops-core" + }, + "kv_namespaces": [ + { + "binding": "SETTINGS", + "id": "377f2078ffd249dbb997e66349f23e0f" + } + ] +}