diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..fe8a581226 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,99 @@ +# https://github.com/gitattributes/gitattributes/blob/fddc586cf0f10ec4485028d0d2dd6f73197a4258/Common.gitattributes +# Common settings that generally should always be used with your language specific settings + +# Auto detect text files and perform LF normalization +* text=auto + +# +# The above will handle all files NOT found below +# + +# Documents +*.bibtex text diff=bibtex +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain +*.md text diff=markdown +*.mdx text diff=markdown +*.tex text diff=tex +*.adoc text +*.textile text +*.mustache text +*.csv text eol=crlf +*.tab text +*.tsv text +*.txt text +*.sql text +*.epub diff=astextplain + +# Graphics +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.tif binary +*.tiff binary +*.ico binary +# SVG treated as text by default. +*.svg text +# If you want to treat it as binary, +# use the following line instead. +# *.svg binary +*.eps binary + +# Scripts +*.bash text eol=lf +*.fish text eol=lf +*.ksh text eol=lf +*.sh text eol=lf +*.zsh text eol=lf +# These are explicitly windows files and should use crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Serialisation +*.json text +*.toml text +*.xml text +*.yaml text +*.yml text + +# Archives +*.7z binary +*.bz binary +*.bz2 binary +*.bzip2 binary +*.gz binary +*.lz binary +*.lzma binary +*.rar binary +*.tar binary +*.taz binary +*.tbz binary +*.tbz2 binary +*.tgz binary +*.tlz binary +*.txz binary +*.xz binary +*.Z binary +*.zip binary +*.zst binary + +# Text files where line endings should be preserved +*.patch -text + +# +# Exclude files from exporting +# + +.gitattributes export-ignore +.gitignore export-ignore +.gitkeep export-ignore diff --git a/.github/actions/ansible-deploy/action.yml b/.github/actions/ansible-deploy/action.yml new file mode 100644 index 0000000000..6382dc90a5 --- /dev/null +++ b/.github/actions/ansible-deploy/action.yml @@ -0,0 +1,57 @@ +name: Ansible Deploy +description: Run an Ansible playbook with vault decryption enabled + +inputs: + ansible-directory: + description: Directory containing the Ansible project + required: false + default: "ansible" + playbook-path: + description: Relative path to the playbook to execute + required: false + default: "playbooks/deploy.yml" + inventory-path: + description: Relative path to the inventory file + required: false + default: "inventory/hosts.ini" + vault-password: + description: Vault password used to decrypt encrypted vars + required: true + tags: + description: Comma-separated tag list to execute + required: false + default: "app_deploy" + +outputs: + log-path: + description: Path to the saved ansible-playbook log file + value: ${{ steps.deploy.outputs.log-path }} + +runs: + using: composite + steps: + - id: deploy + name: Run ansible-playbook + shell: bash + working-directory: ${{ inputs.ansible-directory }} + env: + VAULT_PASSWORD: ${{ inputs.vault-password }} + PLAYBOOK_PATH: ${{ inputs.playbook-path }} + INVENTORY_PATH: ${{ inputs.inventory-path }} + PLAYBOOK_TAGS: ${{ inputs.tags }} + run: | + set -euo pipefail + umask 077 + + log_path="${RUNNER_TEMP}/ansible-deploy.log" + + cleanup() { + rm -f .vault_pass + } + trap cleanup EXIT + + printf '%s\n' "$VAULT_PASSWORD" > .vault_pass + + ansible-playbook "$PLAYBOOK_PATH" -i "$INVENTORY_PATH" --tags "$PLAYBOOK_TAGS" | tee "$log_path" + + echo "log-path=$log_path" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/ansible-lint/action.yml b/.github/actions/ansible-lint/action.yml new file mode 100644 index 0000000000..1a52f08ed6 --- /dev/null +++ b/.github/actions/ansible-lint/action.yml @@ -0,0 +1,39 @@ +name: Ansible Lint +description: Run ansible-lint and syntax checks with vault access + +inputs: + ansible-directory: + description: Directory containing the Ansible project + required: false + default: "ansible" + vault-password: + description: Vault password used to decrypt encrypted vars during linting + required: true + playbook-glob: + description: Playbook glob for ansible-lint + required: false + default: "playbooks/*.yml" + +runs: + using: composite + steps: + - name: Run ansible-lint and syntax checks + shell: bash + working-directory: ${{ inputs.ansible-directory }} + env: + VAULT_PASSWORD: ${{ inputs.vault-password }} + PLAYBOOK_GLOB: ${{ inputs.playbook-glob }} + run: | + set -euo pipefail + umask 077 + cleanup() { + rm -f .vault_pass + } + trap cleanup EXIT + + printf '%s\n' "$VAULT_PASSWORD" > .vault_pass + + ansible-lint $PLAYBOOK_GLOB + ansible-playbook playbooks/provision.yml --syntax-check + ansible-playbook playbooks/deploy.yml --syntax-check + ansible-playbook playbooks/site.yml --syntax-check diff --git a/.github/actions/ansible-setup/action.yml b/.github/actions/ansible-setup/action.yml new file mode 100644 index 0000000000..8ebb487f0d --- /dev/null +++ b/.github/actions/ansible-setup/action.yml @@ -0,0 +1,59 @@ +name: Ansible Setup +description: Set up a Python-based Ansible toolchain and required collections + +inputs: + python-version: + description: Python version to install + required: false + default: "3.12" + working-directory: + description: Directory containing the Ansible project + required: false + default: "ansible" + python-requirements-path: + description: Path to the pip requirements file + required: false + default: "ansible/requirements-ci.txt" + collection-requirements-path: + description: Path to the ansible-galaxy requirements file + required: false + default: "ansible/requirements.yml" + +runs: + using: composite + steps: + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + + - name: Cache Ansible toolchain + uses: actions/cache@v5 + with: + path: | + ~/.cache/pip + ~/.ansible/collections + key: ${{ runner.os }}-py${{ inputs.python-version }}-ansible-${{ hashFiles(inputs.python-requirements-path, inputs.collection-requirements-path) }} + restore-keys: | + ${{ runner.os }}-py${{ inputs.python-version }}-ansible- + + - name: Install Python dependencies + shell: bash + run: | + set -euo pipefail + rm -rf "${{ inputs.working-directory }}/.venv-ci" + python -m venv "${{ inputs.working-directory }}/.venv-ci" + . "${{ inputs.working-directory }}/.venv-ci/bin/activate" + python -m pip install --upgrade pip + python -m pip install -r "${{ inputs.python-requirements-path }}" + + - name: Install Ansible collections + shell: bash + run: | + set -euo pipefail + . "${{ inputs.working-directory }}/.venv-ci/bin/activate" + ansible-galaxy collection install -r "${{ inputs.collection-requirements-path }}" + + - name: Add Ansible venv to PATH + shell: bash + run: echo "${{ github.workspace }}/${{ inputs.working-directory }}/.venv-ci/bin" >> "$GITHUB_PATH" diff --git a/.github/actions/ansible-ssh-setup/action.yml b/.github/actions/ansible-ssh-setup/action.yml new file mode 100644 index 0000000000..d682973fc9 --- /dev/null +++ b/.github/actions/ansible-ssh-setup/action.yml @@ -0,0 +1,41 @@ +name: Ansible SSH Setup +description: Install the SSH key material required for Ansible access + +inputs: + ssh-private-key: + description: Private SSH key used to connect to the target VM + required: true + ssh-key-path: + description: Destination path for the private key + required: false + default: "~/.ssh/vagrant" + known-host: + description: Optional host to add to known_hosts + required: false + default: "" + +runs: + using: composite + steps: + - name: Configure SSH credentials + shell: bash + env: + SSH_PRIVATE_KEY: ${{ inputs.ssh-private-key }} + SSH_KEY_PATH: ${{ inputs.ssh-key-path }} + KNOWN_HOST: ${{ inputs.known-host }} + run: | + set -euo pipefail + + key_path="${SSH_KEY_PATH/#\~/$HOME}" + + install -d -m 700 "$HOME/.ssh" + install -d -m 700 "$(dirname "$key_path")" + printf '%s\n' "$SSH_PRIVATE_KEY" > "$key_path" + chmod 600 "$key_path" + + touch "$HOME/.ssh/known_hosts" + chmod 600 "$HOME/.ssh/known_hosts" + + if [ -n "$KNOWN_HOST" ]; then + ssh-keyscan -H "$KNOWN_HOST" >> "$HOME/.ssh/known_hosts" 2>/dev/null || true + fi diff --git a/.github/actions/http-healthcheck/action.yml b/.github/actions/http-healthcheck/action.yml new file mode 100644 index 0000000000..a1886f690d --- /dev/null +++ b/.github/actions/http-healthcheck/action.yml @@ -0,0 +1,50 @@ +name: HTTP Healthcheck +description: Poll an HTTP endpoint until it returns healthy JSON + +inputs: + url: + description: URL to poll + required: true + retries: + description: Number of polling attempts before failure + required: false + default: "10" + delay-seconds: + description: Delay between retries in seconds + required: false + default: "3" + jq-filter: + description: jq expression that must evaluate to true + required: false + default: '.status == "healthy"' + +runs: + using: composite + steps: + - name: Poll health endpoint + shell: bash + env: + URL: ${{ inputs.url }} + RETRIES: ${{ inputs.retries }} + DELAY_SECONDS: ${{ inputs.delay-seconds }} + JQ_FILTER: ${{ inputs.jq-filter }} + run: | + set -euo pipefail + + response="" + + for attempt in $(seq 1 "$RETRIES"); do + if response="$(curl -fsSL "$URL")"; then + break + fi + + if [ "$attempt" -eq "$RETRIES" ]; then + echo "Health check failed after $RETRIES attempts: $URL" >&2 + exit 1 + fi + + sleep "$DELAY_SECONDS" + done + + echo "$response" | jq . + echo "$response" | jq -e "$JQ_FILTER" >/dev/null diff --git a/.github/actions/python-setup/action.yml b/.github/actions/python-setup/action.yml new file mode 100644 index 0000000000..626200b3f9 --- /dev/null +++ b/.github/actions/python-setup/action.yml @@ -0,0 +1,57 @@ +name: Python Poetry Setup +description: Set up Python + Poetry, cache dependencies, and install project deps + +inputs: + python-version: + description: Python version to install + required: false + default: "3.14" + poetry-version: + description: Poetry version to install + required: false + default: "2.3.2" + working-directory: + description: Project directory containing pyproject.toml + required: false + default: "app_python" + lockfile-path: + description: Path to poetry.lock for cache key invalidation + required: false + default: "app_python/poetry.lock" + install-args: + description: Extra arguments passed to poetry install + required: false + default: "--with dev --no-interaction --no-ansi" + +runs: + using: composite + steps: + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: ${{ inputs.poetry-version }} + + - name: Configure Poetry virtualenv location + shell: bash + working-directory: ${{ inputs.working-directory }} + run: poetry config virtualenvs.in-project true + + - name: Cache Poetry dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cache/pypoetry + ${{ inputs.working-directory }}/.venv + key: ${{ runner.os }}-py${{ inputs.python-version }}-poetry${{ inputs.poetry-version }}-${{ hashFiles(inputs.lockfile-path) }} + restore-keys: | + ${{ runner.os }}-py${{ inputs.python-version }}-poetry${{ inputs.poetry-version }}- + + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + run: poetry install ${{ inputs.install-args }} diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..c7e29f0a17 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,319 @@ +name: Ansible Deployment + +on: + push: + branches: + - master + - "lab*" + paths: + - ansible/** + - .github/actions/ansible-setup/** + - .github/actions/ansible-lint/** + - .github/actions/ansible-ssh-setup/** + - .github/actions/ansible-deploy/** + - .github/actions/http-healthcheck/** + - .github/workflows/ansible-deploy.yml + - "!ansible/docs/**" + pull_request: + branches: + - master + paths: + - ansible/** + - .github/actions/ansible-setup/** + - .github/actions/ansible-lint/** + - .github/actions/ansible-ssh-setup/** + - .github/actions/ansible-deploy/** + - .github/actions/http-healthcheck/** + - .github/workflows/ansible-deploy.yml + - "!ansible/docs/**" + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: ansible-deploy-${{ github.ref }} + cancel-in-progress: true + +env: + ANSIBLE_DIRECTORY: ansible + DEPLOY_PLAYBOOK: playbooks/deploy.yml + DEPLOY_TAGS: app_deploy + +jobs: + lint: + name: Ansible Lint + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Ansible toolchain + uses: ./.github/actions/ansible-setup + + - name: Run lint and syntax checks + uses: ./.github/actions/ansible-lint + with: + ansible-directory: ${{ env.ANSIBLE_DIRECTORY }} + vault-password: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + + wait-for-prerequisites: + name: Wait for Prerequisite Workflows + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Wait for required workflow runs on current commit + env: + GITHUB_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + BEFORE_SHA: ${{ github.event.before || '' }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + run: | + set -euo pipefail + + api() { + curl -fsSL \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "$1" + } + + declare -A required_workflows=() + + add_workflow() { + required_workflows["$1"]=1 + } + + changed_files=() + if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_NUMBER" ]]; then + while IFS= read -r path; do + [[ -n "$path" ]] && changed_files+=("$path") + done < <( + api "https://api.github.com/repos/$REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \ + | jq -r '.[].filename' + ) + elif [[ -n "$BEFORE_SHA" && "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]]; then + while IFS= read -r path; do + [[ -n "$path" ]] && changed_files+=("$path") + done < <( + api "https://api.github.com/repos/$REPOSITORY/compare/$BEFORE_SHA...$COMMIT_SHA" \ + | jq -r '.files[]?.filename' + ) + fi + + for path in "${changed_files[@]}"; do + case "$path" in + app_go/*|.github/workflows/go-docker.yml) + if [[ "$REF_NAME" == lab* ]]; then + add_workflow "Go Docker Publish" + fi + ;; + esac + + case "$path" in + app_python/*|.github/actions/python-setup/*|.github/workflows/python-ci.yml) + add_workflow "Python CI" + ;; + esac + + case "$path" in + app_python/*|.github/workflows/python-docker.yml) + if [[ "$REF_NAME" == lab* ]]; then + add_workflow "Python Docker Publish" + fi + ;; + esac + done + + deadline=$(( $(date +%s) + 900 )) + grace_deadline=$(( $(date +%s) + 60 )) + tracked_workflows=( + "Go Docker Publish" + "Python CI" + "Python Docker Publish" + ) + + while (( $(date +%s) < deadline )); do + runs_json="$( + api "https://api.github.com/repos/$REPOSITORY/actions/runs?head_sha=$COMMIT_SHA&per_page=100" + )" + + while IFS= read -r workflow_name; do + [[ -n "$workflow_name" ]] && add_workflow "$workflow_name" + done < <( + jq -r ' + .workflow_runs[] + | select( + .name == "Go Docker Publish" + or .name == "Python CI" + or .name == "Python Docker Publish" + ) + | .name + ' <<<"$runs_json" + ) + + if (( ${#required_workflows[@]} == 0 )); then + echo "No prerequisite workflows apply to this commit." + exit 0 + fi + + pending=0 + failures=() + + for workflow_name in "${tracked_workflows[@]}"; do + if [[ -z "${required_workflows[$workflow_name]+x}" ]]; then + continue + fi + + run_json="$( + jq -c \ + --arg name "$workflow_name" \ + '.workflow_runs + | map(select(.name == $name)) + | sort_by(.run_started_at // .created_at) + | last // empty' <<<"$runs_json" + )" + + if [[ -z "$run_json" ]]; then + if (( $(date +%s) < grace_deadline )); then + echo "Waiting for workflow record: $workflow_name" + pending=1 + continue + fi + + echo "No run found for $workflow_name on $COMMIT_SHA after grace period; treating it as not triggered." + continue + fi + + status="$(jq -r '.status' <<<"$run_json")" + conclusion="$(jq -r '.conclusion // ""' <<<"$run_json")" + event="$(jq -r '.event' <<<"$run_json")" + html_url="$(jq -r '.html_url' <<<"$run_json")" + + echo "$workflow_name: status=$status conclusion=${conclusion:-n/a} event=$event" + + if [[ "$status" != "completed" ]]; then + pending=1 + continue + fi + + case "$conclusion" in + success|skipped) + ;; + *) + failures+=("$workflow_name ($conclusion) $html_url") + ;; + esac + done + + if (( ${#failures[@]} > 0 )); then + printf 'Prerequisite workflow failed: %s\n' "${failures[@]}" >&2 + exit 1 + fi + + if (( pending == 0 )); then + echo "All prerequisite workflows finished successfully." + exit 0 + fi + + sleep 15 + done + + echo "Timed out while waiting for prerequisite workflows on $COMMIT_SHA." >&2 + exit 1 + + deploy: + name: Deploy Application + needs: + - lint + - wait-for-prerequisites + if: github.event_name != 'pull_request' + runs-on: + - self-hosted + - linux + - vagrant + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Ansible toolchain + uses: ./.github/actions/ansible-setup + + - name: Resolve target host from inventory + working-directory: ${{ env.ANSIBLE_DIRECTORY }} + run: | + set -euo pipefail + target_host="$( + awk ' + /^[[:space:]]*#/ { next } + /^\[/ { next } + NF { + for (i = 1; i <= NF; i++) { + if ($i ~ /^ansible_host=/) { + split($i, value, "=") + print value[2] + exit + } + } + } + ' inventory/hosts.ini + )" + + if [ -z "$target_host" ]; then + echo "Could not determine ansible_host from inventory/hosts.ini" >&2 + exit 1 + fi + + echo "TARGET_VM_HOST=$target_host" >> "$GITHUB_ENV" + + - name: Configure SSH access to the target VM + uses: ./.github/actions/ansible-ssh-setup + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + known-host: ${{ env.TARGET_VM_HOST }} + + - name: Prepare vault password file + working-directory: ${{ env.ANSIBLE_DIRECTORY }} + env: + VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + set -euo pipefail + umask 077 + printf '%s\n' "$VAULT_PASSWORD" > .vault_pass + + - name: Verify target connectivity + working-directory: ${{ env.ANSIBLE_DIRECTORY }} + run: ansible webservers -m ansible.builtin.ping + + - id: deploy + name: Deploy web application + uses: ./.github/actions/ansible-deploy + with: + ansible-directory: ${{ env.ANSIBLE_DIRECTORY }} + playbook-path: ${{ env.DEPLOY_PLAYBOOK }} + vault-password: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + tags: ${{ env.DEPLOY_TAGS }} + + - name: Upload deployment log + if: always() && steps.deploy.outputs.log-path != '' + uses: actions/upload-artifact@v7 + with: + name: ansible-deploy-log + path: ${{ steps.deploy.outputs.log-path }} + + - name: Verify application health + uses: ./.github/actions/http-healthcheck + with: + url: http://${{ env.TARGET_VM_HOST }}:5000/health + + - name: Remove vault password file + if: always() + working-directory: ${{ env.ANSIBLE_DIRECTORY }} + run: rm -f .vault_pass diff --git a/.github/workflows/go-docker.yml b/.github/workflows/go-docker.yml new file mode 100644 index 0000000000..2fc2d8121e --- /dev/null +++ b/.github/workflows/go-docker.yml @@ -0,0 +1,85 @@ +name: Go Docker Publish + +on: + push: + branches: + - "lab*" + paths: + - app_go/** + - .github/workflows/go-docker.yml + pull_request: + branches: + - master + types: + - closed + paths: + - app_go/** + - .github/workflows/go-docker.yml + +jobs: + build-and-push-branch: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Derive lab+sha tag from branch + id: version + run: | + source_branch="${{ github.ref_name }}" + if [[ "$source_branch" =~ ([0-9]+) ]]; then + lab_number="${BASH_REMATCH[1]}" + lab_number=$((10#$lab_number)) + short_sha="${GITHUB_SHA::7}" + echo "branch_tag=1.${lab_number}.${short_sha}" >> "$GITHUB_OUTPUT" + echo "branch_dev_tag=1.${lab_number}-dev" >> "$GITHUB_OUTPUT" + else + echo "Failed to extract lab number from branch: $source_branch" >&2 + exit 1 + fi + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push Docker image (branch) + uses: docker/build-push-action@v7 + with: + context: ./app_go + file: ./app_go/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:${{ steps.version.outputs.branch_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:${{ steps.version.outputs.branch_dev_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:dev + + build-and-push: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Derive lab version tag from merged branch + id: version + run: | + source_branch="${{ github.event.pull_request.head.ref }}" + if [[ "$source_branch" =~ ([0-9]+) ]]; then + lab_number="${BASH_REMATCH[1]}" + lab_number=$((10#$lab_number)) + echo "version_tag=1.${lab_number}" >> "$GITHUB_OUTPUT" + else + echo "Failed to extract lab number from merged branch: $source_branch" >&2 + exit 1 + fi + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: ./app_go + file: ./app_go/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:${{ steps.version.outputs.version_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:latest diff --git a/.github/workflows/go-snyk.yml b/.github/workflows/go-snyk.yml new file mode 100644 index 0000000000..d9d1819e0c --- /dev/null +++ b/.github/workflows/go-snyk.yml @@ -0,0 +1,40 @@ +name: Go Snyk Scan + +on: + push: + paths: + - app_go/** + - .github/workflows/go-snyk.yml + pull_request: + branches: + - master + paths: + - app_go/** + - .github/workflows/go-snyk.yml + +jobs: + snyk: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_go + steps: + - uses: actions/checkout@v6 + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: ./app_go/go.mod + cache-dependency-path: ./app_go/go.sum + - name: Download Go modules + run: go mod download + - name: Setup Snyk CLI + uses: snyk/actions/setup@master + - name: Run Snyk dependency scan (or skip) + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + if [ -z "${SNYK_TOKEN:-}" ]; then + echo "SNYK_TOKEN secret not set; skipping Snyk dependency scan." + exit 0 + fi + snyk test --severity-threshold=medium --fail-on=upgradable diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..62c6a465fd --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,56 @@ +name: Python CI + +on: + push: + paths: + - app_python/** + - .github/actions/python-setup/** + - .github/workflows/python-ci.yml + pull_request: + branches: + - master + paths: + - app_python/** + - .github/actions/python-setup/** + - .github/workflows/python-ci.yml + +jobs: + test: + strategy: + fail-fast: false + matrix: + python-version: [3.14] + poetry-version: [2.3.2] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ./app_python + steps: + - uses: actions/checkout@v6 + - name: Setup Python tooling and dependencies + uses: ./.github/actions/python-setup + with: + python-version: ${{ matrix.python-version }} + poetry-version: ${{ matrix.poetry-version }} + working-directory: app_python + lockfile-path: app_python/poetry.lock + install-args: --with dev --no-interaction --no-ansi + - name: Lint with flake8 + run: poetry run flake8 src tests + - name: Test using pytest with coverage report + run: | + mkdir -p test-results + poetry run pytest \ + --junitxml=test-results/pytest-report.xml \ + --cov=src \ + --cov-report=term-missing \ + --cov-report=xml:test-results/coverage.xml + - name: Upload pytest and coverage reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: python-test-reports + path: | + app_python/test-results/pytest-report.xml + app_python/test-results/coverage.xml diff --git a/.github/workflows/python-docker.yml b/.github/workflows/python-docker.yml new file mode 100644 index 0000000000..047be1e6c7 --- /dev/null +++ b/.github/workflows/python-docker.yml @@ -0,0 +1,85 @@ +name: Python Docker Publish + +on: + push: + branches: + - "lab*" + paths: + - app_python/** + - .github/workflows/python-docker.yml + pull_request: + branches: + - master + types: + - closed + paths: + - app_python/** + - .github/workflows/python-docker.yml + +jobs: + build-and-push-branch: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Derive lab+sha tag from branch + id: version + run: | + source_branch="${{ github.ref_name }}" + if [[ "$source_branch" =~ ([0-9]+) ]]; then + lab_number="${BASH_REMATCH[1]}" + lab_number=$((10#$lab_number)) + short_sha="${GITHUB_SHA::7}" + echo "branch_tag=1.${lab_number}.${short_sha}" >> "$GITHUB_OUTPUT" + echo "branch_dev_tag=1.${lab_number}-dev" >> "$GITHUB_OUTPUT" + else + echo "Failed to extract lab number from branch: $source_branch" >&2 + exit 1 + fi + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push Docker image (branch) + uses: docker/build-push-action@v7 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:${{ steps.version.outputs.branch_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:${{ steps.version.outputs.branch_dev_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:dev + + build-and-push: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Derive lab version tag from merged branch + id: version + run: | + source_branch="${{ github.event.pull_request.head.ref }}" + if [[ "$source_branch" =~ ([0-9]+) ]]; then + lab_number="${BASH_REMATCH[1]}" + lab_number=$((10#$lab_number)) + echo "version_tag=1.${lab_number}" >> "$GITHUB_OUTPUT" + else + echo "Failed to extract lab number from merged branch: $source_branch" >&2 + exit 1 + fi + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:${{ steps.version.outputs.version_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:latest diff --git a/.github/workflows/python-snyk.yml b/.github/workflows/python-snyk.yml new file mode 100644 index 0000000000..e389c1065c --- /dev/null +++ b/.github/workflows/python-snyk.yml @@ -0,0 +1,37 @@ +name: Python Snyk Scan + +on: + push: + paths: + - app_python/** + - .github/actions/python-setup/** + - .github/workflows/python-snyk.yml + pull_request: + branches: + - master + paths: + - app_python/** + - .github/actions/python-setup/** + - .github/workflows/python-snyk.yml + +jobs: + snyk: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_python + steps: + - uses: actions/checkout@v6 + - name: Setup Python tooling and dependencies + uses: ./.github/actions/python-setup + - name: Setup Snyk CLI + uses: snyk/actions/setup@master + - name: Run Snyk dependency scan (or skip) + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + if [ -z "${SNYK_TOKEN:-}" ]; then + echo "SNYK_TOKEN secret not set; skipping Snyk dependency scan." + exit 0 + fi + snyk test --severity-threshold=medium --fail-on=upgradable diff --git a/.gitignore b/.gitignore index 30d74d2584..6d94dd134e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,34 @@ -test \ No newline at end of file +test + +# Terraform +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfplan +tfplan +*.tfvars +*.tfvars.json +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraform.lock.hcl + +# Pulumi +.pulumi/ +pulumi/.venv/ +pulumi/venv/ +pulumi/Pulumi.*.yaml + +# Python caches +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ + +# IDE +.vscode/ + +# Ansible runtime/cache +.ansible/ diff --git a/README.md b/README.md index 9955b0c611..4b917a299c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Labs](https://img.shields.io/badge/Labs-18-blue)](#labs) [![Exam](https://img.shields.io/badge/Exam-Optional-green)](#exam-alternative) [![Duration](https://img.shields.io/badge/Duration-18%20Weeks-lightgrey)](#course-roadmap) +[![Ansible Deployment](https://github.com/LocalT0aster/DevOps-Core-S26/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/LocalT0aster/DevOps-Core-S26/actions/workflows/ansible-deploy.yml) Master **production-grade DevOps practices** through hands-on labs. Build, containerize, deploy, monitor, and scale applications using industry-standard tools. diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint new file mode 100644 index 0000000000..3a55c5e845 --- /dev/null +++ b/ansible/.ansible-lint @@ -0,0 +1,14 @@ +--- +profile: production +strict: true +use_default_rules: true +exclude_paths: + - .venv/ + - .ansible/ + - group_vars/all.yml + - docs/LAB05.md +enable_list: + - args + - empty-string-compare + - no-log-password + - no-prompting diff --git a/ansible/.gitignore b/ansible/.gitignore new file mode 100644 index 0000000000..5985dd6f4a --- /dev/null +++ b/ansible/.gitignore @@ -0,0 +1,4 @@ +*.retry +.ansible/ +.venv/ +.vault_pass diff --git a/ansible/.yamllint.yml b/ansible/.yamllint.yml new file mode 100644 index 0000000000..37734595da --- /dev/null +++ b/ansible/.yamllint.yml @@ -0,0 +1,40 @@ +--- +extends: default + +ignore: | + .venv/ + .ansible/ + group_vars/all.yml + docs/LAB05.md + +rules: + braces: + max-spaces-inside: 1 + comments: + min-spaces-from-content: 1 + comments-indentation: false + document-start: + present: true + empty-lines: + max: 1 + max-start: 0 + max-end: 0 + hyphens: + max-spaces-after: 1 + indentation: + spaces: 2 + indent-sequences: true + check-multi-line-strings: true + line-length: + max: 160 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: false + new-lines: + type: unix + octal-values: + forbid-implicit-octal: true + forbid-explicit-octal: true + trailing-spaces: enable + truthy: + allowed-values: ["true", "false"] + check-keys: true diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..a9c4a0b6a7 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,13 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False +inject_facts_as_vars = False +vault_password_file = .vault_pass + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..fedb8571b1 --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,319 @@ +# LAB05 - Ansible Fundamentals + +## 1. Architecture Overview + +- Ansible version: `ansible [core 2.20.0]` +- Target VM OS/version: Ubuntu `24.04` +- Project layout: + +```text +ansible/ +├── inventory/hosts.ini +├── roles/ +│ ├── common/ +│ │ ├── tasks/common_tasks.yml +│ │ └── defaults/common_defaults.yml +│ ├── docker/ +│ │ ├── tasks/docker_tasks.yml +│ │ ├── defaults/docker_defaults.yml +│ │ └── handlers/docker_handlers.yml +│ └── app_deploy/ +│ ├── tasks/app_deploy_tasks.yml +│ ├── defaults/app_deploy_defaults.yml +│ └── handlers/app_deploy_handlers.yml +├── playbooks/ +│ ├── provision.yml +│ ├── deploy.yml +│ └── site.yml +├── group_vars/all.yml # encrypted with Ansible Vault +├── ansible.cfg +└── docs/LAB05.md +``` + +- Why roles instead of monolithic playbooks: + - Roles isolate concern-specific logic (base OS, Docker, app deploy). + - Reuse is easier across hosts/projects with role defaults. + - Testing and maintenance are simpler because responsibilities are separated. + - `include_role` with `tasks_from/defaults_from/handlers_from` keeps file names descriptive. + +## 2. Roles Documentation + +### `common` + +- Purpose: Baseline host configuration (APT cache, common tools, timezone). +- Key variables: + - `common_packages` + - `common_manage_timezone` + - `common_timezone` +- Handlers: none. +- Dependencies: none. + +### `docker` + +- Purpose: Install Docker CE from official Docker repository and prepare host for Docker Ansible modules. +- Key variables: + - `docker_packages` + - `docker_user` + - `docker_install_python_sdk` +- Handlers: + - `restart docker` +- Dependencies: + - Requires Ubuntu host and internet access. + +### `app_deploy` + +- Purpose: Authenticate to Docker Hub, pull image, replace container when needed, and verify app health. +- Key variables: + - `dockerhub_username` + - `dockerhub_password` (vaulted) + - `docker_image`, `docker_image_tag` + - `app_container_name`, `app_port` +- Handlers: + - `restart app container` +- Dependencies: + - Docker engine installed/running on target host. + +## 3. Idempotency Demonstration + +
+First run (`provision.yml`) +
+ +``` +$ ansible-playbook playbooks/provision.yml + +PLAY [Provision web servers] **************************************************************************** + +TASK [Gathering Facts] ********************************************************************************** +ok: [vagrant] + +TASK [Run common role tasks/defaults] ******************************************************************* +included: common for vagrant + +TASK [common : Update apt cache] ************************************************************************ +changed: [vagrant] + +TASK [common : Install common packages] ***************************************************************** +ok: [vagrant] + +TASK [common : Set /etc/timezone] *********************************************************************** +ok: [vagrant] + +TASK [common : Point /etc/localtime to selected timezone] *********************************************** +ok: [vagrant] + +TASK [Run docker role tasks/defaults/handlers] ********************************************************** +included: docker for vagrant + +TASK [docker : Install Docker prerequisites] ************************************************************ +changed: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************************************** +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************************************** +changed: [vagrant] + +TASK [docker : Add Docker apt repository] *************************************************************** +changed: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************************************** +changed: [vagrant] + +TASK [docker : Install Docker Python SDK package] ******************************************************* +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************************************** +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] ***************************************************** +changed: [vagrant] + +RUNNING HANDLER [docker : restart docker] *************************************************************** +changed: [vagrant] + +PLAY RECAP ********************************************************************************************** +vagrant : ok=16 changed=7 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +
+Second run (`provision.yml`) + +``` +$ ansible-playbook playbooks/provision.yml + +PLAY [Provision web servers] *********************************************************************************************************** + +TASK [Gathering Facts] ***************************************************************************************************************** +ok: [vagrant] + +TASK [Run common role tasks/defaults] ************************************************************************************************** +included: common for vagrant + +TASK [common : Update apt cache] ******************************************************************************************************* +ok: [vagrant] + +TASK [common : Install common packages] ************************************************************************************************ +ok: [vagrant] + +TASK [common : Set /etc/timezone] ****************************************************************************************************** +ok: [vagrant] + +TASK [common : Point /etc/localtime to selected timezone] ****************************************************************************** +ok: [vagrant] + +TASK [Run docker role tasks/defaults/handlers] ***************************************************************************************** +included: docker for vagrant + +TASK [docker : Install Docker prerequisites] ******************************************************************************************* +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ********************************************************************************* +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ***************************************************************************************************** +ok: [vagrant] + +TASK [docker : Add Docker apt repository] ********************************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ***************************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ************************************************************************************** +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] *************************************************************************** +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] ************************************************************************************ +ok: [vagrant] + +PLAY RECAP ***************************************************************************************************************************** +vagrant : ok=15 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +
+ +### Analysis + +- First run: package/repository/service tasks report `changed`. +- Second run: all `ok` with `changed=0`. +- Idempotency is achieved by declarative module states (`state: present`, `state: started`, etc.). + +## 4. Ansible Vault Usage + +- Secrets are stored in `group_vars/all.yml`. +- File is encrypted with: + +```bash +ansible-vault encrypt group_vars/all.yml +``` + +- Why Vault matters: + - Protects credentials in VCS. + - Prevents accidental secret leakage in plaintext config files. + +## 5. Deployment Verification + +
+Deploy output + +``` +$ ansible-playbook playbooks/deploy.yml + +PLAY [Deploy application] ************************************************************************* + +TASK [Gathering Facts] **************************************************************************** +ok: [vagrant] + +TASK [Run app deploy role tasks/defaults/handlers] ************************************************ +included: app_deploy for vagrant + +TASK [app_deploy : Resolve Docker Hub auth secret] ************************************************ +ok: [vagrant] + +TASK [app_deploy : Validate required Docker Hub credentials] ************************************** +ok: [vagrant] => { + "changed": false, + "msg": "All assertions passed" +} + +TASK [app_deploy : Log in to Docker Hub] ********************************************************** +ok: [vagrant] + +TASK [app_deploy : Pull application image] ******************************************************** +ok: [vagrant] + +TASK [app_deploy : Check whether application container already exists] **************************** +ok: [vagrant] + +TASK [app_deploy : Stop existing container before replacement] ************************************ +skipping: [vagrant] + +TASK [app_deploy : Remove old container before replacement] *************************************** +skipping: [vagrant] + +TASK [app_deploy : Run application container] ***************************************************** +ok: [vagrant] + +TASK [app_deploy : Wait for application port] ***************************************************** +ok: [vagrant -> localhost] + +TASK [app_deploy : Verify application health endpoint] ******************************************** +ok: [vagrant -> localhost] + +PLAY RECAP **************************************************************************************** +vagrant : ok=10 changed=0 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0 +``` + +
+ + +
+Container status + + +``` +$ ansible webservers -a "docker ps" +vagrant | CHANGED | rc=0 >> +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +6051f50e3f87 localt0aster/devops-app-py:latest "gunicorn --bind 0.0…" 8 minutes ago Up 8 minutes 0.0.0.0:5000->5000/tcp devops-app +``` + +
+ +### Health checks + +```bash +$ curl -fSsL 192.168.121.50:5000/health | jq +{ + "status": "healthy", + "timestamp": "2026-03-05T20:55:15.731873+00:00", + "uptime_seconds": 816 +} +``` + +## 6. Key Decisions + +- Why use roles instead of plain playbooks? + - Roles reduce duplication and keep each domain-focused (OS, Docker, app). + +- How do roles improve reusability? + - Defaults/handlers/tasks are reusable by attaching the same role to new host groups. + +- What makes a task idempotent? + - The task declares target state and changes only when current state differs. + +- How do handlers improve efficiency? + - Handlers execute only when notified by changed tasks, so restarts are conditional. + +- Why is Ansible Vault necessary? + - It allows storing sensitive values in the repository without exposing plaintext secrets. + +## 7. Challenges + +- Initial deploy attempt failed with 404 on `/health`. +- Root cause: container command ran `src.flask_instance:app` (routes not imported there). +- Fix: override container command to `src.main:app`, and run delegated health checks with `become: false` while using inventory host (`ansible_host`). +- After fix, deploy rerun completed with `failed=0` and healthy status. diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..48ef9ca971 --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,2459 @@ +# LAB06 - Advanced Ansible & CI/CD + +## Task 1: Blocks & Tags (2 pts) + +### Overview + +For Task 1 I refactored the existing Ansible roles to use blocks, rescue handling, and tags without changing the repository's custom file naming convention. The lab sheet mentions `main.yml`, but this repository already uses descriptive files such as `common_tasks.yml` and `docker_tasks.yml`, so I kept that structure. + +### Implementation Details + +#### `common` role + +Changes made: + +- Grouped APT cache refresh and package installation into a `block` tagged `packages`. +- Added a `rescue` path that runs `apt-get update --fix-missing`, then retries the cache refresh and package installation. +- Added an `always` section that records block completion in `/tmp/ansible-common-role.log`. +- Added a separate `users` block that ensures managed users exist and also records completion. +- Kept timezone management outside the package block so `--tags packages` only runs package-related work. + +Practical result: + +- `--tags packages` runs only the package block. +- `--skip-tags common` skips the whole role because the playbook applies the `common` tag at role level. + +#### `docker` role + +Changes made: + +- Grouped Docker installation tasks into a `block` tagged `docker_install`. +- Added a `rescue` path that waits 10 seconds, refreshes APT metadata, and retries the Docker repository/key/package setup. +- Added an `always` section that ensures the Docker service is enabled and running after a successful install path. +- Grouped Docker configuration into a separate `block` tagged `docker_config`. +- Added completion logging to `/tmp/ansible-docker-role.log`. + +Practical result: + +- `--tags docker` runs the whole Docker role. +- `--tags docker_install` runs only installation-related work. +- Rescue behavior is visible in the collected logs. + +#### Playbook tag strategy + +Role-level tags are applied in the playbooks so the role can be selected as a whole, while block tags allow narrower execution. + +| Tag | Purpose | +| ---------------- | ---------------------------------------- | +| `common` | Entire common role | +| `packages` | Package install/update block in `common` | +| `users` | User-management block in `common` | +| `docker` | Entire docker role | +| `docker_install` | Docker installation and repository setup | +| `docker_config` | Docker host configuration | + +### Evidence + +The main evidence file is `task1.log`. + +#### 1. Selective execution with `--tags "docker"` + +This run exercised only the Docker role and also proved that the `rescue` section works: + +
+ansible-playbook playbooks/provision.yml --tags "docker" + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker" + +PLAY [Provision web servers] **************************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************************** +ok: [vagrant] + +TASK [Run docker role tasks/defaults/handlers] ********************************************************************************** +included: docker for vagrant + +TASK [docker : Install Docker prerequisites] ************************************************************************************ +[WARNING]: Failed to update cache after 1 retries due to , retrying +[WARNING]: Sleeping for 2 seconds, before attempting to refresh the cache again +[WARNING]: Failed to update cache after 2 retries due to , retrying +[WARNING]: Sleeping for 3 seconds, before attempting to refresh the cache again +[WARNING]: Failed to update cache after 3 retries due to , retrying +[WARNING]: Sleeping for 5 seconds, before attempting to refresh the cache again +[WARNING]: Failed to update cache after 4 retries due to , retrying +[WARNING]: Sleeping for 9 seconds, before attempting to refresh the cache again +[WARNING]: Failed to update cache after 5 retries due to , retrying +[WARNING]: Sleeping for 13 seconds, before attempting to refresh the cache again +[ERROR]: Task failed: Module failed: Failed to update apt cache after 5 retries: +Origin: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/docker/tasks/docker_tasks.yml:7:7 + +5 - docker_install +6 block: +7 - name: Install Docker prerequisites + ^ column 7 + +fatal: [vagrant]: FAILED! => {"changed": false, "msg": "Failed to update apt cache after 5 retries: "} + +TASK [docker : Mark Docker install rescue as triggered] ************************************************************************* +ok: [vagrant] + +TASK [docker : Wait before retrying Docker apt setup] *************************************************************************** +Pausing for 10 seconds +(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort) +ok: [vagrant] + +TASK [docker : Refresh apt cache before Docker retry] *************************************************************************** +changed: [vagrant] + +TASK [docker : Retry adding Docker GPG key] ************************************************************************************* +ok: [vagrant] + +TASK [docker : Retry adding Docker apt repository] ****************************************************************************** +ok: [vagrant] + +TASK [docker : Retry installing Docker engine packages] ************************************************************************* +ok: [vagrant] + +TASK [docker : Retry installing Docker Python SDK package] ********************************************************************** +ok: [vagrant] + +TASK [docker : Mark Docker service as ready after retry] ************************************************************************ +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************************************************************** +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ********************************************************************* +changed: [vagrant] + +TASK [docker : Add deployment user to docker group] ***************************************************************************** +ok: [vagrant] + +TASK [docker : Record Docker configuration block completion] ******************************************************************** +ok: [vagrant] + +PLAY RECAP ********************************************************************************************************************** +vagrant : ok=14 changed=2 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0 + +``` + +
+ +This is the strongest proof for Task 1 because it shows: + +- only the Docker role was selected, +- the block failed, +- the `rescue` path recovered successfully, +- the play still finished with `failed=0` and `rescued=1`. + +#### 2. Skipping the `common` role + +
+ansible-playbook playbooks/provision.yml --skip-tags "common" + +``` +$ ansible-playbook playbooks/provision.yml --skip-tags "common" +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run docker role tasks/defaults/handlers] ********************************* +included: docker for vagrant + +TASK [docker : Install Docker prerequisites] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************* +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************* +ok: [vagrant] + +TASK [docker : Add Docker apt repository] ************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************* +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ****************************** +ok: [vagrant] + +TASK [docker : Mark Docker service as ready] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************* +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ******************** +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] **************************** +ok: [vagrant] + +TASK [docker : Record Docker configuration block completion] ******************* +ok: [vagrant] + +PLAY RECAP ********************************************************************* +vagrant : ok=13 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +
+ +No `common` tasks ran, which confirms the role-level `common` tag is working. + +#### 3. Running package tasks only + +
+ansible-playbook playbooks/provision.yml --tags "packages" + +``` +$ ansible-playbook playbooks/provision.yml --tags "packages" +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run common role tasks/defaults] ****************************************** +included: common for vagrant + +TASK [common : Update apt cache] *********************************************** +ok: [vagrant] + +TASK [common : Install common packages] **************************************** +ok: [vagrant] + +TASK [common : Record common packages block completion] ************************ +ok: [vagrant] + +PLAY RECAP ********************************************************************* +vagrant : ok=5 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +
+ +This shows the `packages` tag is narrow enough to avoid unrelated common-role tasks. + +#### 4. Check mode for Docker tasks + +
+ansible-playbook playbooks/provision.yml --tags "docker" --check + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker" --check +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run docker role tasks/defaults/handlers] ********************************* +included: docker for vagrant + +TASK [docker : Install Docker prerequisites] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************* +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************* +changed: [vagrant] + +TASK [docker : Add Docker apt repository] ************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************* +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ****************************** +ok: [vagrant] + +TASK [docker : Mark Docker service as ready] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************* +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ******************** +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] **************************** +ok: [vagrant] + +TASK [docker : Record Docker configuration block completion] ******************* +ok: [vagrant] + +PLAY RECAP ********************************************************************* +vagrant : ok=13 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +
+ +Check mode completed without errors. One task reported `changed`, which is not surprising for repository/key download style tasks; check mode is useful, but not perfect, for external-resource operations. + +#### 5. Running only Docker installation tasks + +
+ansible-playbook playbooks/provision.yml --tags "docker_install" + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker_install" +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run docker role tasks/defaults/handlers] ********************************* +included: docker for vagrant + +TASK [docker : Install Docker prerequisites] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************* +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************* +ok: [vagrant] + +TASK [docker : Add Docker apt repository] ************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************* +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ****************************** +ok: [vagrant] + +TASK [docker : Mark Docker service as ready] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************* +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ******************** +ok: [vagrant] + +PLAY RECAP ********************************************************************* +vagrant : ok=11 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +
+ +This confirms that installation tasks can be isolated from broader Docker role execution. + +#### 6. Available tags + +Verified with: + +
+ansible-playbook playbooks/provision.yml --list-tags + +``` +$ ansible-playbook playbooks/provision.yml --list-tags + + +playbook: playbooks/provision.yml + + play #1 (webservers): Provision web servers TAGS: [] + TASK TAGS: [common, docker, docker_config, docker_install, packages, users] + +``` + +
+ +### Analysis + +Blocks improved the structure of both roles because related tasks are now grouped around a single intent instead of being scattered as flat tasks. In practice, this made the Docker install flow easier to reason about: install steps are in one place, recovery steps are in one place, and service enforcement is in one place. + +The rescue behavior in the Docker role is especially useful. The first `--tags "docker"` run showed a real transient APT/cache failure, and the play recovered automatically. That is more convincing than a purely theoretical discussion because the log captured an actual `rescued=1` run. + +The tag layout is also practical rather than decorative: + +- broad tags (`common`, `docker`) support role-level execution, +- narrow tags (`packages`, `users`, `docker_install`, `docker_config`) support targeted runs, +- the tag names are predictable and easy to remember during troubleshooting. + +### Research Answers + +1. **What happens if the rescue block also fails?** + - If a task inside `rescue` fails, the block is no longer recovered and the host stays failed for that task sequence. The `always` section still runs, but the play reports a failure unless some higher-level error handling changes that behavior. + +2. **Can you have nested blocks?** + - Yes. Ansible allows nested blocks. That said, I would use them carefully because deep nesting becomes hard to read quickly. + +3. **How do tags inherit to tasks within blocks?** + - Tags applied to a block are inherited by the tasks inside that block, including `rescue` and `always` tasks associated with the block. In this lab, role-level tags are applied from the playbook, while narrower tags are applied directly on the role blocks. + +--- + +## Task 2: Docker Compose + +### Implementation Summary + +- Renamed the deployment role from `app_deploy` to `web_app`. +- Kept descriptive role filenames for the web app implementation in `ansible/roles/web_app/tasks/web_app_tasks.yml` and `ansible/roles/web_app/defaults/web_app_defaults.yml`. +- Inlined the Docker role logic into `ansible/roles/docker/tasks/main.yml` and `ansible/roles/docker/handlers/main.yml`, leaving only the Ansible-required entrypoints as `main.yml`. +- Replaced the old single-container deployment logic with a Compose-based deployment in `ansible/roles/web_app/tasks/web_app_tasks.yml`. +- Added a Compose template in `ansible/roles/web_app/templates/docker-compose.yml.j2`. +- Added a role dependency in `ansible/roles/web_app/meta/main.yml` so `docker` is installed automatically before the app is deployed. +- Updated `ansible/playbooks/deploy.yml` and `ansible/playbooks/site.yml` to call `web_app`. + +### Compose Deployment Design + +The new role now: + +- optionally logs into Docker Hub when credentials are present, +- creates the project directory under `/opt/{{ app_name }}`, +- removes the legacy standalone container before migration to Compose, +- templates a `docker-compose.yml`, +- deploys the stack with `community.docker.docker_compose_v2`, +- waits for the application port and then verifies `/health`. + +### Practical Notes + +- I added retries around the Compose deployment step because the first live run hit a transient Docker Hub registry timeout. + +### Evidence + +The deployment now works end-to-end with Docker Compose: + +
+ansible-playbook playbooks/deploy.yml + +``` +$ ansible-playbook playbooks/deploy.yml + +PLAY [Deploy application] ******************************************************************************************************* + +TASK [Gathering Facts] ********************************************************************************************************** +ok: [vagrant] + +TASK [Run web app role] ********************************************************************************************************* +included: web_app for vagrant + +TASK [docker : Load docker role defaults] *************************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker prerequisites] ************************************************************************************ +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************************************************************** +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************************************************************** +ok: [vagrant] + +TASK [docker : Add Docker apt repository] *************************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ******************************************************************************* +ok: [vagrant] + +TASK [docker : Mark Docker service as ready] ************************************************************************************ +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************************************************************** +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ********************************************************************* +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] ***************************************************************************** +ok: [vagrant] + +TASK [docker : Record Docker configuration block completion] ******************************************************************** +ok: [vagrant] + +TASK [web_app : Log in to Docker Hub when credentials are available] ************************************************************ +ok: [vagrant] + +TASK [web_app : Ensure Compose project directory exists] ************************************************************************ +ok: [vagrant] + +TASK [web_app : Check for legacy standalone container] ************************************************************************** +ok: [vagrant] + +TASK [web_app : Remove legacy standalone container before Compose migration] **************************************************** +skipping: [vagrant] + +TASK [web_app : Template Docker Compose configuration] ************************************************************************** +ok: [vagrant] + +TASK [web_app : Deploy application stack with Docker Compose] ******************************************************************* +changed: [vagrant] + +TASK [web_app : Wait for application port] ************************************************************************************** +ok: [vagrant -> localhost] + +TASK [web_app : Verify application health endpoint] ***************************************************************************** +ok: [vagrant -> localhost] + +PLAY RECAP ********************************************************************************************************************** +vagrant : ok=21 changed=1 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +
+ +The second deployment run proves idempotency for the Compose-based deployment: + +
+ansible-playbook playbooks/deploy.yml (second run) + +``` +$ ansible-playbook playbooks/deploy.yml + +PLAY [Deploy application] ******************************************************************************************************* + +TASK [Gathering Facts] ********************************************************************************************************** +ok: [vagrant] + +TASK [Run web app role] ********************************************************************************************************* +included: web_app for vagrant + +TASK [docker : Load docker role defaults] *************************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker prerequisites] ************************************************************************************ +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************************************************************** +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************************************************************** +ok: [vagrant] + +TASK [docker : Add Docker apt repository] *************************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************************************************************** +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ******************************************************************************* +ok: [vagrant] + +TASK [docker : Mark Docker service as ready] ************************************************************************************ +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************************************************************** +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ********************************************************************* +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] ***************************************************************************** +ok: [vagrant] + +TASK [docker : Record Docker configuration block completion] ******************************************************************** +ok: [vagrant] + +TASK [web_app : Log in to Docker Hub when credentials are available] ************************************************************ +ok: [vagrant] + +TASK [web_app : Ensure Compose project directory exists] ************************************************************************ +ok: [vagrant] + +TASK [web_app : Check for legacy standalone container] ************************************************************************** +ok: [vagrant] + +TASK [web_app : Remove legacy standalone container before Compose migration] **************************************************** +skipping: [vagrant] + +TASK [web_app : Template Docker Compose configuration] ************************************************************************** +ok: [vagrant] + +TASK [web_app : Deploy application stack with Docker Compose] ******************************************************************* +ok: [vagrant] + +TASK [web_app : Wait for application port] ************************************************************************************** +ok: [vagrant -> localhost] + +TASK [web_app : Verify application health endpoint] ***************************************************************************** +ok: [vagrant -> localhost] + +PLAY RECAP ********************************************************************************************************************** +vagrant : ok=21 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +
+ +Runtime verification on the VM confirms that the Compose stack is up and the application health endpoint is reachable: + +
+docker ps -a + +``` +vagrant@devops-core-s26:~$ docker ps -a +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +bc1ac63a19d3 localt0aster/devops-app-py:latest "sh -c 'gunicorn --b…" 13 seconds ago Up 12 seconds 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp devops-app +``` + +
+ +
+docker compose -f /opt/devops-app-py/docker-compose.yml ps + +``` +vagrant@devops-core-s26:~$ docker compose -f /opt/devops-app-py/docker-compose.yml ps +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +devops-app localt0aster/devops-app-py:latest "sh -c 'gunicorn --b…" devops-app-py About a minute ago Up About a minute 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp +``` + +
+ +
+curl -fSsL 127.0.0.1:5000/health | jq + +``` +vagrant@devops-core-s26:~$ curl -fSsL 127.0.0.1:5000/health | jq +{ + "status": "healthy", + "timestamp": "2026-03-06T03:15:05.637621+00:00", + "uptime_seconds": 139 +} +``` + +
+ +### Validation Status + +- `ansible-playbook playbooks/deploy.yml --syntax-check` passes. +- `ansible-playbook playbooks/site.yml --syntax-check` passes. +- `ansible-lint` passes on the Task 2 files. +- A real deploy run succeeds with `failed=0` and `changed=1`, confirming that the Compose-based deployment works. +- A second deploy run returns `changed=0`, which demonstrates idempotency for the Compose workflow. +- The VM shows the expected running container, Compose project status, and a healthy `/health` response. + +## Task 3: Wipe Logic + +### Implementation Summary + +- Added the wipe control variables to `ansible/roles/web_app/defaults/web_app_defaults.yml`. +- Added the wipe task file `ansible/roles/web_app/tasks/wipe.yml`. +- Included wipe processing at the top of `ansible/roles/web_app/tasks/web_app_tasks.yml` so clean reinstall works as wipe → deploy. +- Added the `web_app_wipe` tag to the `web_app` role includes in `ansible/playbooks/deploy.yml` and `ansible/playbooks/site.yml`. +- Added optional image and volume cleanup switches with safe defaults of `false`. + +### Safety Design + +The wipe logic uses two controls: +- `web_app_wipe: true` is required before any destructive action happens. +- `--tags web_app_wipe` allows wipe-only execution without running the deployment block. + +Practical behavior: +- Normal deployment leaves wipe tasks skipped because `web_app_wipe` defaults to `false`. +- `ansible-playbook playbooks/deploy.yml -e web_app_wipe=true --tags web_app_wipe` performs wipe only. +- `ansible-playbook playbooks/deploy.yml -e web_app_wipe=true` performs clean reinstall. + +I used `-e app_compose_pull_policy=missing` for the deploy-related wipe tests so Docker Hub availability would not distort the wipe-logic results. That override was only for testing. + +### Evidence + +The evidence file is `task3.log`. + +#### Scenario 1: Normal deployment + +This shows that wipe tasks are present in the role flow but are skipped by default when `web_app_wipe` is `false`. + +
+ansible-playbook playbooks/deploy.yml -e app_compose_pull_policy=missing + +``` +$ ansible-playbook playbooks/deploy.yml -e app_compose_pull_policy=missing + +PLAY [Deploy application] ****************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run web app role] ******************************************************** +included: web_app for vagrant + +TASK [docker : Load docker role defaults] ************************************** +ok: [vagrant] + +TASK [docker : Install Docker prerequisites] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************* +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************* +ok: [vagrant] + +TASK [docker : Add Docker apt repository] ************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************* +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ****************************** +ok: [vagrant] + +TASK [docker : Mark Docker service as ready] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************* +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ******************** +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] **************************** +ok: [vagrant] + +TASK [docker : Record Docker configuration block completion] ******************* +ok: [vagrant] + +TASK [web_app : Include web app wipe tasks] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/web_app/tasks/wipe.yml for vagrant + +TASK [web_app : Check whether Compose file exists for wipe] ******************** +ok: [vagrant] + +TASK [web_app : Stop and remove Compose-managed containers] ******************** +skipping: [vagrant] + +TASK [web_app : Remove standalone web app container if present] **************** +skipping: [vagrant] + +TASK [web_app : Remove Compose file] ******************************************* +skipping: [vagrant] + +TASK [web_app : Remove Compose project directory] ****************************** +skipping: [vagrant] + +TASK [web_app : Optionally remove deployed image] ****************************** +skipping: [vagrant] + +TASK [web_app : Record web app wipe completion] ******************************** +skipping: [vagrant] + +TASK [web_app : Report web app wipe status] ************************************ +skipping: [vagrant] + +TASK [web_app : Log in to Docker Hub when credentials are available] *********** +ok: [vagrant] + +TASK [web_app : Ensure Compose project directory exists] *********************** +ok: [vagrant] + +TASK [web_app : Check for legacy standalone container] ************************* +ok: [vagrant] + +TASK [web_app : Remove legacy standalone container before Compose migration] *** +skipping: [vagrant] + +TASK [web_app : Template Docker Compose configuration] ************************* +ok: [vagrant] + +TASK [web_app : Deploy application stack with Docker Compose] ****************** +ok: [vagrant] + +TASK [web_app : Wait for application port] ************************************* +ok: [vagrant -> localhost] + +TASK [web_app : Verify application health endpoint] **************************** +ok: [vagrant -> localhost] + +PLAY RECAP ********************************************************************* +vagrant : ok=23 changed=0 unreachable=0 failed=0 skipped=8 rescued=0 ignored=0 +``` + +
+ +
+ansible webservers -m ansible.builtin.command -a 'docker compose -f /opt/devops-app-py/docker-compose.yml ps' + +``` +$ ansible webservers -m ansible.builtin.command -a 'docker compose -f /opt/devops-app-py/docker-compose.yml ps' +vagrant | CHANGED | rc=0 >> +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +devops-app localt0aster/devops-app-py:latest "sh -c 'gunicorn --b…" devops-app-py 11 minutes ago Up 11 minutes 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp +``` + +
+ +#### Scenario 2: Wipe only + +This is the explicit wipe-only path: variable enabled, tag selected, deployment tasks not executed. + +
+ansible-playbook playbooks/deploy.yml -e web_app_wipe=true --tags web_app_wipe + +``` +$ ansible-playbook playbooks/deploy.yml -e web_app_wipe=true --tags web_app_wipe + +PLAY [Deploy application] ****************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run web app role] ******************************************************** +included: web_app for vagrant + +TASK [docker : Load docker role defaults] ************************************** +ok: [vagrant] + +TASK [web_app : Include web app wipe tasks] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/web_app/tasks/wipe.yml for vagrant + +TASK [web_app : Check whether Compose file exists for wipe] ******************** +ok: [vagrant] + +TASK [web_app : Stop and remove Compose-managed containers] ******************** +changed: [vagrant] + +TASK [web_app : Remove standalone web app container if present] **************** +ok: [vagrant] + +TASK [web_app : Remove Compose file] ******************************************* +changed: [vagrant] + +TASK [web_app : Remove Compose project directory] ****************************** +changed: [vagrant] + +TASK [web_app : Optionally remove deployed image] ****************************** +skipping: [vagrant] + +TASK [web_app : Record web app wipe completion] ******************************** +changed: [vagrant] + +TASK [web_app : Report web app wipe status] ************************************ +ok: [vagrant] => { + "msg": "Web app devops-app-py wipe completed. Project directory=/opt/devops-app-py." +} + +PLAY RECAP ********************************************************************* +vagrant : ok=11 changed=4 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +
+ +
+ansible webservers -m ansible.builtin.shell -a "docker ps -a | grep -F devops-app || true" + +``` +$ ansible webservers -m ansible.builtin.shell -a "docker ps -a | grep -F devops-app || true" +vagrant | CHANGED | rc=0 >> +``` + +
+ +
+ansible webservers -m ansible.builtin.shell -a "if [ -d /opt/devops-app-py ]; then echo present; else echo absent; fi" + +``` +$ ansible webservers -m ansible.builtin.shell -a "if [ -d /opt/devops-app-py ]; then echo present; else echo absent; fi" +vagrant | CHANGED | rc=0 >> +absent +``` + +
+ +#### Scenario 3: Clean reinstall + +This is the key workflow for Task 3: wipe first, then redeploy cleanly in the same playbook run. + +
+ansible-playbook playbooks/deploy.yml -e web_app_wipe=true -e app_compose_pull_policy=missing + +``` +$ ansible-playbook playbooks/deploy.yml -e web_app_wipe=true -e app_compose_pull_policy=missing + +PLAY [Deploy application] ****************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run web app role] ******************************************************** +included: web_app for vagrant + +TASK [docker : Load docker role defaults] ************************************** +ok: [vagrant] + +TASK [docker : Install Docker prerequisites] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker keyring directory exists] ************************* +ok: [vagrant] + +TASK [docker : Add Docker GPG key] ********************************************* +ok: [vagrant] + +TASK [docker : Add Docker apt repository] ************************************** +ok: [vagrant] + +TASK [docker : Install Docker engine packages] ********************************* +ok: [vagrant] + +TASK [docker : Install Docker Python SDK package] ****************************** +ok: [vagrant] + +TASK [docker : Mark Docker service as ready] *********************************** +ok: [vagrant] + +TASK [docker : Ensure Docker service is enabled and running] ******************* +ok: [vagrant] + +TASK [docker : Record Docker installation block completion] ******************** +ok: [vagrant] + +TASK [docker : Add deployment user to docker group] **************************** +ok: [vagrant] + +TASK [docker : Record Docker configuration block completion] ******************* +ok: [vagrant] + +TASK [web_app : Include web app wipe tasks] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/web_app/tasks/wipe.yml for vagrant + +TASK [web_app : Check whether Compose file exists for wipe] ******************** +ok: [vagrant] + +TASK [web_app : Stop and remove Compose-managed containers] ******************** +skipping: [vagrant] + +TASK [web_app : Remove standalone web app container if present] **************** +ok: [vagrant] + +TASK [web_app : Remove Compose file] ******************************************* +ok: [vagrant] + +TASK [web_app : Remove Compose project directory] ****************************** +ok: [vagrant] + +TASK [web_app : Optionally remove deployed image] ****************************** +skipping: [vagrant] + +TASK [web_app : Record web app wipe completion] ******************************** +changed: [vagrant] + +TASK [web_app : Report web app wipe status] ************************************ +ok: [vagrant] => { + "msg": "Web app devops-app-py wipe completed. Project directory=/opt/devops-app-py." +} + +TASK [web_app : Log in to Docker Hub when credentials are available] *********** +ok: [vagrant] + +TASK [web_app : Ensure Compose project directory exists] *********************** +changed: [vagrant] + +TASK [web_app : Check for legacy standalone container] ************************* +ok: [vagrant] + +TASK [web_app : Remove legacy standalone container before Compose migration] *** +skipping: [vagrant] + +TASK [web_app : Template Docker Compose configuration] ************************* +changed: [vagrant] + +TASK [web_app : Deploy application stack with Docker Compose] ****************** +changed: [vagrant] + +TASK [web_app : Wait for application port] ************************************* +ok: [vagrant -> localhost] + +TASK [web_app : Verify application health endpoint] **************************** +ok: [vagrant -> localhost] + +PLAY RECAP ********************************************************************* +vagrant : ok=28 changed=4 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0 +``` + +
+ +
+ansible webservers -m ansible.builtin.command -a 'docker compose -f /opt/devops-app-py/docker-compose.yml ps' + +``` +$ ansible webservers -m ansible.builtin.command -a 'docker compose -f /opt/devops-app-py/docker-compose.yml ps' +vagrant | CHANGED | rc=0 >> +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +devops-app localt0aster/devops-app-py:latest "sh -c 'gunicorn --b…" devops-app-py 4 seconds ago Up 3 seconds 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp +``` + +
+ +
+ansible webservers -m ansible.builtin.shell -a "curl -fSsL 127.0.0.1:5000/health" + +``` +$ ansible webservers -m ansible.builtin.shell -a "curl -fSsL 127.0.0.1:5000/health" +vagrant | CHANGED | rc=0 >> +{"status":"healthy","timestamp":"2026-03-06T03:24:46.458130+00:00","uptime_seconds":3} +``` + +
+ +#### Scenario 4: Safety checks + +For Scenario 4a, the lab text says deployment should run normally when `--tags web_app_wipe` is used with `web_app_wipe=false`. In practice, Ansible tag filtering only selects the wipe-tagged path, so the deployment block does not run. The existing application remains running, which still proves the wipe did not trigger. I believe the lab wording is internally inconsistent here. + +
+ansible-playbook playbooks/deploy.yml --tags web_app_wipe + +``` +$ ansible-playbook playbooks/deploy.yml --tags web_app_wipe + +PLAY [Deploy application] ****************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run web app role] ******************************************************** +included: web_app for vagrant + +TASK [docker : Load docker role defaults] ************************************** +ok: [vagrant] + +TASK [web_app : Include web app wipe tasks] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/web_app/tasks/wipe.yml for vagrant + +TASK [web_app : Check whether Compose file exists for wipe] ******************** +ok: [vagrant] + +TASK [web_app : Stop and remove Compose-managed containers] ******************** +skipping: [vagrant] + +TASK [web_app : Remove standalone web app container if present] **************** +skipping: [vagrant] + +TASK [web_app : Remove Compose file] ******************************************* +skipping: [vagrant] + +TASK [web_app : Remove Compose project directory] ****************************** +skipping: [vagrant] + +TASK [web_app : Optionally remove deployed image] ****************************** +skipping: [vagrant] + +TASK [web_app : Record web app wipe completion] ******************************** +skipping: [vagrant] + +TASK [web_app : Report web app wipe status] ************************************ +skipping: [vagrant] + +PLAY RECAP ********************************************************************* +vagrant : ok=5 changed=0 unreachable=0 failed=0 skipped=7 rescued=0 ignored=0 +``` + +
+ +
+ansible webservers -m ansible.builtin.command -a 'docker compose -f /opt/devops-app-py/docker-compose.yml ps' + +``` +$ ansible webservers -m ansible.builtin.command -a 'docker compose -f /opt/devops-app-py/docker-compose.yml ps' +vagrant | CHANGED | rc=0 >> +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +devops-app localt0aster/devops-app-py:latest "sh -c 'gunicorn --b…" devops-app-py 11 minutes ago Up 11 minutes 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp +``` + +
+ +Scenario 4b is effectively the same selective wipe-only path as Scenario 2, but rechecked after a clean reinstall. + +
+ansible-playbook playbooks/deploy.yml -e web_app_wipe=true --tags web_app_wipe + +``` +$ ansible-playbook playbooks/deploy.yml -e web_app_wipe=true --tags web_app_wipe + +PLAY [Deploy application] ****************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run web app role] ******************************************************** +included: web_app for vagrant + +TASK [docker : Load docker role defaults] ************************************** +ok: [vagrant] + +TASK [web_app : Include web app wipe tasks] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/web_app/tasks/wipe.yml for vagrant + +TASK [web_app : Check whether Compose file exists for wipe] ******************** +ok: [vagrant] + +TASK [web_app : Stop and remove Compose-managed containers] ******************** +changed: [vagrant] + +TASK [web_app : Remove standalone web app container if present] **************** +ok: [vagrant] + +TASK [web_app : Remove Compose file] ******************************************* +changed: [vagrant] + +TASK [web_app : Remove Compose project directory] ****************************** +changed: [vagrant] + +TASK [web_app : Optionally remove deployed image] ****************************** +skipping: [vagrant] + +TASK [web_app : Record web app wipe completion] ******************************** +ok: [vagrant] + +TASK [web_app : Report web app wipe status] ************************************ +ok: [vagrant] => { + "msg": "Web app devops-app-py wipe completed. Project directory=/opt/devops-app-py." +} + +PLAY RECAP ********************************************************************* +vagrant : ok=11 changed=3 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +
+ +
+ansible webservers -m ansible.builtin.shell -a "docker ps -a | grep -F devops-app || true" + +``` +$ ansible webservers -m ansible.builtin.shell -a "docker ps -a | grep -F devops-app || true" +vagrant | CHANGED | rc=0 >> +``` + +
+ +
+ansible webservers -m ansible.builtin.shell -a "if [ -d /opt/devops-app-py ]; then echo present; else echo absent; fi" + +``` +$ ansible webservers -m ansible.builtin.shell -a "if [ -d /opt/devops-app-py ]; then echo present; else echo absent; fi" +vagrant | CHANGED | rc=0 >> +absent +``` + +
+ +### Validation Status + +- `ansible-playbook playbooks/deploy.yml --syntax-check` passes. +- `ansible-playbook playbooks/site.yml --syntax-check` passes. +- `ansible-lint` passes on the Task 3 files. +- `ansible-playbook playbooks/deploy.yml --list-tags` shows `web_app_wipe`. +- All four wipe scenarios were exercised against the VM. +- The application was restored after testing. + +### Research Answers + +1. **Why use both variable AND tag?** + - The variable is the destructive-action safety switch. The tag is the execution selector. Together they reduce accidental wipes and also support a wipe-only workflow without running normal deployment tasks. + +2. **What's the difference between `never` tag and this approach?** + - `never` disables tasks unless explicitly requested by tag, but it does not express business intent by itself. The variable-plus-tag approach is clearer because it encodes both operator intent and destructive permission. It is also easier to support clean reinstall with the same playbook run. + +3. **Why must wipe logic come BEFORE deployment?** + - Because clean reinstall is a sequential workflow: remove the old deployment first, then recreate it from a known-clean state. If wipe happened after deployment, it would destroy the fresh deployment. + +4. **When would you want clean reinstallation vs. rolling update?** + - Clean reinstall is useful when state is corrupted, migrations need a known baseline, or you want to prove reproducibility from scratch. Rolling update is better when you want lower disruption and the current deployment is already healthy. + +5. **How would you extend this to wipe Docker images and volumes too?** + - The current implementation already exposes `web_app_wipe_remove_images` and `web_app_wipe_remove_volumes`. To go further, I would add named-volume targeting, network cleanup verification, and possibly a confirmation variable for destructive data removal if persistent volumes matter. + + +## Task 4: CI/CD (3 pts) + +### Workflow Architecture + +- Added a dedicated GitHub Actions workflow in `.github/workflows/ansible-deploy.yml`. +- Split the workflow logic into local composite actions under `.github/actions/` so the workflow file stays orchestration-focused. +- Kept `lint` on `ubuntu-latest` and `deploy` on the isolated self-hosted runner labels `self-hosted`, `linux`, `vagrant`. +- Limited deployment to `push` and `workflow_dispatch`; pull requests run lint only. +- Added path filters so documentation-only changes under `ansible/docs/` do not trigger the deployment pipeline. + +### Modular Actions + +- `.github/actions/ansible-setup/action.yml` creates a Python virtual environment, installs `ansible-core` + `ansible-lint`, and installs required collections from `ansible/requirements.yml`. +- `.github/actions/ansible-lint/action.yml` writes the vault password to a temporary file, runs `ansible-lint`, and performs syntax checks on `provision.yml`, `deploy.yml`, and `site.yml`. +- `.github/actions/ansible-ssh-setup/action.yml` writes the target SSH key to `~/.ssh/vagrant` so the existing inventory file continues to work unchanged. +- `.github/actions/ansible-deploy/action.yml` runs `ansible-playbook playbooks/deploy.yml --tags app_deploy` and saves the playbook output as a workflow artifact. +- `.github/actions/http-healthcheck/action.yml` polls `http://:5000/health` and validates that the JSON reports `"status": "healthy"`. + +### Secrets and Repository Settings + +Required GitHub Actions secrets: +- `ANSIBLE_VAULT_PASSWORD` +- `SSH_PRIVATE_KEY` + +The workflow derives the deployment target IP from `ansible/inventory/hosts.ini`, so there is no second host variable to keep in sync. +The self-hosted runner itself is isolated in the separate `github-runner` Vagrant VM described in `vagrant/README.md`. + +### Files Added or Updated + +- Added `.github/workflows/ansible-deploy.yml` +- Added `.github/actions/ansible-setup/action.yml` +- Added `.github/actions/ansible-lint/action.yml` +- Added `.github/actions/ansible-ssh-setup/action.yml` +- Added `.github/actions/ansible-deploy/action.yml` +- Added `.github/actions/http-healthcheck/action.yml` +- Added `ansible/requirements-ci.txt` +- Added the workflow badge to `README.md` + +### Evidence + +The raw GitHub Actions run log is saved locally as `task4.log`. + +- Successful workflow run: +- Event: `push` +- Branch: `lab06` +- Result: `success` +- Lint job: +- Deploy job: +- Deployment log artifact: + +
+Workflow run summary + +```json +{ + "run_id": 22750506418, + "title": "work please", + "event": "push", + "branch": "lab06", + "status": "completed", + "conclusion": "success", + "created_at": "2026-03-06T05:29:07Z", + "updated_at": "2026-03-06T05:31:58Z" +} +``` + +
+ +
+Ansible Lint job excerpt + +```text +Passed: 0 failure(s), 0 warning(s) in 9 files processed of 9 encountered. Profile 'production' was required, and it passed. +playbook: playbooks/provision.yml +playbook: playbooks/deploy.yml +playbook: playbooks/site.yml +``` + +
+ +
+Deploy Application job excerpt + +```text +Prepare vault password file +Verify target connectivity +vagrant | SUCCESS => { + "changed": false, + "ping": "pong" +} +PLAY RECAP ********************************************************************* +vagrant : ok=22 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +Artifact ansible-deploy-log has been successfully uploaded! Final size is 808 bytes. Artifact ID is 5792366004 +Remove vault password file +``` + +
+ +
+Health check excerpt + +```json +{ + "status": "healthy", + "timestamp": "2026-03-06T05:31:51.856613+00:00", + "uptime_seconds": 7603 +} +``` + +
+ +
+Full log + +```log +Ansible Lint Set up job 2026-03-06T05:29:10.5329423Z Current runner version: '2.332.0' +Ansible Lint Set up job 2026-03-06T05:29:10.5360328Z ##[group]Runner Image Provisioner +Ansible Lint Set up job 2026-03-06T05:29:10.5361185Z Hosted Compute Agent +Ansible Lint Set up job 2026-03-06T05:29:10.5361774Z Version: 20260213.493 +Ansible Lint Set up job 2026-03-06T05:29:10.5362403Z Commit: 5c115507f6dd24b8de37d8bbe0bb4509d0cc0fa3 +Ansible Lint Set up job 2026-03-06T05:29:10.5363145Z Build Date: 2026-02-13T00:28:41Z +Ansible Lint Set up job 2026-03-06T05:29:10.5363778Z Worker ID: {2041351a-97c7-45c8-90a5-5ac75b9b9cf3} +Ansible Lint Set up job 2026-03-06T05:29:10.5364557Z Azure Region: northcentralus +Ansible Lint Set up job 2026-03-06T05:29:10.5365170Z ##[endgroup] +Ansible Lint Set up job 2026-03-06T05:29:10.5366962Z ##[group]Operating System +Ansible Lint Set up job 2026-03-06T05:29:10.5367667Z Ubuntu +Ansible Lint Set up job 2026-03-06T05:29:10.5368167Z 24.04.3 +Ansible Lint Set up job 2026-03-06T05:29:10.5368631Z LTS +Ansible Lint Set up job 2026-03-06T05:29:10.5369148Z ##[endgroup] +Ansible Lint Set up job 2026-03-06T05:29:10.5369635Z ##[group]Runner Image +Ansible Lint Set up job 2026-03-06T05:29:10.5370202Z Image: ubuntu-24.04 +Ansible Lint Set up job 2026-03-06T05:29:10.5370770Z Version: 20260302.42.1 +Ansible Lint Set up job 2026-03-06T05:29:10.5371770Z Included Software: https://github.com/actions/runner-images/blob/ubuntu24/20260302.42/images/ubuntu/Ubuntu2404-Readme.md +Ansible Lint Set up job 2026-03-06T05:29:10.5373467Z Image Release: https://github.com/actions/runner-images/releases/tag/ubuntu24%2F20260302.42 +Ansible Lint Set up job 2026-03-06T05:29:10.5374434Z ##[endgroup] +Ansible Lint Set up job 2026-03-06T05:29:10.5375723Z ##[group]GITHUB_TOKEN Permissions +Ansible Lint Set up job 2026-03-06T05:29:10.5377590Z Contents: read +Ansible Lint Set up job 2026-03-06T05:29:10.5378212Z Metadata: read +Ansible Lint Set up job 2026-03-06T05:29:10.5378677Z ##[endgroup] +Ansible Lint Set up job 2026-03-06T05:29:10.5380727Z Secret source: Actions +Ansible Lint Set up job 2026-03-06T05:29:10.5381433Z Prepare workflow directory +Ansible Lint Set up job 2026-03-06T05:29:10.5877192Z Prepare all required actions +Ansible Lint Set up job 2026-03-06T05:29:10.5932576Z Getting action download info +Ansible Lint Set up job 2026-03-06T05:29:10.8794598Z Download action repository 'actions/checkout@v4' (SHA:34e114876b0b11c390a56381ad16ebd13914f8d5) +Ansible Lint Set up job 2026-03-06T05:29:11.1099497Z Complete job name: Ansible Lint +Ansible Lint Checkout code 2026-03-06T05:29:11.1884697Z ##[group]Run actions/checkout@v4 +Ansible Lint Checkout code 2026-03-06T05:29:11.1885706Z with: +Ansible Lint Checkout code 2026-03-06T05:29:11.1886156Z repository: LocalT0aster/DevOps-Core-S26 +Ansible Lint Checkout code 2026-03-06T05:29:11.1886842Z token: *** +Ansible Lint Checkout code 2026-03-06T05:29:11.1887231Z ssh-strict: true +Ansible Lint Checkout code 2026-03-06T05:29:11.1887635Z ssh-user: git +Ansible Lint Checkout code 2026-03-06T05:29:11.1888031Z persist-credentials: true +Ansible Lint Checkout code 2026-03-06T05:29:11.1888477Z clean: true +Ansible Lint Checkout code 2026-03-06T05:29:11.1888878Z sparse-checkout-cone-mode: true +Ansible Lint Checkout code 2026-03-06T05:29:11.1889348Z fetch-depth: 1 +Ansible Lint Checkout code 2026-03-06T05:29:11.1889753Z fetch-tags: false +Ansible Lint Checkout code 2026-03-06T05:29:11.1890154Z show-progress: true +Ansible Lint Checkout code 2026-03-06T05:29:11.1890571Z lfs: false +Ansible Lint Checkout code 2026-03-06T05:29:11.1890938Z submodules: false +Ansible Lint Checkout code 2026-03-06T05:29:11.1891331Z set-safe-directory: true +Ansible Lint Checkout code 2026-03-06T05:29:11.1891962Z env: +Ansible Lint Checkout code 2026-03-06T05:29:11.1892352Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Checkout code 2026-03-06T05:29:11.1892850Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Checkout code 2026-03-06T05:29:11.1893371Z DEPLOY_TAGS: app_deploy +Ansible Lint Checkout code 2026-03-06T05:29:11.1893792Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.2964777Z Syncing repository: LocalT0aster/DevOps-Core-S26 +Ansible Lint Checkout code 2026-03-06T05:29:11.2967010Z ##[group]Getting Git version info +Ansible Lint Checkout code 2026-03-06T05:29:11.2967786Z Working directory is '/home/runner/work/DevOps-Core-S26/DevOps-Core-S26' +Ansible Lint Checkout code 2026-03-06T05:29:11.2968817Z [command]/usr/bin/git version +Ansible Lint Checkout code 2026-03-06T05:29:11.3042020Z git version 2.53.0 +Ansible Lint Checkout code 2026-03-06T05:29:11.3067759Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.3081932Z Temporarily overriding HOME='/home/runner/work/_temp/890e68c6-2607-4793-b4c9-d348425e2444' before making global git config changes +Ansible Lint Checkout code 2026-03-06T05:29:11.3094172Z Adding repository directory to the temporary git global config as a safe directory +Ansible Lint Checkout code 2026-03-06T05:29:11.3095606Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/DevOps-Core-S26/DevOps-Core-S26 +Ansible Lint Checkout code 2026-03-06T05:29:11.3134382Z Deleting the contents of '/home/runner/work/DevOps-Core-S26/DevOps-Core-S26' +Ansible Lint Checkout code 2026-03-06T05:29:11.3138290Z ##[group]Initializing the repository +Ansible Lint Checkout code 2026-03-06T05:29:11.3142225Z [command]/usr/bin/git init /home/runner/work/DevOps-Core-S26/DevOps-Core-S26 +Ansible Lint Checkout code 2026-03-06T05:29:11.3230735Z hint: Using 'master' as the name for the initial branch. This default branch name +Ansible Lint Checkout code 2026-03-06T05:29:11.3232149Z hint: will change to "main" in Git 3.0. To configure the initial branch name +Ansible Lint Checkout code 2026-03-06T05:29:11.3233366Z hint: to use in all of your new repositories, which will suppress this warning, +Ansible Lint Checkout code 2026-03-06T05:29:11.3234537Z hint: call: +Ansible Lint Checkout code 2026-03-06T05:29:11.3235183Z hint: +Ansible Lint Checkout code 2026-03-06T05:29:11.3236155Z hint: git config --global init.defaultBranch +Ansible Lint Checkout code 2026-03-06T05:29:11.3236776Z hint: +Ansible Lint Checkout code 2026-03-06T05:29:11.3237351Z hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and +Ansible Lint Checkout code 2026-03-06T05:29:11.3238302Z hint: 'development'. The just-created branch can be renamed via this command: +Ansible Lint Checkout code 2026-03-06T05:29:11.3239050Z hint: +Ansible Lint Checkout code 2026-03-06T05:29:11.3239448Z hint: git branch -m +Ansible Lint Checkout code 2026-03-06T05:29:11.3240148Z hint: +Ansible Lint Checkout code 2026-03-06T05:29:11.3240823Z hint: Disable this message with "git config set advice.defaultBranchName false" +Ansible Lint Checkout code 2026-03-06T05:29:11.3242206Z Initialized empty Git repository in /home/runner/work/DevOps-Core-S26/DevOps-Core-S26/.git/ +Ansible Lint Checkout code 2026-03-06T05:29:11.3246185Z [command]/usr/bin/git remote add origin https://github.com/LocalT0aster/DevOps-Core-S26 +Ansible Lint Checkout code 2026-03-06T05:29:11.3279691Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.3280429Z ##[group]Disabling automatic garbage collection +Ansible Lint Checkout code 2026-03-06T05:29:11.3284110Z [command]/usr/bin/git config --local gc.auto 0 +Ansible Lint Checkout code 2026-03-06T05:29:11.3312715Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.3313412Z ##[group]Setting up auth +Ansible Lint Checkout code 2026-03-06T05:29:11.3319862Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +Ansible Lint Checkout code 2026-03-06T05:29:11.3350673Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +Ansible Lint Checkout code 2026-03-06T05:29:11.3675159Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +Ansible Lint Checkout code 2026-03-06T05:29:11.3706457Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +Ansible Lint Checkout code 2026-03-06T05:29:11.3938616Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +Ansible Lint Checkout code 2026-03-06T05:29:11.3969877Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +Ansible Lint Checkout code 2026-03-06T05:29:11.4214694Z [command]/usr/bin/git config --local http.https://github.com/.extraheader AUTHORIZATION: basic *** +Ansible Lint Checkout code 2026-03-06T05:29:11.4250962Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.4251733Z ##[group]Fetching the repository +Ansible Lint Checkout code 2026-03-06T05:29:11.4259070Z [command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +2492c7d27ac02a50f12e2ca7f51bc1d7882b8489:refs/remotes/origin/lab06 +Ansible Lint Checkout code 2026-03-06T05:29:11.7606599Z From https://github.com/LocalT0aster/DevOps-Core-S26 +Ansible Lint Checkout code 2026-03-06T05:29:11.7607942Z * [new ref] 2492c7d27ac02a50f12e2ca7f51bc1d7882b8489 -> origin/lab06 +Ansible Lint Checkout code 2026-03-06T05:29:11.7641114Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.7641819Z ##[group]Determining the checkout info +Ansible Lint Checkout code 2026-03-06T05:29:11.7644340Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.7651042Z [command]/usr/bin/git sparse-checkout disable +Ansible Lint Checkout code 2026-03-06T05:29:11.7694047Z [command]/usr/bin/git config --local --unset-all extensions.worktreeConfig +Ansible Lint Checkout code 2026-03-06T05:29:11.7726044Z ##[group]Checking out the ref +Ansible Lint Checkout code 2026-03-06T05:29:11.7730689Z [command]/usr/bin/git checkout --progress --force -B lab06 refs/remotes/origin/lab06 +Ansible Lint Checkout code 2026-03-06T05:29:11.7916810Z Switched to a new branch 'lab06' +Ansible Lint Checkout code 2026-03-06T05:29:11.7920253Z branch 'lab06' set up to track 'origin/lab06'. +Ansible Lint Checkout code 2026-03-06T05:29:11.7927351Z ##[endgroup] +Ansible Lint Checkout code 2026-03-06T05:29:11.7961096Z [command]/usr/bin/git log -1 --format=%H +Ansible Lint Checkout code 2026-03-06T05:29:11.7984326Z 2492c7d27ac02a50f12e2ca7f51bc1d7882b8489 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:11.8266011Z Prepare all required actions +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:11.8266740Z Getting action download info +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:11.9642088Z Download action repository 'actions/setup-python@v5' (SHA:a26af69be951a213d495a4c3e4e4022e16d87065) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.0926196Z Download action repository 'actions/cache@v4' (SHA:0057852bfaa89a56745cba8c7296529d2fc39830) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2754803Z ##[group]Run ./.github/actions/ansible-setup +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2756101Z with: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2756875Z python-version: 3.12 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2757772Z working-directory: ansible +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2758919Z python-requirements-path: ansible/requirements-ci.txt +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2760341Z collection-requirements-path: ansible/requirements.yml +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2761526Z env: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2762260Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2763240Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2764268Z DEPLOY_TAGS: app_deploy +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2765123Z ##[endgroup] +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2953557Z ##[group]Run actions/setup-python@v5 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2954605Z with: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2955561Z python-version: 3.12 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2956433Z check-latest: false +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2957494Z token: *** +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2958279Z update-environment: true +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2959180Z allow-prereleases: false +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2960062Z freethreaded: false +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2960864Z env: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2961589Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2962544Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2963542Z DEPLOY_TAGS: app_deploy +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.2964389Z ##[endgroup] +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.4642413Z ##[group]Installed versions +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.4726679Z Successfully set up CPython (3.12.12) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.4729711Z ##[endgroup] +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5526985Z ##[group]Run actions/cache@v4 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5527927Z with: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5528767Z path: ~/.cache/pip +Ansible Lint Setup Ansible toolchain ~/.ansible/collections +Ansible Lint Setup Ansible toolchain +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5530434Z key: Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5532114Z restore-keys: Linux-py3.12-ansible- +Ansible Lint Setup Ansible toolchain +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5533131Z enableCrossOsArchive: false +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5534054Z fail-on-cache-miss: false +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5534903Z lookup-only: false +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5535979Z save-always: false +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5536767Z env: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5537478Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5538412Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5539397Z DEPLOY_TAGS: app_deploy +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5540456Z pythonLocation: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5541914Z PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib/pkgconfig +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5543343Z Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5544669Z Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5546641Z Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5548045Z LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.5549177Z ##[endgroup] +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:12.8056849Z Cache hit for: Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.2661813Z Received 16876233 of 16876233 (100.0%), 45.1 MBs/sec +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.2662769Z Cache Size: ~16 MB (16876233 B) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.2695856Z [command]/usr/bin/tar -xf /home/runner/work/_temp/ed14daa5-3d21-447d-b84b-82030a283c55/cache.tzst -P -C /home/runner/work/DevOps-Core-S26/DevOps-Core-S26 --use-compress-program unzstd +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3615615Z Cache restored successfully +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3743008Z Cache restored from key: Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3852777Z ##[group]Run set -euo pipefail +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3853186Z set -euo pipefail +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3853496Z rm -rf "ansible/.venv-ci" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3853828Z python -m venv "ansible/.venv-ci" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3854470Z . "ansible/.venv-ci/bin/activate" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3854821Z python -m pip install --upgrade pip +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3855722Z python -m pip install -r "ansible/requirements-ci.txt" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3912991Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3913612Z env: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3913856Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3914173Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3914488Z DEPLOY_TAGS: app_deploy +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3914830Z pythonLocation: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3915535Z PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib/pkgconfig +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3916011Z Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3916433Z Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3916855Z Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3917310Z LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:13.3917678Z ##[endgroup] +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.4817418Z Requirement already satisfied: pip in ./ansible/.venv-ci/lib/python3.12/site-packages (25.0.1) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.5674329Z Collecting pip +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.5687767Z Using cached pip-26.0.1-py3-none-any.whl.metadata (4.7 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.5720838Z Using cached pip-26.0.1-py3-none-any.whl (1.8 MB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.5891297Z Installing collected packages: pip +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.5893687Z Attempting uninstall: pip +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.5915418Z Found existing installation: pip 25.0.1 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.6291717Z Uninstalling pip-25.0.1: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:17.6349799Z Successfully uninstalled pip-25.0.1 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:18.7331183Z Successfully installed pip-26.0.1 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.2368546Z Collecting ansible-core<2.20,>=2.16 (from -r ansible/requirements-ci.txt (line 1)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.2386317Z Using cached ansible_core-2.19.7-py3-none-any.whl.metadata (7.7 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.2666691Z Collecting ansible-lint==26.3.0 (from -r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.2678442Z Using cached ansible_lint-26.3.0-py3-none-any.whl.metadata (6.2 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.2841853Z Collecting ansible-compat>=25.8.2 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.2853515Z Using cached ansible_compat-25.12.1-py3-none-any.whl.metadata (3.4 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.3324507Z Collecting black>=24.3.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.3340053Z Using cached black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (88 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.4392554Z Collecting cffi>=1.15.1 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.4410566Z Using cached cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.6 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6020587Z Collecting cryptography>=37 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6039889Z Using cached cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl.metadata (5.7 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6171492Z Collecting distro>=1.9.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6182889Z Using cached distro-1.9.0-py3-none-any.whl.metadata (6.8 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6342972Z Collecting filelock>=3.8.2 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6354454Z Using cached filelock-3.25.0-py3-none-any.whl.metadata (2.0 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6530994Z Collecting jsonschema>=4.10.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6542552Z Using cached jsonschema-4.26.0-py3-none-any.whl.metadata (7.6 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6681268Z Collecting packaging>=22.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6693036Z Using cached packaging-26.0-py3-none-any.whl.metadata (3.3 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6782352Z Collecting pathspec<1.1.0,>=1.0.3 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.6793421Z Using cached pathspec-1.0.4-py3-none-any.whl.metadata (13 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.7114486Z Collecting pyyaml>=6.0.1 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.7127355Z Using cached pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.4 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.7288961Z Collecting referencing>=0.36.2 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.7300535Z Using cached referencing-0.37.0-py3-none-any.whl.metadata (2.8 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.8438693Z Collecting ruamel-yaml>=0.18.11 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.8451054Z Using cached ruamel_yaml-0.19.1-py3-none-any.whl.metadata (16 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.8808593Z Collecting ruamel-yaml-clib>=0.2.12 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.8822030Z Using cached ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (3.5 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.8919802Z Collecting subprocess-tee>=0.4.1 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.8931570Z Using cached subprocess_tee-0.4.2-py3-none-any.whl.metadata (3.3 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9056242Z Collecting wcmatch>=8.5.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9067895Z Using cached wcmatch-10.1-py3-none-any.whl.metadata (5.1 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9186855Z Collecting yamllint>=1.38.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9197893Z Using cached yamllint-1.38.0-py3-none-any.whl.metadata (4.2 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9318148Z Collecting jinja2>=3.1.0 (from ansible-core<2.20,>=2.16->-r ansible/requirements-ci.txt (line 1)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9329139Z Using cached jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9434075Z Collecting resolvelib<2.0.0,>=0.5.3 (from ansible-core<2.20,>=2.16->-r ansible/requirements-ci.txt (line 1)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9445494Z Using cached resolvelib-1.2.1-py3-none-any.whl.metadata (3.7 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9621498Z Collecting click>=8.0.0 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9632716Z Using cached click-8.3.1-py3-none-any.whl.metadata (2.6 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9715932Z Collecting mypy-extensions>=0.4.3 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9727321Z Using cached mypy_extensions-1.1.0-py3-none-any.whl.metadata (1.1 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9890085Z Collecting platformdirs>=2 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:19.9901303Z Using cached platformdirs-4.9.4-py3-none-any.whl.metadata (4.7 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0017127Z Collecting pytokens>=0.3.0 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0028577Z Using cached pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (3.8 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0121729Z Collecting pycparser (from cffi>=1.15.1->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0132684Z Using cached pycparser-3.0-py3-none-any.whl.metadata (8.2 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0616368Z Collecting MarkupSafe>=2.0 (from jinja2>=3.1.0->ansible-core<2.20,>=2.16->-r ansible/requirements-ci.txt (line 1)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0628729Z Using cached markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.7 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0956111Z Collecting attrs>=22.2.0 (from jsonschema>=4.10.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.0968028Z Using cached attrs-25.4.0-py3-none-any.whl.metadata (10 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.1071916Z Collecting jsonschema-specifications>=2023.03.6 (from jsonschema>=4.10.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.1087218Z Using cached jsonschema_specifications-2025.9.1-py3-none-any.whl.metadata (2.9 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3427391Z Collecting rpds-py>=0.25.0 (from jsonschema>=4.10.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3441048Z Using cached rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.1 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3652689Z Collecting typing-extensions>=4.4.0 (from referencing>=0.36.2->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3664703Z Using cached typing_extensions-4.15.0-py3-none-any.whl.metadata (3.3 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3790215Z Collecting bracex>=2.1.1 (from wcmatch>=8.5.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3800941Z Using cached bracex-2.6-py3-none-any.whl.metadata (3.6 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3843568Z Using cached ansible_lint-26.3.0-py3-none-any.whl (330 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3857264Z Using cached ansible_core-2.19.7-py3-none-any.whl (2.4 MB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3886025Z Using cached pathspec-1.0.4-py3-none-any.whl (55 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3897085Z Using cached resolvelib-1.2.1-py3-none-any.whl (18 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3908164Z Using cached ansible_compat-25.12.1-py3-none-any.whl (27 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3919566Z Using cached black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (1.8 MB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3943377Z Using cached cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (219 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3955512Z Using cached click-8.3.1-py3-none-any.whl (108 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.3967497Z Using cached cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl (4.5 MB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4010695Z Using cached distro-1.9.0-py3-none-any.whl (20 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4021518Z Using cached filelock-3.25.0-py3-none-any.whl (26 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4032600Z Using cached jinja2-3.1.6-py3-none-any.whl (134 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4044242Z Using cached jsonschema-4.26.0-py3-none-any.whl (90 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4055630Z Using cached attrs-25.4.0-py3-none-any.whl (67 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4067265Z Using cached jsonschema_specifications-2025.9.1-py3-none-any.whl (18 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4078725Z Using cached markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (22 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4089307Z Using cached mypy_extensions-1.1.0-py3-none-any.whl (5.0 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4099989Z Using cached packaging-26.0-py3-none-any.whl (74 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4111364Z Using cached platformdirs-4.9.4-py3-none-any.whl (21 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4122788Z Using cached pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (269 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4135685Z Using cached pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (807 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4152160Z Using cached referencing-0.37.0-py3-none-any.whl (26 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4163610Z Using cached rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (394 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4177318Z Using cached ruamel_yaml-0.19.1-py3-none-any.whl (118 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4189476Z Using cached ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (788 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4205121Z Using cached subprocess_tee-0.4.2-py3-none-any.whl (5.2 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4216469Z Using cached typing_extensions-4.15.0-py3-none-any.whl (44 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4227263Z Using cached wcmatch-10.1-py3-none-any.whl (39 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4238042Z Using cached bracex-2.6-py3-none-any.whl (11 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4248958Z Using cached yamllint-1.38.0-py3-none-any.whl (68 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.4260046Z Using cached pycparser-3.0-py3-none-any.whl (48 kB) +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:20.5136341Z Installing collected packages: typing-extensions, subprocess-tee, ruamel-yaml-clib, ruamel-yaml, rpds-py, resolvelib, pyyaml, pytokens, pycparser, platformdirs, pathspec, packaging, mypy-extensions, MarkupSafe, filelock, distro, click, bracex, attrs, yamllint, wcmatch, referencing, jinja2, cffi, black, jsonschema-specifications, cryptography, jsonschema, ansible-core, ansible-compat, ansible-lint +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.1752917Z +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.1786289Z Successfully installed MarkupSafe-3.0.3 ansible-compat-25.12.1 ansible-core-2.19.7 ansible-lint-26.3.0 attrs-25.4.0 black-26.1.0 bracex-2.6 cffi-2.0.0 click-8.3.1 cryptography-46.0.5 distro-1.9.0 filelock-3.25.0 jinja2-3.1.6 jsonschema-4.26.0 jsonschema-specifications-2025.9.1 mypy-extensions-1.1.0 packaging-26.0 pathspec-1.0.4 platformdirs-4.9.4 pycparser-3.0 pytokens-0.4.1 pyyaml-6.0.3 referencing-0.37.0 resolvelib-1.2.1 rpds-py-0.30.0 ruamel-yaml-0.19.1 ruamel-yaml-clib-0.2.15 subprocess-tee-0.4.2 typing-extensions-4.15.0 wcmatch-10.1 yamllint-1.38.0 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3132093Z ##[group]Run set -euo pipefail +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3132406Z set -euo pipefail +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3132646Z . "ansible/.venv-ci/bin/activate" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3133042Z ansible-galaxy collection install -r "ansible/requirements.yml" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3182494Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3182814Z env: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3182995Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3183248Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3183513Z DEPLOY_TAGS: app_deploy +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3183785Z pythonLocation: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3184216Z PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib/pkgconfig +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3184628Z Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3185008Z Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3185717Z Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3186089Z LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:23.3186397Z ##[endgroup] +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.0929765Z Starting galaxy collection install process +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.0931533Z Nothing to do. All requested collections are already installed. If you want to reinstall them, consider using `--force`. +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1336095Z ##[group]Run echo "/home/runner/work/DevOps-Core-S26/DevOps-Core-S26/ansible/.venv-ci/bin" >> "$GITHUB_PATH" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1336846Z echo "/home/runner/work/DevOps-Core-S26/DevOps-Core-S26/ansible/.venv-ci/bin" >> "$GITHUB_PATH" +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1385886Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1386212Z env: +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1386392Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1386668Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1386930Z DEPLOY_TAGS: app_deploy +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1387207Z pythonLocation: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1387607Z PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib/pkgconfig +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1388033Z Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1388398Z Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1388756Z Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1389121Z LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib +Ansible Lint Setup Ansible toolchain 2026-03-06T05:29:24.1389428Z ##[endgroup] +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1511346Z Prepare all required actions +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1552577Z ##[group]Run ./.github/actions/ansible-lint +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1552839Z with: +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1553022Z ansible-directory: ansible +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1553378Z vault-password: *** +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1553613Z playbook-glob: playbooks/*.yml +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1553834Z env: +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1554006Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1554240Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1554495Z DEPLOY_TAGS: app_deploy +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1554766Z pythonLocation: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1555562Z PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib/pkgconfig +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1555969Z Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1556338Z Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1556697Z Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1557060Z LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1557393Z ##[endgroup] +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1572559Z ##[group]Run set -euo pipefail +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1572828Z set -euo pipefail +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1573053Z umask 077 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1573263Z cleanup() { +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1573461Z  rm -f .vault_pass +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1573674Z } +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1573857Z trap cleanup EXIT +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1574064Z  +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1574274Z printf '%s\n' "$VAULT_PASSWORD" > .vault_pass +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1574564Z  +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1574750Z ansible-lint $PLAYBOOK_GLOB +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1575099Z ansible-playbook playbooks/provision.yml --syntax-check +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1575667Z ansible-playbook playbooks/deploy.yml --syntax-check +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1576063Z ansible-playbook playbooks/site.yml --syntax-check +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1619676Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1620023Z env: +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1620202Z ANSIBLE_DIRECTORY: ansible +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1620452Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1620726Z DEPLOY_TAGS: app_deploy +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1621008Z pythonLocation: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1621412Z PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib/pkgconfig +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1621812Z Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1622192Z Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1622568Z Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.12/x64 +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1622936Z LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.12/x64/lib +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1623292Z VAULT_PASSWORD: *** +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1623509Z PLAYBOOK_GLOB: playbooks/*.yml +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:24.1623738Z ##[endgroup] +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:29.8477378Z +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:30.0769311Z Passed: 0 failure(s), 0 warning(s) in 9 files processed of 9 encountered. Profile 'production' was required, and it passed. +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:30.6324210Z +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:30.6324827Z playbook: playbooks/provision.yml +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:31.1404326Z +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:31.1405013Z playbook: playbooks/deploy.yml +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:31.6590152Z +Ansible Lint Run lint and syntax checks 2026-03-06T05:29:31.6590785Z playbook: playbooks/site.yml +Ansible Lint Post Setup Ansible toolchain 2026-03-06T05:29:31.7061977Z Post job cleanup. +Ansible Lint Post Setup Ansible toolchain 2026-03-06T05:29:31.7645652Z Post job cleanup. +Ansible Lint Post Setup Ansible toolchain 2026-03-06T05:29:31.8932691Z Cache hit occurred on the primary key Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364, not saving cache. +Ansible Lint Post Setup Ansible toolchain 2026-03-06T05:29:31.9026958Z Post job cleanup. +Ansible Lint Post Checkout code 2026-03-06T05:29:32.0691853Z Post job cleanup. +Ansible Lint Post Checkout code 2026-03-06T05:29:32.1650325Z [command]/usr/bin/git version +Ansible Lint Post Checkout code 2026-03-06T05:29:32.1687355Z git version 2.53.0 +Ansible Lint Post Checkout code 2026-03-06T05:29:32.1730032Z Temporarily overriding HOME='/home/runner/work/_temp/6c20ae22-94d6-4e20-a088-1bc5b65e29e6' before making global git config changes +Ansible Lint Post Checkout code 2026-03-06T05:29:32.1731346Z Adding repository directory to the temporary git global config as a safe directory +Ansible Lint Post Checkout code 2026-03-06T05:29:32.1744172Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/DevOps-Core-S26/DevOps-Core-S26 +Ansible Lint Post Checkout code 2026-03-06T05:29:32.1779165Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +Ansible Lint Post Checkout code 2026-03-06T05:29:32.1812297Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +Ansible Lint Post Checkout code 2026-03-06T05:29:32.2056254Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +Ansible Lint Post Checkout code 2026-03-06T05:29:32.2077494Z http.https://github.com/.extraheader +Ansible Lint Post Checkout code 2026-03-06T05:29:32.2089800Z [command]/usr/bin/git config --local --unset-all http.https://github.com/.extraheader +Ansible Lint Post Checkout code 2026-03-06T05:29:32.2121225Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +Ansible Lint Post Checkout code 2026-03-06T05:29:32.2357989Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +Ansible Lint Post Checkout code 2026-03-06T05:29:32.2390371Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +Ansible Lint Complete job 2026-03-06T05:29:32.2730379Z Cleaning up orphan processes +Deploy Application Set up job 2026-03-06T05:29:57.6492178Z Current runner version: '2.332.0' +Deploy Application Set up job 2026-03-06T05:29:57.6500080Z Runner name: 'github-runner-s26' +Deploy Application Set up job 2026-03-06T05:29:57.6501133Z Runner group name: 'Default' +Deploy Application Set up job 2026-03-06T05:29:57.6502174Z Machine name: 'github-runner-s26' +Deploy Application Set up job 2026-03-06T05:29:57.6505769Z ##[group]GITHUB_TOKEN Permissions +Deploy Application Set up job 2026-03-06T05:29:57.6508764Z Contents: read +Deploy Application Set up job 2026-03-06T05:29:57.6509511Z Metadata: read +Deploy Application Set up job 2026-03-06T05:29:57.6510335Z ##[endgroup] +Deploy Application Set up job 2026-03-06T05:29:57.6513654Z Secret source: Actions +Deploy Application Set up job 2026-03-06T05:29:57.6514727Z Prepare workflow directory +Deploy Application Set up job 2026-03-06T05:29:57.6992670Z Prepare all required actions +Deploy Application Set up job 2026-03-06T05:29:57.7029327Z Getting action download info +Deploy Application Set up job 2026-03-06T05:29:58.7100830Z Download action repository 'actions/checkout@v4' (SHA:34e114876b0b11c390a56381ad16ebd13914f8d5) +Deploy Application Set up job 2026-03-06T05:29:59.9331090Z Download action repository 'actions/upload-artifact@v4' (SHA:ea165f8d65b6e75b540449e92b4886f43607fa02) +Deploy Application Set up job 2026-03-06T05:30:04.4739595Z Complete job name: Deploy Application +Deploy Application Checkout code 2026-03-06T05:30:04.5258726Z ##[group]Run actions/checkout@v4 +Deploy Application Checkout code 2026-03-06T05:30:04.5259351Z with: +Deploy Application Checkout code 2026-03-06T05:30:04.5259658Z repository: LocalT0aster/DevOps-Core-S26 +Deploy Application Checkout code 2026-03-06T05:30:04.5260233Z token: *** +Deploy Application Checkout code 2026-03-06T05:30:04.5260506Z ssh-strict: true +Deploy Application Checkout code 2026-03-06T05:30:04.5260779Z ssh-user: git +Deploy Application Checkout code 2026-03-06T05:30:04.5261051Z persist-credentials: true +Deploy Application Checkout code 2026-03-06T05:30:04.5261350Z clean: true +Deploy Application Checkout code 2026-03-06T05:30:04.5261712Z sparse-checkout-cone-mode: true +Deploy Application Checkout code 2026-03-06T05:30:04.5283182Z fetch-depth: 1 +Deploy Application Checkout code 2026-03-06T05:30:04.5283514Z fetch-tags: false +Deploy Application Checkout code 2026-03-06T05:30:04.5283905Z show-progress: true +Deploy Application Checkout code 2026-03-06T05:30:04.5284216Z lfs: false +Deploy Application Checkout code 2026-03-06T05:30:04.5284495Z submodules: false +Deploy Application Checkout code 2026-03-06T05:30:04.5284768Z set-safe-directory: true +Deploy Application Checkout code 2026-03-06T05:30:04.5285558Z env: +Deploy Application Checkout code 2026-03-06T05:30:04.5285829Z ANSIBLE_DIRECTORY: ansible +Deploy Application Checkout code 2026-03-06T05:30:04.5286151Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Checkout code 2026-03-06T05:30:04.5286485Z DEPLOY_TAGS: app_deploy +Deploy Application Checkout code 2026-03-06T05:30:04.5286765Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:04.6162086Z Syncing repository: LocalT0aster/DevOps-Core-S26 +Deploy Application Checkout code 2026-03-06T05:30:04.6172832Z ##[group]Getting Git version info +Deploy Application Checkout code 2026-03-06T05:30:04.6173515Z Working directory is '/opt/actions-runner/_work/DevOps-Core-S26/DevOps-Core-S26' +Deploy Application Checkout code 2026-03-06T05:30:04.6174360Z [command]/usr/bin/git version +Deploy Application Checkout code 2026-03-06T05:30:04.6174679Z git version 2.52.0 +Deploy Application Checkout code 2026-03-06T05:30:04.6189251Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:04.6201711Z Temporarily overriding HOME='/opt/actions-runner/_work/_temp/02e2583d-1f43-4511-ab95-a17dab0dc139' before making global git config changes +Deploy Application Checkout code 2026-03-06T05:30:04.6202668Z Adding repository directory to the temporary git global config as a safe directory +Deploy Application Checkout code 2026-03-06T05:30:04.6226851Z [command]/usr/bin/git config --global --add safe.directory /opt/actions-runner/_work/DevOps-Core-S26/DevOps-Core-S26 +Deploy Application Checkout code 2026-03-06T05:30:04.6268709Z [command]/usr/bin/git config --local --get remote.origin.url +Deploy Application Checkout code 2026-03-06T05:30:04.6289926Z https://github.com/LocalT0aster/DevOps-Core-S26 +Deploy Application Checkout code 2026-03-06T05:30:04.6302713Z ##[group]Removing previously created refs, to avoid conflicts +Deploy Application Checkout code 2026-03-06T05:30:04.6306437Z [command]/usr/bin/git rev-parse --symbolic-full-name --verify --quiet HEAD +Deploy Application Checkout code 2026-03-06T05:30:04.6335472Z refs/heads/lab06 +Deploy Application Checkout code 2026-03-06T05:30:04.6360933Z [command]/usr/bin/git checkout --detach +Deploy Application Checkout code 2026-03-06T05:30:04.6361427Z HEAD is now at a55c6b2 fix: rebuild ansible ci venv on each run +Deploy Application Checkout code 2026-03-06T05:30:04.6399727Z [command]/usr/bin/git branch --delete --force lab06 +Deploy Application Checkout code 2026-03-06T05:30:04.6419714Z Deleted branch lab06 (was a55c6b2). +Deploy Application Checkout code 2026-03-06T05:30:04.6444500Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:04.6447185Z [command]/usr/bin/git submodule status +Deploy Application Checkout code 2026-03-06T05:30:04.6599060Z ##[group]Cleaning the repository +Deploy Application Checkout code 2026-03-06T05:30:04.6599701Z [command]/usr/bin/git clean -ffdx +Deploy Application Checkout code 2026-03-06T05:30:04.7382400Z Removing ansible/.venv-ci/ +Deploy Application Checkout code 2026-03-06T05:30:04.7399074Z [command]/usr/bin/git reset --hard HEAD +Deploy Application Checkout code 2026-03-06T05:30:04.7435602Z HEAD is now at a55c6b2 fix: rebuild ansible ci venv on each run +Deploy Application Checkout code 2026-03-06T05:30:04.7439527Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:04.7441304Z ##[group]Disabling automatic garbage collection +Deploy Application Checkout code 2026-03-06T05:30:04.7446972Z [command]/usr/bin/git config --local gc.auto 0 +Deploy Application Checkout code 2026-03-06T05:30:04.7478554Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:04.7479110Z ##[group]Setting up auth +Deploy Application Checkout code 2026-03-06T05:30:04.7486602Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +Deploy Application Checkout code 2026-03-06T05:30:04.7524555Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +Deploy Application Checkout code 2026-03-06T05:30:04.7696597Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +Deploy Application Checkout code 2026-03-06T05:30:04.7718808Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +Deploy Application Checkout code 2026-03-06T05:30:05.1868602Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +Deploy Application Checkout code 2026-03-06T05:30:05.1870199Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +Deploy Application Checkout code 2026-03-06T05:30:05.1871124Z [command]/usr/bin/git config --local http.https://github.com/.extraheader AUTHORIZATION: basic *** +Deploy Application Checkout code 2026-03-06T05:30:05.1871927Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:05.1872274Z ##[group]Fetching the repository +Deploy Application Checkout code 2026-03-06T05:30:05.1872929Z [command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +2492c7d27ac02a50f12e2ca7f51bc1d7882b8489:refs/remotes/origin/lab06 +Deploy Application Checkout code 2026-03-06T05:30:05.4238858Z From https://github.com/LocalT0aster/DevOps-Core-S26 +Deploy Application Checkout code 2026-03-06T05:30:05.4240195Z + a55c6b2...2492c7d 2492c7d27ac02a50f12e2ca7f51bc1d7882b8489 -> origin/lab06 (forced update) +Deploy Application Checkout code 2026-03-06T05:30:05.4253108Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:05.4253515Z ##[group]Determining the checkout info +Deploy Application Checkout code 2026-03-06T05:30:05.4253862Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:05.4254090Z [command]/usr/bin/git sparse-checkout disable +Deploy Application Checkout code 2026-03-06T05:30:05.4319076Z [command]/usr/bin/git config --local --unset-all extensions.worktreeConfig +Deploy Application Checkout code 2026-03-06T05:30:05.4355659Z ##[group]Checking out the ref +Deploy Application Checkout code 2026-03-06T05:30:05.4357258Z [command]/usr/bin/git checkout --progress --force -B lab06 refs/remotes/origin/lab06 +Deploy Application Checkout code 2026-03-06T05:30:05.4395770Z Warning: you are leaving 1 commit behind, not connected to +Deploy Application Checkout code 2026-03-06T05:30:05.4397071Z any of your branches: +Deploy Application Checkout code 2026-03-06T05:30:05.4397250Z +Deploy Application Checkout code 2026-03-06T05:30:05.4397372Z a55c6b2 fix: rebuild ansible ci venv on each run +Deploy Application Checkout code 2026-03-06T05:30:05.4397551Z +Deploy Application Checkout code 2026-03-06T05:30:05.4397727Z If you want to keep it by creating a new branch, this may be a good time +Deploy Application Checkout code 2026-03-06T05:30:05.4398006Z to do so with: +Deploy Application Checkout code 2026-03-06T05:30:05.4398107Z +Deploy Application Checkout code 2026-03-06T05:30:05.4398222Z git branch a55c6b2 +Deploy Application Checkout code 2026-03-06T05:30:05.4398368Z +Deploy Application Checkout code 2026-03-06T05:30:05.4401993Z Switched to a new branch 'lab06' +Deploy Application Checkout code 2026-03-06T05:30:05.4406524Z branch 'lab06' set up to track 'origin/lab06'. +Deploy Application Checkout code 2026-03-06T05:30:05.4409674Z ##[endgroup] +Deploy Application Checkout code 2026-03-06T05:30:05.4436197Z [command]/usr/bin/git log -1 --format=%H +Deploy Application Checkout code 2026-03-06T05:30:05.4451707Z 2492c7d27ac02a50f12e2ca7f51bc1d7882b8489 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:05.4663275Z Prepare all required actions +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:05.4663720Z Getting action download info +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:05.7487960Z Download action repository 'actions/setup-python@v5' (SHA:a26af69be951a213d495a4c3e4e4022e16d87065) +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:09.0415725Z Download action repository 'actions/cache@v4' (SHA:0057852bfaa89a56745cba8c7296529d2fc39830) +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5880420Z ##[group]Run ./.github/actions/ansible-setup +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5880698Z with: +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5880863Z python-version: 3.12 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5881059Z working-directory: ansible +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5881319Z python-requirements-path: ansible/requirements-ci.txt +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5881672Z collection-requirements-path: ansible/requirements.yml +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5881948Z env: +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5882110Z ANSIBLE_DIRECTORY: ansible +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5882411Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5882818Z DEPLOY_TAGS: app_deploy +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5883002Z ##[endgroup] +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5991311Z ##[group]Run actions/setup-python@v5 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5991626Z with: +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5991785Z python-version: 3.12 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5991981Z check-latest: false +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5992382Z token: *** +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5992626Z update-environment: true +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5992839Z allow-prereleases: false +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5993026Z freethreaded: false +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5993367Z env: +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5993524Z ANSIBLE_DIRECTORY: ansible +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5993726Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5993962Z DEPLOY_TAGS: app_deploy +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.5994142Z ##[endgroup] +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7393979Z ##[group]Installed versions +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7434733Z Successfully set up CPython (3.12.13) +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7436487Z ##[endgroup] +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7957480Z ##[group]Run actions/cache@v4 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7957724Z with: +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7957975Z path: ~/.cache/pip +Deploy Application Setup Ansible toolchain ~/.ansible/collections +Deploy Application Setup Ansible toolchain +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7958375Z key: Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7958770Z restore-keys: Linux-py3.12-ansible- +Deploy Application Setup Ansible toolchain +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7959012Z enableCrossOsArchive: false +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7959210Z fail-on-cache-miss: false +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7959399Z lookup-only: false +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7959567Z save-always: false +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7959726Z env: +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7959881Z ANSIBLE_DIRECTORY: ansible +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7960079Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7960298Z DEPLOY_TAGS: app_deploy +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7960561Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7960944Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7961317Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7961663Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7962001Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7962559Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:13.7963084Z ##[endgroup] +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:14.4043248Z Cache hit for: Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364 +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:16.4773003Z Received 0 of 16876233 (0.0%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:17.4766920Z Received 0 of 16876233 (0.0%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:18.4769115Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:19.4773453Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:20.4771646Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:21.4765071Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:22.4774139Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:23.4776252Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:24.4785122Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:25.4782607Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:26.4783331Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:27.4786680Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:28.4791776Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:29.4805527Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:30.4799815Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:31.4800143Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:32.4799538Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:33.4800713Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:34.4799954Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:35.4798905Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:36.4805551Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:37.4820057Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:38.4819188Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:39.4827866Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:40.4828823Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:41.4833593Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:42.4832544Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:43.4837095Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:44.4843083Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:45.4846837Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:46.4855272Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:47.4858765Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:48.4860108Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:49.4862180Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:50.4867065Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:51.4866433Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:52.4864504Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:53.4860951Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:54.4862233Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:55.4862078Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:56.4862231Z Received 99017 of 16876233 (0.6%), 0.0 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:57.4862363Z Received 4293321 of 16876233 (25.4%), 0.1 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:58.4866137Z Received 4293321 of 16876233 (25.4%), 0.1 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:30:59.4877504Z Received 8487625 of 16876233 (50.3%), 0.2 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:00.4885835Z Received 8487625 of 16876233 (50.3%), 0.2 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:01.4890920Z Received 8487625 of 16876233 (50.3%), 0.2 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:02.4895700Z Received 8487625 of 16876233 (50.3%), 0.2 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:03.4901206Z Received 8487625 of 16876233 (50.3%), 0.2 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3205436Z Received 16876233 of 16876233 (100.0%), 0.3 MBs/sec +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3206066Z Cache Size: ~16 MB (16876233 B) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3225913Z [command]/usr/bin/tar -xf /opt/actions-runner/_work/_temp/2ef53ebd-2583-4392-acbe-fba664f93a07/cache.tzst -P -C /opt/actions-runner/_work/DevOps-Core-S26/DevOps-Core-S26 --use-compress-program unzstd +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3692804Z Cache restored successfully +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3761111Z Cache restored from key: Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3836550Z ##[group]Run set -euo pipefail +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3836883Z set -euo pipefail +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3837088Z rm -rf "ansible/.venv-ci" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3837324Z python -m venv "ansible/.venv-ci" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3837570Z . "ansible/.venv-ci/bin/activate" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3837812Z python -m pip install --upgrade pip +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3838110Z python -m pip install -r "ansible/requirements-ci.txt" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3850274Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3850741Z env: +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3850906Z ANSIBLE_DIRECTORY: ansible +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3851125Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3851360Z DEPLOY_TAGS: app_deploy +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3851632Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3852014Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3852397Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3852748Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3853097Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3853450Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:04.3853731Z ##[endgroup] +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:06.5334681Z Requirement already satisfied: pip in ./ansible/.venv-ci/lib/python3.12/site-packages (25.0.1) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9460353Z Collecting pip +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9471527Z Using cached pip-26.0.1-py3-none-any.whl.metadata (4.7 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9495739Z Using cached pip-26.0.1-py3-none-any.whl (1.8 MB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9631733Z Installing collected packages: pip +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9633426Z Attempting uninstall: pip +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9651606Z Found existing installation: pip 25.0.1 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9891716Z Uninstalling pip-25.0.1: +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:13.9931126Z Successfully uninstalled pip-25.0.1 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:14.6728555Z Successfully installed pip-26.0.1 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.2284637Z Collecting ansible-core<2.20,>=2.16 (from -r ansible/requirements-ci.txt (line 1)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.2295418Z Using cached ansible_core-2.19.7-py3-none-any.whl.metadata (7.7 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.3078820Z Collecting ansible-lint==26.3.0 (from -r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.3088678Z Using cached ansible_lint-26.3.0-py3-none-any.whl.metadata (6.2 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.3781496Z Collecting ansible-compat>=25.8.2 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.3790856Z Using cached ansible_compat-25.12.1-py3-none-any.whl.metadata (3.4 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.4707132Z Collecting black>=24.3.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.4716630Z Using cached black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (88 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.6128224Z Collecting cffi>=1.15.1 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.6137764Z Using cached cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.6 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.7927015Z Collecting cryptography>=37 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.7937012Z Using cached cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl.metadata (5.7 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.8671128Z Collecting distro>=1.9.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.8682256Z Using cached distro-1.9.0-py3-none-any.whl.metadata (6.8 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.9373489Z Collecting filelock>=3.8.2 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:15.9386611Z Using cached filelock-3.25.0-py3-none-any.whl.metadata (2.0 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.0109744Z Collecting jsonschema>=4.10.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.0120324Z Using cached jsonschema-4.26.0-py3-none-any.whl.metadata (7.6 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.0792137Z Collecting packaging>=22.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.0801561Z Using cached packaging-26.0-py3-none-any.whl.metadata (3.3 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.1437771Z Collecting pathspec<1.1.0,>=1.0.3 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.1452500Z Using cached pathspec-1.0.4-py3-none-any.whl.metadata (13 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.2274470Z Collecting pyyaml>=6.0.1 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.2288415Z Using cached pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.4 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.2980758Z Collecting referencing>=0.36.2 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.2990421Z Using cached referencing-0.37.0-py3-none-any.whl.metadata (2.8 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.4413848Z Collecting ruamel-yaml>=0.18.11 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.4431007Z Using cached ruamel_yaml-0.19.1-py3-none-any.whl.metadata (16 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.5318734Z Collecting ruamel-yaml-clib>=0.2.12 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.5330098Z Using cached ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (3.5 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.5966352Z Collecting subprocess-tee>=0.4.1 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.5975349Z Using cached subprocess_tee-0.4.2-py3-none-any.whl.metadata (3.3 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.6638567Z Collecting wcmatch>=8.5.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.6651187Z Using cached wcmatch-10.1-py3-none-any.whl.metadata (5.1 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.7312397Z Collecting yamllint>=1.38.0 (from ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.7323026Z Using cached yamllint-1.38.0-py3-none-any.whl.metadata (4.2 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.7987117Z Collecting jinja2>=3.1.0 (from ansible-core<2.20,>=2.16->-r ansible/requirements-ci.txt (line 1)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.7997263Z Using cached jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.8639164Z Collecting resolvelib<2.0.0,>=0.5.3 (from ansible-core<2.20,>=2.16->-r ansible/requirements-ci.txt (line 1)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.8651084Z Using cached resolvelib-1.2.1-py3-none-any.whl.metadata (3.7 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.9362504Z Collecting click>=8.0.0 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:16.9375445Z Using cached click-8.3.1-py3-none-any.whl.metadata (2.6 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.0012448Z Collecting mypy-extensions>=0.4.3 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.0024237Z Using cached mypy_extensions-1.1.0-py3-none-any.whl.metadata (1.1 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.0715338Z Collecting platformdirs>=2 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.0726463Z Using cached platformdirs-4.9.4-py3-none-any.whl.metadata (4.7 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.1416270Z Collecting pytokens>=0.3.0 (from black>=24.3.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.1425142Z Using cached pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (3.8 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.2067636Z Collecting pycparser (from cffi>=1.15.1->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.2082912Z Using cached pycparser-3.0-py3-none-any.whl.metadata (8.2 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.3010526Z Collecting MarkupSafe>=2.0 (from jinja2>=3.1.0->ansible-core<2.20,>=2.16->-r ansible/requirements-ci.txt (line 1)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.3019786Z Using cached markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.7 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.4000818Z Collecting attrs>=22.2.0 (from jsonschema>=4.10.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.4016296Z Using cached attrs-25.4.0-py3-none-any.whl.metadata (10 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.4695332Z Collecting jsonschema-specifications>=2023.03.6 (from jsonschema>=4.10.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.4707000Z Using cached jsonschema_specifications-2025.9.1-py3-none-any.whl.metadata (2.9 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.7147396Z Collecting rpds-py>=0.25.0 (from jsonschema>=4.10.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.7156027Z Using cached rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.1 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.7938498Z Collecting typing-extensions>=4.4.0 (from referencing>=0.36.2->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.7949097Z Using cached typing_extensions-4.15.0-py3-none-any.whl.metadata (3.3 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8610682Z Collecting bracex>=2.1.1 (from wcmatch>=8.5.0->ansible-lint==26.3.0->-r ansible/requirements-ci.txt (line 2)) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8620246Z Using cached bracex-2.6-py3-none-any.whl.metadata (3.6 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8657240Z Using cached ansible_lint-26.3.0-py3-none-any.whl (330 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8667064Z Using cached ansible_core-2.19.7-py3-none-any.whl (2.4 MB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8682968Z Using cached pathspec-1.0.4-py3-none-any.whl (55 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8690638Z Using cached resolvelib-1.2.1-py3-none-any.whl (18 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8698234Z Using cached ansible_compat-25.12.1-py3-none-any.whl (27 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8705759Z Using cached black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (1.8 MB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8718422Z Using cached cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (219 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8726345Z Using cached click-8.3.1-py3-none-any.whl (108 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8735000Z Using cached cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl (4.5 MB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8755686Z Using cached distro-1.9.0-py3-none-any.whl (20 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8763372Z Using cached filelock-3.25.0-py3-none-any.whl (26 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8770674Z Using cached jinja2-3.1.6-py3-none-any.whl (134 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8778285Z Using cached jsonschema-4.26.0-py3-none-any.whl (90 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8786395Z Using cached attrs-25.4.0-py3-none-any.whl (67 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8793893Z Using cached jsonschema_specifications-2025.9.1-py3-none-any.whl (18 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8801335Z Using cached markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (22 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8810007Z Using cached mypy_extensions-1.1.0-py3-none-any.whl (5.0 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8818082Z Using cached packaging-26.0-py3-none-any.whl (74 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8827093Z Using cached platformdirs-4.9.4-py3-none-any.whl (21 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8835125Z Using cached pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (269 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8843675Z Using cached pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (807 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8853576Z Using cached referencing-0.37.0-py3-none-any.whl (26 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8861708Z Using cached rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (394 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8870267Z Using cached ruamel_yaml-0.19.1-py3-none-any.whl (118 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8879101Z Using cached ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (788 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8888596Z Using cached subprocess_tee-0.4.2-py3-none-any.whl (5.2 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8897105Z Using cached typing_extensions-4.15.0-py3-none-any.whl (44 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8905400Z Using cached wcmatch-10.1-py3-none-any.whl (39 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8912148Z Using cached bracex-2.6-py3-none-any.whl (11 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8920436Z Using cached yamllint-1.38.0-py3-none-any.whl (68 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.8929741Z Using cached pycparser-3.0-py3-none-any.whl (48 kB) +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:17.9681888Z Installing collected packages: typing-extensions, subprocess-tee, ruamel-yaml-clib, ruamel-yaml, rpds-py, resolvelib, pyyaml, pytokens, pycparser, platformdirs, pathspec, packaging, mypy-extensions, MarkupSafe, filelock, distro, click, bracex, attrs, yamllint, wcmatch, referencing, jinja2, cffi, black, jsonschema-specifications, cryptography, jsonschema, ansible-core, ansible-compat, ansible-lint +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.4801930Z +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.4827204Z Successfully installed MarkupSafe-3.0.3 ansible-compat-25.12.1 ansible-core-2.19.7 ansible-lint-26.3.0 attrs-25.4.0 black-26.1.0 bracex-2.6 cffi-2.0.0 click-8.3.1 cryptography-46.0.5 distro-1.9.0 filelock-3.25.0 jinja2-3.1.6 jsonschema-4.26.0 jsonschema-specifications-2025.9.1 mypy-extensions-1.1.0 packaging-26.0 pathspec-1.0.4 platformdirs-4.9.4 pycparser-3.0 pytokens-0.4.1 pyyaml-6.0.3 referencing-0.37.0 resolvelib-1.2.1 rpds-py-0.30.0 ruamel-yaml-0.19.1 ruamel-yaml-clib-0.2.15 subprocess-tee-0.4.2 typing-extensions-4.15.0 wcmatch-10.1 yamllint-1.38.0 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7108789Z ##[group]Run set -euo pipefail +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7109088Z set -euo pipefail +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7109301Z . "ansible/.venv-ci/bin/activate" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7109613Z ansible-galaxy collection install -r "ansible/requirements.yml" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7118701Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7118978Z env: +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7119140Z ANSIBLE_DIRECTORY: ansible +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7119362Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7119585Z DEPLOY_TAGS: app_deploy +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7119866Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7120262Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7120665Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7121032Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7121412Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7121795Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.7122083Z ##[endgroup] +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.8582727Z [WARNING]: Deprecation warnings can be disabled by setting `deprecation_warnings=False` in ansible.cfg. +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.8585490Z [DEPRECATION WARNING]: DEFAULT_MANAGED_STR option. Reason: The `ansible_managed` variable can be set just like any other variable, or a different variable can be used. +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.8586598Z Alternatives: Set the `ansible_managed` variable, or use any custom variable in templates. This feature will be removed from ansible-core version 2.23. +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:20.8587575Z +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.0412152Z Starting galaxy collection install process +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.0413773Z Nothing to do. All requested collections are already installed. If you want to reinstall them, consider using `--force`. +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1165007Z ##[group]Run echo "/opt/actions-runner/_work/DevOps-Core-S26/DevOps-Core-S26/ansible/.venv-ci/bin" >> "$GITHUB_PATH" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1165671Z echo "/opt/actions-runner/_work/DevOps-Core-S26/DevOps-Core-S26/ansible/.venv-ci/bin" >> "$GITHUB_PATH" +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1174955Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1175220Z env: +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1175388Z ANSIBLE_DIRECTORY: ansible +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1175598Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1175823Z DEPLOY_TAGS: app_deploy +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1176090Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1176475Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1176862Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1177206Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1177576Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1177923Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Setup Ansible toolchain 2026-03-06T05:31:21.1178380Z ##[endgroup] +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1279754Z ##[group]Run set -euo pipefail +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1280229Z set -euo pipefail +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1280543Z target_host="$( +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1280903Z  awk ' +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1281137Z  /^[[:space:]]*#/ { next } +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1281415Z  /^\[/ { next } +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1281695Z  NF { +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1281992Z  for (i = 1; i <= NF; i++) { +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1282367Z  if ($i ~ /^ansible_host=/) { +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1282691Z  split($i, value, "=") +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1283073Z  print value[2] +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1283403Z  exit +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1283692Z  } +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1284191Z  } +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1284449Z  } +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1284725Z  ' inventory/hosts.ini +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1285173Z )" +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1285419Z  +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1285729Z if [ -z "$target_host" ]; then +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1286258Z  echo "Could not determine ansible_host from inventory/hosts.ini" >&2 +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1286775Z  exit 1 +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1287030Z fi +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1287277Z  +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1287612Z echo "TARGET_VM_HOST=$target_host" >> "$GITHUB_ENV" +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1304058Z shell: /usr/bin/bash -e {0} +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1304401Z env: +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1304663Z ANSIBLE_DIRECTORY: ansible +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1305177Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1305563Z DEPLOY_TAGS: app_deploy +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1305969Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1306624Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1307275Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1307883Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1308559Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1309167Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Resolve target host from inventory 2026-03-06T05:31:21.1309639Z ##[endgroup] +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1398964Z Prepare all required actions +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1425869Z ##[group]Run ./.github/actions/ansible-ssh-setup +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1426139Z with: +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1427741Z ssh-private-key: *** +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1427946Z known-host: 192.168.121.50 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1428487Z ssh-key-path: ~/.ssh/vagrant +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1428672Z env: +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1428876Z ANSIBLE_DIRECTORY: ansible +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1429141Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1429488Z DEPLOY_TAGS: app_deploy +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1430020Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1430557Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1431104Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1431573Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1432190Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1432833Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1433368Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1433635Z ##[endgroup] +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1450702Z ##[group]Run set -euo pipefail +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1451087Z set -euo pipefail +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1451359Z  +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1451627Z key_path="${SSH_KEY_PATH/#\~/$HOME}" +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1451990Z  +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1452214Z install -d -m 700 "$HOME/.ssh" +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1452546Z install -d -m 700 "$(dirname "$key_path")" +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1452831Z printf '%s\n' "$SSH_PRIVATE_KEY" > "$key_path" +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1453085Z chmod 600 "$key_path" +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1453270Z  +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1453450Z touch "$HOME/.ssh/known_hosts" +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1453777Z chmod 600 "$HOME/.ssh/known_hosts" +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1453991Z  +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1454160Z if [ -n "$KNOWN_HOST" ]; then +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1454476Z  ssh-keyscan -H "$KNOWN_HOST" >> "$HOME/.ssh/known_hosts" 2>/dev/null || true +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1454781Z fi +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1464672Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1465104Z env: +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1465271Z ANSIBLE_DIRECTORY: ansible +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1465655Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1465883Z DEPLOY_TAGS: app_deploy +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1466159Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1466572Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1466981Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1467375Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1467736Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1468184Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1468502Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1470330Z SSH_PRIVATE_KEY: *** +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1470552Z SSH_KEY_PATH: ~/.ssh/vagrant +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1470761Z KNOWN_HOST: 192.168.121.50 +Deploy Application Configure SSH access to the target VM 2026-03-06T05:31:21.1470958Z ##[endgroup] +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3636008Z ##[group]Run set -euo pipefail +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3636331Z set -euo pipefail +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3636526Z umask 077 +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3636742Z printf '%s\n' "$VAULT_PASSWORD" > .vault_pass +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3647110Z shell: /usr/bin/bash -e {0} +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3647418Z env: +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3647583Z ANSIBLE_DIRECTORY: ansible +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3647796Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3648097Z DEPLOY_TAGS: app_deploy +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3648379Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3648795Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3649207Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3649583Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3650192Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3650901Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3651195Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3651681Z VAULT_PASSWORD: *** +Deploy Application Prepare vault password file 2026-03-06T05:31:21.3651864Z ##[endgroup] +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3715635Z ##[group]Run ansible webservers -m ansible.builtin.ping +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3716023Z ansible webservers -m ansible.builtin.ping +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3725398Z shell: /usr/bin/bash -e {0} +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3725623Z env: +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3725787Z ANSIBLE_DIRECTORY: ansible +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3726022Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3726262Z DEPLOY_TAGS: app_deploy +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3726550Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3726964Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3727372Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3727847Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3728334Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3728701Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3729032Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Verify target connectivity 2026-03-06T05:31:21.3729236Z ##[endgroup] +Deploy Application Verify target connectivity 2026-03-06T05:31:22.7085708Z vagrant | SUCCESS => { +Deploy Application Verify target connectivity 2026-03-06T05:31:22.7086542Z "changed": false, +Deploy Application Verify target connectivity 2026-03-06T05:31:22.7086737Z "ping": "pong" +Deploy Application Verify target connectivity 2026-03-06T05:31:22.7086919Z } +Deploy Application Deploy web application 2026-03-06T05:31:22.7740839Z Prepare all required actions +Deploy Application Deploy web application 2026-03-06T05:31:22.7772377Z ##[group]Run ./.github/actions/ansible-deploy +Deploy Application Deploy web application 2026-03-06T05:31:22.7772622Z with: +Deploy Application Deploy web application 2026-03-06T05:31:22.7772795Z ansible-directory: ansible +Deploy Application Deploy web application 2026-03-06T05:31:22.7773016Z playbook-path: playbooks/deploy.yml +Deploy Application Deploy web application 2026-03-06T05:31:22.7773339Z vault-password: *** +Deploy Application Deploy web application 2026-03-06T05:31:22.7773529Z tags: app_deploy +Deploy Application Deploy web application 2026-03-06T05:31:22.7773717Z inventory-path: inventory/hosts.ini +Deploy Application Deploy web application 2026-03-06T05:31:22.7774033Z env: +Deploy Application Deploy web application 2026-03-06T05:31:22.7774189Z ANSIBLE_DIRECTORY: ansible +Deploy Application Deploy web application 2026-03-06T05:31:22.7774391Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Deploy web application 2026-03-06T05:31:22.7774667Z DEPLOY_TAGS: app_deploy +Deploy Application Deploy web application 2026-03-06T05:31:22.7775068Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7775475Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Deploy web application 2026-03-06T05:31:22.7775860Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7776208Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7776600Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7776974Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Deploy web application 2026-03-06T05:31:22.7777285Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Deploy web application 2026-03-06T05:31:22.7777521Z ##[endgroup] +Deploy Application Deploy web application 2026-03-06T05:31:22.7789074Z ##[group]Run set -euo pipefail +Deploy Application Deploy web application 2026-03-06T05:31:22.7789339Z set -euo pipefail +Deploy Application Deploy web application 2026-03-06T05:31:22.7789542Z umask 077 +Deploy Application Deploy web application 2026-03-06T05:31:22.7789713Z  +Deploy Application Deploy web application 2026-03-06T05:31:22.7789906Z log_path="${RUNNER_TEMP}/ansible-deploy.log" +Deploy Application Deploy web application 2026-03-06T05:31:22.7790139Z  +Deploy Application Deploy web application 2026-03-06T05:31:22.7790294Z cleanup() { +Deploy Application Deploy web application 2026-03-06T05:31:22.7790472Z  rm -f .vault_pass +Deploy Application Deploy web application 2026-03-06T05:31:22.7790651Z } +Deploy Application Deploy web application 2026-03-06T05:31:22.7790808Z trap cleanup EXIT +Deploy Application Deploy web application 2026-03-06T05:31:22.7790990Z  +Deploy Application Deploy web application 2026-03-06T05:31:22.7791180Z printf '%s\n' "$VAULT_PASSWORD" > .vault_pass +Deploy Application Deploy web application 2026-03-06T05:31:22.7791419Z  +Deploy Application Deploy web application 2026-03-06T05:31:22.7791726Z ansible-playbook "$PLAYBOOK_PATH" -i "$INVENTORY_PATH" --tags "$PLAYBOOK_TAGS" | tee "$log_path" +Deploy Application Deploy web application 2026-03-06T05:31:22.7792091Z  +Deploy Application Deploy web application 2026-03-06T05:31:22.7792278Z echo "log-path=$log_path" >> "$GITHUB_OUTPUT" +Deploy Application Deploy web application 2026-03-06T05:31:22.7802395Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Deploy Application Deploy web application 2026-03-06T05:31:22.7802669Z env: +Deploy Application Deploy web application 2026-03-06T05:31:22.7802840Z ANSIBLE_DIRECTORY: ansible +Deploy Application Deploy web application 2026-03-06T05:31:22.7803059Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Deploy web application 2026-03-06T05:31:22.7803284Z DEPLOY_TAGS: app_deploy +Deploy Application Deploy web application 2026-03-06T05:31:22.7803560Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7803946Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Deploy web application 2026-03-06T05:31:22.7804328Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7804676Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7805109Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Deploy web application 2026-03-06T05:31:22.7805452Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Deploy web application 2026-03-06T05:31:22.7805733Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Deploy web application 2026-03-06T05:31:22.7806020Z VAULT_PASSWORD: *** +Deploy Application Deploy web application 2026-03-06T05:31:22.7806213Z PLAYBOOK_PATH: playbooks/deploy.yml +Deploy Application Deploy web application 2026-03-06T05:31:22.7806433Z INVENTORY_PATH: inventory/hosts.ini +Deploy Application Deploy web application 2026-03-06T05:31:22.7806651Z PLAYBOOK_TAGS: app_deploy +Deploy Application Deploy web application 2026-03-06T05:31:22.7806832Z ##[endgroup] +Deploy Application Deploy web application 2026-03-06T05:31:23.1853694Z +Deploy Application Deploy web application 2026-03-06T05:31:23.1855725Z PLAY [Deploy application] ****************************************************** +Deploy Application Deploy web application 2026-03-06T05:31:23.1870137Z +Deploy Application Deploy web application 2026-03-06T05:31:23.1871854Z TASK [Gathering Facts] ********************************************************* +Deploy Application Deploy web application 2026-03-06T05:31:24.1988353Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:24.1991083Z +Deploy Application Deploy web application 2026-03-06T05:31:24.1991421Z TASK [Run web app role] ******************************************************** +Deploy Application Deploy web application 2026-03-06T05:31:24.2774549Z included: web_app for vagrant +Deploy Application Deploy web application 2026-03-06T05:31:24.2777912Z +Deploy Application Deploy web application 2026-03-06T05:31:24.2778295Z TASK [docker : Load docker role defaults] ************************************** +Deploy Application Deploy web application 2026-03-06T05:31:24.3006960Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:24.3007239Z +Deploy Application Deploy web application 2026-03-06T05:31:24.3007555Z TASK [docker : Install Docker prerequisites] *********************************** +Deploy Application Deploy web application 2026-03-06T05:31:37.6684351Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:37.6685233Z +Deploy Application Deploy web application 2026-03-06T05:31:37.6685698Z TASK [docker : Ensure Docker keyring directory exists] ************************* +Deploy Application Deploy web application 2026-03-06T05:31:38.0194162Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:38.0195223Z +Deploy Application Deploy web application 2026-03-06T05:31:38.0195503Z TASK [docker : Add Docker GPG key] ********************************************* +Deploy Application Deploy web application 2026-03-06T05:31:38.6477382Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:38.6478060Z +Deploy Application Deploy web application 2026-03-06T05:31:38.6478249Z TASK [docker : Add Docker apt repository] ************************************** +Deploy Application Deploy web application 2026-03-06T05:31:39.0961629Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:39.0962379Z +Deploy Application Deploy web application 2026-03-06T05:31:39.0962593Z TASK [docker : Install Docker engine packages] ********************************* +Deploy Application Deploy web application 2026-03-06T05:31:39.9038774Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:39.9039596Z +Deploy Application Deploy web application 2026-03-06T05:31:39.9039935Z TASK [docker : Install Docker Python SDK package] ****************************** +Deploy Application Deploy web application 2026-03-06T05:31:40.7113465Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:40.7114301Z +Deploy Application Deploy web application 2026-03-06T05:31:40.7114984Z TASK [docker : Mark Docker service as ready] *********************************** +Deploy Application Deploy web application 2026-03-06T05:31:40.7248008Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:40.7248187Z +Deploy Application Deploy web application 2026-03-06T05:31:40.7248370Z TASK [docker : Ensure Docker service is enabled and running] ******************* +Deploy Application Deploy web application 2026-03-06T05:31:41.2950461Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:41.2951270Z +Deploy Application Deploy web application 2026-03-06T05:31:41.2951998Z TASK [docker : Record Docker installation block completion] ******************** +Deploy Application Deploy web application 2026-03-06T05:31:41.6671862Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:41.6672474Z +Deploy Application Deploy web application 2026-03-06T05:31:41.6672884Z TASK [docker : Add deployment user to docker group] **************************** +Deploy Application Deploy web application 2026-03-06T05:31:42.1253486Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:42.1253807Z +Deploy Application Deploy web application 2026-03-06T05:31:42.1254165Z TASK [docker : Record Docker configuration block completion] ******************* +Deploy Application Deploy web application 2026-03-06T05:31:42.4014420Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:42.4015201Z +Deploy Application Deploy web application 2026-03-06T05:31:42.4015690Z TASK [web_app : Include web app wipe tasks] ************************************ +Deploy Application Deploy web application 2026-03-06T05:31:42.4227650Z included: /opt/actions-runner/_work/DevOps-Core-S26/DevOps-Core-S26/ansible/roles/web_app/tasks/wipe.yml for vagrant +Deploy Application Deploy web application 2026-03-06T05:31:42.4229296Z +Deploy Application Deploy web application 2026-03-06T05:31:42.4230444Z TASK [web_app : Log in to Docker Hub when credentials are available] *********** +Deploy Application Deploy web application 2026-03-06T05:31:43.0978245Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:43.0985222Z +Deploy Application Deploy web application 2026-03-06T05:31:43.0986015Z TASK [web_app : Ensure Compose project directory exists] *********************** +Deploy Application Deploy web application 2026-03-06T05:31:43.3761498Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:43.3761774Z +Deploy Application Deploy web application 2026-03-06T05:31:43.3762026Z TASK [web_app : Check for legacy standalone container] ************************* +Deploy Application Deploy web application 2026-03-06T05:31:44.0326329Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:44.0326547Z +Deploy Application Deploy web application 2026-03-06T05:31:44.0326808Z TASK [web_app : Remove legacy standalone container before Compose migration] *** +Deploy Application Deploy web application 2026-03-06T05:31:44.0522208Z skipping: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:44.0522462Z +Deploy Application Deploy web application 2026-03-06T05:31:44.0522727Z TASK [web_app : Template Docker Compose configuration] ************************* +Deploy Application Deploy web application 2026-03-06T05:31:44.6914512Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:44.6915400Z +Deploy Application Deploy web application 2026-03-06T05:31:44.6915611Z TASK [web_app : Deploy application stack with Docker Compose] ****************** +Deploy Application Deploy web application 2026-03-06T05:31:47.5675136Z ok: [vagrant] +Deploy Application Deploy web application 2026-03-06T05:31:47.5675772Z +Deploy Application Deploy web application 2026-03-06T05:31:47.5675983Z TASK [web_app : Wait for application port] ************************************* +Deploy Application Deploy web application 2026-03-06T05:31:48.8916776Z ok: [vagrant -> localhost] +Deploy Application Deploy web application 2026-03-06T05:31:48.8917460Z +Deploy Application Deploy web application 2026-03-06T05:31:48.8917655Z TASK [web_app : Verify application health endpoint] **************************** +Deploy Application Deploy web application 2026-03-06T05:31:49.3035774Z ok: [vagrant -> localhost] +Deploy Application Deploy web application 2026-03-06T05:31:49.3035978Z +Deploy Application Deploy web application 2026-03-06T05:31:49.3036114Z PLAY RECAP ********************************************************************* +Deploy Application Deploy web application 2026-03-06T05:31:49.3036827Z vagrant : ok=22 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +Deploy Application Deploy web application 2026-03-06T05:31:49.3037103Z +Deploy Application Upload deployment log 2026-03-06T05:31:49.3876767Z ##[group]Run actions/upload-artifact@v4 +Deploy Application Upload deployment log 2026-03-06T05:31:49.3877027Z with: +Deploy Application Upload deployment log 2026-03-06T05:31:49.3877263Z name: ansible-deploy-log +Deploy Application Upload deployment log 2026-03-06T05:31:49.3877536Z path: /opt/actions-runner/_work/_temp/ansible-deploy.log +Deploy Application Upload deployment log 2026-03-06T05:31:49.3878027Z if-no-files-found: warn +Deploy Application Upload deployment log 2026-03-06T05:31:49.3878218Z compression-level: 6 +Deploy Application Upload deployment log 2026-03-06T05:31:49.3878406Z overwrite: false +Deploy Application Upload deployment log 2026-03-06T05:31:49.3878581Z include-hidden-files: false +Deploy Application Upload deployment log 2026-03-06T05:31:49.3878774Z env: +Deploy Application Upload deployment log 2026-03-06T05:31:49.3878941Z ANSIBLE_DIRECTORY: ansible +Deploy Application Upload deployment log 2026-03-06T05:31:49.3879148Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Upload deployment log 2026-03-06T05:31:49.3879366Z DEPLOY_TAGS: app_deploy +Deploy Application Upload deployment log 2026-03-06T05:31:49.3879620Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Upload deployment log 2026-03-06T05:31:49.3879995Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Upload deployment log 2026-03-06T05:31:49.3880380Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Upload deployment log 2026-03-06T05:31:49.3880717Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Upload deployment log 2026-03-06T05:31:49.3881090Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Upload deployment log 2026-03-06T05:31:49.3881522Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Upload deployment log 2026-03-06T05:31:49.3881932Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Upload deployment log 2026-03-06T05:31:49.3882178Z ##[endgroup] +Deploy Application Upload deployment log 2026-03-06T05:31:49.5697737Z With the provided path, there will be 1 file uploaded +Deploy Application Upload deployment log 2026-03-06T05:31:49.5700524Z Artifact name is valid! +Deploy Application Upload deployment log 2026-03-06T05:31:49.5701410Z Root directory input is valid! +Deploy Application Upload deployment log 2026-03-06T05:31:50.0726944Z Beginning upload of artifact content to blob storage +Deploy Application Upload deployment log 2026-03-06T05:31:51.2153751Z Uploaded bytes 808 +Deploy Application Upload deployment log 2026-03-06T05:31:51.4759396Z Finished uploading artifact content to blob storage! +Deploy Application Upload deployment log 2026-03-06T05:31:51.4765601Z SHA256 digest of uploaded artifact zip is f81eaa1099002b69ff9e2cbf817f9266dd7ccfa0569af8cc9456757ad35a79e2 +Deploy Application Upload deployment log 2026-03-06T05:31:51.4766682Z Finalizing artifact upload +Deploy Application Upload deployment log 2026-03-06T05:31:51.7819447Z Artifact ansible-deploy-log.zip successfully finalized. Artifact ID 5792366004 +Deploy Application Upload deployment log 2026-03-06T05:31:51.7820077Z Artifact ansible-deploy-log has been successfully uploaded! Final size is 808 bytes. Artifact ID is 5792366004 +Deploy Application Upload deployment log 2026-03-06T05:31:51.7825632Z Artifact download URL: https://github.com/LocalT0aster/DevOps-Core-S26/actions/runs/22750506418/artifacts/5792366004 +Deploy Application Verify application health 2026-03-06T05:31:51.7906094Z Prepare all required actions +Deploy Application Verify application health 2026-03-06T05:31:51.7945544Z ##[group]Run ./.github/actions/http-healthcheck +Deploy Application Verify application health 2026-03-06T05:31:51.7945786Z with: +Deploy Application Verify application health 2026-03-06T05:31:51.7946000Z url: http://192.168.121.50:5000/health +Deploy Application Verify application health 2026-03-06T05:31:51.7946378Z retries: 10 +Deploy Application Verify application health 2026-03-06T05:31:51.7946541Z delay-seconds: 3 +Deploy Application Verify application health 2026-03-06T05:31:51.7946722Z jq-filter: .status == "healthy" +Deploy Application Verify application health 2026-03-06T05:31:51.7946918Z env: +Deploy Application Verify application health 2026-03-06T05:31:51.7947078Z ANSIBLE_DIRECTORY: ansible +Deploy Application Verify application health 2026-03-06T05:31:51.7947283Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Verify application health 2026-03-06T05:31:51.7947503Z DEPLOY_TAGS: app_deploy +Deploy Application Verify application health 2026-03-06T05:31:51.7947798Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7948210Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Verify application health 2026-03-06T05:31:51.7948588Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7948946Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7949312Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7949683Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Verify application health 2026-03-06T05:31:51.7949992Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Verify application health 2026-03-06T05:31:51.7950188Z ##[endgroup] +Deploy Application Verify application health 2026-03-06T05:31:51.7961664Z ##[group]Run set -euo pipefail +Deploy Application Verify application health 2026-03-06T05:31:51.7961940Z set -euo pipefail +Deploy Application Verify application health 2026-03-06T05:31:51.7962141Z  +Deploy Application Verify application health 2026-03-06T05:31:51.7962297Z response="" +Deploy Application Verify application health 2026-03-06T05:31:51.7962468Z  +Deploy Application Verify application health 2026-03-06T05:31:51.7962651Z for attempt in $(seq 1 "$RETRIES"); do +Deploy Application Verify application health 2026-03-06T05:31:51.7962922Z  if response="$(curl -fsSL "$URL")"; then +Deploy Application Verify application health 2026-03-06T05:31:51.7963152Z  break +Deploy Application Verify application health 2026-03-06T05:31:51.7963325Z  fi +Deploy Application Verify application health 2026-03-06T05:31:51.7963477Z  +Deploy Application Verify application health 2026-03-06T05:31:51.7963665Z  if [ "$attempt" -eq "$RETRIES" ]; then +Deploy Application Verify application health 2026-03-06T05:31:51.7964155Z  echo "Health check failed after $RETRIES attempts: $URL" >&2 +Deploy Application Verify application health 2026-03-06T05:31:51.7964541Z  exit 1 +Deploy Application Verify application health 2026-03-06T05:31:51.7964803Z  fi +Deploy Application Verify application health 2026-03-06T05:31:51.7965062Z  +Deploy Application Verify application health 2026-03-06T05:31:51.7965263Z  sleep "$DELAY_SECONDS" +Deploy Application Verify application health 2026-03-06T05:31:51.7965467Z done +Deploy Application Verify application health 2026-03-06T05:31:51.7965619Z  +Deploy Application Verify application health 2026-03-06T05:31:51.7965778Z echo "$response" | jq . +Deploy Application Verify application health 2026-03-06T05:31:51.7966048Z echo "$response" | jq -e "$JQ_FILTER" >/dev/null +Deploy Application Verify application health 2026-03-06T05:31:51.7975984Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +Deploy Application Verify application health 2026-03-06T05:31:51.7976317Z env: +Deploy Application Verify application health 2026-03-06T05:31:51.7976490Z ANSIBLE_DIRECTORY: ansible +Deploy Application Verify application health 2026-03-06T05:31:51.7976706Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Verify application health 2026-03-06T05:31:51.7977112Z DEPLOY_TAGS: app_deploy +Deploy Application Verify application health 2026-03-06T05:31:51.7977471Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7977899Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Verify application health 2026-03-06T05:31:51.7978322Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7978707Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7979071Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Verify application health 2026-03-06T05:31:51.7979436Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Verify application health 2026-03-06T05:31:51.7979733Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Verify application health 2026-03-06T05:31:51.7979968Z URL: http://192.168.121.50:5000/health +Deploy Application Verify application health 2026-03-06T05:31:51.7980186Z RETRIES: 10 +Deploy Application Verify application health 2026-03-06T05:31:51.7980346Z DELAY_SECONDS: 3 +Deploy Application Verify application health 2026-03-06T05:31:51.7980525Z JQ_FILTER: .status == "healthy" +Deploy Application Verify application health 2026-03-06T05:31:51.7980725Z ##[endgroup] +Deploy Application Verify application health 2026-03-06T05:31:51.8117339Z { +Deploy Application Verify application health 2026-03-06T05:31:51.8118416Z "status": "healthy", +Deploy Application Verify application health 2026-03-06T05:31:51.8119624Z "timestamp": "2026-03-06T05:31:51.856613+00:00", +Deploy Application Verify application health 2026-03-06T05:31:51.8125582Z "uptime_seconds": 7603 +Deploy Application Verify application health 2026-03-06T05:31:51.8126569Z } +Deploy Application Remove vault password file 2026-03-06T05:31:51.8173310Z ##[group]Run rm -f .vault_pass +Deploy Application Remove vault password file 2026-03-06T05:31:51.8173612Z rm -f .vault_pass +Deploy Application Remove vault password file 2026-03-06T05:31:51.8183658Z shell: /usr/bin/bash -e {0} +Deploy Application Remove vault password file 2026-03-06T05:31:51.8183890Z env: +Deploy Application Remove vault password file 2026-03-06T05:31:51.8184060Z ANSIBLE_DIRECTORY: ansible +Deploy Application Remove vault password file 2026-03-06T05:31:51.8184297Z DEPLOY_PLAYBOOK: playbooks/deploy.yml +Deploy Application Remove vault password file 2026-03-06T05:31:51.8184526Z DEPLOY_TAGS: app_deploy +Deploy Application Remove vault password file 2026-03-06T05:31:51.8184815Z pythonLocation: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Remove vault password file 2026-03-06T05:31:51.8185349Z PKG_CONFIG_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib/pkgconfig +Deploy Application Remove vault password file 2026-03-06T05:31:51.8185769Z Python_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Remove vault password file 2026-03-06T05:31:51.8186221Z Python2_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Remove vault password file 2026-03-06T05:31:51.8186587Z Python3_ROOT_DIR: /opt/actions-runner/_work/_tool/Python/3.12.13/x64 +Deploy Application Remove vault password file 2026-03-06T05:31:51.8186969Z LD_LIBRARY_PATH: /opt/actions-runner/_work/_tool/Python/3.12.13/x64/lib +Deploy Application Remove vault password file 2026-03-06T05:31:51.8187298Z TARGET_VM_HOST: 192.168.121.50 +Deploy Application Remove vault password file 2026-03-06T05:31:51.8187521Z ##[endgroup] +Deploy Application Post Setup Ansible toolchain 2026-03-06T05:31:51.8272725Z Post job cleanup. +Deploy Application Post Setup Ansible toolchain 2026-03-06T05:31:51.8758473Z Post job cleanup. +Deploy Application Post Setup Ansible toolchain 2026-03-06T05:31:51.9913680Z Cache hit occurred on the primary key Linux-py3.12-ansible-70fee6f2b98d7def1a2c43ddbf364d7b6b2648821ca185e0955c8d98e4cb9364, not saving cache. +Deploy Application Post Setup Ansible toolchain 2026-03-06T05:31:51.9977690Z Post job cleanup. +Deploy Application Post Checkout code 2026-03-06T05:31:52.1550112Z Post job cleanup. +Deploy Application Post Checkout code 2026-03-06T05:31:52.2320436Z [command]/usr/bin/git version +Deploy Application Post Checkout code 2026-03-06T05:31:52.2355214Z git version 2.52.0 +Deploy Application Post Checkout code 2026-03-06T05:31:52.2385866Z Temporarily overriding HOME='/opt/actions-runner/_work/_temp/92425419-0a79-44b9-9641-d9e6f2b4f52e' before making global git config changes +Deploy Application Post Checkout code 2026-03-06T05:31:52.2453359Z Adding repository directory to the temporary git global config as a safe directory +Deploy Application Post Checkout code 2026-03-06T05:31:52.2456877Z [command]/usr/bin/git config --global --add safe.directory /opt/actions-runner/_work/DevOps-Core-S26/DevOps-Core-S26 +Deploy Application Post Checkout code 2026-03-06T05:31:52.2458061Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +Deploy Application Post Checkout code 2026-03-06T05:31:52.2475428Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +Deploy Application Post Checkout code 2026-03-06T05:31:52.2676524Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +Deploy Application Post Checkout code 2026-03-06T05:31:52.2696268Z http.https://github.com/.extraheader +Deploy Application Post Checkout code 2026-03-06T05:31:52.2704496Z [command]/usr/bin/git config --local --unset-all http.https://github.com/.extraheader +Deploy Application Post Checkout code 2026-03-06T05:31:52.2731261Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +Deploy Application Post Checkout code 2026-03-06T05:31:52.2879233Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +Deploy Application Post Checkout code 2026-03-06T05:31:52.2903772Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +Deploy Application Complete job 2026-03-06T05:31:52.3147748Z Cleaning up orphan processes +``` + +
+ + +### Validation Status + +- The local Ansible side is already validated: `ansible-lint` passes, and the playbooks used by the workflow pass syntax checks. +- `vagrant validate` for the isolated runner VM passes. +- The GitHub Actions workflow completed successfully in run `22750506418`. +- The `Ansible Lint` and `Deploy Application` jobs both completed successfully. +- The deploy job resolved the target host from inventory, prepared `.vault_pass`, verified SSH connectivity with `ansible ping`, deployed the playbook, uploaded the deployment log artifact, checked `/health`, and removed `.vault_pass` afterwards. +- Two workflow defects were found and fixed during testing: stale self-hosted runner virtualenv caching and creating `.vault_pass` too late for the connectivity check. +- Pull requests from external forks are intentionally excluded from the secret-backed lint path, because vault decryption requires repository secrets. +- The workflow now has successful GitHub-side evidence, not just local validation. + +### Research Answers + +1. **What are the security implications of storing SSH keys in GitHub Secrets?** + - The main benefit is that the key is not committed to the repository, but it is still high-value material. Anyone who can modify a trusted workflow on the default branch can potentially exfiltrate it. The practical controls are branch protection, restricted workflow write access, least-privilege keys, and avoiding self-hosted execution on untrusted pull requests. + +2. **How would you implement a staging → production deployment pipeline?** + - I would split deployment into at least two environments, each with separate inventories, secrets, and GitHub environments. The workflow would deploy automatically to staging, run verification, and only then allow a protected manual approval gate for production. + +3. **What would you add to make rollbacks possible?** + - I would pin image tags to immutable versions instead of `latest`, persist the previously deployed tag, and add a rollback workflow input that redeploys the last known good version. For stronger rollback guarantees, I would also archive the exact Compose template and deployment metadata as workflow artifacts. + +4. **How does self-hosted runner improve security compared to GitHub-hosted?** + - In this lab's setup, the runner stays inside the local private network and can reach the VM directly without exposing SSH to the public internet. That reduces credential sprawl and keeps deployment traffic local. The tradeoff is that a self-hosted runner is persistent, so its trust boundary must be managed more carefully than GitHub-hosted ephemeral runners. + +## Task 5: Documentation + +This file now serves as the complete lab report for Lab 6. + +### Final Status + +- Task 1 blocks and tags are implemented and validated. +- Task 2 Docker Compose deployment is implemented, idempotent, and verified on the VM. +- Task 3 wipe logic is implemented and tested across the required scenarios. +- Task 4 GitHub Actions CI/CD is implemented and validated with successful workflow run `22750506418`. +- Supporting raw evidence files collected during the lab include `task1.log`, `task3.log`, and `task4.log`. diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..8750d6b89b --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,22 @@ +$ANSIBLE_VAULT;1.1;AES256 +39666562623135653036646261306635633634636232343333323866353531663931323438303138 +6633626630373139366232303730333766313330396266350a306530353938393263613230653865 +36316564363836633665366665363761333165373333303537353163376330376131636534636337 +6334623331643265330a373134326364376662643939396262313932326564316637666463633533 +63653666326265303566306331633165313161323130303962303734636638663832613039636361 +33666165613732336435376430396662613864363766303764353934333038636564316637656337 +32396261633039343635346439323532386665343065626438666265336335313433626562616261 +65663136613337326662643433316336346163643235303861613835366635623363396632643365 +65326466313366343530633163633434633365653462643438343730376339646363636239316139 +39306234353863666563393239343337396662383764333038386230363838353766626465616561 +35353262396465643135666436653938643836623862356539653463306434313862393435336535 +65346661316261363063623364356433663031626631386336636137353262653462636132626266 +33626530393235343039316139376339396366396135643864646236353365336536663264386534 +62653931396464636264333034373336666636643636653236356432663133633263656535386137 +32666362313331356532313963393230343332373931346638353463356333383963373131356537 +31633736363531366238346166336639303864376461313933303033306230623765633238633561 +32626463373235633461343365643135613561376634383536323131393932396362666430653633 +65336466323438636232386132653535623862333336666163346334303965376365353263663861 +33396363656361313134323063336638633639313038643636623631663336303632303462346235 +64626634653062663039393930316532326565663434633132306231306465336333633062396439 +6162 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..da56b99798 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,6 @@ +[webservers] +# Update host/user/key for your VM from Lab 4 +vagrant ansible_host=192.168.121.50 ansible_user=vagrant ansible_port=22 ansible_ssh_private_key_file=~/.ssh/vagrant + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy-monitoring.yml b/ansible/playbooks/deploy-monitoring.yml new file mode 100644 index 0000000000..97a00f34e2 --- /dev/null +++ b/ansible/playbooks/deploy-monitoring.yml @@ -0,0 +1,18 @@ +--- +- name: Deploy monitoring stack + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + + tasks: + - name: Run monitoring role + ansible.builtin.include_role: + name: monitoring + apply: + tags: + - monitoring + tags: + - monitoring + - monitoring_deploy + - compose diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..880c313fb7 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,21 @@ +--- +- name: Deploy application + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + + tasks: + - name: Run web app role + ansible.builtin.include_role: + name: web_app + tasks_from: web_app_tasks + defaults_from: web_app_defaults + apply: + tags: + - web_app + tags: + - web_app + - app_deploy + - compose + - web_app_wipe diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..cdb77ad22a --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,30 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + tasks: + - name: Run common role tasks/defaults + ansible.builtin.include_role: + name: common + tasks_from: common_tasks + defaults_from: common_defaults + apply: + tags: + - common + tags: + - common + - packages + - users + + - name: Run docker role tasks/defaults/handlers + ansible.builtin.include_role: + name: docker + defaults_from: docker_defaults + apply: + tags: + - docker + tags: + - docker + - docker_install + - docker_config diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..a80d09911d --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,34 @@ +--- +- name: Provision and deploy application + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + + tasks: + - name: Run common role tasks/defaults + ansible.builtin.include_role: + name: common + tasks_from: common_tasks + defaults_from: common_defaults + apply: + tags: + - common + tags: + - common + - packages + - users + + - name: Run web app role + ansible.builtin.include_role: + name: web_app + tasks_from: web_app_tasks + defaults_from: web_app_defaults + apply: + tags: + - web_app + tags: + - web_app + - app_deploy + - compose + - web_app_wipe diff --git a/ansible/requirements-ci.txt b/ansible/requirements-ci.txt new file mode 100644 index 0000000000..313cb46fde --- /dev/null +++ b/ansible/requirements-ci.txt @@ -0,0 +1,2 @@ +ansible-core>=2.16,<2.20 +ansible-lint==26.3.0 diff --git a/ansible/requirements-lint.txt b/ansible/requirements-lint.txt new file mode 100644 index 0000000000..3a71006dca --- /dev/null +++ b/ansible/requirements-lint.txt @@ -0,0 +1 @@ +ansible-lint==26.3.0 diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000000..660f775816 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,3 @@ +--- +collections: + - name: community.docker diff --git a/ansible/roles/common/defaults/common_defaults.yml b/ansible/roles/common/defaults/common_defaults.yml new file mode 100644 index 0000000000..c9f6d61424 --- /dev/null +++ b/ansible/roles/common/defaults/common_defaults.yml @@ -0,0 +1,19 @@ +--- +common_packages: + - ca-certificates + - curl + - git + - htop + - vim + - python3-pip + - python3-apt + - tzdata + +common_manage_timezone: true +common_timezone: "Etc/UTC" +common_completion_log_path: "/tmp/ansible-common-role.log" + +common_managed_users: + - name: "{{ ansible_user | default('ubuntu') }}" + shell: "/bin/bash" + create_home: true diff --git a/ansible/roles/common/tasks/common_tasks.yml b/ansible/roles/common/tasks/common_tasks.yml new file mode 100644 index 0000000000..a0a740aa0b --- /dev/null +++ b/ansible/roles/common/tasks/common_tasks.yml @@ -0,0 +1,89 @@ +--- +- name: Manage common packages + become: true + tags: + - packages + block: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + rescue: + - name: Mark common package rescue as triggered + ansible.builtin.set_fact: + common_packages_rescue_triggered: true + + - name: Refresh apt cache with fix-missing + ansible.builtin.command: + argv: + - apt-get + - update + - --fix-missing + changed_when: false + + - name: Retry apt cache update + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Retry common package installation + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + always: + - name: Record common packages block completion + ansible.builtin.lineinfile: + path: "{{ common_completion_log_path }}" + line: >- + packages block completed + (rescue_triggered={{ common_packages_rescue_triggered | default(false) }}) + create: true + mode: "0644" + +- name: Set /etc/timezone + ansible.builtin.copy: + dest: /etc/timezone + content: "{{ common_timezone }}\n" + owner: root + group: root + mode: "0644" + when: common_manage_timezone | bool + +- name: Point /etc/localtime to selected timezone + ansible.builtin.file: + src: "/usr/share/zoneinfo/{{ common_timezone }}" + dest: /etc/localtime + state: link + force: true + when: common_manage_timezone | bool + +- name: Manage common users + become: true + tags: + - users + block: + - name: Ensure managed users exist + ansible.builtin.user: + name: "{{ item.name }}" + state: "{{ item.state | default('present') }}" + shell: "{{ item.shell | default('/bin/bash') }}" + create_home: "{{ item.create_home | default(true) }}" + loop: "{{ common_managed_users }}" + loop_control: + label: "{{ item.name }}" + when: common_managed_users | length > 0 + + always: + - name: Record common users block completion + ansible.builtin.lineinfile: + path: "{{ common_completion_log_path }}" + line: users block completed + create: true + mode: "0644" diff --git a/ansible/roles/docker/defaults/docker_defaults.yml b/ansible/roles/docker/defaults/docker_defaults.yml new file mode 100644 index 0000000000..ebc8ad6ac0 --- /dev/null +++ b/ansible/roles/docker/defaults/docker_defaults.yml @@ -0,0 +1,19 @@ +--- +docker_apt_arch_map: + x86_64: amd64 + aarch64: arm64 + armv7l: armhf + ppc64le: ppc64el + +docker_apt_arch: "{{ docker_apt_arch_map.get(ansible_facts['architecture'], ansible_facts['architecture']) }}" + +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + +docker_user: "{{ ansible_user | default('ubuntu') }}" +docker_install_python_sdk: true +docker_completion_log_path: "/tmp/ansible-docker-role.log" diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..0162ba52da --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart Docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..8e29842d8b --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,145 @@ +--- +- name: Load docker role defaults + ansible.builtin.include_vars: + file: "{{ role_path }}/defaults/docker_defaults.yml" + tags: + - always + +- name: Manage Docker installation + become: true + tags: + - docker_install + block: + - name: Install Docker prerequisites + ansible.builtin.apt: + name: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - software-properties-common + state: present + update_cache: true + + - name: Ensure Docker keyring directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + + - name: Add Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + force: true + + - name: Add Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ docker_apt_arch }} signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_facts['distribution_release'] }} stable + filename: docker + state: present + update_cache: true + + - name: Install Docker engine packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + notify: Restart Docker + + - name: Install Docker Python SDK package + ansible.builtin.apt: + name: python3-docker + state: present + when: docker_install_python_sdk | bool + + - name: Mark Docker service as ready + ansible.builtin.set_fact: + docker_service_ready: true + + rescue: + - name: Mark Docker install rescue as triggered + ansible.builtin.set_fact: + docker_install_rescue_triggered: true + + - name: Wait before retrying Docker apt setup + ansible.builtin.pause: + seconds: 10 + + - name: Refresh apt cache before Docker retry + ansible.builtin.apt: + update_cache: true + cache_valid_time: 0 + + - name: Retry adding Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + force: true + + - name: Retry adding Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ docker_apt_arch }} signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_facts['distribution_release'] }} stable + filename: docker + state: present + update_cache: true + + - name: Retry installing Docker engine packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + notify: Restart Docker + + - name: Retry installing Docker Python SDK package + ansible.builtin.apt: + name: python3-docker + state: present + when: docker_install_python_sdk | bool + + - name: Mark Docker service as ready after retry + ansible.builtin.set_fact: + docker_service_ready: true + + always: + - name: Ensure Docker service is enabled and running + ansible.builtin.service: + name: docker + state: started + enabled: true + when: docker_service_ready | default(false) + + - name: Record Docker installation block completion + ansible.builtin.lineinfile: + path: "{{ docker_completion_log_path }}" + line: >- + docker installation block completed + (rescue_triggered={{ docker_install_rescue_triggered | default(false) }}) + create: true + mode: "0644" + +- name: Manage Docker configuration + become: true + tags: + - docker_config + block: + - name: Add deployment user to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: docker + append: true + when: docker_user | length > 0 + + always: + - name: Record Docker configuration block completion + ansible.builtin.lineinfile: + path: "{{ docker_completion_log_path }}" + line: docker configuration block completed + create: true + mode: "0644" diff --git a/ansible/roles/monitoring/defaults/main.yml b/ansible/roles/monitoring/defaults/main.yml new file mode 100644 index 0000000000..d9d610fbf5 --- /dev/null +++ b/ansible/roles/monitoring/defaults/main.yml @@ -0,0 +1,83 @@ +--- +monitoring_registry_username: "{{ dockerhub_username | default('') }}" +monitoring_registry_password: "{{ dockerhub_password | default(docker_api_token | default('')) }}" + +monitoring_dir: /opt/devops-monitoring +monitoring_compose_file: docker-compose.yml +monitoring_env_file: .env +monitoring_project_name: "{{ monitoring_dir | basename }}" + +monitoring_loki_version: 3.0.0 +monitoring_promtail_version: 3.0.0 +monitoring_grafana_version: 12.3.1 + +monitoring_loki_port: 3100 +monitoring_loki_grpc_port: 9096 +monitoring_promtail_port: 9080 +monitoring_grafana_port: 3000 + +monitoring_loki_retention_period: 168h +monitoring_loki_schema_version: v13 +monitoring_loki_schema_from: "2024-01-01" + +monitoring_grafana_admin_user: "{{ grafana_admin_user | default('admin') }}" +monitoring_grafana_admin_password: "{{ grafana_admin_password | default('ChangeMe!123') }}" +monitoring_grafana_allow_embedding: true +monitoring_grafana_anonymous_enabled: false + +monitoring_python_image: >- + {{ (monitoring_registry_username ~ '/devops-app-py') + if monitoring_registry_username | length > 0 else 'localt0aster/devops-app-py' }} +monitoring_python_tag: "{{ monitoring_python_image_tag | default(docker_image_tag | default('latest')) }}" +monitoring_python_port: 8000 +monitoring_python_internal_port: 8000 +monitoring_python_app_label: devops-python +monitoring_python_health_path: /health + +monitoring_go_image: >- + {{ (monitoring_registry_username ~ '/devops-app-go') + if monitoring_registry_username | length > 0 else 'localt0aster/devops-app-go' }} +monitoring_go_tag: "{{ monitoring_go_image_tag | default(docker_image_tag | default('latest')) }}" +monitoring_go_port: 8001 +monitoring_go_internal_port: 8001 +monitoring_go_app_label: devops-go +monitoring_go_health_path: /health +monitoring_go_external_healthcheck_enabled: true +monitoring_go_healthcheck_image: curlimages/curl:8.18.0 + +monitoring_loki_cpu_limit: "1.0" +monitoring_loki_memory_limit: 1G +monitoring_loki_cpu_reservation: "0.25" +monitoring_loki_memory_reservation: 256M + +monitoring_promtail_cpu_limit: "0.5" +monitoring_promtail_memory_limit: 256M +monitoring_promtail_cpu_reservation: "0.10" +monitoring_promtail_memory_reservation: 64M + +monitoring_grafana_cpu_limit: "1.0" +monitoring_grafana_memory_limit: 512M +monitoring_grafana_cpu_reservation: "0.25" +monitoring_grafana_memory_reservation: 128M + +monitoring_app_cpu_limit: "0.5" +monitoring_app_memory_limit: 256M +monitoring_app_cpu_reservation: "0.10" +monitoring_app_memory_reservation: 64M + +monitoring_go_healthcheck_cpu_limit: "0.10" +monitoring_go_healthcheck_memory_limit: 64M +monitoring_go_healthcheck_cpu_reservation: "0.05" +monitoring_go_healthcheck_memory_reservation: 32M + +monitoring_compose_pull_policy: always +monitoring_compose_recreate: auto +monitoring_compose_wait: true +monitoring_compose_wait_timeout: 180 +monitoring_compose_remove_orphans: true + +monitoring_healthcheck_host: "{{ monitoring_public_host | default(ansible_host | default(inventory_hostname)) }}" +monitoring_healthcheck_delegate_to: "{{ monitoring_healthcheck_delegate | default('localhost') }}" +monitoring_healthcheck_timeout: 5 +monitoring_healthcheck_retries: 20 +monitoring_healthcheck_delay: 3 diff --git a/ansible/roles/monitoring/tasks/deploy.yml b/ansible/roles/monitoring/tasks/deploy.yml new file mode 100644 index 0000000000..ccb6b6b4a0 --- /dev/null +++ b/ansible/roles/monitoring/tasks/deploy.yml @@ -0,0 +1,174 @@ +--- +- name: Skip monitoring deployment actions in check mode + ansible.builtin.debug: + msg: Monitoring stack deployment is skipped in check mode. + when: ansible_check_mode + +- name: Deploy monitoring stack with Docker Compose + tags: + - monitoring + - monitoring_deploy + - compose + when: not ansible_check_mode + block: + - name: Log in to Docker Hub when credentials are available + community.docker.docker_login: + registry_url: https://index.docker.io/v1/ + username: "{{ monitoring_registry_username }}" + password: "{{ monitoring_registry_password }}" + no_log: true + when: + - monitoring_registry_username | string | length > 0 + - monitoring_registry_password | string | length > 0 + + - name: Deploy monitoring stack with Docker Compose v2 + community.docker.docker_compose_v2: + project_src: "{{ monitoring_dir }}" + files: + - "{{ monitoring_compose_file }}" + pull: "{{ monitoring_compose_pull_policy }}" + recreate: "{{ monitoring_compose_recreate }}" + remove_orphans: "{{ monitoring_compose_remove_orphans | bool }}" + state: present + wait: "{{ monitoring_compose_wait | bool }}" + wait_timeout: "{{ monitoring_compose_wait_timeout | int }}" + register: monitoring_compose_result + retries: 3 + delay: 10 + until: monitoring_compose_result is succeeded + + - name: Wait for exposed monitoring ports + ansible.builtin.wait_for: + host: "{{ monitoring_healthcheck_host }}" + port: "{{ item | int }}" + timeout: 60 + delay: 1 + loop: + - "{{ monitoring_loki_port }}" + - "{{ monitoring_promtail_port }}" + - "{{ monitoring_grafana_port }}" + - "{{ monitoring_python_port }}" + - "{{ monitoring_go_port }}" + delegate_to: "{{ monitoring_healthcheck_delegate_to }}" + become: false + + - name: Verify Loki readiness endpoint + ansible.builtin.uri: + url: "http://{{ monitoring_healthcheck_host }}:{{ monitoring_loki_port }}/ready" + method: GET + status_code: 200 + return_content: true + timeout: "{{ monitoring_healthcheck_timeout | int }}" + register: monitoring_loki_ready + retries: "{{ monitoring_healthcheck_retries | int }}" + delay: "{{ monitoring_healthcheck_delay | int }}" + until: + - monitoring_loki_ready.status == 200 + - "'ready' in (monitoring_loki_ready.content | default(''))" + delegate_to: "{{ monitoring_healthcheck_delegate_to }}" + become: false + + - name: Verify Promtail targets endpoint + ansible.builtin.uri: + url: "http://{{ monitoring_healthcheck_host }}:{{ monitoring_promtail_port }}/targets" + method: GET + status_code: 200 + timeout: "{{ monitoring_healthcheck_timeout | int }}" + register: monitoring_promtail_targets + retries: "{{ monitoring_healthcheck_retries | int }}" + delay: "{{ monitoring_healthcheck_delay | int }}" + until: monitoring_promtail_targets.status == 200 + delegate_to: "{{ monitoring_healthcheck_delegate_to }}" + become: false + + - name: Verify Grafana API health + ansible.builtin.uri: + url: "http://{{ monitoring_healthcheck_host }}:{{ monitoring_grafana_port }}/api/health" + method: GET + status_code: 200 + timeout: "{{ monitoring_healthcheck_timeout | int }}" + register: monitoring_grafana_health + retries: "{{ monitoring_healthcheck_retries | int }}" + delay: "{{ monitoring_healthcheck_delay | int }}" + until: monitoring_grafana_health.status == 200 + delegate_to: "{{ monitoring_healthcheck_delegate_to }}" + become: false + + - name: Verify Grafana requires authentication + ansible.builtin.uri: + url: "http://{{ monitoring_healthcheck_host }}:{{ monitoring_grafana_port }}/api/user" + method: GET + status_code: 401 + timeout: "{{ monitoring_healthcheck_timeout | int }}" + register: monitoring_grafana_auth_gate + retries: "{{ monitoring_healthcheck_retries | int }}" + delay: "{{ monitoring_healthcheck_delay | int }}" + until: monitoring_grafana_auth_gate.status == 401 + delegate_to: "{{ monitoring_healthcheck_delegate_to }}" + become: false + + - name: Verify Python application health endpoint + ansible.builtin.uri: + url: "http://{{ monitoring_healthcheck_host }}:{{ monitoring_python_port }}{{ monitoring_python_health_path }}" + method: GET + status_code: 200 + return_content: true + timeout: "{{ monitoring_healthcheck_timeout | int }}" + register: monitoring_python_health + retries: "{{ monitoring_healthcheck_retries | int }}" + delay: "{{ monitoring_healthcheck_delay | int }}" + until: + - monitoring_python_health.status == 200 + - monitoring_python_health.json.status | default('') == 'healthy' + delegate_to: "{{ monitoring_healthcheck_delegate_to }}" + become: false + + - name: Verify Go application health endpoint + ansible.builtin.uri: + url: "http://{{ monitoring_healthcheck_host }}:{{ monitoring_go_port }}{{ monitoring_go_health_path }}" + method: GET + status_code: 200 + return_content: true + timeout: "{{ monitoring_healthcheck_timeout | int }}" + register: monitoring_go_health + retries: "{{ monitoring_healthcheck_retries | int }}" + delay: "{{ monitoring_healthcheck_delay | int }}" + until: + - monitoring_go_health.status == 200 + - monitoring_go_health.json.status | default('') == 'healthy' + delegate_to: "{{ monitoring_healthcheck_delegate_to }}" + become: false + + - name: Read external Go healthcheck container info + community.docker.docker_container_info: + name: "{{ monitoring_project_name }}-app-go-healthcheck-1" + register: monitoring_go_healthcheck_container + when: monitoring_go_external_healthcheck_enabled | bool + + - name: Assert external Go healthcheck is healthy + ansible.builtin.assert: + that: + - monitoring_go_healthcheck_container.exists | bool + - monitoring_go_healthcheck_container.container.State.Health.Status == 'healthy' + fail_msg: External Go healthcheck container is not healthy. + when: monitoring_go_external_healthcheck_enabled | bool + + rescue: + - name: Capture docker compose status after failed monitoring deployment + ansible.builtin.command: + argv: + - docker + - compose + - -f + - "{{ monitoring_dir }}/{{ monitoring_compose_file }}" + - ps + - --all + register: monitoring_compose_ps + changed_when: false + failed_when: false + + - name: Fail deployment with compose status context + ansible.builtin.fail: + msg: >- + Monitoring deployment failed. Compose status: + {{ monitoring_compose_ps.stdout | default('no compose status available') }} diff --git a/ansible/roles/monitoring/tasks/main.yml b/ansible/roles/monitoring/tasks/main.yml new file mode 100644 index 0000000000..e0b934409c --- /dev/null +++ b/ansible/roles/monitoring/tasks/main.yml @@ -0,0 +1,8 @@ +--- +- name: Prepare monitoring stack files + ansible.builtin.include_tasks: + file: setup.yml + +- name: Deploy monitoring stack + ansible.builtin.include_tasks: + file: deploy.yml diff --git a/ansible/roles/monitoring/tasks/setup.yml b/ansible/roles/monitoring/tasks/setup.yml new file mode 100644 index 0000000000..45fc1fe486 --- /dev/null +++ b/ansible/roles/monitoring/tasks/setup.yml @@ -0,0 +1,55 @@ +--- +- name: Ensure monitoring directory structure exists + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - "{{ monitoring_dir }}" + - "{{ monitoring_dir }}/loki" + - "{{ monitoring_dir }}/promtail" + - "{{ monitoring_dir }}/grafana" + - "{{ monitoring_dir }}/grafana/provisioning" + - "{{ monitoring_dir }}/grafana/provisioning/datasources" + +- name: Template monitoring environment file + ansible.builtin.template: + src: env.j2 + dest: "{{ monitoring_dir }}/{{ monitoring_env_file }}" + owner: root + group: root + mode: "0600" + +- name: Template monitoring Docker Compose configuration + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ monitoring_dir }}/{{ monitoring_compose_file }}" + owner: root + group: root + mode: "0644" + +- name: Template Loki configuration + ansible.builtin.template: + src: loki-config.yml.j2 + dest: "{{ monitoring_dir }}/loki/config.yml" + owner: root + group: root + mode: "0644" + +- name: Template Promtail configuration + ansible.builtin.template: + src: promtail-config.yml.j2 + dest: "{{ monitoring_dir }}/promtail/config.yml" + owner: root + group: root + mode: "0644" + +- name: Template Grafana Loki datasource provisioning + ansible.builtin.template: + src: grafana-loki-datasource.yml.j2 + dest: "{{ monitoring_dir }}/grafana/provisioning/datasources/loki.yml" + owner: root + group: root + mode: "0644" diff --git a/ansible/roles/monitoring/templates/docker-compose.yml.j2 b/ansible/roles/monitoring/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..73178674d3 --- /dev/null +++ b/ansible/roles/monitoring/templates/docker-compose.yml.j2 @@ -0,0 +1,183 @@ +services: + loki: + image: grafana/loki:{{ monitoring_loki_version }} + command: + - -config.file=/etc/loki/config.yml + ports: + - "{{ monitoring_loki_port }}:{{ monitoring_loki_port }}" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + healthcheck: + test: + - CMD-SHELL + - wget --no-verbose --tries=1 --spider http://127.0.0.1:{{ monitoring_loki_port }}/ready || exit 1 + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "{{ monitoring_loki_cpu_limit }}" + memory: {{ monitoring_loki_memory_limit }} + reservations: + cpus: "{{ monitoring_loki_cpu_reservation }}" + memory: {{ monitoring_loki_memory_reservation }} + networks: + - monitoring + restart: unless-stopped + + promtail: + image: grafana/promtail:{{ monitoring_promtail_version }} + command: + - -config.file=/etc/promtail/config.yml + user: "0:0" + depends_on: + loki: + condition: service_healthy + ports: + - "{{ monitoring_promtail_port }}:{{ monitoring_promtail_port }}" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - promtail-data:/run/promtail + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + deploy: + resources: + limits: + cpus: "{{ monitoring_promtail_cpu_limit }}" + memory: {{ monitoring_promtail_memory_limit }} + reservations: + cpus: "{{ monitoring_promtail_cpu_reservation }}" + memory: {{ monitoring_promtail_memory_reservation }} + networks: + - monitoring + restart: unless-stopped + + grafana: + image: grafana/grafana:{{ monitoring_grafana_version }} + depends_on: + loki: + condition: service_healthy + env_file: + - .env + environment: + GF_AUTH_ANONYMOUS_ENABLED: "{{ monitoring_grafana_anonymous_enabled | ternary('true', 'false') }}" + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_SECURITY_ALLOW_EMBEDDING: "{{ monitoring_grafana_allow_embedding | ternary('true', 'false') }}" + ports: + - "{{ monitoring_grafana_port }}:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + healthcheck: + test: + - CMD-SHELL + - wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1 + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + cpus: "{{ monitoring_grafana_cpu_limit }}" + memory: {{ monitoring_grafana_memory_limit }} + reservations: + cpus: "{{ monitoring_grafana_cpu_reservation }}" + memory: {{ monitoring_grafana_memory_reservation }} + networks: + - monitoring + restart: unless-stopped + + app-python: + image: {{ monitoring_python_image }}:{{ monitoring_python_tag }} + environment: + HOST: "0.0.0.0" + PORT: "{{ monitoring_python_internal_port }}" + ports: + - "{{ monitoring_python_port }}:{{ monitoring_python_internal_port }}" + labels: + logging: "promtail" + app: "{{ monitoring_python_app_label }}" + healthcheck: + test: + - CMD-SHELL + - wget --no-verbose --tries=1 --spider http://127.0.0.1:{{ monitoring_python_internal_port }}{{ monitoring_python_health_path }} || exit 1 + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "{{ monitoring_app_cpu_limit }}" + memory: {{ monitoring_app_memory_limit }} + reservations: + cpus: "{{ monitoring_app_cpu_reservation }}" + memory: {{ monitoring_app_memory_reservation }} + networks: + - monitoring + restart: unless-stopped + + app-go: + image: {{ monitoring_go_image }}:{{ monitoring_go_tag }} + environment: + HOST: "0.0.0.0" + PORT: "{{ monitoring_go_internal_port }}" + ports: + - "{{ monitoring_go_port }}:{{ monitoring_go_internal_port }}" + labels: + logging: "promtail" + app: "{{ monitoring_go_app_label }}" + deploy: + resources: + limits: + cpus: "{{ monitoring_app_cpu_limit }}" + memory: {{ monitoring_app_memory_limit }} + reservations: + cpus: "{{ monitoring_app_cpu_reservation }}" + memory: {{ monitoring_app_memory_reservation }} + networks: + - monitoring + restart: unless-stopped +{% if monitoring_go_external_healthcheck_enabled | bool %} + + app-go-healthcheck: + image: {{ monitoring_go_healthcheck_image }} + command: + - sh + - -c + - sleep infinity + depends_on: + - app-go + healthcheck: + test: + - CMD-SHELL + - curl -fsS http://app-go:{{ monitoring_go_internal_port }}{{ monitoring_go_health_path }} >/dev/null || exit 1 + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "{{ monitoring_go_healthcheck_cpu_limit }}" + memory: {{ monitoring_go_healthcheck_memory_limit }} + reservations: + cpus: "{{ monitoring_go_healthcheck_cpu_reservation }}" + memory: {{ monitoring_go_healthcheck_memory_reservation }} + networks: + - monitoring + restart: unless-stopped +{% endif %} + +volumes: + loki-data: + promtail-data: + grafana-data: + +networks: + monitoring: diff --git a/ansible/roles/monitoring/templates/env.j2 b/ansible/roles/monitoring/templates/env.j2 new file mode 100644 index 0000000000..c5134d339c --- /dev/null +++ b/ansible/roles/monitoring/templates/env.j2 @@ -0,0 +1,2 @@ +GRAFANA_ADMIN_USER={{ monitoring_grafana_admin_user }} +GRAFANA_ADMIN_PASSWORD={{ monitoring_grafana_admin_password }} diff --git a/ansible/roles/monitoring/templates/grafana-loki-datasource.yml.j2 b/ansible/roles/monitoring/templates/grafana-loki-datasource.yml.j2 new file mode 100644 index 0000000000..f13a7c7c40 --- /dev/null +++ b/ansible/roles/monitoring/templates/grafana-loki-datasource.yml.j2 @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki:{{ monitoring_loki_port }} + isDefault: true + editable: true diff --git a/ansible/roles/monitoring/templates/loki-config.yml.j2 b/ansible/roles/monitoring/templates/loki-config.yml.j2 new file mode 100644 index 0000000000..c6b83653fa --- /dev/null +++ b/ansible/roles/monitoring/templates/loki-config.yml.j2 @@ -0,0 +1,42 @@ +auth_enabled: false + +server: + http_listen_port: {{ monitoring_loki_port }} + grpc_listen_port: {{ monitoring_loki_grpc_port }} + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + +schema_config: + configs: + - from: {{ monitoring_loki_schema_from }} + store: tsdb + object_store: filesystem + schema: {{ monitoring_loki_schema_version }} + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: {{ monitoring_loki_retention_period }} + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + delete_request_store: filesystem diff --git a/ansible/roles/monitoring/templates/promtail-config.yml.j2 b/ansible/roles/monitoring/templates/promtail-config.yml.j2 new file mode 100644 index 0000000000..2cb509ba23 --- /dev/null +++ b/ansible/roles/monitoring/templates/promtail-config.yml.j2 @@ -0,0 +1,38 @@ +server: + http_listen_port: {{ monitoring_promtail_port }} + grpc_listen_port: 0 + +positions: + filename: /run/promtail/positions.yaml + +clients: + - url: http://loki:{{ monitoring_loki_port }}/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: + - logging=promtail + relabel_configs: + - target_label: job + replacement: docker + - source_labels: + - __meta_docker_container_label_app + action: replace + regex: "(.+)" + replacement: "$1" + target_label: app + - source_labels: + - __meta_docker_container_name + regex: "/(.*)" + target_label: container + - source_labels: + - __meta_docker_container_label_com_docker_compose_service + target_label: compose_service + - source_labels: + - __meta_docker_container_log_stream + target_label: logstream diff --git a/ansible/roles/web_app/defaults/web_app_defaults.yml b/ansible/roles/web_app/defaults/web_app_defaults.yml new file mode 100644 index 0000000000..b79d064df1 --- /dev/null +++ b/ansible/roles/web_app/defaults/web_app_defaults.yml @@ -0,0 +1,46 @@ +--- +web_app_registry_username: "{{ dockerhub_username | default('') }}" +web_app_registry_password: "{{ dockerhub_password | default(docker_api_token | default('')) }}" + +web_app_name: "{{ app_name | default('devops-app-py') }}" +web_app_service_name: "{{ web_app_name }}" +web_app_image: >- + {{ docker_image | default((web_app_registry_username ~ '/' ~ web_app_name) + if web_app_registry_username | length > 0 else web_app_name) }} +web_app_image_tag: "{{ docker_tag | default(docker_image_tag | default('latest')) }}" + +web_app_port: "{{ app_port | default(5000) }}" +web_app_container_name: "{{ app_container_name | default(web_app_name) }}" +web_app_internal_port: "{{ app_internal_port | default(app_container_internal_port | default(web_app_port)) }}" +web_app_restart_policy: "{{ app_restart_policy | default('unless-stopped') }}" + +web_app_environment: >- + {{ {'HOST': '0.0.0.0', 'PORT': (web_app_internal_port | string)} + | combine(app_environment | default({})) }} + +web_app_healthcheck_path: "{{ app_healthcheck_path | default('/health') }}" +web_app_healthcheck_retries: "{{ app_healthcheck_retries | default(20) }}" +web_app_healthcheck_delay: "{{ app_healthcheck_delay | default(3) }}" +web_app_healthcheck_timeout: "{{ app_healthcheck_timeout | default(3) }}" +web_app_healthcheck_host: "{{ app_healthcheck_host | default(ansible_host | default(inventory_hostname)) }}" +web_app_healthcheck_delegate_to: "{{ app_healthcheck_delegate_to | default('localhost') }}" + +web_app_compose_version: "{{ docker_compose_version | default('3.8') }}" +web_app_compose_project_dir: "{{ compose_project_dir | default('/opt/' ~ web_app_name) }}" +web_app_compose_file_name: "docker-compose.yml" +web_app_compose_pull_policy: "{{ app_compose_pull_policy | default('always') }}" +web_app_compose_recreate: "{{ app_compose_recreate | default('auto') }}" +web_app_compose_wait: "{{ app_compose_wait | default(true) }}" +web_app_compose_wait_timeout: "{{ app_compose_wait_timeout | default(60) }}" +web_app_compose_remove_orphans: "{{ app_compose_remove_orphans | default(true) }}" +web_app_network_name: "{{ web_app_name }}-network" +web_app_container_command: "{{ app_container_command | default('') }}" + +# Wipe logic control +# Set to true to remove the deployed application before continuing. +# Wipe only: ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe +# Clean install: ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" +web_app_wipe: false +web_app_wipe_remove_images: false +web_app_wipe_remove_volumes: false +web_app_wipe_log_path: "/tmp/ansible-web-app-wipe.log" diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..2f1b4fbf00 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,10 @@ +--- +dependencies: + - role: docker + tags: + - docker + - docker_install + - docker_config + - web_app + - app_deploy + - compose diff --git a/ansible/roles/web_app/tasks/web_app_tasks.yml b/ansible/roles/web_app/tasks/web_app_tasks.yml new file mode 100644 index 0000000000..c8de93b0ca --- /dev/null +++ b/ansible/roles/web_app/tasks/web_app_tasks.yml @@ -0,0 +1,117 @@ +--- +- name: Include web app wipe tasks + ansible.builtin.include_tasks: + file: wipe.yml + apply: + tags: + - web_app_wipe + tags: + - always + - web_app_wipe + +- name: Deploy web application with Docker Compose + become: true + tags: + - web_app + - app_deploy + - compose + block: + - name: Log in to Docker Hub when credentials are available + community.docker.docker_login: + registry_url: https://index.docker.io/v1/ + username: "{{ web_app_registry_username }}" + password: "{{ web_app_registry_password }}" + no_log: true + when: + - web_app_registry_username | string | length > 0 + - web_app_registry_password | string | length > 0 + + - name: Ensure Compose project directory exists + ansible.builtin.file: + path: "{{ web_app_compose_project_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Check for legacy standalone container + community.docker.docker_container_info: + name: "{{ web_app_container_name }}" + register: web_app_legacy_container + + - name: Remove legacy standalone container before Compose migration + community.docker.docker_container: + name: "{{ web_app_container_name }}" + state: absent + when: + - web_app_legacy_container.exists | bool + - web_app_legacy_container.container is defined + - "'com.docker.compose.project' not in (web_app_legacy_container.container.Config.Labels | default({}))" + + - name: Template Docker Compose configuration + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ web_app_compose_project_dir }}/{{ web_app_compose_file_name }}" + owner: root + group: root + mode: "0644" + + - name: Deploy application stack with Docker Compose + community.docker.docker_compose_v2: + project_src: "{{ web_app_compose_project_dir }}" + files: + - "{{ web_app_compose_file_name }}" + pull: "{{ web_app_compose_pull_policy }}" + recreate: "{{ web_app_compose_recreate }}" + remove_orphans: "{{ web_app_compose_remove_orphans | bool }}" + state: present + wait: "{{ web_app_compose_wait | bool }}" + wait_timeout: "{{ web_app_compose_wait_timeout | int }}" + register: web_app_compose_result + retries: 3 + delay: 10 + until: web_app_compose_result is succeeded + + - name: Wait for application port + ansible.builtin.wait_for: + host: "{{ web_app_healthcheck_host }}" + port: "{{ web_app_port | int }}" + timeout: 60 + delay: 1 + delegate_to: "{{ web_app_healthcheck_delegate_to }}" + become: false + + - name: Verify application health endpoint + ansible.builtin.uri: + url: "http://{{ web_app_healthcheck_host }}:{{ web_app_port }}{{ web_app_healthcheck_path }}" + method: GET + status_code: 200 + return_content: true + timeout: "{{ web_app_healthcheck_timeout | int }}" + register: web_app_healthcheck + retries: "{{ web_app_healthcheck_retries | int }}" + delay: "{{ web_app_healthcheck_delay | int }}" + until: web_app_healthcheck.status == 200 + failed_when: web_app_healthcheck.json.status | default("") != "healthy" + delegate_to: "{{ web_app_healthcheck_delegate_to }}" + become: false + + rescue: + - name: Capture docker compose status after failed deployment + ansible.builtin.command: + argv: + - docker + - compose + - -f + - "{{ web_app_compose_project_dir }}/{{ web_app_compose_file_name }}" + - ps + - --all + register: web_app_compose_ps + changed_when: false + failed_when: false + + - name: Fail deployment with compose status context + ansible.builtin.fail: + msg: >- + Web app deployment failed. Compose status: + {{ web_app_compose_ps.stdout | default('no compose status available') }} diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..12bfce55f0 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,69 @@ +--- +- name: Check whether Compose file exists for wipe + ansible.builtin.stat: + path: "{{ web_app_compose_project_dir }}/{{ web_app_compose_file_name }}" + register: web_app_wipe_compose_file + become: true + tags: + - web_app_wipe + +- name: Wipe web application deployment + become: true + when: web_app_wipe | bool + tags: + - web_app_wipe + block: + - name: Stop and remove Compose-managed containers + community.docker.docker_compose_v2: + project_src: "{{ web_app_compose_project_dir }}" + files: + - "{{ web_app_compose_file_name }}" + state: absent + remove_orphans: true + remove_volumes: "{{ web_app_wipe_remove_volumes | bool }}" + when: web_app_wipe_compose_file.stat.exists + register: web_app_wipe_compose_down + failed_when: false + + - name: Remove standalone web app container if present + community.docker.docker_container: + name: "{{ web_app_container_name }}" + state: absent + + - name: Remove Compose file + ansible.builtin.file: + path: "{{ web_app_compose_project_dir }}/{{ web_app_compose_file_name }}" + state: absent + + - name: Remove Compose project directory + ansible.builtin.file: + path: "{{ web_app_compose_project_dir }}" + state: absent + + - name: Optionally remove deployed image + community.docker.docker_image_remove: + name: "{{ web_app_image }}" + tag: "{{ web_app_image_tag }}" + force: true + when: web_app_wipe_remove_images | bool + register: web_app_wipe_image_remove + failed_when: false + + always: + - name: Record web app wipe completion + ansible.builtin.lineinfile: + path: "{{ web_app_wipe_log_path }}" + line: >- + web_app wipe completed + (requested={{ web_app_wipe | bool }}, + compose_file_present={{ web_app_wipe_compose_file.stat.exists | default(false) }}, + remove_images={{ web_app_wipe_remove_images | bool }}, + remove_volumes={{ web_app_wipe_remove_volumes | bool }}) + create: true + mode: "0644" + + - name: Report web app wipe status + ansible.builtin.debug: + msg: >- + Web app {{ web_app_name }} wipe completed. + Project directory={{ web_app_compose_project_dir }}. 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..0470877645 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,21 @@ +services: + {{ web_app_service_name }}: + image: "{{ web_app_image }}:{{ web_app_image_tag }}" + container_name: "{{ web_app_container_name }}" +{% if web_app_container_command | length > 0 %} + command: "{{ web_app_container_command }}" +{% endif %} + ports: + - "{{ web_app_port }}:{{ web_app_internal_port }}" + environment: +{% for env_name, env_value in web_app_environment | dictsort %} + {{ env_name }}: "{{ env_value }}" +{% endfor %} + restart: "{{ web_app_restart_policy }}" + networks: + - web_app_network + +networks: + web_app_network: + name: "{{ web_app_network_name }}" + driver: bridge diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..64ce80193c --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,5 @@ +* +!go.mod +!go.sum +!*.go +main_test.go diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..add20bc5eb --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,33 @@ +# https://github.com/github/gitignore/blob/53fee13f20a05efc93ef4edcad0c62863520e268/Go.gitignore +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..e8e9d0dedd --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service.out + +FROM scratch +COPY --from=build /app/devops-info-service.out / +# Use UID:GID to avoid copying user info (/etc/passwd) +USER 10001:10001 +CMD ["/devops-info-service.out"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..a2d551a7a3 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,45 @@ +# DevOps Info Service (Go) + +## Overview +Simple Go web service that exposes system/runtime details, a file-backed visits counter, health and readiness checks, Prometheus metrics, and structured JSON logs. + +## Prerequisites +- Go 1.25+ + +## Build +```bash +go build -o devops-info-service.out . +``` + +## Run +```bash +./devops-info-service.out +# Or with custom config +HOST=127.0.0.1 PORT=8080 ./devops-info-service.out +``` + +## Endpoints +- `GET /` - service + system + runtime + request info +- `GET /visits` - current visits counter stored in `/data/visits` +- `GET /health` - health check +- `GET /ready` - readiness check +- `GET /metrics` - Prometheus metrics exposition + +## Visits Counter +- The root handler increments the counter on every `GET /`. +- The counter is persisted as plain text in `/data/visits`. +- If the file is missing, the service starts from `0`. +- If the file is malformed, the service logs a warning and treats the value as `0`. + +## Local Docker Check +For Lab 12, run the monitoring stack with a writable `/data` volume for the Go container and verify that: +- repeated `GET /` calls increment the counter +- `GET /visits` returns the current count +- the counter survives a container restart because the backing file is persisted on the host + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | diff --git a/app_go/build.sh b/app_go/build.sh new file mode 100644 index 0000000000..2832311420 --- /dev/null +++ b/app_go/build.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +go build -o devops-info-service.out . diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..3f4073e0d8 --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,10 @@ +# Go Language Justification + +## Why Go + +I chose Go because it produces small, static binaries, compiles quickly, and has a minimal standard library that already covers HTTP servers. That makes it a good fit for a tiny service and for multi‑stage Docker builds later in the course. + +## Tradeoffs + +- **Pros:** fast compile/run, simple deployment, good concurrency model, no runtime dependency chain. +- **Cons:** less dynamic than Python for quick iteration, and JSON struct definitions add some boilerplate. diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..d80e7b00b9 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,42 @@ +# LAB01 — DevOps Info Service (Go) + +## Implementation Overview + +This Go version mirrors the Python service and exposes the same two endpoints using the standard `net/http` package. + +### Endpoints + +- `GET /` returns service, system, runtime, request info, and a list of endpoints. +- `GET /health` returns a health status, timestamp, and uptime in seconds. + +### Runtime Behavior + +- **Uptime:** computed from a `startTime` set at process start. +- **System info:** hostname via `os.Hostname`, OS/arch via `runtime`. +- **Request info:** client IP from `X-Forwarded-For` or `RemoteAddr`. +- **Errors:** JSON 404 for unknown paths and JSON 500 on panics (recovery middleware). + +## Build & Run + +```bash +go build -o devops-info-service.out . +./devops-info-service.out +# Custom config +HOST=127.0.0.1 PORT=8080 ./devops-info-service.out +``` + +## API Examples + +```bash +curl -sS http://127.0.0.1:5000/ | jq +curl -sS http://127.0.0.1:5000/health | jq +``` + +## Notes + +- `python_version` in the JSON is populated with the Go runtime version to keep the output shape identical to the Python app. +- The advertised endpoint list is a static slice to match the Python output. + +## Screenshot + +![Screenshot](img/lab01go.png) \ No newline at end of file diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..d906ab57fb --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,300 @@ +# LAB02 — Multi-Stage Docker Build (Go) + +## Multi-Stage Build Strategy + +The Go service is built with a two-stage Dockerfile: + +1. **Build stage (`golang:1.25-alpine`)** +- Compiles the application binary with `CGO_ENABLED=0 GOOS=linux`. +- Keeps compiler/toolchain in the build environment only. + +2. **Runtime stage (`scratch`)** +- Copies only `devops-info-service.out` from the build stage. +- Runs as non-root with `USER 10001:10001`. +- Contains no package manager, shell, or compiler. + +Dockerfile used: `app_go/Dockerfile` + +```dockerfile +FROM golang:1.25-alpine AS build +WORKDIR /app +COPY go.mod *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service.out + +FROM scratch +COPY --from=build /app/devops-info-service.out / +USER 10001:10001 +CMD ["/devops-info-service.out"] +``` + +Also, `.dockerignore` keeps context minimal: + +```dockerignore +* +!go.mod +!go.sum +!*.go +``` + +## Technical Explanation of Each Stage + +- **`FROM golang:1.25-alpine AS build`** + Provides Go toolchain and Alpine userspace needed to compile. +- **`WORKDIR /app` + `COPY go.mod *.go ./`** + Copies only build inputs (module file and source files). +- **`RUN CGO_ENABLED=0 GOOS=linux go build ...`** + Produces a Linux static binary suitable for `scratch` runtime. +- **`FROM scratch`** + Starts an empty runtime image. +- **`COPY --from=build ...`** + Transfers only the compiled artifact, not compilers or source. +- **`USER 10001:10001`** + Drops root privileges in runtime. + +## Build Process (Terminal Output) + +
+🔨 Build target + +```log +$ docker build --no-cache --progress=plain --target build -t lab02-go:builder . +#0 building with "default" instance using docker driver + +#1 [internal] load build definition from Dockerfile +#1 transferring dockerfile: 424B 0.0s done +#1 DONE 0.1s + +#2 [internal] load metadata for docker.io/library/golang:1.25-alpine +#2 DONE 1.0s + +#3 [internal] load .dockerignore +#3 transferring context: 64B 0.0s done +#3 DONE 0.0s + +#4 [internal] load build context +#4 DONE 0.0s + +#5 [build 1/4] FROM docker.io/library/golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced +#5 resolve docker.io/library/golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced 0.0s done +#5 DONE 0.1s + +#6 [build 2/4] WORKDIR /app +#6 CACHED + +#4 [internal] load build context +#4 transferring context: 54B done +#4 DONE 0.0s + +#7 [build 3/4] COPY go.mod *.go ./ +#7 DONE 0.1s + +#8 [build 4/4] RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service.out +#8 DONE 62.8s + +#9 exporting to image +#9 exporting layers +#9 exporting layers 7.4s done +#9 exporting manifest sha256:f3e73461dd53d9f346f612d14a5d7db25b865b7aab912ba8d3cb89a098da0546 +#9 exporting manifest sha256:f3e73461dd53d9f346f612d14a5d7db25b865b7aab912ba8d3cb89a098da0546 0.0s done +#9 exporting config sha256:06ba3662b02750d25c0817c4d26a4d0f77805f722bb6d60fa2b8c04b4308e480 0.0s done +#9 exporting attestation manifest sha256:844a56a9b83102a634becbc82128fa16fd1c41bba4fd9f5c56cf7ed84ec0b2ad 0.0s done +#9 exporting manifest list sha256:f2f7690814f0d4b01954394858a41285da9b7a2a425a2525c36f4f7dfe1577aa done +#9 naming to docker.io/library/lab02-go:builder 0.0s done +#9 unpacking to docker.io/library/lab02-go:builder +#9 unpacking to docker.io/library/lab02-go:builder 1.7s done +#9 DONE 9.4 +``` + +
+ +
+🔨 Final multi-stage target + +```log +$ docker build --no-cache --progress=plain -t lab02-go:final . +#0 building with "default" instance using docker driver + +#1 [internal] load build definition from Dockerfile +#1 transferring dockerfile: 424B 0.0s done +#1 DONE 0.0s + +#2 [internal] load metadata for docker.io/library/golang:1.25-alpine +#2 DONE 0.9s + +#3 [internal] load .dockerignore +#3 transferring context: 64B done +#3 DONE 0.0s + +#4 [internal] load build context +#4 DONE 0.0s + +#5 [build 1/4] FROM docker.io/library/golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced +#5 resolve docker.io/library/golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced 0.0s done +#5 DONE 0.0s + +#6 [build 2/4] WORKDIR /app +#6 CACHED + +#4 [internal] load build context +#4 transferring context: 54B done +#4 DONE 0.0s + +#7 [build 3/4] COPY go.mod *.go ./ +#7 DONE 0.1s + +#8 [build 4/4] RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service.out +#8 DONE 67.4s + +#9 [stage-1 1/1] COPY --from=build /app/devops-info-service.out / +#9 DONE 0.1s + +#10 exporting to image +#10 exporting layers +#10 exporting layers 1.0s done +#10 exporting manifest sha256:b3ddddd75de1b8fe87ecf287b479ae5804ae9b73e3c8c88b58553ae1e949d209 +#10 exporting manifest sha256:b3ddddd75de1b8fe87ecf287b479ae5804ae9b73e3c8c88b58553ae1e949d209 0.0s done +#10 exporting config sha256:65c1bd7c8937841b2bb1e5d455bd1ec37dab85a4e0ac4eab15bf50d1fb61d19a done +#10 exporting attestation manifest sha256:2c7f952e05e64da351b651ceb30a12d35c0304ef9fb21d7dd5089b365862464e 0.0s done +#10 exporting manifest list sha256:2d3f56459e956a745bfe802d54a7f652677a6a993406ec23d7d0334f9ec99af5 0.0s done +#10 naming to docker.io/library/lab02-go:final done +#10 unpacking to docker.io/library/lab02-go:final +#10 unpacking to docker.io/library/lab02-go:final 0.2s done +#10 DONE 1.4s +``` + +
+ +## Working Containerized Application (Terminal Output) + +
+Server + +```bash +$ docker run --rm -p 5000:5000 lab02-go:final +2026/02/10 20:02:11 Application starting on 0.0.0.0:5000 +2026/02/10 20:02:27 Request: GET / +2026/02/10 20:03:55 Request: GET /health +``` + +
+ +
+Client + +```json +$ curl -sS 127.0.0.1:5000 | jq +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Go net/http" + }, + "system": { + "hostname": "1208319f6a92", + "platform": "Linux", + "platform_version": "linux", + "architecture": "amd64", + "cpu_count": 1, + "python_version": "go1.25.7" + }, + "runtime": { + "seconds": 15, + "human": "0 hours, 0 minutes" + }, + "request": { + "client_ip": "172.17.0.1", + "user_agent": "curl/8.14.1", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information." + }, + { + "path": "/health", + "method": "GET", + "description": "Health check endpoint." + } + ] +} +``` + +```json +$ curl -sS 127.0.0.1:5000/health | jq +{ + "status": "healthy", + "timestamp": "2026-02-10T20:03:55.538319+00:00", + "uptime_seconds": 104 +} +``` + +
+ +## Image Size Comparison and Analysis + + +| Image | Image size | +| ------------------------------------ | ------------ | +| Builder (`lab02-go:builder`) | **85.50MiB** | +| Final multi-stage (`lab02-go:final`) | **4.41MiB** | + +
+⚖️ Measuring command + +```bash +docker inspect -f "{{ .Size }}" | numfmt --to=iec-i --format="%.2f" +``` +
+ +Reduction from builder to final: +- **94.84%** smaller +- **19.39x** smaller runtime image + +These metrics come from the same `docker inspect` size source, so they are directly comparable. + +## Why Multi-Stage Builds Matter for Compiled Languages + +For Go (and similarly Rust/C/C++), the compiler and build toolchain are large and needed only at build time. Multi-stage builds let us: + +- Keep full SDK only in builder stage. +- Ship only the compiled binary in runtime. +- Reduce registry transfer and startup pull time. +- Reduce operational footprint and patch surface in production. + +Without multi-stage, runtime image carries unnecessary build dependencies, increasing size and risk. + +## Security Implications (Smaller Attack Surface) + +Security improvements in this implementation: + +- `scratch` runtime has no shell/package manager/toolchain. +- Non-root runtime user via `USER 10001:10001`. +- Fewer filesystem artifacts (only binary), reducing exposure. + +Practical impact: + +- Fewer components to scan/patch. +- Lower chance of post-exploitation tooling availability inside container. +- Simpler SBOM/runtime dependency graph. + +## Trade-Offs and Decisions + +### Decisions made + +- **Chose `scratch`** for maximal size/security reduction. +- **Used static build (`CGO_ENABLED=0`)** so binary runs in empty base image. +- **Used numeric UID:GID (`10001:10001`)** because `scratch` has no user-management tools. + +### Trade-offs + +- `scratch` is harder to debug (no shell utilities). +- No bundled CA certs/timezone data by default (important if app adds outbound TLS or timezone-sensitive logic later). +- Builder-stage caching is currently simple; if dependencies grow, splitting module download and source copy can improve cache efficiency further. + +## Summary + +The multi-stage approach in `app_go/Dockerfile` produces a working, non-root runtime image and achieves major size reduction compared with keeping the full Go toolchain in the final image. The result is a materially smaller and safer production artifact while preserving application functionality. diff --git a/app_go/docs/img/lab01go.png b/app_go/docs/img/lab01go.png new file mode 100644 index 0000000000..42652a163c Binary files /dev/null and b/app_go/docs/img/lab01go.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..dc064b6a3b --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,18 @@ +module example.com/devops-info-service + +go 1.26.1 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/sys v0.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/app_go/go.sum b/app_go/go.sum new file mode 100644 index 0000000000..e235955aab --- /dev/null +++ b/app_go/go.sum @@ -0,0 +1,36 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..31a9c48af4 --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,597 @@ +// DevOps Info Service in Go. +package main + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + serviceName = "devops-info-service" + serviceVersion = "1.12.0" + serviceDescription = "DevOps course info service" + serviceFramework = "Go net/http" + serviceLoggerName = "devops_info_service" + accessLoggerName = "http.access" +) + +type ServiceInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type SystemInfo struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + PythonVersion string `json:"python_version"` +} + +type UptimeInfo struct { + Seconds int64 `json:"seconds"` + Human string `json:"human"` +} + +type RequestInfo struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type EndpointInfo struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type RootResponse struct { + Service ServiceInfo `json:"service"` + System SystemInfo `json:"system"` + Runtime UptimeInfo `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []EndpointInfo `json:"endpoints"` +} + +type StatusResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int64 `json:"uptime_seconds"` +} + +type VisitsResponse struct { + Visits int `json:"visits"` +} + +var ( + // startTime is used for uptime calculations. + startTime = time.Now().UTC() + logMu sync.Mutex + visitsMu sync.Mutex + logOutput io.Writer = os.Stdout + visitsFilePath = "/data/visits" + // metricsRegistry only exposes service metrics, matching the Python app. + metricsRegistry = prometheus.NewRegistry() + httpRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total HTTP requests handled by the service.", + }, + []string{"method", "endpoint", "status_code"}, + ) + httpRequestDurationSeconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request duration in seconds.", + }, + []string{"method", "endpoint", "status_code"}, + ) + httpRequestsInProgress = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "http_requests_in_progress", + Help: "HTTP requests currently being processed.", + }, + []string{"method", "endpoint"}, + ) + endpointCallsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "devops_info_endpoint_calls_total", + Help: "Total calls to application endpoints.", + }, + []string{"endpoint"}, + ) + systemInfoDurationSeconds = prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "devops_info_system_info_duration_seconds", + Help: "Time spent collecting system information.", + }, + ) + metricsHTTPHandler = promhttp.HandlerFor( + metricsRegistry, + promhttp.HandlerOpts{}, + ) + // endpoints is a static list used to mirror the Python app output. + endpoints = []EndpointInfo{ + {Path: "/", Method: http.MethodGet, Description: "Service information."}, + {Path: "/visits", Method: http.MethodGet, Description: "Visits counter."}, + {Path: "/health", Method: http.MethodGet, Description: "Health check."}, + {Path: "/ready", Method: http.MethodGet, Description: "Readiness check."}, + {Path: "/metrics", Method: http.MethodGet, Description: "Prometheus metrics."}, + } +) + +func init() { + metricsRegistry.MustRegister( + httpRequestsTotal, + httpRequestDurationSeconds, + httpRequestsInProgress, + endpointCallsTotal, + systemInfoDurationSeconds, + ) +} + +type responseRecorder struct { + http.ResponseWriter + statusCode int + bytesWritten int +} + +// getServiceInfo returns static service metadata. +func getServiceInfo() ServiceInfo { + return ServiceInfo{ + Name: serviceName, + Version: serviceVersion, + Description: serviceDescription, + Framework: serviceFramework, + } +} + +// getSystemInfo returns host and runtime information. +func getSystemInfo() SystemInfo { + startedAt := time.Now() + defer systemInfoDurationSeconds.Observe(time.Since(startedAt).Seconds()) + + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + return SystemInfo{ + Hostname: hostname, + Platform: platformName(), + PlatformVersion: platformVersion(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + PythonVersion: runtime.Version(), + } +} + +// platformName maps GOOS to a human-readable name. +func platformName() string { + switch runtime.GOOS { + case "linux": + return "Linux" + case "windows": + return "Windows" + case "darwin": + return "Darwin" + default: + return runtime.GOOS + } +} + +// platformVersion attempts to return a friendly OS version. +func platformVersion() string { + switch runtime.GOOS { + case "linux": + if pretty := linuxPrettyName(); pretty != "" { + return pretty + } + case "windows": + if osName := os.Getenv("OS"); osName != "" { + return osName + } + } + + return runtime.GOOS +} + +// linuxPrettyName reads PRETTY_NAME from /etc/os-release if available. +func linuxPrettyName() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "" + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "PRETTY_NAME=") { + value := strings.TrimPrefix(line, "PRETTY_NAME=") + return strings.Trim(value, "\"") + } + } + + return "" +} + +// getUptime returns elapsed time since startTime. +func getUptime() UptimeInfo { + seconds := int64(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + + return UptimeInfo{ + Seconds: seconds, + Human: fmt.Sprintf("%d hours, %d minutes", hours, minutes), + } +} + +// getRequestInfo captures minimal request metadata. +func getRequestInfo(r *http.Request) RequestInfo { + return RequestInfo{ + ClientIP: clientIP(r), + UserAgent: r.Header.Get("User-Agent"), + Method: r.Method, + Path: r.URL.Path, + } +} + +// clientIP attempts to derive the client IP from proxy headers or RemoteAddr. +func clientIP(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + return strings.TrimSpace(parts[0]) + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + + return r.RemoteAddr +} + +func readVisitsCount() int { + data, err := os.ReadFile(visitsFilePath) + if err != nil { + if os.IsNotExist(err) { + return 0 + } + + emitLog("WARNING", serviceLoggerName, "failed to read visits counter", map[string]any{ + "error": err.Error(), + "path": visitsFilePath, + }) + return 0 + } + + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + emitLog("WARNING", serviceLoggerName, "invalid visits counter, resetting to zero", map[string]any{ + "path": visitsFilePath, + "value": "", + }) + return 0 + } + + count, err := strconv.Atoi(trimmed) + if err != nil || count < 0 { + emitLog("WARNING", serviceLoggerName, "invalid visits counter, resetting to zero", map[string]any{ + "path": visitsFilePath, + "value": trimmed, + }) + return 0 + } + + return count +} + +func writeVisitsCount(count int) error { + if err := os.MkdirAll(filepath.Dir(visitsFilePath), 0o755); err != nil { + return err + } + + return os.WriteFile(visitsFilePath, []byte(fmt.Sprintf("%d\n", count)), 0o644) +} + +func getVisitsCount() int { + visitsMu.Lock() + defer visitsMu.Unlock() + + return readVisitsCount() +} + +func incrementVisitsCount() int { + visitsMu.Lock() + defer visitsMu.Unlock() + + count := readVisitsCount() + 1 + if err := writeVisitsCount(count); err != nil { + emitLog("WARNING", serviceLoggerName, "failed to persist visits counter", map[string]any{ + "error": err.Error(), + "path": visitsFilePath, + "value": count, + }) + } + + return count +} + +// listEndpoints returns the advertised endpoints for the root response. +func listEndpoints() []EndpointInfo { + return endpoints +} + +func normalizeEndpointLabel(path string) string { + switch path { + case "/", "/health", "/metrics", "/ready", "/visits": + return path + default: + return "unmatched" + } +} + +func recordEndpointCall(endpoint string) { + endpointCallsTotal.WithLabelValues(endpoint).Inc() +} + +func newResponseRecorder(w http.ResponseWriter) *responseRecorder { + return &responseRecorder{ + ResponseWriter: w, + statusCode: http.StatusOK, + } +} + +func (recorder *responseRecorder) WriteHeader(statusCode int) { + recorder.statusCode = statusCode + recorder.ResponseWriter.WriteHeader(statusCode) +} + +func (recorder *responseRecorder) Write(data []byte) (int, error) { + written, err := recorder.ResponseWriter.Write(data) + recorder.bytesWritten += written + return written, err +} + +func emitLog(level, loggerName, message string, fields map[string]any) { + payload := map[string]any{ + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "level": level, + "logger": loggerName, + } + + if message != "" { + payload["message"] = message + } + + for key, value := range fields { + payload[key] = value + } + + encoded, err := json.Marshal(payload) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal log entry: %v\n", err) + return + } + + logMu.Lock() + defer logMu.Unlock() + + if _, err := fmt.Fprintln(logOutput, string(encoded)); err != nil { + fmt.Fprintf(os.Stderr, "failed to write log entry: %v\n", err) + } +} + +func queryString(r *http.Request) string { + if r.URL.RawQuery == "" { + return "" + } + + return "?" + r.URL.RawQuery +} + +// mainHandler serves GET /. +func mainHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/") + incrementVisitsCount() + payload := RootResponse{ + Service: getServiceInfo(), + System: getSystemInfo(), + Runtime: getUptime(), + Request: getRequestInfo(r), + Endpoints: listEndpoints(), + } + + writeJSON(w, http.StatusOK, payload) +} + +// visitsHandler serves GET /visits. +func visitsHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/visits") + writeJSON(w, http.StatusOK, VisitsResponse{ + Visits: getVisitsCount(), + }) +} + +// healthHandler serves GET /health. +func healthHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/health") + writeStatusResponse(w, "healthy") +} + +// readinessHandler serves GET /ready. +func readinessHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/ready") + writeStatusResponse(w, "ready") +} + +func writeStatusResponse(w http.ResponseWriter, status string) { + payload := StatusResponse{ + Status: status, + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05.000000-07:00"), + UptimeSeconds: getUptime().Seconds, + } + + writeJSON(w, http.StatusOK, payload) +} + +// metricsHandler serves GET /metrics. +func metricsHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/metrics") + metricsHTTPHandler.ServeHTTP(w, r) +} + +// notFound returns a JSON 404. +func notFound(w http.ResponseWriter, r *http.Request) { + emitLog("WARNING", serviceLoggerName, "request returned not found", map[string]any{ + "client_ip": clientIP(r), + "method": r.Method, + "path": r.URL.Path, + "status_code": http.StatusNotFound, + "user_agent": r.Header.Get("User-Agent"), + }) + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "Not Found", + "message": "Endpoint does not exist", + }) +} + +// router dispatches requests to handlers. +func router(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/" && r.Method == http.MethodGet: + mainHandler(w, r) + case r.URL.Path == "/visits" && r.Method == http.MethodGet: + visitsHandler(w, r) + case r.URL.Path == "/health" && r.Method == http.MethodGet: + healthHandler(w, r) + case r.URL.Path == "/metrics" && r.Method == http.MethodGet: + metricsHandler(w, r) + case r.URL.Path == "/ready" && r.Method == http.MethodGet: + readinessHandler(w, r) + default: + notFound(w, r) + } +} + +// recoverMiddleware converts panics into JSON 500 responses. +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + emitLog("ERROR", serviceLoggerName, "request panic recovered", map[string]any{ + "error": fmt.Sprint(err), + "client_ip": clientIP(r), + "method": r.Method, + "path": r.URL.Path, + "query": queryString(r), + "user_agent": r.Header.Get("User-Agent"), + }) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }) + } + }() + next.ServeHTTP(w, r) + }) +} + +func requestLoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startedAt := time.Now() + recorder := newResponseRecorder(w) + + next.ServeHTTP(recorder, r) + + emitLog("INFO", accessLoggerName, "", map[string]any{ + "client_ip": clientIP(r), + "method": r.Method, + "path": r.URL.Path, + "query": queryString(r), + "status_code": recorder.statusCode, + "response_bytes": fmt.Sprintf("%d", recorder.bytesWritten), + "request_time_us": time.Since(startedAt).Microseconds(), + "user_agent": r.Header.Get("User-Agent"), + }) + }) +} + +func metricsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + endpoint := normalizeEndpointLabel(r.URL.Path) + httpRequestsInProgress.WithLabelValues(r.Method, endpoint).Inc() + defer httpRequestsInProgress.WithLabelValues(r.Method, endpoint).Dec() + + startedAt := time.Now() + recorder := newResponseRecorder(w) + + next.ServeHTTP(recorder, r) + + statusCode := strconv.Itoa(recorder.statusCode) + httpRequestsTotal.WithLabelValues(r.Method, endpoint, statusCode).Inc() + httpRequestDurationSeconds.WithLabelValues( + r.Method, + endpoint, + statusCode, + ).Observe(time.Since(startedAt).Seconds()) + }) +} + +// writeJSON serializes a payload with the given status code. +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + emitLog("ERROR", serviceLoggerName, "failed to encode response", map[string]any{ + "status_code": status, + "error": err.Error(), + }) + } +} + +func main() { + host := os.Getenv("HOST") + if host == "" { + host = "0.0.0.0" + } + + port := os.Getenv("PORT") + if port == "" { + port = "5000" + } + + addr := net.JoinHostPort(host, port) + emitLog("INFO", serviceLoggerName, "application starting", map[string]any{ + "address": addr, + "service": serviceName, + "version": serviceVersion, + }) + + handler := requestLoggingMiddleware( + metricsMiddleware(recoverMiddleware(http.HandlerFunc(router))), + ) + if err := http.ListenAndServe(addr, handler); err != nil { + emitLog("ERROR", serviceLoggerName, "server error", map[string]any{ + "error": err.Error(), + }) + os.Exit(1) + } +} diff --git a/app_go/main_test.go b/app_go/main_test.go new file mode 100644 index 0000000000..dbb7d9d79d --- /dev/null +++ b/app_go/main_test.go @@ -0,0 +1,595 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func captureLogOutput(w io.Writer) func() { + logMu.Lock() + previous := logOutput + logOutput = w + logMu.Unlock() + + return func() { + logMu.Lock() + logOutput = previous + logMu.Unlock() + } +} + +func decodeLogEntries(t *testing.T, buffer *bytes.Buffer) []map[string]any { + t.Helper() + + lines := bytes.Split(bytes.TrimSpace(buffer.Bytes()), []byte("\n")) + entries := make([]map[string]any, 0, len(lines)) + + for _, line := range lines { + if len(line) == 0 { + continue + } + + var entry map[string]any + if err := json.Unmarshal(line, &entry); err != nil { + t.Fatalf("failed to decode log entry: %v", err) + } + entries = append(entries, entry) + } + + if len(entries) == 0 { + t.Fatal("expected at least one log entry") + } + + return entries +} + +func decodeLogEntry(t *testing.T, buffer *bytes.Buffer) map[string]any { + t.Helper() + + entries := decodeLogEntries(t, buffer) + if len(entries) != 1 { + t.Fatalf("expected exactly one log line, got %d", len(entries)) + } + + return entries[0] +} + +func decodeJSONResponse[T any](t *testing.T, recorder *httptest.ResponseRecorder) T { + t.Helper() + + var payload T + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode JSON response: %v", err) + } + + return payload +} + +func performRequest(handler http.Handler, method, path string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, nil) + request.RemoteAddr = "203.0.113.7:4321" + request.Header.Set("User-Agent", "go-test") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder +} + +func withTempVisitsFile(t *testing.T) string { + t.Helper() + + oldPath := visitsFilePath + visitsFilePath = filepath.Join(t.TempDir(), "visits") + t.Cleanup(func() { + visitsFilePath = oldPath + }) + + return visitsFilePath +} + +func metricValue(metricsText, sampleName string, labels map[string]string) (float64, bool) { + for _, line := range strings.Split(metricsText, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + + metricName, metricLabels := parseMetricSample(fields[0]) + if metricName != sampleName { + continue + } + if !labelsMatch(metricLabels, labels) { + continue + } + + value, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + return 0, false + } + return value, true + } + + return 0, false +} + +func parseMetricSample(sample string) (string, map[string]string) { + openBrace := strings.Index(sample, "{") + if openBrace == -1 { + return sample, map[string]string{} + } + + name := sample[:openBrace] + labelText := strings.TrimSuffix(sample[openBrace+1:], "}") + labels := map[string]string{} + if labelText == "" { + return name, labels + } + + for _, part := range strings.Split(labelText, ",") { + key, value, found := strings.Cut(part, "=") + if !found { + continue + } + labels[key] = strings.Trim(value, "\"") + } + + return name, labels +} + +func labelsMatch(actual map[string]string, expected map[string]string) bool { + for key, value := range expected { + if actual[key] != value { + return false + } + } + return true +} + +func scrapeMetrics(t *testing.T) string { + t.Helper() + + recorder := performRequest(http.HandlerFunc(metricsHandler), http.MethodGet, "/metrics") + if recorder.Code != http.StatusOK { + t.Fatalf("expected metrics status %d, got %d", http.StatusOK, recorder.Code) + } + + return recorder.Body.String() +} + +func TestIndexReturnsExpectedJSONStructureAndTypes(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[RootResponse](t, recorder) + if payload.Service.Name != serviceName { + t.Fatalf("expected service name %q, got %q", serviceName, payload.Service.Name) + } + if payload.Service.Framework != serviceFramework { + t.Fatalf("expected framework %q, got %q", serviceFramework, payload.Service.Framework) + } + if payload.Service.Version == "" { + t.Fatal("expected non-empty version") + } + if payload.System.Hostname == "" { + t.Fatal("expected hostname to be populated") + } + if payload.System.CPUCount < 1 { + t.Fatalf("expected cpu_count >= 1, got %d", payload.System.CPUCount) + } + if payload.Runtime.Seconds < 0 { + t.Fatalf("expected non-negative uptime, got %d", payload.Runtime.Seconds) + } + if payload.Request.ClientIP != "203.0.113.7" { + t.Fatalf("expected client_ip %q, got %q", "203.0.113.7", payload.Request.ClientIP) + } + + routeIndex := map[string]bool{} + for _, endpoint := range payload.Endpoints { + routeIndex[endpoint.Method+" "+endpoint.Path] = true + } + + for _, route := range []string{ + http.MethodGet + " /", + http.MethodGet + " /visits", + http.MethodGet + " /health", + http.MethodGet + " /ready", + http.MethodGet + " /metrics", + } { + if !routeIndex[route] { + t.Fatalf("expected endpoint %q to be listed", route) + } + } +} + +func TestVisitsEndpointDefaultsToZeroWhenFileMissing(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + withTempVisitsFile(t) + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[VisitsResponse](t, recorder) + if payload.Visits != 0 { + t.Fatalf("expected visits to default to 0, got %d", payload.Visits) + } + + if _, err := os.Stat(visitsFilePath); !os.IsNotExist(err) { + t.Fatalf("expected visits file to remain absent, got err=%v", err) + } +} + +func TestRootIncrementsVisitsCounterAndPersistsFile(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + withTempVisitsFile(t) + + first := performRequest(http.HandlerFunc(router), http.MethodGet, "/") + if first.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, first.Code) + } + + payload := decodeJSONResponse[RootResponse](t, first) + routeIndex := map[string]bool{} + for _, endpoint := range payload.Endpoints { + routeIndex[endpoint.Method+" "+endpoint.Path] = true + } + for _, route := range []string{ + http.MethodGet + " /", + http.MethodGet + " /visits", + http.MethodGet + " /health", + http.MethodGet + " /ready", + http.MethodGet + " /metrics", + } { + if !routeIndex[route] { + t.Fatalf("expected endpoint %q to be listed", route) + } + } + + data, err := os.ReadFile(visitsFilePath) + if err != nil { + t.Fatalf("expected visits file to be created: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "1" { + t.Fatalf("expected visits file to contain 1 after first root request, got %q", got) + } + + second := performRequest(http.HandlerFunc(router), http.MethodGet, "/") + if second.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, second.Code) + } + + data, err = os.ReadFile(visitsFilePath) + if err != nil { + t.Fatalf("expected visits file to remain readable: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "2" { + t.Fatalf("expected visits file to contain 2 after second root request, got %q", got) + } + + visits := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits") + if visits.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, visits.Code) + } + + count := decodeJSONResponse[VisitsResponse](t, visits) + if count.Visits != 2 { + t.Fatalf("expected visits endpoint to report 2, got %d", count.Visits) + } +} + +func TestVisitsEndpointFallsBackToZeroForMalformedFile(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + withTempVisitsFile(t) + + if err := os.WriteFile(visitsFilePath, []byte("broken"), 0o644); err != nil { + t.Fatalf("failed to seed malformed visits file: %v", err) + } + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[VisitsResponse](t, recorder) + if payload.Visits != 0 { + t.Fatalf("expected malformed counter to fall back to 0, got %d", payload.Visits) + } + + after := performRequest(http.HandlerFunc(router), http.MethodGet, "/") + if after.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, after.Code) + } + + data, err := os.ReadFile(visitsFilePath) + if err != nil { + t.Fatalf("expected visits file to be repaired by root request: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "1" { + t.Fatalf("expected repaired visits file to contain 1, got %q", got) + } +} + +func TestHealthReturnsExpectedJSONStructureAndTypes(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/health") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[StatusResponse](t, recorder) + if payload.Status != "healthy" { + t.Fatalf("expected status %q, got %q", "healthy", payload.Status) + } + if payload.UptimeSeconds < 0 { + t.Fatalf("expected non-negative uptime, got %d", payload.UptimeSeconds) + } + if payload.Timestamp == "" { + t.Fatal("expected non-empty timestamp") + } +} + +func TestReadyReturnsExpectedJSONStructureAndTypes(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/ready") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[StatusResponse](t, recorder) + if payload.Status != "ready" { + t.Fatalf("expected status %q, got %q", "ready", payload.Status) + } + if payload.UptimeSeconds < 0 { + t.Fatalf("expected non-negative uptime, got %d", payload.UptimeSeconds) + } + if payload.Timestamp == "" { + t.Fatal("expected non-empty timestamp") + } +} + +func TestUnknownEndpointReturnsJSON404(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/missing") + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code) + } + + payload := decodeJSONResponse[map[string]string](t, recorder) + expected := map[string]string{ + "error": "Not Found", + "message": "Endpoint does not exist", + } + if payload["error"] != expected["error"] || payload["message"] != expected["message"] { + t.Fatalf("expected %#v, got %#v", expected, payload) + } +} + +func TestNotFoundEmitsJSONWarningLog(t *testing.T) { + var buffer bytes.Buffer + restore := captureLogOutput(&buffer) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/missing") + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code) + } + + entry := decodeLogEntry(t, &buffer) + if entry["level"] != "WARNING" { + t.Fatalf("expected WARNING level, got %#v", entry["level"]) + } + if entry["logger"] != serviceLoggerName { + t.Fatalf("expected logger %q, got %#v", serviceLoggerName, entry["logger"]) + } + if entry["message"] != "request returned not found" { + t.Fatalf("expected message to be logged, got %#v", entry["message"]) + } + if entry["status_code"] != float64(http.StatusNotFound) { + t.Fatalf("expected status_code %d, got %#v", http.StatusNotFound, entry["status_code"]) + } +} + +func TestRequestLoggingMiddlewareEmitsJSONAccessLog(t *testing.T) { + var buffer bytes.Buffer + restore := captureLogOutput(&buffer) + defer restore() + + handler := requestLoggingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + + request := httptest.NewRequest(http.MethodGet, "/health?full=1", nil) + request.RemoteAddr = "203.0.113.10:4321" + request.Header.Set("User-Agent", "go-test") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusCreated { + t.Fatalf("expected status %d, got %d", http.StatusCreated, recorder.Code) + } + + entry := decodeLogEntry(t, &buffer) + if entry["level"] != "INFO" { + t.Fatalf("expected INFO level, got %#v", entry["level"]) + } + if entry["logger"] != accessLoggerName { + t.Fatalf("expected logger %q, got %#v", accessLoggerName, entry["logger"]) + } + if entry["client_ip"] != "203.0.113.10" { + t.Fatalf("expected client_ip to be logged, got %#v", entry["client_ip"]) + } + if entry["method"] != http.MethodGet { + t.Fatalf("expected method to be logged, got %#v", entry["method"]) + } + if entry["path"] != "/health" { + t.Fatalf("expected path to be logged, got %#v", entry["path"]) + } + if entry["query"] != "?full=1" { + t.Fatalf("expected query string to be logged, got %#v", entry["query"]) + } + if entry["status_code"] != float64(http.StatusCreated) { + t.Fatalf("expected status_code to be logged, got %#v", entry["status_code"]) + } + if entry["response_bytes"] != "11" { + t.Fatalf("expected response_bytes to be logged, got %#v", entry["response_bytes"]) + } + if _, ok := entry["request_time_us"].(float64); !ok { + t.Fatalf("expected request_time_us to be numeric, got %#v", entry["request_time_us"]) + } + if entry["user_agent"] != "go-test" { + t.Fatalf("expected user_agent to be logged, got %#v", entry["user_agent"]) + } + if _, hasMessage := entry["message"]; hasMessage { + t.Fatalf("access log should not include message, got %#v", entry["message"]) + } +} + +func TestRecoverMiddlewareEmitsJSONPanicLog(t *testing.T) { + var buffer bytes.Buffer + restore := captureLogOutput(&buffer) + defer restore() + + handler := recoverMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("boom") + })) + + request := httptest.NewRequest(http.MethodGet, "/explode", nil) + request.RemoteAddr = "203.0.113.20:8080" + request.Header.Set("User-Agent", "go-test") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, recorder.Code) + } + + entry := decodeLogEntry(t, &buffer) + if entry["level"] != "ERROR" { + t.Fatalf("expected ERROR level, got %#v", entry["level"]) + } + if entry["logger"] != serviceLoggerName { + t.Fatalf("expected logger %q, got %#v", serviceLoggerName, entry["logger"]) + } + if entry["message"] != "request panic recovered" { + t.Fatalf("expected panic message to be logged, got %#v", entry["message"]) + } + if entry["error"] != "boom" { + t.Fatalf("expected panic error to be logged, got %#v", entry["error"]) + } + if entry["path"] != "/explode" { + t.Fatalf("expected panic path to be logged, got %#v", entry["path"]) + } + if entry["query"] != "" { + t.Fatalf("expected empty query string, got %#v", entry["query"]) + } + if entry["client_ip"] != "203.0.113.20" { + t.Fatalf("expected client_ip to be logged, got %#v", entry["client_ip"]) + } +} + +func TestMetricsEndpointExposesHTTPAndApplicationMetrics(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + handler := metricsMiddleware(http.HandlerFunc(router)) + + performRequest(handler, http.MethodGet, "/") + performRequest(handler, http.MethodGet, "/health") + performRequest(handler, http.MethodGet, "/ready") + performRequest(handler, http.MethodGet, "/does-not-exist") + + recorder := performRequest(handler, http.MethodGet, "/metrics") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + if !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("expected text/plain content type, got %q", recorder.Header().Get("Content-Type")) + } + + metricsText := recorder.Body.String() + for _, tc := range []struct { + name string + labels map[string]string + }{ + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/", "status_code": "200"}}, + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/health", "status_code": "200"}}, + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/ready", "status_code": "200"}}, + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "unmatched", "status_code": "404"}}, + {name: "http_request_duration_seconds_count", labels: map[string]string{"method": "GET", "endpoint": "/", "status_code": "200"}}, + {name: "devops_info_endpoint_calls_total", labels: map[string]string{"endpoint": "/"}}, + {name: "devops_info_endpoint_calls_total", labels: map[string]string{"endpoint": "/ready"}}, + {name: "devops_info_system_info_duration_seconds_count", labels: map[string]string{}}, + } { + value, ok := metricValue(metricsText, tc.name, tc.labels) + if !ok || value < 1.0 { + t.Fatalf("expected %s with labels %#v to be >= 1, got ok=%v value=%v", tc.name, tc.labels, ok, value) + } + } + + value, ok := metricValue( + metricsText, + "http_requests_in_progress", + map[string]string{"method": "GET", "endpoint": "/"}, + ) + if !ok || value != 0.0 { + t.Fatalf("expected in-progress gauge to be 0, got ok=%v value=%v", ok, value) + } +} + +func TestMetricsCountInternalServerErrorsWithStatusLabels(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + labels := map[string]string{"method": "GET", "endpoint": "/", "status_code": "500"} + before, _ := metricValue(scrapeMetrics(t), "http_requests_total", labels) + + handler := metricsMiddleware(recoverMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("boom") + }))) + recorder := performRequest(handler, http.MethodGet, "/") + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, recorder.Code) + } + + after, ok := metricValue(scrapeMetrics(t), "http_requests_total", labels) + if !ok { + t.Fatalf("expected %s with labels %#v to exist after panic request", "http_requests_total", labels) + } + if after != before+1.0 { + t.Fatalf("expected counter to increase by 1, got before=%v after=%v", before, after) + } +} diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..deb2fd9687 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,5 @@ +* +!src/** +!pyproject.toml +!poetry.lock +!gunicorn.conf.py diff --git a/app_python/.flake8 b/app_python/.flake8 new file mode 100644 index 0000000000..63c477b455 --- /dev/null +++ b/app_python/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 100 +max-complexity = 10 +exclude = .*,docs,*/__pycache__ diff --git a/app_python/.gitattributes b/app_python/.gitattributes new file mode 100644 index 0000000000..e48505a0fe --- /dev/null +++ b/app_python/.gitattributes @@ -0,0 +1,30 @@ +# https://github.com/gitattributes/gitattributes/blob/fddc586cf0f10ec4485028d0d2dd6f73197a4258/Python.gitattributes +# Basic .gitattributes for a python repo. + +# Source files +# ============ +*.pxd text diff=python +*.py text diff=python +*.py3 text diff=python +*.pyw text diff=python +*.pyx text diff=python +*.pyz text diff=python +*.pyi text diff=python + +# Binary files +# ============ +*.db binary +*.p binary +*.pkl binary +*.pickle binary +*.pyc binary export-ignore +*.pyo binary export-ignore +*.pyd binary + +# Jupyter notebook +*.ipynb text eol=lf + +# Note: .db, .p, and .pkl files are associated +# with the python modules ``pickle``, ``dbm.*``, +# ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` +# (among others). diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..c33866fe47 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,217 @@ +# https://github.com/github/gitignore/blob/53fee13f20a05efc93ef4edcad0c62863520e268/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +.vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..a123b9e3be --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.14-alpine +RUN apk upgrade -U + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV POETRY_VERSION=2.3.2 + +RUN pip install --no-cache-dir "poetry==$POETRY_VERSION" \ + && addgroup appgroup \ + && adduser --disabled-password --gecos "" --no-create-home -s /bin/sh appuser -G appgroup + +WORKDIR /app + +COPY pyproject.toml poetry.lock gunicorn.conf.py ./ +RUN poetry config virtualenvs.create false \ + && poetry install --only main --no-interaction --no-ansi --no-root + +COPY src ./src + +ENV PORT=5000 +ENV HOST="0.0.0.0" + +USER appuser +CMD ["sh", "-c", "gunicorn --config /app/gunicorn.conf.py src.main:app"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..e9e3dd1bca --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,105 @@ +# DevOps Info Service + +[![Python CI](https://github.com/LocalT0aster/DevOps-Core-S26/actions/workflows/python-ci.yml/badge.svg)](https://github.com/LocalT0aster/DevOps-Core-S26/actions/workflows/python-ci.yml) + +## Overview + +Small Flask web service that reports service metadata, system information, runtime uptime, and basic request details. Includes a persistent visits counter stored at `/data/visits`, plus health, readiness, and Prometheus metrics endpoints for monitoring. + +## Prerequisites + +- Python 3.14+ +- Poetry + +## Installation + +```bash +poetry install +``` + +### Docker + +- Pull the container: + ```bash + docker pull localt0aster/devops-app-py + ``` +- OR build the container yourself: + ```bash + docker build -t localt0aster/devops-app-py . + ``` + The Docker build installs dependencies with: + ```bash + poetry install --only main --no-root + ``` + +## Running the Application + +Production-style local run with Gunicorn: + +```bash +poetry run gunicorn --config gunicorn.conf.py src.main:app +HOST=127.0.0.1 PORT=8080 poetry run gunicorn --config gunicorn.conf.py src.main:app +``` + +Gunicorn access logs are emitted as JSON so Loki can parse request fields cleanly. + +### Docker + +- Run the container: + ```bash + docker run -p 5000:5000 -e HOST="0.0.0.0" -d localt0aster/devops-app-py + ``` + +## API Endpoints + +- `GET /` - Service and system information +- `GET /visits` - Current persisted visit counter +- `GET /health` - Health check +- `GET /ready` - Readiness check +- `GET /metrics` - Prometheus metrics exposition + +## Visits Counter + +- The root handler increments the counter on every `GET /`. +- The counter is persisted as plain text in `/data/visits`. +- If the file is missing, the service starts from `0`. +- If the file is malformed, empty, or negative, the service logs a warning and treats the value as `0`. + +## Local Docker Check + +For Lab 12, run the monitoring stack with a writable `/data` volume for the Python container and verify that: + +- repeated `GET /` calls increment the counter +- `GET /visits` returns the current count +- the counter survives a container restart because the backing file is persisted on the host + +## Configuration + +| Variable | Default | Description | +| -------- | --------- | ---------------------------------------- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | +| `DEBUG` | `False` | Enable Flask debug mode (`true`/`false`) | + +## Testing + +The project uses `pytest` for unit tests. + +```bash +poetry install --with dev +poetry run pytest --cov=src --cov-report=term-missing +``` + +The test suite covers: + +- `GET /` response schema and visits counter increment behavior +- `GET /visits` bootstrap, persisted reads, and malformed-file fallback +- `GET /health` successful response schema and types +- `404` JSON error handling for unknown routes +- `500` JSON error handling for simulated internal failures + +## Linting + +```bash +poetry run flake8 src tests +``` diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..07894b7146 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,191 @@ +# LAB01 - DevOps Info Service (Python) + +## Framework Selection + +**Choice:** Flask +**Why:** I did not know any Python web framework for APIs, and Flask felt simplest to start with. Most lab examples were in Flask, so it reduced friction. + +**Comparison (concise):** + +| Framework | Pros | Cons | Fit for this lab | +| -------------- | ------------------------------------ | ------------------------------ | ----------------------------------------- | +| Flask (chosen) | Minimal, easy to learn, flexible | Fewer batteries included | Best for a small info service | +| FastAPI | Great typing, auto docs, async ready | Slightly more concepts upfront | Good, but extra overhead for a simple lab | +| Django | Full stack, ORM, auth | Heavy for a tiny API | Overkill here | + +## Best Practices Applied + +Below are concrete examples from `app.py` and why they matter. + +1. **Configuration via env vars** - makes the service configurable without code changes. + +```python +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" +``` + +2. **Clear separation into helper functions** - keeps endpoints small and readable. + +```python +def get_system_info() -> dict[str, str | int]: + ... + +def get_uptime(): + ... +``` + +3. **Logging** - provides startup and request diagnostics. + +```python +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger.info("Application starting...") +``` + +4. **Error handling** - consistent JSON errors for clients. + +```python +@app.errorhandler(404) +def not_found(error): + return jsonify({"error": "Not Found", "message": "Endpoint does not exist"}), 404 +``` + +## API Documentation + +### Endpoints + +- `GET /` - service + system + runtime + request info +- `GET /health` - health check + +### Example Requests + +```bash +curl -sS http://127.0.0.1:5000/ | jq +curl -sS http://127.0.0.1:5000/health | jq +``` + +### Example Responses + +`GET /`: + +```json +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.18.0" + }, + "runtime": { + "human": "0 hours, 6 minutes", + "seconds": 418 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 8, + "hostname": "aSUS-sTUFf-arch", + "platform": "Linux", + "platform_version": "Arch Linux", + "python_version": "3.14.2" + } +} +``` + +`GET /health`: + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T20:04:59.234201+00:00", + "uptime_seconds": 426 +} +``` + +## Testing Evidence + +### Screenshot + +![Screenshot of a terminal](img/lab01.png) + +### Output + +```js +$ curl -sS 127.0.0.1:3926 | jq +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.18.0" + }, + "runtime": { + "human": "0 hours, 6 minutes", + "seconds": 418 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 8, + "hostname": "aSUS-sTUFf-arch", + "platform": "Linux", + "platform_version": "Arch Linux", + "python_version": "3.14.2" + } +} +``` + +```js +$ curl -sS 127.0.0.1:3926/health | jq +{ + "status": "healthy", + "timestamp": "2026-01-28T20:04:59.234201+00:00", + "uptime_seconds": 426 +} +``` + +## Challenges & Solutions + +1. **Framework choice** - I went with Flask because I did not know any Python API framework and Flask looked simplest; the lab examples were already in Flask. +2. **Listing endpoints dynamically** - I struggled with Flask's routing introspection; a StackOverflow snippet didn't work, and ChatGPT helped me craft a working approach. + +## GitHub Community + +Starring repositories helps me bookmark useful projects and signal appreciation, which improves visibility in open source. Following developers keeps me aware of what classmates and maintainers are doing, supporting collaboration and professional growth. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..e650b5a669 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,288 @@ +# LAB02 - Docker Containerization (Python) + +## Docker Best Practices Applied + +1. **Pinned base image version** - guarantees repeatable builds and avoids unexpected upstream changes. + +```Dockerfile +FROM python:3.14-alpine +``` + +2. **Non-root user** - reduces blast radius if the app is compromised. + +```Dockerfile +RUN addgroup appgroup && adduser --disabled-password --gecos "" --no-create-home -s /bin/sh appuser -G appgroup +USER appuser +``` + +3. **Layer caching for dependencies** - installing requirements before copying the full app keeps rebuilds fast when only code changes. + +```Dockerfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +``` + +4. **Minimal build context via .dockerignore** - avoids sending unrelated files (venv, git, docs) to the build context. + +```dockerignore +* +!app.py +!requirements.txt +!tests/* +``` + +5. **No pip cache** - prevents leaving package download caches in the image. + +```Dockerfile +RUN pip install --no-cache-dir -r requirements.txt +``` + +6. **Explicit workdir** - ensures all app files live under a single predictable path. + +```Dockerfile +WORKDIR /app +``` + +## Image Information & Decisions + +**Base image chosen:** `python:3.14-alpine` + +**Why:** + +- Pinned Python version for reproducibility. +- Alpine variant keeps the runtime small and reduces attack surface. +- The app is pure-Python, so musl vs glibc compatibility is not an issue here. + +**Final image size:** + +```bash +$ docker inspect -f "{{ .Size }}" localt0aster/devops-app-py:lab.2 | numfmt --to=iec-i --format="%.2f" +22.82Mi +``` + +**Layer structure (top to bottom):** + +- Base image: Python runtime on Alpine. +- User/group creation: creates `appuser` and drops root privileges. +- Workdir: standardizes file locations. +- Dependency layer: copy `requirements.txt`, then install dependencies. +- App layer: copy remaining files into `/app`. +- Cleanup: remove `requirements.txt` (runtime tidiness). +- Runtime config: set `HOST` and `PORT` env vars. +- Switch to non-root user and start app. + +**Optimization choices:** + +- Used Alpine for smaller base image. +- Copied `requirements.txt` separately to maximize build cache hits. +- Used `pip --no-cache-dir` to avoid cached wheel files. +- `.dockerignore` reduces context size and speeds up builds. +- Note: `RUN rm requirements.txt` in a separate layer does not reduce image size; it only removes it from the final filesystem view. + +## Build & Run Process + +
+🔨 Build output + +```log +$ docker build --no-cache --progress=plain -t localt0aster/devops-app-py . +#0 building with "default" instance using docker driver + +#1 [internal] load build definition from Dockerfile +#1 transferring dockerfile: 369B done +#1 DONE 0.0s + +#2 [internal] load metadata for docker.io/library/python:3.14-alpine +#2 DONE 0.5s + +#3 [internal] load .dockerignore +#3 transferring context: 77B done +#3 DONE 0.0s + +#4 [1/7] FROM docker.io/library/python:3.14-alpine@sha256:faee120f7885a06fcc9677922331391fa690d911c020abb9e8025ff3d908e510 +#4 resolve docker.io/library/python:3.14-alpine@sha256:faee120f7885a06fcc9677922331391fa690d911c020abb9e8025ff3d908e510 0.0s done +#4 CACHED + +#5 [internal] load build context +#5 transferring context: 123B done +#5 DONE 0.0s + +#6 [2/7] RUN addgroup appgroup && adduser --disabled-password --gecos "" --no-create-home -s /bin/sh appuser -G appgroup +#6 DONE 0.3s + +#7 [3/7] WORKDIR /app +#7 DONE 0.1s + +#8 [4/7] COPY requirements.txt . +#8 DONE 0.1s + +#9 [5/7] RUN pip install --no-cache-dir -r requirements.txt +#9 4.545 Collecting blinker==1.9.0 (from -r requirements.txt (line 1)) +#9 4.736 Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB) +#9 4.814 Collecting certifi==2026.1.4 (from -r requirements.txt (line 2)) +#9 4.855 Downloading certifi-2026.1.4-py3-none-any.whl.metadata (2.5 kB) +#9 5.098 Collecting charset-normalizer==3.4.4 (from -r requirements.txt (line 3)) +#9 5.140 Downloading charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl.metadata (37 kB) +#9 5.229 Collecting click==8.3.1 (from -r requirements.txt (line 4)) +#9 5.270 Downloading click-8.3.1-py3-none-any.whl.metadata (2.6 kB) +#9 5.417 Collecting Flask==3.1.2 (from -r requirements.txt (line 5)) +#9 5.458 Downloading flask-3.1.2-py3-none-any.whl.metadata (3.2 kB) +#9 5.517 Collecting idna==3.11 (from -r requirements.txt (line 6)) +#9 5.560 Downloading idna-3.11-py3-none-any.whl.metadata (8.4 kB) +#9 5.608 Collecting itsdangerous==2.2.0 (from -r requirements.txt (line 7)) +#9 5.648 Downloading itsdangerous-2.2.0-py3-none-any.whl.metadata (1.9 kB) +#9 5.711 Collecting Jinja2==3.1.6 (from -r requirements.txt (line 8)) +#9 5.752 Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB) +#9 5.877 Collecting MarkupSafe==3.0.3 (from -r requirements.txt (line 9)) +#9 5.928 Downloading markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl.metadata (2.7 kB) +#9 6.003 Collecting requests==2.32.5 (from -r requirements.txt (line 10)) +#9 6.048 Downloading requests-2.32.5-py3-none-any.whl.metadata (4.9 kB) +#9 6.120 Collecting urllib3==2.6.3 (from -r requirements.txt (line 11)) +#9 6.160 Downloading urllib3-2.6.3-py3-none-any.whl.metadata (6.9 kB) +#9 6.251 Collecting Werkzeug==3.1.5 (from -r requirements.txt (line 12)) +#9 6.296 Downloading werkzeug-3.1.5-py3-none-any.whl.metadata (4.0 kB) +#9 6.399 Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB) +#9 6.440 Downloading certifi-2026.1.4-py3-none-any.whl (152 kB) +#9 6.530 Downloading charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl (154 kB) +#9 6.577 Downloading click-8.3.1-py3-none-any.whl (108 kB) +#9 6.620 Downloading flask-3.1.2-py3-none-any.whl (103 kB) +#9 6.662 Downloading idna-3.11-py3-none-any.whl (71 kB) +#9 6.704 Downloading itsdangerous-2.2.0-py3-none-any.whl (16 kB) +#9 6.743 Downloading jinja2-3.1.6-py3-none-any.whl (134 kB) +#9 6.792 Downloading markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl (23 kB) +#9 6.831 Downloading requests-2.32.5-py3-none-any.whl (64 kB) +#9 6.871 Downloading urllib3-2.6.3-py3-none-any.whl (131 kB) +#9 6.915 Downloading werkzeug-3.1.5-py3-none-any.whl (225 kB) +#9 6.985 Installing collected packages: urllib3, MarkupSafe, itsdangerous, idna, click, charset-normalizer, certifi, blinker, Werkzeug, requests, Jinja2, Flask +#9 8.701 +#9 8.709 Successfully installed Flask-3.1.2 Jinja2-3.1.6 MarkupSafe-3.0.3 Werkzeug-3.1.5 blinker-1.9.0 certifi-2026.1.4 charset-normalizer-3.4.4 click-8.3.1 idna-3.11 itsdangerous-2.2.0 requests-2.32.5 urllib3-2.6.3 +#9 8.710 WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning. +#9 9.000 +#9 9.000 [notice] A new release of pip is available: 25.3 -> 26.0.1 +#9 9.000 [notice] To update, run: pip install --upgrade pip +#9 DONE 9.4s + +#10 [6/7] COPY . . +#10 DONE 0.1s + +#11 [7/7] RUN rm requirements.txt +#11 DONE 0.3s + +#12 exporting to image +#12 exporting layers +#12 exporting layers 1.4s done +#12 exporting manifest sha256:0e08d9c814e82ba9bfc64ab9bffca15d59c52f63d1b9db264e10723bf23c2daf +#12 exporting manifest sha256:0e08d9c814e82ba9bfc64ab9bffca15d59c52f63d1b9db264e10723bf23c2daf 0.0s done +#12 exporting config sha256:89b3883bbcb401b8bc8aa815aef1cde31083c25f245921185ce4acae286a51fb 0.0s done +#12 exporting attestation manifest sha256:fbcf722602c9bb0c149874e7052029e55cebf067e88f7448a0282f3b3fb1b926 0.0s done +#12 exporting manifest list sha256:24ce3d2f1f6270cedba6257c73fd1b5105b821025b9e38f87798ca75fba493d7 +#12 exporting manifest list sha256:24ce3d2f1f6270cedba6257c73fd1b5105b821025b9e38f87798ca75fba493d7 0.0s done +#12 naming to docker.io/localt0aster/devops-app-py:latest done +#12 unpacking to docker.io/localt0aster/devops-app-py:latest +#12 unpacking to docker.io/localt0aster/devops-app-py:latest 0.5s done +#12 DONE 2.1s + + 1 warning found (use docker --debug to expand): + - CopyIgnoredFile: Attempting to Copy file "." that is excluded by .dockerignore (line 6) +``` + +
+ +
+🏃 Run output + +```log +$ docker run -p 5000:5000 --rm localt0aster/devops-app-py +2026-02-10 16:52:32,232 - __main__ - INFO - Application starting... + * Serving Flask app 'app' + * Debug mode: off +2026-02-10 16:52:32,238 - werkzeug - INFO - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5000 + * Running on http://172.17.0.2:5000 +2026-02-10 16:52:32,239 - werkzeug - INFO - Press CTRL+C to quit +2026-02-10 16:53:49,498 - werkzeug - INFO - 172.17.0.1 - - [10/Feb/2026 16:53:49] "GET / HTTP/1.1" 200 - +``` + +
+ +
+🛰️ Endpoint test output + +```json +$ curl -Ss 127.0.0.1:5000 | jq +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "172.17.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.14.1" + }, + "runtime": { + "human": "0 hours, 1 minutes", + "seconds": 86 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 1, + "hostname": "bd45062076fd", + "platform": "Linux", + "platform_version": "Alpine Linux v3.23", + "python_version": "3.14.3" + } +} +``` + +
+ +Docker Hub repository URL: + +## Technical Analysis + +**Why this Dockerfile works:** + +- Dependencies are installed before application code, enabling Docker cache reuse. +- Environment variables set defaults that match the Flask app’s config. +- The `CMD` runs the app with `python app.py`, which is the same startup command as local development. +- `USER appuser` prevents the Flask process from running as root. + +**What happens if you change the layer order:** + +- If `COPY . .` comes before `pip install`, any code change will invalidate the cache and force a full dependency reinstall. +- If you install dependencies after copying everything, small edits trigger slower rebuilds. + +**Security considerations implemented:** + +- Non-root user for runtime. +- Minimal base image reduces available tooling and attack surface. +- Pinned base image version reduces supply-chain drift. + +**How .dockerignore improves the build:** + +- Less data sent to the Docker daemon means faster builds. +- Prevents accidental inclusion of venvs, git history, and local artifacts. + +## Challenges & Solutions + +- **Debian to Alpine command differences.** + - Issue: `python:3.14-slim` (Debian) lab examples use `useradd/groupadd`, which don’t exist in `python:3.14-alpine`. + - Fix: use `addgroup` & `adduser` diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..5f12e800e1 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,124 @@ +# LAB03 - Continuous Integration (Python) + +## 1. Overview + +**Testing framework used:** `pytest` + +**Why this choice:** + +- concise assertions and clear failure output +- fixtures simplify Flask test-client setup +- `monkeypatch` enables controlled error-path testing + +**What is covered by tests:** + +- endpoint tests for `GET /` and `GET /health` (success + error behavior) +- JSON schema/type assertions +- helper/unit tests for runtime/platform/request metadata +- entrypoint behavior test for `main.run()` argument wiring + +**Current CI trigger configuration:** + +- workflow files: + - `.github/workflows/python-ci.yml` (lint + tests + coverage reports) + - `.github/workflows/python-snyk.yml` (security scan) + - `.github/workflows/python-docker.yml` (container publish) +- note: Docker login/build/push is intentionally separated into `python-docker.yml` rather than embedded in `python-ci.yml`. +- triggers: + - CI/Snyk: `push` + `pull_request` with path filters + - Docker publish: + - branch pushes to `lab*` publish `1..` + - merged PRs to `master` publish `1.` + `latest` + +**Versioning strategy (SemVer/CalVer):** + +- SemVer-style lab release tags: `1.` + `latest` +- lab number is extracted from merged branch name (example: `lab03` -> `1.3`) + +## 2. Workflow Evidence + +Provide links/terminal output for: + +- Tests passing locally (terminal output below) +- Successful workflow run links (GitHub Actions): + - Python CI: + - Python Docker Publish: + - Python Snyk Scan: +- Docker image on Docker Hub (links): + - Tags page: + - Example pushed tag (`1.3.d4ae1ce`): +- Status badge in `app_python/README.md`: + - + +
+pytest output log + +```log +$ poetry run pytest --cov=src --cov-report=term-missing +========================= test session starts ========================= +platform linux -- Python 3.14.2, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/t0ast/Repos/DevOps-Core-S26/app_python +configfile: pyproject.toml +plugins: anyio-4.12.1, mock-3.15.1, cov-7.0.0 +collected 10 items + +tests/test_endpoints.py ..... [ 50%] +tests/test_unit_helpers.py ..... [100%] + +=========================== tests coverage ============================ +___________ coverage: platform linux, python 3.14.2-final-0 ___________ + +Name Stmts Miss Cover Missing +----------------------------------------------------- +src/flask_instance.py 7 0 100% +src/main.py 10 0 100% +src/router.py 60 0 100% +----------------------------------------------------- +TOTAL 77 0 100% +========================= 10 passed in 0.06s ========================== +``` + +
+ +## 3. Best Practices Implemented + +- **Practice 1: Path-based trigger filtering**: avoids running Python CI when unrelated folders change. +- **Practice 2: Lint + test stages in CI**: catches style and functional issues early. +- **Practice 3: Coverage reporting in CI command**: makes test quality visible, not just pass/fail. +- **Practice 4: Pipeline separation by concern**: test, security, and deploy concerns run independently for clearer failure diagnosis. +- **Practice 5: Reusable setup action**: shared Python/Poetry setup is centralized in `.github/actions/python-setup/action.yml` to avoid duplication. +- **Caching**: `actions/cache` stores `~/.cache/pypoetry` and `app_python/.venv` using a `poetry.lock`-based key. +- **Snyk**: integrated via `snyk/actions/setup` + `snyk test --severity-threshold=high`. +- **Snyk token handling**: workflow skips Snyk step if `SNYK_TOKEN` secret is missing. + +
+Snyk result (run #21961075835) + +``` +Testing /home/runner/work/DevOps-Core-S26/DevOps-Core-S26/app_python... + +Organization: localt0aster +Package manager: poetry +Target file: pyproject.toml +Project name: devops-info-service +Open source: no +Project path: /home/runner/work/DevOps-Core-S26/DevOps-Core-S26/app_python +Licenses: enabled + +✔ Tested 15 dependencies for known issues, no vulnerable paths found. +``` + +
+ +## 4. Key Decisions + +- **Versioning Strategy:** SemVer-style `1.` because releases happen once per lab and are easy to map back to coursework milestones. +- **Docker Tags:** branch builds publish `1..`; merged lab releases publish `1.` and `latest`. +- **Workflow Triggers:** path-filtered pushes/PRs for CI and Snyk, with container publishing gated on merged PRs to `master`. +- **Test Coverage:** endpoint and helper logic are covered; launcher-only code is excluded with pragma. +- **Snyk policy:** CI fails only for vulnerabilities at `high` severity or above. + +## 5. Challenges (Optional) + +- Moving from endpoint-only tests to helper-level unit tests increased meaningful coverage. +- Local and CI environments may have different tool availability; Poetry-based commands are used for reproducibility. diff --git a/app_python/docs/LAB08.md b/app_python/docs/LAB08.md new file mode 100644 index 0000000000..1d947a2350 --- /dev/null +++ b/app_python/docs/LAB08.md @@ -0,0 +1,138 @@ +# LAB08 - Metrics and Monitoring (Task 1) + +## 1. Overview + +Prometheus instrumentation was added to the Flask service using `prometheus-client==0.23.1`. + +Implemented metrics: + +- `http_requests_total` counter with `method`, `endpoint`, and `status_code` +- `http_request_duration_seconds` histogram with `method`, `endpoint`, and `status_code` +- `http_requests_in_progress` gauge with `method` and `endpoint` +- `devops_info_endpoint_calls_total` counter for application endpoint usage +- `devops_info_system_info_duration_seconds` histogram for system-info collection latency + +Labeling choice: + +- Matched routes use normalized Flask rules such as `/`, `/health`, and `/metrics` +- Unmatched requests are grouped under `endpoint="unmatched"` to keep label cardinality low +- The in-progress gauge does not include `status_code` because that value does not exist until a response is produced + +## 2. Verification + +Install and run with the project-local Poetry binary: + +```bash +cd app_python +.venv/bin/poetry install --with dev +.venv/bin/poetry run pytest +.venv/bin/poetry run gunicorn --config gunicorn.conf.py src.main:app +``` + +Generate a few requests, then inspect metrics: + +```bash +curl -fSsL http://127.0.0.1:5000/ | jq +curl -fSsL http://127.0.0.1:5000/health | jq +curl -fSsL http://127.0.0.1:5000/metrics +``` + +
+/metrics output + +```text +$ curl -fSsL http://127.0.0.1:5000/metrics +# HELP http_requests_total Total HTTP requests handled by the service. +# TYPE http_requests_total counter +http_requests_total{endpoint="/",method="GET",status_code="200"} 6.0 +http_requests_total{endpoint="/health",method="GET",status_code="200"} 6.0 +# HELP http_requests_created Total HTTP requests handled by the service. +# TYPE http_requests_created gauge +http_requests_created{endpoint="/",method="GET",status_code="200"} 1.7739616481696362e+09 +http_requests_created{endpoint="/health",method="GET",status_code="200"} 1.7739616482041702e+09 +# HELP http_request_duration_seconds HTTP request duration in seconds. +# TYPE http_request_duration_seconds histogram +http_request_duration_seconds_bucket{endpoint="/",le="0.005",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.01",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.025",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.05",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.075",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.1",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.25",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.5",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="0.75",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="1.0",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="2.5",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="5.0",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="7.5",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="10.0",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/",le="+Inf",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_count{endpoint="/",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_sum{endpoint="/",method="GET",status_code="200"} 0.0015464909993170295 +http_request_duration_seconds_bucket{endpoint="/health",le="0.005",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.01",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.025",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.05",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.075",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.1",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.25",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.5",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="0.75",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="1.0",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="2.5",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="5.0",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="7.5",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="10.0",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_bucket{endpoint="/health",le="+Inf",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_count{endpoint="/health",method="GET",status_code="200"} 6.0 +http_request_duration_seconds_sum{endpoint="/health",method="GET",status_code="200"} 0.0019912700008717366 +# HELP http_request_duration_seconds_created HTTP request duration in seconds. +# TYPE http_request_duration_seconds_created gauge +http_request_duration_seconds_created{endpoint="/",method="GET",status_code="200"} 1.7739616481696527e+09 +http_request_duration_seconds_created{endpoint="/health",method="GET",status_code="200"} 1.7739616482041845e+09 +# HELP http_requests_in_progress HTTP requests currently being processed. +# TYPE http_requests_in_progress gauge +http_requests_in_progress{endpoint="/",method="GET"} 0.0 +http_requests_in_progress{endpoint="/health",method="GET"} 0.0 +http_requests_in_progress{endpoint="/metrics",method="GET"} 1.0 +# HELP devops_info_endpoint_calls_total Total calls to application endpoints. +# TYPE devops_info_endpoint_calls_total counter +devops_info_endpoint_calls_total{endpoint="/"} 6.0 +devops_info_endpoint_calls_total{endpoint="/health"} 6.0 +devops_info_endpoint_calls_total{endpoint="/metrics"} 1.0 +# HELP devops_info_endpoint_calls_created Total calls to application endpoints. +# TYPE devops_info_endpoint_calls_created gauge +devops_info_endpoint_calls_created{endpoint="/"} 1.773961648169205e+09 +devops_info_endpoint_calls_created{endpoint="/health"} 1.7739616482040732e+09 +devops_info_endpoint_calls_created{endpoint="/metrics"} 1.7739616631203315e+09 +# HELP devops_info_system_info_duration_seconds Time spent collecting system information. +# TYPE devops_info_system_info_duration_seconds histogram +devops_info_system_info_duration_seconds_bucket{le="0.005"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.01"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.025"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.05"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.075"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.1"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.25"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.5"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="0.75"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="1.0"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="2.5"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="5.0"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="7.5"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="10.0"} 6.0 +devops_info_system_info_duration_seconds_bucket{le="+Inf"} 6.0 +devops_info_system_info_duration_seconds_count 6.0 +devops_info_system_info_duration_seconds_sum 0.00042895499973383266 +# HELP devops_info_system_info_duration_seconds_created Time spent collecting system information. +# TYPE devops_info_system_info_duration_seconds_created gauge +devops_info_system_info_duration_seconds_created 1.7739616389214125e+09 +``` + +
+ +## 3. Notes + +- HTTP metrics are captured with Flask request hooks so 2xx, 4xx, and 5xx responses are all measured consistently. +- Application-specific metrics are recorded in route handlers and around system-info collection. +- Automated tests cover `/metrics` exposure plus label handling for `200`, `404`, and `500` responses. diff --git a/app_python/docs/img/lab01.png b/app_python/docs/img/lab01.png new file mode 100644 index 0000000000..5316f2f18a Binary files /dev/null and b/app_python/docs/img/lab01.png differ diff --git a/app_python/gunicorn.conf.py b/app_python/gunicorn.conf.py new file mode 100644 index 0000000000..187118541e --- /dev/null +++ b/app_python/gunicorn.conf.py @@ -0,0 +1,17 @@ +"""Gunicorn configuration for container deployment.""" + +from __future__ import annotations + +import os + +bind = f"{os.getenv('HOST', '0.0.0.0')}:{os.getenv('PORT', '5000')}" +workers = int(os.getenv("GUNICORN_WORKERS", "1")) +accesslog = "-" +errorlog = "-" +loglevel = os.getenv("LOG_LEVEL", "info").lower() +access_log_format = ( + '{"timestamp":"%(t)s","level":"INFO","logger":"gunicorn.access",' + '"client_ip":"%(h)s","method":"%(m)s","path":"%(U)s","query":"%(q)s",' + '"status_code":%(s)s,"response_bytes":"%(B)s","request_time_us":%(D)s,' + '"user_agent":"%(a)s"}' +) diff --git a/app_python/poetry.lock b/app_python/poetry.lock new file mode 100644 index 0000000000..82248b12d8 --- /dev/null +++ b/app_python/poetry.lock @@ -0,0 +1,746 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, +] + +[[package]] +name = "click" +version = "8.3.2" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, + {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.13.5" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, + {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, + {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, + {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, + {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, + {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, + {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, + {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, + {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, + {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, + {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, + {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, + {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, + {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, + {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, + {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, + {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, + {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, + {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, + {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, + {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, + {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, + {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "flake8" +version = "7.3.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" + +[[package]] +name = "flask" +version = "3.1.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"}, + {file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "gunicorn" +version = "25.3.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660"}, + {file = "gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.40.3)"] +fast = ["gunicorn_h1c (>=0.6.3)"] +gevent = ["gevent (>=24.10.1)"] +http2 = ["h2 (>=4.1.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet (>=0.40.3)", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"] +tornado = ["tornado (>=6.5.0)"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "pep8-naming" +version = "0.15.1" +description = "Check PEP-8 naming conventions, plugin for flake8" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pep8_naming-0.15.1-py3-none-any.whl", hash = "sha256:eb63925e7fd9e028c7f7ee7b1e413ec03d1ee5de0e627012102ee0222c273c86"}, + {file = "pep8_naming-0.15.1.tar.gz", hash = "sha256:f6f4a499aba2deeda93c1f26ccc02f3da32b035c8b2db9696b730ef2c9639d29"}, +] + +[package.dependencies] +flake8 = ">=5.0.0" + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +description = "Python client for the Prometheus monitoring system." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99"}, + {file = "prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce"}, +] + +[package.extras] +twisted = ["twisted"] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, + {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "requests" +version = "2.33.1" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, + {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "werkzeug" +version = "3.1.8" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50"}, + {file = "werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44"}, +] + +[package.dependencies] +markupsafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14" +content-hash = "978605bae0c54f50d967c46bf36e71be1c8baa8f5deab0c54397546aaa573a3a" diff --git a/app_python/pyproject.toml b/app_python/pyproject.toml new file mode 100644 index 0000000000..43c22bd9cd --- /dev/null +++ b/app_python/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "devops-info-service" +version = "1.12.0" +description = "" +authors = [ + {name = "LocalT0aster",email = "90502400+LocalT0aster@users.noreply.github.com"} +] +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "flask (>=3.1.3,<4.0.0)", + "requests (>=2.32.5,<3.0.0)", + "gunicorn (>=25.1.0,<26.0.0)", + "prometheus-client (==0.23.1)" +] + +[dependency-groups] +dev = [ + "pytest (>=9.0.2,<10.0.0)", + "pytest-cov (>=7.0.0,<8.0.0)", + "flake8 (>=7.3.0,<8.0.0)", + "pep8-naming (>=0.15.1,<0.16.0)" +] + +[tool.poetry] +packages = [{ include = "src" }] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/app_python/src/flask_instance.py b/app_python/src/flask_instance.py new file mode 100644 index 0000000000..13950caaff --- /dev/null +++ b/app_python/src/flask_instance.py @@ -0,0 +1,29 @@ +"""Flask app instance and shared process-level state.""" + +from datetime import datetime, timezone +import os + +from flask import Flask + +try: + from .logging_utils import configure_json_logger +except ImportError: # pragma: no cover - allows `python src/main.py` + from logging_utils import configure_json_logger + +app = Flask("DevOps Info Service") +START_TIME = datetime.now(timezone.utc) # Application start time (UTC). +logger = configure_json_logger("devops_info_service") + +app.logger.handlers = list(logger.handlers) +app.logger.setLevel(logger.level) +app.logger.propagate = False + +logger.info( + "application initialized", + extra={ + "event": "startup", + "host": os.getenv("HOST", "0.0.0.0"), + "port": int(os.getenv("PORT", 5000)), + "debug": os.getenv("DEBUG", "False").lower() == "true", + }, +) diff --git a/app_python/src/logging_utils.py b/app_python/src/logging_utils.py new file mode 100644 index 0000000000..1f4017ee6a --- /dev/null +++ b/app_python/src/logging_utils.py @@ -0,0 +1,73 @@ +"""Shared JSON logging helpers for the Python service.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging +import os +import sys +from typing import Any + +_RESERVED_RECORD_FIELDS = frozenset( + vars(logging.LogRecord("", logging.INFO, "", 0, "", (), None)).keys() +) | {"message", "asctime"} + + +def _to_jsonable(value: Any) -> Any: + """Convert values into JSON-safe representations.""" + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if isinstance(value, dict): + return {str(key): _to_jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_to_jsonable(item) for item in value] + return str(value) + + +class JSONFormatter(logging.Formatter): + """Format log records as a single JSON object per line.""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "timestamp": datetime.fromtimestamp( + record.created, tz=timezone.utc + ).isoformat().replace("+00:00", "Z"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + for key, value in record.__dict__.items(): + if key in _RESERVED_RECORD_FIELDS or key.startswith("_"): + continue + payload[key] = _to_jsonable(value) + + if record.exc_info: + payload["exc_info"] = self.formatException(record.exc_info) + if record.stack_info: + payload["stack_info"] = self.formatStack(record.stack_info) + + return json.dumps(payload, separators=(",", ":")) + + +def get_log_level() -> int: + """Return the configured application log level.""" + raw_level = os.getenv("LOG_LEVEL", "INFO").upper() + return getattr(logging, raw_level, logging.INFO) + + +def configure_json_logger(name: str) -> logging.Logger: + """Create a stdout logger that emits JSON records.""" + logger = logging.getLogger(name) + logger.handlers.clear() + logger.setLevel(get_log_level()) + logger.propagate = False + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JSONFormatter()) + logger.addHandler(handler) + + return logger diff --git a/app_python/src/main.py b/app_python/src/main.py new file mode 100644 index 0000000000..d2d7356067 --- /dev/null +++ b/app_python/src/main.py @@ -0,0 +1,27 @@ +""" +DevOps Info Service +Application runtime entrypoint. +""" + +import os + +try: + from .flask_instance import app, logger + from . import router # noqa: F401 +except ImportError: # pragma: no cover - allows `python src/main.py` + from flask_instance import app, logger + import router # noqa: F401 + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + + +def run() -> None: + """Run development server.""" + logger.info("Application starting...") + app.run(host=HOST, port=PORT, debug=DEBUG) + + +if __name__ == "__main__": # pragma: no cover + run() diff --git a/app_python/src/metrics.py b/app_python/src/metrics.py new file mode 100644 index 0000000000..1441b3f8f1 --- /dev/null +++ b/app_python/src/metrics.py @@ -0,0 +1,119 @@ +"""Prometheus metrics and Flask request instrumentation.""" + +from time import perf_counter + +from flask import Response, g, request +from prometheus_client import ( + CONTENT_TYPE_LATEST, + CollectorRegistry, + Counter, + Gauge, + Histogram, + generate_latest, +) + +try: + from .flask_instance import app +except ImportError: # pragma: no cover - allows `python src/main.py` + from flask_instance import app + +METRICS_REGISTRY = CollectorRegistry() + +HTTP_REQUESTS_TOTAL = Counter( + "http_requests_total", + "Total HTTP requests handled by the service.", + ["method", "endpoint", "status_code"], + registry=METRICS_REGISTRY, +) +HTTP_REQUEST_DURATION_SECONDS = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds.", + ["method", "endpoint", "status_code"], + registry=METRICS_REGISTRY, +) +HTTP_REQUESTS_IN_PROGRESS = Gauge( + "http_requests_in_progress", + "HTTP requests currently being processed.", + ["method", "endpoint"], + registry=METRICS_REGISTRY, +) +DEVOPS_INFO_ENDPOINT_CALLS_TOTAL = Counter( + "devops_info_endpoint_calls_total", + "Total calls to application endpoints.", + ["endpoint"], + registry=METRICS_REGISTRY, +) +DEVOPS_INFO_SYSTEM_INFO_DURATION_SECONDS = Histogram( + "devops_info_system_info_duration_seconds", + "Time spent collecting system information.", + registry=METRICS_REGISTRY, +) + + +def normalize_endpoint_label() -> str: + """Return a low-cardinality endpoint label for the current request.""" + rule = getattr(request, "url_rule", None) + if rule is not None: + return rule.rule + return "unmatched" + + +def record_endpoint_call(endpoint: str) -> None: + """Increment the app-specific endpoint usage counter.""" + DEVOPS_INFO_ENDPOINT_CALLS_TOTAL.labels(endpoint=endpoint).inc() + + +def generate_metrics_response() -> Response: + """Return the current Prometheus exposition payload.""" + return Response( + generate_latest(METRICS_REGISTRY), + content_type=CONTENT_TYPE_LATEST, + ) + + +@app.before_request +def start_http_request_metrics() -> None: + """Capture request start time and increase the in-flight gauge.""" + endpoint = normalize_endpoint_label() + g.metrics_method = request.method + g.metrics_endpoint = endpoint + g.metrics_start_time = perf_counter() + g.metrics_in_progress = True + HTTP_REQUESTS_IN_PROGRESS.labels( + method=request.method, + endpoint=endpoint, + ).inc() + + +@app.after_request +def record_http_request_metrics(response: Response) -> Response: + """Persist request counter and latency observations.""" + method = getattr(g, "metrics_method", request.method) + endpoint = getattr(g, "metrics_endpoint", normalize_endpoint_label()) + start_time = getattr(g, "metrics_start_time", None) + if start_time is None: + return response + + labels = { + "method": method, + "endpoint": endpoint, + "status_code": str(response.status_code), + } + HTTP_REQUESTS_TOTAL.labels(**labels).inc() + HTTP_REQUEST_DURATION_SECONDS.labels(**labels).observe( + perf_counter() - start_time + ) + return response + + +@app.teardown_request +def finish_http_request_metrics(error: BaseException | None) -> None: # noqa: ARG001 + """Decrease the in-flight gauge after the request finishes.""" + if not getattr(g, "metrics_in_progress", False): + return + + HTTP_REQUESTS_IN_PROGRESS.labels( + method=g.metrics_method, + endpoint=g.metrics_endpoint, + ).dec() + g.metrics_in_progress = False diff --git a/app_python/src/router.py b/app_python/src/router.py new file mode 100644 index 0000000000..47174e44cd --- /dev/null +++ b/app_python/src/router.py @@ -0,0 +1,279 @@ +""" +Route handlers and response helpers. +""" + +from datetime import datetime, timezone +import inspect +from multiprocessing import cpu_count +import platform +from pathlib import Path +import socket +from threading import Lock + +from flask import jsonify, request + +try: + from .flask_instance import START_TIME, app, logger + from .metrics import ( + DEVOPS_INFO_SYSTEM_INFO_DURATION_SECONDS, + generate_metrics_response, + record_endpoint_call, + ) +except ImportError: # pragma: no cover - allows `python src/main.py` + from flask_instance import START_TIME, app, logger + from metrics import ( + DEVOPS_INFO_SYSTEM_INFO_DURATION_SECONDS, + generate_metrics_response, + record_endpoint_call, + ) + +__version__ = "1.12.0" +VISITS_FILE = Path("/data/visits") +_VISITS_LOCK = Lock() + + +def get_service_info() -> dict[str, str]: + """Collect info about service.""" + return { + "name": "devops-info-service", + "version": __version__, + "description": "DevOps course info service", + "framework": "Flask", + } + + +@DEVOPS_INFO_SYSTEM_INFO_DURATION_SECONDS.time() +def get_platform_info() -> dict[str, str | int]: + """Collect system information.""" + + def _platform_version() -> str: + """Return a human-friendly OS version string.""" + match platform.system().lower(): + case "linux": + return platform.freedesktop_os_release()["PRETTY_NAME"] + case "windows": + return f"{platform.system()} {platform.win32_ver()[1]}" + case _: + return platform.version() + + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": _platform_version(), + "architecture": platform.machine(), + "cpu_count": cpu_count(), + "python_version": platform.python_version(), + } + + +def get_uptime() -> dict[str, str | int]: + """Return uptime in seconds and a simple human string.""" + delta = datetime.now(tz=timezone.utc) - START_TIME + up_seconds = int(delta.total_seconds()) + up_hours = up_seconds // 3600 + up_minutes = (up_seconds % 3600) // 60 + return { + "seconds": up_seconds, + "human": f"{up_hours} hours, {up_minutes} minutes", + } + + +def get_runtime() -> dict[str, str | int]: + """Return current runtime metadata (uptime + UTC timestamp).""" + up = get_uptime() + return { + "uptime_seconds": up["seconds"], + "uptime_human": up["human"], + "current_time": datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "timezone": "UTC", + } + + +def get_request_info(req) -> dict[str, str | None]: + """Return basic request metadata for debugging/telemetry.""" + return { + "client_ip": req.remote_addr, + "user_agent": req.headers.get("User-Agent"), + "method": req.method, + "path": req.path, + } + + +def get_request_log_context(req, status_code: int) -> dict[str, str | int | None]: + """Return request metadata suitable for structured logs.""" + context = get_request_info(req) + context["status_code"] = status_code + return context + + +def list_routes() -> list[dict[str, str]]: + """Return a flat list of route + method + description.""" + out: list[dict[str, str]] = [] + + for rule in sorted(app.url_map.iter_rules(), key=lambda r: (r.rule, r.endpoint)): + if rule.endpoint == "static": + continue + + view = app.view_functions.get(rule.endpoint) + + desc = "" + if view is not None: + desc = inspect.getdoc(view) or "" + desc = desc.splitlines()[0].strip() or "" + + for method in sorted(rule.methods - {"HEAD", "OPTIONS"}): + out.append( + { + "path": rule.rule, + "method": method, + "description": desc, + } + ) + return out + + +def _read_visits_count() -> int: + """Read the current visits counter, defaulting to zero when missing.""" + try: + raw_count = VISITS_FILE.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return 0 + except OSError as error: + logger.warning( + "failed to read visits counter", + extra={"path": str(VISITS_FILE), "error": str(error)}, + ) + return 0 + + if not raw_count: + logger.warning( + "invalid visits counter, resetting to zero", + extra={"path": str(VISITS_FILE), "value": ""}, + ) + return 0 + + try: + count = int(raw_count) + except ValueError: + logger.warning( + "invalid visits counter, resetting to zero", + extra={"path": str(VISITS_FILE), "value": raw_count}, + ) + return 0 + + if count < 0: + logger.warning( + "invalid visits counter, resetting to zero", + extra={"path": str(VISITS_FILE), "value": raw_count}, + ) + return 0 + + return count + + +def _write_visits_count(count: int) -> None: + """Persist the visits counter to disk.""" + VISITS_FILE.parent.mkdir(parents=True, exist_ok=True) + VISITS_FILE.write_text(f"{count}\n", encoding="utf-8") + + +def get_visits_count() -> int: + """Return the current persisted visits count.""" + with _VISITS_LOCK: + return _read_visits_count() + + +def increment_visits_count() -> int: + """Increment and persist the visits counter.""" + with _VISITS_LOCK: + count = _read_visits_count() + 1 + _write_visits_count(count) + return count + + +@app.route("/") +def index(): + """Service information.""" + increment_visits_count() + record_endpoint_call("/") + return jsonify( + { + "service": get_service_info(), + "system": get_platform_info(), + "runtime": get_uptime(), + "request": get_request_info(request), + "endpoints": list_routes(), + } + ) + + +@app.route("/visits") +def visits(): + """Return the current persisted visits count.""" + record_endpoint_call("/visits") + return jsonify({"visits": get_visits_count()}) + + +@app.route("/health") +def health(): + """Health check.""" + record_endpoint_call("/health") + return _status_response("healthy") + + +@app.route("/ready") +def readiness(): + """Readiness check.""" + record_endpoint_call("/ready") + return _status_response("ready") + + +def _status_response(status: str): + """Return a shared JSON payload for health-style endpoints.""" + return jsonify( + { + "status": status, + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": get_uptime()["seconds"], + } + ) + + +@app.route("/metrics") +def metrics(): + """Prometheus metrics.""" + record_endpoint_call("/metrics") + return generate_metrics_response() + + +@app.errorhandler(404) +def not_found(error): # noqa: ARG001 + """Return a JSON 404 payload.""" + logger.warning( + "request returned not found", + extra=get_request_log_context(request, status_code=404), + ) + return jsonify({"error": "Not Found", "message": "Endpoint does not exist"}), 404 + + +@app.errorhandler(500) +def internal_error(error): # noqa: ARG001 + """Return a JSON 500 payload.""" + original_error = getattr(error, "original_exception", None) or error + logger.error( + "request failed", + extra={ + **get_request_log_context(request, status_code=500), + "error_type": type(original_error).__name__, + "error": str(original_error), + }, + ) + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) 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/conftest.py b/app_python/tests/conftest.py new file mode 100644 index 0000000000..2319d53971 --- /dev/null +++ b/app_python/tests/conftest.py @@ -0,0 +1,20 @@ +"""Shared pytest fixtures for app endpoint tests.""" + +import pytest + +from src.flask_instance import app +import src.router # noqa: F401 # Ensure route decorators are loaded. + + +@pytest.fixture() +def client(): + """Return a Flask test client without starting a real HTTP server.""" + app.config.update(TESTING=True, PROPAGATE_EXCEPTIONS=False) + with app.test_client() as test_client: + yield test_client + + +@pytest.fixture(autouse=True) +def isolated_visits_file(tmp_path, monkeypatch): + """Route the visits counter to a per-test temporary file.""" + monkeypatch.setattr(src.router, "VISITS_FILE", tmp_path / "visits") diff --git a/app_python/tests/test_endpoints.py b/app_python/tests/test_endpoints.py new file mode 100644 index 0000000000..08fe39fece --- /dev/null +++ b/app_python/tests/test_endpoints.py @@ -0,0 +1,211 @@ +"""Unit tests for HTTP endpoints and error handling.""" + +from datetime import datetime +from unittest.mock import Mock + +import src.router as router + + +def _raise_runtime_error() -> None: + raise RuntimeError("simulated failure") + + +def test_index_returns_expected_json_structure_and_types(client): + """GET / should return the expected nested schema with stable field types.""" + response = client.get( + "/", + headers={"User-Agent": "pytest-suite/1.0"}, + environ_overrides={"REMOTE_ADDR": "203.0.113.7"}, + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload is not None + + assert {"service", "system", "runtime", "request", "endpoints"} <= payload.keys() + + service = payload["service"] + assert service["name"] == "devops-info-service" + assert service["framework"] == "Flask" + assert isinstance(service["version"], str) + assert isinstance(service["description"], str) + + system = payload["system"] + assert isinstance(system["hostname"], str) + assert system["hostname"] + assert isinstance(system["platform"], str) + assert isinstance(system["platform_version"], str) + assert isinstance(system["architecture"], str) + assert isinstance(system["cpu_count"], int) + assert system["cpu_count"] >= 1 + assert isinstance(system["python_version"], str) + + runtime = payload["runtime"] + assert isinstance(runtime["seconds"], int) + assert runtime["seconds"] >= 0 + assert isinstance(runtime["human"], str) + + request = payload["request"] + assert request["client_ip"] == "203.0.113.7" + assert request["user_agent"] == "pytest-suite/1.0" + assert request["method"] == "GET" + assert request["path"] == "/" + + endpoints = payload["endpoints"] + assert isinstance(endpoints, list) + assert endpoints + for endpoint in endpoints: + assert {"path", "method", "description"} <= endpoint.keys() + assert isinstance(endpoint["path"], str) + assert isinstance(endpoint["method"], str) + assert isinstance(endpoint["description"], str) + + route_index = {(endpoint["method"], endpoint["path"]) for endpoint in endpoints} + assert ("GET", "/") in route_index + assert ("GET", "/visits") in route_index + assert ("GET", "/health") in route_index + assert ("GET", "/ready") in route_index + assert ("GET", "/metrics") in route_index + + +def test_visits_defaults_to_zero_when_counter_file_is_missing(client, tmp_path, monkeypatch): + """GET /visits should bootstrap from zero when the counter file is absent.""" + visits_file = tmp_path / "visits" + monkeypatch.setattr(router, "VISITS_FILE", visits_file) + + response = client.get("/visits") + + assert response.status_code == 200 + assert response.get_json() == {"visits": 0} + assert not visits_file.exists() + + +def test_index_increments_and_persists_visits_count(client, tmp_path, monkeypatch): + """GET / should increment the counter and persist the new value.""" + visits_file = tmp_path / "visits" + monkeypatch.setattr(router, "VISITS_FILE", visits_file) + + first_response = client.get("/") + second_response = client.get("/") + visits_response = client.get("/visits") + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert visits_response.status_code == 200 + assert visits_response.get_json() == {"visits": 2} + assert visits_file.read_text(encoding="utf-8") == "2\n" + + +def test_visits_returns_persisted_count(client, tmp_path, monkeypatch): + """GET /visits should return the current persisted counter value.""" + visits_file = tmp_path / "visits" + visits_file.write_text("7\n", encoding="utf-8") + monkeypatch.setattr(router, "VISITS_FILE", visits_file) + + response = client.get("/visits") + + assert response.status_code == 200 + assert response.get_json() == {"visits": 7} + + +def test_visits_falls_back_to_zero_when_counter_file_is_malformed( + client, + tmp_path, + monkeypatch, +): + """GET /visits should warn and recover when the counter file is malformed.""" + visits_file = tmp_path / "visits" + visits_file.write_text("definitely-not-an-integer\n", encoding="utf-8") + warning_mock = Mock() + monkeypatch.setattr(router, "VISITS_FILE", visits_file) + monkeypatch.setattr(router.logger, "warning", warning_mock) + + response = client.get("/visits") + + assert response.status_code == 200 + assert response.get_json() == {"visits": 0} + warning_mock.assert_called() + + +def test_health_returns_expected_json_structure_and_types(client): + """GET /health should report healthy status and typed runtime metadata.""" + response = client.get("/health") + + assert response.status_code == 200 + payload = response.get_json() + assert payload is not None + + assert {"status", "timestamp", "uptime_seconds"} <= payload.keys() + assert payload["status"] == "healthy" + assert isinstance(payload["uptime_seconds"], int) + assert payload["uptime_seconds"] >= 0 + + parsed_timestamp = datetime.fromisoformat(payload["timestamp"]) + assert parsed_timestamp.tzinfo is not None + + +def test_ready_returns_expected_json_structure_and_types(client): + """GET /ready should report ready status and typed runtime metadata.""" + response = client.get("/ready") + + assert response.status_code == 200 + payload = response.get_json() + assert payload is not None + + assert {"status", "timestamp", "uptime_seconds"} <= payload.keys() + assert payload["status"] == "ready" + assert isinstance(payload["uptime_seconds"], int) + assert payload["uptime_seconds"] >= 0 + + parsed_timestamp = datetime.fromisoformat(payload["timestamp"]) + assert parsed_timestamp.tzinfo is not None + + +def test_unknown_endpoint_returns_json_404(client): + """Unknown routes should be handled by JSON 404 error handler.""" + response = client.get("/definitely-does-not-exist") + + assert response.status_code == 404 + assert response.get_json() == { + "error": "Not Found", + "message": "Endpoint does not exist", + } + + +def test_index_returns_json_500_when_platform_probe_fails(client, monkeypatch): + """GET / should return JSON 500 when an internal helper crashes.""" + monkeypatch.setattr(router, "get_platform_info", _raise_runtime_error) + + response = client.get("/") + + assert response.status_code == 500 + assert response.get_json() == { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + + +def test_health_returns_json_500_when_uptime_probe_fails(client, monkeypatch): + """GET /health should return JSON 500 when uptime collection crashes.""" + monkeypatch.setattr(router, "get_uptime", _raise_runtime_error) + + response = client.get("/health") + + assert response.status_code == 500 + assert response.get_json() == { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + + +def test_ready_returns_json_500_when_uptime_probe_fails(client, monkeypatch): + """GET /ready should return JSON 500 when uptime collection crashes.""" + monkeypatch.setattr(router, "get_uptime", _raise_runtime_error) + + response = client.get("/ready") + + assert response.status_code == 500 + assert response.get_json() == { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } diff --git a/app_python/tests/test_logging_utils.py b/app_python/tests/test_logging_utils.py new file mode 100644 index 0000000000..bef1790ef3 --- /dev/null +++ b/app_python/tests/test_logging_utils.py @@ -0,0 +1,34 @@ +"""Unit tests for JSON logging helpers.""" + +import json +import logging + +from src.logging_utils import JSONFormatter + + +def test_json_formatter_serializes_message_and_extra_fields(): + """Formatter should emit a JSON line with standard and custom fields.""" + record = logging.LogRecord( + name="devops_info_service", + level=logging.INFO, + pathname=__file__, + lineno=12, + msg="hello %s", + args=("world",), + exc_info=None, + ) + record.client_ip = "203.0.113.7" + record.method = "GET" + record.path = "/health" + record.status_code = 200 + + payload = json.loads(JSONFormatter().format(record)) + + assert payload["logger"] == "devops_info_service" + assert payload["level"] == "INFO" + assert payload["message"] == "hello world" + assert payload["client_ip"] == "203.0.113.7" + assert payload["method"] == "GET" + assert payload["path"] == "/health" + assert payload["status_code"] == 200 + assert payload["timestamp"].endswith("Z") diff --git a/app_python/tests/test_metrics.py b/app_python/tests/test_metrics.py new file mode 100644 index 0000000000..717f9320dc --- /dev/null +++ b/app_python/tests/test_metrics.py @@ -0,0 +1,113 @@ +"""Tests for Prometheus metrics exposure and labels.""" + +from collections.abc import Mapping + +from prometheus_client.parser import text_string_to_metric_families + +import src.router as router + + +def _raise_runtime_error() -> None: + raise RuntimeError("simulated failure") + + +def _metric_value( + metrics_text: str, + sample_name: str, + labels: Mapping[str, str] | None = None, +) -> float | None: + expected_labels = labels or {} + + for family in text_string_to_metric_families(metrics_text): + for sample in family.samples: + if sample.name != sample_name: + continue + if all( + sample.labels.get(key) == value + for key, value in expected_labels.items() + ): + return float(sample.value) + return None + + +def _metrics_text(client) -> str: + response = client.get("/metrics") + assert response.status_code == 200 + return response.get_data(as_text=True) + + +def test_metrics_endpoint_exposes_http_and_application_metrics(client): + """Metrics endpoint should expose HTTP RED data and app-specific metrics.""" + client.get("/") + client.get("/health") + client.get("/ready") + client.get("/does-not-exist") + + response = client.get("/metrics") + metrics_text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert response.content_type.startswith("text/plain") + + root_total = _metric_value( + metrics_text, + "http_requests_total", + {"method": "GET", "endpoint": "/", "status_code": "200"}, + ) + health_total = _metric_value( + metrics_text, + "http_requests_total", + {"method": "GET", "endpoint": "/health", "status_code": "200"}, + ) + ready_total = _metric_value( + metrics_text, + "http_requests_total", + {"method": "GET", "endpoint": "/ready", "status_code": "200"}, + ) + unmatched_total = _metric_value( + metrics_text, + "http_requests_total", + {"method": "GET", "endpoint": "unmatched", "status_code": "404"}, + ) + root_duration_count = _metric_value( + metrics_text, + "http_request_duration_seconds_count", + {"method": "GET", "endpoint": "/", "status_code": "200"}, + ) + root_in_progress = _metric_value( + metrics_text, + "http_requests_in_progress", + {"method": "GET", "endpoint": "/"}, + ) + endpoint_calls = _metric_value( + metrics_text, + "devops_info_endpoint_calls_total", + {"endpoint": "/"}, + ) + system_info_count = _metric_value( + metrics_text, + "devops_info_system_info_duration_seconds_count", + ) + + assert root_total is not None and root_total >= 1.0 + assert health_total is not None and health_total >= 1.0 + assert ready_total is not None and ready_total >= 1.0 + assert unmatched_total is not None and unmatched_total >= 1.0 + assert root_duration_count is not None and root_duration_count >= 1.0 + assert root_in_progress == 0.0 + assert endpoint_calls is not None and endpoint_calls >= 1.0 + assert system_info_count is not None and system_info_count >= 1.0 + + +def test_metrics_count_internal_server_errors_with_status_labels(client, monkeypatch): + """Failed requests should still be counted with a 500 status code label.""" + labels = {"method": "GET", "endpoint": "/", "status_code": "500"} + before = _metric_value(_metrics_text(client), "http_requests_total", labels) or 0.0 + + monkeypatch.setattr(router, "get_platform_info", _raise_runtime_error) + + response = client.get("/") + after = _metric_value(_metrics_text(client), "http_requests_total", labels) + + assert response.status_code == 500 + assert after == before + 1.0 diff --git a/app_python/tests/test_unit_helpers.py b/app_python/tests/test_unit_helpers.py new file mode 100644 index 0000000000..a17f071a61 --- /dev/null +++ b/app_python/tests/test_unit_helpers.py @@ -0,0 +1,93 @@ +"""Unit tests for helper functions and app entrypoint behavior.""" + +from datetime import datetime +from unittest.mock import Mock + +from flask import request + +from src.flask_instance import app +import src.main as main +import src.router as router + + +def test_run_calls_flask_app_with_configured_host_port_debug(monkeypatch): + """main.run should log startup and pass module config into app.run.""" + run_mock = Mock() + info_mock = Mock() + + monkeypatch.setattr(main, "HOST", "127.0.0.1") + monkeypatch.setattr(main, "PORT", 5050) + monkeypatch.setattr(main, "DEBUG", True) + monkeypatch.setattr(main.app, "run", run_mock) + monkeypatch.setattr(main.logger, "info", info_mock) + + main.run() + + info_mock.assert_called_once_with("Application starting...") + run_mock.assert_called_once_with(host="127.0.0.1", port=5050, debug=True) + + +def test_get_runtime_maps_uptime_payload(monkeypatch): + """get_runtime should map uptime fields and produce UTC timestamp text.""" + monkeypatch.setattr( + router, + "get_uptime", + lambda: {"seconds": 42, "human": "0 hours, 0 minutes"}, + ) + + runtime = router.get_runtime() + + assert runtime["uptime_seconds"] == 42 + assert runtime["uptime_human"] == "0 hours, 0 minutes" + assert runtime["timezone"] == "UTC" + assert runtime["current_time"].endswith("Z") + datetime.strptime(runtime["current_time"], "%Y-%m-%dT%H:%M:%SZ") + + +def test_get_platform_info_windows_platform_version_branch(monkeypatch): + """Windows branch should format platform_version from win32 metadata.""" + monkeypatch.setattr(router.platform, "system", lambda: "Windows") + monkeypatch.setattr(router.platform, "win32_ver", lambda: ("", "11", "", "")) + monkeypatch.setattr(router.platform, "machine", lambda: "AMD64") + monkeypatch.setattr(router.platform, "python_version", lambda: "3.14.2") + monkeypatch.setattr(router.socket, "gethostname", lambda: "test-host") + monkeypatch.setattr(router, "cpu_count", lambda: 8) + + payload = router.get_platform_info() + + assert payload["platform"] == "Windows" + assert payload["platform_version"] == "Windows 11" + assert payload["hostname"] == "test-host" + assert payload["cpu_count"] == 8 + + +def test_get_platform_info_default_platform_version_branch(monkeypatch): + """Non-Linux and non-Windows branch should use platform.version().""" + monkeypatch.setattr(router.platform, "system", lambda: "Darwin") + monkeypatch.setattr(router.platform, "version", lambda: "Darwin Kernel 25.0") + monkeypatch.setattr(router.platform, "machine", lambda: "arm64") + monkeypatch.setattr(router.platform, "python_version", lambda: "3.14.2") + monkeypatch.setattr(router.socket, "gethostname", lambda: "mac-host") + monkeypatch.setattr(router, "cpu_count", lambda: 10) + + payload = router.get_platform_info() + + assert payload["platform"] == "Darwin" + assert payload["platform_version"] == "Darwin Kernel 25.0" + + +def test_get_request_info_returns_none_when_user_agent_missing(): + """Missing User-Agent header should map to None without crashing.""" + with app.test_request_context( + "/diagnostic", + method="POST", + environ_base={"REMOTE_ADDR": "198.51.100.9"}, + ): + info = router.get_request_info(request) + + assert info == { + "client_ip": "198.51.100.9", + "user_agent": None, + "method": "POST", + "path": "/diagnostic", + } diff --git a/docker/provision_vm.sh b/docker/provision_vm.sh new file mode 100755 index 0000000000..b79bfd3c4d --- /dev/null +++ b/docker/provision_vm.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${VM_USER:?VM_USER must be set}" +: "${SSH_PUBLIC_KEY:?SSH_PUBLIC_KEY must be set}" + +export DEBIAN_FRONTEND=noninteractive + +if ! command -v sshd >/dev/null 2>&1; then + apt-get update + apt-get install -y --no-install-recommends openssh-server ca-certificates +fi + +id -u "${VM_USER}" >/dev/null 2>&1 || useradd -m -s /bin/bash "${VM_USER}" +install -d -m 700 -o "${VM_USER}" -g "${VM_USER}" "/home/${VM_USER}/.ssh" +printf '%s\n' "${SSH_PUBLIC_KEY}" >"/home/${VM_USER}/.ssh/authorized_keys" +chown "${VM_USER}:${VM_USER}" "/home/${VM_USER}/.ssh/authorized_keys" +chmod 600 "/home/${VM_USER}/.ssh/authorized_keys" + +mkdir -p /run/sshd +cat >/etc/ssh/sshd_config.d/lab04.conf < container `22` + - HTTP: host `0.0.0.0:80` -> container `80` + - App: host `0.0.0.0:5000` -> container `5000` +- Public IP equivalent: `127.0.0.1`. +- Cost: `$0`. + +## 2. Terraform (OpenTofu) Implementation + +- CLI used: OpenTofu `v1.10.9` (Terraform-compatible HCL). +- Project path: `terraform/`. +- Main files: + - `versions.tf`: provider + required version. + - `main.tf`: network + Ubuntu VM container + startup bootstrap for SSH service + published ports. + - `variables.tf`: bind IPs, host ports, labels. + - `outputs.tf`: endpoints and connection commands. +- Project structure: split into `versions.tf` (providers), `variables.tf` (inputs), `main.tf` (resources), and `outputs.tf` (connection/output values) for readability and predictable diffs. + +### Key Decisions + +- Used Ubuntu image directly to keep `apply` simple and avoid local custom image build failures. +- Used startup bootstrap in a separate shell script (`docker/provision_vm.sh`) to avoid duplicated provisioning logic across Terraform and Pulumi. +- Kept `80` and `5000` port mappings defined in IaC, but did not run mock HTTP services in the container. +- Bound SSH to `127.0.0.1` by default to reduce exposure. + +### Challenges + +- Provider download from registry/GitHub release assets may timeout on slow links. +- Workaround: local plugin mirror (`~/.terraform.d/plugins`) if direct provider install fails. + +### Command Output + +
+`tofu plan` + +``` +$ tofu plan + +OpenTofu used the selected providers to generate the following execution plan. Resource +actions are indicated with the following symbols: + + create + +OpenTofu will perform the following actions: + + # docker_container.vm will be created + + resource "docker_container" "vm" { + + attach = false + + bridge = (known after apply) + + command = [ + + "/bin/bash", + + "-lc", + + <<-EOT + #!/usr/bin/env bash + + set -euo pipefail + + : "${VM_USER:?VM_USER must be set}" + : "${SSH_PUBLIC_KEY:?SSH_PUBLIC_KEY must be set}" + + export DEBIAN_FRONTEND=noninteractive + + if ! command -v sshd >/dev/null 2>&1; then + apt-get update + apt-get install -y --no-install-recommends openssh-server ca-certificates + fi + + id -u "${VM_USER}" >/dev/null 2>&1 || useradd -m -s /bin/bash "${VM_USER}" + install -d -m 700 -o "${VM_USER}" -g "${VM_USER}" "/home/${VM_USER}/.ssh" + printf '%s\n' "${SSH_PUBLIC_KEY}" >"/home/${VM_USER}/.ssh/authorized_keys" + chown "${VM_USER}:${VM_USER}" "/home/${VM_USER}/.ssh/authorized_keys" + chmod 600 "/home/${VM_USER}/.ssh/authorized_keys" + + mkdir -p /run/sshd + cat >/etc/ssh/sshd_config.d/lab04.conf < + +
+`tofu apply` + +``` +$ tofu apply + +OpenTofu used the selected providers to generate the following execution plan. Resource +actions are indicated with the following symbols: + + create + +OpenTofu will perform the following actions: + + # docker_container.vm will be created + + resource "docker_container" "vm" { + + attach = false + + bridge = (known after apply) + + command = [ + + "/bin/bash", + + "-lc", + + <<-EOT + #!/usr/bin/env bash + + set -euo pipefail + + : "${VM_USER:?VM_USER must be set}" + : "${SSH_PUBLIC_KEY:?SSH_PUBLIC_KEY must be set}" + + export DEBIAN_FRONTEND=noninteractive + + if ! command -v sshd >/dev/null 2>&1; then + apt-get update + apt-get install -y --no-install-recommends openssh-server ca-certificates + fi + + id -u "${VM_USER}" >/dev/null 2>&1 || useradd -m -s /bin/bash "${VM_USER}" + install -d -m 700 -o "${VM_USER}" -g "${VM_USER}" "/home/${VM_USER}/.ssh" + printf '%s\n' "${SSH_PUBLIC_KEY}" >"/home/${VM_USER}/.ssh/authorized_keys" + chown "${VM_USER}:${VM_USER}" "/home/${VM_USER}/.ssh/authorized_keys" + chmod 600 "/home/${VM_USER}/.ssh/authorized_keys" + + mkdir -p /run/sshd + cat >/etc/ssh/sshd_config.d/lab04.conf < + +
+SSH test + +``` +$ ssh -i ~/.ssh/id_ed25519 -p 2222 devops@127.0.0.1 echo "SSH available" +The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established. +ED25519 key fingerprint is: SHA256:shGIrzMssSaR8sB9yuUyId7BYrKHyfi/OQSvGJq5gkk +This key is not known by any other names. +Are you sure you want to continue connecting (yes/no/[fingerprint])? yes +Warning: Permanently added '[127.0.0.1]:2222' (ED25519) to the list of known hosts. +SSH available +``` + +
+ +Teardown command (Terraform resources): + +```bash +cd terraform +tofu destroy -auto-approve +``` + +## 3. Pulumi Implementation + +- Pulumi CLI: `v3.192.0` +- Language: Python +- Project path: `pulumi/` +- Resources: + - Docker network + - Docker `RemoteImage` (`ubuntu:24.04`) + - Docker container with same ports as Terraform setup + +### Code Differences vs Terraform + +- Terraform uses declarative HCL resources and variable blocks. +- Pulumi uses Python (`__main__.py`) and typed constructor args (`docker.ContainerPortArgs`, `docker.ContainerLabelArgs`). +- The shared provisioning logic is loaded from `docker/provision_vm.sh` in both implementations, but Pulumi reads it via `Path(...).read_text()`, while Terraform uses `file(...)`. + +### Advantages Discovered + +- Strong typing and native language constructs in Python made refactoring (for example, shared provisioning script usage) easier. +- Pulumi outputs and resource objects map naturally to normal programming workflows. +- For this lab size, Pulumi and Terraform were both fast enough; Pulumi felt better when logic started to grow. + +### Challenges + +- Pulumi passphrase prompts can interrupt command flow if `PULUMI_CONFIG_PASSPHRASE` is not set. +- On Nix/Home-Manager-based setups, `pulumi-language-python` may be missing from `PATH`, which blocks `preview/up` until fixed. +- Docker provider behavior is similar across tools, but plugin/setup issues differ and require separate troubleshooting steps. + +### Command Output + +`tofu destroy` before Pulumi migration: + +```bash +$ tofu destroy -auto-approve +``` + +
+`pulumi preview` + +``` +$ pulumi preview +Enter your passphrase to unlock config/secrets + (set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember): +Enter your passphrase to unlock config/secrets +Previewing update (dev): + Type Name Plan Info + + pulumi:pulumi:Stack lab04-local-docker-dev create 1 warning + + ├─ docker:index:Network lab04-net create + + ├─ docker:index:RemoteImage lab04-vm-image create + + └─ docker:index:Container lab04-vm create + +Diagnostics: + pulumi:pulumi:Stack (lab04-local-docker-dev): + warning: using pulumi-language-python from $PATH at /etc/profiles/per-user/t0ast/bin/pulumi-language-python + +Outputs: + appUrl : "http://127.0.0.1:5000" + containerShellCommand: "docker exec -it lab04-local-vm /bin/bash" + httpUrl : "http://127.0.0.1:80" + networkName : "lab04-local-net" + publicIpEquivalent : "127.0.0.1" + sshCommand : "ssh -i ~/.ssh/id_ed25519 -p 2222 devops@127.0.0.1" + vmName : "lab04-local-vm" + +Resources: + + 4 to create +``` + +
+ +
+`pulumi up` + +``` +$ pulumi up +Enter your passphrase to unlock config/secrets + (set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember): +Enter your passphrase to unlock config/secrets +Previewing update (dev): + Type Name Plan Info + + pulumi:pulumi:Stack lab04-local-docker-dev create 1 warning + + ├─ docker:index:RemoteImage lab04-vm-image create + + ├─ docker:index:Network lab04-net create + + └─ docker:index:Container lab04-vm create + +Diagnostics: + pulumi:pulumi:Stack (lab04-local-docker-dev): + warning: using pulumi-language-python from $PATH at /etc/profiles/per-user/t0ast/bin/pulumi-language-python + +Outputs: + appUrl : "http://127.0.0.1:5000" + containerShellCommand: "docker exec -it lab04-local-vm /bin/bash" + httpUrl : "http://127.0.0.1:80" + networkName : "lab04-local-net" + publicIpEquivalent : "127.0.0.1" + sshCommand : "ssh -i ~/.ssh/id_ed25519 -p 2222 devops@127.0.0.1" + vmName : "lab04-local-vm" + +Resources: + + 4 to create + +Do you want to perform this update? yes +Updating (dev): + Type Name Status Info + + pulumi:pulumi:Stack lab04-local-docker-dev created (2s) 1 warning + + ├─ docker:index:RemoteImage lab04-vm-image created (0.01s) + + ├─ docker:index:Network lab04-net created (2s) + + └─ docker:index:Container lab04-vm created (0.38s) + +Diagnostics: + pulumi:pulumi:Stack (lab04-local-docker-dev): + warning: using pulumi-language-python from $PATH at /etc/profiles/per-user/t0ast/bin/pulumi-language-python + +Outputs: + appUrl : "http://127.0.0.1:5000" + containerShellCommand: "docker exec -it lab04-local-vm /bin/bash" + httpUrl : "http://127.0.0.1:80" + networkName : "lab04-local-net" + publicIpEquivalent : "127.0.0.1" + sshCommand : "ssh -i ~/.ssh/id_ed25519 -p 2222 devops@127.0.0.1" + vmName : "lab04-local-vm" + +Resources: + + 4 created + +Duration: 3s +``` + +
+ +
+SSH test + +``` +$ ssh -i ~/.ssh/id_ed25519 -p 2222 devops@127.0.0.1 echo "SSH works" +The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established. +ED25519 key fingerprint is: SHA256:spW/AgFoqrVqpf1i7ZWEUqYGXJ8rZM6wGU5+S4WheVI +This key is not known by any other names. +Are you sure you want to continue connecting (yes/no/[fingerprint])? yes +Warning: Permanently added '[127.0.0.1]:2222' (ED25519) to the list of known hosts. +SSH works +``` + +
+ +Teardown command (Pulumi resources): + +```bash +pulumi destroy --yes +``` + +## 4. Terraform vs Pulumi (Local Docker Case) + +### Ease of Learning + +Terraform/OpenTofu was faster to start because the resource graph is explicit in HCL and examples are abundant. I needed less scaffolding to get a first working run with `tofu init/plan/apply`. Pulumi required understanding stack config and language-plugin behavior in addition to infrastructure code. + +### Code Readability + +For small infrastructure, Terraform is shorter and easier to scan in one file. Pulumi is more verbose, but the Python structure becomes clearer when the project grows and you need reusable helpers. In this lab, Terraform is more concise, while Pulumi is more flexible. + +### Debugging + +Terraform plan/apply diffs are straightforward and helped quickly validate expected port mappings and resource creation. Pulumi diagnostics were helpful when runtime issues occurred, but setup-level failures (passphrase/plugin) were less obvious initially. Once setup was correct, both were manageable to debug. + +### Documentation + +Terraform has broader community examples and more copy-paste-ready snippets for common patterns. Pulumi official docs are good and practical, but there are fewer examples for some edge workflows. For this lab, Terraform documentation felt easier to navigate quickly. + +### Use Case + +I would choose Terraform/OpenTofu for straightforward declarative infrastructure with predictable patterns. I would choose Pulumi when infrastructure logic needs stronger abstraction, conditional behavior, or shared code with application teams. For this local Docker lab, either works, but Terraform was simpler and Pulumi was more programmable. + +## 5. Lab 5 Preparation & Cleanup + +### VM for Lab 5 + +- Are you keeping your VM for Lab 5? **No**. +- What will you use for Lab 5? A local VM via libvirt, or a fresh Linux VPS. + +### Cleanup Status + +- Decision: destroy both Terraform and Pulumi-managed resources after verification. +- Teardown commands used: + +```bash +cd terraform +tofu destroy -auto-approve + +cd ../pulumi +pulumi destroy --yes +``` + +- Verification commands: + +```bash +docker ps --format '{{.Names}}' | rg 'lab04-local' || echo "No lab04 containers" +docker network ls --format '{{.Name}}' | rg 'lab04-local' || echo "No lab04 networks" +``` + +## Notes + +- This is a local Docker-provider adaptation of a cloud-VM lab. +- Suggestion: this lab could also use `localstack/localstack` (or forks) to emulate parts of AWS locally for free. + - + - + - The developer recently stated the end of support for this community image, but there will most probably be forks. diff --git a/k8s/ARGOCD.md b/k8s/ARGOCD.md new file mode 100644 index 0000000000..766c8a20cb --- /dev/null +++ b/k8s/ARGOCD.md @@ -0,0 +1,16 @@ +# ArgoCD Notes + +This file exists to satisfy the Lab 13 requirement for a dedicated ArgoCD document without flattening the Kubernetes module back into one large documentation directory. + +## Lab 13 Documentation + +The full Lab 13 write-up, GitOps manifests, ArgoCD command transcripts, sync-policy evidence, and self-healing notes are kept in [docs/LAB13.md](docs/LAB13.md). + +## Why This Structure Is Better + +- `k8s/README.md` stays short and usable as the module entry point. +- `k8s/docs/LAB09.md`, [docs/LAB10.md](docs/LAB10.md), [docs/LAB11.md](docs/LAB11.md), [docs/LAB12.md](docs/LAB12.md), and [docs/LAB13.md](docs/LAB13.md) keep each Kubernetes lab self-contained. +- Raw manifests, Helm chart files, and documentation stay separated, which makes the implementation files easier to navigate. +- `k8s/ARGOCD.md` provides the compatibility filename the lab expects while the actual report remains in the `docs/` hierarchy. + +In short, `ARGOCD.md` is the compatibility layer, and `k8s/docs/` remains the maintainable long-term structure. diff --git a/k8s/CONFIGMAPS.md b/k8s/CONFIGMAPS.md new file mode 100644 index 0000000000..3343b2c6a7 --- /dev/null +++ b/k8s/CONFIGMAPS.md @@ -0,0 +1,16 @@ +# ConfigMap Notes + +This file exists to satisfy the Lab 12 requirement for a dedicated ConfigMap document without flattening the Kubernetes module back into one large documentation directory. + +## Lab 12 Documentation + +The full Lab 12 write-up, command transcripts, Docker persistence proof, Kubernetes verification, and hot-reload notes are kept in [docs/LAB12.md](docs/LAB12.md). + +## Why This Structure Is Better + +- `k8s/README.md` stays short and usable as the module entry point. +- `k8s/docs/LAB09.md`, [docs/LAB10.md](docs/LAB10.md), [docs/LAB11.md](docs/LAB11.md), and [docs/LAB12.md](docs/LAB12.md) keep each Kubernetes lab self-contained. +- Raw manifests, Helm chart files, and documentation stay separated, which makes the implementation files easier to navigate. +- `k8s/CONFIGMAPS.md` provides the compatibility filename the lab expects while the actual report remains in the `docs/` hierarchy. + +In short, `CONFIGMAPS.md` is the compatibility layer, and `k8s/docs/` remains the maintainable long-term structure. diff --git a/k8s/HELM.md b/k8s/HELM.md new file mode 100644 index 0000000000..43b7c1d2cd --- /dev/null +++ b/k8s/HELM.md @@ -0,0 +1,16 @@ +# Helm Notes + +This file exists to satisfy the Lab 10 requirement for a dedicated Helm document without forcing the entire Kubernetes module back into a flat documentation layout. + +## Lab 10 Documentation + +The full Helm lab write-up, command transcripts, and verification logs are kept in [docs/LAB10.md](docs/LAB10.md). The Task 5 documentation section that covers chart overview, configuration, hooks, operations, and validation is here: [docs/LAB10.md#task-5-documentation](docs/LAB10.md#task-5-documentation). + +## Why This Structure Is Better + +- `k8s/README.md` stays short and works as the module entry point instead of becoming a 50 kB transcript dump. +- `k8s/docs/LAB09.md` and `k8s/docs/LAB10.md` keep each lab self-contained, which scales better as more Kubernetes labs are added. +- Raw manifests and Helm chart files remain easy to find because documentation is separated from implementation files. +- `k8s/HELM.md` provides the explicit Helm-facing document name the lab expects, while the detailed content stays in the more maintainable `docs/` hierarchy. + +In short, `HELM.md` is the compatibility layer, and `k8s/docs/` is the maintainable structure. diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000000..c3782b6982 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,22 @@ +# Kubernetes Module + +This directory contains the Kubernetes deliverables for the course application. It includes the raw Kubernetes manifests used in Lab 9, the Helm chart created in Lab 10, and the lab write-ups moved into `k8s/docs/` so the module root stays readable. + +The main deployment assets are: + +- `deployment.yml`: baseline Kubernetes `Deployment` manifest for the Python app. +- `service.yml`: baseline Kubernetes `Service` manifest exposing the app inside the cluster and via `NodePort`. +- `devops-app-py/`: Helm chart version of the application deployment. +- `docs/`: lab documentation split by assignment. + +## Documentation + +- [Helm Notes](HELM.md) +- [Secrets Notes](SECRETS.md) +- [ConfigMap Notes](CONFIGMAPS.md) +- [ArgoCD Notes](ARGOCD.md) +- [Lab 09 - Kubernetes Basics](docs/LAB09.md) +- [Lab 10 - Helm Package Manager](docs/LAB10.md) +- [Lab 11 - Kubernetes Secrets and Vault](docs/LAB11.md) +- [Lab 12 - ConfigMaps and Persistent Volumes](docs/LAB12.md) +- [Lab 13 - GitOps with ArgoCD](docs/LAB13.md) diff --git a/k8s/SECRETS.md b/k8s/SECRETS.md new file mode 100644 index 0000000000..cc3d5ce001 --- /dev/null +++ b/k8s/SECRETS.md @@ -0,0 +1,16 @@ +# Secrets Notes + +This file exists to satisfy the Lab 11 requirement for a dedicated secrets document without flattening the Kubernetes module back into one large documentation directory. + +## Lab 11 Documentation + +The full Lab 11 write-up, command transcripts, verification logs, and sanitized Vault evidence are kept in [docs/LAB11.md](docs/LAB11.md). + +## Why This Structure Is Better + +- `k8s/README.md` stays short and usable as the module entry point. +- `k8s/docs/LAB09.md`, [docs/LAB10.md](docs/LAB10.md), and [docs/LAB11.md](docs/LAB11.md) keep each Kubernetes lab self-contained. +- Raw manifests, Helm chart files, and documentation stay separated, which makes the implementation files easier to navigate. +- `k8s/SECRETS.md` provides the compatibility filename the lab expects while the actual report remains in the `docs/` hierarchy. + +In short, `SECRETS.md` is the compatibility layer, and `k8s/docs/` remains the maintainable long-term structure. diff --git a/k8s/argocd/application-dev.yaml b/k8s/argocd/application-dev.yaml new file mode 100644 index 0000000000..3fce36a82a --- /dev/null +++ b/k8s/argocd/application-dev.yaml @@ -0,0 +1,24 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-app-py-dev + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/LocalT0aster/DevOps-Core-S26.git + targetRevision: lab13 + path: k8s/devops-app-py + helm: + releaseName: devops-app-py-dev + 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..8d37d1deb8 --- /dev/null +++ b/k8s/argocd/application-prod.yaml @@ -0,0 +1,21 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-app-py-prod + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/LocalT0aster/DevOps-Core-S26.git + targetRevision: lab13 + path: k8s/devops-app-py + helm: + releaseName: devops-app-py-prod + valueFiles: + - values-prod.yaml + destination: + server: https://kubernetes.default.svc + namespace: prod + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/k8s/argocd/application.yaml b/k8s/argocd/application.yaml new file mode 100644 index 0000000000..bb44a82ce9 --- /dev/null +++ b/k8s/argocd/application.yaml @@ -0,0 +1,21 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-app-py + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/LocalT0aster/DevOps-Core-S26.git + targetRevision: lab13 + path: k8s/devops-app-py + helm: + releaseName: devops-app-py + 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..362ce70e04 --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-app-py + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 +spec: + replicas: 5 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: devops-app-py + template: + metadata: + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 + spec: + containers: + - name: devops-app-py + image: localt0aster/devops-app-py:1.9 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi diff --git a/k8s/devops-app-py/.helmignore b/k8s/devops-app-py/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/k8s/devops-app-py/.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/devops-app-py/Chart.yaml b/k8s/devops-app-py/Chart.yaml new file mode 100644 index 0000000000..43cfd8310f --- /dev/null +++ b/k8s/devops-app-py/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: devops-app-py +description: Helm chart for the DevOps Core Python application +type: application +version: 0.5.0 +appVersion: "1.12.0" +keywords: + - python + - flask + - kubernetes + - helm diff --git a/k8s/devops-app-py/files/config.json b/k8s/devops-app-py/files/config.json new file mode 100644 index 0000000000..6cf32ae23e --- /dev/null +++ b/k8s/devops-app-py/files/config.json @@ -0,0 +1,17 @@ +{ + "application": { + "name": {{ .Values.config.file.appName | quote }}, + "environment": {{ .Values.config.file.environment | quote }}, + "version": {{ (.Values.image.tag | default .Chart.AppVersion) | quote }} + }, + "featureFlags": { + "visitsCounter": {{ .Values.config.file.featureFlags.visitsCounter }}, + "metrics": {{ .Values.config.file.featureFlags.metrics }}, + "configReloadDemo": {{ .Values.config.file.featureFlags.configReloadDemo }} + }, + "settings": { + "configPath": {{ printf "%s/config.json" .Values.config.mountPath | quote }}, + "visitsFile": {{ printf "%s/visits" .Values.persistence.mountPath | quote }}, + "reloadStrategy": "checksum-rollout" + } +} diff --git a/k8s/devops-app-py/templates/NOTES.txt b/k8s/devops-app-py/templates/NOTES.txt new file mode 100644 index 0000000000..7919c1775c --- /dev/null +++ b/k8s/devops-app-py/templates/NOTES.txt @@ -0,0 +1,9 @@ +1. Review the release: + helm status {{ .Release.Name }} -n {{ .Release.Namespace }} + +2. Forward the service locally: + kubectl port-forward svc/{{ include "devops-app-py.serviceName" . }} 8080:{{ .Values.service.port }} -n {{ .Release.Namespace }} + +3. Verify the application: + curl -fsSL http://127.0.0.1:8080/health | jq + curl -fsSL http://127.0.0.1:8080/ready | jq diff --git a/k8s/devops-app-py/templates/_helpers.tpl b/k8s/devops-app-py/templates/_helpers.tpl new file mode 100644 index 0000000000..c86a8d9ab9 --- /dev/null +++ b/k8s/devops-app-py/templates/_helpers.tpl @@ -0,0 +1,159 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "devops-app-py.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 "devops-app-py.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 "devops-app-py.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "devops-app-py.labels" -}} +helm.sh/chart: {{ include "devops-app-py.chart" . }} +{{ include "devops-app-py.selectorLabels" . }} +{{- if (or .Values.image.tag .Chart.AppVersion) }} +app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: {{ .Values.partOf }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "devops-app-py.selectorLabels" -}} +app.kubernetes.io/name: {{ include "devops-app-py.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the service name. +*/}} +{{- define "devops-app-py.serviceName" -}} +{{- printf "%s-service" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the secret name. +*/}} +{{- define "devops-app-py.secretName" -}} +{{- printf "%s-secret" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the file ConfigMap name. +*/}} +{{- define "devops-app-py.fileConfigMapName" -}} +{{- printf "%s-config" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the env ConfigMap name. +*/}} +{{- define "devops-app-py.envConfigMapName" -}} +{{- printf "%s-env" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the PVC name. +*/}} +{{- define "devops-app-py.pvcName" -}} +{{- printf "%s-data" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the service account name. +*/}} +{{- define "devops-app-py.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "devops-app-py.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Render non-secret environment variables. +*/}} +{{- define "devops-app-py.envVars" -}} +{{- range .Values.env }} +- name: {{ .name }} + value: {{ .value | quote }} +{{- end }} +{{- end }} + +{{/* +Render the chart-managed config.json file. +*/}} +{{- define "devops-app-py.renderedConfigJson" -}} +{{- tpl (.Files.Get "files/config.json") . -}} +{{- end }} + +{{/* +Render pod checksum annotations for config-driven rollouts. +*/}} +{{- define "devops-app-py.configChecksums" -}} +{{- if .Values.config.file.enabled }} +checksum/config-file: {{ include "devops-app-py.renderedConfigJson" . | sha256sum | quote }} +{{- end }} +{{- if .Values.config.env.enabled }} +checksum/config-env: {{ toJson .Values.config.env.data | sha256sum | quote }} +{{- end }} +{{- end }} + +{{/* +Render Vault injector annotations. +*/}} +{{- define "devops-app-py.vaultAnnotations" -}} +{{- if .Values.vault.enabled }} +vault.hashicorp.com/agent-inject: "true" +vault.hashicorp.com/role: {{ .Values.vault.role | quote }} +vault.hashicorp.com/agent-inject-secret-config: {{ .Values.vault.secretPath | quote }} +vault.hashicorp.com/agent-inject-file-config: {{ .Values.vault.templateFile | quote }} +vault.hashicorp.com/agent-inject-template-config: | + {{ "{{- with secret \"" }}{{ .Values.vault.secretPath }}{{ "\" -}}" }} + APP_USERNAME={{ "{{ .Data.data.APP_USERNAME }}" }} + APP_PASSWORD={{ "{{ .Data.data.APP_PASSWORD }}" }} + APP_API_KEY={{ "{{ .Data.data.APP_API_KEY }}" }} + {{ "{{- end }}" }} +{{- end }} +{{- end }} + +{{/* +Create the pre-install hook job name. +*/}} +{{- define "devops-app-py.preInstallJobName" -}} +{{- printf "%s-pre-install" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the post-install hook job name. +*/}} +{{- define "devops-app-py.postInstallJobName" -}} +{{- printf "%s-post-install" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} diff --git a/k8s/devops-app-py/templates/configmap.yaml b/k8s/devops-app-py/templates/configmap.yaml new file mode 100644 index 0000000000..a7026b28bb --- /dev/null +++ b/k8s/devops-app-py/templates/configmap.yaml @@ -0,0 +1,24 @@ +{{- if .Values.config.file.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-app-py.fileConfigMapName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +data: + config.json: |- +{{ include "devops-app-py.renderedConfigJson" . | indent 4 }} +{{- end }} +{{- if and .Values.config.file.enabled .Values.config.env.enabled }} +--- +{{- end }} +{{- if .Values.config.env.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-app-py.envConfigMapName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +data: + {{- toYaml .Values.config.env.data | nindent 2 }} +{{- end }} diff --git a/k8s/devops-app-py/templates/deployment.yaml b/k8s/devops-app-py/templates/deployment.yaml new file mode 100644 index 0000000000..d099e3a42c --- /dev/null +++ b/k8s/devops-app-py/templates/deployment.yaml @@ -0,0 +1,102 @@ +apiVersion: apps/v1 +kind: Deployment +{{- $envVars := include "devops-app-py.envVars" . | trim }} +{{- $vaultAnnotations := include "devops-app-py.vaultAnnotations" . | trim }} +{{- $configChecksums := include "devops-app-py.configChecksums" . | trim }} +metadata: + name: {{ include "devops-app-py.fullname" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: {{ .Values.deployment.strategy.maxSurge }} + maxUnavailable: {{ .Values.deployment.strategy.maxUnavailable }} + selector: + matchLabels: + {{- include "devops-app-py.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- if or $vaultAnnotations $configChecksums .Values.podAnnotations }} + annotations: + {{- if $vaultAnnotations }} + {{- $vaultAnnotations | nindent 8 }} + {{- end }} + {{- if $configChecksums }} + {{- $configChecksums | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + labels: + {{- include "devops-app-py.selectorLabels" . | nindent 8 }} + app.kubernetes.io/part-of: {{ .Values.partOf }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "devops-app-py.serviceAccountName" . }} + containers: + - name: {{ include "devops-app-py.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.containerPort }} + protocol: TCP + {{- if or .Values.config.file.enabled .Values.persistence.enabled }} + volumeMounts: + {{- if .Values.config.file.enabled }} + - name: config-volume + mountPath: {{ .Values.config.mountPath | quote }} + readOnly: true + {{- end }} + {{- if .Values.persistence.enabled }} + - name: data-volume + mountPath: {{ .Values.persistence.mountPath | quote }} + {{- end }} + {{- end }} + {{- if or .Values.config.env.enabled .Values.secrets.enabled }} + envFrom: + {{- if .Values.config.env.enabled }} + - configMapRef: + name: {{ include "devops-app-py.envConfigMapName" . }} + {{- end }} + {{- if .Values.secrets.enabled }} + - secretRef: + name: {{ include "devops-app-py.secretName" . }} + {{- end }} + {{- end }} + {{- if $envVars }} + env: + {{- $envVars | nindent 12 }} + {{- end }} + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if or .Values.config.file.enabled .Values.persistence.enabled }} + volumes: + {{- if .Values.config.file.enabled }} + - name: config-volume + configMap: + name: {{ include "devops-app-py.fileConfigMapName" . }} + {{- end }} + {{- if .Values.persistence.enabled }} + - name: data-volume + persistentVolumeClaim: + claimName: {{ include "devops-app-py.pvcName" . }} + {{- end }} + {{- end }} diff --git a/k8s/devops-app-py/templates/hooks/post-install-job.yaml b/k8s/devops-app-py/templates/hooks/post-install-job.yaml new file mode 100644 index 0000000000..95bacb10ea --- /dev/null +++ b/k8s/devops-app-py/templates/hooks/post-install-job.yaml @@ -0,0 +1,47 @@ +{{- if .Values.hooks.postInstall.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "devops-app-py.postInstallJobName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} + app.kubernetes.io/component: hook + annotations: + "helm.sh/hook": post-install + "helm.sh/hook-weight": {{ .Values.hooks.postInstall.weight | quote }} + "helm.sh/hook-delete-policy": {{ .Values.hooks.postInstall.deletePolicy | quote }} +spec: + backoffLimit: 0 + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "devops-app-py.name" . }} + app.kubernetes.io/component: hook + spec: + restartPolicy: Never + containers: + - name: post-install-smoke-test + image: "{{ .Values.hooks.postInstall.image.repository }}:{{ .Values.hooks.postInstall.image.tag }}" + imagePullPolicy: {{ .Values.hooks.postInstall.image.pullPolicy }} + command: + - sh + - -c + - | + set -eu + url="http://{{ include "devops-app-py.serviceName" . }}:{{ .Values.service.port }}/ready" + attempt=1 + while [ "$attempt" -le {{ .Values.hooks.postInstall.maxAttempts }} ]; do + code="$(curl -sS -o /tmp/ready.json -w "%{http_code}" "$url" || true)" + if [ "$code" = "200" ]; then + echo "Smoke test passed on attempt ${attempt}" + cat /tmp/ready.json + sleep 3 + exit 0 + fi + echo "Attempt ${attempt} returned ${code}" + attempt=$((attempt + 1)) + sleep {{ .Values.hooks.postInstall.retryIntervalSeconds }} + done + echo "Smoke test failed for ${url}" + exit 1 +{{- end }} diff --git a/k8s/devops-app-py/templates/hooks/pre-install-job.yaml b/k8s/devops-app-py/templates/hooks/pre-install-job.yaml new file mode 100644 index 0000000000..5a9a1ced62 --- /dev/null +++ b/k8s/devops-app-py/templates/hooks/pre-install-job.yaml @@ -0,0 +1,37 @@ +{{- if .Values.hooks.preInstall.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "devops-app-py.preInstallJobName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} + app.kubernetes.io/component: hook + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-weight": {{ .Values.hooks.preInstall.weight | quote }} + "helm.sh/hook-delete-policy": {{ .Values.hooks.preInstall.deletePolicy | quote }} +spec: + backoffLimit: 0 + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "devops-app-py.name" . }} + app.kubernetes.io/component: hook + spec: + restartPolicy: Never + containers: + - name: pre-install-validation + image: "{{ .Values.hooks.preInstall.image.repository }}:{{ .Values.hooks.preInstall.image.tag }}" + imagePullPolicy: {{ .Values.hooks.preInstall.image.pullPolicy }} + command: + - sh + - -c + - | + set -eu + echo "Pre-install validation for {{ .Release.Name }}" + echo "Namespace: {{ .Release.Namespace }}" + echo "Image tag: {{ .Values.image.tag | default .Chart.AppVersion }}" + echo "Replica count: {{ .Values.replicaCount }}" + sleep 3 + echo "Pre-install validation completed" +{{- end }} diff --git a/k8s/devops-app-py/templates/pvc.yaml b/k8s/devops-app-py/templates/pvc.yaml new file mode 100644 index 0000000000..5f817de35e --- /dev/null +++ b/k8s/devops-app-py/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "devops-app-py.pvcName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/k8s/devops-app-py/templates/secrets.yaml b/k8s/devops-app-py/templates/secrets.yaml new file mode 100644 index 0000000000..e68143bd57 --- /dev/null +++ b/k8s/devops-app-py/templates/secrets.yaml @@ -0,0 +1,12 @@ +{{- if .Values.secrets.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "devops-app-py.secretName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +type: Opaque +stringData: + APP_USERNAME: {{ .Values.secrets.stringData.APP_USERNAME | quote }} + APP_PASSWORD: {{ .Values.secrets.stringData.APP_PASSWORD | quote }} +{{- end }} diff --git a/k8s/devops-app-py/templates/service.yaml b/k8s/devops-app-py/templates/service.yaml new file mode 100644 index 0000000000..03a7ec5675 --- /dev/null +++ b/k8s/devops-app-py/templates/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-app-py.serviceName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + {{- if and (or (eq .Values.service.type "NodePort") (eq .Values.service.type "LoadBalancer")) .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + {{- include "devops-app-py.selectorLabels" . | nindent 4 }} diff --git a/k8s/devops-app-py/templates/serviceaccount.yaml b/k8s/devops-app-py/templates/serviceaccount.yaml new file mode 100644 index 0000000000..c9a148fb0b --- /dev/null +++ b/k8s/devops-app-py/templates/serviceaccount.yaml @@ -0,0 +1,8 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "devops-app-py.serviceAccountName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +{{- end }} diff --git a/k8s/devops-app-py/values-dev.yaml b/k8s/devops-app-py/values-dev.yaml new file mode 100644 index 0000000000..53b0d9f3cb --- /dev/null +++ b/k8s/devops-app-py/values-dev.yaml @@ -0,0 +1,58 @@ +replicaCount: 1 + +image: + tag: "1.12-dev" + +deployment: + revisionHistoryLimit: 2 + +env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + +config: + file: + environment: "development" + env: + data: + APP_ENV: "development" + LOG_LEVEL: "debug" + +podLabels: + environment: dev + +podAnnotations: + lab13-sync-wave: "1" + +service: + type: ClusterIP + port: 80 + targetPort: 5000 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 diff --git a/k8s/devops-app-py/values-prod.yaml b/k8s/devops-app-py/values-prod.yaml new file mode 100644 index 0000000000..1f5fb5fb24 --- /dev/null +++ b/k8s/devops-app-py/values-prod.yaml @@ -0,0 +1,60 @@ +replicaCount: 2 + +image: + tag: "1.12" + +deployment: + revisionHistoryLimit: 10 + +env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + +config: + file: + environment: "production" + featureFlags: + configReloadDemo: false + env: + data: + APP_ENV: "production" + LOG_LEVEL: "info" + +podLabels: + environment: prod + +podAnnotations: + lab13-sync-wave: "1" + +service: + type: ClusterIP + port: 80 + targetPort: 5000 + +resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 10 + periodSeconds: 3 + timeoutSeconds: 2 + failureThreshold: 3 diff --git a/k8s/devops-app-py/values.yaml b/k8s/devops-app-py/values.yaml new file mode 100644 index 0000000000..b034f0be33 --- /dev/null +++ b/k8s/devops-app-py/values.yaml @@ -0,0 +1,119 @@ +replicaCount: 1 +partOf: devops-core-s26 + +image: + repository: localt0aster/devops-app-py + tag: "1.12-dev" + pullPolicy: IfNotPresent + +containerPort: 5000 + +nameOverride: "" +fullnameOverride: "" + +podAnnotations: {} +podLabels: {} + +deployment: + revisionHistoryLimit: 5 + strategy: + maxSurge: 1 + maxUnavailable: 0 + +env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + +config: + mountPath: /config + file: + enabled: true + appName: "devops-info-service" + environment: "development" + featureFlags: + visitsCounter: true + metrics: true + configReloadDemo: true + env: + enabled: true + data: + APP_NAME: "devops-info-service" + APP_ENV: "development" + APP_CONFIG_PATH: "/config/config.json" + APP_VISITS_PATH: "/data/visits" + LOG_LEVEL: "info" + +persistence: + enabled: true + mountPath: /data + size: 100Mi + storageClass: "" + +secrets: + enabled: true + stringData: + APP_USERNAME: "placeholder-user" + APP_PASSWORD: "placeholder-password" + +serviceAccount: + create: true + name: "" + +vault: + enabled: false + role: "lab11-devops-app-py" + secretPath: "secret/data/lab11/devops-app-py" + templateFile: "app-config.env" + +service: + type: ClusterIP + port: 80 + targetPort: 5000 + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +hooks: + preInstall: + enabled: true + weight: -5 + deletePolicy: before-hook-creation,hook-succeeded + image: + repository: busybox + tag: "1.37.0" + pullPolicy: IfNotPresent + postInstall: + enabled: true + weight: 5 + deletePolicy: before-hook-creation,hook-succeeded + image: + repository: curlimages/curl + tag: "8.12.1" + pullPolicy: IfNotPresent + maxAttempts: 20 + retryIntervalSeconds: 3 diff --git a/k8s/docs/LAB09.md b/k8s/docs/LAB09.md new file mode 100644 index 0000000000..b20187c999 --- /dev/null +++ b/k8s/docs/LAB09.md @@ -0,0 +1,651 @@ +# Kubernetes Lab 9 + +## Task 1 - Local Kubernetes Setup + +I used `minikube` because it was in Arch Linux extra repo (`kind` is only in AUR), integrates cleanly with the Docker driver, and has more features. + +
+Cluster setup verification output + +```text +$ minikube status +minikube +type: Control Plane +host: Running +kubelet: Running +apiserver: Running +kubeconfig: Configured + + +$ kubectl cluster-info +Kubernetes control plane is running at https://192.168.49.2:8443 +CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy + +To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'. + +$ kubectl get nodes -o wide +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 2m45s v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.19.10-1-cachyos docker://29.2.1 + +$ kubectl get namespaces +NAME STATUS AGE +default Active 3m9s +kube-node-lease Active 3m9s +kube-public Active 3m9s +kube-system Active 3m9s +``` + +
+ +## Task 2 - Application Deployment + +The initial Task 2 deployment used `localt0aster/devops-app-py:1.9` with 3 replicas, rolling updates, and resource requests and limits. At that stage, the probes were `GET /health` for liveness and `GET /ready` for readiness. Task 4 later scaled the manifest to 5 replicas and tightened the rollout strategy. + +
+Deployment rollout verification output + +```text +$ kubectl delete deployment devops-app-py --cascade=foreground --wait=true +deployment.apps "devops-app-py" deleted from default namespace + +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py created + +$ kubectl rollout status deployment/devops-app-py --timeout=180s +Waiting for deployment "devops-app-py" rollout to finish: 0 of 3 updated replicas are available... +Waiting for deployment "devops-app-py" rollout to finish: 1 of 3 updated replicas are available... +Waiting for deployment "devops-app-py" rollout to finish: 2 of 3 updated replicas are available... +deployment "devops-app-py" successfully rolled out + +$ kubectl get deployment devops-app-py +NAME READY UP-TO-DATE AVAILABLE AGE +devops-app-py 3/3 3 3 8s + +$ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +devops-app-py-76fc7985df-jq2tr 1/1 Running 0 8s 10.244.0.14 minikube +devops-app-py-76fc7985df-jwpsf 1/1 Running 0 8s 10.244.0.13 minikube +devops-app-py-76fc7985df-nwr58 1/1 Running 0 8s 10.244.0.12 minikube + +$ kubectl describe deployment devops-app-py +Name: devops-app-py +Namespace: default +CreationTimestamp: Fri, 27 Mar 2026 05:16:21 +0300 +Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 +Annotations: deployment.kubernetes.io/revision: 1 +Selector: app.kubernetes.io/name=devops-app-py +Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unavailable +StrategyType: RollingUpdate +MinReadySeconds: 0 +RollingUpdateStrategy: 1 max unavailable, 1 max surge +Pod Template: + Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 + Containers: + devops-app-py: + Image: localt0aster/devops-app-py:1.9 + Port: 5000/TCP (http) + Host Port: 0/TCP (http) + Limits: + cpu: 250m + memory: 256Mi + Requests: + cpu: 100m + memory: 128Mi + Liveness: http-get http://:http/health delay=10s timeout=2s period=10s #success=1 #failure=3 + Readiness: http-get http://:http/ready delay=5s timeout=2s period=5s #success=1 #failure=3 + Environment: + HOST: 0.0.0.0 + PORT: 5000 + Mounts: + Volumes: + Node-Selectors: + Tolerations: +Conditions: + Type Status Reason + ---- ------ ------ + Available True MinimumReplicasAvailable + Progressing True NewReplicaSetAvailable +OldReplicaSets: +NewReplicaSet: devops-app-py-76fc7985df (3/3 replicas created) +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal ScalingReplicaSet 9s deployment-controller Scaled up replica set devops-app-py-76fc7985df from 0 to 3 +``` + +
+ +## Task 3 - Service Configuration + +The Service uses type `NodePort` and targets the Deployment Pods with the `app.kubernetes.io/name=devops-app-py` label. It exposes service port `80` and forwards traffic to container port `5000` on a fixed NodePort, `30080`. + +For connectivity verification, I used `kubectl port-forward service/devops-app-py-service 8080:80`. I tested `minikube service ... --url` first, but in this Docker-driver setup the returned node IP was not directly reachable from the host, so port-forward was the practical local-access path. + +
+Service verification output + +```text +$ kubectl apply -f k8s/service.yml +service/devops-app-py-service unchanged + +$ kubectl get services +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 32s +kubernetes ClusterIP 10.96.0.1 443/TCP 80m + +$ kubectl describe service devops-app-py-service +Name: devops-app-py-service +Namespace: default +Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 +Annotations: +Selector: app.kubernetes.io/name=devops-app-py +Type: NodePort +IP Family Policy: SingleStack +IP Families: IPv4 +IP: 10.110.168.128 +IPs: 10.110.168.128 +Port: http 80/TCP +TargetPort: 5000/TCP +NodePort: http 30080/TCP +Endpoints: 10.244.0.12:5000,10.244.0.13:5000,10.244.0.14:5000 +Session Affinity: None +External Traffic Policy: Cluster +Internal Traffic Policy: Cluster +Events: + +$ kubectl get endpoints devops-app-py-service +Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice +NAME ENDPOINTS AGE +devops-app-py-service 10.244.0.12:5000,10.244.0.13:5000,10.244.0.14:5000 32s + +$ kubectl port-forward service/devops-app-py-service 8080:80 +Forwarding from 127.0.0.1:8080 -> 5000 +Forwarding from [::1]:8080 -> 5000 +Handling connection for 8080 +Handling connection for 8080 +Handling connection for 8080 +Handling connection for 8080 + +$ curl -fsSL 127.0.0.1:8080 | jq .service.name +"devops-info-service" + +$ curl -fsSL 127.0.0.1:8080/health | jq .status +"healthy" + +$ curl -fsSL 127.0.0.1:8080/ready | jq .status +"ready" + +$ curl -fsSL 127.0.0.1:8080/metrics | head -n 12 +# HELP http_requests_total Total HTTP requests handled by the service. +# TYPE http_requests_total counter +http_requests_total{endpoint="/ready",method="GET",status_code="200"} 180.0 +http_requests_total{endpoint="/health",method="GET",status_code="200"} 90.0 +http_requests_total{endpoint="/",method="GET",status_code="200"} 2.0 +http_requests_total{endpoint="/metrics",method="GET",status_code="200"} 1.0 +# HELP http_requests_created Total HTTP requests handled by the service. +# TYPE http_requests_created gauge +http_requests_created{endpoint="/ready",method="GET",status_code="200"} 1.7745777896655755e+09 +http_requests_created{endpoint="/health",method="GET",status_code="200"} 1.7745778018120363e+09 +http_requests_created{endpoint="/",method="GET",status_code="200"} 1.7745779956714542e+09 +http_requests_created{endpoint="/metrics",method="GET",status_code="200"} 1.7745779957933705e+09 +``` + +
+ +## Task 4 - Scaling and Updates + +I scaled the Deployment declaratively to 5 replicas and verified that all 5 Pods were running. For the rolling-update portion, I changed the pod template with a temporary `LOG_LEVEL=DEBUG` environment variable. An in-cluster probe exposed a brief failed request with `maxUnavailable: 1`, so I changed the strategy to `maxUnavailable: 0` and reran the rollout. With that stricter strategy, the Service returned `200` for 35 consecutive `/ready` checks during the rollout. I then used `kubectl rollout undo` and returned the live Deployment to the baseline `1.9` pod template while keeping the safer rollout strategy in the manifest. + +
+Scaling to 5 replicas + +```text +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py configured + +$ kubectl rollout status deployment/devops-app-py --timeout=180s +Waiting for deployment "devops-app-py" rollout to finish: 3 of 5 updated replicas are available... +Waiting for deployment "devops-app-py" rollout to finish: 4 of 5 updated replicas are available... +deployment "devops-app-py" successfully rolled out + +$ kubectl get deployment devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +devops-app-py 5/5 5 5 21m devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/name=devops-app-py + +$ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +devops-app-py-76fc7985df-jmnrd 1/1 Running 0 13s 10.244.0.16 minikube +devops-app-py-76fc7985df-jq2tr 1/1 Running 0 21m 10.244.0.14 minikube +devops-app-py-76fc7985df-jrgms 1/1 Running 0 13s 10.244.0.15 minikube +devops-app-py-76fc7985df-jwpsf 1/1 Running 0 21m 10.244.0.13 minikube +devops-app-py-76fc7985df-nwr58 1/1 Running 0 21m 10.244.0.12 minikube +``` + +
+ +
+Rolling update with corrected zero-downtime strategy + +```text +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py configured + +$ kubectl rollout status deployment/devops-app-py --timeout=240s +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +deployment "devops-app-py" successfully rolled out + +$ kubectl get rs -l app.kubernetes.io/name=devops-app-py -o wide +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +devops-app-py-65fc658668 5 5 5 6m45s devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/name=devops-app-py,pod-template-hash=65fc658668 +devops-app-py-76fc7985df 0 0 0 29m devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/name=devops-app-py,pod-template-hash=76fc7985df + +$ kubectl get deployment devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +devops-app-py 5/5 5 5 29m devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/name=devops-app-py + +$ kubectl rollout history deployment/devops-app-py +deployment.apps/devops-app-py +REVISION CHANGE-CAUSE +5 +6 +``` + +
+ +
+In-cluster readiness probe during rollout + +```text +$ kubectl run task4-probe-zdt \ + --image=curlimages/curl --rm -i --command -- \ + sh -c ' + for i in $(seq 1 35); do + code=$(curl -sS -o /dev/null -w "%{http_code}" http://devops-app-py-service/ready) + printf "%s %s\n" "$(date +%H:%M:%S)" "$code" + sleep 1 + done + ' +All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt. +If you don't see a command prompt, try pressing enter. +02:44:47 200 +02:44:48 200 +02:44:49 200 +02:44:50 200 +02:44:51 200 +02:44:52 200 +02:44:53 200 +02:44:54 200 +02:44:55 200 +02:44:56 200 +02:44:57 200 +02:44:58 200 +02:44:59 200 +02:45:00 200 +02:45:01 200 +02:45:02 200 +02:45:03 200 +02:45:04 200 +02:45:05 200 +02:45:06 200 +02:45:07 200 +02:45:08 200 +02:45:09 200 +02:45:10 200 +02:45:11 200 +02:45:12 200 +02:45:13 200 +02:45:14 200 +02:45:15 200 +02:45:16 200 +02:45:17 200 +02:45:18 200 +02:45:19 200 +02:45:20 200 +pod "task4-probe-zdt" deleted from default namespace +``` + +
+ +
+Rollback and rollout history + +```text +$ kubectl rollout undo deployment/devops-app-py +deployment.apps/devops-app-py rolled back + +$ kubectl rollout status deployment/devops-app-py --timeout=240s +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +deployment "devops-app-py" successfully rolled out + +$ kubectl get rs -l app.kubernetes.io/name=devops-app-py -o wide +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +devops-app-py-65fc658668 0 0 0 7m45s devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/name=devops-app-py,pod-template-hash=65fc658668 +devops-app-py-76fc7985df 5 5 5 30m devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/name=devops-app-py,pod-template-hash=76fc7985df + +$ kubectl get deployment devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +devops-app-py 5/5 5 5 30m devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/name=devops-app-py + +$ kubectl rollout history deployment/devops-app-py +deployment.apps/devops-app-py +REVISION CHANGE-CAUSE +6 +7 +``` + +
+ +## Task 5 - Documentation + +### Architecture Overview + +The final Kubernetes layout is one `Deployment` and one `NodePort` `Service` in the default namespace. The Deployment runs 5 Flask Pods from `localt0aster/devops-app-py:1.9`, and the Service load-balances traffic from port `80` to container port `5000`. + +```mermaid +flowchart LR + Client[Client] + Service[NodePort Service
devops-app-py-service
80 -> 5000
30080/TCP] + Deployment[Deployment
devops-app-py
5 replicas] + Pod1[Pod
/] + Pod2[Pod
/health] + Pod3[Pod
/ready] + Pod4[Pod
/metrics] + Pod5[Pod
5000/TCP] + + Client --> Service + Service --> Deployment + Deployment --> Pod1 + Deployment --> Pod2 + Deployment --> Pod3 + Deployment --> Pod4 + Deployment --> Pod5 +``` + +The resource strategy is intentionally small and predictable for a local lab cluster: each Pod requests `100m` CPU and `128Mi` memory, with limits of `250m` CPU and `256Mi` memory. For rollouts, the Deployment now uses `maxSurge: 1` and `maxUnavailable: 0` to preserve availability during Pod replacement. + +### Manifest Files + +- `k8s/deployment.yml`: defines the `Deployment`, `5` replicas, the `1.9` Python image, labels/selectors, container port, `HOST` and `PORT` environment variables, resource requests and limits, and liveness/readiness probes. `maxUnavailable: 0` was chosen after testing showed that `1` could still allow a transient failed request during rollout. +- `k8s/service.yml`: defines the `NodePort` `Service`, maps service port `80` to target port `5000`, uses node port `30080`, and selects Pods by `app.kubernetes.io/name=devops-app-py`. + +### Deployment Evidence + +
+Final cluster evidence + +```text +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py configured + +$ kubectl get all +NAME READY STATUS RESTARTS AGE +pod/devops-app-py-76fc7985df-6hmn5 1/1 Running 0 52s +pod/devops-app-py-76fc7985df-6rk64 1/1 Running 0 69s +pod/devops-app-py-76fc7985df-hr29v 1/1 Running 0 61s +pod/devops-app-py-76fc7985df-ptjkm 1/1 Running 0 78s +pod/devops-app-py-76fc7985df-t6d7b 1/1 Running 0 44s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 27m +service/kubernetes ClusterIP 10.96.0.1 443/TCP 107m + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-app-py 5/5 5 5 30m + +NAME DESIRED CURRENT READY AGE +replicaset.apps/devops-app-py-65fc658668 0 0 0 8m20s +replicaset.apps/devops-app-py-76fc7985df 5 5 5 30m + +$ kubectl get pods,svc -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/devops-app-py-76fc7985df-6hmn5 1/1 Running 0 52s 10.244.0.47 minikube +pod/devops-app-py-76fc7985df-6rk64 1/1 Running 0 69s 10.244.0.45 minikube +pod/devops-app-py-76fc7985df-hr29v 1/1 Running 0 61s 10.244.0.46 minikube +pod/devops-app-py-76fc7985df-ptjkm 1/1 Running 0 78s 10.244.0.44 minikube +pod/devops-app-py-76fc7985df-t6d7b 1/1 Running 0 44s 10.244.0.48 minikube + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 27m app.kubernetes.io/name=devops-app-py +service/kubernetes ClusterIP 10.96.0.1 443/TCP 107m + +$ kubectl describe deployment devops-app-py +Name: devops-app-py +Namespace: default +CreationTimestamp: Fri, 27 Mar 2026 05:16:21 +0300 +Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 +Annotations: deployment.kubernetes.io/revision: 7 +Selector: app.kubernetes.io/name=devops-app-py +Replicas: 5 desired | 5 updated | 5 total | 5 available | 0 unavailable +StrategyType: RollingUpdate +MinReadySeconds: 0 +RollingUpdateStrategy: 0 max unavailable, 1 max surge +Pod Template: + Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 + Containers: + devops-app-py: + Image: localt0aster/devops-app-py:1.9 + Port: 5000/TCP (http) + Host Port: 0/TCP (http) + Limits: + cpu: 250m + memory: 256Mi + Requests: + cpu: 100m + memory: 128Mi + Liveness: http-get http://:http/health delay=10s timeout=2s period=10s #success=1 #failure=3 + Readiness: http-get http://:http/ready delay=5s timeout=2s period=5s #success=1 #failure=3 + Environment: + HOST: 0.0.0.0 + PORT: 5000 + Mounts: + Volumes: + Node-Selectors: + Tolerations: +Conditions: + Type Status Reason + ---- ------ ------ + Available True MinimumReplicasAvailable + Progressing True NewReplicaSetAvailable +OldReplicaSets: devops-app-py-65fc658668 (0/0 replicas created) +NewReplicaSet: devops-app-py-76fc7985df (5/5 replicas created) +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal ScalingReplicaSet 30m deployment-controller Scaled up replica set devops-app-py-76fc7985df from 0 to 3 + Normal ScalingReplicaSet 9m21s deployment-controller Scaled up replica set devops-app-py-76fc7985df from 3 to 5 + Normal ScalingReplicaSet 8m20s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 0 to 1 + Normal ScalingReplicaSet 8m20s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 1 to 2 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 4 to 3 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 2 to 3 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 3 to 2 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 3 to 4 + Normal ScalingReplicaSet 8m3s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 2 to 1 + Normal ScalingReplicaSet 4m58s (x2 over 8m20s) deployment-controller Scaled down replica set devops-app-py-76fc7985df from 5 to 4 + Normal ScalingReplicaSet 3m39s (x22 over 8m3s) deployment-controller (combined from similar events): Scaled up replica set devops-app-py-76fc7985df from 0 to 1 + +$ kubectl run task5-curl \ + --image=curlimages/curl \ + \ + --rm -i \ + --command -- \ + sh -c ' + curl -fsS http://devops-app-py-service + printf "\n\n" + curl -fsS http://devops-app-py-service/health + printf "\n\n" + curl -fsS http://devops-app-py-service/ready + printf "\n\n" + curl -fsS http://devops-app-py-service/metrics | head -n 12 + ' +{ + "endpoints": [ + { + "description": "Service information.", + "method": "GET", + "path": "/" + }, + { + "description": "Health check.", + "method": "GET", + "path": "/health" + }, + { + "description": "Prometheus metrics.", + "method": "GET", + "path": "/metrics" + }, + { + "description": "Readiness check.", + "method": "GET", + "path": "/ready" + } + ], + "request": { + "client_ip": "10.244.0.49", + "method": "GET", + "path": "/", + "user_agent": "curl/8.12.1" + }, + "runtime": { + "human": "0 hours, 1 minutes", + "seconds": 61 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.8.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 8, + "hostname": "devops-app-py-76fc7985df-6rk64", + "platform": "Linux", + "platform_version": "Alpine Linux v3.23", + "python_version": "3.14.3" + } +} + + +{ + "status": "healthy", + "timestamp": "2026-03-27T02:47:15.357513+00:00", + "uptime_seconds": 70 +} + + +{ + "status": "ready", + "timestamp": "2026-03-27T02:47:15.363373+00:00", + "uptime_seconds": 53 +} + + +# HELP http_requests_total Total HTTP requests handled by the service. +# TYPE http_requests_total counter +http_requests_total{endpoint="/ready",method="GET",status_code="200"} 13.0 +http_requests_total{endpoint="/health",method="GET",status_code="200"} 5.0 +http_requests_total{endpoint="/",method="GET",status_code="200"} 1.0 +# HELP http_requests_created Total HTTP requests handled by the service. +# TYPE http_requests_created gauge +http_requests_created{endpoint="/ready",method="GET",status_code="200"} 1.7745795737986815e+09 +http_requests_created{endpoint="/health",method="GET",status_code="200"} 1.7745795857890186e+09 +http_requests_created{endpoint="/",method="GET",status_code="200"} 1.774579635349531e+09 +# HELP http_request_duration_seconds HTTP request duration in seconds. +# TYPE http_request_duration_seconds histogram +pod "task5-curl" deleted from default namespace +``` + +
+ +### Operations Performed + +1. Deployment and verification: + + ```bash + kubectl apply -f k8s/deployment.yml + kubectl rollout status deployment/devops-app-py + kubectl get deployment devops-app-py -o wide + kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide + kubectl describe deployment devops-app-py + ``` + +2. Service setup and access checks: + + ```bash + kubectl apply -f k8s/service.yml + kubectl describe service devops-app-py-service + kubectl port-forward service/devops-app-py-service 8080:80 + kubectl run task5-curl --image=curlimages/curl --rm -i --command -- sh + ``` + +3. Scaling to 5 replicas: + + ```bash + kubectl apply -f k8s/deployment.yml + kubectl rollout status deployment/devops-app-py --timeout=180s + kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide + ``` + +4. Rolling updates and rollback: + + ```bash + kubectl apply -f k8s/deployment.yml + kubectl rollout status deployment/devops-app-py --timeout=240s + kubectl rollout history deployment/devops-app-py + kubectl rollout undo deployment/devops-app-py + ``` + +### Production Considerations + +- Health checks: `/health` is used for liveness and `/ready` is used for readiness so Kubernetes only sends traffic to Pods that are actually prepared to serve requests. +- Rollout safety: `maxUnavailable: 0` and `maxSurge: 1` were chosen to keep capacity available during updates. This was not just theoretical; the strategy was tightened after observing a transient failed request with `maxUnavailable: 1`. +- Resource limits: `100m/128Mi` requests and `250m/256Mi` limits are reasonable for a lab environment and protect the single-node cluster from noisy-neighbor behavior. +- Observability: the app exposes `/metrics`, which is ready for Prometheus scraping. `kubectl describe`, rollout history, and event inspection were enough for this lab, but production should add centralized logs and dashboards. +- Further improvements: use image digests instead of mutable tags, add a `PodDisruptionBudget`, enable `HorizontalPodAutoscaler`, isolate workloads in a dedicated namespace, and place an Ingress with TLS in front of the NodePort service. + +### Challenges & Solutions + +- `minikube service devops-app-py-service --url` returned a valid NodePort URL, but with the Docker driver that node IP was not directly reachable from the host. I used `kubectl port-forward` for local testing and an in-cluster curl Pod for authoritative service verification. +- The first zero-downtime test with `maxUnavailable: 1` produced a brief failed request during rollout. Instead of papering over it, I changed the Deployment strategy to `maxUnavailable: 0` and reran the test until the probe showed a clean `200` sequence. +- Host-side port-forward tests can introduce their own connection artifacts during fast backend replacement. The more reliable method here was probing the Kubernetes Service from inside the cluster. + diff --git a/k8s/docs/LAB10.md b/k8s/docs/LAB10.md new file mode 100644 index 0000000000..f2bbd951b7 --- /dev/null +++ b/k8s/docs/LAB10.md @@ -0,0 +1,1064 @@ +# Kubernetes Lab 10 - Helm Package Manager + +## Task 1 - Helm Fundamentals + +Helm is a package manager for Kubernetes. In practical terms, a chart bundles templates, defaults, and metadata so the same application can be installed as reusable releases instead of copying raw YAML by hand. Repositories distribute those charts, and `values.yaml` provides the layer where environment-specific settings are changed without rewriting templates. + +For the fundamentals task, I verified the local Helm installation, refreshed public repositories, searched available Prometheus-related charts, inspected the metadata of the public `prometheus-community/prometheus` chart, and pulled it locally to review the typical chart structure. The public chart layout confirmed the core Helm pattern: `Chart.yaml` for metadata, `values.yaml` and `values.schema.json` for configuration, `_helpers.tpl` for naming/label helpers, and `templates/` for rendered Kubernetes manifests. + +
+Task 1 command output + +```bash +$ helm version +version.BuildInfo{Version:"v4.1.3", GitCommit:"c94d381b03be117e7e57908edbf642104e00eb8f", GitTreeState:"", GoVersion:"go1.26.1-X:nodwarf5", KubeClientVersion:"v1.35"} + +$ helm repo add bitnami https://charts.bitnami.com/bitnami +"bitnami" has been added to your repositories + +$ helm repo update +Hang tight while we grab the latest from your chart repositories... +...Successfully got an update from the "prometheus-community" chart repository +...Successfully got an update from the "bitnami" chart repository +Update Complete. ⎈Happy Helming!⎈ + +$ helm search repo prometheus +NAME CHART VERSION APP VERSION DESCRIPTION +bitnami/kube-prometheus 11.3.10 0.85.0 Prometheus Operator provides easy monitoring de... +bitnami/prometheus 2.1.23 3.5.0 Prometheus is an open source monitoring and ale... +bitnami/wavefront-prometheus-storage-adapter 2.3.3 1.0.7 DEPRECATED Wavefront Storage Adapter is a Prome... +prometheus-community/kube-prometheus-stack 82.16.1 v0.89.0 kube-prometheus-stack collects Kubernetes manif... +prometheus-community/prometheus 28.15.0 v3.11.0 Prometheus is a monitoring system and time seri... +prometheus-community/prometheus-adapter 5.3.0 v0.12.0 A Helm chart for k8s prometheus adapter +prometheus-community/prometheus-blackbox-exporter 11.9.1 v0.28.0 Prometheus Blackbox Exporter +prometheus-community/prometheus-cloudwatch-expo... 0.28.1 0.16.0 A Helm chart for prometheus cloudwatch-exporter +prometheus-community/prometheus-conntrack-stats... 0.5.35 v0.4.42 A Helm chart for conntrack-stats-exporter +prometheus-community/prometheus-consul-exporter 1.1.1 v0.13.0 A Helm chart for the Prometheus Consul Exporter +prometheus-community/prometheus-couchdb-exporter 1.0.1 1.0 A Helm chart to export the metrics from couchdb... +prometheus-community/prometheus-druid-exporter 1.2.0 v0.11.0 Druid exporter to monitor druid metrics with Pr... +prometheus-community/prometheus-elasticsearch-e... 7.2.1 v1.10.0 Elasticsearch stats exporter for Prometheus +prometheus-community/prometheus-fastly-exporter 0.11.0 v10.2.0 A Helm chart for the Prometheus Fastly Exporter +prometheus-community/prometheus-ipmi-exporter 0.8.0 v1.10.1 This is an IPMI exporter for Prometheus. +prometheus-community/prometheus-json-exporter 0.19.2 v0.7.0 Install prometheus-json-exporter +prometheus-community/prometheus-kafka-exporter 3.0.1 v1.9.0 A Helm chart to export metrics from Kafka in Pr... +prometheus-community/prometheus-memcached-exporter 0.4.5 v0.15.5 Prometheus exporter for Memcached metrics +prometheus-community/prometheus-modbus-exporter 0.1.4 0.4.1 A Helm chart for prometheus-modbus-exporter +prometheus-community/prometheus-mongodb-exporter 3.18.0 0.49.0 A Prometheus exporter for MongoDB metrics +prometheus-community/prometheus-mysql-exporter 2.13.0 v0.19.0 A Helm chart for prometheus mysql exporter with... +prometheus-community/prometheus-nats-exporter 2.22.1 0.19.2 A Helm chart for prometheus-nats-exporter +prometheus-community/prometheus-nginx-exporter 1.20.8 1.5.1 A Helm chart for NGINX Prometheus Exporter +prometheus-community/prometheus-node-exporter 4.52.2 1.10.2 A Helm chart for prometheus node-exporter +prometheus-community/prometheus-opencost-exporter 0.1.2 1.108.0 Prometheus OpenCost Exporter +prometheus-community/prometheus-operator 9.3.2 0.38.1 DEPRECATED - This chart will be renamed. See ht... +prometheus-community/prometheus-operator-admiss... 0.38.0 0.90.1 Prometheus Operator Admission Webhook +prometheus-community/prometheus-operator-crds 28.0.1 v0.90.1 A Helm chart that collects custom resource defi... +prometheus-community/prometheus-pgbouncer-exporter 0.10.0 v0.12.0 A Helm chart for prometheus pgbouncer-exporter +prometheus-community/prometheus-pingdom-exporter 3.4.2 v0.5.6 A Helm chart for Prometheus Pingdom Exporter +prometheus-community/prometheus-pingmesh-exporter 0.4.3 v1.2.2 Prometheus Pingmesh Exporter +prometheus-community/prometheus-postgres-exporter 7.5.2 v0.19.1 A Helm chart for prometheus postgres-exporter +prometheus-community/prometheus-pushgateway 3.6.0 v1.11.2 A Helm chart for prometheus pushgateway +prometheus-community/prometheus-rabbitmq-exporter 2.1.2 1.0.0 Rabbitmq metrics exporter for prometheus +prometheus-community/prometheus-redis-exporter 6.22.0 v1.82.0 Prometheus exporter for Redis metrics +prometheus-community/prometheus-smartctl-exporter 0.16.0 v0.14.0 A Helm chart for Kubernetes +prometheus-community/prometheus-snmp-exporter 9.13.1 v0.30.1 Prometheus SNMP Exporter +prometheus-community/prometheus-sql-exporter 0.5.0 v0.8 Prometheus SQL Exporter +prometheus-community/prometheus-stackdriver-exp... 4.12.2 v0.18.0 Stackdriver exporter for Prometheus +prometheus-community/prometheus-statsd-exporter 1.0.0 v0.28.0 A Helm chart for prometheus stats-exporter +prometheus-community/prometheus-systemd-exporter 0.5.2 0.7.0 A Helm chart for prometheus systemd-exporter +prometheus-community/prometheus-to-sd 0.5.1 v0.9.2 Scrape metrics stored in prometheus format and ... +prometheus-community/prometheus-windows-exporter 0.12.6 0.31.6 A Helm chart for prometheus windows-exporter +prometheus-community/prometheus-yet-another-clo... 0.43.0 v0.64.0 Yace - Yet Another CloudWatch Exporter +prometheus-community/alertmanager 1.34.0 v0.31.1 The Alertmanager handles alerts sent by client ... +prometheus-community/alertmanager-snmp-notifier 2.1.0 v2.1.0 The SNMP Notifier handles alerts coming from Pr... +prometheus-community/jiralert 1.8.2 v1.3.0 A Helm chart for Kubernetes to install jiralert +prometheus-community/kube-state-metrics 7.2.2 2.18.0 Install kube-state-metrics to generate and expo... +prometheus-community/prom-label-proxy 0.18.0 v0.12.1 A proxy that enforces a given label in a given ... +prometheus-community/yet-another-cloudwatch-exp... 0.39.1 v0.62.1 Yace - Yet Another CloudWatch Exporter +bitnami/grafana-alloy 1.0.7 1.10.2 Grafana Alloy is an open source OpenTelemetry C... +bitnami/grafana-mimir 3.0.18 2.17.0 Grafana Mimir is an open source, horizontally s... +bitnami/node-exporter 4.5.19 1.9.1 Prometheus exporter for hardware and OS metrics... +bitnami/thanos 17.3.1 0.39.2 Thanos is a highly available metrics system tha... +bitnami/victoriametrics 0.1.31 1.124.0 VictoriaMetrics is a fast, cost-effective, and ... +bitnami/kube-state-metrics 5.1.0 2.16.0 kube-state-metrics is a simple service that lis... +bitnami/mariadb 25.0.6 12.2.2 MariaDB is an open source, community-developed ... +bitnami/mariadb-galera 16.0.1 12.0.2 MariaDB Galera is a multi-primary database clus... + +$ 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.11.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.15.0 + + +$ helm pull prometheus-community/prometheus --untar --untardir /tmp/lab10-public-chart + +$ find /tmp/lab10-public-chart/prometheus -maxdepth 2 -type f +/tmp/lab10-public-chart/prometheus/README.md +/tmp/lab10-public-chart/prometheus/.helmignore +/tmp/lab10-public-chart/prometheus/templates/vpa.yaml +/tmp/lab10-public-chart/prometheus/templates/serviceaccount.yaml +/tmp/lab10-public-chart/prometheus/templates/service.yaml +/tmp/lab10-public-chart/prometheus/templates/rolebinding.yaml +/tmp/lab10-public-chart/prometheus/templates/pvc.yaml +/tmp/lab10-public-chart/prometheus/templates/pdb.yaml +/tmp/lab10-public-chart/prometheus/templates/network-policy.yaml +/tmp/lab10-public-chart/prometheus/templates/ingress.yaml +/tmp/lab10-public-chart/prometheus/templates/httproute.yaml +/tmp/lab10-public-chart/prometheus/templates/headless-svc.yaml +/tmp/lab10-public-chart/prometheus/templates/extra-manifests.yaml +/tmp/lab10-public-chart/prometheus/templates/deploy.yaml +/tmp/lab10-public-chart/prometheus/templates/cm.yaml +/tmp/lab10-public-chart/prometheus/templates/clusterrolebinding.yaml +/tmp/lab10-public-chart/prometheus/templates/clusterrole.yaml +/tmp/lab10-public-chart/prometheus/templates/_helpers.tpl +/tmp/lab10-public-chart/prometheus/templates/NOTES.txt +/tmp/lab10-public-chart/prometheus/values.schema.json +/tmp/lab10-public-chart/prometheus/values.yaml +/tmp/lab10-public-chart/prometheus/Chart.lock +/tmp/lab10-public-chart/prometheus/Chart.yaml +``` + +
+ +## Task 2 - Create Your Helm Chart + +I created the application chart in `k8s/devops-app-py` with the standard Helm structure and converted the existing Lab 9 Deployment and Service into templates. The chart keeps the same application behavior while moving the changeable parts into values: image repository and tag, replica count, rollout strategy, environment variables, resource requests and limits, service settings, and both probes. Naming and labels are centralized in `_helpers.tpl` so the Deployment and Service stay consistent across installs. + +The chart was deliberately trimmed to what the lab actually uses. I removed the default scaffold templates for ingress, autoscaling, service accounts, test hooks, and HTTPRoute because they were noise for this app and would have made the chart look more generic than intentional. One practical detail mattered during real installation: the raw Lab 9 service was still occupying `30080`, so I kept the chart default at `30080` but installed the Lab 10 release with `--set service.nodePort=30081` to avoid a collision while preserving the chart defaults required by the lab. + +
+Task 2 command output + +```bash +$ helm lint k8s/devops-app-py +==> Linting k8s/devops-app-py +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed + +$ helm template devops-app-py k8s/devops-app-py +--- +# Source: devops-app-py/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: devops-app-py-service + labels: + helm.sh/chart: devops-app-py-0.1.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: devops-app-py + app.kubernetes.io/version: "1.9" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 +spec: + type: NodePort + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 5000 + nodePort: 30080 + selector: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: devops-app-py +--- +# Source: devops-app-py/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-app-py + labels: + helm.sh/chart: devops-app-py-0.1.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: devops-app-py + app.kubernetes.io/version: "1.9" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 +spec: + replicas: 5 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: devops-app-py + template: + metadata: + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 + spec: + containers: + - name: devops-app-py + image: "localt0aster/devops-app-py:1.9" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + protocol: TCP + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + livenessProbe: + failureThreshold: 3 + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + resources: + limits: + cpu: 250m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +$ helm install --dry-run --debug test-release k8s/devops-app-py --set service.nodePort=30081 +level=WARN msg="--dry-run is deprecated and should be replaced with '--dry-run=client'" +level=DEBUG msg="Original chart version" version="" +level=DEBUG msg="Chart path" path=/home/t0ast/Repos/DevOps-Core-S26/k8s/devops-app-py +level=DEBUG msg="number of dependencies in the chart" chart=devops-app-py dependencies=0 +NAME: test-release +LAST DEPLOYED: Thu Apr 2 23:09:12 2026 +NAMESPACE: default +STATUS: pending-install +REVISION: 1 +DESCRIPTION: Dry run complete +TEST SUITE: None +USER-SUPPLIED VALUES: +service: + nodePort: 30081 + +COMPUTED VALUES: +containerPort: 5000 +deployment: + revisionHistoryLimit: 5 + strategy: + maxSurge: 1 + maxUnavailable: 0 +env: +- name: HOST + value: 0.0.0.0 +- name: PORT + value: "5000" +fullnameOverride: "" +image: + pullPolicy: IfNotPresent + repository: localt0aster/devops-app-py + tag: 1.9 +livenessProbe: + failureThreshold: 3 + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 +nameOverride: "" +partOf: devops-core-s26 +podAnnotations: {} +podLabels: {} +readinessProbe: + failureThreshold: 3 + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 +replicaCount: 5 +resources: + limits: + cpu: 250m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi +service: + nodePort: 30081 + port: 80 + targetPort: 5000 + type: NodePort + +HOOKS: +MANIFEST: +--- +# Source: devops-app-py/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: test-release-devops-app-py-service + labels: + helm.sh/chart: devops-app-py-0.1.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: test-release + app.kubernetes.io/version: "1.9" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 +spec: + type: NodePort + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 5000 + nodePort: 30081 + selector: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: test-release +--- +# Source: devops-app-py/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-release-devops-app-py + labels: + helm.sh/chart: devops-app-py-0.1.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: test-release + app.kubernetes.io/version: "1.9" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 +spec: + replicas: 5 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: test-release + template: + metadata: + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: test-release + app.kubernetes.io/part-of: devops-core-s26 + spec: + containers: + - name: devops-app-py + image: "localt0aster/devops-app-py:1.9" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + protocol: TCP + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + livenessProbe: + failureThreshold: 3 + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + resources: + limits: + cpu: 250m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +NOTES: +1. Review the release: + helm status test-release -n default + +2. Forward the service locally: + kubectl port-forward svc/test-release-devops-app-py-service 8080:80 -n default + +3. Verify the application: + curl -fsSL http://127.0.0.1:8080/health + curl -fsSL http://127.0.0.1:8080/ready + +$ helm install lab10-devops-app-py k8s/devops-app-py --set service.nodePort=30081 +NAME: lab10-devops-app-py +LAST DEPLOYED: Thu Apr 2 23:09:12 2026 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +TEST SUITE: None +NOTES: +1. Review the release: + helm status lab10-devops-app-py -n default + +2. Forward the service locally: + kubectl port-forward svc/lab10-devops-app-py-service 8080:80 -n default + +3. Verify the application: + curl -fsSL http://127.0.0.1:8080/health + curl -fsSL http://127.0.0.1:8080/ready + +$ kubectl rollout status deployment/lab10-devops-app-py --timeout=240s +Waiting for deployment "lab10-devops-app-py" rollout to finish: 0 of 5 updated replicas are available... +Waiting for deployment "lab10-devops-app-py" rollout to finish: 1 of 5 updated replicas are available... +Waiting for deployment "lab10-devops-app-py" rollout to finish: 2 of 5 updated replicas are available... +Waiting for deployment "lab10-devops-app-py" rollout to finish: 3 of 5 updated replicas are available... +Waiting for deployment "lab10-devops-app-py" rollout to finish: 4 of 5 updated replicas are available... +deployment "lab10-devops-app-py" successfully rolled out + +$ helm list -A +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +lab10-devops-app-py default 1 2026-04-02 23:09:12.132768347 +0300 +03 deployed devops-app-py-0.1.0 1.9 + +$ kubectl get deploy,svc,pods -l app.kubernetes.io/instance=lab10-devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +deployment.apps/lab10-devops-app-py 5/5 5 5 7s devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab10-devops-app-py-service NodePort 10.96.60.48 80:30081/TCP 7s app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab10-devops-app-py-7b7dbf4648-6k55b 1/1 Running 0 7s 10.244.0.60 minikube +pod/lab10-devops-app-py-7b7dbf4648-fz8j2 1/1 Running 0 7s 10.244.0.58 minikube +pod/lab10-devops-app-py-7b7dbf4648-l5fdj 1/1 Running 0 7s 10.244.0.56 minikube +pod/lab10-devops-app-py-7b7dbf4648-sdklz 1/1 Running 0 7s 10.244.0.57 minikube +pod/lab10-devops-app-py-7b7dbf4648-zp9dt 1/1 Running 0 7s 10.244.0.59 minikube + +$ kubectl port-forward svc/lab10-devops-app-py-service 18082:80 +Forwarding from 127.0.0.1:18082 -> 5000 +Forwarding from [::1]:18082 -> 5000 + +$ curl -fsSL http://127.0.0.1:18082 | jq . +{ + "endpoints": [ + { + "description": "Service information.", + "method": "GET", + "path": "/" + }, + { + "description": "Health check.", + "method": "GET", + "path": "/health" + }, + { + "description": "Prometheus metrics.", + "method": "GET", + "path": "/metrics" + }, + { + "description": "Readiness check.", + "method": "GET", + "path": "/ready" + } + ], + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.19.0" + }, + "runtime": { + "human": "0 hours, 0 minutes", + "seconds": 12 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.8.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 8, + "hostname": "lab10-devops-app-py-7b7dbf4648-6k55b", + "platform": "Linux", + "platform_version": "Alpine Linux v3.23", + "python_version": "3.14.3" + } +} + +$ curl -fsSL http://127.0.0.1:18082/health | jq . +{ + "status": "healthy", + "timestamp": "2026-04-02T20:09:31.062442+00:00", + "uptime_seconds": 12 +} + +$ curl -fsSL http://127.0.0.1:18082/ready | jq . +{ + "status": "ready", + "timestamp": "2026-04-02T20:09:31.100199+00:00", + "uptime_seconds": 13 +} +``` + +
+ +## Task 3 - Multi-Environment Support + +I added two environment-specific values files to the chart: `values-dev.yaml` for a lightweight local deployment and `values-prod.yaml` for a more production-shaped configuration. The dev profile uses a single replica, smaller CPU and memory reservations, `APP_ENV=development`, and the `localt0aster/devops-app-py:1.9-dev` image on a `NodePort` service. The prod profile raises the deployment to 3 replicas, increases resource requests and limits, switches `APP_ENV=production`, uses the `localt0aster/devops-app-py:1.9` image, and changes the service type to `LoadBalancer`. + +I tested the environment flow on the real release instead of only rendering templates. First I reinstalled `lab10-devops-app-py` with the dev values, verified the single-replica `1.9-dev` deployment, and then upgraded the same release with the prod values. The service type changed to `LoadBalancer` and the Deployment converged to 3 ready Pods. In this minikube setup the external IP stayed ``, which is expected without cloud load-balancer integration, so I verified the upgraded release with `kubectl port-forward` and `curl ... | jq .` against `/ready`. + +
+Task 3 command output + +```bash +$ helm uninstall lab10-devops-app-py +release "lab10-devops-app-py" uninstalled + +$ helm install lab10-devops-app-py k8s/devops-app-py -f k8s/devops-app-py/values-dev.yaml --wait=watcher --wait-for-jobs --timeout 240s +NAME: lab10-devops-app-py +LAST DEPLOYED: Fri Apr 3 01:40:19 2026 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +TEST SUITE: None +NOTES: +1. Review the release: + helm status lab10-devops-app-py -n default + +2. Forward the service locally: + kubectl port-forward svc/lab10-devops-app-py-service 8080:80 -n default + +3. Verify the application: + curl -fsSL http://127.0.0.1:8080/health | jq + curl -fsSL http://127.0.0.1:8080/ready | jq + +$ helm get values lab10-devops-app-py --all +COMPUTED VALUES: +containerPort: 5000 +deployment: + revisionHistoryLimit: 2 + strategy: + maxSurge: 1 + maxUnavailable: 0 +env: +- name: HOST + value: 0.0.0.0 +- name: PORT + value: "5000" +- name: APP_ENV + value: development +fullnameOverride: "" +hooks: + postInstall: + deletePolicy: before-hook-creation,hook-succeeded + enabled: true + image: + pullPolicy: IfNotPresent + repository: curlimages/curl + tag: 8.12.1 + maxAttempts: 20 + retryIntervalSeconds: 3 + weight: 5 + preInstall: + deletePolicy: before-hook-creation,hook-succeeded + enabled: true + image: + pullPolicy: IfNotPresent + repository: busybox + tag: 1.37.0 + weight: -5 +image: + pullPolicy: IfNotPresent + repository: localt0aster/devops-app-py + tag: 1.9-dev +livenessProbe: + failureThreshold: 3 + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 +nameOverride: "" +partOf: devops-core-s26 +podAnnotations: {} +podLabels: + environment: dev +readinessProbe: + failureThreshold: 3 + httpGet: + path: /ready + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 +replicaCount: 1 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi +service: + nodePort: 30081 + port: 80 + targetPort: 5000 + type: NodePort + +$ kubectl get deploy,svc,pods -l app.kubernetes.io/instance=lab10-devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +deployment.apps/lab10-devops-app-py 1/1 1 1 23s devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab10-devops-app-py-service NodePort 10.102.64.255 80:30081/TCP 23s app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab10-devops-app-py-7fd54dc44b-5lndl 1/1 Running 0 23s 10.244.0.62 minikube + +$ helm upgrade lab10-devops-app-py k8s/devops-app-py -f k8s/devops-app-py/values-prod.yaml --wait=watcher --timeout 240s +Release "lab10-devops-app-py" has been upgraded. Happy Helming! +NAME: lab10-devops-app-py +LAST DEPLOYED: Fri Apr 3 01:40:53 2026 +NAMESPACE: default +STATUS: deployed +REVISION: 2 +DESCRIPTION: Upgrade complete +TEST SUITE: None +NOTES: +1. Review the release: + helm status lab10-devops-app-py -n default + +2. Forward the service locally: + kubectl port-forward svc/lab10-devops-app-py-service 8080:80 -n default + +3. Verify the application: + curl -fsSL http://127.0.0.1:8080/health | jq + curl -fsSL http://127.0.0.1:8080/ready | jq + +$ kubectl rollout status deployment/lab10-devops-app-py --timeout=240s +deployment "lab10-devops-app-py" successfully rolled out + +$ helm get values lab10-devops-app-py --all +COMPUTED VALUES: +containerPort: 5000 +deployment: + revisionHistoryLimit: 10 + strategy: + maxSurge: 1 + maxUnavailable: 0 +env: +- name: HOST + value: 0.0.0.0 +- name: PORT + value: "5000" +- name: APP_ENV + value: production +fullnameOverride: "" +hooks: + postInstall: + deletePolicy: before-hook-creation,hook-succeeded + enabled: true + image: + pullPolicy: IfNotPresent + repository: curlimages/curl + tag: 8.12.1 + maxAttempts: 20 + retryIntervalSeconds: 3 + weight: 5 + preInstall: + deletePolicy: before-hook-creation,hook-succeeded + enabled: true + image: + pullPolicy: IfNotPresent + repository: busybox + tag: 1.37.0 + weight: -5 +image: + pullPolicy: IfNotPresent + repository: localt0aster/devops-app-py + tag: "1.9" +livenessProbe: + failureThreshold: 3 + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 2 +nameOverride: "" +partOf: devops-core-s26 +podAnnotations: {} +podLabels: + environment: prod +readinessProbe: + failureThreshold: 3 + httpGet: + path: /ready + port: http + initialDelaySeconds: 10 + periodSeconds: 3 + timeoutSeconds: 2 +replicaCount: 3 +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 200m + memory: 256Mi +service: + nodePort: 30081 + port: 80 + targetPort: 5000 + type: LoadBalancer + +$ kubectl get deploy,svc,pods -l app.kubernetes.io/instance=lab10-devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +deployment.apps/lab10-devops-app-py 3/3 3 3 65s devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab10-devops-app-py-service LoadBalancer 10.102.64.255 80:30081/TCP 65s app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab10-devops-app-py-67694d9f5c-57h24 1/1 Running 0 22s 10.244.0.67 minikube +pod/lab10-devops-app-py-67694d9f5c-7scvn 1/1 Running 0 41s 10.244.0.64 minikube +pod/lab10-devops-app-py-67694d9f5c-tk2kt 1/1 Running 0 11s 10.244.0.68 minikube +pod/lab10-devops-app-py-7fd54dc44b-5lndl 1/1 Terminating 0 65s 10.244.0.62 minikube + +$ kubectl port-forward svc/lab10-devops-app-py-service 18083:80 +Forwarding from 127.0.0.1:18083 -> 5000 +Forwarding from [::1]:18083 -> 5000 + +$ curl -fsSL http://127.0.0.1:18083/ready | jq . +{ + "status": "ready", + "timestamp": "2026-04-02T22:41:38.602170+00:00", + "uptime_seconds": 33 +} +``` + +
+ +## Task 4 - Chart Hooks + +I added two lifecycle hook Jobs under `templates/hooks/`. The pre-install hook is a small validation job based on `busybox`, and the post-install hook is a smoke test based on `curlimages/curl` that checks the release-local Service on `/ready`. Their weights are `-5` and `5` respectively, so the validation step runs first and the smoke test runs after the workload is installed. Both hooks use the deletion policy `before-hook-creation,hook-succeeded` so repeated installs do not accumulate stale Jobs. + +Verification happened in two layers. First, a dry run showed both hook manifests rendering under Helm’s `HOOKS:` section with the expected annotations. Then the real dev installation produced the expected Kubernetes events for both Jobs, including `Completed` on pre-install and post-install. After completion, `kubectl get jobs -A` returned no resources and no hook pods remained, which confirmed the cleanup policy worked in practice. + +
+Task 4 command output + +```bash +$ helm lint k8s/devops-app-py +==> Linting k8s/devops-app-py +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed + +$ helm install --dry-run=client --debug hook-preview k8s/devops-app-py -f k8s/devops-app-py/values-dev.yaml | rg -n -C 3 'Source: devops-app-py/templates/hooks|kind: Job|name: hook-preview-devops-app-py-(pre-install|post-install)|helm.sh/hook|helm.sh/hook-weight|helm.sh/hook-delete-policy' +124- +125-HOOKS: +126---- +127:# Source: devops-app-py/templates/hooks/post-install-job.yaml +128-apiVersion: batch/v1 +129:kind: Job +130-metadata: +131: name: hook-preview-devops-app-py-post-install +132- labels: +133- helm.sh/chart: devops-app-py-0.2.0 +134- app.kubernetes.io/name: devops-app-py +-- +138- app.kubernetes.io/part-of: devops-core-s26 +139- app.kubernetes.io/component: hook +140- annotations: +141: "helm.sh/hook": post-install +142: "helm.sh/hook-weight": "5" +143: "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" +144-spec: +145- backoffLimit: 0 +146- template: +-- +177- echo "Smoke test failed for ${url}" +178- exit 1 +179---- +180:# Source: devops-app-py/templates/hooks/pre-install-job.yaml +181-apiVersion: batch/v1 +182:kind: Job +183-metadata: +184: name: hook-preview-devops-app-py-pre-install +185- labels: +186- helm.sh/chart: devops-app-py-0.2.0 +187- app.kubernetes.io/name: devops-app-py +-- +191- app.kubernetes.io/part-of: devops-core-s26 +192- app.kubernetes.io/component: hook +193- annotations: +194: "helm.sh/hook": pre-install +195: "helm.sh/hook-weight": "-5" +196: "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" +197-spec: +198- backoffLimit: 0 +199- template: + +$ kubectl get events -A --sort-by=.metadata.creationTimestamp | rg 'lab10-devops-app-py-(pre-install|post-install)|Job completed|Created pod: lab10-devops-app-py-(pre-install|post-install)' +default 112s Normal SuccessfulCreate job/lab10-devops-app-py-pre-install Created pod: lab10-devops-app-py-pre-install-gl882 +default 111s Normal Scheduled pod/lab10-devops-app-py-pre-install-gl882 Successfully assigned default/lab10-devops-app-py-pre-install-gl882 to minikube +default 111s Normal Pulling pod/lab10-devops-app-py-pre-install-gl882 Pulling image "busybox:1.37.0" +default 107s Normal Pulled pod/lab10-devops-app-py-pre-install-gl882 Successfully pulled image "busybox:1.37.0" in 4.711s (4.711s including waiting). Image size: 4421246 bytes. +default 107s Normal Created pod/lab10-devops-app-py-pre-install-gl882 Container created +default 106s Normal Started pod/lab10-devops-app-py-pre-install-gl882 Container started +default 101s Normal Completed job/lab10-devops-app-py-pre-install Job completed +default 84s Normal SuccessfulCreate job/lab10-devops-app-py-post-install Created pod: lab10-devops-app-py-post-install-9jpjt +default 83s Normal Scheduled pod/lab10-devops-app-py-post-install-9jpjt Successfully assigned default/lab10-devops-app-py-post-install-9jpjt to minikube +default 83s Normal Pulled pod/lab10-devops-app-py-post-install-9jpjt Container image "curlimages/curl:8.12.1" already present on machine and can be accessed by the pod +default 83s Normal Created pod/lab10-devops-app-py-post-install-9jpjt Container created +default 83s Normal Started pod/lab10-devops-app-py-post-install-9jpjt Container started +default 78s Normal Completed job/lab10-devops-app-py-post-install Job completed + +$ kubectl get jobs -A 2>&1 +No resources found + +$ kubectl get pods -A | rg 'lab10-devops-app-py-(pre-install|post-install)' || true +``` + +
+ +## Task 5 - Documentation + +This section completes the documentation requirement for the Helm chart itself. The course asks for `k8s/HELM.md`; in this repo that file is kept as a compatibility entry point, while the detailed write-up lives here in `k8s/docs/LAB10.md` so the module root does not turn into a transcript dump. + +### Chart Overview + +The chart lives in `k8s/devops-app-py` and is split into a small set of focused files: + +- `Chart.yaml`: chart metadata, chart version, and app version. +- `values.yaml`: common defaults shared by all environments. +- `values-dev.yaml`: local development override with `1` replica, smaller resources, `NodePort`, and `1.9-dev`. +- `values-prod.yaml`: production-shaped override with `3` replicas, larger resources, `LoadBalancer`, and `1.9`. +- `templates/_helpers.tpl`: shared naming and label helpers, including service and hook job names. +- `templates/deployment.yaml`: the main application Deployment template. +- `templates/service.yaml`: the Service template, supporting both `NodePort` and `LoadBalancer`. +- `templates/hooks/pre-install-job.yaml`: validation job that runs before install. +- `templates/hooks/post-install-job.yaml`: smoke test job that runs after install. +- `templates/NOTES.txt`: post-install usage hints. + +The values strategy is layered: keep sensible defaults in `values.yaml`, then use environment overlays to change only what differs between dev and prod. That keeps templates stable and pushes configuration changes to values files instead of templating conditionals everywhere. + +### Configuration Guide + +The most important values are: + +- `replicaCount`: controls pod count for each environment. +- `image.repository` and `image.tag`: define which application image is deployed. +- `service.type`, `service.port`, `service.targetPort`, and `service.nodePort`: define exposure strategy. +- `resources.requests` and `resources.limits`: shape scheduling and runtime ceilings. +- `livenessProbe` and `readinessProbe`: keep health checks configurable without removing them. +- `env`: injects runtime environment variables like `HOST`, `PORT`, and `APP_ENV`. +- `hooks.preInstall.*` and `hooks.postInstall.*`: configure hook enablement, weight, deletion policy, image, and retry behavior. + +Example usage: + +```bash +# Development installation +helm install lab10-devops-app-py k8s/devops-app-py \ + -f k8s/devops-app-py/values-dev.yaml \ + --wait=watcher \ + --wait-for-jobs + +# Upgrade the same release to the production profile +helm upgrade lab10-devops-app-py k8s/devops-app-py \ + -f k8s/devops-app-py/values-prod.yaml \ + --wait=watcher + +# Override a specific value without editing files +helm upgrade lab10-devops-app-py k8s/devops-app-py \ + -f k8s/devops-app-py/values-prod.yaml \ + --set replicaCount=4 +``` + +### Hook Implementation + +Two hooks are implemented: + +- Pre-install hook: a `busybox` validation job that records the release name, namespace, image tag, and replica count before installation proceeds. +- Post-install hook: a `curlimages/curl` smoke test job that polls `http:///ready` until it gets HTTP `200` or times out. + +Execution order is controlled by weights: + +- `pre-install`: weight `-5` +- `post-install`: weight `5` + +Deletion is handled by `before-hook-creation,hook-succeeded`, which means Helm removes old hook resources before recreating them and cleans up successful Jobs afterward. The cluster evidence below confirms that no hook Jobs remain after completion. + +### Installation Evidence + +
+Current chart and release evidence + +```bash +$ find k8s/devops-app-py -maxdepth 3 -type f | sort +k8s/devops-app-py/.helmignore +k8s/devops-app-py/Chart.yaml +k8s/devops-app-py/templates/NOTES.txt +k8s/devops-app-py/templates/_helpers.tpl +k8s/devops-app-py/templates/deployment.yaml +k8s/devops-app-py/templates/hooks/post-install-job.yaml +k8s/devops-app-py/templates/hooks/pre-install-job.yaml +k8s/devops-app-py/templates/service.yaml +k8s/devops-app-py/values-dev.yaml +k8s/devops-app-py/values-prod.yaml +k8s/devops-app-py/values.yaml + +$ helm list -A +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +lab10-devops-app-py default 2 2026-04-03 01:40:53.968813438 +0300 +03 deployed devops-app-py-0.2.0 1.9 + +$ helm history lab10-devops-app-py +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Fri Apr 3 01:40:19 2026 superseded devops-app-py-0.2.0 1.9 Install complete +2 Fri Apr 3 01:40:53 2026 deployed devops-app-py-0.2.0 1.9 Upgrade complete + +$ kubectl get all -l app.kubernetes.io/instance=lab10-devops-app-py -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab10-devops-app-py-67694d9f5c-57h24 1/1 Running 0 14m 10.244.0.67 minikube +pod/lab10-devops-app-py-67694d9f5c-7scvn 1/1 Running 0 14m 10.244.0.64 minikube +pod/lab10-devops-app-py-67694d9f5c-tk2kt 1/1 Running 0 14m 10.244.0.68 minikube + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab10-devops-app-py-service LoadBalancer 10.102.64.255 80:30081/TCP 15m app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +deployment.apps/lab10-devops-app-py 3/3 3 3 15m devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +replicaset.apps/lab10-devops-app-py-67694d9f5c 3 3 3 14m devops-app-py localt0aster/devops-app-py:1.9 app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py,pod-template-hash=67694d9f5c +replicaset.apps/lab10-devops-app-py-7fd54dc44b 0 0 0 15m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/instance=lab10-devops-app-py,app.kubernetes.io/name=devops-app-py,pod-template-hash=7fd54dc44b + +$ kubectl get jobs -A 2>&1 +No resources found +``` + +
+ +### Operations + +1. Install the development profile: + + ```bash + helm install lab10-devops-app-py k8s/devops-app-py \ + -f k8s/devops-app-py/values-dev.yaml \ + --wait=watcher \ + --wait-for-jobs \ + --timeout 240s + ``` + +2. Upgrade to the production profile: + + ```bash + helm upgrade lab10-devops-app-py k8s/devops-app-py \ + -f k8s/devops-app-py/values-prod.yaml \ + --wait=watcher \ + --timeout 240s + ``` + +3. Inspect and troubleshoot the release: + + ```bash + helm list -A + helm history lab10-devops-app-py + helm get values lab10-devops-app-py --all + kubectl get all -l app.kubernetes.io/instance=lab10-devops-app-py -o wide + ``` + +4. Roll back or remove the release: + + ```bash + helm rollback lab10-devops-app-py 1 + helm uninstall lab10-devops-app-py + ``` + +### Testing & Validation + +Validation was performed at several levels: + +- Static validation: `helm lint` passed with only the non-blocking `icon is recommended` note. +- Render validation: `helm template ... -f values-prod.yaml` showed `Service`, `Deployment`, and both hook `Job` resources, with `type: LoadBalancer`, `replicas: 3`, and the expected hook annotations. +- Dry-run validation: Task 4’s `helm install --dry-run=client --debug` output showed both hooks under the `HOOKS:` section before any cluster changes were applied. +- Runtime validation: Task 3 verified the dev install, the prod upgrade, and service accessibility via `kubectl port-forward` and `curl ... | jq .`. +- Hook validation: Task 4 confirmed both Jobs completed and were deleted afterward. + +One limitation is specific to the local minikube environment: after the prod upgrade, the `LoadBalancer` service stayed at `EXTERNAL-IP `. That is expected on this cluster without an additional load-balancer implementation, so the authoritative accessibility check remained `kubectl port-forward` instead of a cloud-style public IP. diff --git a/k8s/docs/LAB11.md b/k8s/docs/LAB11.md new file mode 100644 index 0000000000..54f1ae73b7 --- /dev/null +++ b/k8s/docs/LAB11.md @@ -0,0 +1,671 @@ +# Kubernetes Lab 11 - Kubernetes Secrets and HashiCorp Vault + +I started by hard-resetting the local Kubernetes setup and recreating the cluster with `minikube` on the Docker driver. I did not document the pre-cleanup leftovers because this lab run was intentionally destructive and the goal was to produce evidence only from the fresh environment. All usernames, passwords, API keys, Vault tokens, JWTs, and base64 secret payloads are redacted in this write-up. + +## Fresh Cluster Baseline + +
+Fresh cluster bootstrap + +```text +$ kubectl config current-context +minikube + +$ minikube status +minikube +type: Control Plane +host: Running +kubelet: Running +apiserver: Running +kubeconfig: Configured + + +$ kubectl cluster-info +Kubernetes control plane is running at https://192.168.49.2:8443 +CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy + +To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'. + +$ kubectl get nodes -o wide +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 2m14s v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.19.11-1-cachyos docker://29.2.1 + +$ kubectl get node minikube -o jsonpath="{.status.nodeInfo.containerRuntimeVersion}{\"\\n\"}" +docker://29.2.1 +``` + +
+ +## Task 1 - Kubernetes Secrets Fundamentals + +I created `app-credentials` imperatively with `kubectl create secret generic`, then verified the stored object and decoded both keys. The important security point is that Kubernetes Secrets are base64-encoded for transport and manifest representation, but base64 is not encryption. Anyone who can read the Secret object can decode it immediately. + +By default, Kubernetes does not give Secrets meaningful confidentiality at rest unless the cluster administrator enables etcd encryption at rest. In production I would combine three controls: enable etcd encryption, restrict Secret access with RBAC, and use an external secret manager when credentials need centralized policy, auditing, or rotation. + +
+kubectl create secret generic app-credentials + +```bash +$ kubectl create secret generic app-credentials --from-literal=username="$LAB11_DEMO_USERNAME" --from-literal=password="$LAB11_DEMO_PASSWORD" +secret/app-credentials created +``` + +
+ +
+kubectl get secret app-credentials -o yaml (redacted) + +```yaml +apiVersion: v1 +data: + password: + username: +kind: Secret +metadata: + creationTimestamp: "2026-04-09T22:59:08Z" + name: app-credentials + namespace: default + resourceVersion: "511" + uid: a02c09dd-5ff9-4eba-b431-57ce64edb215 +type: Opaque +``` + +
+ +
+base64 -d proof for both keys + +```text +$ kubectl get secret app-credentials -o jsonpath="{.data.username}" | base64 -d +username= + +$ kubectl get secret app-credentials -o jsonpath="{.data.password}" | base64 -d +password= +``` + +
+ +## Task 2 - Helm-Managed Secrets + +I extended the Lab 10 chart instead of bolting on one-off manifests. The chart now has a dedicated `templates/secrets.yaml` and `templates/serviceaccount.yaml`, plus new values blocks for `secrets`, `serviceAccount`, and `vault`. Real demo values were supplied from untracked files under `/tmp/lab11/`; tracked YAML keeps placeholder defaults only. + +I also preserved the resource management from Lab 10. Requests reserve the minimum capacity the Pod needs to be scheduled predictably, while limits cap runaway usage. For the dev profile I kept `50m` CPU and `64Mi` memory requests with `100m` CPU and `128Mi` memory limits, which is appropriate for a single-replica local Flask/Gunicorn deployment in `minikube`. + +For the bonus DRY requirement, I moved the plain environment variable list into a named helper (`devops-app-py.envVars`) and added a second helper (`devops-app-py.vaultAnnotations`) so the Deployment template stays readable when Vault injection is enabled. + +
+find k8s/devops-app-py -maxdepth 3 -type f | sort + +```text +k8s/devops-app-py/.helmignore +k8s/devops-app-py/Chart.yaml +k8s/devops-app-py/templates/NOTES.txt +k8s/devops-app-py/templates/_helpers.tpl +k8s/devops-app-py/templates/deployment.yaml +k8s/devops-app-py/templates/hooks/post-install-job.yaml +k8s/devops-app-py/templates/hooks/pre-install-job.yaml +k8s/devops-app-py/templates/secrets.yaml +k8s/devops-app-py/templates/service.yaml +k8s/devops-app-py/templates/serviceaccount.yaml +k8s/devops-app-py/values-dev.yaml +k8s/devops-app-py/values-prod.yaml +k8s/devops-app-py/values.yaml +``` + +
+ +
+helm lint and rendered manifest excerpts + +```bash +==> Linting k8s/devops-app-py +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +```yaml +# Source: devops-app-py/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: lab11-devops-app-py + labels: + helm.sh/chart: devops-app-py-0.3.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: lab11-devops-app-py + app.kubernetes.io/version: "1.9-dev" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 +--- + +# Source: devops-app-py/templates/secrets.yaml +apiVersion: v1 +kind: Secret +metadata: + name: lab11-devops-app-py-secret + labels: + helm.sh/chart: devops-app-py-0.3.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: lab11-devops-app-py + app.kubernetes.io/version: "1.9-dev" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 +type: Opaque +stringData: + APP_USERNAME: "" + APP_PASSWORD: "" +--- + +# Source: devops-app-py/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lab11-devops-app-py + labels: + helm.sh/chart: devops-app-py-0.3.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: lab11-devops-app-py + app.kubernetes.io/version: "1.9-dev" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 +spec: + replicas: 1 + revisionHistoryLimit: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: lab11-devops-app-py + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "lab11-devops-app-py" + vault.hashicorp.com/agent-inject-secret-config: "secret/data/lab11/devops-app-py" + vault.hashicorp.com/agent-inject-file-config: "app-config.env" + vault.hashicorp.com/agent-inject-template-config: | + {{- with secret "secret/data/lab11/devops-app-py" -}} + APP_USERNAME={{ .Data.data.APP_USERNAME }} + APP_PASSWORD={{ .Data.data.APP_PASSWORD }} + APP_API_KEY={{ .Data.data.APP_API_KEY }} + {{- end }} + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: lab11-devops-app-py + app.kubernetes.io/part-of: devops-core-s26 + environment: dev + spec: + serviceAccountName: lab11-devops-app-py + containers: + - name: devops-app-py + image: "localt0aster/devops-app-py:1.9-dev" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + protocol: TCP + envFrom: + - secretRef: + name: lab11-devops-app-py-secret + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + - name: APP_ENV + value: "development" + livenessProbe: + failureThreshold: 3 + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /ready + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi +--- +``` + +
+ +
+helm upgrade --install lab11-devops-app-py ... + +```bash +$ helm upgrade --install lab11-devops-app-py k8s/devops-app-py -f k8s/devops-app-py/values-dev.yaml -f /tmp/lab11/app-secrets.values.yaml --wait=watcher --wait-for-jobs --timeout 300s +Release "lab11-devops-app-py" does not exist. Installing it now. +NAME: lab11-devops-app-py +LAST DEPLOYED: Fri Apr 10 02:00:31 2026 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +TEST SUITE: None +NOTES: +1. Review the release: + helm status lab11-devops-app-py -n default + +2. Forward the service locally: + kubectl port-forward svc/lab11-devops-app-py-service 8080:80 -n default + +3. Verify the application: + curl -fsSL http://127.0.0.1:8080/health | jq + curl -fsSL http://127.0.0.1:8080/ready | jq + +$ kubectl rollout status deployment/lab11-devops-app-py --timeout=300s +deployment "lab11-devops-app-py" successfully rolled out + +$ kubectl get deploy,svc,pods,secret -l app.kubernetes.io/instance=lab11-devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +deployment.apps/lab11-devops-app-py 1/1 1 1 38s devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/instance=lab11-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab11-devops-app-py-service NodePort 10.108.159.118 80:30081/TCP 38s app.kubernetes.io/instance=lab11-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab11-devops-app-py-578786c7fb-f8zzz 1/1 Running 0 38s 10.244.0.4 minikube + +NAME TYPE DATA AGE +secret/lab11-devops-app-py-secret Opaque 2 38s +``` + +
+ +
+kubectl exec ... printenv (redacted) + +```text +$ kubectl exec "$POD_NAME" -- printenv | rg "^APP_(USERNAME|PASSWORD)=" | sed -E "s/=.*/=/" +APP_USERNAME= +APP_PASSWORD= +``` + +
+ +
+kubectl describe pod showing Secret reference instead of cleartext + +```text +Name: lab11-devops-app-py-578786c7fb-f8zzz +Namespace: default +Priority: 0 +Service Account: lab11-devops-app-py +Node: minikube/192.168.49.2 +Start Time: Fri, 10 Apr 2026 02:00:43 +0300 +Labels: app.kubernetes.io/instance=lab11-devops-app-py + app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 + environment=dev + pod-template-hash=578786c7fb +Annotations: +Status: Running +IP: 10.244.0.4 +IPs: + IP: 10.244.0.4 +Controlled By: ReplicaSet/lab11-devops-app-py-578786c7fb +Containers: + devops-app-py: + Container ID: docker://c243351e2d85929b21b521a6cb6ad023801ceb1d4c608e5ebdbb836146ca47d2 + Image: localt0aster/devops-app-py:1.9-dev + Image ID: docker-pullable://localt0aster/devops-app-py@sha256:2f3a987db91b7327ed30da86a6cfb8358cb720e2f968c7b326a031d31503f765 + Port: 5000/TCP (http) + Host Port: 0/TCP (http) + State: Running + Started: Fri, 10 Apr 2026 02:00:53 +0300 + Ready: True + Restart Count: 0 + Limits: + cpu: 100m + memory: 128Mi + Requests: + cpu: 50m + memory: 64Mi + Liveness: http-get http://:http/health delay=5s timeout=2s period=10s #success=1 #failure=3 + Readiness: http-get http://:http/ready delay=3s timeout=2s period=5s #success=1 #failure=3 + Environment Variables from: + lab11-devops-app-py-secret Secret Optional: false + Environment: + HOST: 0.0.0.0 + PORT: 5000 + APP_ENV: development + Mounts: + /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-zvfx6 (ro) +Conditions: + Type Status + PodReadyToStartContainers True + Initialized True + Ready True + ContainersReady True + PodScheduled True +Volumes: + kube-api-access-zvfx6: + Type: Projected (a volume that contains injected data from multiple sources) + TokenExpirationSeconds: 3607 + ConfigMapName: kube-root-ca.crt + Optional: false + DownwardAPI: true +QoS Class: Burstable +Node-Selectors: +Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s + node.kubernetes.io/unreachable:NoExecute op=Exists for 300s +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal Scheduled 52s default-scheduler Successfully assigned default/lab11-devops-app-py-578786c7fb-f8zzz to minikube + Normal Pulling 52s kubelet spec.containers{devops-app-py}: Pulling image "localt0aster/devops-app-py:1.9-dev" + Normal Pulled 43s kubelet spec.containers{devops-app-py}: Successfully pulled image "localt0aster/devops-app-py:1.9-dev" in 9.083s (9.083s including waiting). Image size: 138919242 bytes. + Normal Created 42s kubelet spec.containers{devops-app-py}: Container created + Normal Started 42s kubelet spec.containers{devops-app-py}: Container started + Warning Unhealthy 30s kubelet spec.containers{devops-app-py}: Liveness probe failed: Get "http://10.244.0.4:5000/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers) + Warning Unhealthy 28s (x2 over 33s) kubelet spec.containers{devops-app-py}: Readiness probe failed: Get "http://10.244.0.4:5000/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers) +``` + +
+ +The key verification point is the difference between the two views: inside the container the variables exist and are usable, while `kubectl describe pod` only exposes the Secret reference (`Environment Variables from:`) rather than the cleartext values. + +## Task 3 - HashiCorp Vault Integration + +I installed Vault with the official Helm chart in dev mode, enabled the injector, then configured a KV v2 path for the application, Kubernetes auth, a read-only policy, and a role bound to the chart-created ServiceAccount. After that I upgraded the app release with Vault annotations enabled so the agent injected an `.env`-style file into `/vault/secrets/app-config.env`. + +The sidecar injection pattern works by mutating the Pod at admission time. In the upgraded Pod there is a `vault-agent-init` init container that authenticates and pre-populates the secret file, plus a long-running `vault-agent` sidecar that can keep templates refreshed while the application container consumes the rendered file. + +
+Vault Helm install + +```bash +$ helm repo add --force-update hashicorp https://helm.releases.hashicorp.com +"hashicorp" has been added to your repositories + +$ helm repo update +Hang tight while we grab the latest from your chart repositories... +...Successfully got an update from the "hashicorp" chart repository +...Successfully got an update from the "prometheus-community" chart repository +...Successfully got an update from the "bitnami" chart repository +Update Complete. ⎈Happy Helming!⎈ + +$ helm upgrade --install vault hashicorp/vault --namespace vault --create-namespace --set server.dev.enabled=true --set injector.enabled=true --wait=watcher --timeout 300s +Release "vault" does not exist. Installing it now. +NAME: vault +LAST DEPLOYED: Fri Apr 10 02:02:00 2026 +NAMESPACE: vault +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +NOTES: +Thank you for installing HashiCorp Vault! + +Now that you have deployed Vault, you should look over the docs on using +Vault with Kubernetes available here: + +https://developer.hashicorp.com/vault/docs + + +Your release is named vault. To learn more about the release, try: + + $ helm status vault + $ helm get manifest vault + +$ kubectl get pods -n vault -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +vault-0 0/1 ContainerCreating 0 15s minikube +vault-agent-injector-8c76487db-wptvx 1/1 Running 0 16s 10.244.0.6 minikube +``` + +
+ +
+kubectl get pods -n vault -o wide after readiness wait + +```text +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +vault-0 1/1 Running 0 28s 10.244.0.7 minikube +vault-agent-injector-8c76487db-wptvx 1/1 Running 0 29s 10.244.0.6 minikube +``` + +
+ +
+kubectl exec -n vault vault-0 -- sh /tmp/vault-config.sh + +```text +$ kubectl exec -n vault vault-0 -- sh /tmp/vault-config.sh +Checking secret engines +Path Type Accessor Description +---- ---- -------- ----------- +cubbyhole/ cubbyhole cubbyhole_1e3be723 per-token private secret storage +identity/ identity identity_2ffebb85 identity store +secret/ kv kv_f9a64ce9 key/value secret storage +sys/ system system_b73e780d system endpoints used for control, policy and debugging +secret/ mount already present + +Writing application secret metadata + ========= Secret Path ========= +secret/data/lab11/devops-app-py + + ======= Metadata ======= +Key Value +--- ----- +created_time 2026-04-09T23:04:39.746883798Z +custom_metadata +deletion_time n/a +destroyed false +version 2 + +Checking auth methods +Path Type Accessor Description Version +---- ---- -------- ----------- ------- +kubernetes/ kubernetes auth_kubernetes_9526b481 n/a n/a +token/ token auth_token_8763c414 token based credentials n/a +kubernetes auth already enabled +Configured Kubernetes auth backend +Success! Uploaded policy: lab11-devops-app-py + +Configuring role + +Policy readback +path "secret/data/lab11/devops-app-py" { + capabilities = ["read"] +} +``` + +
+ +
+auth/kubernetes/role/lab11-devops-app-py readback + +```json +{ + "data": { + "bound_service_account_names": [ + "lab11-devops-app-py" + ], + "bound_service_account_namespaces": [ + "default" + ], + "policies": [ + "lab11-devops-app-py" + ], + "ttl": 86400 + } +} +``` + +
+ +
+helm upgrade ... -f /tmp/lab11/app-vault.values.yaml + +```bash +$ helm upgrade lab11-devops-app-py k8s/devops-app-py -f k8s/devops-app-py/values-dev.yaml -f /tmp/lab11/app-secrets.values.yaml -f /tmp/lab11/app-vault.values.yaml --wait=watcher --wait-for-jobs --timeout 300s +Release "lab11-devops-app-py" has been upgraded. Happy Helming! +NAME: lab11-devops-app-py +LAST DEPLOYED: Fri Apr 10 02:04:56 2026 +NAMESPACE: default +STATUS: deployed +REVISION: 2 +DESCRIPTION: Upgrade complete +TEST SUITE: None +NOTES: +1. Review the release: + helm status lab11-devops-app-py -n default + +2. Forward the service locally: + kubectl port-forward svc/lab11-devops-app-py-service 8080:80 -n default + +3. Verify the application: + curl -fsSL http://127.0.0.1:8080/health | jq + curl -fsSL http://127.0.0.1:8080/ready | jq + +$ kubectl rollout status deployment/lab11-devops-app-py --timeout=300s +deployment "lab11-devops-app-py" successfully rolled out + +$ kubectl get pods -l app.kubernetes.io/instance=lab11-devops-app-py -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +lab11-devops-app-py-578786c7fb-f8zzz 1/1 Terminating 0 4m28s 10.244.0.4 minikube +lab11-devops-app-py-78488cd99-4297p 2/2 Running 0 15s 10.244.0.8 minikube +``` + +
+ +
+Injected Pod summary + +```json +{ + "metadata": { + "name": "lab11-devops-app-py-78488cd99-4297p", + "annotations": { + "agent_inject": "true", + "role": "lab11-devops-app-py", + "inject_secret_config": "secret/data/lab11/devops-app-py", + "inject_file_config": "app-config.env" + } + }, + "spec": { + "serviceAccountName": "lab11-devops-app-py", + "initContainers": [ + "vault-agent-init" + ], + "containers": [ + "devops-app-py", + "vault-agent" + ] + }, + "status": { + "phase": "Running", + "initContainerStatuses": [ + { + "name": "vault-agent-init", + "ready": true, + "state": { + "terminated": { + "containerID": "docker://6ac0871513caf747c2af9fc895961f66f29a9e8a48dfb882b22529c93ad24558", + "exitCode": 0, + "finishedAt": "2026-04-09T23:04:57Z", + "reason": "Completed", + "startedAt": "2026-04-09T23:04:57Z" + } + } + } + ], + "containerStatuses": [ + { + "name": "devops-app-py", + "ready": true, + "restartCount": 0 + }, + { + "name": "vault-agent", + "ready": true, + "restartCount": 0 + } + ] + } +} +``` + +
+ +
+ls -l /vault/secrets + +```text +$ kubectl exec "$POD_NAME" -c devops-app-py -- ls -l /vault/secrets +total 4 +-rw-r--r-- 1 100 appgroup 89 Apr 9 23:04 app-config.env +``` + +
+ +
+cat /vault/secrets/app-config.env (redacted) + +```text +$ kubectl exec "$POD_NAME" -c devops-app-py -- cat /vault/secrets/app-config.env | sed -E "s/=.*/=/" +APP_USERNAME= +APP_PASSWORD= +APP_API_KEY= +``` + +
+ +
+/health and /ready after Vault-enabled rollout + +```bash +$ curl -fsSL http://127.0.0.1:18084/health | jq +{ + "status": "healthy", + "timestamp": "2026-04-09T23:06:01.075863+00:00", + "uptime_seconds": 51 +} + +$ curl -fsSL http://127.0.0.1:18084/ready | jq +{ + "status": "ready", + "timestamp": "2026-04-09T23:06:01.102648+00:00", + "uptime_seconds": 51 +} +``` + +
+ +## Bonus - Vault Agent Templates + +The bonus part is implemented in two places. First, the Pod annotations now include `vault.hashicorp.com/agent-inject-template-config`, which renders a single `.env`-style file containing `APP_USERNAME`, `APP_PASSWORD`, and `APP_API_KEY`. Second, the chart now uses named helpers in `_helpers.tpl` so the common environment list and the Vault annotation block are both reusable instead of repeated inline. + +For secret refresh behavior, the important distinction is between renewable and non-renewable secrets. Vault Agent templates renew renewable leases when about two-thirds of the lease duration has elapsed. KV v2 values like the ones used in this lab are non-renewable static secrets, so the agent re-fetches and re-renders them on its static refresh interval rather than through lease renewal. The default interval for those static secrets is periodic rather than instantaneous, which is acceptable for this lab but important to remember in production. + +`vault.hashicorp.com/agent-inject-command-` is the annotation you would add if the application must run a command after a template is rendered or refreshed. The common use case is reloading a process after the file changes, for example a `kill -HUP` signal or a small wrapper script. I documented it rather than enabling it because this Flask/Gunicorn lab service already starts cleanly with the injected file and does not need an automatic config reload hook for the exercise. + +## Security Analysis + +Kubernetes Secrets are fine for simple cluster-local use cases where you only need to hand a small amount of sensitive data to workloads and you control RBAC tightly. They are still just Kubernetes API objects, though, so by themselves they do not solve centralized auditing, rotation workflows, or short-lived credentials. Base64 also does not protect them; it only serializes them. + +Vault is the stronger choice once the environment needs policy-based access control, centralized secret management, auditable reads, multi-service reuse, or dynamic credentials. The tradeoff is operational complexity: you now need a Vault deployment, auth configuration, policies, roles, injector behavior, and an application consumption pattern that can tolerate file-based secret delivery and refresh. + +For production I would not use Vault dev mode. I would run HA storage, TLS, explicit audiences on Kubernetes auth roles, short-lived or dynamic credentials where possible, etcd encryption at rest for any remaining Kubernetes Secrets, and strict RBAC so only the intended workloads and operators can read secret material. + +## Task 4 - Documentation + +This file is the full Lab 11 write-up. To keep the Kubernetes module maintainable, the course-facing compatibility document is `k8s/SECRETS.md`, which points back here in the same way `k8s/HELM.md` points to the Lab 10 report. diff --git a/k8s/docs/LAB12.md b/k8s/docs/LAB12.md new file mode 100644 index 0000000000..8660a969c2 --- /dev/null +++ b/k8s/docs/LAB12.md @@ -0,0 +1,519 @@ +# Kubernetes Lab 12 - ConfigMaps and Persistent Volumes + +I reused the existing Docker-backed `minikube` cluster instead of tearing it down. The starting state already contained the Lab 11 app release and Vault, so this lab was added on top of that environment with a new Helm release name, `lab12-devops-app-py`, to avoid clobbering the earlier work. All usernames and passwords are redacted in this write-up. + +## Current Cluster Context + +
+kubectl config current-context, kubectl cluster-info, kubectl get nodes -o wide, kubectl get storageclass, helm list -A + +```text +$ kubectl config current-context +minikube +$ kubectl cluster-info +Kubernetes control plane is running at https://192.168.49.2:8443 +CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy + +To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'. +$ kubectl get nodes -o wide +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 52m v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.19.11-1-cachyos docker://29.2.1 +$ kubectl get storageclass +NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE +standard (default) k8s.io/minikube-hostpath Delete Immediate false 52m +$ helm list -A +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +lab11-devops-app-py default 2 2026-04-10 02:04:56.679295263 +0300 +03 deployed devops-app-py-0.3.0 1.9 +vault vault 1 2026-04-10 02:02:00.558749873 +0300 +03 deployed vault-0.32.0 1.21.2 +``` + +
+ +## Task 1 - Application Persistence Upgrade + +I implemented the same file-backed visits counter in both `app_python` and `app_go`. Both services now store the counter in `/data/visits`, increment it on every `GET /`, expose `GET /visits`, default to `0` when the file is missing, and recover from malformed content by treating it as `0`. The Python runtime version and `pyproject.toml` version both moved to `1.12.0`, and the Go runtime version moved to `1.12.0`. + +
+./.venv/bin/pytest, go test ./..., and separate app commits + +```bash +$ ./.venv/bin/pytest +============================= test session starts ============================== +platform linux -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/t0ast/Repos/DevOps-Core-S26/app_python +configfile: pyproject.toml +plugins: anyio-4.12.1, cov-7.1.0 +collected 19 items + +tests/test_endpoints.py ........... [ 57%] +tests/test_logging_utils.py . [ 63%] +tests/test_metrics.py .. [ 73%] +tests/test_unit_helpers.py ..... [100%] + +============================== 19 passed in 0.11s ============================== + +$ go test ./... +ok example.com/devops-info-service 0.005s + +$ git log --oneline -2 +3ebf11e feat(app_go): add persistent visits endpoint +ceaf67d feat(app_python): add persistent visits endpoint +``` + +
+ +
+docker compose -f monitoring/docker-compose.yml config | sed -n "/app-go:/,/grafana:/p" + +```text +$ docker compose -f monitoring/docker-compose.yml config | sed -n /app-go:/,/grafana:/p + app-go: + environment: + HOST: 0.0.0.0 + PORT: "8001" + image: localt0aster/devops-app-go:1.12-dev + ports: + - mode: ingress + target: 8001 + published: "8001" + protocol: tcp + volumes: + - type: bind + source: /home/t0ast/Repos/DevOps-Core-S26/monitoring/data/app-go + target: /data + bind: {} + app-python: + environment: + HOST: 0.0.0.0 + PORT: "8000" + image: localt0aster/devops-app-py:1.12-dev + ports: + - mode: ingress + target: 8000 + published: "8000" + protocol: tcp + volumes: + - type: bind + source: /home/t0ast/Repos/DevOps-Core-S26/monitoring/data/app-python + target: /data + bind: {} +``` + +
+ +
+docker compose up and local visits persistence proof for both apps + +```bash +$ docker compose -f monitoring/docker-compose.yml up -d --pull always app-python app-go app-go-healthcheck +... +$ docker compose -f monitoring/docker-compose.yml ps app-python app-go app-go-healthcheck +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +monitoring-app-go-1 localt0aster/devops-app-go:1.12-dev "/devops-info-servic…" app-go 1 second ago Up Less than a second 0.0.0.0:8001->8001/tcp, [::]:8001->8001/tcp +monitoring-app-go-healthcheck-1 curlimages/curl:8.18.0 "/entrypoint.sh sh -…" app-go-healthcheck 1 second ago Up Less than a second (health: starting) +monitoring-app-python-1 localt0aster/devops-app-py:1.12-dev "sh -c 'gunicorn --c…" app-python 1 second ago Up Less than a second (health: starting) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp + +$ curl -sS http://127.0.0.1:8000/visits | jq . +{ + "visits": 0 +} +$ curl -sS http://127.0.0.1:8000/ >/dev/null +$ curl -sS http://127.0.0.1:8000/ >/dev/null +$ curl -sS http://127.0.0.1:8000/visits | jq . +{ + "visits": 2 +} +$ cat monitoring/data/app-python/visits +2 + +$ curl -sS http://127.0.0.1:8001/visits | jq . +{ + "visits": 0 +} +$ curl -sS http://127.0.0.1:8001/ >/dev/null +$ curl -sS http://127.0.0.1:8001/ >/dev/null +$ curl -sS http://127.0.0.1:8001/visits | jq . +{ + "visits": 2 +} +$ cat monitoring/data/app-go/visits +2 + +$ docker compose -f monitoring/docker-compose.yml restart app-python app-go +... +$ curl -sS http://127.0.0.1:8000/visits | jq . +{ + "visits": 2 +} +$ curl -sS http://127.0.0.1:8001/visits | jq . +{ + "visits": 2 +} +$ cat monitoring/data/app-python/visits +2 +$ cat monitoring/data/app-go/visits +2 +``` + +
+ +## Task 2 - ConfigMaps + +I extended the existing Helm chart instead of writing one-off manifests. The chart now contains: + +- `files/config.json` as the file-backed application config source +- `templates/configmap.yaml` rendering both a file ConfigMap and an env ConfigMap +- `templates/pvc.yaml` for the visits counter volume +- checksum annotations on the Pod template so chart-managed config changes trigger a rollout + +I also changed the chart defaults for Lab 12 correctness: `replicaCount` is now `1`, the chart version is `0.4.0`, the app version is `1.12.0`, and the dev NodePort moved to `30082` so it does not collide with the existing Lab 11 release on `30081`. + +
+helm lint and rendered manifest excerpts + +```bash +$ helm lint k8s/devops-app-py +==> Linting k8s/devops-app-py +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +```yaml +# Source: devops-app-py/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: lab12-devops-app-py-config +data: + config.json: |- + { + "application": { + "name": "devops-info-service", + "environment": "development", + "version": "1.12-dev" + }, + "featureFlags": { + "visitsCounter": true, + "metrics": true, + "configReloadDemo": true + }, + "settings": { + "configPath": "/config/config.json", + "visitsFile": "/data/visits", + "reloadStrategy": "checksum-rollout" + } + } +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: lab12-devops-app-py-env +data: + APP_CONFIG_PATH: /config/config.json + APP_ENV: development + APP_NAME: devops-info-service + APP_VISITS_PATH: /data/visits + LOG_LEVEL: debug +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: lab12-devops-app-py-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lab12-devops-app-py +spec: + replicas: 1 + template: + metadata: + annotations: + checksum/config-file: "75d068a2b686d100b01ef7eb95c683ff78dc01f7103131c74502cd3dba657e95" + checksum/config-env: "ade0526aff038f694a130dfce92e2748879ea4cd4e9a802b692762639b4851bf" + spec: + containers: + - name: devops-app-py + image: "localt0aster/devops-app-py:1.12-dev" + volumeMounts: + - name: config-volume + mountPath: "/config" + readOnly: true + - name: data-volume + mountPath: "/data" + envFrom: + - configMapRef: + name: lab12-devops-app-py-env + - secretRef: + name: lab12-devops-app-py-secret + volumes: + - name: config-volume + configMap: + name: lab12-devops-app-py-config + - name: data-volume + persistentVolumeClaim: + claimName: lab12-devops-app-py-data +``` + +
+ +## Task 3 - Persistent Volumes + +I installed the updated chart as a new release named `lab12-devops-app-py`, verified the rendered ConfigMaps and PVC in-cluster, then proved that the application could read `/config/config.json`, receive the env ConfigMap via `envFrom`, write `/data/visits`, and survive a Pod replacement without losing the counter. + +One useful operational detail from `kubectl describe pod` is that Kubernetes shows the env var sources as `ConfigMap` and `Secret` references instead of dumping the actual values. That keeps Pod inspection safer while still proving where the data comes from. + +
+helm upgrade --install lab12-devops-app-py ... and initial resource state + +```bash +$ helm upgrade --install lab12-devops-app-py k8s/devops-app-py -n default -f k8s/devops-app-py/values-dev.yaml --wait --wait-for-jobs --timeout 300s +Release "lab12-devops-app-py" does not exist. Installing it now. +NAME: lab12-devops-app-py +LAST DEPLOYED: Fri Apr 10 03:02:03 2026 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +``` + +```text +$ kubectl get deploy,svc,pod,configmap,pvc -n default -l app.kubernetes.io/instance=lab12-devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +deployment.apps/lab12-devops-app-py 1/1 1 1 61s devops-app-py localt0aster/devops-app-py:1.12-dev app.kubernetes.io/instance=lab12-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab12-devops-app-py-service NodePort 10.101.70.46 80:30082/TCP 61s app.kubernetes.io/instance=lab12-devops-app-py,app.kubernetes.io/name=devops-app-py + +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab12-devops-app-py-5f6df94f6d-5wxtq 1/1 Running 0 61s 10.244.0.10 minikube + +NAME DATA AGE +configmap/lab12-devops-app-py-config 1 61s +configmap/lab12-devops-app-py-env 5 61s + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE +persistentvolumeclaim/lab12-devops-app-py-data Bound pvc-d04d328c-f6f3-44f7-8347-d8724a16b744 100Mi RWO standard 61s Filesystem +``` + +
+ +
+kubectl exec for mounted /config/config.json, env vars, and Pod description + +```bash +$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-5wxtq -- cat /config/config.json | jq . +{ + "application": { + "name": "devops-info-service", + "environment": "development", + "version": "1.12-dev" + }, + "featureFlags": { + "visitsCounter": true, + "metrics": true, + "configReloadDemo": true + }, + "settings": { + "configPath": "/config/config.json", + "visitsFile": "/data/visits", + "reloadStrategy": "checksum-rollout" + } +} + +$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-5wxtq -- printenv | grep -E "^(APP_|LOG_LEVEL)" +APP_ENV=development +LOG_LEVEL=debug +APP_CONFIG_PATH=/config/config.json +APP_NAME=devops-info-service +APP_VISITS_PATH=/data/visits +APP_PASSWORD=[REDACTED] +APP_USERNAME=[REDACTED] +``` + +```text +$ kubectl describe pod -n default lab12-devops-app-py-5f6df94f6d-bkwfx +... +Environment Variables from: + lab12-devops-app-py-env ConfigMap Optional: false + lab12-devops-app-py-secret Secret Optional: false +Environment: + HOST: 0.0.0.0 + PORT: 5000 +Mounts: + /config from config-volume (ro) + /data from data-volume (rw) +... +``` + +
+ +
+curl through the NodePort and PVC persistence across pod deletion + +```bash +$ minikube ip +192.168.49.2 +$ curl -sS http://192.168.49.2:30082/visits | jq . +{ + "visits": 0 +} +$ curl -sS http://192.168.49.2:30082/ >/dev/null +$ curl -sS http://192.168.49.2:30082/ >/dev/null +$ curl -sS http://192.168.49.2:30082/visits | jq . +{ + "visits": 2 +} +$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-5wxtq -- cat /data/visits +2 + +$ kubectl delete pod -n default lab12-devops-app-py-5f6df94f6d-5wxtq +pod "lab12-devops-app-py-5f6df94f6d-5wxtq" deleted from default namespace +$ kubectl wait -n default --for=condition=Ready pod -l app.kubernetes.io/instance=lab12-devops-app-py --timeout=180s +pod/lab12-devops-app-py-5f6df94f6d-bkwfx condition met +old_pod=lab12-devops-app-py-5f6df94f6d-5wxtq +new_pod=lab12-devops-app-py-5f6df94f6d-bkwfx +$ curl -sS http://192.168.49.2:30082/visits | jq . +{ + "visits": 2 +} +$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-bkwfx -- cat /data/visits +2 +``` + +
+ +## Bonus - ConfigMap Update Behavior + +I deliberately mounted the ConfigMap as a directory (`/config`) instead of using `subPath`. The reason is simple: `subPath` mounts are bind mounts to a fixed inode, so they do not receive projected ConfigMap updates. For a live file update demonstration, the whole projected directory mount is the correct pattern. + +I tested three distinct behaviors: + +1. A manual `kubectl patch` against the file ConfigMap updated the mounted `/config/config.json` inside the running Pod after roughly 11 seconds. +2. A manual patch against the env ConfigMap did not change `APP_ENV` inside the already-running process, which confirms that `envFrom` variables are fixed at container start. +3. A chart-managed config change updated the checksum annotations and rolled the Deployment to a new Pod, which then saw the new file content and the new env vars. + +One practical wrinkle showed up during this: after I used `kubectl patch` on Helm-managed ConfigMaps, the next `helm upgrade` hit server-side-apply field ownership conflicts on the same keys. I repaired that by reapplying the rendered ConfigMaps with `kubectl apply --server-side --force-conflicts --field-manager=helm`, then reran a new chart-managed config change successfully. + +
+kubectl patch configmap lab12-devops-app-py-config and mounted-file update delay + +```bash +$ jq . <<< "$PATCH" +{ + "data": { + "config.json": "{\n \"application\": {\n \"name\": \"devops-info-service\",\n \"environment\": \"manual-edit\",\n \"version\": \"1.12-dev\"\n },\n \"featureFlags\": {\n \"visitsCounter\": true,\n \"metrics\": true,\n \"configReloadDemo\": true\n },\n \"settings\": {\n \"configPath\": \"/config/config.json\",\n \"visitsFile\": \"/data/visits\",\n \"reloadStrategy\": \"checksum-rollout\"\n }\n}" + } +} +$ kubectl patch configmap -n default lab12-devops-app-py-config --type merge -p "$PATCH" +configmap/lab12-devops-app-py-config patched +$ wait for mounted /config/config.json to show environment=manual-edit +delay_seconds=11 +$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-bkwfx -- cat /config/config.json | jq .application.environment +"manual-edit" +``` + +
+ +
+kubectl patch configmap lab12-devops-app-py-env and proof that envFrom does not hot-reload + +```bash +$ jq . <<< "$PATCH" +{ + "data": { + "APP_ENV": "manual-edit" + } +} +$ kubectl patch configmap -n default lab12-devops-app-py-env --type merge -p "$PATCH" +configmap/lab12-devops-app-py-env patched +$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-bkwfx -- printenv APP_ENV +development +``` + +
+ +
+kubectl apply --server-side --force-conflicts --field-manager=helm to repair Helm ownership + +```bash +$ helm template lab12-devops-app-py k8s/devops-app-py -n default -f k8s/devops-app-py/values-dev.yaml --set config.file.environment=chart-rollout --set config.env.data.APP_ENV=chart-rollout --show-only templates/configmap.yaml > /tmp/lab12/52-rendered-configmaps.yaml +$ kubectl apply --server-side --force-conflicts --field-manager=helm -f /tmp/lab12/52-rendered-configmaps.yaml +configmap/lab12-devops-app-py-config serverside-applied +configmap/lab12-devops-app-py-env serverside-applied +$ kubectl get configmap lab12-devops-app-py-config -n default -o json | jq -r .data["config.json"] | jq .application.environment +"chart-rollout" +$ kubectl get configmap lab12-devops-app-py-env -n default -o json | jq -r .data.APP_ENV +chart-rollout +``` + +
+ +
+helm upgrade ... --set config.file.environment=chart-rollout-fixed --set config.env.data.APP_ENV=chart-rollout-fixed + +```bash +$ helm upgrade lab12-devops-app-py k8s/devops-app-py -n default -f k8s/devops-app-py/values-dev.yaml --set config.file.environment=chart-rollout-fixed --set config.env.data.APP_ENV=chart-rollout-fixed --wait --wait-for-jobs --timeout 300s +Release "lab12-devops-app-py" has been upgraded. Happy Helming! +NAME: lab12-devops-app-py +LAST DEPLOYED: Fri Apr 10 03:07:15 2026 +NAMESPACE: default +STATUS: deployed +REVISION: 4 +DESCRIPTION: Upgrade complete +old_pod=lab12-devops-app-py-6b4d4cff8d-wbcnz +new_pod=lab12-devops-app-py-7bb96994f8-n6269 +$ kubectl get deployment -n default lab12-devops-app-py -o json | jq .spec.template.metadata.annotations +{ + "checksum/config-env": "e34c4bf455ae82b7283e96127fcffbd6fe96332325a9e8953204033ec6ade5f5", + "checksum/config-file": "8d6ed3c72ff6122928a1d3e148717df696ff7cb1f6f203fcc8934903da9669a7" +} +$ kubectl exec -n default lab12-devops-app-py-7bb96994f8-n6269 -- cat /config/config.json | jq .application.environment +"chart-rollout-fixed" +$ kubectl exec -n default lab12-devops-app-py-7bb96994f8-n6269 -- printenv APP_ENV +chart-rollout-fixed +``` + +
+ +
+curl end-state service check after the final rollout + +```bash +$ curl -sS http://192.168.49.2:30082/health | jq . +{ + "status": "healthy", + "timestamp": "2026-04-10T00:07:47.059488+00:00", + "uptime_seconds": 14 +} +$ curl -sS http://192.168.49.2:30082/ready | jq . +{ + "status": "ready", + "timestamp": "2026-04-10T00:07:47.079301+00:00", + "uptime_seconds": 14 +} +$ curl -sS http://192.168.49.2:30082/visits | jq . +{ + "visits": 2 +} +``` + +
+ +## ConfigMap vs Secret + +- Use a `ConfigMap` for non-sensitive application configuration such as environment names, feature flags, log levels, file paths, and JSON app settings. +- Use a `Secret` for credentials or tokens. In this repo the chart still keeps `APP_USERNAME` and `APP_PASSWORD` in a separate Secret and only references them via `envFrom`. +- `ConfigMap` data is meant to be readable operational config. `Secret` data is still only base64-encoded unless cluster-side at-rest encryption and RBAC are configured properly. +- Mounted ConfigMap files can update in place when the whole projected directory is mounted. Environment variables injected from either `ConfigMap` or `Secret` do not update inside an already-running container. + +## Task 4 - Documentation + +This file is the full Lab 12 report. The compatibility filename required by the lab lives at [../CONFIGMAPS.md](../CONFIGMAPS.md) and points back here so the module root does not turn into one large transcript dump. diff --git a/k8s/docs/LAB13.md b/k8s/docs/LAB13.md new file mode 100644 index 0000000000..826b066154 --- /dev/null +++ b/k8s/docs/LAB13.md @@ -0,0 +1,423 @@ +# Kubernetes Lab 13 - GitOps with ArgoCD + +I reused the existing Docker-backed `minikube` profile and built Lab 13 on top of the running Lab 11/12 environment instead of resetting the cluster. ArgoCD was installed from the official Helm chart as `argo/argo-cd 9.5.0` with app version `v3.3.6`, and the GitOps source for every `Application` in this lab is `https://github.com/LocalT0aster/DevOps-Core-S26.git` on branch `lab13`. The ArgoCD UI screenshots below capture the final applications overview, an application details page, and the sync-policy comparison between `dev` and `prod`. + +## Current Cluster Context + +
+kubectl config current-context, minikube status, kubectl get nodes -o wide, helm list -A, kubectl get pods -A + +```text +$ kubectl config current-context +minikube +$ minikube status -p minikube +minikube +type: Control Plane +host: Running +kubelet: Running +apiserver: Running +kubeconfig: Configured + +$ kubectl get nodes -o wide +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 12h v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.19.11-1-cachyos docker://29.2.1 + +$ helm list -A +NAME NAMESPACE REVISION STATUS CHART APP VERSION +lab11-devops-app-py default 2 deployed devops-app-py-0.3.0 1.9 +lab12-devops-app-py default 4 deployed devops-app-py-0.4.0 1.12.0 +vault vault 1 deployed vault-0.32.0 1.21.2 +``` + +
+ +## Task 1 - ArgoCD Installation and Access + +The local `argocd` CLI was already installed as `v3.3.3+unknown`, so I only needed to add the official Helm repo, pin the chart version, install ArgoCD into namespace `argocd`, and log in over a local TLS port-forward. I exposed `svc/argocd-server` on `https://127.0.0.1:8080`, confirmed the UI returned the login HTML over HTTPS, retrieved the initial admin password in redacted form, and verified CLI access with `argocd app list`. + +I also verified the browser-access path through the same TLS port-forward and captured the resulting ArgoCD UI views for the final documentation. + +
+helm repo add argo, helm search repo argo/argo-cd --versions, and Helm install + +```bash +$ helm repo add argo https://argoproj.github.io/argo-helm +"argo" has been added to your repositories +$ helm repo update +...Successfully got an update from the "argo" chart repository +$ helm search repo argo/argo-cd --versions | head -n 3 +NAME CHART VERSION APP VERSION DESCRIPTION +argo/argo-cd 9.5.0 v3.3.6 A Helm chart for Argo CD, a declarative, GitOps... + +$ helm upgrade --install argocd argo/argo-cd --namespace argocd --create-namespace --version 9.5.0 --wait --timeout 10m +NAME: argocd +NAMESPACE: argocd +STATUS: deployed + +$ kubectl get pods -n argocd +NAME READY STATUS RESTARTS AGE +argocd-application-controller-0 1/1 Running 0 30s +argocd-applicationset-controller-58c9647667-wcw95 1/1 Running 0 30s +argocd-dex-server-d68bfd4b7-9ln57 1/1 Running 0 30s +argocd-notifications-controller-58f8fcd889-9x4nq 1/1 Running 0 30s +argocd-redis-5d5bb8d56b-qjwdl 1/1 Running 0 30s +argocd-repo-server-5d5755cbb-fstsd 1/1 Running 0 30s +argocd-server-5964cdf9fb-rd62w 1/1 Running 0 30s +``` + +
+ +
+kubectl port-forward svc/argocd-server -n argocd 8080:443, argocd admin initial-password, and CLI login + +```bash +$ kubectl port-forward service/argocd-server -n argocd 8080:443 +Running in a separate terminal session for this verification step. +$ curl -kI https://127.0.0.1:8080 +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 + +$ argocd admin initial-password -n argocd +[REDACTED] +$ argocd login 127.0.0.1:8080 --insecure --username admin --password [REDACTED] +'admin:login' logged in successfully +Context '127.0.0.1:8080' updated +$ argocd app list +NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY CONDITIONS REPO PATH TARGET +``` + +
+ +## Task 2 - Application Deployment with Manual Sync + +I created `k8s/argocd/application.yaml` as a manual-sync `Application` pointing to `k8s/devops-app-py` on branch `lab13`, with `helm.releaseName: devops-app-py` and `valueFiles: [values.yaml]`. The chart was updated for GitOps use at the same time: chart version `0.5.0`, `service.type: ClusterIP` across default/dev/prod, and `values-prod.yaml` now uses `replicaCount: 2`. + +After applying the `Application`, I manually synced it, verified the release in `default`, and reached it through `kubectl port-forward svc/devops-app-py-service -n default 18080:80`. To test the GitOps loop, I committed and pushed a change from `values.yaml` `replicaCount: 1` to `2`; ArgoCD eventually marked the app `OutOfSync`, and a manual sync brought the Deployment to `2/2` ready replicas. Once that evidence was captured, I deleted the temporary in-cluster `devops-app-py` application and its resources so the final ArgoCD state only contains `dev` and `prod`. + +The relevant commits for Task 2 were: + +- `9cab12c feat(k8s): add argocd applications` +- `2e2fdcc chore(k8s): scale default argocd demo` + +
+kubectl apply -f k8s/argocd/application.yaml and the first manual sync + +```bash +$ kubectl apply -f k8s/argocd/application.yaml +application.argoproj.io/devops-app-py created +$ argocd app sync devops-app-py +... +2026-04-10T14:09:34+03:00 batch Job default devops-app-py-post-install Succeeded Synced PostSync Reached expected number of succeeded pods + +$ argocd app wait devops-app-py --health --sync --timeout 300 +Sync Status: Synced to lab13 (9cab12c) +Health Status: Healthy + +$ kubectl get deploy,svc,pvc,pods -n default -l app.kubernetes.io/instance=devops-app-py +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-app-py 1/1 1 1 36s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-app-py-service ClusterIP 10.109.114.177 80/TCP 36s + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +persistentvolumeclaim/devops-app-py-data Bound pvc-9fac4c4d-8f73-4765-9bee-2430d7331618 100Mi RWO standard 36s +``` + +
+ +
+kubectl port-forward svc/devops-app-py-service -n default 18080:80 and application check + +```bash +$ curl -fsSL http://127.0.0.1:18080/ready | jq . +{ + "status": "ready", + "timestamp": "2026-04-10T11:09:49.814083+00:00", + "uptime_seconds": 22 +} +$ curl -fsSL http://127.0.0.1:18080/ | jq .service +{ + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.12.0" +} +$ curl -fsSL http://127.0.0.1:18080/visits | jq . +{ + "visits": 1 +} +``` + +
+ +
+git push after changing values.yaml replicas, ArgoCD drift detection, and manual resync + +```bash +$ git push origin lab13 +To https://github.com/LocalT0aster/DevOps-Core-S26.git + 9cab12c..2e2fdcc lab13 -> lab13 + +$ while true; do date -Is; argocd app get devops-app-py -o json | jq -r "[.status.sync.status, .status.health.status, .status.sync.revision] | @tsv"; done +2026-04-10T14:10:18+03:00 Synced Healthy 9cab12c53df7e8679c7736388f4bf0e65ac01bbf +... +2026-04-10T14:14:09+03:00 OutOfSync Healthy 2e2fdccdcfb43bc659aa5c6a7d30f54c21b87d76 + +$ argocd app sync devops-app-py +$ argocd app wait devops-app-py --health --sync --timeout 300 +Sync Status: Synced to lab13 (2e2fdcc) +Health Status: Healthy + +$ kubectl get deployment devops-app-py -n default +NAME READY UP-TO-DATE AVAILABLE AGE +devops-app-py 2/2 2 2 5m40s +``` + +
+ +## Task 3 - Multi-Environment Deployment + +I created `dev` and `prod` namespaces, then added `k8s/argocd/application-dev.yaml` and `k8s/argocd/application-prod.yaml`. Both point to the same chart and branch, but `values-dev.yaml` keeps one replica and lighter resources while `values-prod.yaml` keeps two replicas and higher limits. The dev app uses `automated.prune: true` plus `selfHeal: true`, while prod stays manual. + +Both environments were deployed successfully and verified through port-forwards on `18081` and `18082`. To demonstrate the policy difference, I committed the same harmless pod annotation into both env values files. Dev auto-synced itself to revision `0bb803c`, while prod became `OutOfSync` and stayed there until I ran a manual sync. + +During that work I found a chart bug in the Helm hook jobs: the hook pod templates reused the same selector labels as the main app pods, so the service could temporarily select the post-install smoke-test pod. I fixed that in commit `8f1d087 fix(k8s): exclude hook pods from service endpoints` by removing the release-instance label from the hook pod templates, revalidated the chart, pushed the fix, and left both environments synced to `8f1d087`. + +
+kubectl apply -f k8s/argocd/application-dev.yaml, kubectl apply -f k8s/argocd/application-prod.yaml, and initial syncs + +```bash +$ kubectl create namespace dev --dry-run=client -o yaml | kubectl apply -f - +namespace/dev created +$ kubectl create namespace prod --dry-run=client -o yaml | kubectl apply -f - +namespace/prod created +$ kubectl apply -f k8s/argocd/application-dev.yaml +application.argoproj.io/devops-app-py-dev created +$ kubectl apply -f k8s/argocd/application-prod.yaml +application.argoproj.io/devops-app-py-prod created + +$ argocd app list +NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY +argocd/devops-app-py-dev https://kubernetes.default.svc dev default Auto-Prune +argocd/devops-app-py-prod https://kubernetes.default.svc prod default Manual + +$ argocd app wait devops-app-py-dev --health --sync --timeout 300 +Sync Status: Synced to lab13 (2e2fdcc) +Health Status: Healthy + +$ argocd app wait devops-app-py-prod --health --sync --timeout 300 +Sync Status: Synced to lab13 (2e2fdcc) +Health Status: Healthy + +$ kubectl get deploy,svc,pods -n dev -l app.kubernetes.io/instance=devops-app-py-dev +deployment.apps/devops-app-py-dev 1/1 +service/devops-app-py-dev-service ClusterIP +pod/devops-app-py-dev-85d85556b7-7j6c8 1/1 Running + +$ kubectl get deploy,svc,pods -n prod -l app.kubernetes.io/instance=devops-app-py-prod +deployment.apps/devops-app-py-prod 2/2 +service/devops-app-py-prod-service ClusterIP +pod/devops-app-py-prod-b8fbf58ff-7rzpc 1/1 Running +pod/devops-app-py-prod-b8fbf58ff-xfnls 1/1 Running +``` + +
+ +
+kubectl port-forward checks for dev and prod + +```bash +$ curl -fsSL http://127.0.0.1:18081/ready | jq . +{ + "status": "ready", + "timestamp": "2026-04-10T11:16:28.441384+00:00", + "uptime_seconds": 30 +} +$ curl -fsSL http://127.0.0.1:18081/ | jq .service,.system.hostname +{ + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.12.0" +} +"devops-app-py-dev-85d85556b7-7j6c8" + +$ curl -fsSL http://127.0.0.1:18082/ready | jq . +{ + "status": "ready", + "timestamp": "2026-04-10T11:16:28.502495+00:00", + "uptime_seconds": 36 +} +$ curl -fsSL http://127.0.0.1:18082/ | jq .service,.system.hostname +{ + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.12.0" +} +"devops-app-py-prod-b8fbf58ff-xfnls" +``` + +
+ +
+git push for the shared env-values change and dev/prod sync-policy difference + +```bash +$ git push origin lab13 +To https://github.com/LocalT0aster/DevOps-Core-S26.git + 2e2fdcc..0bb803c lab13 -> lab13 + +$ while true; do date -Is; argocd app get devops-app-py-dev -o json; argocd app get devops-app-py-prod -o json; done +2026-04-10T14:19:45+03:00 dev Synced Healthy 2e2fdccdcfb43bc659aa5c6a7d30f54c21b87d76 +2026-04-10T14:19:45+03:00 prod OutOfSync Healthy 0bb803c97b1785269b5408533f98b1222f264f6d +2026-04-10T14:20:01+03:00 dev Synced Progressing 0bb803c97b1785269b5408533f98b1222f264f6d +2026-04-10T14:20:01+03:00 prod OutOfSync Healthy 0bb803c97b1785269b5408533f98b1222f264f6d + +$ argocd app get devops-app-py-prod +Sync Policy: Manual +Sync Status: OutOfSync from lab13 (0bb803c) +Health Status: Healthy + +$ argocd app sync devops-app-py-prod +$ argocd app wait devops-app-py-prod --health --sync --timeout 300 +Sync Status: Synced to lab13 (0bb803c) +Health Status: Healthy +``` + +
+ +
+fix(k8s): exclude hook pods from service endpoints and final app state + +```bash +$ helm template devops-app-py-prod k8s/devops-app-py -f k8s/devops-app-py/values-prod.yaml | sed -n '/^# Source: devops-app-py\/templates\/hooks\/post-install-job.yaml/,/^---$/p' +apiVersion: batch/v1 +kind: Job +metadata: + name: devops-app-py-prod-post-install +... +spec: + template: + metadata: + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/component: hook + +$ argocd app list +NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY +argocd/devops-app-py-dev https://kubernetes.default.svc dev default Synced Healthy Auto-Prune +argocd/devops-app-py-prod https://kubernetes.default.svc prod default Synced Healthy Manual +``` + +
+ +## Task 4 - Self-Healing and Drift Behavior + +Replica drift behaved exactly as expected for an auto-sync app: scaling the dev deployment from `1` to `5` replicas made the app `OutOfSync`, and ArgoCD pulled it back to `1` within the next few polling samples. Pod deletion was different: when I deleted the only dev pod, the Deployment/ReplicaSet controller recreated it while ArgoCD stayed `Synced`; that recovery is Kubernetes self-healing, not GitOps reconciliation. + +For config drift, I tried extra Deployment labels first because the lab suggests labels as an example. In this environment, extra labels on either the Deployment metadata or the pod template were not surfaced as `OutOfSync` even after a manual refresh, so they were not a reliable self-heal demonstration. I switched to an image-field drift instead by changing the live dev deployment image from `1.12-dev` to `1.12`; ArgoCD restored the desired image back to `1.12-dev` within 5 seconds. Afterward I manually removed the temporary label experiments and verified the dev app was back to `Synced/Healthy`. + +The official docs say the application reconciliation timeout defaults to `120s` plus up to `60s` jitter, and automated self-heal retries after `5s`. In this run, repo-driven drift detection still took several minutes to show up in ArgoCD, so I relied on the actual timestamps captured below instead of assuming the documented minimum. + +
+kubectl scale deployment devops-app-py-dev -n dev --replicas=5 and ArgoCD self-heal + +```bash +$ kubectl scale deployment devops-app-py-dev -n dev --replicas=5 +deployment.apps/devops-app-py-dev scaled +2026-04-10T14:25:10+03:00 deploy 5 1 1 +2026-04-10T14:25:10+03:00 app OutOfSync Progressing 8f1d0879c728e15141a5bf3c317282da040154da +2026-04-10T14:25:16+03:00 deploy 5 1 1 +2026-04-10T14:25:16+03:00 app OutOfSync Progressing 8f1d0879c728e15141a5bf3c317282da040154da +2026-04-10T14:25:21+03:00 deploy 1 1 1 +2026-04-10T14:25:21+03:00 app Synced Healthy 8f1d0879c728e15141a5bf3c317282da040154da +``` + +
+ +
+kubectl delete pod -n dev ... and Deployment/ReplicaSet recovery + +```bash +$ kubectl delete pod -n dev devops-app-py-dev-79d7ddf98c-zxksq +pod "devops-app-py-dev-79d7ddf98c-zxksq" deleted from dev namespace +2026-04-10T14:26:13+03:00 app Synced Progressing +2026-04-10T14:26:13+03:00 pod devops-app-py-dev-79d7ddf98c-bkc9l Running false +... +2026-04-10T14:26:31+03:00 app Synced Healthy +2026-04-10T14:26:31+03:00 pod devops-app-py-dev-79d7ddf98c-bkc9l Running true +``` + +
+ +
+kubectl set image deployment/devops-app-py-dev -n dev devops-app-py=localt0aster/devops-app-py:1.12 and image drift self-heal + +```bash +$ kubectl set image deployment/devops-app-py-dev -n dev devops-app-py=localt0aster/devops-app-py:1.12 +deployment.apps/devops-app-py-dev image updated +2026-04-10T14:33:11+03:00 image localt0aster/devops-app-py:1.12 +2026-04-10T14:33:11+03:00 app Synced Progressing 8f1d0879c728e15141a5bf3c317282da040154da +2026-04-10T14:33:16+03:00 image localt0aster/devops-app-py:1.12-dev +2026-04-10T14:33:16+03:00 app Synced Healthy 8f1d0879c728e15141a5bf3c317282da040154da +``` + +
+ +
+kubectl label ... lab13-drift-, kubectl patch ... /spec/template/metadata/labels/lab13-template-drift, and final cleanup + +```bash +$ kubectl label deployment devops-app-py-dev -n dev lab13-drift- +deployment.apps/devops-app-py-dev unlabeled +$ kubectl patch deployment devops-app-py-dev -n dev --type json -p '[{"op":"remove","path":"/spec/template/metadata/labels/lab13-template-drift"}]' +deployment.apps/devops-app-py-dev patched +$ kubectl rollout status deployment/devops-app-py-dev -n dev --timeout=300s +deployment "devops-app-py-dev" successfully rolled out +$ argocd app wait devops-app-py-dev --health --sync --timeout 300 +Sync Status: Synced to lab13 (8f1d087) +Health Status: Healthy +``` + +
+ +## Screenshots + +ArgoCD applications overview: + +![](img/lab13_applications_overview.png) + +Application details view: + +![](img/lab13_application_details.png) + +Dev auto-sync vs prod manual sync: + +![](img/lab13_sync_policy_difference.png) + +## Final State + +
+argocd app list and final dev/prod resources + +```text +$ argocd app list +NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY +argocd/devops-app-py-dev https://kubernetes.default.svc dev default Synced Healthy Auto-Prune +argocd/devops-app-py-prod https://kubernetes.default.svc prod default Synced Healthy Manual + +$ kubectl get deploy,svc,pods -n dev +deployment.apps/devops-app-py-dev 1/1 +service/devops-app-py-dev-service ClusterIP +pod/devops-app-py-dev-79d7ddf98c-4fsgw 1/1 Running + +$ kubectl get deploy,svc,pods -n prod +deployment.apps/devops-app-py-prod 2/2 +service/devops-app-py-prod-service ClusterIP +pod/devops-app-py-prod-764c4cdb7f-fqbqc 1/1 Running +pod/devops-app-py-prod-764c4cdb7f-qf2n5 1/1 Running +``` + +
diff --git a/k8s/docs/img/lab13_application_details.png b/k8s/docs/img/lab13_application_details.png new file mode 100644 index 0000000000..db25bfc8f5 Binary files /dev/null and b/k8s/docs/img/lab13_application_details.png differ diff --git a/k8s/docs/img/lab13_applications_overview.png b/k8s/docs/img/lab13_applications_overview.png new file mode 100644 index 0000000000..ed3fe2586f Binary files /dev/null and b/k8s/docs/img/lab13_applications_overview.png differ diff --git a/k8s/docs/img/lab13_sync_policy_difference.png b/k8s/docs/img/lab13_sync_policy_difference.png new file mode 100644 index 0000000000..a6617dd769 Binary files /dev/null and b/k8s/docs/img/lab13_sync_policy_difference.png differ diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..a6ebff9108 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-app-py-service + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 +spec: + type: NodePort + selector: + app.kubernetes.io/name: devops-app-py + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 5000 + nodePort: 30080 diff --git a/monitoring/.env.example b/monitoring/.env.example new file mode 100644 index 0000000000..5b4ba04148 --- /dev/null +++ b/monitoring/.env.example @@ -0,0 +1,2 @@ +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=ChangeMe!123 diff --git a/monitoring/.gitignore b/monitoring/.gitignore new file mode 100644 index 0000000000..d965490406 --- /dev/null +++ b/monitoring/.gitignore @@ -0,0 +1,2 @@ +*.env +data/ diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..15acfd1bb4 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,230 @@ +services: + loki: + image: grafana/loki:3.0.0 + command: + - -config.file=/etc/loki/config.yml + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + healthcheck: + test: + - CMD-SHELL + - wget --no-verbose --tries=1 --spider http://127.0.0.1:3100/ready || exit 1 + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + networks: + - monitoring + restart: unless-stopped + + promtail: + image: grafana/promtail:3.0.0 + command: + - -config.file=/etc/promtail/config.yml + user: "0:0" + depends_on: + loki: + condition: service_healthy + ports: + - "9080:9080" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - promtail-data:/run/promtail + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + healthcheck: + test: + - CMD-SHELL + - >- + bash -lc 'exec 3<>/dev/tcp/127.0.0.1/9080 + && printf "GET /ready HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" >&3 + && grep -q "200 OK" <&3' + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.10" + memory: 64M + networks: + - monitoring + restart: unless-stopped + + grafana: + image: grafana/grafana:12.3.1 + depends_on: + loki: + condition: service_healthy + environment: + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set in monitoring/.env} + GF_SECURITY_ALLOW_EMBEDDING: "true" + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + healthcheck: + test: + - CMD-SHELL + - wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1 + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + reservations: + cpus: "0.25" + memory: 128M + networks: + - monitoring + restart: unless-stopped + + prometheus: + image: prom/prometheus:v3.9.0 + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.retention.time=15d + - --storage.tsdb.retention.size=10GB + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + healthcheck: + test: + - CMD-SHELL + - wget --no-verbose --tries=1 --spider http://127.0.0.1:9090/-/healthy || exit 1 + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + networks: + - monitoring + restart: unless-stopped + + app-python: + image: localt0aster/devops-app-py:1.12-dev + environment: + HOST: "0.0.0.0" + PORT: "8000" + ports: + - "8000:8000" + volumes: + - ./data/app-python:/data + labels: + logging: "promtail" + app: "devops-python" + healthcheck: + test: + - CMD-SHELL + - wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/health || exit 1 + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.10" + memory: 64M + networks: + - monitoring + restart: unless-stopped + + app-go: + image: localt0aster/devops-app-go:1.12-dev + # Re-enable local builds if Docker networking breaks behind the tun/VPN setup. + # build: + # context: ../app_go + # network: host + environment: + HOST: "0.0.0.0" + PORT: "8001" + ports: + - "8001:8001" + volumes: + - ./data/app-go:/data + labels: + logging: "promtail" + app: "devops-go" + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.10" + memory: 64M + networks: + - monitoring + restart: unless-stopped + + app-go-healthcheck: + image: curlimages/curl:8.18.0 + command: + - sh + - -c + - sleep infinity + depends_on: + - app-go + healthcheck: + test: + - CMD-SHELL + - curl -fsS http://app-go:8001/health >/dev/null || exit 1 + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "0.10" + memory: 64M + reservations: + cpus: "0.05" + memory: 32M + networks: + - monitoring + restart: unless-stopped + +volumes: + loki-data: + promtail-data: + grafana-data: + prometheus-data: + +networks: + monitoring: diff --git a/monitoring/docs/LAB07.md b/monitoring/docs/LAB07.md new file mode 100644 index 0000000000..cd74a47a51 --- /dev/null +++ b/monitoring/docs/LAB07.md @@ -0,0 +1,800 @@ +# LAB07 - Observability & Logging with Loki Stack + +## 1. Architecture + +This lab uses a single Docker Compose stack for log collection, storage, querying, and visualization. + +- `loki` stores logs on local disk with TSDB and schema `v13`. +- `promtail` discovers Docker containers through the Docker socket and ships selected logs to Loki. +- `grafana` uses Loki as the default data source and provides Explore plus a custom dashboard. +- `app-python` and `app-go` write structured JSON logs to container stdout. +- Only containers labeled `logging=promtail` are scraped. + +Current image tags in Compose are branch-style image tags: + +- `localt0aster/devops-app-py:1.7.9a42ee5` +- `localt0aster/devops-app-go:1.7.9a42ee5` + +The application payloads themselves report service version `1.7.0`. + +```text +curl / browser + | + v ++-----------------------------+ +| app-python app-go | +| JSON logs to stdout | ++-----------------------------+ + | + v ++-----------------------------+ +| promtail | +| docker_sd + relabeling | ++-----------------------------+ + | + v ++-----------------------------+ +| loki | +| TSDB + filesystem storage | ++-----------------------------+ + | + v ++-----------------------------+ +| grafana | +| Explore + dashboard | ++-----------------------------+ +``` + +## 2. Setup Guide + +The project structure for the monitoring stack is: + +```text +monitoring/ +├── docker-compose.yml +├── loki/config.yml +├── promtail/config.yml +├── grafana/provisioning/datasources/loki.yml +└── docs/LAB07.md +``` + +Bring the stack up from the repository root: + +```bash +cd monitoring +docker compose up -d +docker compose ps +``` + +Useful local endpoints: + +- Grafana: `http://localhost:3000` +- Loki: `http://localhost:3100` +- Promtail: `http://localhost:9080` +- Python app: `http://localhost:8000` +- Go app: `http://localhost:8001` + +Basic verification commands: + +```bash +curl -fSsL localhost:3100/ready +curl -fSsL localhost:9080/targets +curl -fSsL localhost:3000/api/health +curl -fSsL localhost:8000/health +curl -fSsL localhost:8001/health +``` + +Grafana is configured to provision Loki automatically, so the data source is available immediately after startup. + +## 3. Configuration + +### Docker Compose + +The stack keeps all services on one shared `monitoring` network and persists Loki, Promtail positions, and Grafana state in named volumes. + +Compose excerpt: + +```yaml +services: + loki: + image: grafana/loki:3.0.0 + promtail: + image: grafana/promtail:3.0.0 + grafana: + image: grafana/grafana:12.3.1 + app-python: + image: localt0aster/devops-app-py:1.7.9a42ee5 + labels: + logging: "promtail" + app: "devops-python" + app-go: + image: localt0aster/devops-app-go:1.7.9a42ee5 + labels: + logging: "promtail" + app: "devops-go" +``` + +Two practical decisions matter here: + +- published images are used for the apps instead of local builds; +- the earlier `build.network: host` workaround is preserved as commented YAML for the tun/VPN case, but it is not active in the final stack. + +### Loki + +Loki is configured as a single-node instance with filesystem storage, TSDB, schema `v13`, and 7-day retention. + +Snippet: + +```yaml +common: + path_prefix: /loki + replication_factor: 1 + storage: + filesystem: + chunks_directory: /loki/chunks + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + +limits_config: + retention_period: 168h + +compactor: + retention_enabled: true +``` + +Why this setup: + +- TSDB is the current Loki 3.x recommendation. +- Filesystem storage is enough for a single-node lab environment. +- 7-day retention keeps local disk usage bounded. + +### Promtail + +Promtail uses Docker service discovery and only scrapes labeled containers. + +Snippet: + +```yaml +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: label + values: + - logging=promtail + relabel_configs: + - target_label: job + replacement: docker + - source_labels: [__meta_docker_container_label_app] + target_label: app + - source_labels: [__meta_docker_container_name] + regex: "/(.*)" + target_label: container +``` + +Why this setup: + +- `logging=promtail` avoids scraping unrelated containers. +- the custom `app` label makes LogQL queries stable across container restarts; +- `container`, `compose_service`, and `logstream` are useful for debugging and panel filtering. + +### Grafana + +Loki is provisioned as the default data source. + +Snippet: + +```yaml +datasources: + - name: Loki + uid: loki + type: loki + url: http://loki:3100 + isDefault: true +``` + +This removes a manual setup step and makes the stack reproducible. + +## 4. Application Logging + +### Python app + +The Python service has two JSON logging paths: + +- Gunicorn access logging for every HTTP request. +- Application logging through a custom `JSONFormatter`. + +Gunicorn access format: + +```python +access_log_format = ( + '{"timestamp":"%(t)s","level":"INFO","logger":"gunicorn.access",' + '"client_ip":"%(h)s","method":"%(m)s","path":"%(U)s","query":"%(q)s",' + '"status_code":%(s)s,"response_bytes":"%(B)s","request_time_us":%(D)s,' + '"user_agent":"%(a)s"}' +) +``` + +Application logger behavior: + +- startup is logged as `application initialized`; +- `404` responses are logged as `WARNING`; +- `500` responses are logged as `ERROR` with `error_type` and `error`. + +### Go app + +The Go service was updated for parity with the Python service and now emits JSON for: + +- startup; +- access logs after each request; +- panic recovery; +- response encoding failures. + +Its access logger writes fields compatible with the Python app: + +- `timestamp` +- `level` +- `logger` +- `client_ip` +- `method` +- `path` +- `query` +- `status_code` +- `response_bytes` +- `request_time_us` +- `user_agent` + +### Example queries and evidence + +Logs from both applications: + +```logql +{job="docker", app=~"devops-python|devops-go"} +``` + +![](img/task2_apps.png) + +Only JSON request logs: + +```logql +{app=~"devops-python|devops-go"} | json | method="GET" +``` + +![](img/task2_get.png) + +Warnings: + +```logql +{app=~"devops-python|devops-go"} |= "WARN" +``` + +![](img/task2_warn.png) + +## 5. Dashboard + +The Grafana dashboard is named `DevOps Service`. It contains four panels and uses Loki as the only data source. + +![](img/task3_panel.png) + +### Panel overview + +#### Logs Table + +- Type: table +- Purpose: show recent raw logs from both applications +- Query: + +```logql +{app=~"devops-.*"} +``` + +#### Request Rate + +- Type: time series +- Purpose: show request throughput grouped by `app` +- Query: + +```logql +sum by (app) (rate({app=~"devops-.*"} [1m])) +``` + +#### Error Logs + +- Type: table +- Purpose: show only error-level log lines +- Query: + +```logql +{app=~"devops-.*"} | json | level=~"ERROR|error" +``` + +Practical note: + +- the dashboard currently includes one synthetic Python error record used to keep the panel non-empty during normal demo traffic; +- an easy public error endpoint was intentionally not added, because it would let any user spam error logs on demand; +- ordinary health and index requests only generate `INFO`, and missing endpoints generate `WARNING`, so a forced error was needed for visible evidence. + +#### Log Level Distribution + +- Type: stat +- Purpose: count logs by parsed JSON `level` +- Query: + +```logql +sum by (level) (count_over_time({app=~"devops-.*"} | json [5m])) +``` + +## 6. Production Hardening + +Task 4 is implemented in the Compose stack and verified locally. + +### Implemented changes + +- Resource limits and reservations were added to all services in `docker-compose.yml`. +- Anonymous Grafana access is disabled with `GF_AUTH_ANONYMOUS_ENABLED=false`. +- Grafana admin credentials are read from a local `.env` file. +- `.env` is ignored by git, and `.env.example` documents the required variables. +- Healthchecks were added for Loki, Grafana, and the Python app. +- The Go app is monitored by a small external probe service named `app-go-healthcheck`. +- Grafana and Promtail now wait for Loki to become healthy before starting. + +Implemented snippets: + +```yaml +deploy: + resources: + limits: + cpus: "1.0" + memory: 1G +``` + +```yaml +environment: + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}" +``` + +```yaml +healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1", + ] + interval: 10s + timeout: 5s + retries: 5 +``` + +### Verification + +- `docker compose ps` shows Loki, Grafana, `app-python`, and `app-go-healthcheck` as `healthy`. +- Anonymous access to `http://localhost:3000/api/user` now returns `401`. +- Admin access works with the credentials from the local `.env`. +- The Grafana login page is served at `http://localhost:3000/login`. + +![](img/task4_password_required.png) + +Practical note: + +- the Go app image is based on `scratch`, so it does not contain shell or probe tools for a simple in-container HTTP healthcheck; +- for that reason, `app-go-healthcheck` performs the HTTP probe externally with `curl` against `http://app-go:8001/health`. + +## 7. Testing + +Commands used to generate traffic and verify ingestion: + +```bash +cd monitoring +docker compose up -d + +for i in $(seq 1 10); do + curl -fsSL localhost:8000/ >/dev/null + curl -fsSL localhost:8000/health >/dev/null + curl -fsSL localhost:8001/ >/dev/null + curl -fsSL localhost:8001/health >/dev/null +done + +curl -fsSL localhost:8000/do404 >/dev/null +curl -fsSL localhost:8001/do404 >/dev/null +``` + +Useful checks: + +```bash +curl -fSsL localhost:3100/ready +curl -fSsL localhost:9080/targets +curl -fSsL localhost:3000/api/health +curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/user +docker compose ps +docker compose logs app-python --tail=20 +docker compose logs app-go --tail=20 +``` + +Useful LogQL checks: + +```logql +{app=~"devops-python|devops-go"} +{app=~"devops-python|devops-go"} | json | method="GET" +{app=~"devops-python|devops-go"} |= "WARN" +{app=~"devops-python|devops-go"} | json | level=~"ERROR|error" +``` + +## 8. Bonus Task + +### Automated deployment with Ansible + +The bonus task was implemented as a dedicated monitoring deployment playbook plus a reusable role: + +- `ansible/playbooks/deploy-monitoring.yml` +- `ansible/roles/monitoring/defaults/main.yml` +- `ansible/roles/monitoring/tasks/setup.yml` +- `ansible/roles/monitoring/tasks/deploy.yml` +- `ansible/roles/monitoring/templates/*.j2` + +The role creates `/opt/devops-monitoring`, templates the Compose stack plus Loki, Promtail, Grafana, and `.env` files, starts the stack with `community.docker.docker_compose_v2`, and verifies: + +- published ports for Loki, Promtail, Grafana, Python app, and Go app; +- Loki `/ready`; +- Promtail `/targets`; +- Grafana `/api/health`; +- Grafana auth gate returning `401` anonymously; +- Python `/health`; +- Go `/health`; +- the external `app-go-healthcheck` container status. + +The first manual runs exposed a real bug in the role: the healthcheck assertion was hard-coded to `monitoring-app-go-healthcheck-1`, but the VM uses Compose project name `devops-monitoring`, so the real container name is `devops-monitoring-app-go-healthcheck-1`. I fixed that by deriving the container name from `monitoring_dir | basename`. + +### CI dependency gate + +`.github/workflows/ansible-deploy.yml` now contains a `wait-for-prerequisites` job. It polls workflow runs for the current commit and waits for: + +- `Go Docker Publish` +- `Python CI` +- `Python Docker Publish` + +Practical behavior: + +- if one of these workflows exists for the same commit and is still running, Ansible deployment waits; +- if one exists and fails, the Ansible workflow fails before deployment; +- if a workflow never started for that commit because its path filters did not match, it is treated as not applicable after a short grace period instead of deadlocking the pipeline. + +### Playbook evidence + +Because the VM images were already pulled and Docker Hub reachability on my host is inconsistent, the successful validation reruns used: + +```bash +cd ansible +.venv/bin/ansible-playbook playbooks/deploy-monitoring.yml \ + -e monitoring_compose_pull_policy=missing \ + -e monitoring_compose_wait=false +``` + +
+Initial failed run before the container-name fix + +```text +PLAY [Deploy monitoring stack] ************************************************* + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run monitoring role] ***************************************************** +included: monitoring for vagrant + +TASK [monitoring : Prepare monitoring stack files] ***************************** +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/setup.yml for vagrant + +TASK [monitoring : Ensure monitoring directory structure exists] *************** +ok: [vagrant] => (item=/opt/devops-monitoring) +ok: [vagrant] => (item=/opt/devops-monitoring/loki) +ok: [vagrant] => (item=/opt/devops-monitoring/promtail) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana/provisioning) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana/provisioning/datasources) + +TASK [monitoring : Template monitoring environment file] *********************** +ok: [vagrant] + +TASK [monitoring : Template monitoring Docker Compose configuration] *********** +ok: [vagrant] + +TASK [monitoring : Template Loki configuration] ******************************** +ok: [vagrant] + +TASK [monitoring : Template Promtail configuration] **************************** +ok: [vagrant] + +TASK [monitoring : Template Grafana Loki datasource provisioning] ************** +ok: [vagrant] + +TASK [monitoring : Deploy monitoring stack] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/deploy.yml for vagrant + +TASK [monitoring : Skip monitoring deployment actions in check mode] *********** +skipping: [vagrant] + +TASK [monitoring : Log in to Docker Hub when credentials are available] ******** +ok: [vagrant] + +TASK [monitoring : Deploy monitoring stack with Docker Compose v2] ************* +changed: [vagrant] + +TASK [monitoring : Wait for exposed monitoring ports] ************************** +ok: [vagrant -> localhost] => (item=3100) +ok: [vagrant -> localhost] => (item=9080) +ok: [vagrant -> localhost] => (item=3000) +ok: [vagrant -> localhost] => (item=8000) +ok: [vagrant -> localhost] => (item=8001) + +TASK [monitoring : Verify Loki readiness endpoint] ***************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Promtail targets endpoint] *************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Grafana API health] ********************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Grafana requires authentication] ********************* +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Python application health endpoint] ****************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Go application health endpoint] ********************** +ok: [vagrant -> localhost] + +TASK [monitoring : Read external Go healthcheck container info] **************** +ok: [vagrant] + +TASK [monitoring : Assert external Go healthcheck is healthy] ****************** +[ERROR]: Task failed: Action failed: External Go healthcheck container is not healthy. +Origin: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/deploy.yml:148:7 + +146 when: monitoring_go_external_healthcheck_enabled | bool +147 +148 - name: Assert external Go healthcheck is healthy + ^ column 7 + +fatal: [vagrant]: FAILED! => { + "assertion": "monitoring_go_healthcheck_container.exists | bool", + "changed": false, + "evaluated_to": false, + "msg": "External Go healthcheck container is not healthy." +} + +TASK [monitoring : Capture docker compose status after failed monitoring deployment] *** +ok: [vagrant] + +TASK [monitoring : Fail deployment with compose status context] **************** +[ERROR]: Task failed: Action failed: Monitoring deployment failed. Compose status: NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +devops-monitoring-app-go-1 localt0aster/devops-app-go:latest "/devops-info-servic…" app-go 41 seconds ago Up 40 seconds 0.0.0.0:8001->8001/tcp, [::]:8001->8001/tcp +devops-monitoring-app-go-healthcheck-1 curlimages/curl:8.18.0 "/entrypoint.sh sh -…" app-go-healthcheck 41 seconds ago Up 40 seconds (healthy) +devops-monitoring-app-python-1 localt0aster/devops-app-py:latest "sh -c 'gunicorn --c…" app-python 41 seconds ago Up 40 seconds (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp +devops-monitoring-grafana-1 grafana/grafana:12.3.1 "/run.sh" grafana 41 seconds ago Up 20 seconds (healthy) 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp +devops-monitoring-loki-1 grafana/loki:3.0.0 "/usr/bin/loki -conf…" loki 41 seconds ago Up 40 seconds (healthy) 0.0.0.0:3100->3100/tcp, [::]:3100->3100/tcp +devops-monitoring-promtail-1 grafana/promtail:3.0.0 "/usr/bin/promtail -…" promtail 41 seconds ago Up 20 seconds 0.0.0.0:9080->9080/tcp, [::]:9080->9080/tcp +Origin: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/deploy.yml:170:7 + +168 failed_when: false +169 +170 - name: Fail deployment with compose status context + ^ column 7 + +fatal: [vagrant]: FAILED! => {"changed": false, "msg": "Monitoring deployment failed. Compose status: NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS\ndevops-monitoring-app-go-1 localt0aster/devops-app-go:latest \"/devops-info-servic…\" app-go 41 seconds ago Up 40 seconds 0.0.0.0:8001->8001/tcp, [::]:8001->8001/tcp\ndevops-monitoring-app-go-healthcheck-1 curlimages/curl:8.18.0 \"/entrypoint.sh sh -…\" app-go-healthcheck 41 seconds ago Up 40 seconds (healthy) \ndevops-monitoring-app-python-1 localt0aster/devops-app-py:latest \"sh -c 'gunicorn --c…\" app-python 41 seconds ago Up 40 seconds (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp\ndevops-monitoring-grafana-1 grafana/grafana:12.3.1 \"/run.sh\" grafana 41 seconds ago Up 20 seconds (healthy) 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp\ndevops-monitoring-loki-1 grafana/loki:3.0.0 \"/usr/bin/loki -conf…\" loki 41 seconds ago Up 40 seconds (healthy) 0.0.0.0:3100->3100/tcp, [::]:3100->3100/tcp\ndevops-monitoring-promtail-1 grafana/promtail:3.0.0 \"/usr/bin/promtail -…\" promtail 41 seconds ago Up 20 seconds 0.0.0.0:9080->9080/tcp, [::]:9080->9080/tcp"} + +PLAY RECAP ********************************************************************* +vagrant : ok=21 changed=1 unreachable=0 failed=1 skipped=1 rescued=1 ignored=0 +``` + +
+ +
+Successful rerun after the fix + +```text +PLAY [Deploy monitoring stack] ************************************************* + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run monitoring role] ***************************************************** +included: monitoring for vagrant + +TASK [monitoring : Prepare monitoring stack files] ***************************** +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/setup.yml for vagrant + +TASK [monitoring : Ensure monitoring directory structure exists] *************** +ok: [vagrant] => (item=/opt/devops-monitoring) +ok: [vagrant] => (item=/opt/devops-monitoring/loki) +ok: [vagrant] => (item=/opt/devops-monitoring/promtail) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana/provisioning) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana/provisioning/datasources) + +TASK [monitoring : Template monitoring environment file] *********************** +ok: [vagrant] + +TASK [monitoring : Template monitoring Docker Compose configuration] *********** +ok: [vagrant] + +TASK [monitoring : Template Loki configuration] ******************************** +ok: [vagrant] + +TASK [monitoring : Template Promtail configuration] **************************** +ok: [vagrant] + +TASK [monitoring : Template Grafana Loki datasource provisioning] ************** +ok: [vagrant] + +TASK [monitoring : Deploy monitoring stack] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/deploy.yml for vagrant + +TASK [monitoring : Skip monitoring deployment actions in check mode] *********** +skipping: [vagrant] + +TASK [monitoring : Log in to Docker Hub when credentials are available] ******** +ok: [vagrant] + +TASK [monitoring : Deploy monitoring stack with Docker Compose v2] ************* +ok: [vagrant] + +TASK [monitoring : Wait for exposed monitoring ports] ************************** +ok: [vagrant -> localhost] => (item=3100) +ok: [vagrant -> localhost] => (item=9080) +ok: [vagrant -> localhost] => (item=3000) +ok: [vagrant -> localhost] => (item=8000) +ok: [vagrant -> localhost] => (item=8001) + +TASK [monitoring : Verify Loki readiness endpoint] ***************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Promtail targets endpoint] *************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Grafana API health] ********************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Grafana requires authentication] ********************* +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Python application health endpoint] ****************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Go application health endpoint] ********************** +ok: [vagrant -> localhost] + +TASK [monitoring : Read external Go healthcheck container info] **************** +ok: [vagrant] + +TASK [monitoring : Assert external Go healthcheck is healthy] ****************** +ok: [vagrant] => { + "changed": false, + "msg": "All assertions passed" +} + +PLAY RECAP ********************************************************************* +vagrant : ok=21 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +
+ +
+Idempotent rerun after the fix + +```text +PLAY [Deploy monitoring stack] ************************************************* + +TASK [Gathering Facts] ********************************************************* +ok: [vagrant] + +TASK [Run monitoring role] ***************************************************** +included: monitoring for vagrant + +TASK [monitoring : Prepare monitoring stack files] ***************************** +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/setup.yml for vagrant + +TASK [monitoring : Ensure monitoring directory structure exists] *************** +ok: [vagrant] => (item=/opt/devops-monitoring) +ok: [vagrant] => (item=/opt/devops-monitoring/loki) +ok: [vagrant] => (item=/opt/devops-monitoring/promtail) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana/provisioning) +ok: [vagrant] => (item=/opt/devops-monitoring/grafana/provisioning/datasources) + +TASK [monitoring : Template monitoring environment file] *********************** +ok: [vagrant] + +TASK [monitoring : Template monitoring Docker Compose configuration] *********** +ok: [vagrant] + +TASK [monitoring : Template Loki configuration] ******************************** +ok: [vagrant] + +TASK [monitoring : Template Promtail configuration] **************************** +ok: [vagrant] + +TASK [monitoring : Template Grafana Loki datasource provisioning] ************** +ok: [vagrant] + +TASK [monitoring : Deploy monitoring stack] ************************************ +included: /home/t0ast/Repos/DevOps-Core-S26/ansible/roles/monitoring/tasks/deploy.yml for vagrant + +TASK [monitoring : Skip monitoring deployment actions in check mode] *********** +skipping: [vagrant] + +TASK [monitoring : Log in to Docker Hub when credentials are available] ******** +ok: [vagrant] + +TASK [monitoring : Deploy monitoring stack with Docker Compose v2] ************* +ok: [vagrant] + +TASK [monitoring : Wait for exposed monitoring ports] ************************** +ok: [vagrant -> localhost] => (item=3100) +ok: [vagrant -> localhost] => (item=9080) +ok: [vagrant -> localhost] => (item=3000) +ok: [vagrant -> localhost] => (item=8000) +ok: [vagrant -> localhost] => (item=8001) + +TASK [monitoring : Verify Loki readiness endpoint] ***************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Promtail targets endpoint] *************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Grafana API health] ********************************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Grafana requires authentication] ********************* +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Python application health endpoint] ****************** +ok: [vagrant -> localhost] + +TASK [monitoring : Verify Go application health endpoint] ********************** +ok: [vagrant -> localhost] + +TASK [monitoring : Read external Go healthcheck container info] **************** +ok: [vagrant] + +TASK [monitoring : Assert external Go healthcheck is healthy] ****************** +ok: [vagrant] => { + "changed": false, + "msg": "All assertions passed" +} + +PLAY RECAP ********************************************************************* +vagrant : ok=21 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +
+ +## 9. Challenges + +### 1. Docker build networking under VPN + +Local Docker builds initially failed because build containers could not reach package indexes over the default bridge network. The practical workaround was `build.network: host`. In the final Compose file I switched to published images and kept that workaround commented for future use. + +### 2. Go logging parity + +The Go service originally used plain `log.Printf`, which was enough for console output but poor for LogQL field filtering. I replaced it with structured JSON access and error logging so both apps can be queried the same way in Loki. + +### 3. Empty error panel + +Normal demo traffic produced `INFO` and `WARNING` records but not `ERROR`. I intentionally did not add a trivial user-triggered error route, because that would make log spamming easy from the client side. For dashboard evidence I seeded one synthetic `ERROR` entry into the Python app log stream instead. That is not ideal in production, but it is a practical way to prove the panel and query work in the lab environment. + +### 4. Container crash behavior + +Crashing a Gunicorn worker with `SIGSEGV` produced only a Gunicorn `WARNING`, not an application `ERROR`. Killing the whole container process later was useful for demonstrating stop behavior, but it still did not produce the desired application-level error log for the dashboard. Docker then treated the service as a stopped container until it was started again. + +### 5. Grafana password persistence + +Disabling anonymous auth was immediate, but the admin password from Compose environment variables did not replace the existing password stored in Grafana's persistent SQLite database. Because the Grafana volume was already initialized, I had to reset the admin password once against the running instance to bring it in sync with `.env`. diff --git a/monitoring/docs/LAB08.md b/monitoring/docs/LAB08.md new file mode 100644 index 0000000000..053664e40b --- /dev/null +++ b/monitoring/docs/LAB08.md @@ -0,0 +1,612 @@ +# LAB08 - Metrics and Monitoring + +## Task 1 — Application Instrumentation + +### Metrics Added + +The Python service was instrumented with Prometheus metrics in `app_python/src/metrics.py` and `app_python/src/router.py`. + +- `http_requests_total` + - Counter for total HTTP requests + - Labels: `method`, `endpoint`, `status_code` +- `http_request_duration_seconds` + - Histogram for request latency + - Labels: `method`, `endpoint`, `status_code` +- `http_requests_in_progress` + - Gauge for active in-flight requests + - Labels: `method`, `endpoint` +- `devops_info_endpoint_calls_total` + - Counter for endpoint usage inside the app + - Labels: `endpoint` +- `devops_info_system_info_duration_seconds` + - Histogram for the platform-info collection path + - No labels + +### Why These Metrics + +The metric set follows the RED method for a request-driven service. + +- Rate: `http_requests_total` +- Errors: `http_requests_total{status_code=~"5.."}` +- Duration: `http_request_duration_seconds` + +Two extra business-level metrics were added so the dashboard shows something specific to this service rather than only generic HTTP traffic. + +### Labeling Choices + +- Matched routes use normalized endpoint labels such as `/`, `/health`, and `/metrics` +- Unknown routes are grouped as `endpoint="unmatched"` to keep cardinality low +- The implementation uses `status_code`, not `status` + +That last point matters because some of the lab examples use `status`, but this service exports `status_code`. + +### Metrics vs Logs + +Metrics and logs solve different problems. + +- Metrics answer trend questions quickly: request rate, latency, error rate, uptime +- Logs answer forensic questions: which request failed, what stack trace occurred, what client sent the request +- Lab 7 kept Loki + Promtail for logs +- Lab 8 adds Prometheus for numeric time-series monitoring + +In practice: + +- Use metrics for dashboards, SLO-style views, and alert conditions +- Use logs for debugging after a metric tells you something is wrong + +## Task 2 — Prometheus Setup + +### Architecture + +```mermaid +flowchart TD + A[app-python:8000
/metrics] + P[prometheus:9090
TSDB + retention] + G[grafana:3000
dashboards + panels] + PT[promtail] + L[loki] + + A -- scrape every 15s --> P + P -- query / visualize --> G + + A -- stdout logs --> PT + PT --> L + L --> G +``` + +### Prometheus Configuration + +Prometheus was added to the existing monitoring stack in `monitoring/docker-compose.yml` and configured with `monitoring/prometheus/prometheus.yml`. + +Current scrape targets: + +- `prometheus` -> `localhost:9090` +- `app` -> `app-python:8000/metrics` +- `loki` -> `loki:3100/metrics` +- `grafana` -> `grafana:3000/metrics` + +Current scrape/evaluation interval: + +- `15s` + +### Task 2 Commands Used + +```bash +PS1="$ " +cd monitoring +docker compose up -d +docker compose ps | tee /tmp/lab08_task2_compose_ps.txt +curl -fSs http://127.0.0.1:9090/api/v1/targets \ + | jq '{status, data: {activeTargets: [.data.activeTargets[] | {labels, scrapeUrl, lastError, health}]}}' \ + | tee /tmp/lab08_task2_targets_final.json +curl -fSsG --data-urlencode 'query=up' http://127.0.0.1:9090/api/v1/query \ + | jq '{status, data: {resultType: .data.resultType, result: .data.result}}' \ + | tee /tmp/lab08_task2_up_final.json +``` + +### Task 2 Evidence + +Prometheus target screenshot: + +![](img/lab08_task2_targets.png) + +PromQL `up` screenshot: + +![](img/lab08_task2_up_query.png) + +
+/api/v1/targets output + +```json +$ curl -fSs http://127.0.0.1:9090/api/v1/targets | jq '{status, data: {activeTargets: [.data.activeTargets[] | {labels, scrapeUrl, lastError, health}]}}' | tee /tmp/lab08_task2_targets_final.json +{ + "status": "success", + "data": { + "activeTargets": [ + { + "labels": { + "instance": "app-python:8000", + "job": "app" + }, + "scrapeUrl": "http://app-python:8000/metrics", + "lastError": "", + "health": "up" + }, + { + "labels": { + "instance": "grafana:3000", + "job": "grafana" + }, + "scrapeUrl": "http://grafana:3000/metrics", + "lastError": "", + "health": "up" + }, + { + "labels": { + "instance": "loki:3100", + "job": "loki" + }, + "scrapeUrl": "http://loki:3100/metrics", + "lastError": "", + "health": "up" + }, + { + "labels": { + "instance": "localhost:9090", + "job": "prometheus" + }, + "scrapeUrl": "http://localhost:9090/metrics", + "lastError": "", + "health": "up" + } + ] + } +} +``` + +
+ +
+query=up output + +```json +$ curl -fSsG --data-urlencode 'query=up' http://127.0.0.1:9090/api/v1/query | jq '{status, data: {resultType: .data.resultType, result: .data.result}}' | tee /tmp/lab08_task2_up_final.json +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "up", + "instance": "grafana:3000", + "job": "grafana" + }, + "value": [ + 1773967701.736, + "1" + ] + }, + { + "metric": { + "__name__": "up", + "instance": "localhost:9090", + "job": "prometheus" + }, + "value": [ + 1773967701.736, + "1" + ] + }, + { + "metric": { + "__name__": "up", + "instance": "app-python:8000", + "job": "app" + }, + "value": [ + 1773967701.736, + "1" + ] + }, + { + "metric": { + "__name__": "up", + "instance": "loki:3100", + "job": "loki" + }, + "value": [ + 1773967701.736, + "1" + ] + } + ] + } +} +``` + +
+ +## Task 3 — Grafana Dashboards + +### Prometheus Data Source + +The Prometheus data source was added in Grafana with: + +- URL: `http://prometheus:9090` +- access mode: proxy + +### Dashboard Walkthrough + +The exported dashboard is stored in `monitoring/grafana/dashbboard.json`. + +Panels in the current dashboard: + +- `Status Code Distribution` + - Type: `piechart` + - Query: `sum by (status_code) (rate(http_requests_total[5m]))` + - Purpose: show 2xx/4xx/5xx mix +- `Uptime` + - Type: `stat` + - Query: `up{job="app"}` + - Purpose: show whether the app is scrapeable +- `Active Requests` + - Type: `timeseries` + - Query: `http_requests_in_progress` + - Purpose: show in-flight request concurrency +- `Error Rate` + - Type: `timeseries` + - Query: `sum(rate(http_requests_total{status_code=~"5.."}[5m]))` + - Purpose: highlight 5xx traffic +- `Request Rate` + - Type: `timeseries` + - Query: `sum(rate(http_requests_total[5m])) by (endpoint)` + - Purpose: show throughput per endpoint +- `Request Duration p95` + - Type: `timeseries` + - Query: `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))` + - Purpose: track latency percentile +- `Request Duration Heatmap` + - Type: `heatmap` + - Query: `rate(http_request_duration_seconds_bucket[5m])` + - Purpose: visualize latency distribution + +### Task 3 Notes + +Two corrections were made to the exported JSON during the final review: + +- `status` was replaced with `status_code` +- `Request Duration p95` was corrected from `heatmap` to `timeseries` + +These changes align the dashboard with the actual metric schema emitted by the Python app. + +### Task 3 Commands Used + +```bash +PS1="$ " +cd monitoring +jq '{uid, title, panels: [.panels[] | {title, type, expr: .targets[0].expr}]}' monitoring/grafana/dashbboard.json \ + | tee /tmp/lab08_task3_dashboard_summary.json +curl -fSsG --data-urlencode 'query=http_requests_total' http://127.0.0.1:9090/api/v1/query \ + | jq '{status, data: {resultType: .data.resultType, resultCount: (.data.result | length), result: .data.result[0:4]}}' \ + | tee /tmp/lab08_task3_requests_total.json +``` + +### Task 3 Evidence + +Custom dashboard screenshot: + +![](img/lab08_task3_custom_dashboard.png) + +
+dashboard export summary output + +```json +$ jq '{uid, title, panels: [.panels[] | {title, type, expr: .targets[0].expr}]}' monitoring/grafana/dashbboard.json | tee /tmp/lab08_task3_dashboard_summary.json +{ + "uid": "adksq66", + "title": "Custom", + "panels": [ + { + "title": "Status Code Distribution", + "type": "piechart", + "expr": "sum by (status_code) (rate(http_requests_total[5m]))" + }, + { + "title": "Uptime", + "type": "stat", + "expr": "up{job=\"app\"}" + }, + { + "title": "Active Requests", + "type": "timeseries", + "expr": "http_requests_in_progress" + }, + { + "title": "Error Rate", + "type": "timeseries", + "expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m]))" + }, + { + "title": "Request Rate", + "type": "timeseries", + "expr": "sum(rate(http_requests_total[5m])) by (endpoint)" + }, + { + "title": "Request Duration p95", + "type": "timeseries", + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))" + }, + { + "title": "Request Duration Heatmap", + "type": "heatmap", + "expr": "rate(http_request_duration_seconds_bucket[5m])" + } + ] +} +``` + +
+ +
+http_requests_total query output + +```json +$ curl -fSsG --data-urlencode 'query=http_requests_total' http://127.0.0.1:9090/api/v1/query | jq '{status, data: {resultType: .data.resultType, resultCount: (.data.result | length), result: .data.result[0:4]}}' | tee /tmp/lab08_task3_requests_total.json +{ + "status": "success", + "data": { + "resultType": "vector", + "resultCount": 4, + "result": [ + { + "metric": { + "__name__": "http_requests_total", + "endpoint": "/health", + "instance": "app-python:8000", + "job": "app", + "method": "GET", + "status_code": "200" + }, + "value": [ + 1773967701.768, + "9" + ] + }, + { + "metric": { + "__name__": "http_requests_total", + "endpoint": "/metrics", + "instance": "app-python:8000", + "job": "app", + "method": "GET", + "status_code": "200" + }, + "value": [ + 1773967701.768, + "8" + ] + }, + { + "metric": { + "__name__": "http_requests_total", + "endpoint": "/metrics", + "instance": "app-python:8000", + "job": "app", + "method": "HEAD", + "status_code": "200" + }, + "value": [ + 1773967701.768, + "1" + ] + }, + { + "metric": { + "__name__": "http_requests_total", + "endpoint": "/", + "instance": "app-python:8000", + "job": "app", + "method": "GET", + "status_code": "200" + }, + "value": [ + 1773967701.768, + "5" + ] + } + ] + } +} +``` + +
+ +## Task 4 — Production Configuration + +### Health Checks + +The stack now includes production-style health checks for the services that can reasonably self-test. + +- `loki` + - endpoint: `http://127.0.0.1:3100/ready` +- `grafana` + - endpoint: `http://127.0.0.1:3000/api/health` +- `prometheus` + - endpoint: `http://127.0.0.1:9090/-/healthy` +- `promtail` + - endpoint: `http://127.0.0.1:9080/ready` + - implemented with `bash` + `/dev/tcp` because this image does not include `wget` or `curl` +- `app-python` + - endpoint: `http://127.0.0.1:8000/health` +- `app-go` + - monitored by the existing `app-go-healthcheck` helper container + - reason: the Go image is built `FROM scratch`, so it cannot run an in-container shell-based HTTP probe + +### Resource Limits + +Configured limits in `monitoring/docker-compose.yml`: + +- Prometheus: `1.0` CPU, `1G` memory +- Loki: `1.0` CPU, `1G` memory +- Grafana: `0.5` CPU, `512M` memory +- app-python: `0.5` CPU, `256M` memory +- app-go: `0.5` CPU, `256M` memory +- Promtail: `0.5` CPU, `256M` memory + +### Retention and Persistence + +Prometheus retention is enforced through container flags: + +- `--storage.tsdb.retention.time=15d` +- `--storage.tsdb.retention.size=10GB` + +Persistent volumes in the stack: + +- `prometheus-data` +- `loki-data` +- `grafana-data` +- `promtail-data` + +### Task 4 Commands Used + +```bash +PS1="$ " +cd monitoring +set -a && source .env +curl -fSs -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" 'http://127.0.0.1:3000/api/search?query=Custom' \ + | jq '{count: length, dashboards: [.[] | {uid, title, url}]}' \ + | tee /tmp/lab08_task4_grafana_before.json +docker compose down +docker compose up -d +docker compose ps | tee /tmp/lab08_task4_compose_ps_final.txt +curl -fSs -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" 'http://127.0.0.1:3000/api/search?query=Custom' \ + | jq '{count: length, dashboards: [.[] | {uid, title, url}]}' \ + | tee /tmp/lab08_task4_grafana_after.json +docker inspect monitoring-prometheus-1 --format '{{json .Config.Healthcheck.Test}} {{json .Config.Cmd}}' \ + | tee /tmp/lab08_task4_prometheus_inspect.txt +``` + +### Task 4 Evidence + +
+docker compose ps after restart + +```text +$ docker compose ps | tee /tmp/lab08_task4_compose_ps_final.txt +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +monitoring-app-go-1 localt0aster/devops-app-go:1.7.9a42ee5 "/devops-info-servic…" app-go 2 minutes ago Up 2 minutes 0.0.0.0:8001->8001/tcp, [::]:8001->8001/tcp +monitoring-app-go-healthcheck-1 curlimages/curl:8.18.0 "/entrypoint.sh sh -…" app-go-healthcheck 2 minutes ago Up 2 minutes (healthy) +monitoring-app-python-1 localt0aster/devops-app-py:1.8.806c77e "sh -c 'gunicorn --c…" app-python 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp +monitoring-grafana-1 grafana/grafana:12.3.1 "/run.sh" grafana 2 minutes ago Up About a minute (healthy) 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp +monitoring-loki-1 grafana/loki:3.0.0 "/usr/bin/loki -conf…" loki 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:3100->3100/tcp, [::]:3100->3100/tcp +monitoring-prometheus-1 prom/prometheus:v3.9.0 "/bin/prometheus --c…" prometheus 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:9090->9090/tcp, [::]:9090->9090/tcp +monitoring-promtail-1 grafana/promtail:3.0.0 "/usr/bin/promtail -…" promtail 23 seconds ago Up 22 seconds (healthy) 0.0.0.0:9080->9080/tcp, [::]:9080->9080/tcp +``` + +
+ +
+Grafana dashboard inventory before restart + +```json +$ curl -fSs -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" 'http://127.0.0.1:3000/api/search?query=Custom' | jq '{count: length, dashboards: [.[] | {uid, title, url}]}' | tee /tmp/lab08_task4_grafana_before.json +{ + "count": 2, + "dashboards": [ + { + "uid": "adksq66", + "title": "Custom", + "url": "/d/adksq66/custom" + }, + { + "uid": "adksq661", + "title": "Custom2", + "url": "/d/adksq661/custom2" + } + ] +} +``` + +
+ +
+Grafana dashboard inventory after restart + +```json +$ curl -fSs -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" 'http://127.0.0.1:3000/api/search?query=Custom' | jq '{count: length, dashboards: [.[] | {uid, title, url}]}' | tee /tmp/lab08_task4_grafana_after.json +{ + "count": 2, + "dashboards": [ + { + "uid": "adksq66", + "title": "Custom", + "url": "/d/adksq66/custom" + }, + { + "uid": "adksq661", + "title": "Custom2", + "url": "/d/adksq661/custom2" + } + ] +} +``` + +
+ +
+Prometheus healthcheck and retention flags + +```text +$ docker inspect monitoring-prometheus-1 --format '{{json .Config.Healthcheck.Test}} {{json .Config.Cmd}}' | tee /tmp/lab08_task4_prometheus_inspect.txt +["CMD-SHELL","wget --no-verbose --tries=1 --spider http://127.0.0.1:9090/-/healthy || exit 1"] ["--config.file=/etc/prometheus/prometheus.yml","--storage.tsdb.retention.time=15d","--storage.tsdb.retention.size=10GB"] +``` + +
+ +### Persistence Result + +Dashboard persistence was confirmed because the same Grafana dashboard UIDs existed before and after `docker compose down` and `docker compose up -d`. + +## Task 5 — Final Documentation Pass + +### PromQL Examples + +The following queries match the actual exported label names: + +- `up{job="app"}` + - Is the Python app currently scrapeable? +- `sum(rate(http_requests_total[5m])) by (endpoint)` + - Requests per second per endpoint +- `sum(rate(http_requests_total{status_code=~"5.."}[5m]))` + - 5xx error rate +- `sum by (status_code) (rate(http_requests_total[5m]))` + - Status-code distribution for the pie chart +- `http_requests_in_progress` + - Current in-flight requests +- `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))` + - p95 latency estimate +- `devops_info_endpoint_calls_total` + - App-specific endpoint usage counter + +### Testing Results + +What was verified during this lab: + +- the Python app exposes `/metrics` +- Prometheus scrapes the app, Loki, Grafana, and itself successfully +- Grafana dashboard panels render live Prometheus data +- Prometheus retention flags are applied to the running container +- Grafana dashboards persist across `docker compose down` and `up -d` +- Promtail, Loki, Grafana, Prometheus, and the Python app all report healthy status after the final restart + +### Challenges and Solutions + +- Challenge: the branch image tag mattered because the older published Python image did not contain the new `/metrics` endpoint + - Solution: use `localt0aster/devops-app-py:1.8.806c77e` +- Challenge: the lab examples used `status`, while the implemented app uses `status_code` + - Solution: adapt the Grafana and PromQL queries to `status_code` +- Challenge: the Promtail image does not include `wget` or `curl` + - Solution: use a `bash` + `/dev/tcp` healthcheck against `/ready` +- Challenge: the Go service is built from `scratch`, so it cannot run a normal in-container HTTP healthcheck + - Solution: keep the dedicated `app-go-healthcheck` helper container diff --git a/monitoring/docs/img/lab08_task2_targets.png b/monitoring/docs/img/lab08_task2_targets.png new file mode 100644 index 0000000000..be3c369b91 Binary files /dev/null and b/monitoring/docs/img/lab08_task2_targets.png differ diff --git a/monitoring/docs/img/lab08_task2_up_query.png b/monitoring/docs/img/lab08_task2_up_query.png new file mode 100644 index 0000000000..dc281d37b6 Binary files /dev/null and b/monitoring/docs/img/lab08_task2_up_query.png differ diff --git a/monitoring/docs/img/lab08_task3_custom_dashboard.png b/monitoring/docs/img/lab08_task3_custom_dashboard.png new file mode 100644 index 0000000000..a018051d99 Binary files /dev/null and b/monitoring/docs/img/lab08_task3_custom_dashboard.png differ diff --git a/monitoring/docs/img/task2_apps.png b/monitoring/docs/img/task2_apps.png new file mode 100644 index 0000000000..7e12728522 Binary files /dev/null and b/monitoring/docs/img/task2_apps.png differ diff --git a/monitoring/docs/img/task2_get.png b/monitoring/docs/img/task2_get.png new file mode 100644 index 0000000000..b37d536989 Binary files /dev/null and b/monitoring/docs/img/task2_get.png differ diff --git a/monitoring/docs/img/task2_warn.png b/monitoring/docs/img/task2_warn.png new file mode 100644 index 0000000000..ad51dca168 Binary files /dev/null and b/monitoring/docs/img/task2_warn.png differ diff --git a/monitoring/docs/img/task3_panel.png b/monitoring/docs/img/task3_panel.png new file mode 100644 index 0000000000..e46e3e2667 Binary files /dev/null and b/monitoring/docs/img/task3_panel.png differ diff --git a/monitoring/docs/img/task4_password_required.png b/monitoring/docs/img/task4_password_required.png new file mode 100644 index 0000000000..0856c75782 Binary files /dev/null and b/monitoring/docs/img/task4_password_required.png differ diff --git a/monitoring/grafana/dashbboard.json b/monitoring/grafana/dashbboard.json new file mode 100644 index 0000000000..8300187931 --- /dev/null +++ b/monitoring/grafana/dashbboard.json @@ -0,0 +1,620 @@ +{ + "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": "afgj96bua3bpce" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "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": "afgj96bua3bpce" + }, + "editorMode": "code", + "expr": "sum by (status_code) (rate(http_requests_total[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Status Code Distribution", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "afgj96bua3bpce" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "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": "afgj96bua3bpce" + }, + "editorMode": "code", + "expr": "up{job=\"app\"}", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Uptime", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "afgj96bua3bpce" + }, + "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": 12, + "y": 0 + }, + "id": 5, + "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": "afgj96bua3bpce" + }, + "editorMode": "code", + "expr": "http_requests_in_progress", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Active Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "afgj96bua3bpce" + }, + "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": 8 + }, + "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": "afgj96bua3bpce" + }, + "editorMode": "code", + "expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Error Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "afgj96bua3bpce" + }, + "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": 12, + "y": 8 + }, + "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": "afgj96bua3bpce" + }, + "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": "afgj96bua3bpce" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "calculate": false, + "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 + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "afgj96bua3bpce" + }, + "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": "afgj96bua3bpce" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 4, + "options": { + "calculate": false, + "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 + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "afgj96bua3bpce" + }, + "editorMode": "code", + "expr": "rate(http_request_duration_seconds_bucket[5m])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Duration Heatmap", + "type": "heatmap" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Custom", + "uid": "adksq66", + "version": 10 +} diff --git a/monitoring/grafana/provisioning/datasources/loki.yml b/monitoring/grafana/provisioning/datasources/loki.yml new file mode 100644 index 0000000000..fba0b1b8e0 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/loki.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + editable: true diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000000..bf81bc7602 --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,42 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 168h + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + delete_request_store: filesystem diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000000..405abc0fdf --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,27 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: + - localhost:9090 + + - job_name: app + metrics_path: /metrics + static_configs: + - targets: + - app-python:8000 + + - job_name: loki + metrics_path: /metrics + static_configs: + - targets: + - loki:3100 + + - job_name: grafana + metrics_path: /metrics + static_configs: + - targets: + - grafana:3000 diff --git a/monitoring/promtail/config.yml b/monitoring/promtail/config.yml new file mode 100644 index 0000000000..0b58e710d6 --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,38 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /run/promtail/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 + filters: + - name: label + values: + - logging=promtail + relabel_configs: + - target_label: job + replacement: docker + - source_labels: + - __meta_docker_container_label_app + action: replace + regex: "(.+)" + replacement: "$1" + target_label: app + - source_labels: + - __meta_docker_container_name + regex: "/(.*)" + target_label: container + - source_labels: + - __meta_docker_container_label_com_docker_compose_service + target_label: compose_service + - source_labels: + - __meta_docker_container_log_stream + target_label: logstream diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 0000000000..a3807e5bdb --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,2 @@ +*.pyc +venv/ diff --git a/pulumi/Pulumi.dev.yaml.example b/pulumi/Pulumi.dev.yaml.example new file mode 100644 index 0000000000..aaf637b3de --- /dev/null +++ b/pulumi/Pulumi.dev.yaml.example @@ -0,0 +1,10 @@ +config: + lab04-local-docker:projectName: lab04-local + lab04-local-docker:vmUser: devops + lab04-local-docker:sshPublicKey: "ssh-ed25519 AAAA... your-user@host" + lab04-local-docker:sshPrivateKeyPath: "~/.ssh/id_ed25519" + lab04-local-docker:sshBindIp: 127.0.0.1 + lab04-local-docker:publicBindIp: 0.0.0.0 + lab04-local-docker:sshHostPort: "2222" + lab04-local-docker:httpHostPort: "80" + lab04-local-docker:appHostPort: "5000" diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..a4a92ce5d1 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,11 @@ +name: lab04-local-docker +description: IaC using Pulumi with local Docker provider. +runtime: + name: python + options: + toolchain: pip + virtualenv: venv +config: + pulumi:tags: + value: + pulumi:template: python diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..a26adf200d --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import pulumi +import pulumi_docker as docker + + +@dataclass(frozen=True) +class HostPorts: + ssh: int + http: int + app: int + + +config = pulumi.Config() + +project_name: str = config.get("projectName") or "lab04-local" +vm_user: str = config.get("vmUser") or "devops" +ssh_public_key: str = config.require("sshPublicKey") +ssh_private_key_path: str = config.get("sshPrivateKeyPath") or "~/.ssh/id_ed25519" +ssh_bind_ip: str = config.get("sshBindIp") or "127.0.0.1" +public_bind_ip: str = config.get("publicBindIp") or "0.0.0.0" + +ports = HostPorts( + ssh=config.get_int("sshHostPort") or 2222, + http=config.get_int("httpHostPort") or 80, + app=config.get_int("appHostPort") or 5000, +) + +labels: dict[str, str] = { + "lab": "04", + "managed-by": "pulumi", + "project": project_name, +} + +bootstrap_script = (Path(__file__).resolve().parent.parent / "docker" / "provision_vm.sh").read_text( + encoding="utf-8" +) + +network = docker.Network( + "lab04-net", + name=f"{project_name}-net", + labels=[docker.NetworkLabelArgs(label=k, value=v) for k, v in labels.items()], +) + +image = docker.RemoteImage( + "lab04-vm-image", + name="ubuntu:24.04", + keep_locally=True, +) + +container = docker.Container( + "lab04-vm", + name=f"{project_name}-vm", + image=image.repo_digest, + hostname=f"{project_name}-vm", + restart="unless-stopped", + command=["/bin/bash", "-lc", bootstrap_script], + envs=[f"VM_USER={vm_user}", f"SSH_PUBLIC_KEY={ssh_public_key}"], + ports=[ + docker.ContainerPortArgs(internal=22, external=ports.ssh, ip=ssh_bind_ip, protocol="tcp"), + docker.ContainerPortArgs(internal=80, external=ports.http, ip=public_bind_ip, protocol="tcp"), + docker.ContainerPortArgs(internal=5000, external=ports.app, ip=public_bind_ip, protocol="tcp"), + ], + labels=[docker.ContainerLabelArgs(label=k, value=v) for k, v in labels.items()], + networks_advanced=[ + docker.ContainerNetworksAdvancedArgs( + name=network.name, + aliases=[f"{project_name}-vm"], + ) + ], +) + +pulumi.export("vmName", container.name) +pulumi.export("networkName", network.name) +pulumi.export("publicIpEquivalent", "127.0.0.1") +pulumi.export("sshCommand", f"ssh -i {ssh_private_key_path} -p {ports.ssh} {vm_user}@127.0.0.1") +pulumi.export("containerShellCommand", f"docker exec -it {project_name}-vm /bin/bash") +pulumi.export("httpUrl", f"http://127.0.0.1:{ports.http}") +pulumi.export("appUrl", f"http://127.0.0.1:{ports.app}") diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..f5a5f5ebd4 --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi_docker>=4.0.0,<5.0.0 diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000000..7097fc4b2c --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,45 @@ +# Lab04 Terraform (Local Docker Provider) + +This Terraform project implements Lab04 with the local Docker provider instead of a cloud VM. + +## What It Creates + +- Docker network (`network/VPC` equivalent) +- Ubuntu 24.04 container (`VM/compute` equivalent) with startup bootstrap + - installs and starts `openssh-server` + - configures SSH authorized key + - starts simple HTTP endpoints on ports `80` and `5000` +- Port mappings as firewall equivalents: + - SSH: container `22` -> host `2222` (bound to `127.0.0.1`) + - HTTP: container `80` -> host `8080` + - App: container `5000` -> host `5000` + +## Local Prerequisites + +- Docker daemon running +- OpenTofu or Terraform CLI + +## Quick Start (OpenTofu) + +```bash +cp terraform/terraform.tfvars.example terraform/terraform.tfvars +# edit terraform.tfvars and set ssh_public_key + +cd terraform +tofu init -plugin-dir="$HOME/.terraform.d/plugins" +tofu plan +tofu apply -auto-approve + +# verify SSH +ssh -i ~/.ssh/id_ed25519 -p 2222 devops@127.0.0.1 'echo SSH_OK' +``` + +If provider download is blocked, manually place provider binaries under: +`~/.terraform.d/plugins/registry.terraform.io////linux_amd64/` + +## Destroy + +```bash +cd terraform +tofu destroy -auto-approve +``` diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..e5fc12074c --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,82 @@ +provider "docker" { + host = var.docker_host +} + +locals { + vm_name = "${var.project_name}-vm" + bootstrap_script = file("${path.module}/../docker/provision_vm.sh") + + default_labels = { + lab = "04" + managed-by = "terraform" + project = var.project_name + } + + resource_labels = merge(local.default_labels, var.extra_labels) +} + +resource "docker_network" "lab04" { + name = "${var.project_name}-net" + + dynamic "labels" { + for_each = local.resource_labels + + content { + label = labels.key + value = labels.value + } + } +} + +resource "docker_image" "vm_image" { + name = "ubuntu:24.04" + keep_locally = true +} + +resource "docker_container" "vm" { + name = local.vm_name + image = docker_image.vm_image.image_id + hostname = local.vm_name + restart = "unless-stopped" + command = ["/bin/bash", "-lc", local.bootstrap_script] + + env = [ + "VM_USER=${var.vm_user}", + "SSH_PUBLIC_KEY=${var.ssh_public_key}", + ] + + networks_advanced { + name = docker_network.lab04.name + aliases = [local.vm_name] + } + + ports { + internal = 22 + external = var.ssh_host_port + ip = var.ssh_bind_ip + protocol = "tcp" + } + + ports { + internal = 80 + external = var.http_host_port + ip = var.public_bind_ip + protocol = "tcp" + } + + ports { + internal = 5000 + external = var.app_host_port + ip = var.public_bind_ip + protocol = "tcp" + } + + dynamic "labels" { + for_each = local.resource_labels + + content { + label = labels.key + value = labels.value + } + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..d8130abbce --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,39 @@ +output "vm_name" { + description = "Name of the VM-like Docker container." + value = docker_container.vm.name +} + +output "network_name" { + description = "Name of the Docker network (VPC equivalent)." + value = docker_network.lab04.name +} + +output "container_ip" { + description = "Container IP inside the Docker network." + value = one(docker_container.vm.network_data).ip_address +} + +output "public_ip_equivalent" { + description = "Host endpoint used as public access in the local provider setup." + value = "127.0.0.1" +} + +output "ssh_command" { + description = "SSH command for the VM-like container." + value = "ssh -i ${var.ssh_private_key_path} -p ${var.ssh_host_port} ${var.vm_user}@127.0.0.1" +} + +output "container_shell_command" { + description = "Direct shell access without SSH." + value = "docker exec -it ${docker_container.vm.name} /bin/bash" +} + +output "http_url" { + description = "HTTP endpoint (port 80 equivalent)." + value = "http://127.0.0.1:${var.http_host_port}" +} + +output "app_url" { + description = "Application endpoint (port 5000 equivalent)." + value = "http://127.0.0.1:${var.app_host_port}" +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000000..2642161e51 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,13 @@ +ssh_public_key = "ssh-ed25519 AAAA... your-user@host" + +# Optional overrides: +# vm_user = "devops" +# ssh_private_key_path = "~/.ssh/id_ed25519" +# ssh_host_port = 2222 +# http_host_port = 80 +# app_host_port = 5000 +# ssh_bind_ip = "127.0.0.1" +# public_bind_ip = "0.0.0.0" +# extra_labels = { +# owner = "your-name" +# } diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..d6ae2bfd65 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,80 @@ +variable "docker_host" { + description = "Docker daemon socket." + type = string + default = "unix:///var/run/docker.sock" +} + +variable "project_name" { + description = "Prefix used for Docker resource names." + type = string + default = "lab04-local" +} + +variable "vm_user" { + description = "Linux username created inside the VM-like container for SSH access." + type = string + default = "devops" +} + +variable "ssh_public_key" { + description = "SSH public key allowed to access the VM-like container." + type = string + sensitive = true +} + +variable "ssh_private_key_path" { + description = "Private key path used in the rendered SSH command output." + type = string + default = "~/.ssh/id_ed25519" +} + +variable "ssh_bind_ip" { + description = "Host IP used for SSH binding. Keep 127.0.0.1 to restrict access." + type = string + default = "127.0.0.1" +} + +variable "public_bind_ip" { + description = "Host IP used for HTTP and app ports." + type = string + default = "0.0.0.0" +} + +variable "ssh_host_port" { + description = "Host port mapped to container port 22." + type = number + default = 2222 + + validation { + condition = var.ssh_host_port >= 1 && var.ssh_host_port <= 65535 + error_message = "ssh_host_port must be between 1 and 65535." + } +} + +variable "http_host_port" { + description = "Host port mapped to container port 80." + type = number + default = 80 + + validation { + condition = var.http_host_port >= 1 && var.http_host_port <= 65535 + error_message = "http_host_port must be between 1 and 65535." + } +} + +variable "app_host_port" { + description = "Host port mapped to container port 5000." + type = number + default = 5000 + + validation { + condition = var.app_host_port >= 1 && var.app_host_port <= 65535 + error_message = "app_host_port must be between 1 and 65535." + } +} + +variable "extra_labels" { + description = "Additional Docker labels to attach to resources." + type = map(string) + default = {} +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000000..db6d92cec4 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.9.0" + + required_providers { + docker = { + source = "registry.terraform.io/kreuzwerker/docker" + version = "~> 3.6" + } + } +} diff --git a/vagrant/.gitignore b/vagrant/.gitignore new file mode 100644 index 0000000000..f6183f774c --- /dev/null +++ b/vagrant/.gitignore @@ -0,0 +1,12 @@ + +# General +.vagrant/ + +# Log files (if you are creating logs in debug mode, uncomment this) +# *.log +shared/** +!shared/provision.sh +!shared/provision-post-kernel.sh +!shared/provision-gh-runner.sh +!shared/provision-gh-runner-register.sh +!shared/github-runner.env.example diff --git a/vagrant/README.md b/vagrant/README.md new file mode 100644 index 0000000000..1b44c25725 --- /dev/null +++ b/vagrant/README.md @@ -0,0 +1,150 @@ +# Vagrant (libvirt) + +This Vagrant setup is configured for the `libvirt` provider and uses: + +- Box: `alvistack/ubuntu-24.04` +- Machines: + - `default` — main lab VM / deployment target + - `github-runner` — isolated self-hosted GitHub Actions runner VM + +## Requirements + +- `vagrant` +- `libvirt` + `qemu`/`kvm` +- Vagrant plugin: `vagrant-libvirt` + +## Usage + +From repository root: + +```bash +cd vagrant +vagrant plugin install vagrant-libvirt +vagrant up +vagrant ssh +``` + +`vagrant up` automatically runs `shared/provision.sh` as root. +To re-run provisioning on an existing VM: + +```bash +vagrant provision +``` + +The `github-runner` machine is defined with `autostart: false`, so it will not +come up unless you ask for it explicitly: + +```bash +cd vagrant +vagrant up github-runner +vagrant ssh github-runner +``` + +Provisioning is split into two stages: + +- `shared/provision.sh` (kernel/package update stage) +- automatic reboot between stages +- `shared/provision-post-kernel.sh` (post-kernel stage with ansible install) +- `shared/provision-gh-runner.sh` (runner VM only; installs the GitHub runner binaries) +- `shared/provision-gh-runner-register.sh` (runner VM only; manual registration step) + +SSH key setup: + +- host private key path: `~/.ssh/vagrant` +- host public key path: `~/.ssh/vagrant.pub` +- public key is added to `/home/vagrant/.ssh/authorized_keys` during provisioning + +Static VM IP: + +- default: `192.168.121.50` +- override: `VM_IP=192.168.121.60 vagrant up` +- runner default: `192.168.121.51` +- runner override: `RUNNER_VM_IP=192.168.121.61 vagrant up github-runner` + +This setup uses: + +- management NIC (`eth0`, DHCP) for Vagrant internals +- static NIC (`eth1`, fixed IP above) for your direct SSH usage + +If the VM already existed before static IP was added, recreate it once: + +```bash +vagrant destroy -f +vagrant up --provider=libvirt +``` + +`~/.ssh/config` example: + +```sshconfig +Host vagrant + HostName 192.168.121.50 + User vagrant + IdentityFile ~/.ssh/vagrant +``` + +## GitHub Runner VM + +The runner is intentionally isolated in its own VM. That keeps the deployment +target and the CI runner state separate. + +Bring up the runner VM: + +```bash +cd vagrant +vagrant up github-runner +``` + +Base runner provisioning installs: + +- `ansible` +- `git`, `curl`, `jq`, `tar`, `unzip` +- the GitHub Actions runner under `/opt/actions-runner` +- a dedicated `github-runner` user + +Registration is a separate manual step because GitHub registration tokens are +short-lived. + +1. Copy the example environment file: + +```bash +cp shared/github-runner.env.example shared/github-runner.env +``` + +2. Edit `shared/github-runner.env` and fill in: + +- `GH_RUNNER_URL` +- `GH_RUNNER_API_TOKEN` or `GH_RUNNER_TOKEN` +- optional runner name / labels / group / workdir + +`GH_RUNNER_API_TOKEN` is the safer option because the provisioner will exchange it for a fresh one-hour runner registration token every time it runs. For a repository runner, GitHub's REST API requires a token that can create registration tokens for that repository. For a fine-grained PAT, that means repository `Administration: write`. A manually copied `GH_RUNNER_TOKEN` still works, but it expires after one hour and must be refreshed before provisioning. + +> ❗ If the runner VM is already running, don't forget to sync the updated shared files into the guest: +> +> ```bash +> vagrant rsync github-runner +> ``` + +3. Run the registration provisioner: + +```bash +vagrant provision github-runner --provision-with github-runner-register +``` + +You can also pass the same values through host environment variables instead of +using `shared/github-runner.env`. + +Useful commands: + +```bash +vagrant ssh github-runner +sudo systemctl list-units 'actions.runner.*' +sudo systemctl status 'actions.runner.*' +``` + +Notes: + +- The registration step is idempotent for an already configured runner; it + starts the service again if needed. +- The runner VM does not install Docker by default. For this lab's Ansible + workflow, that is sufficient. If you later add container actions, install + Docker on the runner VM as well. diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile new file mode 100644 index 0000000000..80152c12e6 --- /dev/null +++ b/vagrant/Vagrantfile @@ -0,0 +1,102 @@ +Vagrant.configure("2") do |config| + box_name = "alvistack/ubuntu-24.04" + target_vm_ip = ENV.fetch("VM_IP", "192.168.121.50") + runner_vm_ip = ENV.fetch("RUNNER_VM_IP", "192.168.121.51") + + host_ssh_key = File.expand_path("~/.ssh/vagrant") + host_ssh_pub = "#{host_ssh_key}.pub" + fallback_ssh_key = File.expand_path("~/.vagrant.d/insecure_private_key") + + runner_env = { + "GH_RUNNER_VERSION" => ENV.fetch("GH_RUNNER_VERSION", ""), + "GH_RUNNER_URL" => ENV.fetch("GH_RUNNER_URL", ""), + "GH_RUNNER_TOKEN" => ENV.fetch("GH_RUNNER_TOKEN", ""), + "GH_RUNNER_API_TOKEN" => ENV.fetch("GH_RUNNER_API_TOKEN", ""), + "GH_RUNNER_API_URL" => ENV.fetch("GH_RUNNER_API_URL", ""), + "GH_RUNNER_NAME" => ENV.fetch("GH_RUNNER_NAME", ""), + "GH_RUNNER_LABELS" => ENV.fetch("GH_RUNNER_LABELS", ""), + "GH_RUNNER_GROUP" => ENV.fetch("GH_RUNNER_GROUP", ""), + "GH_RUNNER_WORKDIR" => ENV.fetch("GH_RUNNER_WORKDIR", ""), + "GH_RUNNER_DISABLE_UPDATE" => ENV.fetch("GH_RUNNER_DISABLE_UPDATE", "") + } + + configure_common_vm = lambda do |machine, hostname:, ip:, cpus:, memory:| + machine.vm.box = box_name + machine.vm.box_check_update = false + machine.vm.hostname = hostname + + machine.ssh.insert_key = false + machine.ssh.private_key_path = [host_ssh_key, fallback_ssh_key] + + machine.vm.synced_folder ".", "/vagrant", disabled: true + machine.vm.synced_folder "./shared", "/shared" + machine.vm.network "private_network", ip: ip + + if File.exist?(host_ssh_pub) + machine.vm.provision "file", source: host_ssh_pub, destination: "/tmp/vagrant.pub" + machine.vm.provision "shell", + name: "install-host-ssh-key", + inline: <<-SHELL + set -euo pipefail + if [ ! -f /tmp/vagrant.pub ]; then + echo "Skipping host SSH key install: /tmp/vagrant.pub not found." + exit 0 + fi + install -d -m 700 /home/vagrant/.ssh + touch /home/vagrant/.ssh/authorized_keys + pub_key="$(cat /tmp/vagrant.pub)" + grep -qxF "$pub_key" /home/vagrant/.ssh/authorized_keys || echo "$pub_key" >> /home/vagrant/.ssh/authorized_keys + chown -R vagrant:vagrant /home/vagrant/.ssh + chmod 700 /home/vagrant/.ssh + chmod 600 /home/vagrant/.ssh/authorized_keys + rm -f /tmp/vagrant.pub + SHELL + end + + machine.vm.provider :libvirt do |libvirt| + libvirt.cpus = cpus.to_i + libvirt.memory = memory.to_i + end + end + + config.vm.define "default", primary: true do |target| + configure_common_vm.call( + target, + hostname: "devops-core-s26", + ip: target_vm_ip, + cpus: ENV.fetch("VM_CPUS", "2"), + memory: ENV.fetch("VM_MEMORY", "2048") + ) + + target.vm.provision "shell", + name: "kernel-update", + path: "shared/provision.sh", + reboot: true + target.vm.provision "shell", name: "post-kernel", path: "shared/provision-post-kernel.sh" + end + + config.vm.define "github-runner", autostart: false do |runner| + configure_common_vm.call( + runner, + hostname: "github-runner-s26", + ip: runner_vm_ip, + cpus: ENV.fetch("RUNNER_VM_CPUS", "2"), + memory: ENV.fetch("RUNNER_VM_MEMORY", "2048") + ) + + runner.vm.provision "shell", + name: "kernel-update", + path: "shared/provision.sh", + reboot: true + runner.vm.provision "shell", name: "post-kernel", path: "shared/provision-post-kernel.sh" + runner.vm.provision "shell", + name: "github-runner-base", + path: "shared/provision-gh-runner.sh", + env: runner_env + runner.vm.provision "shell", + name: "github-runner-register", + path: "shared/provision-gh-runner-register.sh", + env: runner_env, + run: "never" + end +end diff --git a/vagrant/shared/github-runner.env.example b/vagrant/shared/github-runner.env.example new file mode 100644 index 0000000000..4e1c1b215f --- /dev/null +++ b/vagrant/shared/github-runner.env.example @@ -0,0 +1,26 @@ +# Copy this file to shared/github-runner.env and fill in the values before +# running: +# vagrant provision github-runner --provision-with github-runner-register + +GH_RUNNER_URL="https://github.com/OWNER/REPOSITORY" + +# Preferred: a GitHub API token that can mint a fresh registration token during +# provisioning. For repository runners on github.com, a fine-grained PAT needs +# repository Administration: write. For organization runners, it needs +# organization Self-hosted runners: write. +# GH_RUNNER_API_TOKEN="github_pat_..." + +# Optional: override the API root for GitHub Enterprise Server instances. +# GH_RUNNER_API_URL="https://github.example.com/api/v3" + +# Fallback: a time-limited registration token copied from GitHub. This expires +# after one hour, so stale values will fail during runner registration. +GH_RUNNER_TOKEN="" + +# Optional overrides +GH_RUNNER_NAME="github-runner-s26" +GH_RUNNER_LABELS="self-hosted,linux,vagrant" +GH_RUNNER_GROUP="Default" +GH_RUNNER_WORKDIR="_work" +# GH_RUNNER_VERSION="PIN_A_KNOWN_RUNNER_VERSION" +# GH_RUNNER_DISABLE_UPDATE="true" diff --git a/vagrant/shared/provision-gh-runner-register.sh b/vagrant/shared/provision-gh-runner-register.sh new file mode 100644 index 0000000000..ac29aecad1 --- /dev/null +++ b/vagrant/shared/provision-gh-runner-register.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Please run this script as root or using sudo!" + exit 13 +fi + +if [ -f /shared/github-runner.env ]; then + set -a + # shellcheck disable=SC1091 + . /shared/github-runner.env + set +a +fi + +if [ ! -f /shared/github-runner.env ] && [ -z "${GH_RUNNER_URL:-}" ] && [ -z "${GH_RUNNER_TOKEN:-}" ] && [ -z "${GH_RUNNER_API_TOKEN:-}" ]; then + cat <<'EOF' >&2 +Runner configuration is missing inside the guest. +If you created vagrant/shared/github-runner.env on the host after the VM was already running, +sync it first with: + vagrant rsync github-runner +Or bypass the shared file and pass GH_RUNNER_URL plus GH_RUNNER_API_TOKEN or GH_RUNNER_TOKEN in the host environment. +EOF +fi + +: "${GH_RUNNER_URL:?Set GH_RUNNER_URL in /shared/github-runner.env or the host environment.}" + +if [ -z "${GH_RUNNER_TOKEN:-}" ] && [ -z "${GH_RUNNER_API_TOKEN:-}" ]; then + echo "Set GH_RUNNER_API_TOKEN (preferred) or GH_RUNNER_TOKEN in /shared/github-runner.env or the host environment." >&2 + exit 1 +fi + +if [ ! -x /opt/actions-runner/config.sh ]; then + echo "GitHub runner is not installed. Run the base provisioner first." >&2 + exit 1 +fi + +runner_url="${GH_RUNNER_URL%/}" +case "$runner_url" in + https://*) + runner_scheme="https" + ;; + http://*) + runner_scheme="http" + ;; + *) + echo "GH_RUNNER_URL must start with http:// or https:// and point to a repository or organization root." >&2 + exit 1 + ;; +esac + +runner_url_no_scheme="${runner_url#*://}" +runner_host="${runner_url_no_scheme%%/*}" +runner_path="" +if [ "$runner_url_no_scheme" != "$runner_host" ]; then + runner_path="/${runner_url_no_scheme#*/}" +fi +runner_path="${runner_path%%\?*}" +runner_path="${runner_path%%\#*}" +runner_path="${runner_path%/}" + +IFS='/' read -r runner_segment1 runner_segment2 runner_segment3 _runner_extra <<< "${runner_path#/}" + +if [ -z "${runner_segment1:-}" ]; then + echo "GH_RUNNER_URL must point to a repository or organization root, for example https://github.com/OWNER/REPOSITORY." >&2 + exit 1 +fi + +if [ -n "${runner_segment3:-}" ] || [ -n "${_runner_extra:-}" ]; then + echo "GH_RUNNER_URL must be a repository or organization root URL, not a deeper settings page." >&2 + exit 1 +fi + +runner_scope="organization" +runner_owner="$runner_segment1" +runner_repo="" +if [ -n "${runner_segment2:-}" ]; then + runner_scope="repository" + runner_repo="${runner_segment2%.git}" +fi + +runner_api_base="${GH_RUNNER_API_URL:-}" +if [ -z "$runner_api_base" ]; then + if [ "$runner_host" = "github.com" ]; then + runner_api_base="https://api.github.com" + else + runner_api_base="${runner_scheme}://${runner_host}/api/v3" + fi +fi +runner_api_base="${runner_api_base%/}" + +create_registration_token() { + local endpoint response http_code body token expires_at message + + if [ -n "${GH_RUNNER_API_TOKEN:-}" ]; then + case "$runner_scope" in + repository) + endpoint="${runner_api_base}/repos/${runner_owner}/${runner_repo}/actions/runners/registration-token" + ;; + organization) + endpoint="${runner_api_base}/orgs/${runner_owner}/actions/runners/registration-token" + ;; + *) + echo "Unsupported runner scope: $runner_scope" >&2 + exit 1 + ;; + esac + + response="$( + curl -sS -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_RUNNER_API_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$endpoint" \ + -w $'\n%{http_code}' + )" + + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + message="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + echo "Failed to create a runner registration token from ${endpoint} (HTTP ${http_code})." >&2 + if [ -n "$message" ]; then + echo "GitHub API message: ${message}" >&2 + fi + echo "Check that GH_RUNNER_API_TOKEN has admin access to the target and the required runner permissions." >&2 + exit 1 + fi + + token="$(printf '%s' "$body" | jq -r '.token // empty')" + expires_at="$(printf '%s' "$body" | jq -r '.expires_at // empty')" + if [ -z "$token" ]; then + echo "GitHub did not return a runner registration token." >&2 + exit 1 + fi + + export RUNNER_TOKEN="$token" + if [ -n "$expires_at" ]; then + echo "Generated a fresh runner registration token via the GitHub API (expires at ${expires_at})." + else + echo "Generated a fresh runner registration token via the GitHub API." + fi + return 0 + fi + + export RUNNER_TOKEN="${GH_RUNNER_TOKEN}" + echo "Using GH_RUNNER_TOKEN from the environment. GitHub runner registration tokens expire after one hour." +} + +runner_name="${GH_RUNNER_NAME:-$(hostname -s)}" +runner_labels="${GH_RUNNER_LABELS:-self-hosted,linux,vagrant}" +runner_group="${GH_RUNNER_GROUP:-Default}" +runner_workdir="${GH_RUNNER_WORKDIR:-_work}" +runner_disable_update="${GH_RUNNER_DISABLE_UPDATE:-false}" + +export RUNNER_URL="$GH_RUNNER_URL" +export RUNNER_NAME="$runner_name" +export RUNNER_LABELS="$runner_labels" +export RUNNER_GROUP="$runner_group" +export RUNNER_WORKDIR="$runner_workdir" +export RUNNER_DISABLE_UPDATE="$runner_disable_update" + +if [ ! -f /opt/actions-runner/.runner ]; then + create_registration_token + sudo -u github-runner --preserve-env=RUNNER_URL,RUNNER_TOKEN,RUNNER_NAME,RUNNER_LABELS,RUNNER_GROUP,RUNNER_WORKDIR,RUNNER_DISABLE_UPDATE bash <<'EOF' +set -euo pipefail +cd /opt/actions-runner + +config_args=( + ./config.sh + --unattended + --url "$RUNNER_URL" + --token "$RUNNER_TOKEN" + --name "$RUNNER_NAME" + --labels "$RUNNER_LABELS" + --work "$RUNNER_WORKDIR" + --replace +) + +if [ "$RUNNER_GROUP" != "Default" ]; then + config_args+=(--runnergroup "$RUNNER_GROUP") +fi + +if [ "$RUNNER_DISABLE_UPDATE" = "true" ]; then + config_args+=(--disableupdate) +fi + +"${config_args[@]}" +EOF +fi + +install -d /etc/needrestart/conf.d +cat <<'EOF' > /etc/needrestart/conf.d/actions_runner_services.conf +$nrconf{override_rc}{qr(^actions\.runner\..+\.service$)} = 0; +EOF + +if ! compgen -G "/etc/systemd/system/actions.runner.*.service" >/dev/null; then + ( + cd /opt/actions-runner + ./svc.sh install github-runner + ) +fi + +systemctl daemon-reload + +service_units=(/etc/systemd/system/actions.runner.*.service) +if [ "${service_units[0]}" = "/etc/systemd/system/actions.runner.*.service" ]; then + echo "Runner service unit was not created." >&2 + exit 1 +fi + +for service_unit in "${service_units[@]}"; do + systemctl enable --now "$(basename "$service_unit")" +done + +echo "GitHub runner configured and started successfully." +# diff --git a/vagrant/shared/provision-gh-runner.sh b/vagrant/shared/provision-gh-runner.sh new file mode 100644 index 0000000000..0e58c5c2d2 --- /dev/null +++ b/vagrant/shared/provision-gh-runner.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Please run this script as root or using sudo!" + exit 13 +fi + +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + jq \ + tar \ + unzip + +if ! id -u github-runner >/dev/null 2>&1; then + useradd --create-home --home-dir /home/github-runner --shell /bin/bash github-runner +fi + +install -d -o github-runner -g github-runner /opt/actions-runner +install -d -o github-runner -g github-runner /opt/actions-runner/_work + +runner_version="${GH_RUNNER_VERSION:-}" +if [ -z "$runner_version" ]; then + runner_version="$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name | ltrimstr("v")')" +fi + +runner_arch="$(dpkg --print-architecture)" +case "$runner_arch" in + amd64) + runner_arch="x64" + ;; + arm64) + runner_arch="arm64" + ;; + *) + echo "Unsupported runner architecture: $runner_arch" >&2 + exit 1 + ;; +esac + +install_marker="/opt/actions-runner/.runner-version" +installed_version="" +if [ -f "$install_marker" ]; then + installed_version="$(cat "$install_marker")" +fi + +runner_is_configured="false" +if [ -f /opt/actions-runner/.runner ]; then + runner_is_configured="true" +fi + +if [ "$installed_version" != "$runner_version" ] || [ ! -x /opt/actions-runner/bin/Runner.Listener ]; then + if [ "$runner_is_configured" = "true" ] && [ -x /opt/actions-runner/bin/Runner.Listener ]; then + cat < "$install_marker" + chown -R github-runner:github-runner /opt/actions-runner +fi + +if [ ! -f /shared/github-runner.env ]; then + cat <<'EOF' +GitHub runner base installation complete. +Create /shared/github-runner.env from /shared/github-runner.env.example, +fill in GH_RUNNER_URL and either GH_RUNNER_API_TOKEN (preferred) or GH_RUNNER_TOKEN, then run: + vagrant provision github-runner --provision-with github-runner-register +EOF +fi diff --git a/vagrant/shared/provision-post-kernel.sh b/vagrant/shared/provision-post-kernel.sh new file mode 100755 index 0000000000..e555d98cf2 --- /dev/null +++ b/vagrant/shared/provision-post-kernel.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +if [ "$(id -u)" -ne 0 ]; then + echo "Please run this script as root or using sudo!" + exit 13 +fi +apt-get autoremove -y --allow-change-held-packages +apt-get install -y ansible diff --git a/vagrant/shared/provision.sh b/vagrant/shared/provision.sh new file mode 100755 index 0000000000..7cab63fa18 --- /dev/null +++ b/vagrant/shared/provision.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +if [ $(id -u) -ne 0 ]; then + echo "Please run this script as root or using sudo!" + exit 13 +fi +rm /etc/apt/sources.list.d/cappelikan.sources /etc/apt/sources.list.d/home-alvistack.sources +apt-get update +apt-get purge -y --allow-change-held-packages ansible mainline sosreport +# upgrade kernel +apt-get autoremove -y --allow-change-held-packages +apt full-upgrade -y