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..ddd420a3c7 --- /dev/null +++ b/.github/actions/python-setup/action.yml @@ -0,0 +1,45 @@ +name: Python uv Setup +description: Set up Python + uv, cache dependencies, and install project deps + +inputs: + python-version: + description: Python version to install + required: false + default: "3.14" + uv-version: + description: uv version to install + required: false + default: "0.11.14" + working-directory: + description: Project directory containing pyproject.toml + required: false + default: "app_python" + lockfile-path: + description: Path to uv.lock for cache key invalidation + required: false + default: "app_python/uv.lock" + install-args: + description: Extra arguments passed to uv sync + required: false + default: "--locked" + +runs: + using: composite + steps: + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + + - name: Setup uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: ${{ inputs.uv-version }} + python-version: ${{ inputs.python-version }} + enable-cache: true + cache-dependency-glob: ${{ inputs.lockfile-path }} + + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + run: uv sync ${{ 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..3cdc67a861 --- /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] + uv-version: [0.11.14] + 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 }} + uv-version: ${{ matrix.uv-version }} + working-directory: app_python + lockfile-path: app_python/uv.lock + install-args: --locked + - name: Lint with flake8 + run: uv run flake8 src tests + - name: Test using pytest with coverage report + run: | + mkdir -p test-results + uv 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..2d67ef2be7 --- /dev/null +++ b/.github/workflows/python-snyk.yml @@ -0,0 +1,61 @@ +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 + with: + uv-version: 0.11.14 + install-args: --locked --no-dev + - name: Setup Snyk CLI + uses: snyk/actions/setup@master + - name: Verify Snyk requirements export + run: | + uv export \ + --locked \ + --no-dev \ + --no-annotate \ + --no-header \ + --no-hashes \ + --format requirements.txt \ + --output-file /tmp/snyk-requirements.txt \ + > /dev/null + diff -u requirements.txt /tmp/snyk-requirements.txt + - name: Prepare Snyk scan environment + run: | + uv venv --seed .snyk-venv + uv pip install --python .snyk-venv/bin/python -r requirements.txt + - name: Run Snyk dependency scan + 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 \ + --file=requirements.txt \ + --package-manager=pip \ + --command=.snyk-venv/bin/python \ + --severity-threshold=medium \ + --fail-on=upgradable diff --git a/.gitignore b/.gitignore index 30d74d2584..225230c620 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,37 @@ -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/ +app_python/.venv/ +app_python/.snyk-venv/ +app_python/test-results/ + +# IDE +.vscode/ + +# Ansible runtime/cache +.ansible/ diff --git a/README.md b/README.md index 9955b0c611..739ea9b677 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. @@ -15,6 +16,14 @@ Master **production-grade DevOps practices** through hands-on labs. Build, conta 3. **Start with Lab 1** and progress sequentially 4. **Submit PRs** for each lab (details below) +## Module Directories + +- `app_python/`: Python DevOps Info Service. +- `app_go/`: Go DevOps Info Service. +- `k8s/`: Kubernetes, Helm, GitOps, rollout, storage, and monitoring labs. +- `edge-api/`: Lab 17 Cloudflare Workers API. +- `nix/`: Lab 18 reproducible builds with Nix, uv, flakes, and `dockerTools`. + --- ## Course Roadmap 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..64b0526ee0 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,5 @@ +* +!src/** +!pyproject.toml +!uv.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..edaf12cd32 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +FROM python:3.14-alpine + +COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_PROJECT_ENVIRONMENT=/app/.venv +ENV HOME=/home/appuser +ENV PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +RUN apk upgrade -U \ + && addgroup -S appgroup \ + && adduser -S -D -h /home/appuser -s /sbin/nologin -G appgroup appuser \ + && mkdir -p /data \ + && chown -R appuser:appgroup /app /data /home/appuser + +COPY pyproject.toml uv.lock gunicorn.conf.py ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-install-project + +COPY --chown=appuser:appgroup src ./src + +ENV PORT=5000 +ENV HOST="0.0.0.0" + +USER appuser +CMD ["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..cafc1986ef --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,112 @@ +# 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+ +- uv + +## Installation + +```bash +uv sync --locked +``` + +`uv.lock` is the source of truth for Python dependencies. `requirements.txt` is generated from it for Snyk's pip-compatible scan path and should be refreshed with: + +```bash +uv export --locked --no-dev --no-annotate --no-header --no-hashes --format requirements.txt --output-file requirements.txt > /dev/null +``` + +### 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 + uv sync --locked --no-dev --no-install-project + ``` + The image uses the pinned uv binary image and runs as `appuser` with `HOME=/home/appuser`. + +## Running the Application + +Production-style local run with Gunicorn: + +```bash +uv run gunicorn --config gunicorn.conf.py src.main:app +HOST=127.0.0.1 PORT=8080 uv 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 +uv sync --locked +uv 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 +uv 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/pyproject.toml b/app_python/pyproject.toml new file mode 100644 index 0000000000..4c14219924 --- /dev/null +++ b/app_python/pyproject.toml @@ -0,0 +1,25 @@ +[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", + "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.uv] +package = false diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..36e54767a0 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,11 @@ +blinker==1.9.0 +click==8.3.3 +colorama==0.4.6 ; sys_platform == 'win32' +flask==3.1.3 +gunicorn==25.3.0 +itsdangerous==2.2.0 +jinja2==3.1.6 +markupsafe==3.0.3 +packaging==26.2 +prometheus-client==0.23.1 +werkzeug==3.1.8 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/app_python/uv.lock b/app_python/uv.lock new file mode 100644 index 0000000000..409352f765 --- /dev/null +++ b/app_python/uv.lock @@ -0,0 +1,325 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "devops-info-service" +version = "1.12.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "gunicorn" }, + { name = "prometheus-client" }, +] + +[package.dev-dependencies] +dev = [ + { name = "flake8" }, + { name = "pep8-naming" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = ">=3.1.3,<4.0.0" }, + { name = "gunicorn", specifier = ">=25.1.0,<26.0.0" }, + { name = "prometheus-client", specifier = "==0.23.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "flake8", specifier = ">=7.3.0,<8.0.0" }, + { name = "pep8-naming", specifier = ">=0.15.1,<0.16.0" }, + { name = "pytest", specifier = ">=9.0.2,<10.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pep8-naming" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/59/c32862134635ba231d45f1711035550dc38246396c27269a4cde4bfe18d2/pep8_naming-0.15.1.tar.gz", hash = "sha256:f6f4a499aba2deeda93c1f26ccc02f3da32b035c8b2db9696b730ef2c9639d29", size = 17640, upload-time = "2025-05-05T20:43:12.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/78/25281540f1121acaa78926f599a17ce102b8971bc20b096fa7fb6b5b59c1/pep8_naming-0.15.1-py3-none-any.whl", hash = "sha256:eb63925e7fd9e028c7f7ee7b1e413ec03d1ee5de0e627012102ee0222c273c86", size = 9561, upload-time = "2025-05-05T20:43:11.626Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] 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/edge-api/.gitignore b/edge-api/.gitignore new file mode 100644 index 0000000000..7f42eebdff --- /dev/null +++ b/edge-api/.gitignore @@ -0,0 +1,28 @@ +# Dependencies +node_modules/ + +# Wrangler local state and secrets +.wrangler/ +.dev.vars* +!.dev.vars.example +.env* +!.env.example + +# Build and cache output +dist/ +coverage/ +.cache/ +.parcel-cache/ +.eslintcache +*.tsbuildinfo + +# Logs and diagnostics +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +report.*.json + +# Packed npm artifacts +*.tgz diff --git a/edge-api/AGENTS.md b/edge-api/AGENTS.md new file mode 100644 index 0000000000..340506a599 --- /dev/null +++ b/edge-api/AGENTS.md @@ -0,0 +1,41 @@ +# Cloudflare Workers + +STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task. + +## Docs + +- https://developers.cloudflare.com/workers/ +- MCP: `https://docs.mcp.cloudflare.com/mcp` + +For all limits and quotas, retrieve from the product's `/platform/limits/` page. eg. `/workers/platform/limits` + +## Commands + +| Command | Purpose | +|---------|---------| +| `npx wrangler dev` | Local development | +| `npx wrangler deploy` | Deploy to Cloudflare | +| `npx wrangler types` | Generate TypeScript types | + +Run `wrangler types` after changing bindings in wrangler.jsonc. + +## Node.js Compatibility + +https://developers.cloudflare.com/workers/runtime-apis/nodejs/ + +## Errors + +- **Error 1102** (CPU/Memory exceeded): Retrieve limits from `/workers/platform/limits/` +- **All errors**: https://developers.cloudflare.com/workers/observability/errors/ + +## Product Docs + +Retrieve API references and limits from: +`/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/` + +## Best Practices (conditional) + +If the application uses Durable Objects or Workflows, refer to the relevant best practices: + +- Durable Objects: https://developers.cloudflare.com/durable-objects/best-practices/rules-of-durable-objects/ +- Workflows: https://developers.cloudflare.com/workflows/build/rules-of-workflows/ diff --git a/edge-api/README.md b/edge-api/README.md new file mode 100644 index 0000000000..f61c2c3ff6 --- /dev/null +++ b/edge-api/README.md @@ -0,0 +1,24 @@ +# edge-api + +Cloudflare Worker for Lab 17. The full lab report is in [`WORKERS.md`](WORKERS.md). + +The Worker is a small Hono API with Cloudflare edge metadata, redacted configuration inspection, and a KV-backed counter. + +## Commands + +```bash +npm install +npm run typecheck +npx wrangler types +npx wrangler dev --port 8787 +npx wrangler deploy +``` + +## Routes + +- `GET /` +- `GET /health` +- `GET /edge` +- `GET /config` +- `GET /counter` +- `POST /counter` diff --git a/edge-api/WORKERS.md b/edge-api/WORKERS.md new file mode 100644 index 0000000000..733f99e851 --- /dev/null +++ b/edge-api/WORKERS.md @@ -0,0 +1,619 @@ +# Lab 17: Cloudflare Workers + +This lab deploys a small TypeScript API to Cloudflare Workers at: + +```text +https://edge-api.d-nesterov.workers.dev +``` + +The Worker source lives in this folder. It uses Hono for routing, Wrangler for local development and deployment, and Workers KV for state that survives code redeploys and rollback drills. + +Official Cloudflare references used for this implementation: + +- Wrangler configuration: https://developers.cloudflare.com/workers/wrangler/configuration/ +- Workers KV setup: https://developers.cloudflare.com/kv/get-started/ +- Request edge metadata: https://developers.cloudflare.com/workers/runtime-apis/request/ +- Workers observability: https://developers.cloudflare.com/workers/observability/ +- Versions, deployments, and rollback: https://developers.cloudflare.com/workers/configuration/versions-and-deployments/ + +## Implementation + +The Worker exposes these routes: + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/` | Application metadata and route index | +| `GET` | `/health` | Health probe with timestamp | +| `GET` | `/edge` | Selected `request.cf` edge metadata | +| `GET` | `/config` | Plaintext config and redacted secret status | +| `GET` | `/counter` | Reads the KV-backed counter | +| `POST` | `/counter` | Increments the KV-backed counter | + +The important config is in `wrangler.jsonc`: + +- `workers_dev: true` publishes the app on the account `workers.dev` subdomain. +- `observability.enabled: true` enables Workers logs and metrics. +- `vars` stores non-sensitive app metadata. +- `secrets.required` declares `API_TOKEN` and `ADMIN_EMAIL` for type generation and local validation. +- `kv_namespaces` binds `SETTINGS` to the Worker. + +Secrets are only reported by configured/not configured status from `/config`. The Worker never returns secret values. + +## Screenshots + +The dashboard screenshots were captured manually because Cloudflare's human verification blocked repeatable Playwright login during the final pass. + +The report embeds these captures: + +![Cloudflare Workers and Pages overview](docs/img/lab17_cloudflare_workers_pages.png) + +![Cloudflare Worker metrics](docs/img/lab17_cloudflare_metrics_or_logs.png) + +![Cloudflare Worker KV binding](docs/img/lab17_cloudflare_bindings.png) + +![Cloudflare Worker active deployment](docs/img/lab17_cloudflare_deployments.png) + +
+Cloudflare login and account check + +```text +$ source ~/.sdk/nvm/nvm.sh + +$ nvm use 24 +Now using node v24.15.0 (npm v11.13.0) + +$ npx wrangler whoami + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +Getting User settings... +👋 You are logged in with an OAuth Token, associated with the email d.nesterov@innopolis.university. +┌───────────────────────────────────────────┬──────────────────────────────────┐ +│ Account Name │ Account ID │ +├───────────────────────────────────────────┼──────────────────────────────────┤ +│ D.nesterov@innopolis.university's Account │ 4d7eadac0e419c4614832b150c6867e3 │ +└───────────────────────────────────────────┴──────────────────────────────────┘ +``` + +
+ +
+KV namespace and secrets + +```text +$ npx wrangler kv namespace create SETTINGS + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +Resource location: remote + +🌀 Creating namespace with title "SETTINGS" +✨ Success! +To access your new KV Namespace in your Worker, add the following snippet to your configuration file: +{ + "kv_namespaces": [ + { + "binding": "SETTINGS", + "id": "a7dd06a571e7468d9906db5d52c32d12" + } + ] +} +? Would you like Wrangler to add it on your behalf? +🤖 Using fallback value in non-interactive context: no + +$ npx wrangler secret put API_TOKEN + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +🌀 Creating the secret for the Worker "edge-api" +? There doesn't seem to be a Worker called "edge-api". Do you want to create a new Worker with that name and add secrets to it? +🤖 Using fallback value in non-interactive context: yes +🌀 Creating new Worker "edge-api"... +✨ Success! Uploaded secret API_TOKEN + +$ npx wrangler secret put ADMIN_EMAIL + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +🌀 Creating the secret for the Worker "edge-api" +✨ Success! Uploaded secret ADMIN_EMAIL +``` + +Secret values were entered through stdin and intentionally omitted from the transcript. + +
+ +
+Static checks + +```text +$ npm install + +added 1 package, removed 8 packages, and audited 40 packages in 1s + +7 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities + +$ npx wrangler types + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +Generating project types... + +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env { + SETTINGS: KVNamespace; + APP_NAME: "edge-api"; + COURSE_NAME: "DevOps Core S26"; + APP_VERSION: "1.0.1"; + ENVIRONMENT: "production"; + API_TOKEN: string; + ADMIN_EMAIL: string; + } +} + +✨ Types written to worker-configuration.d.ts + +$ npm run typecheck + +> edge-api@1.0.1 typecheck +> tsc --noEmit +``` + +
+ +
+Local Worker route checks + +```text +$ npx wrangler dev --port 8787 + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +Using secrets defined in .dev.vars +Your Worker has access to the following bindings: +Binding Resource Mode +env.SETTINGS (a7dd06a571e7468d9906db5d52c32d12) KV Namespace local +env.APP_NAME ("edge-api") Environment Variable local +env.COURSE_NAME ("DevOps Core S26") Environment Variable local +env.APP_VERSION ("1.0.0") Environment Variable local +env.ENVIRONMENT ("production") Environment Variable local +env.API_TOKEN ("(hidden)") Environment Variable local +env.ADMIN_EMAIL ("(hidden)") Environment Variable local + +$ curl -fsS 127.0.0.1:8787/health | jq +{ + "status": "ok", + "service": "edge-api", + "version": "1.0.0", + "timestamp": "2026-05-14T17:10:50.285Z" +} + +$ curl -fsS 127.0.0.1:8787/config | jq +{ + "vars": { + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.0", + "ENVIRONMENT": "production" + }, + "secrets": { + "API_TOKEN": { + "configured": true, + "value": "[redacted]" + }, + "ADMIN_EMAIL": { + "configured": true, + "value": "[redacted]" + } + }, + "kv": { + "binding": "SETTINGS", + "counterKey": "lab17-counter" + } +} + +$ curl -fsS -X POST 127.0.0.1:8787/counter | jq +{ + "key": "lab17-counter", + "previous": 1, + "value": 2, + "persisted": true +} + +$ curl -sS 127.0.0.1:8787/missing | jq +{ + "error": "not_found", + "path": "/missing", + "routes": [ + { + "method": "GET", + "path": "/", + "description": "Application metadata and route index" + }, + { + "method": "GET", + "path": "/health", + "description": "Health probe for uptime checks" + }, + { + "method": "GET", + "path": "/edge", + "description": "Selected Cloudflare edge request metadata" + }, + { + "method": "GET", + "path": "/config", + "description": "Plaintext config and redacted secret status" + }, + { + "method": "GET", + "path": "/counter", + "description": "Read the KV-backed counter" + }, + { + "method": "POST", + "path": "/counter", + "description": "Increment the KV-backed counter" + } + ] +} +``` + +The full local transcript is in `/tmp/lab17/local-routes.txt`. + +
+ +
+Deploy v1 and v2 + +```text +$ npx wrangler deploy + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +Total Upload: 64.96 KiB / gzip: 16.02 KiB +Worker Startup Time: 5 ms +Your Worker has access to the following bindings: +Binding Resource +env.SETTINGS (a7dd06a571e7468d9906db5d52c32d12) KV Namespace +env.APP_NAME ("edge-api") Environment Variable +env.COURSE_NAME ("DevOps Core S26") Environment Variable +env.APP_VERSION ("1.0.0") Environment Variable +env.ENVIRONMENT ("production") Environment Variable + +Deployed edge-api triggers (10.12 sec) + https://edge-api.d-nesterov.workers.dev +Current Version ID: a20f4d8a-ccd7-4bb7-a040-f3be3f87a255 + +$ npx wrangler deploy + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +Total Upload: 64.96 KiB / gzip: 16.02 KiB +Worker Startup Time: 8 ms +Your Worker has access to the following bindings: +Binding Resource +env.SETTINGS (a7dd06a571e7468d9906db5d52c32d12) KV Namespace +env.APP_NAME ("edge-api") Environment Variable +env.COURSE_NAME ("DevOps Core S26") Environment Variable +env.APP_VERSION ("1.0.1") Environment Variable +env.ENVIRONMENT ("production") Environment Variable + +Deployed edge-api triggers (6.30 sec) + https://edge-api.d-nesterov.workers.dev +Current Version ID: 469bd470-53b7-4b7d-b229-4ce161da0333 +``` + +
+ +
+Public route checks + +```text +$ WORKER_URL=https://edge-api.d-nesterov.workers.dev + +$ curl -fsS "$WORKER_URL/health" | jq +{ + "status": "ok", + "service": "edge-api", + "version": "1.0.1", + "timestamp": "2026-05-14T17:13:31.426Z" +} + +$ curl -fsS "$WORKER_URL/edge" | jq +{ + "colo": "ARN", + "country": "FI", + "city": "Helsinki", + "region": "Uusimaa", + "postalCode": "00100", + "timezone": "Europe/Helsinki", + "asn": 56971, + "asOrganization": "CGI GLOBAL LIMITED", + "httpProtocol": "HTTP/2", + "tlsVersion": "TLSv1.3" +} + +$ curl -fsS "$WORKER_URL/config" | jq +{ + "vars": { + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.1", + "ENVIRONMENT": "production" + }, + "secrets": { + "API_TOKEN": { + "configured": true, + "value": "[redacted]" + }, + "ADMIN_EMAIL": { + "configured": true, + "value": "[redacted]" + } + }, + "kv": { + "binding": "SETTINGS", + "counterKey": "lab17-counter" + } +} + +$ curl -fsS -X POST "$WORKER_URL/counter" | jq +{ + "key": "lab17-counter", + "previous": 1, + "value": 2, + "persisted": true +} +``` + +The full route transcript is in `/tmp/lab17/public-routes.txt`. + +
+ +
+KV persistence across redeploy + +```text +$ curl -fsS "$WORKER_URL/config" | jq .vars +{ + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.0", + "ENVIRONMENT": "production" +} + +$ curl -fsS -X POST "$WORKER_URL/counter" | jq +{ + "key": "lab17-counter", + "previous": 0, + "value": 1, + "persisted": true +} + +$ curl -fsS "$WORKER_URL/config" | jq .vars +{ + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.1", + "ENVIRONMENT": "production" +} + +$ curl -fsS "$WORKER_URL/counter" | jq +{ + "key": "lab17-counter", + "value": 2, + "persisted": true +} +``` + +
+ +
+Observability with Wrangler tail + +```text +$ npx wrangler tail --format json | jq ' +{ + outcome, + scriptName, + scriptVersion: .scriptVersion.id, + log: .logs[0].message[0], + request: { + method: .event.request.method, + url: .event.request.url, + colo: .event.request.cf.colo, + country: .event.request.cf.country + }, + status: .event.response.status +}' +{ + "outcome": "ok", + "scriptName": "edge-api", + "scriptVersion": "469bd470-53b7-4b7d-b229-4ce161da0333", + "log": "{\"event\":\"request\",\"method\":\"GET\",\"path\":\"/health\",\"colo\":\"ARN\",\"country\":\"FI\"}", + "request": { + "method": "GET", + "url": "https://edge-api.d-nesterov.workers.dev/health", + "colo": "ARN", + "country": "FI" + }, + "status": 200 +} +{ + "outcome": "ok", + "scriptName": "edge-api", + "scriptVersion": "469bd470-53b7-4b7d-b229-4ce161da0333", + "log": "{\"event\":\"request\",\"method\":\"POST\",\"path\":\"/counter\",\"colo\":\"ARN\",\"country\":\"FI\"}", + "request": { + "method": "POST", + "url": "https://edge-api.d-nesterov.workers.dev/counter", + "colo": "ARN", + "country": "FI" + }, + "status": 200 +} + +$ curl -fsS "$WORKER_URL/health" | jq .status +"ok" + +$ curl -fsS -X POST "$WORKER_URL/counter" | jq .value +3 +``` + +
+ +
+Rollback drill + +```text +$ npx wrangler rollback a20f4d8a-ccd7-4bb7-a040-f3be3f87a255 --message "Lab 17 rollback drill" --yes + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +├ Fetching latest deployment +│ +├ Your current deployment has 1 version(s): +│ +│ (100%) 469bd470-53b7-4b7d-b229-4ce161da0333 +│ Created: 2026-05-14T17:13:05.782035Z +│ Tag: - +│ Message: - +│ +├  WARNING  You are about to rollback to Worker Version a20f4d8a-ccd7-4bb7-a040-f3be3f87a255. +│ This will immediately replace the current deployment and become the active deployment across all your deployed triggers. +│ However, your local development environment will not be affected by this rollback. +│ Rolling back to a previous deployment will not rollback any of the bound resources (Durable Object, D1, R2, KV, etc). +│ +╰  SUCCESS  Worker Version a20f4d8a-ccd7-4bb7-a040-f3be3f87a255 has been deployed to 100% of traffic. + +Current Version ID: a20f4d8a-ccd7-4bb7-a040-f3be3f87a255 + +$ curl -fsS "$WORKER_URL/config" | jq .vars +{ + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.0", + "ENVIRONMENT": "production" +} + +$ npx wrangler deploy + +Deployed edge-api triggers (5.76 sec) + https://edge-api.d-nesterov.workers.dev +Current Version ID: e46aeb10-5fab-49df-a416-44d42bdc27f9 + +$ curl -fsS "$WORKER_URL/config" | jq .vars +{ + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.1", + "ENVIRONMENT": "production" +} + +$ curl -fsS "$WORKER_URL/counter" | jq +{ + "key": "lab17-counter", + "value": 3, + "persisted": true +} +``` + +The rollback test confirmed two things: traffic can be returned to an older Worker version, and KV data is not rolled back with code. + +
+ +
+Final redeploy after source cleanup + +```text +$ npm run typecheck + +> edge-api@1.0.1 typecheck +> tsc --noEmit + + +$ npx wrangler deploy + + ⛅️ wrangler 4.90.0 (update available 4.91.0) +───────────────────────────────────────────── +Total Upload: 65.04 KiB / gzip: 16.02 KiB +Worker Startup Time: 4 ms +Your Worker has access to the following bindings: +Binding Resource +env.SETTINGS (a7dd06a571e7468d9906db5d52c32d12) KV Namespace +env.APP_NAME ("edge-api") Environment Variable +env.COURSE_NAME ("DevOps Core S26") Environment Variable +env.APP_VERSION ("1.0.1") Environment Variable +env.ENVIRONMENT ("production") Environment Variable + +Uploaded edge-api (12.21 sec) +Deployed edge-api triggers (6.00 sec) + https://edge-api.d-nesterov.workers.dev +Current Version ID: 2297d0f9-493e-4de6-8745-b34f7c9c9f99 + +$ curl -fsS https://edge-api.d-nesterov.workers.dev/config | jq .vars +{ + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.1", + "ENVIRONMENT": "production" +} + +$ curl -fsS https://edge-api.d-nesterov.workers.dev/counter | jq +{ + "key": "lab17-counter", + "value": 3, + "persisted": true +} +``` + +
+ +
+Manual dashboard screenshot checklist + +```text +$ ls -lh docs/img/lab17_cloudflare_*.png +-rw-r--r-- 1 t0ast t0ast 82K May 14 21:42 docs/img/lab17_cloudflare_bindings.png +-rw-r--r-- 1 t0ast t0ast 38K May 14 21:43 docs/img/lab17_cloudflare_deployments.png +-rw-r--r-- 1 t0ast t0ast 89K May 14 21:41 docs/img/lab17_cloudflare_metrics_or_logs.png +-rw-r--r-- 1 t0ast t0ast 177K May 14 21:38 docs/img/lab17_cloudflare_workers_pages.png + +$ sha256sum docs/img/lab17_cloudflare_*.png +d92191268434e69a1d33d497c13ed9afaa2a75ceef1bdcbee2c49246d840efc5 docs/img/lab17_cloudflare_bindings.png +ace7f62e5bedca06b0d47183561fd9efde7309146b1ccd45158f1a76d2813afc docs/img/lab17_cloudflare_deployments.png +9f1f892a15ba70d12fc5c5731f03a99250031244306e853a86795b145e5326df docs/img/lab17_cloudflare_metrics_or_logs.png +1e8f16984c018e78ee20812a1d57da058d1e99a6311721056db78aa40b817978 docs/img/lab17_cloudflare_workers_pages.png + +$ file docs/img/lab17_cloudflare_*.png +docs/img/lab17_cloudflare_bindings.png: PNG image data, 1597 x 976, 8-bit/color RGBA, non-interlaced +docs/img/lab17_cloudflare_deployments.png: PNG image data, 895 x 406, 8-bit/color RGBA, non-interlaced +docs/img/lab17_cloudflare_metrics_or_logs.png: PNG image data, 1596 x 975, 8-bit/color RGBA, non-interlaced +docs/img/lab17_cloudflare_workers_pages.png: PNG image data, 1220 x 1145, 8-bit/color RGBA, non-interlaced +``` + +The four images cover the Workers & Pages list, Worker metrics, the `SETTINGS` KV binding, and the active deployment/version view. + +
+ +## Kubernetes vs Workers + +Kubernetes gives more control over runtime shape, deployment strategy, network policies, and cluster-level behavior. That control is valuable for long-running services and platform engineering, but it carries operational cost: nodes, controllers, ingress, image distribution, and rollout machinery all need care. + +Cloudflare Workers removes most of that operational surface. Deployment is a source upload plus platform-managed global execution. Rollback is version based, logs are available through Wrangler and the dashboard, and KV can hold small global state without provisioning a database. The tradeoff is less control over runtime placement, request lifecycle, and local parity. It is a strong fit for edge APIs, request adapters, webhooks, and lightweight public APIs; it is not a direct replacement for every Kubernetes service. + +## Final State + +- Worker URL: `https://edge-api.d-nesterov.workers.dev` +- Final app version: `1.0.1` +- Current final deployment after rollback drill and final redeploy: `2297d0f9-493e-4de6-8745-b34f7c9c9f99` +- KV binding: `SETTINGS` +- KV counter after final redeploy: `3` +- Dashboard screenshots are stored under `docs/img/` diff --git a/edge-api/docs/img/lab17_cloudflare_bindings.png b/edge-api/docs/img/lab17_cloudflare_bindings.png new file mode 100644 index 0000000000..0e1cad08ad Binary files /dev/null and b/edge-api/docs/img/lab17_cloudflare_bindings.png differ diff --git a/edge-api/docs/img/lab17_cloudflare_deployments.png b/edge-api/docs/img/lab17_cloudflare_deployments.png new file mode 100644 index 0000000000..72b370258a Binary files /dev/null and b/edge-api/docs/img/lab17_cloudflare_deployments.png differ diff --git a/edge-api/docs/img/lab17_cloudflare_metrics_or_logs.png b/edge-api/docs/img/lab17_cloudflare_metrics_or_logs.png new file mode 100644 index 0000000000..f8fce5649b Binary files /dev/null and b/edge-api/docs/img/lab17_cloudflare_metrics_or_logs.png differ diff --git a/edge-api/docs/img/lab17_cloudflare_workers_pages.png b/edge-api/docs/img/lab17_cloudflare_workers_pages.png new file mode 100644 index 0000000000..7b72537c46 Binary files /dev/null and b/edge-api/docs/img/lab17_cloudflare_workers_pages.png differ diff --git a/edge-api/package-lock.json b/edge-api/package-lock.json new file mode 100644 index 0000000000..2cce5f3dc3 --- /dev/null +++ b/edge-api/package-lock.json @@ -0,0 +1,1597 @@ +{ + "name": "edge-api", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "edge-api", + "version": "1.0.1", + "dependencies": { + "hono": "^4.12.12" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.9.3", + "wrangler": "^4.90.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260507.1.tgz", + "integrity": "sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260507.1.tgz", + "integrity": "sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260507.1.tgz", + "integrity": "sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260507.1.tgz", + "integrity": "sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260507.1.tgz", + "integrity": "sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/node": { + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260507.1", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260507.1.tgz", + "integrity": "sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260507.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260507.1.tgz", + "integrity": "sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260507.1", + "@cloudflare/workerd-darwin-arm64": "1.20260507.1", + "@cloudflare/workerd-linux-64": "1.20260507.1", + "@cloudflare/workerd-linux-arm64": "1.20260507.1", + "@cloudflare/workerd-windows-64": "1.20260507.1" + } + }, + "node_modules/wrangler": { + "version": "4.90.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.90.0.tgz", + "integrity": "sha512-bmNIykl59TfCUn5xQgU7IWylSsPx3LQaPLMSAq2VQHt89CBrcj9qXQ0eYfjBCWA5XTBVgten391evt7xxtXwcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260507.1", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260507.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260507.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/edge-api/package.json b/edge-api/package.json new file mode 100644 index 0000000000..c3093b207b --- /dev/null +++ b/edge-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "edge-api", + "version": "1.0.1", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types" + }, + "dependencies": { + "hono": "^4.12.12" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.9.3", + "wrangler": "^4.90.0" + } +} diff --git a/edge-api/src/index.ts b/edge-api/src/index.ts new file mode 100644 index 0000000000..1f8b8462a6 --- /dev/null +++ b/edge-api/src/index.ts @@ -0,0 +1,176 @@ +import { Hono } from "hono"; + +type WorkerEnv = { + APP_NAME: string; + COURSE_NAME: string; + APP_VERSION: string; + ENVIRONMENT: string; + API_TOKEN?: string; + ADMIN_EMAIL?: string; + SETTINGS: KVNamespace; +}; + +type CloudflareRequest = Request & { + cf?: Record; +}; + +const counterKey = "lab17-counter"; +const routes = [ + { method: "GET", path: "/", description: "Application metadata and route index" }, + { method: "GET", path: "/health", description: "Health probe for uptime checks" }, + { method: "GET", path: "/edge", description: "Selected Cloudflare edge request metadata" }, + { method: "GET", path: "/config", description: "Plaintext config and redacted secret status" }, + { method: "GET", path: "/counter", description: "Read the KV-backed counter" }, + { method: "POST", path: "/counter", description: "Increment the KV-backed counter" }, +] as const; + +const app = new Hono<{ Bindings: WorkerEnv }>(); + +const cfString = (cf: Record, key: string): string | null => { + const value = cf[key]; + + return typeof value === "string" ? value : null; +}; + +const cfNumber = (cf: Record, key: string): number | null => { + const value = cf[key]; + + return typeof value === "number" ? value : null; +}; + +const secretStatus = (value: string | undefined) => ({ + configured: Boolean(value), + value: value ? "[redacted]" : null, +}); + +app.use("*", async (c, next) => { + const request = c.req.raw as CloudflareRequest; + const cf = request.cf ?? {}; + const path = new URL(c.req.url).pathname; + + console.log( + JSON.stringify({ + event: "request", + method: c.req.method, + path, + colo: cfString(cf, "colo"), + country: cfString(cf, "country"), + }), + ); + + await next(); +}); + +app.get("/", (c) => { + return c.json({ + app: c.env.APP_NAME, + course: c.env.COURSE_NAME, + version: c.env.APP_VERSION, + environment: c.env.ENVIRONMENT, + routes, + }); +}); + +app.get("/health", (c) => { + return c.json({ + status: "ok", + service: c.env.APP_NAME, + version: c.env.APP_VERSION, + timestamp: new Date().toISOString(), + }); +}); + +app.get("/edge", (c) => { + const request = c.req.raw as CloudflareRequest; + const cf = request.cf ?? {}; + + return c.json({ + colo: cfString(cf, "colo"), + country: cfString(cf, "country"), + city: cfString(cf, "city"), + region: cfString(cf, "region"), + postalCode: cfString(cf, "postalCode"), + timezone: cfString(cf, "timezone"), + asn: cfNumber(cf, "asn"), + asOrganization: cfString(cf, "asOrganization"), + httpProtocol: cfString(cf, "httpProtocol"), + tlsVersion: cfString(cf, "tlsVersion"), + }); +}); + +app.get("/config", (c) => { + return c.json({ + vars: { + APP_NAME: c.env.APP_NAME, + COURSE_NAME: c.env.COURSE_NAME, + APP_VERSION: c.env.APP_VERSION, + ENVIRONMENT: c.env.ENVIRONMENT, + }, + secrets: { + API_TOKEN: secretStatus(c.env.API_TOKEN), + ADMIN_EMAIL: secretStatus(c.env.ADMIN_EMAIL), + }, + kv: { + binding: "SETTINGS", + counterKey, + }, + }); +}); + +app.get("/counter", async (c) => { + const rawValue = await c.env.SETTINGS.get(counterKey); + const value = Number.parseInt(rawValue ?? "0", 10); + + return c.json({ + key: counterKey, + value: Number.isFinite(value) ? value : 0, + persisted: rawValue !== null, + }); +}); + +app.post("/counter", async (c) => { + const rawValue = await c.env.SETTINGS.get(counterKey); + const currentValue = Number.parseInt(rawValue ?? "0", 10); + const previous = Number.isFinite(currentValue) ? currentValue : 0; + const value = previous + 1; + + await c.env.SETTINGS.put(counterKey, value.toString()); + + return c.json({ + key: counterKey, + previous, + value, + persisted: true, + }); +}); + +app.notFound((c) => { + return c.json( + { + error: "not_found", + path: new URL(c.req.url).pathname, + routes, + }, + 404, + ); +}); + +app.onError((error, c) => { + console.error( + JSON.stringify({ + event: "error", + message: error.message, + path: new URL(c.req.url).pathname, + }), + ); + + return c.json( + { + error: "internal_error", + message: "Unexpected Worker error", + }, + 500, + ); +}); + +export default app; diff --git a/edge-api/tsconfig.json b/edge-api/tsconfig.json new file mode 100644 index 0000000000..5879fef775 --- /dev/null +++ b/edge-api/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + /* Base Options: */ + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2024", + "verbatimModuleSyntax": false, + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + /* Strictness */ + "strict": true, + "noUncheckedIndexedAccess": true, + /* If NOT transpiling with TypeScript: */ + "moduleResolution": "Bundler", + "module": "es2022", + "noEmit": true, + "lib": ["es2024"], + "types": [ + "./worker-configuration.d.ts", + "@types/node", + "node" + ] + }, + "exclude": ["node_modules", "dist", "tests"], + "include": ["src", "worker-configuration.d.ts"] +} diff --git a/edge-api/worker-configuration.d.ts b/edge-api/worker-configuration.d.ts new file mode 100644 index 0000000000..f1d0f4a98a --- /dev/null +++ b/edge-api/worker-configuration.d.ts @@ -0,0 +1,13563 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types` (hash: 957ac867a989f10144351edf09a586ea) +// Runtime types generated with workerd@1.20260507.1 2026-05-10 nodejs_compat +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env { + SETTINGS: KVNamespace; + APP_NAME: "edge-api"; + COURSE_NAME: "DevOps Core S26"; + APP_VERSION: "1.0.1"; + ENVIRONMENT: "production"; + API_TOKEN: string; + ADMIN_EMAIL: string; + } +} +interface Env extends Cloudflare.Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} + +// Begin runtime types +/*! ***************************************************************************** +Copyright (c) Cloudflare. All rights reserved. +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ +/* eslint-disable */ +// noinspection JSUnusedGlobalSymbols +declare var onmessage: never; +/** + * The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException) + */ +declare class DOMException extends Error { + constructor(message?: string, name?: string); + /** + * The **`message`** read-only property of the a message or description associated with the given error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) + */ + readonly message: string; + /** + * The **`name`** read-only property of the one of the strings associated with an error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) + */ + readonly name: string; + /** + * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code) + */ + readonly code: number; + static readonly INDEX_SIZE_ERR: number; + static readonly DOMSTRING_SIZE_ERR: number; + static readonly HIERARCHY_REQUEST_ERR: number; + static readonly WRONG_DOCUMENT_ERR: number; + static readonly INVALID_CHARACTER_ERR: number; + static readonly NO_DATA_ALLOWED_ERR: number; + static readonly NO_MODIFICATION_ALLOWED_ERR: number; + static readonly NOT_FOUND_ERR: number; + static readonly NOT_SUPPORTED_ERR: number; + static readonly INUSE_ATTRIBUTE_ERR: number; + static readonly INVALID_STATE_ERR: number; + static readonly SYNTAX_ERR: number; + static readonly INVALID_MODIFICATION_ERR: number; + static readonly NAMESPACE_ERR: number; + static readonly INVALID_ACCESS_ERR: number; + static readonly VALIDATION_ERR: number; + static readonly TYPE_MISMATCH_ERR: number; + static readonly SECURITY_ERR: number; + static readonly NETWORK_ERR: number; + static readonly ABORT_ERR: number; + static readonly URL_MISMATCH_ERR: number; + static readonly QUOTA_EXCEEDED_ERR: number; + static readonly TIMEOUT_ERR: number; + static readonly INVALID_NODE_TYPE_ERR: number; + static readonly DATA_CLONE_ERR: number; + get stack(): any; + set stack(value: any); +} +type WorkerGlobalScopeEventMap = { + fetch: FetchEvent; + scheduled: ScheduledEvent; + queue: QueueEvent; + unhandledrejection: PromiseRejectionEvent; + rejectionhandled: PromiseRejectionEvent; +}; +declare abstract class WorkerGlobalScope extends EventTarget { + EventTarget: typeof EventTarget; +} +/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). * + * The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) + */ +interface Console { + "assert"(condition?: boolean, ...data: any[]): void; + /** + * The **`console.clear()`** static method clears the console if possible. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) + */ + clear(): void; + /** + * The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) + */ + count(label?: string): void; + /** + * The **`console.countReset()`** static method resets counter used with console/count_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) + */ + countReset(label?: string): void; + /** + * The **`console.debug()`** static method outputs a message to the console at the 'debug' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) + */ + debug(...data: any[]): void; + /** + * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) + */ + dir(item?: any, options?: any): void; + /** + * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) + */ + dirxml(...data: any[]): void; + /** + * The **`console.error()`** static method outputs a message to the console at the 'error' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) + */ + error(...data: any[]): void; + /** + * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) + */ + group(...data: any[]): void; + /** + * The **`console.groupCollapsed()`** static method creates a new inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) + */ + groupCollapsed(...data: any[]): void; + /** + * The **`console.groupEnd()`** static method exits the current inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) + */ + groupEnd(): void; + /** + * The **`console.info()`** static method outputs a message to the console at the 'info' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) + */ + info(...data: any[]): void; + /** + * The **`console.log()`** static method outputs a message to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) + */ + log(...data: any[]): void; + /** + * The **`console.table()`** static method displays tabular data as a table. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) + */ + table(tabularData?: any, properties?: string[]): void; + /** + * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) + */ + time(label?: string): void; + /** + * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) + */ + timeEnd(label?: string): void; + /** + * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) + */ + timeLog(label?: string, ...data: any[]): void; + timeStamp(label?: string): void; + /** + * The **`console.trace()`** static method outputs a stack trace to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) + */ + trace(...data: any[]): void; + /** + * The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) + */ + warn(...data: any[]): void; +} +declare const console: Console; +type BufferSource = ArrayBufferView | ArrayBuffer; +type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array; +declare namespace WebAssembly { + class CompileError extends Error { + constructor(message?: string); + } + class RuntimeError extends Error { + constructor(message?: string); + } + type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128"; + interface GlobalDescriptor { + value: ValueType; + mutable?: boolean; + } + class Global { + constructor(descriptor: GlobalDescriptor, value?: any); + value: any; + valueOf(): any; + } + type ImportValue = ExportValue | number; + type ModuleImports = Record; + type Imports = Record; + type ExportValue = Function | Global | Memory | Table; + type Exports = Record; + class Instance { + constructor(module: Module, imports?: Imports); + readonly exports: Exports; + } + interface MemoryDescriptor { + initial: number; + maximum?: number; + shared?: boolean; + } + class Memory { + constructor(descriptor: MemoryDescriptor); + readonly buffer: ArrayBuffer; + grow(delta: number): number; + } + type ImportExportKind = "function" | "global" | "memory" | "table"; + interface ModuleExportDescriptor { + kind: ImportExportKind; + name: string; + } + interface ModuleImportDescriptor { + kind: ImportExportKind; + module: string; + name: string; + } + abstract class Module { + static customSections(module: Module, sectionName: string): ArrayBuffer[]; + static exports(module: Module): ModuleExportDescriptor[]; + static imports(module: Module): ModuleImportDescriptor[]; + } + type TableKind = "anyfunc" | "externref"; + interface TableDescriptor { + element: TableKind; + initial: number; + maximum?: number; + } + class Table { + constructor(descriptor: TableDescriptor, value?: any); + readonly length: number; + get(index: number): any; + grow(delta: number, value?: any): number; + set(index: number, value?: any): void; + } + function instantiate(module: Module, imports?: Imports): Promise; + function validate(bytes: BufferSource): boolean; +} +/** + * The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope) + */ +interface ServiceWorkerGlobalScope extends WorkerGlobalScope { + DOMException: typeof DOMException; + WorkerGlobalScope: typeof WorkerGlobalScope; + btoa(data: string): string; + atob(data: string): string; + setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; + setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + clearTimeout(timeoutId: number | null): void; + setInterval(callback: (...args: any[]) => void, msDelay?: number): number; + setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + clearInterval(timeoutId: number | null): void; + queueMicrotask(task: Function): void; + structuredClone(value: T, options?: StructuredSerializeOptions): T; + reportError(error: any): void; + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + self: ServiceWorkerGlobalScope; + crypto: Crypto; + caches: CacheStorage; + scheduler: Scheduler; + performance: Performance; + Cloudflare: Cloudflare; + readonly origin: string; + Event: typeof Event; + ExtendableEvent: typeof ExtendableEvent; + CustomEvent: typeof CustomEvent; + PromiseRejectionEvent: typeof PromiseRejectionEvent; + FetchEvent: typeof FetchEvent; + TailEvent: typeof TailEvent; + TraceEvent: typeof TailEvent; + ScheduledEvent: typeof ScheduledEvent; + MessageEvent: typeof MessageEvent; + CloseEvent: typeof CloseEvent; + ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader; + ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader; + ReadableStream: typeof ReadableStream; + WritableStream: typeof WritableStream; + WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter; + TransformStream: typeof TransformStream; + ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy; + CountQueuingStrategy: typeof CountQueuingStrategy; + ErrorEvent: typeof ErrorEvent; + MessageChannel: typeof MessageChannel; + MessagePort: typeof MessagePort; + EventSource: typeof EventSource; + ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest; + ReadableStreamDefaultController: typeof ReadableStreamDefaultController; + ReadableByteStreamController: typeof ReadableByteStreamController; + WritableStreamDefaultController: typeof WritableStreamDefaultController; + TransformStreamDefaultController: typeof TransformStreamDefaultController; + CompressionStream: typeof CompressionStream; + DecompressionStream: typeof DecompressionStream; + TextEncoderStream: typeof TextEncoderStream; + TextDecoderStream: typeof TextDecoderStream; + Headers: typeof Headers; + Body: typeof Body; + Request: typeof Request; + Response: typeof Response; + WebSocket: typeof WebSocket; + WebSocketPair: typeof WebSocketPair; + WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; + AbortController: typeof AbortController; + AbortSignal: typeof AbortSignal; + TextDecoder: typeof TextDecoder; + TextEncoder: typeof TextEncoder; + navigator: Navigator; + Navigator: typeof Navigator; + URL: typeof URL; + URLSearchParams: typeof URLSearchParams; + URLPattern: typeof URLPattern; + Blob: typeof Blob; + File: typeof File; + FormData: typeof FormData; + Crypto: typeof Crypto; + SubtleCrypto: typeof SubtleCrypto; + CryptoKey: typeof CryptoKey; + CacheStorage: typeof CacheStorage; + Cache: typeof Cache; + FixedLengthStream: typeof FixedLengthStream; + IdentityTransformStream: typeof IdentityTransformStream; + HTMLRewriter: typeof HTMLRewriter; +} +declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; +declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; +/** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ +declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */ +declare function btoa(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */ +declare function atob(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */ +declare function clearTimeout(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */ +declare function clearInterval(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */ +declare function queueMicrotask(task: Function): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */ +declare function structuredClone(value: T, options?: StructuredSerializeOptions): T; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */ +declare function reportError(error: any): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +declare const self: ServiceWorkerGlobalScope; +/** +* The Web Crypto API provides a set of low-level functions for common cryptographic tasks. +* The Workers runtime implements the full surface of this API, but with some differences in +* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) +* compared to those implemented in most browsers. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) +*/ +declare const crypto: Crypto; +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare const caches: CacheStorage; +declare const scheduler: Scheduler; +/** +* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, +* as well as timing of subrequests and other operations. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) +*/ +declare const performance: Performance; +declare const Cloudflare: Cloudflare; +declare const origin: string; +declare const navigator: Navigator; +interface TestController { +} +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; + readonly exports: Cloudflare.Exports; + readonly props: Props; + cache?: CacheContext; + tracing?: Tracing; +} +type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; +type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; +type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; +interface ExportedHandler { + fetch?: ExportedHandlerFetchHandler; + connect?: ExportedHandlerConnectHandler; + tail?: ExportedHandlerTailHandler; + trace?: ExportedHandlerTraceHandler; + tailStream?: ExportedHandlerTailStreamHandler; + scheduled?: ExportedHandlerScheduledHandler; + test?: ExportedHandlerTestHandler; + email?: EmailExportedHandler; + queue?: ExportedHandlerQueueHandler; +} +interface StructuredSerializeOptions { + transfer?: any[]; +} +declare abstract class Navigator { + sendBeacon(url: string, body?: BodyInit): boolean; + readonly userAgent: string; + readonly hardwareConcurrency: number; + readonly platform: string; + readonly language: string; + readonly languages: string[]; +} +interface AlarmInvocationInfo { + readonly isRetry: boolean; + readonly retryCount: number; + readonly scheduledTime: number; +} +interface Cloudflare { + readonly compatibilityFlags: Record; +} +interface CachePurgeError { + code: number; + message: string; +} +interface CachePurgeResult { + success: boolean; + errors: CachePurgeError[]; +} +interface CachePurgeOptions { + tags?: string[]; + pathPrefixes?: string[]; + purgeEverything?: boolean; +} +interface CacheContext { + purge(options: CachePurgeOptions): Promise; +} +declare abstract class ColoLocalActorNamespace { + get(actorId: string): Fetcher; +} +interface DurableObject { + fetch(request: Request): Response | Promise; + connect?(socket: Socket): void | Promise; + alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise; + webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise; + webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise; + webSocketError?(ws: WebSocket, error: unknown): void | Promise; +} +type DurableObjectStub = Fetcher & { + readonly id: DurableObjectId; + readonly name?: string; +}; +interface DurableObjectId { + toString(): string; + equals(other: DurableObjectId): boolean; + readonly name?: string; + readonly jurisdiction?: string; +} +declare abstract class DurableObjectNamespace { + newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId; + idFromName(name: string): DurableObjectId; + idFromString(id: string): DurableObjectId; + get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub; + getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub; + jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; +} +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; +interface DurableObjectNamespaceNewUniqueIdOptions { + jurisdiction?: DurableObjectJurisdiction; +} +type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; +type DurableObjectRoutingMode = "primary-only"; +interface DurableObjectNamespaceGetDurableObjectOptions { + locationHint?: DurableObjectLocationHint; + routingMode?: DurableObjectRoutingMode; +} +interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { +} +interface DurableObjectState { + waitUntil(promise: Promise): void; + readonly exports: Cloudflare.Exports; + readonly props: Props; + readonly id: DurableObjectId; + readonly storage: DurableObjectStorage; + container?: Container; + facets: DurableObjectFacets; + blockConcurrencyWhile(callback: () => Promise): Promise; + acceptWebSocket(ws: WebSocket, tags?: string[]): void; + getWebSockets(tag?: string): WebSocket[]; + setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void; + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null; + getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null; + setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; + getHibernatableWebSocketEventTimeout(): number | null; + getTags(ws: WebSocket): string[]; + abort(reason?: string): void; +} +interface DurableObjectTransaction { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + rollback(): void; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; +} +interface DurableObjectStorage { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + deleteAll(options?: DurableObjectPutOptions): Promise; + transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; + sync(): Promise; + sql: SqlStorage; + kv: SyncKvStorage; + transactionSync(closure: () => T): T; + getCurrentBookmark(): Promise; + getBookmarkForTime(timestamp: number | Date): Promise; + onNextSessionRestoreBookmark(bookmark: string): Promise; +} +interface DurableObjectListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetOptions { + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetAlarmOptions { + allowConcurrency?: boolean; +} +interface DurableObjectPutOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; + noCache?: boolean; +} +interface DurableObjectSetAlarmOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; +} +declare class WebSocketRequestResponsePair { + constructor(request: string, response: string); + get request(): string; + get response(): string; +} +interface DurableObjectFacets { + get(name: string, getStartupOptions: () => FacetStartupOptions | Promise>): Fetcher; + abort(name: string, reason: any): void; + delete(name: string): void; +} +interface FacetStartupOptions { + id?: DurableObjectId | string; + class: DurableObjectClass; +} +interface AnalyticsEngineDataset { + writeDataPoint(event?: AnalyticsEngineDataPoint): void; +} +interface AnalyticsEngineDataPoint { + indexes?: ((ArrayBuffer | string) | null)[]; + doubles?: number[]; + blobs?: ((ArrayBuffer | string) | null)[]; +} +/** + * The **`Event`** interface represents an event which takes place on an `EventTarget`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) + */ +declare class Event { + constructor(type: string, init?: EventInit); + /** + * The **`type`** read-only property of the Event interface returns a string containing the event's type. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type) + */ + get type(): string; + /** + * The **`eventPhase`** read-only property of the being evaluated. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase) + */ + get eventPhase(): number; + /** + * The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed) + */ + get composed(): boolean; + /** + * The **`bubbles`** read-only property of the Event interface indicates whether the event bubbles up through the DOM tree or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/bubbles) + */ + get bubbles(): boolean; + /** + * The **`cancelable`** read-only property of the Event interface indicates whether the event can be canceled, and therefore prevented as if the event never happened. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelable) + */ + get cancelable(): boolean; + /** + * The **`defaultPrevented`** read-only property of the Event interface returns a boolean value indicating whether or not the call to Event.preventDefault() canceled the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented) + */ + get defaultPrevented(): boolean; + /** + * The Event property **`returnValue`** indicates whether the default action for this event has been prevented or not. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/returnValue) + */ + get returnValue(): boolean; + /** + * The **`currentTarget`** read-only property of the Event interface identifies the element to which the event handler has been attached. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/currentTarget) + */ + get currentTarget(): EventTarget | undefined; + /** + * The read-only **`target`** property of the dispatched. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target) + */ + get target(): EventTarget | undefined; + /** + * The deprecated **`Event.srcElement`** is an alias for the Event.target property. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement) + */ + get srcElement(): EventTarget | undefined; + /** + * The **`timeStamp`** read-only property of the Event interface returns the time (in milliseconds) at which the event was created. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/timeStamp) + */ + get timeStamp(): number; + /** + * The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted) + */ + get isTrusted(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + get cancelBubble(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + set cancelBubble(value: boolean); + /** + * The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation) + */ + stopImmediatePropagation(): void; + /** + * The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) + */ + preventDefault(): void; + /** + * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) + */ + stopPropagation(): void; + /** + * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath) + */ + composedPath(): EventTarget[]; + static readonly NONE: number; + static readonly CAPTURING_PHASE: number; + static readonly AT_TARGET: number; + static readonly BUBBLING_PHASE: number; +} +interface EventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; +} +type EventListener = (event: EventType) => void; +interface EventListenerObject { + handleEvent(event: EventType): void; +} +type EventListenerOrEventListenerObject = EventListener | EventListenerObject; +/** + * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) + */ +declare class EventTarget = Record> { + constructor(); + /** + * The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener) + */ + addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; + /** + * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener) + */ + removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; + /** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ + dispatchEvent(event: EventMap[keyof EventMap]): boolean; +} +interface EventTargetEventListenerOptions { + capture?: boolean; +} +interface EventTargetAddEventListenerOptions { + capture?: boolean; + passive?: boolean; + once?: boolean; + signal?: AbortSignal; +} +interface EventTargetHandlerObject { + handleEvent: (event: Event) => any | undefined; +} +/** + * The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController) + */ +declare class AbortController { + constructor(); + /** + * The **`signal`** read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal) + */ + get signal(): AbortSignal; + /** + * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort) + */ + abort(reason?: any): void; +} +/** + * The **`AbortSignal`** interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal) + */ +declare abstract class AbortSignal extends EventTarget { + /** + * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) + */ + static abort(reason?: any): AbortSignal; + /** + * The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static) + */ + static timeout(delay: number): AbortSignal; + /** + * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) + */ + static any(signals: AbortSignal[]): AbortSignal; + /** + * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted) + */ + get aborted(): boolean; + /** + * The **`reason`** read-only property returns a JavaScript value that indicates the abort reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason) + */ + get reason(): any; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + get onabort(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + set onabort(value: any | null); + /** + * The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) + */ + throwIfAborted(): void; +} +interface Scheduler { + wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise; +} +interface SchedulerWaitOptions { + signal?: AbortSignal; +} +/** + * The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) + */ +declare abstract class ExtendableEvent extends Event { + /** + * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) + */ + waitUntil(promise: Promise): void; +} +/** + * The **`CustomEvent`** interface represents events initialized by an application for any purpose. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) + */ +declare class CustomEvent extends Event { + constructor(type: string, init?: CustomEventCustomEventInit); + /** + * The read-only **`detail`** property of the CustomEvent interface returns any data passed when initializing the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent/detail) + */ + get detail(): T; +} +interface CustomEventCustomEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + detail?: any; +} +/** + * The **`Blob`** interface represents a blob, which is a file-like object of immutable, raw data; they can be read as text or binary data, or converted into a ReadableStream so its methods can be used for processing the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob) + */ +declare class Blob { + constructor(bits?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); + /** + * The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) + */ + get size(): number; + /** + * The **`type`** read-only property of the Blob interface returns the MIME type of the file. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) + */ + get type(): string; + /** + * The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) + */ + slice(start?: number, end?: number, type?: string): Blob; + /** + * The **`arrayBuffer()`** method of the Blob interface returns a Promise that resolves with the contents of the blob as binary data contained in an ArrayBuffer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer) + */ + arrayBuffer(): Promise; + /** + * The **`bytes()`** method of the Blob interface returns a Promise that resolves with a Uint8Array containing the contents of the blob as an array of bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes) + */ + bytes(): Promise; + /** + * The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) + */ + text(): Promise; + /** + * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) + */ + stream(): ReadableStream; +} +interface BlobOptions { + type?: string; +} +/** + * The **`File`** interface provides information about files and allows JavaScript in a web page to access their content. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File) + */ +declare class File extends Blob { + constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions); + /** + * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) + */ + get name(): string; + /** + * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) + */ + get lastModified(): number; +} +interface FileOptions { + type?: string; + lastModified?: number; +} +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare abstract class CacheStorage { + /** + * The **`open()`** method of the the Cache object matching the `cacheName`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) + */ + open(cacheName: string): Promise; + readonly default: Cache; +} +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare abstract class Cache { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#delete) */ + delete(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) */ + match(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#put) */ + put(request: RequestInfo | URL, response: Response): Promise; +} +interface CacheQueryOptions { + ignoreMethod?: boolean; +} +/** +* The Web Crypto API provides a set of low-level functions for common cryptographic tasks. +* The Workers runtime implements the full surface of this API, but with some differences in +* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) +* compared to those implemented in most browsers. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) +*/ +declare abstract class Crypto { + /** + * The **`Crypto.subtle`** read-only property returns a cryptographic operations. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) + */ + get subtle(): SubtleCrypto; + /** + * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) + */ + getRandomValues(buffer: T): T; + /** + * The **`randomUUID()`** method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID) + */ + randomUUID(): string; + DigestStream: typeof DigestStream; +} +/** + * The **`SubtleCrypto`** interface of the Web Crypto API provides a number of low-level cryptographic functions. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto) + */ +declare abstract class SubtleCrypto { + /** + * The **`encrypt()`** method of the SubtleCrypto interface encrypts data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) + */ + encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) + */ + decrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, cipherText: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`sign()`** method of the SubtleCrypto interface generates a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign) + */ + sign(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`verify()`** method of the SubtleCrypto interface verifies a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) + */ + verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) + */ + digest(algorithm: string | SubtleCryptoHashAlgorithm, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`generateKey()`** method of the SubtleCrypto interface is used to generate a new key (for symmetric algorithms) or key pair (for public-key algorithms). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) + */ + generateKey(algorithm: string | SubtleCryptoGenerateKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`deriveKey()`** method of the SubtleCrypto interface can be used to derive a secret key from a master key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) + */ + deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`deriveBits()`** method of the key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) + */ + deriveBits(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, length?: number | null): Promise; + /** + * The **`importKey()`** method of the SubtleCrypto interface imports a key: that is, it takes as input a key in an external, portable format and gives you a CryptoKey object that you can use in the Web Crypto API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey) + */ + importKey(format: string, keyData: (ArrayBuffer | ArrayBufferView) | JsonWebKey, algorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`exportKey()`** method of the SubtleCrypto interface exports a key: that is, it takes as input a CryptoKey object and gives you the key in an external, portable format. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey) + */ + exportKey(format: string, key: CryptoKey): Promise; + /** + * The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) + */ + wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise; + /** + * The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) + */ + unwrapKey(format: string, wrappedKey: ArrayBuffer | ArrayBufferView, unwrappingKey: CryptoKey, unwrapAlgorithm: string | SubtleCryptoEncryptAlgorithm, unwrappedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; +} +/** + * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey) + */ +declare abstract class CryptoKey { + /** + * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) + */ + readonly type: string; + /** + * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) + */ + readonly extractable: boolean; + /** + * The read-only **`algorithm`** property of the CryptoKey interface returns an object describing the algorithm for which this key can be used, and any associated extra parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/algorithm) + */ + readonly algorithm: CryptoKeyKeyAlgorithm | CryptoKeyAesKeyAlgorithm | CryptoKeyHmacKeyAlgorithm | CryptoKeyRsaKeyAlgorithm | CryptoKeyEllipticKeyAlgorithm | CryptoKeyArbitraryKeyAlgorithm; + /** + * The read-only **`usages`** property of the CryptoKey interface indicates what can be done with the key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/usages) + */ + readonly usages: string[]; +} +interface CryptoKeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; +} +interface JsonWebKey { + kty: string; + use?: string; + key_ops?: string[]; + alg?: string; + ext?: boolean; + crv?: string; + x?: string; + y?: string; + d?: string; + n?: string; + e?: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; + oth?: RsaOtherPrimesInfo[]; + k?: string; +} +interface RsaOtherPrimesInfo { + r?: string; + d?: string; + t?: string; +} +interface SubtleCryptoDeriveKeyAlgorithm { + name: string; + salt?: (ArrayBuffer | ArrayBufferView); + iterations?: number; + hash?: (string | SubtleCryptoHashAlgorithm); + $public?: CryptoKey; + info?: (ArrayBuffer | ArrayBufferView); +} +interface SubtleCryptoEncryptAlgorithm { + name: string; + iv?: (ArrayBuffer | ArrayBufferView); + additionalData?: (ArrayBuffer | ArrayBufferView); + tagLength?: number; + counter?: (ArrayBuffer | ArrayBufferView); + length?: number; + label?: (ArrayBuffer | ArrayBufferView); +} +interface SubtleCryptoGenerateKeyAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + modulusLength?: number; + publicExponent?: (ArrayBuffer | ArrayBufferView); + length?: number; + namedCurve?: string; +} +interface SubtleCryptoHashAlgorithm { + name: string; +} +interface SubtleCryptoImportKeyAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + length?: number; + namedCurve?: string; + compressed?: boolean; +} +interface SubtleCryptoSignAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + dataLength?: number; + saltLength?: number; +} +interface CryptoKeyKeyAlgorithm { + name: string; +} +interface CryptoKeyAesKeyAlgorithm { + name: string; + length: number; +} +interface CryptoKeyHmacKeyAlgorithm { + name: string; + hash: CryptoKeyKeyAlgorithm; + length: number; +} +interface CryptoKeyRsaKeyAlgorithm { + name: string; + modulusLength: number; + publicExponent: ArrayBuffer | ArrayBufferView; + hash?: CryptoKeyKeyAlgorithm; +} +interface CryptoKeyEllipticKeyAlgorithm { + name: string; + namedCurve: string; +} +interface CryptoKeyArbitraryKeyAlgorithm { + name: string; + hash?: CryptoKeyKeyAlgorithm; + namedCurve?: string; + length?: number; +} +declare class DigestStream extends WritableStream { + constructor(algorithm: string | SubtleCryptoHashAlgorithm); + readonly digest: Promise; + get bytesWritten(): number | bigint; +} +/** + * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) + */ +declare class TextDecoder { + constructor(label?: string, options?: TextDecoderConstructorOptions); + /** + * The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode) + */ + decode(input?: (ArrayBuffer | ArrayBufferView), options?: TextDecoderDecodeOptions): string; + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +/** + * The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) + */ +declare class TextEncoder { + constructor(); + /** + * The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) + */ + encode(input?: string): Uint8Array; + /** + * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto) + */ + encodeInto(input: string, buffer: Uint8Array): TextEncoderEncodeIntoResult; + get encoding(): string; +} +interface TextDecoderConstructorOptions { + fatal: boolean; + ignoreBOM: boolean; +} +interface TextDecoderDecodeOptions { + stream: boolean; +} +interface TextEncoderEncodeIntoResult { + read: number; + written: number; +} +/** + * The **`ErrorEvent`** interface represents events providing information related to errors in scripts or in files. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent) + */ +declare class ErrorEvent extends Event { + constructor(type: string, init?: ErrorEventErrorEventInit); + /** + * The **`filename`** read-only property of the ErrorEvent interface returns a string containing the name of the script file in which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename) + */ + get filename(): string; + /** + * The **`message`** read-only property of the ErrorEvent interface returns a string containing a human-readable error message describing the problem. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message) + */ + get message(): string; + /** + * The **`lineno`** read-only property of the ErrorEvent interface returns an integer containing the line number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno) + */ + get lineno(): number; + /** + * The **`colno`** read-only property of the ErrorEvent interface returns an integer containing the column number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno) + */ + get colno(): number; + /** + * The **`error`** read-only property of the ErrorEvent interface returns a JavaScript value, such as an Error or DOMException, representing the error associated with this event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error) + */ + get error(): any; +} +interface ErrorEventErrorEventInit { + message?: string; + filename?: string; + lineno?: number; + colno?: number; + error?: any; +} +/** + * The **`MessageEvent`** interface represents a message received by a target object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) + */ +declare class MessageEvent extends Event { + constructor(type: string, initializer: MessageEventInit); + /** + * The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data) + */ + readonly data: any; + /** + * The **`origin`** read-only property of the origin of the message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin) + */ + readonly origin: string | null; + /** + * The **`lastEventId`** read-only property of the unique ID for the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId) + */ + readonly lastEventId: string; + /** + * The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source) + */ + readonly source: MessagePort | null; + /** + * The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports) + */ + readonly ports: MessagePort[]; +} +interface MessageEventInit { + data: ArrayBuffer | string; +} +/** + * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) + */ +declare abstract class PromiseRejectionEvent extends Event { + /** + * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) + */ + readonly promise: Promise; + /** + * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) + */ + readonly reason: any; +} +/** + * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) + */ +declare class FormData { + constructor(); + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string | Blob): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: Blob, filename?: string): void; + /** + * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) + */ + delete(name: string): void; + /** + * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) + */ + get(name: string): (File | string) | null; + /** + * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) + */ + getAll(name: string): (File | string)[]; + /** + * The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string | Blob): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: Blob, filename?: string): void; + /* Returns an array of key, value pairs for every entry in the list. */ + entries(): IterableIterator<[ + key: string, + value: File | string + ]>; + /* Returns a list of keys in the list. */ + keys(): IterableIterator; + /* Returns a list of values in the list. */ + values(): IterableIterator<(File | string)>; + forEach(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: File | string + ]>; +} +interface ContentOptions { + html?: boolean; +} +declare class HTMLRewriter { + constructor(); + on(selector: string, handlers: HTMLRewriterElementContentHandlers): HTMLRewriter; + onDocument(handlers: HTMLRewriterDocumentContentHandlers): HTMLRewriter; + transform(response: Response): Response; +} +interface HTMLRewriterElementContentHandlers { + element?(element: Element): void | Promise; + comments?(comment: Comment): void | Promise; + text?(element: Text): void | Promise; +} +interface HTMLRewriterDocumentContentHandlers { + doctype?(doctype: Doctype): void | Promise; + comments?(comment: Comment): void | Promise; + text?(text: Text): void | Promise; + end?(end: DocumentEnd): void | Promise; +} +interface Doctype { + readonly name: string | null; + readonly publicId: string | null; + readonly systemId: string | null; +} +interface Element { + tagName: string; + readonly attributes: IterableIterator; + readonly removed: boolean; + readonly namespaceURI: string; + getAttribute(name: string): string | null; + hasAttribute(name: string): boolean; + setAttribute(name: string, value: string): Element; + removeAttribute(name: string): Element; + before(content: string | ReadableStream | Response, options?: ContentOptions): Element; + after(content: string | ReadableStream | Response, options?: ContentOptions): Element; + prepend(content: string | ReadableStream | Response, options?: ContentOptions): Element; + append(content: string | ReadableStream | Response, options?: ContentOptions): Element; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Element; + remove(): Element; + removeAndKeepContent(): Element; + setInnerContent(content: string | ReadableStream | Response, options?: ContentOptions): Element; + onEndTag(handler: (tag: EndTag) => void | Promise): void; +} +interface EndTag { + name: string; + before(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + after(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + remove(): EndTag; +} +interface Comment { + text: string; + readonly removed: boolean; + before(content: string, options?: ContentOptions): Comment; + after(content: string, options?: ContentOptions): Comment; + replace(content: string, options?: ContentOptions): Comment; + remove(): Comment; +} +interface Text { + readonly text: string; + readonly lastInTextNode: boolean; + readonly removed: boolean; + before(content: string | ReadableStream | Response, options?: ContentOptions): Text; + after(content: string | ReadableStream | Response, options?: ContentOptions): Text; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Text; + remove(): Text; +} +interface DocumentEnd { + append(content: string, options?: ContentOptions): DocumentEnd; +} +/** + * This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) + */ +declare abstract class FetchEvent extends ExtendableEvent { + /** + * The **`request`** read-only property of the the event handler. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) + */ + readonly request: Request; + /** + * The **`respondWith()`** method of allows you to provide a promise for a Response yourself. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) + */ + respondWith(promise: Response | Promise): void; + passThroughOnException(): void; +} +type HeadersInit = Headers | Iterable> | Record; +/** + * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) + */ +declare class Headers { + constructor(init?: HeadersInit); + /** + * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) + */ + get(name: string): string | null; + getAll(name: string): string[]; + /** + * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) + */ + getSetCookie(): string[]; + /** + * The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) + */ + set(name: string, value: string): void; + /** + * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) + */ + delete(name: string): void; + forEach(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void; + /* Returns an iterator allowing to go through all key/value pairs contained in this object. */ + entries(): IterableIterator<[ + key: string, + value: string + ]>; + /* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */ + keys(): IterableIterator; + /* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */ + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: string + ]>; +} +type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable | AsyncIterable; +declare abstract class Body { + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ + get body(): ReadableStream | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ + get bodyUsed(): boolean; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */ + arrayBuffer(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */ + bytes(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */ + text(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ + json(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/formData) */ + formData(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */ + blob(): Promise; +} +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +declare var Response: { + prototype: Response; + new (body?: BodyInit | null, init?: ResponseInit): Response; + error(): Response; + redirect(url: string, status?: number): Response; + json(any: any, maybeInit?: (ResponseInit | Response)): Response; +}; +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +interface Response extends Body { + /** + * The **`clone()`** method of the Response interface creates a clone of a response object, identical in every way, but stored in a different variable. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/clone) + */ + clone(): Response; + /** + * The **`status`** read-only property of the Response interface contains the HTTP status codes of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status) + */ + status: number; + /** + * The **`statusText`** read-only property of the Response interface contains the status message corresponding to the HTTP status code in Response.status. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/statusText) + */ + statusText: string; + /** + * The **`headers`** read-only property of the with the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) + */ + headers: Headers; + /** + * The **`ok`** read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok) + */ + ok: boolean; + /** + * The **`redirected`** read-only property of the Response interface indicates whether or not the response is the result of a request you made which was redirected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/redirected) + */ + redirected: boolean; + /** + * The **`url`** read-only property of the Response interface contains the URL of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) + */ + url: string; + webSocket: WebSocket | null; + cf: any | undefined; + /** + * The **`type`** read-only property of the Response interface contains the type of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) + */ + type: "default" | "error"; +} +interface ResponseInit { + status?: number; + statusText?: string; + headers?: HeadersInit; + cf?: any; + webSocket?: (WebSocket | null); + encodeBody?: "automatic" | "manual"; +} +type RequestInfo> = Request | string; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +declare var Request: { + prototype: Request; + new >(input: RequestInfo | URL, init?: RequestInit): Request; +}; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +interface Request> extends Body { + /** + * The **`clone()`** method of the Request interface creates a copy of the current `Request` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) + */ + clone(): Request; + /** + * The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method) + */ + method: string; + /** + * The **`url`** read-only property of the Request interface contains the URL of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url) + */ + url: string; + /** + * The **`headers`** read-only property of the with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers) + */ + headers: Headers; + /** + * The **`redirect`** read-only property of the Request interface contains the mode for how redirects are handled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/redirect) + */ + redirect: string; + fetcher: Fetcher | null; + /** + * The read-only **`signal`** property of the Request interface returns the AbortSignal associated with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal) + */ + signal: AbortSignal; + cf?: Cf; + /** + * The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity) + */ + integrity: string; + /** + * The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) + */ + keepalive: boolean; + /** + * The **`cache`** read-only property of the Request interface contains the cache mode of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) + */ + cache?: "no-store" | "no-cache"; +} +interface RequestInit { + /* A string to set request's method. */ + method?: string; + /* A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ + headers?: HeadersInit; + /* A BodyInit object or null to set request's body. */ + body?: BodyInit | null; + /* A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ + redirect?: string; + fetcher?: (Fetcher | null); + cf?: Cf; + /* A string indicating how the request will interact with the browser's cache to set request's cache. */ + cache?: "no-store" | "no-cache"; + /* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ + integrity?: string; + /* An AbortSignal to set request's signal. */ + signal?: (AbortSignal | null); + encodeResponseBody?: "automatic" | "manual"; +} +type Service Rpc.WorkerEntrypointBranded) | Rpc.WorkerEntrypointBranded | ExportedHandler | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? Fetcher> : T extends Rpc.WorkerEntrypointBranded ? Fetcher : T extends Exclude ? never : Fetcher; +type Fetcher = (T extends Rpc.EntrypointBranded ? Rpc.Provider : unknown) & { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + connect(address: SocketAddress | string, options?: SocketOptions): Socket; +}; +interface KVNamespaceListKey { + name: Key; + expiration?: number; + metadata?: Metadata; +} +type KVNamespaceListResult = { + list_complete: false; + keys: KVNamespaceListKey[]; + cursor: string; + cacheStatus: string | null; +} | { + list_complete: true; + keys: KVNamespaceListKey[]; + cacheStatus: string | null; +}; +interface KVNamespace { + get(key: Key, options?: Partial>): Promise; + get(key: Key, type: "text"): Promise; + get(key: Key, type: "json"): Promise; + get(key: Key, type: "arrayBuffer"): Promise; + get(key: Key, type: "stream"): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"text">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"json">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"arrayBuffer">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"stream">): Promise; + get(key: Array, type: "text"): Promise>; + get(key: Array, type: "json"): Promise>; + get(key: Array, options?: Partial>): Promise>; + get(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>; + get(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>; + list(options?: KVNamespaceListOptions): Promise>; + put(key: Key, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVNamespacePutOptions): Promise; + getWithMetadata(key: Key, options?: Partial>): Promise>; + getWithMetadata(key: Key, type: "text"): Promise>; + getWithMetadata(key: Key, type: "json"): Promise>; + getWithMetadata(key: Key, type: "arrayBuffer"): Promise>; + getWithMetadata(key: Key, type: "stream"): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"text">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"json">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"arrayBuffer">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"stream">): Promise>; + getWithMetadata(key: Array, type: "text"): Promise>>; + getWithMetadata(key: Array, type: "json"): Promise>>; + getWithMetadata(key: Array, options?: Partial>): Promise>>; + getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>>; + getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>>; + delete(key: Key): Promise; +} +interface KVNamespaceListOptions { + limit?: number; + prefix?: (string | null); + cursor?: (string | null); +} +interface KVNamespaceGetOptions { + type: Type; + cacheTtl?: number; +} +interface KVNamespacePutOptions { + expiration?: number; + expirationTtl?: number; + metadata?: (any | null); +} +interface KVNamespaceGetWithMetadataResult { + value: Value | null; + metadata: Metadata | null; + cacheStatus: string | null; +} +type QueueContentType = "text" | "bytes" | "json" | "v8"; +interface Queue { + metrics(): Promise; + send(message: Body, options?: QueueSendOptions): Promise; + sendBatch(messages: Iterable>, options?: QueueSendBatchOptions): Promise; +} +interface QueueSendMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendMetadata { + metrics: QueueSendMetrics; +} +interface QueueSendResponse { + metadata: QueueSendMetadata; +} +interface QueueSendBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendBatchMetadata { + metrics: QueueSendBatchMetrics; +} +interface QueueSendBatchResponse { + metadata: QueueSendBatchMetadata; +} +interface QueueSendOptions { + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueSendBatchOptions { + delaySeconds?: number; +} +interface MessageSendRequest { + body: Body; + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetadata { + metrics: MessageBatchMetrics; +} +interface QueueRetryOptions { + delaySeconds?: number; +} +interface Message { + readonly id: string; + readonly timestamp: Date; + readonly body: Body; + readonly attempts: number; + retry(options?: QueueRetryOptions): void; + ack(): void; +} +interface QueueEvent extends ExtendableEvent { + readonly messages: readonly Message[]; + readonly queue: string; + readonly metadata: MessageBatchMetadata; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface MessageBatch { + readonly messages: readonly Message[]; + readonly queue: string; + readonly metadata: MessageBatchMetadata; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface R2Error extends Error { + readonly name: string; + readonly code: number; + readonly message: string; + readonly action: string; + readonly stack: any; +} +interface R2ListOptions { + limit?: number; + prefix?: string; + cursor?: string; + delimiter?: string; + startAfter?: string; + include?: ("httpMetadata" | "customMetadata")[]; +} +interface R2Bucket { + head(key: string): Promise; + get(key: string, options: R2GetOptions & { + onlyIf: R2Conditional | Headers; + }): Promise; + get(key: string, options?: R2GetOptions): Promise; + put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions & { + onlyIf: R2Conditional | Headers; + }): Promise; + put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions): Promise; + createMultipartUpload(key: string, options?: R2MultipartOptions): Promise; + resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload; + delete(keys: string | string[]): Promise; + list(options?: R2ListOptions): Promise; +} +interface R2MultipartUpload { + readonly key: string; + readonly uploadId: string; + uploadPart(partNumber: number, value: ReadableStream | (ArrayBuffer | ArrayBufferView) | string | Blob, options?: R2UploadPartOptions): Promise; + abort(): Promise; + complete(uploadedParts: R2UploadedPart[]): Promise; +} +interface R2UploadedPart { + partNumber: number; + etag: string; +} +declare abstract class R2Object { + readonly key: string; + readonly version: string; + readonly size: number; + readonly etag: string; + readonly httpEtag: string; + readonly checksums: R2Checksums; + readonly uploaded: Date; + readonly httpMetadata?: R2HTTPMetadata; + readonly customMetadata?: Record; + readonly range?: R2Range; + readonly storageClass: string; + readonly ssecKeyMd5?: string; + writeHttpMetadata(headers: Headers): void; +} +interface R2ObjectBody extends R2Object { + get body(): ReadableStream; + get bodyUsed(): boolean; + arrayBuffer(): Promise; + bytes(): Promise; + text(): Promise; + json(): Promise; + blob(): Promise; +} +type R2Range = { + offset: number; + length?: number; +} | { + offset?: number; + length: number; +} | { + suffix: number; +}; +interface R2Conditional { + etagMatches?: string; + etagDoesNotMatch?: string; + uploadedBefore?: Date; + uploadedAfter?: Date; + secondsGranularity?: boolean; +} +interface R2GetOptions { + onlyIf?: (R2Conditional | Headers); + range?: (R2Range | Headers); + ssecKey?: (ArrayBuffer | string); +} +interface R2PutOptions { + onlyIf?: (R2Conditional | Headers); + httpMetadata?: (R2HTTPMetadata | Headers); + customMetadata?: Record; + md5?: ((ArrayBuffer | ArrayBufferView) | string); + sha1?: ((ArrayBuffer | ArrayBufferView) | string); + sha256?: ((ArrayBuffer | ArrayBufferView) | string); + sha384?: ((ArrayBuffer | ArrayBufferView) | string); + sha512?: ((ArrayBuffer | ArrayBufferView) | string); + storageClass?: string; + ssecKey?: (ArrayBuffer | string); +} +interface R2MultipartOptions { + httpMetadata?: (R2HTTPMetadata | Headers); + customMetadata?: Record; + storageClass?: string; + ssecKey?: (ArrayBuffer | string); +} +interface R2Checksums { + readonly md5?: ArrayBuffer; + readonly sha1?: ArrayBuffer; + readonly sha256?: ArrayBuffer; + readonly sha384?: ArrayBuffer; + readonly sha512?: ArrayBuffer; + toJSON(): R2StringChecksums; +} +interface R2StringChecksums { + md5?: string; + sha1?: string; + sha256?: string; + sha384?: string; + sha512?: string; +} +interface R2HTTPMetadata { + contentType?: string; + contentLanguage?: string; + contentDisposition?: string; + contentEncoding?: string; + cacheControl?: string; + cacheExpiry?: Date; +} +type R2Objects = { + objects: R2Object[]; + delimitedPrefixes: string[]; +} & ({ + truncated: true; + cursor: string; +} | { + truncated: false; +}); +interface R2UploadPartOptions { + ssecKey?: (ArrayBuffer | string); +} +declare abstract class ScheduledEvent extends ExtendableEvent { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface ScheduledController { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface QueuingStrategy { + highWaterMark?: (number | bigint); + size?: (chunk: T) => number | bigint; +} +interface UnderlyingSink { + type?: string; + start?: (controller: WritableStreamDefaultController) => void | Promise; + write?: (chunk: W, controller: WritableStreamDefaultController) => void | Promise; + abort?: (reason: any) => void | Promise; + close?: () => void | Promise; +} +interface UnderlyingByteSource { + type: "bytes"; + autoAllocateChunkSize?: number; + start?: (controller: ReadableByteStreamController) => void | Promise; + pull?: (controller: ReadableByteStreamController) => void | Promise; + cancel?: (reason: any) => void | Promise; +} +interface UnderlyingSource { + type?: "" | undefined; + start?: (controller: ReadableStreamDefaultController) => void | Promise; + pull?: (controller: ReadableStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: (number | bigint); +} +interface Transformer { + readableType?: string; + writableType?: string; + start?: (controller: TransformStreamDefaultController) => void | Promise; + transform?: (chunk: I, controller: TransformStreamDefaultController) => void | Promise; + flush?: (controller: TransformStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: number; +} +interface StreamPipeOptions { + preventAbort?: boolean; + preventCancel?: boolean; + /** + * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + * + * Errors and closures of the source and destination streams propagate as follows: + * + * An error in this source readable stream will abort destination, unless preventAbort is truthy. The returned promise will be rejected with the source's error, or with any error that occurs during aborting the destination. + * + * An error in destination will cancel this source readable stream, unless preventCancel is truthy. The returned promise will be rejected with the destination's error, or with any error that occurs during canceling the source. + * + * When this source readable stream closes, destination will be closed, unless preventClose is truthy. The returned promise will be fulfilled once this process completes, unless an error is encountered while closing the destination, in which case it will be rejected with that error. + * + * If destination starts out closed or closing, this source readable stream will be canceled, unless preventCancel is true. The returned promise will be rejected with an error indicating piping to a closed stream failed, or with any error that occurs during canceling the source. + * + * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set. + */ + preventClose?: boolean; + signal?: AbortSignal; +} +type ReadableStreamReadResult = { + done: false; + value: R; +} | { + done: true; + value?: undefined; +}; +/** + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +interface ReadableStream { + /** + * The **`locked`** read-only property of the ReadableStream interface returns whether or not the readable stream is locked to a reader. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/locked) + */ + get locked(): boolean; + /** + * The **`cancel()`** method of the ReadableStream interface returns a Promise that resolves when the stream is canceled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/cancel) + */ + cancel(reason?: any): Promise; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(): ReadableStreamDefaultReader; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(options: ReadableStreamGetReaderOptions): ReadableStreamBYOBReader; + /** + * The **`pipeThrough()`** method of the ReadableStream interface provides a chainable way of piping the current stream through a transform stream or any other writable/readable pair. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeThrough) + */ + pipeThrough(transform: ReadableWritablePair, options?: StreamPipeOptions): ReadableStream; + /** + * The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo) + */ + pipeTo(destination: WritableStream, options?: StreamPipeOptions): Promise; + /** + * The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee) + */ + tee(): [ + ReadableStream, + ReadableStream + ]; + values(options?: ReadableStreamValuesOptions): AsyncIterableIterator; + [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator; +} +/** + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +declare const ReadableStream: { + prototype: ReadableStream; + new (underlyingSource: UnderlyingByteSource, strategy?: QueuingStrategy): ReadableStream; + new (underlyingSource?: UnderlyingSource, strategy?: QueuingStrategy): ReadableStream; +}; +/** + * The **`ReadableStreamDefaultReader`** interface of the Streams API represents a default reader that can be used to read stream data supplied from a network (such as a fetch request). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader) + */ +declare class ReadableStreamDefaultReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamDefaultReader interface returns a Promise providing access to the next chunk in the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/read) + */ + read(): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamDefaultReader interface releases the reader's lock on the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/releaseLock) + */ + releaseLock(): void; +} +/** + * The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) + */ +declare class ReadableStreamBYOBReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read) + */ + read(view: T): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock) + */ + releaseLock(): void; + readAtLeast(minElements: number, view: T): Promise>; +} +interface ReadableStreamBYOBReaderReadableStreamBYOBReaderReadOptions { + min?: number; +} +interface ReadableStreamGetReaderOptions { + /** + * Creates a ReadableStreamBYOBReader and locks the stream to the new reader. + * + * This call behaves the same way as the no-argument variant, except that it only works on readable byte streams, i.e. streams which were constructed specifically with the ability to handle "bring your own buffer" reading. The returned BYOB reader provides the ability to directly read individual chunks from the stream via its read() method, into developer-supplied buffers, allowing more precise control over allocation. + */ + mode: "byob"; +} +/** + * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) + */ +declare abstract class ReadableStreamBYOBRequest { + /** + * The **`view`** getter property of the ReadableStreamBYOBRequest interface returns the current view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/view) + */ + get view(): Uint8Array | null; + /** + * The **`respond()`** method of the ReadableStreamBYOBRequest interface is used to signal to the associated readable byte stream that the specified number of bytes were written into the ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respond) + */ + respond(bytesWritten: number): void; + /** + * The **`respondWithNewView()`** method of the ReadableStreamBYOBRequest interface specifies a new view that the consumer of the associated readable byte stream should write to instead of ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respondWithNewView) + */ + respondWithNewView(view: ArrayBuffer | ArrayBufferView): void; + get atLeast(): number | null; +} +/** + * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController) + */ +declare abstract class ReadableStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the required to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableStreamDefaultController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue) + */ + enqueue(chunk?: R): void; + /** + * The **`error()`** method of the with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error) + */ + error(reason: any): void; +} +/** + * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) + */ +declare abstract class ReadableByteStreamController { + /** + * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest) + */ + get byobRequest(): ReadableStreamBYOBRequest | null; + /** + * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableByteStreamController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue) + */ + enqueue(chunk: ArrayBuffer | ArrayBufferView): void; + /** + * The **`error()`** method of the ReadableByteStreamController interface causes any future interactions with the associated stream to error with the specified reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/error) + */ + error(reason: any): void; +} +/** + * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController) + */ +declare abstract class WritableStreamDefaultController { + /** + * The read-only **`signal`** property of the WritableStreamDefaultController interface returns the AbortSignal associated with the controller. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/signal) + */ + get signal(): AbortSignal; + /** + * The **`error()`** method of the with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error) + */ + error(reason?: any): void; +} +/** + * The **`TransformStreamDefaultController`** interface of the Streams API provides methods to manipulate the associated ReadableStream and WritableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController) + */ +declare abstract class TransformStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the TransformStreamDefaultController interface returns the desired size to fill the queue of the associated ReadableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`enqueue()`** method of the TransformStreamDefaultController interface enqueues the given chunk in the readable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/enqueue) + */ + enqueue(chunk?: O): void; + /** + * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error) + */ + error(reason: any): void; + /** + * The **`terminate()`** method of the TransformStreamDefaultController interface closes the readable side and errors the writable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/terminate) + */ + terminate(): void; +} +interface ReadableWritablePair { + readable: ReadableStream; + /** + * Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + */ + writable: WritableStream; +} +/** + * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream) + */ +declare class WritableStream { + constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy); + /** + * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked) + */ + get locked(): boolean; + /** + * The **`abort()`** method of the WritableStream interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the WritableStream interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close) + */ + close(): Promise; + /** + * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter) + */ + getWriter(): WritableStreamDefaultWriter; +} +/** + * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter) + */ +declare class WritableStreamDefaultWriter { + constructor(stream: WritableStream); + /** + * The **`closed`** read-only property of the the stream errors or the writer's lock is released. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed) + */ + get closed(): Promise; + /** + * The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready) + */ + get ready(): Promise; + /** + * The **`desiredSize`** read-only property of the to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close) + */ + close(): Promise; + /** + * The **`write()`** method of the operation. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write) + */ + write(chunk?: W): Promise; + /** + * The **`releaseLock()`** method of the corresponding stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock) + */ + releaseLock(): void; +} +/** + * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream) + */ +declare class TransformStream { + constructor(transformer?: Transformer, writableStrategy?: QueuingStrategy, readableStrategy?: QueuingStrategy); + /** + * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable) + */ + get readable(): ReadableStream; + /** + * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable) + */ + get writable(): WritableStream; +} +declare class FixedLengthStream extends IdentityTransformStream { + constructor(expectedLength: number | bigint, queuingStrategy?: IdentityTransformStreamQueuingStrategy); +} +declare class IdentityTransformStream extends TransformStream { + constructor(queuingStrategy?: IdentityTransformStreamQueuingStrategy); +} +interface IdentityTransformStreamQueuingStrategy { + highWaterMark?: (number | bigint); +} +interface ReadableStreamValuesOptions { + preventCancel?: boolean; +} +/** + * The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) + */ +declare class CompressionStream extends TransformStream { + constructor(format: "gzip" | "deflate" | "deflate-raw"); +} +/** + * The **`DecompressionStream`** interface of the Compression Streams API is an API for decompressing a stream of data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream) + */ +declare class DecompressionStream extends TransformStream { + constructor(format: "gzip" | "deflate" | "deflate-raw"); +} +/** + * The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream) + */ +declare class TextEncoderStream extends TransformStream { + constructor(); + get encoding(): string; +} +/** + * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream) + */ +declare class TextDecoderStream extends TransformStream { + constructor(label?: string, options?: TextDecoderStreamTextDecoderStreamInit); + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +interface TextDecoderStreamTextDecoderStreamInit { + fatal?: boolean; + ignoreBOM?: boolean; +} +/** + * The **`ByteLengthQueuingStrategy`** interface of the Streams API provides a built-in byte length queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy) + */ +declare class ByteLengthQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`ByteLengthQueuingStrategy.highWaterMark`** property returns the total number of bytes that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +/** + * The **`CountQueuingStrategy`** interface of the Streams API provides a built-in chunk counting queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy) + */ +declare class CountQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`CountQueuingStrategy.highWaterMark`** property returns the total number of chunks that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +interface QueuingStrategyInit { + /** + * Creates a new ByteLengthQueuingStrategy with the provided high water mark. + * + * Note that the provided high water mark will not be validated ahead of time. Instead, if it is negative, NaN, or not a number, the resulting ByteLengthQueuingStrategy will cause the corresponding stream constructor to throw. + */ + highWaterMark: number; +} +interface TracePreviewInfo { + id: string; + slug: string; + name: string; +} +interface ScriptVersion { + id?: string; + tag?: string; + message?: string; +} +declare abstract class TailEvent extends ExtendableEvent { + readonly events: TraceItem[]; + readonly traces: TraceItem[]; +} +interface TraceItem { + readonly event: (TraceItemFetchEventInfo | TraceItemJsRpcEventInfo | TraceItemConnectEventInfo | TraceItemScheduledEventInfo | TraceItemAlarmEventInfo | TraceItemQueueEventInfo | TraceItemEmailEventInfo | TraceItemTailEventInfo | TraceItemCustomEventInfo | TraceItemHibernatableWebSocketEventInfo) | null; + readonly eventTimestamp: number | null; + readonly logs: TraceLog[]; + readonly exceptions: TraceException[]; + readonly diagnosticsChannelEvents: TraceDiagnosticChannelEvent[]; + readonly scriptName: string | null; + readonly entrypoint?: string; + readonly scriptVersion?: ScriptVersion; + readonly dispatchNamespace?: string; + readonly scriptTags?: string[]; + readonly tailAttributes?: Record; + readonly preview?: TracePreviewInfo; + readonly durableObjectId?: string; + readonly outcome: string; + readonly executionModel: string; + readonly truncated: boolean; + readonly cpuTime: number; + readonly wallTime: number; +} +interface TraceItemAlarmEventInfo { + readonly scheduledTime: Date; +} +interface TraceItemConnectEventInfo { +} +interface TraceItemCustomEventInfo { +} +interface TraceItemScheduledEventInfo { + readonly scheduledTime: number; + readonly cron: string; +} +interface TraceItemQueueEventInfo { + readonly queue: string; + readonly batchSize: number; +} +interface TraceItemEmailEventInfo { + readonly mailFrom: string; + readonly rcptTo: string; + readonly rawSize: number; +} +interface TraceItemTailEventInfo { + readonly consumedEvents: TraceItemTailEventInfoTailItem[]; +} +interface TraceItemTailEventInfoTailItem { + readonly scriptName: string | null; +} +interface TraceItemFetchEventInfo { + readonly response?: TraceItemFetchEventInfoResponse; + readonly request: TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoRequest { + readonly cf?: any; + readonly headers: Record; + readonly method: string; + readonly url: string; + getUnredacted(): TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoResponse { + readonly status: number; +} +interface TraceItemJsRpcEventInfo { + readonly rpcMethod: string; +} +interface TraceItemHibernatableWebSocketEventInfo { + readonly getWebSocketEvent: TraceItemHibernatableWebSocketEventInfoMessage | TraceItemHibernatableWebSocketEventInfoClose | TraceItemHibernatableWebSocketEventInfoError; +} +interface TraceItemHibernatableWebSocketEventInfoMessage { + readonly webSocketEventType: string; +} +interface TraceItemHibernatableWebSocketEventInfoClose { + readonly webSocketEventType: string; + readonly code: number; + readonly wasClean: boolean; +} +interface TraceItemHibernatableWebSocketEventInfoError { + readonly webSocketEventType: string; +} +interface TraceLog { + readonly timestamp: number; + readonly level: string; + readonly message: any; +} +interface TraceException { + readonly timestamp: number; + readonly message: string; + readonly name: string; + readonly stack?: string; +} +interface TraceDiagnosticChannelEvent { + readonly timestamp: number; + readonly channel: string; + readonly message: any; +} +interface TraceMetrics { + readonly cpuTime: number; + readonly wallTime: number; +} +interface UnsafeTraceMetrics { + fromTrace(item: TraceItem): TraceMetrics; +} +/** + * The **`URL`** interface is used to parse, construct, normalize, and encode URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) + */ +declare class URL { + constructor(url: string | URL, base?: string | URL); + /** + * The **`origin`** read-only property of the URL interface returns a string containing the Unicode serialization of the origin of the represented URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/origin) + */ + get origin(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + get href(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + set href(value: string); + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + get protocol(): string; + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + set protocol(value: string); + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + get username(): string; + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + set username(value: string); + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + get password(): string; + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + set password(value: string); + /** + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + get host(): string; + /** + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + set host(value: string); + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + get hostname(): string; + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + set hostname(value: string); + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + get port(): string; + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + set port(value: string); + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + get pathname(): string; + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + set pathname(value: string); + /** + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + get search(): string; + /** + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + set search(value: string); + /** + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + get hash(): string; + /** + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + set hash(value: string); + /** + * The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams) + */ + get searchParams(): URLSearchParams; + /** + * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON) + */ + toJSON(): string; + /*function toString() { [native code] }*/ + toString(): string; + /** + * The **`URL.canParse()`** static method of the URL interface returns a boolean indicating whether or not an absolute URL, or a relative URL combined with a base URL, are parsable and valid. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/canParse_static) + */ + static canParse(url: string, base?: string): boolean; + /** + * The **`URL.parse()`** static method of the URL interface returns a newly created URL object representing the URL defined by the parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/parse_static) + */ + static parse(url: string, base?: string): URL | null; + /** + * The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static) + */ + static createObjectURL(object: File | Blob): string; + /** + * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static) + */ + static revokeObjectURL(object_url: string): void; +} +/** + * The **`URLSearchParams`** interface defines utility methods to work with the query string of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams) + */ +declare class URLSearchParams { + constructor(init?: (Iterable> | Record | string)); + /** + * The **`size`** read-only property of the URLSearchParams interface indicates the total number of search parameter entries. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size) + */ + get size(): number; + /** + * The **`append()`** method of the URLSearchParams interface appends a specified key/value pair as a new search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/delete) + */ + delete(name: string, value?: string): void; + /** + * The **`get()`** method of the URLSearchParams interface returns the first value associated to the given search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get) + */ + get(name: string): string | null; + /** + * The **`getAll()`** method of the URLSearchParams interface returns all the values associated with a given search parameter as an array. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll) + */ + getAll(name: string): string[]; + /** + * The **`has()`** method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has) + */ + has(name: string, value?: string): boolean; + /** + * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set) + */ + set(name: string, value: string): void; + /** + * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) + */ + sort(): void; + /* Returns an array of key, value pairs for every entry in the search params. */ + entries(): IterableIterator<[ + key: string, + value: string + ]>; + /* Returns a list of keys in the search params. */ + keys(): IterableIterator; + /* Returns a list of values in the search params. */ + values(): IterableIterator; + forEach(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; + /*function toString() { [native code] }*/ + toString(): string; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: string + ]>; +} +declare class URLPattern { + constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions); + get protocol(): string; + get username(): string; + get password(): string; + get hostname(): string; + get port(): string; + get pathname(): string; + get search(): string; + get hash(): string; + get hasRegExpGroups(): boolean; + test(input?: (string | URLPatternInit), baseURL?: string): boolean; + exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null; +} +interface URLPatternInit { + protocol?: string; + username?: string; + password?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; + baseURL?: string; +} +interface URLPatternComponentResult { + input: string; + groups: Record; +} +interface URLPatternResult { + inputs: (string | URLPatternInit)[]; + protocol: URLPatternComponentResult; + username: URLPatternComponentResult; + password: URLPatternComponentResult; + hostname: URLPatternComponentResult; + port: URLPatternComponentResult; + pathname: URLPatternComponentResult; + search: URLPatternComponentResult; + hash: URLPatternComponentResult; +} +interface URLPatternOptions { + ignoreCase?: boolean; +} +/** + * A `CloseEvent` is sent to clients using WebSockets when the connection is closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent) + */ +declare class CloseEvent extends Event { + constructor(type: string, initializer?: CloseEventInit); + /** + * The **`code`** read-only property of the CloseEvent interface returns a WebSocket connection close code indicating the reason the connection was closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/code) + */ + readonly code: number; + /** + * The **`reason`** read-only property of the CloseEvent interface returns the WebSocket connection close reason the server gave for closing the connection; that is, a concise human-readable prose explanation for the closure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/reason) + */ + readonly reason: string; + /** + * The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean) + */ + readonly wasClean: boolean; +} +interface CloseEventInit { + code?: number; + reason?: string; + wasClean?: boolean; +} +type WebSocketEventMap = { + close: CloseEvent; + message: MessageEvent; + open: Event; + error: ErrorEvent; +}; +/** + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +declare var WebSocket: { + prototype: WebSocket; + new (url: string, protocols?: (string[] | string)): WebSocket; + readonly READY_STATE_CONNECTING: number; + readonly CONNECTING: number; + readonly READY_STATE_OPEN: number; + readonly OPEN: number; + readonly READY_STATE_CLOSING: number; + readonly CLOSING: number; + readonly READY_STATE_CLOSED: number; + readonly CLOSED: number; +}; +/** + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +interface WebSocket extends EventTarget { + accept(options?: WebSocketAcceptOptions): void; + /** + * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send) + */ + send(message: (ArrayBuffer | ArrayBufferView) | string): void; + /** + * The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close) + */ + close(code?: number, reason?: string): void; + serializeAttachment(attachment: any): void; + deserializeAttachment(): any | null; + /** + * The **`WebSocket.readyState`** read-only property returns the current state of the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/readyState) + */ + readyState: number; + /** + * The **`WebSocket.url`** read-only property returns the absolute URL of the WebSocket as resolved by the constructor. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/url) + */ + url: string | null; + /** + * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol) + */ + protocol: string | null; + /** + * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) + */ + extensions: string | null; + /** + * The **`WebSocket.binaryType`** property controls the type of binary data being received over the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) + */ + binaryType: "blob" | "arraybuffer"; +} +interface WebSocketAcceptOptions { + /** + * When set to `true`, receiving a server-initiated WebSocket Close frame will not + * automatically send a reciprocal Close frame, leaving the connection in a half-open + * state. This is useful for proxying scenarios where you need to coordinate closing + * both sides independently. Defaults to `false` when the + * `no_web_socket_half_open_by_default` compatibility flag is enabled. + */ + allowHalfOpen?: boolean; +} +declare const WebSocketPair: { + new (): { + 0: WebSocket; + 1: WebSocket; + }; +}; +interface SqlStorage { + exec>(query: string, ...bindings: any[]): SqlStorageCursor; + get databaseSize(): number; + Cursor: typeof SqlStorageCursor; + Statement: typeof SqlStorageStatement; +} +declare abstract class SqlStorageStatement { +} +type SqlStorageValue = ArrayBuffer | string | number | null; +declare abstract class SqlStorageCursor> { + next(): { + done?: false; + value: T; + } | { + done: true; + value?: never; + }; + toArray(): T[]; + one(): T; + raw(): IterableIterator; + columnNames: string[]; + get rowsRead(): number; + get rowsWritten(): number; + [Symbol.iterator](): IterableIterator; +} +interface Socket { + get readable(): ReadableStream; + get writable(): WritableStream; + get closed(): Promise; + get opened(): Promise; + get upgraded(): boolean; + get secureTransport(): "on" | "off" | "starttls"; + close(): Promise; + startTls(options?: TlsOptions): Socket; +} +interface SocketOptions { + secureTransport?: string; + allowHalfOpen: boolean; + highWaterMark?: (number | bigint); +} +interface SocketAddress { + hostname: string; + port: number; +} +interface TlsOptions { + expectedServerHostname?: string; +} +interface SocketInfo { + remoteAddress?: string; + localAddress?: string; +} +/** + * The **`EventSource`** interface is web content's interface to server-sent events. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource) + */ +declare class EventSource extends EventTarget { + constructor(url: string, init?: EventSourceEventSourceInit); + /** + * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) + */ + close(): void; + /** + * The **`url`** read-only property of the URL of the source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) + */ + get url(): string; + /** + * The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) + */ + get withCredentials(): boolean; + /** + * The **`readyState`** read-only property of the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) + */ + get readyState(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + get onopen(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + set onopen(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + get onmessage(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + set onmessage(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + get onerror(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + set onerror(value: any | null); + static readonly CONNECTING: number; + static readonly OPEN: number; + static readonly CLOSED: number; + static from(stream: ReadableStream): EventSource; +} +interface EventSourceEventSourceInit { + withCredentials?: boolean; + fetcher?: Fetcher; +} +interface Container { + get running(): boolean; + start(options?: ContainerStartupOptions): void; + monitor(): Promise; + destroy(error?: any): Promise; + signal(signo: number): void; + getTcpPort(port: number): Fetcher; + setInactivityTimeout(durationMs: number | bigint): Promise; + interceptOutboundHttp(addr: string, binding: Fetcher): Promise; + interceptAllOutboundHttp(binding: Fetcher): Promise; + snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise; + snapshotContainer(options: ContainerSnapshotOptions): Promise; + interceptOutboundHttps(addr: string, binding: Fetcher): Promise; +} +interface ContainerDirectorySnapshot { + id: string; + size: number; + dir: string; + name?: string; +} +interface ContainerDirectorySnapshotOptions { + dir: string; + name?: string; +} +interface ContainerDirectorySnapshotRestoreParams { + snapshot: ContainerDirectorySnapshot; + mountPoint?: string; +} +interface ContainerSnapshot { + id: string; + size: number; + name?: string; +} +interface ContainerSnapshotOptions { + name?: string; +} +interface ContainerStartupOptions { + entrypoint?: string[]; + enableInternet: boolean; + env?: Record; + labels?: Record; + directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; + containerSnapshot?: ContainerSnapshot; +} +/** + * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort) + */ +declare abstract class MessagePort extends EventTarget { + /** + * The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage) + */ + postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void; + /** + * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close) + */ + close(): void; + /** + * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start) + */ + start(): void; + get onmessage(): any | null; + set onmessage(value: any | null); +} +/** + * The **`MessageChannel`** interface of the Channel Messaging API allows us to create a new message channel and send data through it via its two MessagePort properties. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel) + */ +declare class MessageChannel { + constructor(); + /** + * The **`port1`** read-only property of the the port attached to the context that originated the channel. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) + */ + readonly port1: MessagePort; + /** + * The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) + */ + readonly port2: MessagePort; +} +interface MessagePortPostMessageOptions { + transfer?: any[]; +} +type LoopbackForExport Rpc.EntrypointBranded) | ExportedHandler | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? LoopbackServiceStub> : T extends new (...args: any[]) => Rpc.DurableObjectBranded ? LoopbackDurableObjectClass> : T extends ExportedHandler ? LoopbackServiceStub : undefined; +type LoopbackServiceStub = Fetcher & (T extends CloudflareWorkersModule.WorkerEntrypoint ? (opts: { + props?: Props; +}) => Fetcher : (opts: { + props?: any; +}) => Fetcher); +type LoopbackDurableObjectClass = DurableObjectClass & (T extends CloudflareWorkersModule.DurableObject ? (opts: { + props?: Props; +}) => DurableObjectClass : (opts: { + props?: any; +}) => DurableObjectClass); +interface LoopbackDurableObjectNamespace extends DurableObjectNamespace { +} +interface LoopbackColoLocalActorNamespace extends ColoLocalActorNamespace { +} +interface SyncKvStorage { + get(key: string): T | undefined; + list(options?: SyncKvListOptions): Iterable<[ + string, + T + ]>; + put(key: string, value: T): void; + delete(key: string): boolean; +} +interface SyncKvListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; +} +interface WorkerStub { + getEntrypoint(name?: string, options?: WorkerStubEntrypointOptions): Fetcher; + getDurableObjectClass(name?: string, options?: WorkerStubEntrypointOptions): DurableObjectClass; +} +interface WorkerStubEntrypointOptions { + props?: any; + limits?: workerdResourceLimits; +} +interface WorkerLoader { + get(name: string | null, getCode: () => WorkerLoaderWorkerCode | Promise): WorkerStub; + load(code: WorkerLoaderWorkerCode): WorkerStub; +} +interface WorkerLoaderModule { + js?: string; + cjs?: string; + text?: string; + data?: ArrayBuffer; + json?: any; + py?: string; + wasm?: ArrayBuffer; +} +interface WorkerLoaderWorkerCode { + compatibilityDate: string; + compatibilityFlags?: string[]; + allowExperimental?: boolean; + limits?: workerdResourceLimits; + mainModule: string; + modules: Record; + env?: any; + globalOutbound?: (Fetcher | null); + tails?: Fetcher[]; + streamingTails?: Fetcher[]; +} +interface workerdResourceLimits { + cpuMs?: number; + subRequests?: number; +} +/** +* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, +* as well as timing of subrequests and other operations. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) +*/ +declare abstract class Performance { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */ + get timeOrigin(): number; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ + now(): number; + /** + * The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) + */ + toJSON(): object; +} +interface Tracing { + enterSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; + Span: typeof Span; +} +declare abstract class Span { + get isTraced(): boolean; + setAttribute(key: string, value?: (boolean | number | string)): void; +} +// ============ AI Search Error Interfaces ============ +interface AiSearchInternalError extends Error { +} +interface AiSearchNotFoundError extends Error { +} +// ============ AI Search Common Types ============ +/** A single message in a conversation-style search or chat request. */ +type AiSearchMessage = { + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; +}; +/** + * Common shape for `ai_search_options` used by both single-instance and multi-instance requests. + * Contains retrieval, query rewrite, reranking, and cache sub-options. + */ +type AiSearchOptions = { + retrieval?: { + /** Which retrieval backend to use. Defaults to the instance's configured index_method. */ + retrieval_type?: 'vector' | 'keyword' | 'hybrid'; + /** Fusion method for combining vector + keyword results. */ + fusion_method?: 'max' | 'rrf'; + /** How keyword terms are combined: "and" = all terms must match, "or" = any term matches. */ + keyword_match_mode?: 'and' | 'or'; + /** Minimum similarity score (0-1) for a result to be included. Default 0.4. */ + match_threshold?: number; + /** Maximum number of results to return (1-50). Default 10. */ + max_num_results?: number; + /** Vectorize metadata filters applied to the search. */ + filters?: VectorizeVectorMetadataFilter; + /** Number of surrounding chunks to include for context (0-3). Default 0. */ + context_expansion?: number; + /** If true, return only item metadata without chunk text. */ + metadata_only?: boolean; + /** If true (default), return empty results on retrieval failure instead of throwing. */ + return_on_failure?: boolean; + /** Boost results by metadata field values. Max 3 entries. */ + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + [key: string]: unknown; + }; + query_rewrite?: { + enabled?: boolean; + model?: string; + rewrite_prompt?: string; + [key: string]: unknown; + }; + reranking?: { + enabled?: boolean; + model?: string; + /** Match threshold (0-1, default 0.4) */ + match_threshold?: number; + [key: string]: unknown; + }; + cache?: { + enabled?: boolean; + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + }; + [key: string]: unknown; +}; +// ============ AI Search Request Types ============ +/** + * Request body for single-instance search. + * Exactly one of `query` or `messages` must be provided. + */ +type AiSearchSearchRequest = { + /** Simple query string. */ + query: string; + messages?: never; + ai_search_options?: AiSearchOptions; +} | { + query?: never; + /** Conversation-style input. At least one user message with non-empty content is required. */ + messages: AiSearchMessage[]; + ai_search_options?: AiSearchOptions; +}; +type AiSearchChatCompletionsRequest = { + messages: AiSearchMessage[]; + model?: string; + stream?: boolean; + ai_search_options?: AiSearchOptions; + [key: string]: unknown; +}; +// ============ AI Search Multi-Instance Types (Namespace-Scoped) ============ +/** `ai_search_options` shape for multi-instance requests — requires `instance_ids`. */ +type AiSearchMultiSearchOptions = AiSearchOptions & { + /** Instance IDs to search across (1-10). */ + instance_ids: string[]; +}; +/** + * Request for searching across multiple instances within a namespace. + * `ai_search_options` is required and must include `instance_ids`. + * Exactly one of `query` or `messages` must be provided. + */ +type AiSearchMultiSearchRequest = { + /** Simple query string. */ + query: string; + messages?: never; + ai_search_options: AiSearchMultiSearchOptions; +} | { + query?: never; + /** Conversation-style input. */ + messages: AiSearchMessage[]; + ai_search_options: AiSearchMultiSearchOptions; +}; +/** A search result chunk tagged with the instance it originated from. */ +type AiSearchMultiSearchChunk = AiSearchSearchResponse['chunks'][number] & { + instance_id: string; +}; +/** Describes a per-instance error during a multi-instance operation. */ +type AiSearchMultiSearchError = { + instance_id: string; + message: string; +}; +/** Response from a multi-instance search, with chunks tagged by instance and optional partial-failure errors. */ +type AiSearchMultiSearchResponse = { + search_query: string; + chunks: AiSearchMultiSearchChunk[]; + errors?: AiSearchMultiSearchError[]; +}; +/** Request for chat completions across multiple instances within a namespace. `ai_search_options` is required and must include `instance_ids`. */ +type AiSearchMultiChatCompletionsRequest = Omit & { + ai_search_options: AiSearchMultiSearchOptions; +}; +/** Response from multi-instance chat completions, with chunks tagged by instance and optional partial-failure errors. */ +type AiSearchMultiChatCompletionsResponse = Omit & { + chunks: AiSearchMultiSearchChunk[]; + errors?: AiSearchMultiSearchError[]; +}; +// ============ AI Search Response Types ============ +type AiSearchSearchResponse = { + search_query: string; + chunks: Array<{ + id: string; + type: string; + /** Match score (0-1) */ + score: number; + text: string; + item: { + timestamp?: number; + key: string; + metadata?: Record; + }; + scoring_details?: { + /** Keyword match score (0-1) */ + keyword_score?: number; + /** Vector similarity score (0-1) */ + vector_score?: number; + /** Keyword rank position */ + keyword_rank?: number; + /** Vector rank position */ + vector_rank?: number; + /** Reranking model score */ + reranking_score?: number; + /** Fusion method used to combine results */ + fusion_method?: 'rrf' | 'max'; + [key: string]: unknown; + }; + }>; +}; +type AiSearchChatCompletionsResponse = { + id?: string; + object?: string; + model?: string; + choices: Array<{ + index?: number; + message: { + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; + [key: string]: unknown; + }; + [key: string]: unknown; + }>; + chunks: AiSearchSearchResponse['chunks']; + [key: string]: unknown; +}; +type AiSearchStatsResponse = { + queued?: number; + running?: number; + completed?: number; + error?: number; + skipped?: number; + outdated?: number; + last_activity?: string; + /** Storage engine statistics. */ + engine?: { + vectorize?: { + vectorsCount: number; + dimensions: number; + }; + r2?: { + payloadSizeBytes: number; + metadataSizeBytes: number; + objectCount: number; + }; + }; +}; +// ============ AI Search Instance Info Types ============ +type AiSearchInstanceInfo = { + id: string; + type?: 'r2' | 'web-crawler' | string; + source?: string; + source_params?: unknown; + paused?: boolean; + status?: string; + namespace?: string; + created_at?: string; + modified_at?: string; + token_id?: string; + ai_gateway_id?: string; + rewrite_query?: boolean; + reranking?: boolean; + embedding_model?: string; + ai_search_model?: string; + rewrite_model?: string; + reranking_model?: string; + /** @deprecated Use index_method instead. */ + hybrid_search_enabled?: boolean; + /** Controls which storage backends are active. */ + index_method?: { + vector?: boolean; + keyword?: boolean; + }; + /** Fusion method for combining vector and keyword results. */ + fusion_method?: 'max' | 'rrf'; + indexing_options?: { + keyword_tokenizer?: 'porter' | 'trigram'; + } | null; + retrieval_options?: { + keyword_match_mode?: 'and' | 'or'; + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + } | null; + chunk?: boolean; + chunk_size?: number; + chunk_overlap?: number; + score_threshold?: number; + max_num_results?: number; + cache?: boolean; + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + custom_metadata?: Array<{ + field_name: string; + data_type: 'text' | 'number' | 'boolean' | 'datetime'; + }>; + /** Sync interval in seconds. */ + sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400; + metadata?: Record; + [key: string]: unknown; +}; +/** Pagination, search, and ordering parameters for listing instances within a namespace. */ +type AiSearchListInstancesParams = { + page?: number; + per_page?: number; + /** Search instances by ID. */ + search?: string; + /** Field to sort by. */ + order_by?: 'created_at'; + /** Sort direction. */ + order_by_direction?: 'asc' | 'desc'; +}; +type AiSearchListResponse = { + result: AiSearchInstanceInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Config Types ============ +type AiSearchConfig = { + /** Instance ID (1-32 chars, pattern: ^[a-z0-9_]+(?:-[a-z0-9_]+)*$) */ + id: string; + /** Instance type. Omit to create with built-in storage. */ + type?: 'r2' | 'web-crawler' | string; + /** Source URL (required for web-crawler type). */ + source?: string; + source_params?: unknown; + /** Token ID (UUID format) */ + token_id?: string; + ai_gateway_id?: string; + /** Enable query rewriting (default false) */ + rewrite_query?: boolean; + /** Enable reranking (default false) */ + reranking?: boolean; + embedding_model?: string; + ai_search_model?: string; + rewrite_model?: string; + reranking_model?: string; + /** @deprecated Use index_method instead. */ + hybrid_search_enabled?: boolean; + /** Controls which storage backends are used during indexing. Defaults to vector-only. */ + index_method?: { + vector?: boolean; + keyword?: boolean; + }; + /** Fusion method for combining vector and keyword results. "rrf" = reciprocal rank fusion (default), "max" = maximum score. */ + fusion_method?: 'max' | 'rrf'; + indexing_options?: { + keyword_tokenizer?: 'porter' | 'trigram'; + } | null; + retrieval_options?: { + keyword_match_mode?: 'and' | 'or'; + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + } | null; + chunk?: boolean; + chunk_size?: number; + chunk_overlap?: number; + /** Minimum similarity score (0-1) for a result to be included. */ + score_threshold?: number; + max_num_results?: number; + cache?: boolean; + /** Similarity threshold for cache hits. Stricter = fewer cache hits but higher relevance. */ + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + custom_metadata?: Array<{ + field_name: string; + data_type: 'text' | 'number' | 'boolean' | 'datetime'; + }>; + namespace?: string; + /** Sync interval in seconds. 3600=1h, 7200=2h, 14400=4h, 21600=6h, 43200=12h, 86400=24h. */ + sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400; + metadata?: Record; + [key: string]: unknown; +}; +// ============ AI Search Item Types ============ +type AiSearchItemInfo = { + id: string; + key: string; + status: 'completed' | 'error' | 'skipped' | 'queued' | 'running' | 'outdated'; + next_action?: 'INDEX' | 'DELETE' | null; + error?: string; + checksum?: string; + namespace?: string; + chunks_count?: number | null; + file_size?: number | null; + source_id?: string | null; + last_seen_at?: string; + created_at?: string; + metadata?: Record; + [key: string]: unknown; +}; +type AiSearchItemContentResult = { + body: ReadableStream; + contentType: string; + filename: string; + size: number; +}; +type AiSearchUploadItemOptions = { + metadata?: Record; +}; +type AiSearchListItemsParams = { + page?: number; + per_page?: number; + /** Search items by key name. */ + search?: string; + /** Sort order for results. */ + sort_by?: 'status' | 'modified_at'; + /** Filter items by processing status. */ + status?: 'queued' | 'running' | 'completed' | 'error' | 'skipped' | 'outdated'; + /** Filter items by source (e.g. "builtin" or "web-crawler:https://example.com"). */ + source?: string; + /** JSON-encoded Vectorize filter for metadata filtering. */ + metadata_filter?: string; +}; +type AiSearchListItemsResponse = { + result: AiSearchItemInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Item Logs Types ============ +type AiSearchItemLogsParams = { + /** Maximum number of log entries to return (1-100, default 50). */ + limit?: number; + /** Opaque cursor for pagination. Pass the `cursor` value from a previous response. */ + cursor?: string; +}; +type AiSearchItemLog = { + timestamp: string; + action: string; + message: string; + fileKey?: string; + chunkCount?: number; + processingTimeMs?: number; + errorType?: string; +}; +/** Paginated response for item processing logs (cursor-based). */ +type AiSearchItemLogsResponse = { + result: AiSearchItemLog[]; + result_info: { + count: number; + per_page: number; + cursor: string | null; + truncated: boolean; + }; +}; +// ============ AI Search Item Chunks Types ============ +type AiSearchItemChunksParams = { + /** Maximum number of chunks to return (1-100, default 20). */ + limit?: number; + /** Offset into the chunks list (default 0). */ + offset?: number; +}; +/** A single indexed chunk belonging to an item, including its text content and byte range. */ +type AiSearchItemChunk = { + id: string; + text: string; + start_byte: number; + end_byte: number; + item?: { + timestamp?: number; + key: string; + metadata?: Record; + }; +}; +/** Paginated response for item chunks (offset-based). */ +type AiSearchItemChunksResponse = { + result: AiSearchItemChunk[]; + result_info: { + count: number; + total: number; + limit: number; + offset: number; + }; +}; +// ============ AI Search Job Types ============ +type AiSearchJobInfo = { + id: string; + source: 'user' | 'schedule'; + description?: string; + last_seen_at?: string; + started_at?: string; + ended_at?: string; + end_reason?: string; +}; +type AiSearchJobLog = { + id: number; + message: string; + message_type: number; + created_at: number; +}; +type AiSearchCreateJobParams = { + description?: string; +}; +type AiSearchListJobsParams = { + page?: number; + per_page?: number; +}; +type AiSearchListJobsResponse = { + result: AiSearchJobInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +type AiSearchJobLogsParams = { + page?: number; + per_page?: number; +}; +type AiSearchJobLogsResponse = { + result: AiSearchJobLog[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Sub-Service Classes ============ +/** + * Single item service for an AI Search instance. + * Provides info, download, sync, logs, and chunks operations on a specific item. + */ +declare abstract class AiSearchItem { + /** Get metadata about this item. */ + info(): Promise; + /** + * Download the item's content. + * @returns Object with body stream, content type, filename, and size. + */ + download(): Promise; + /** + * Trigger re-indexing of this item. + * @returns The updated item info. + */ + sync(): Promise; + /** + * Retrieve processing logs for this item (cursor-based pagination). + * @param params Optional pagination parameters (limit, cursor). + * @returns Paginated log entries for this item. + */ + logs(params?: AiSearchItemLogsParams): Promise; + /** + * List indexed chunks for this item (offset-based pagination). + * @param params Optional pagination parameters (limit, offset). + * @returns Paginated chunk entries for this item. + */ + chunks(params?: AiSearchItemChunksParams): Promise; +} +/** + * Items collection service for an AI Search instance. + * Provides list, upload, and access to individual items. + */ +declare abstract class AiSearchItems { + /** List items in this instance. */ + list(params?: AiSearchListItemsParams): Promise; + /** + * Upload a file as an item. Behaves as an upsert: if an item with the same + * filename already exists, it is overwritten and re-indexed. + * @param name Filename for the uploaded item. + * @param content File content as a ReadableStream, Blob, or string. + * @param options Optional metadata to attach to the item. + * @returns The created item info. + */ + upload(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions): Promise; + /** + * Upload a file and poll until processing completes. + * Behaves as an upsert: if an item with the same filename already exists, + * it is overwritten and re-indexed. + * @param name Filename for the uploaded item. + * @param content File content as a ReadableStream, Blob, or string. + * @param options Optional metadata and polling configuration. + * @returns The item info after processing completes (or timeout). + */ + uploadAndPoll(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions & { + /** Polling interval in milliseconds (default 1000). */ + pollIntervalMs?: number; + /** Maximum time to wait in milliseconds (default 30000). */ + timeoutMs?: number; + }): Promise; + /** + * Get an item by ID. + * @param itemId The item identifier. + * @returns Item service for info, download, sync, logs, and chunks operations. + */ + get(itemId: string): AiSearchItem; + /** + * Delete an item from the instance. + * @param itemId The item identifier. + */ + delete(itemId: string): Promise; +} +/** + * Single job service for an AI Search instance. + * Provides info, logs, and cancel operations for a specific job. + */ +declare abstract class AiSearchJob { + /** Get metadata about this job. */ + info(): Promise; + /** Get logs for this job. */ + logs(params?: AiSearchJobLogsParams): Promise; + /** + * Cancel a running job. + * @returns The updated job info. + * @throws AiSearchNotFoundError if the job does not exist. + */ + cancel(): Promise; +} +/** + * Jobs collection service for an AI Search instance. + * Provides list, create, and access to individual jobs. + */ +declare abstract class AiSearchJobs { + /** List jobs for this instance. */ + list(params?: AiSearchListJobsParams): Promise; + /** + * Create a new indexing job. + * @param params Optional job parameters. + * @returns The created job info. + */ + create(params?: AiSearchCreateJobParams): Promise; + /** + * Get a job by ID. + * @param jobId The job identifier. + * @returns Job service for info, logs, and cancel operations. + */ + get(jobId: string): AiSearchJob; +} +// ============ AI Search Binding Classes ============ +/** + * Instance-level AI Search service. + * + * Used as: + * - The return type of `AiSearchNamespace.get(name)` (namespace binding) + * - The type of `env.BLOG_SEARCH` (single instance binding via `ai_search`) + * + * Provides search, chat, update, stats, items, and jobs operations. + * + * @example + * ```ts + * // Via namespace binding + * const instance = env.AI_SEARCH.get("blog"); + * const results = await instance.search({ + * query: "How does caching work?", + * }); + * + * // Via single instance binding + * const results = await env.BLOG_SEARCH.search({ + * messages: [{ role: "user", content: "How does caching work?" }], + * }); + * ``` + */ +declare abstract class AiSearchInstance { + /** + * Search the AI Search instance for relevant chunks. + * @param params Search request with query or messages and optional AI search options. + * @returns Search response with matching chunks and search query. + */ + search(params: AiSearchSearchRequest): Promise; + /** + * Generate chat completions with AI Search context (streaming). + * @param params Chat completions request with stream: true. + * @returns ReadableStream of server-sent events. + */ + chatCompletions(params: AiSearchChatCompletionsRequest & { + stream: true; + }): Promise; + /** + * Generate chat completions with AI Search context. + * @param params Chat completions request. + * @returns Chat completion response with choices and RAG chunks. + */ + chatCompletions(params: AiSearchChatCompletionsRequest): Promise; + /** + * Update the instance configuration. + * @param config Partial configuration to update. + * @returns Updated instance info. + */ + update(config: Partial): Promise; + /** Get metadata about this instance. */ + info(): Promise; + /** + * Get instance statistics (item count, indexing status, etc.). + * @returns Statistics with counts per status, last activity time, and engine details. + */ + stats(): Promise; + /** Items collection — list, upload, and manage items in this instance. */ + get items(): AiSearchItems; + /** Jobs collection — list, create, and inspect indexing jobs. */ + get jobs(): AiSearchJobs; +} +/** + * Namespace-level AI Search service. + * + * Used as the type of `env.AI_SEARCH` (namespace binding via `ai_search_namespaces`). + * Scoped to a single namespace. Provides dynamic instance access, creation, deletion, + * and multi-instance search/chat operations. + * + * @example + * ```ts + * // Access an instance within the namespace + * const blog = env.AI_SEARCH.get("blog"); + * const results = await blog.search({ query: "How does caching work?" }); + * + * // List all instances in the namespace + * const instances = await env.AI_SEARCH.list(); + * + * // Create a new instance with built-in storage + * const tenant = await env.AI_SEARCH.create({ id: "tenant-123" }); + * + * // Upload items into the instance + * await tenant.items.upload("doc.pdf", fileContent); + * + * // Search across multiple instances + * const multi = await env.AI_SEARCH.search({ + * query: "caching", + * ai_search_options: { instance_ids: ["blog", "docs"] }, + * }); + * + * // Delete an instance + * await env.AI_SEARCH.delete("tenant-123"); + * ``` + */ +declare abstract class AiSearchNamespace { + /** + * Get an instance by name within the bound namespace. + * @param name Instance name. + * @returns Instance service for search, chat, update, stats, items, and jobs. + */ + get(name: string): AiSearchInstance; + /** + * List instances in the bound namespace. + * @param params Optional pagination, search, and ordering parameters. + * @returns Array of instance metadata with pagination info. + */ + list(params?: AiSearchListInstancesParams): Promise; + /** + * Create a new instance within the bound namespace. + * @param config Instance configuration. Only `id` is required — omit `type` and `source` to create with built-in storage. + * @returns Instance service for the newly created instance. + * + * @example + * ```ts + * // Create with built-in storage (upload items manually) + * const instance = await env.AI_SEARCH.create({ id: "my-search" }); + * + * // Create with web crawler source + * const instance = await env.AI_SEARCH.create({ + * id: "docs-search", + * type: "web-crawler", + * source: "https://developers.cloudflare.com", + * }); + * ``` + */ + create(config: AiSearchConfig): Promise; + /** + * Delete an instance from the bound namespace. + * @param name Instance name to delete. + */ + delete(name: string): Promise; + /** + * Search across multiple instances within the bound namespace. + * Fans out to the specified instance_ids and merges results. + * @param params Search request with required `ai_search_options.instance_ids`. + * @returns Search response with chunks tagged by instance_id and optional partial-failure errors. + */ + search(params: AiSearchMultiSearchRequest): Promise; + /** + * Generate chat completions across multiple instances within the bound namespace (streaming). + * Fans out to the specified instance_ids, merges context, and generates a response. + * @param params Chat completions request with stream: true and required `ai_search_options.instance_ids`. + * @returns ReadableStream of server-sent events. + */ + chatCompletions(params: AiSearchMultiChatCompletionsRequest & { + stream: true; + }): Promise; + /** + * Generate chat completions across multiple instances within the bound namespace. + * Fans out to the specified instance_ids, merges context, and generates a response. + * @param params Chat completions request with required `ai_search_options.instance_ids`. + * @returns Chat completion response with choices, chunks tagged by instance_id, and optional partial-failure errors. + */ + chatCompletions(params: AiSearchMultiChatCompletionsRequest): Promise; +} +type AiImageClassificationInput = { + image: number[]; +}; +type AiImageClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiImageClassification { + inputs: AiImageClassificationInput; + postProcessedOutputs: AiImageClassificationOutput; +} +type AiImageToTextInput = { + image: number[]; + prompt?: string; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageToText { + inputs: AiImageToTextInput; + postProcessedOutputs: AiImageToTextOutput; +} +type AiImageTextToTextInput = { + image: string; + prompt?: string; + max_tokens?: number; + temperature?: number; + ignore_eos?: boolean; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageTextToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageTextToText { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiMultimodalEmbeddingsInput = { + image: string; + text: string[]; +}; +type AiIMultimodalEmbeddingsOutput = { + data: number[][]; + shape: number[]; +}; +declare abstract class BaseAiMultimodalEmbeddings { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiObjectDetectionInput = { + image: number[]; +}; +type AiObjectDetectionOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiObjectDetection { + inputs: AiObjectDetectionInput; + postProcessedOutputs: AiObjectDetectionOutput; +} +type AiSentenceSimilarityInput = { + source: string; + sentences: string[]; +}; +type AiSentenceSimilarityOutput = number[]; +declare abstract class BaseAiSentenceSimilarity { + inputs: AiSentenceSimilarityInput; + postProcessedOutputs: AiSentenceSimilarityOutput; +} +type AiAutomaticSpeechRecognitionInput = { + audio: number[]; +}; +type AiAutomaticSpeechRecognitionOutput = { + text?: string; + words?: { + word: string; + start: number; + end: number; + }[]; + vtt?: string; +}; +declare abstract class BaseAiAutomaticSpeechRecognition { + inputs: AiAutomaticSpeechRecognitionInput; + postProcessedOutputs: AiAutomaticSpeechRecognitionOutput; +} +type AiSummarizationInput = { + input_text: string; + max_length?: number; +}; +type AiSummarizationOutput = { + summary: string; +}; +declare abstract class BaseAiSummarization { + inputs: AiSummarizationInput; + postProcessedOutputs: AiSummarizationOutput; +} +type AiTextClassificationInput = { + text: string; +}; +type AiTextClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiTextClassification { + inputs: AiTextClassificationInput; + postProcessedOutputs: AiTextClassificationOutput; +} +type AiTextEmbeddingsInput = { + text: string | string[]; +}; +type AiTextEmbeddingsOutput = { + shape: number[]; + data: number[][]; +}; +declare abstract class BaseAiTextEmbeddings { + inputs: AiTextEmbeddingsInput; + postProcessedOutputs: AiTextEmbeddingsOutput; +} +type RoleScopedChatInput = { + role: "user" | "assistant" | "system" | "tool" | (string & NonNullable); + content: string; + name?: string; +}; +type AiTextGenerationToolLegacyInput = { + name: string; + description: string; + parameters?: { + type: "object" | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; +}; +type AiTextGenerationToolInput = { + type: "function" | (string & NonNullable); + function: { + name: string; + description: string; + parameters?: { + type: "object" | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; + }; +}; +type AiTextGenerationFunctionsInput = { + name: string; + code: string; +}; +type AiTextGenerationResponseFormat = { + type: string; + json_schema?: any; +}; +type AiTextGenerationInput = { + prompt?: string; + raw?: boolean; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + messages?: RoleScopedChatInput[]; + response_format?: AiTextGenerationResponseFormat; + tools?: AiTextGenerationToolInput[] | AiTextGenerationToolLegacyInput[] | (object & NonNullable); + functions?: AiTextGenerationFunctionsInput[]; +}; +type AiTextGenerationToolLegacyOutput = { + name: string; + arguments: unknown; +}; +type AiTextGenerationToolOutput = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; +type UsageTags = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +}; +type AiTextGenerationOutput = { + response?: string; + tool_calls?: AiTextGenerationToolLegacyOutput[] & AiTextGenerationToolOutput[]; + usage?: UsageTags; +}; +declare abstract class BaseAiTextGeneration { + inputs: AiTextGenerationInput; + postProcessedOutputs: AiTextGenerationOutput; +} +type AiTextToSpeechInput = { + prompt: string; + lang?: string; +}; +type AiTextToSpeechOutput = Uint8Array | { + audio: string; +}; +declare abstract class BaseAiTextToSpeech { + inputs: AiTextToSpeechInput; + postProcessedOutputs: AiTextToSpeechOutput; +} +type AiTextToImageInput = { + prompt: string; + negative_prompt?: string; + height?: number; + width?: number; + image?: number[]; + image_b64?: string; + mask?: number[]; + num_steps?: number; + strength?: number; + guidance?: number; + seed?: number; +}; +type AiTextToImageOutput = ReadableStream; +declare abstract class BaseAiTextToImage { + inputs: AiTextToImageInput; + postProcessedOutputs: AiTextToImageOutput; +} +type AiTranslationInput = { + text: string; + target_lang: string; + source_lang?: string; +}; +type AiTranslationOutput = { + translated_text?: string; +}; +declare abstract class BaseAiTranslation { + inputs: AiTranslationInput; + postProcessedOutputs: AiTranslationOutput; +} +/** + * Workers AI support for OpenAI's Chat Completions API + */ +type ChatCompletionContentPartText = { + type: "text"; + text: string; +}; +type ChatCompletionContentPartImage = { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "low" | "high"; + }; +}; +type ChatCompletionContentPartInputAudio = { + type: "input_audio"; + input_audio: { + /** Base64 encoded audio data. */ + data: string; + format: "wav" | "mp3"; + }; +}; +type ChatCompletionContentPartFile = { + type: "file"; + file: { + /** Base64 encoded file data. */ + file_data?: string; + /** The ID of an uploaded file. */ + file_id?: string; + filename?: string; + }; +}; +type ChatCompletionContentPartRefusal = { + type: "refusal"; + refusal: string; +}; +type ChatCompletionContentPart = ChatCompletionContentPartText | ChatCompletionContentPartImage | ChatCompletionContentPartInputAudio | ChatCompletionContentPartFile; +type FunctionDefinition = { + name: string; + description?: string; + parameters?: Record; + strict?: boolean | null; +}; +type ChatCompletionFunctionTool = { + type: "function"; + function: FunctionDefinition; +}; +type ChatCompletionCustomToolGrammarFormat = { + type: "grammar"; + grammar: { + definition: string; + syntax: "lark" | "regex"; + }; +}; +type ChatCompletionCustomToolTextFormat = { + type: "text"; +}; +type ChatCompletionCustomToolFormat = ChatCompletionCustomToolTextFormat | ChatCompletionCustomToolGrammarFormat; +type ChatCompletionCustomTool = { + type: "custom"; + custom: { + name: string; + description?: string; + format?: ChatCompletionCustomToolFormat; + }; +}; +type ChatCompletionTool = ChatCompletionFunctionTool | ChatCompletionCustomTool; +type ChatCompletionMessageFunctionToolCall = { + id: string; + type: "function"; + function: { + name: string; + /** JSON-encoded arguments string. */ + arguments: string; + }; +}; +type ChatCompletionMessageCustomToolCall = { + id: string; + type: "custom"; + custom: { + name: string; + input: string; + }; +}; +type ChatCompletionMessageToolCall = ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall; +type ChatCompletionToolChoiceFunction = { + type: "function"; + function: { + name: string; + }; +}; +type ChatCompletionToolChoiceCustom = { + type: "custom"; + custom: { + name: string; + }; +}; +type ChatCompletionToolChoiceAllowedTools = { + type: "allowed_tools"; + allowed_tools: { + mode: "auto" | "required"; + tools: Array>; + }; +}; +type ChatCompletionToolChoiceOption = "none" | "auto" | "required" | ChatCompletionToolChoiceFunction | ChatCompletionToolChoiceCustom | ChatCompletionToolChoiceAllowedTools; +type DeveloperMessage = { + role: "developer"; + content: string | Array<{ + type: "text"; + text: string; + }>; + name?: string; +}; +type SystemMessage = { + role: "system"; + content: string | Array<{ + type: "text"; + text: string; + }>; + name?: string; +}; +/** + * Permissive merged content part used inside UserMessage arrays. + * + * Cabidela has a limitation where anyOf/oneOf with enum-based discrimination + * inside nested array items does not correctly match different branches for + * different array elements, so the schema uses a single merged object. + */ +type UserMessageContentPart = { + type: "text" | "image_url" | "input_audio" | "file"; + text?: string; + image_url?: { + url?: string; + detail?: "auto" | "low" | "high"; + }; + input_audio?: { + data?: string; + format?: "wav" | "mp3"; + }; + file?: { + file_data?: string; + file_id?: string; + filename?: string; + }; +}; +type UserMessage = { + role: "user"; + content: string | Array; + name?: string; +}; +type AssistantMessageContentPart = { + type: "text" | "refusal"; + text?: string; + refusal?: string; +}; +type AssistantMessage = { + role: "assistant"; + content?: string | null | Array; + refusal?: string | null; + name?: string; + audio?: { + id: string; + }; + tool_calls?: Array; + function_call?: { + name: string; + arguments: string; + }; +}; +type ToolMessage = { + role: "tool"; + content: string | Array<{ + type: "text"; + text: string; + }>; + tool_call_id: string; +}; +type FunctionMessage = { + role: "function"; + content: string; + name: string; +}; +type ChatCompletionMessageParam = DeveloperMessage | SystemMessage | UserMessage | AssistantMessage | ToolMessage | FunctionMessage; +type ChatCompletionsResponseFormatText = { + type: "text"; +}; +type ChatCompletionsResponseFormatJSONObject = { + type: "json_object"; +}; +type ResponseFormatJSONSchema = { + type: "json_schema"; + json_schema: { + name: string; + description?: string; + schema?: Record; + strict?: boolean | null; + }; +}; +type ResponseFormat = ChatCompletionsResponseFormatText | ChatCompletionsResponseFormatJSONObject | ResponseFormatJSONSchema; +type ChatCompletionsStreamOptions = { + include_usage?: boolean; + include_obfuscation?: boolean; +}; +type PredictionContent = { + type: "content"; + content: string | Array<{ + type: "text"; + text: string; + }>; +}; +type AudioParams = { + voice: string | { + id: string; + }; + format: "wav" | "aac" | "mp3" | "flac" | "opus" | "pcm16"; +}; +type WebSearchUserLocation = { + type: "approximate"; + approximate: { + city?: string; + country?: string; + region?: string; + timezone?: string; + }; +}; +type WebSearchOptions = { + search_context_size?: "low" | "medium" | "high"; + user_location?: WebSearchUserLocation; +}; +type ChatTemplateKwargs = { + /** Whether to enable reasoning, enabled by default. */ + enable_thinking?: boolean; + /** If false, preserves reasoning context between turns. */ + clear_thinking?: boolean; +}; +/** Shared optional properties used by both Prompt and Messages input branches. */ +type ChatCompletionsCommonOptions = { + model?: string; + audio?: AudioParams; + frequency_penalty?: number | null; + logit_bias?: Record | null; + logprobs?: boolean | null; + top_logprobs?: number | null; + max_tokens?: number | null; + max_completion_tokens?: number | null; + metadata?: Record | null; + modalities?: Array<"text" | "audio"> | null; + n?: number | null; + parallel_tool_calls?: boolean; + prediction?: PredictionContent; + presence_penalty?: number | null; + reasoning_effort?: "low" | "medium" | "high" | null; + chat_template_kwargs?: ChatTemplateKwargs; + response_format?: ResponseFormat; + seed?: number | null; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + stop?: string | Array | null; + store?: boolean | null; + stream?: boolean | null; + stream_options?: ChatCompletionsStreamOptions; + temperature?: number | null; + tool_choice?: ChatCompletionToolChoiceOption; + tools?: Array; + top_p?: number | null; + user?: string; + web_search_options?: WebSearchOptions; + function_call?: "none" | "auto" | { + name: string; + }; + functions?: Array; +}; +type PromptTokensDetails = { + cached_tokens?: number; + audio_tokens?: number; +}; +type CompletionTokensDetails = { + reasoning_tokens?: number; + audio_tokens?: number; + accepted_prediction_tokens?: number; + rejected_prediction_tokens?: number; +}; +type CompletionUsage = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + prompt_tokens_details?: PromptTokensDetails; + completion_tokens_details?: CompletionTokensDetails; +}; +type ChatCompletionTopLogprob = { + token: string; + logprob: number; + bytes: Array | null; +}; +type ChatCompletionTokenLogprob = { + token: string; + logprob: number; + bytes: Array | null; + top_logprobs: Array; +}; +type ChatCompletionAudio = { + id: string; + /** Base64 encoded audio bytes. */ + data: string; + expires_at: number; + transcript: string; +}; +type ChatCompletionUrlCitation = { + type: "url_citation"; + url_citation: { + url: string; + title: string; + start_index: number; + end_index: number; + }; +}; +type ChatCompletionResponseMessage = { + role: "assistant"; + content: string | null; + refusal: string | null; + annotations?: Array; + audio?: ChatCompletionAudio; + tool_calls?: Array; + function_call?: { + name: string; + arguments: string; + } | null; +}; +type ChatCompletionLogprobs = { + content: Array | null; + refusal?: Array | null; +}; +type ChatCompletionChoice = { + index: number; + message: ChatCompletionResponseMessage; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | "function_call"; + logprobs: ChatCompletionLogprobs | null; +}; +type ChatCompletionsPromptInput = { + prompt: string; +} & ChatCompletionsCommonOptions; +type ChatCompletionsMessagesInput = { + messages: Array; +} & ChatCompletionsCommonOptions; +type ChatCompletionsOutput = { + id: string; + object: string; + created: number; + model: string; + choices: Array; + usage?: CompletionUsage; + system_fingerprint?: string | null; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; +}; +/** + * Workers AI support for OpenAI's Responses API + * Reference: https://github.com/openai/openai-node/blob/master/src/resources/responses/responses.ts + * + * It's a stripped down version from its source. + * It currently supports basic function calling, json mode and accepts images as input. + * + * It does not include types for WebSearch, CodeInterpreter, FileInputs, MCP, CustomTools. + * We plan to add those incrementally as model + platform capabilities evolve. + */ +type ResponsesInput = { + background?: boolean | null; + conversation?: string | ResponseConversationParam | null; + include?: Array | null; + input?: string | ResponseInput; + instructions?: string | null; + max_output_tokens?: number | null; + parallel_tool_calls?: boolean | null; + previous_response_id?: string | null; + prompt_cache_key?: string; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + stream?: boolean | null; + stream_options?: StreamOptions | null; + temperature?: number | null; + text?: ResponseTextConfig; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + truncation?: "auto" | "disabled" | null; +}; +type ResponsesOutput = { + id?: string; + created_at?: number; + output_text?: string; + error?: ResponseError | null; + incomplete_details?: ResponseIncompleteDetails | null; + instructions?: string | Array | null; + object?: "response"; + output?: Array; + parallel_tool_calls?: boolean; + temperature?: number | null; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + max_output_tokens?: number | null; + previous_response_id?: string | null; + prompt?: ResponsePrompt | null; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + status?: ResponseStatus; + text?: ResponseTextConfig; + truncation?: "auto" | "disabled" | null; + usage?: ResponseUsage; +}; +type EasyInputMessage = { + content: string | ResponseInputMessageContentList; + role: "user" | "assistant" | "system" | "developer"; + type?: "message"; +}; +type ResponsesFunctionTool = { + name: string; + parameters: { + [key: string]: unknown; + } | null; + strict: boolean | null; + type: "function"; + description?: string | null; +}; +type ResponseIncompleteDetails = { + reason?: "max_output_tokens" | "content_filter"; +}; +type ResponsePrompt = { + id: string; + variables?: { + [key: string]: string | ResponseInputText | ResponseInputImage; + } | null; + version?: string | null; +}; +type Reasoning = { + effort?: ReasoningEffort | null; + generate_summary?: "auto" | "concise" | "detailed" | null; + summary?: "auto" | "concise" | "detailed" | null; +}; +type ResponseContent = ResponseInputText | ResponseInputImage | ResponseOutputText | ResponseOutputRefusal | ResponseContentReasoningText; +type ResponseContentReasoningText = { + text: string; + type: "reasoning_text"; +}; +type ResponseConversationParam = { + id: string; +}; +type ResponseCreatedEvent = { + response: Response; + sequence_number: number; + type: "response.created"; +}; +type ResponseCustomToolCallOutput = { + call_id: string; + output: string | Array; + type: "custom_tool_call_output"; + id?: string; +}; +type ResponseError = { + code: "server_error" | "rate_limit_exceeded" | "invalid_prompt" | "vector_store_timeout" | "invalid_image" | "invalid_image_format" | "invalid_base64_image" | "invalid_image_url" | "image_too_large" | "image_too_small" | "image_parse_error" | "image_content_policy_violation" | "invalid_image_mode" | "image_file_too_large" | "unsupported_image_media_type" | "empty_image_file" | "failed_to_download_image" | "image_file_not_found"; + message: string; +}; +type ResponseErrorEvent = { + code: string | null; + message: string; + param: string | null; + sequence_number: number; + type: "error"; +}; +type ResponseFailedEvent = { + response: Response; + sequence_number: number; + type: "response.failed"; +}; +type ResponseFormatText = { + type: "text"; +}; +type ResponseFormatJSONObject = { + type: "json_object"; +}; +type ResponseFormatTextConfig = ResponseFormatText | ResponseFormatTextJSONSchemaConfig | ResponseFormatJSONObject; +type ResponseFormatTextJSONSchemaConfig = { + name: string; + schema: { + [key: string]: unknown; + }; + type: "json_schema"; + description?: string; + strict?: boolean | null; +}; +type ResponseFunctionCallArgumentsDeltaEvent = { + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.function_call_arguments.delta"; +}; +type ResponseFunctionCallArgumentsDoneEvent = { + arguments: string; + item_id: string; + name: string; + output_index: number; + sequence_number: number; + type: "response.function_call_arguments.done"; +}; +type ResponseFunctionCallOutputItem = ResponseInputTextContent | ResponseInputImageContent; +type ResponseFunctionCallOutputItemList = Array; +type ResponseFunctionToolCall = { + arguments: string; + call_id: string; + name: string; + type: "function_call"; + id?: string; + status?: "in_progress" | "completed" | "incomplete"; +}; +interface ResponseFunctionToolCallItem extends ResponseFunctionToolCall { + id: string; +} +type ResponseFunctionToolCallOutputItem = { + id: string; + call_id: string; + output: string | Array; + type: "function_call_output"; + status?: "in_progress" | "completed" | "incomplete"; +}; +type ResponseIncludable = "message.input_image.image_url" | "message.output_text.logprobs"; +type ResponseIncompleteEvent = { + response: Response; + sequence_number: number; + type: "response.incomplete"; +}; +type ResponseInput = Array; +type ResponseInputContent = ResponseInputText | ResponseInputImage; +type ResponseInputImage = { + detail: "low" | "high" | "auto"; + type: "input_image"; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputImageContent = { + type: "input_image"; + detail?: "low" | "high" | "auto" | null; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputItem = EasyInputMessage | ResponseInputItemMessage | ResponseOutputMessage | ResponseFunctionToolCall | ResponseInputItemFunctionCallOutput | ResponseReasoningItem; +type ResponseInputItemFunctionCallOutput = { + call_id: string; + output: string | ResponseFunctionCallOutputItemList; + type: "function_call_output"; + id?: string | null; + status?: "in_progress" | "completed" | "incomplete" | null; +}; +type ResponseInputItemMessage = { + content: ResponseInputMessageContentList; + role: "user" | "system" | "developer"; + status?: "in_progress" | "completed" | "incomplete"; + type?: "message"; +}; +type ResponseInputMessageContentList = Array; +type ResponseInputMessageItem = { + id: string; + content: ResponseInputMessageContentList; + role: "user" | "system" | "developer"; + status?: "in_progress" | "completed" | "incomplete"; + type?: "message"; +}; +type ResponseInputText = { + text: string; + type: "input_text"; +}; +type ResponseInputTextContent = { + text: string; + type: "input_text"; +}; +type ResponseItem = ResponseInputMessageItem | ResponseOutputMessage | ResponseFunctionToolCallItem | ResponseFunctionToolCallOutputItem; +type ResponseOutputItem = ResponseOutputMessage | ResponseFunctionToolCall | ResponseReasoningItem; +type ResponseOutputItemAddedEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: "response.output_item.added"; +}; +type ResponseOutputItemDoneEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: "response.output_item.done"; +}; +type ResponseOutputMessage = { + id: string; + content: Array; + role: "assistant"; + status: "in_progress" | "completed" | "incomplete"; + type: "message"; +}; +type ResponseOutputRefusal = { + refusal: string; + type: "refusal"; +}; +type ResponseOutputText = { + text: string; + type: "output_text"; + logprobs?: Array; +}; +type ResponseReasoningItem = { + id: string; + summary: Array; + type: "reasoning"; + content?: Array; + encrypted_content?: string | null; + status?: "in_progress" | "completed" | "incomplete"; +}; +type ResponseReasoningSummaryItem = { + text: string; + type: "summary_text"; +}; +type ResponseReasoningContentItem = { + text: string; + type: "reasoning_text"; +}; +type ResponseReasoningTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.reasoning_text.delta"; +}; +type ResponseReasoningTextDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + sequence_number: number; + text: string; + type: "response.reasoning_text.done"; +}; +type ResponseRefusalDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.refusal.delta"; +}; +type ResponseRefusalDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + refusal: string; + sequence_number: number; + type: "response.refusal.done"; +}; +type ResponseStatus = "completed" | "failed" | "in_progress" | "cancelled" | "queued" | "incomplete"; +type ResponseStreamEvent = ResponseCompletedEvent | ResponseCreatedEvent | ResponseErrorEvent | ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent | ResponseFailedEvent | ResponseIncompleteEvent | ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent | ResponseReasoningTextDeltaEvent | ResponseReasoningTextDoneEvent | ResponseRefusalDeltaEvent | ResponseRefusalDoneEvent | ResponseTextDeltaEvent | ResponseTextDoneEvent; +type ResponseCompletedEvent = { + response: Response; + sequence_number: number; + type: "response.completed"; +}; +type ResponseTextConfig = { + format?: ResponseFormatTextConfig; + verbosity?: "low" | "medium" | "high" | null; +}; +type ResponseTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + type: "response.output_text.delta"; +}; +type ResponseTextDoneEvent = { + content_index: number; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + text: string; + type: "response.output_text.done"; +}; +type Logprob = { + token: string; + logprob: number; + top_logprobs?: Array; +}; +type TopLogprob = { + token?: string; + logprob?: number; +}; +type ResponseUsage = { + input_tokens: number; + output_tokens: number; + total_tokens: number; +}; +type Tool = ResponsesFunctionTool; +type ToolChoiceFunction = { + name: string; + type: "function"; +}; +type ToolChoiceOptions = "none"; +type ReasoningEffort = "minimal" | "low" | "medium" | "high" | null; +type StreamOptions = { + include_obfuscation?: boolean; +}; +/** Marks keys from T that aren't in U as optional never */ +type Without = { + [P in Exclude]?: never; +}; +/** Either T or U, but not both (mutually exclusive) */ +type XOR = (T & Without) | (U & Without); +type Ai_Cf_Baai_Bge_Base_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Base_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Base_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Output; +} +type Ai_Cf_Openai_Whisper_Input = string | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; +}; +interface Ai_Cf_Openai_Whisper_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper { + inputs: Ai_Cf_Openai_Whisper_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Output; +} +type Ai_Cf_Meta_M2M100_1_2B_Input = { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; + }[]; +}; +type Ai_Cf_Meta_M2M100_1_2B_Output = { + /** + * The translated text in the target language + */ + translated_text?: string; +} | Ai_Cf_Meta_M2M100_1_2B_AsyncResponse; +interface Ai_Cf_Meta_M2M100_1_2B_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_M2M100_1_2B { + inputs: Ai_Cf_Meta_M2M100_1_2B_Input; + postProcessedOutputs: Ai_Cf_Meta_M2M100_1_2B_Output; +} +type Ai_Cf_Baai_Bge_Small_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Small_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Small_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Output; +} +type Ai_Cf_Baai_Bge_Large_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Large_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Large_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Output; +} +type Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input = string | { + /** + * The input text prompt for the model to generate a response. + */ + prompt?: string; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + image: number[] | (string & NonNullable); + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; +}; +interface Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output { + description?: string; +} +declare abstract class Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M { + inputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input; + postProcessedOutputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output; +} +type Ai_Cf_Openai_Whisper_Tiny_En_Input = string | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; +}; +interface Ai_Cf_Openai_Whisper_Tiny_En_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Tiny_En { + inputs: Ai_Cf_Openai_Whisper_Tiny_En_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Tiny_En_Output; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input { + audio: string | { + body?: object; + contentType?: string; + }; + /** + * Supported tasks are 'translate' or 'transcribe'. + */ + task?: string; + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * Preprocess the audio with a voice activity detection model. + */ + vad_filter?: boolean; + /** + * A text prompt to help provide context to the model on the contents of the audio. + */ + initial_prompt?: string; + /** + * The prefix appended to the beginning of the output of the transcription and can guide the transcription result. + */ + prefix?: string; + /** + * The number of beams to use in beam search decoding. Higher values may improve accuracy at the cost of speed. + */ + beam_size?: number; + /** + * Whether to condition on previous text during transcription. Setting to false may help prevent hallucination loops. + */ + condition_on_previous_text?: boolean; + /** + * Threshold for detecting no-speech segments. Segments with no-speech probability above this value are skipped. + */ + no_speech_threshold?: number; + /** + * Threshold for filtering out segments with high compression ratio, which often indicate repetitive or hallucinated text. + */ + compression_ratio_threshold?: number; + /** + * Threshold for filtering out segments with low average log probability, indicating low confidence. + */ + log_prob_threshold?: number; + /** + * Optional threshold (in seconds) to skip silent periods that may cause hallucinations. + */ + hallucination_silence_threshold?: number; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output { + transcription_info?: { + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * The confidence level or probability of the detected language being accurate, represented as a decimal between 0 and 1. + */ + language_probability?: number; + /** + * The total duration of the original audio file, in seconds. + */ + duration?: number; + /** + * The duration of the audio after applying Voice Activity Detection (VAD) to remove silent or irrelevant sections, in seconds. + */ + duration_after_vad?: number; + }; + /** + * The complete transcription of the audio. + */ + text: string; + /** + * The total number of words in the transcription. + */ + word_count?: number; + segments?: { + /** + * The starting time of the segment within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the segment within the audio, in seconds. + */ + end?: number; + /** + * The transcription of the segment. + */ + text?: string; + /** + * The temperature used in the decoding process, controlling randomness in predictions. Lower values result in more deterministic outputs. + */ + temperature?: number; + /** + * The average log probability of the predictions for the words in this segment, indicating overall confidence. + */ + avg_logprob?: number; + /** + * The compression ratio of the input to the output, measuring how much the text was compressed during the transcription process. + */ + compression_ratio?: number; + /** + * The probability that the segment contains no speech, represented as a decimal between 0 and 1. + */ + no_speech_prob?: number; + words?: { + /** + * The individual word transcribed from the audio. + */ + word?: string; + /** + * The starting time of the word within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the word within the audio, in seconds. + */ + end?: number; + }[]; + }[]; + /** + * The transcription in WebVTT format, which includes timing and text information for use in subtitles. + */ + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo { + inputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output; +} +type Ai_Cf_Baai_Bge_M3_Input = Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts | Ai_Cf_Baai_Bge_M3_Input_Embedding | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: (Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 | Ai_Cf_Baai_Bge_M3_Input_Embedding_1)[]; +}; +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding_1 { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +type Ai_Cf_Baai_Bge_M3_Output = Ai_Cf_Baai_Bge_M3_Output_Query | Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts | Ai_Cf_Baai_Bge_M3_Output_Embedding | Ai_Cf_Baai_Bge_M3_AsyncResponse; +interface Ai_Cf_Baai_Bge_M3_Output_Query { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +interface Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts { + response?: number[][]; + shape?: number[]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} +interface Ai_Cf_Baai_Bge_M3_Output_Embedding { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} +interface Ai_Cf_Baai_Bge_M3_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_M3 { + inputs: Ai_Cf_Baai_Bge_M3_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_M3_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * The number of diffusion steps; higher values can improve quality but take longer. + */ + steps?: number; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell { + inputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input = Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt | Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages; +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + image?: number[] | (string & NonNullable); + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; +} +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + image?: number[] | (string & NonNullable); + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * If true, the response will be streamed back incrementally. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output = { + /** + * The generated text response from the model + */ + response?: string; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct { + inputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input = Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch { + requests?: { + /** + * User-supplied reference. This field will be present in the response as well it can be used to reference the request and response. It's NOT validated to be unique. + */ + external_reference?: string; + /** + * Prompt for the text generation model + */ + prompt?: string; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2; + }[]; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +} | string | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast { + inputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Input { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender must alternate between 'user' and 'assistant'. + */ + role: "user" | "assistant"; + /** + * The content of the message as a string. + */ + content: string; + }[]; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Dictate the output format of the generated response. + */ + response_format?: { + /** + * Set to json_object to process and output generated text as JSON. + */ + type?: string; + }; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Output { + response?: string | { + /** + * Whether the conversation is safe or not. + */ + safe?: boolean; + /** + * A list of what hazard categories predicted for the conversation, if the conversation is deemed unsafe. + */ + categories?: string[]; + }; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +declare abstract class Base_Ai_Cf_Meta_Llama_Guard_3_8B { + inputs: Ai_Cf_Meta_Llama_Guard_3_8B_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_Guard_3_8B_Output; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Input { + /** + * A query you wish to perform against the provided contexts. + */ + /** + * Number of returned results starting with the best score. + */ + top_k?: number; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Output { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Reranker_Base { + inputs: Ai_Cf_Baai_Bge_Reranker_Base_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Reranker_Base_Output; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input = Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt | Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages; +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct { + inputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output; +} +type Ai_Cf_Qwen_Qwq_32B_Input = Ai_Cf_Qwen_Qwq_32B_Prompt | Ai_Cf_Qwen_Qwq_32B_Messages; +interface Ai_Cf_Qwen_Qwq_32B_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwq_32B_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Qwen_Qwq_32B_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwq_32B { + inputs: Ai_Cf_Qwen_Qwq_32B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwq_32B_Output; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input = Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt | Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages; +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct { + inputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output; +} +type Ai_Cf_Google_Gemma_3_12B_It_Input = Ai_Cf_Google_Gemma_3_12B_It_Prompt | Ai_Cf_Google_Gemma_3_12B_It_Messages; +interface Ai_Cf_Google_Gemma_3_12B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Google_Gemma_3_12B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Google_Gemma_3_12B_It_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Google_Gemma_3_12B_It { + inputs: Ai_Cf_Google_Gemma_3_12B_It_Input; + postProcessedOutputs: Ai_Cf_Google_Gemma_3_12B_It_Output; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input = Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch; +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch { + requests: (Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner)[]; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The tool call id. + */ + id?: string; + /** + * Specifies the type of tool (e.g., 'function'). + */ + type?: string; + /** + * Details of the function tool. + */ + function?: { + /** + * The name of the tool to be called + */ + name?: string; + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + }; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct { + inputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch { + requests: (Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1)[]; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response | string | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "chat.completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: "function"; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "text_completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8 { + inputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output; +} +interface Ai_Cf_Deepgram_Nova_3_Input { + audio: { + body: object; + contentType: string; + }; + /** + * Sets how the model will interpret strings submitted to the custom_topic param. When strict, the model will only return topics submitted using the custom_topic param. When extended, the model will return its own detected topics in addition to those submitted using the custom_topic param. + */ + custom_topic_mode?: "extended" | "strict"; + /** + * Custom topics you want the model to detect within your input audio or text if present Submit up to 100 + */ + custom_topic?: string; + /** + * Sets how the model will interpret intents submitted to the custom_intent param. When strict, the model will only return intents submitted using the custom_intent param. When extended, the model will return its own detected intents in addition those submitted using the custom_intents param + */ + custom_intent_mode?: "extended" | "strict"; + /** + * Custom intents you want the model to detect within your input audio if present + */ + custom_intent?: string; + /** + * Identifies and extracts key entities from content in submitted audio + */ + detect_entities?: boolean; + /** + * Identifies the dominant language spoken in submitted audio + */ + detect_language?: boolean; + /** + * Recognize speaker changes. Each word in the transcript will be assigned a speaker number starting at 0 + */ + diarize?: boolean; + /** + * Identify and extract key entities from content in submitted audio + */ + dictation?: boolean; + /** + * Specify the expected encoding of your submitted audio + */ + encoding?: "linear16" | "flac" | "mulaw" | "amr-nb" | "amr-wb" | "opus" | "speex" | "g729"; + /** + * Arbitrary key-value pairs that are attached to the API response for usage in downstream processing + */ + extra?: string; + /** + * Filler Words can help transcribe interruptions in your audio, like 'uh' and 'um' + */ + filler_words?: boolean; + /** + * Key term prompting can boost or suppress specialized terminology and brands. + */ + keyterm?: string; + /** + * Keywords can boost or suppress specialized terminology and brands. + */ + keywords?: string; + /** + * The BCP-47 language tag that hints at the primary spoken language. Depending on the Model and API endpoint you choose only certain languages are available. + */ + language?: string; + /** + * Spoken measurements will be converted to their corresponding abbreviations. + */ + measurements?: boolean; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to our Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip. + */ + mip_opt_out?: boolean; + /** + * Mode of operation for the model representing broad area of topic that will be talked about in the supplied audio + */ + mode?: "general" | "medical" | "finance"; + /** + * Transcribe each audio channel independently. + */ + multichannel?: boolean; + /** + * Numerals converts numbers from written format to numerical format. + */ + numerals?: boolean; + /** + * Splits audio into paragraphs to improve transcript readability. + */ + paragraphs?: boolean; + /** + * Profanity Filter looks for recognized profanity and converts it to the nearest recognized non-profane word or removes it from the transcript completely. + */ + profanity_filter?: boolean; + /** + * Add punctuation and capitalization to the transcript. + */ + punctuate?: boolean; + /** + * Redaction removes sensitive information from your transcripts. + */ + redact?: string; + /** + * Search for terms or phrases in submitted audio and replaces them. + */ + replace?: string; + /** + * Search for terms or phrases in submitted audio. + */ + search?: string; + /** + * Recognizes the sentiment throughout a transcript or text. + */ + sentiment?: boolean; + /** + * Apply formatting to transcript output. When set to true, additional formatting will be applied to transcripts to improve readability. + */ + smart_format?: boolean; + /** + * Detect topics throughout a transcript or text. + */ + topics?: boolean; + /** + * Segments speech into meaningful semantic units. + */ + utterances?: boolean; + /** + * Seconds to wait before detecting a pause between words in submitted audio. + */ + utt_split?: number; + /** + * The number of channels in the submitted audio + */ + channels?: number; + /** + * Specifies whether the streaming endpoint should provide ongoing transcription updates as more audio is received. When set to true, the endpoint sends continuous updates, meaning transcription results may evolve over time. Note: Supported only for webosockets. + */ + interim_results?: boolean; + /** + * Indicates how long model will wait to detect whether a speaker has finished speaking or pauses for a significant period of time. When set to a value, the streaming endpoint immediately finalizes the transcription for the processed time range and returns the transcript with a speech_final parameter set to true. Can also be set to false to disable endpointing + */ + endpointing?: string; + /** + * Indicates that speech has started. You'll begin receiving Speech Started messages upon speech starting. Note: Supported only for webosockets. + */ + vad_events?: boolean; + /** + * Indicates how long model will wait to send an UtteranceEnd message after a word has been transcribed. Use with interim_results. Note: Supported only for webosockets. + */ + utterance_end_ms?: boolean; +} +interface Ai_Cf_Deepgram_Nova_3_Output { + results?: { + channels?: { + alternatives?: { + confidence?: number; + transcript?: string; + words?: { + confidence?: number; + end?: number; + start?: number; + word?: string; + }[]; + }[]; + }[]; + summary?: { + result?: string; + short?: string; + }; + sentiments?: { + segments?: { + text?: string; + start_word?: number; + end_word?: number; + sentiment?: string; + sentiment_score?: number; + }[]; + average?: { + sentiment?: string; + sentiment_score?: number; + }; + }; + }; +} +declare abstract class Base_Ai_Cf_Deepgram_Nova_3 { + inputs: Ai_Cf_Deepgram_Nova_3_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Nova_3_Output; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input { + queries?: string | string[]; + /** + * Optional instruction for the task + */ + instruction?: string; + documents?: string | string[]; + text?: string | string[]; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output { + data?: number[][]; + shape?: number[]; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B { + inputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output; +} +type Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input = { + /** + * readable stream with audio data and content-type specified for that data + */ + audio: { + body: object; + contentType: string; + }; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: "uint8" | "float32" | "float64"; +} | { + /** + * base64 encoded audio data + */ + audio: string; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: "uint8" | "float32" | "float64"; +}; +interface Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output { + /** + * if true, end-of-turn was detected + */ + is_complete?: boolean; + /** + * probability of the end-of-turn detection + */ + probability?: number; +} +declare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 { + inputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input; + postProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B { + inputs: XOR; + postProcessedOutputs: XOR; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B { + inputs: XOR; + postProcessedOutputs: XOR; +} +interface Ai_Cf_Leonardo_Phoenix_1_0_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * Specify what to exclude from the generated images + */ + negative_prompt?: string; +} +/** + * The generated image in JPEG format + */ +type Ai_Cf_Leonardo_Phoenix_1_0_Output = string; +declare abstract class Base_Ai_Cf_Leonardo_Phoenix_1_0 { + inputs: Ai_Cf_Leonardo_Phoenix_1_0_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Phoenix_1_0_Output; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + steps?: number; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Leonardo_Lucid_Origin { + inputs: Ai_Cf_Leonardo_Lucid_Origin_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Lucid_Origin_Output; +} +interface Ai_Cf_Deepgram_Aura_1_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "angus" | "asteria" | "arcas" | "orion" | "orpheus" | "athena" | "luna" | "zeus" | "perseus" | "helios" | "hera" | "stella"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_1_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_1 { + inputs: Ai_Cf_Deepgram_Aura_1_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_1_Output; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input { + /** + * Input text to translate. Can be a single string or a list of strings. + */ + text: string | string[]; + /** + * Target langauge to translate to + */ + target_language: "asm_Beng" | "awa_Deva" | "ben_Beng" | "bho_Deva" | "brx_Deva" | "doi_Deva" | "eng_Latn" | "gom_Deva" | "gon_Deva" | "guj_Gujr" | "hin_Deva" | "hne_Deva" | "kan_Knda" | "kas_Arab" | "kas_Deva" | "kha_Latn" | "lus_Latn" | "mag_Deva" | "mai_Deva" | "mal_Mlym" | "mar_Deva" | "mni_Beng" | "mni_Mtei" | "npi_Deva" | "ory_Orya" | "pan_Guru" | "san_Deva" | "sat_Olck" | "snd_Arab" | "snd_Deva" | "tam_Taml" | "tel_Telu" | "urd_Arab" | "unr_Deva"; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output { + /** + * Translated texts + */ + translations: string[]; +} +declare abstract class Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B { + inputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input; + postProcessedOutputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch { + requests: (Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1)[]; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response | string | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "chat.completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: "function"; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "text_completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It { + inputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input; + postProcessedOutputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Input { + /** + * Input text to embed. Can be a single string or a list of strings. + */ + text: string | string[]; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Output { + /** + * Embedding vectors, where each vector is a list of floats. + */ + data: number[][]; + /** + * Shape of the embedding data as [number_of_embeddings, embedding_dimension]. + * + * @minItems 2 + * @maxItems 2 + */ + shape: [ + number, + number + ]; +} +declare abstract class Base_Ai_Cf_Pfnet_Plamo_Embedding_1B { + inputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Input; + postProcessedOutputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Output; +} +interface Ai_Cf_Deepgram_Flux_Input { + /** + * Encoding of the audio stream. Currently only supports raw signed little-endian 16-bit PCM. + */ + encoding: "linear16"; + /** + * Sample rate of the audio stream in Hz. + */ + sample_rate: string; + /** + * End-of-turn confidence required to fire an eager end-of-turn event. When set, enables EagerEndOfTurn and TurnResumed events. Valid Values 0.3 - 0.9. + */ + eager_eot_threshold?: string; + /** + * End-of-turn confidence required to finish a turn. Valid Values 0.5 - 0.9. + */ + eot_threshold?: string; + /** + * A turn will be finished when this much time has passed after speech, regardless of EOT confidence. + */ + eot_timeout_ms?: string; + /** + * Keyterm prompting can improve recognition of specialized terminology. Pass multiple keyterm query parameters to boost multiple keyterms. + */ + keyterm?: string; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to Deepgram Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip + */ + mip_opt_out?: "true" | "false"; + /** + * Label your requests for the purpose of identification during usage reporting + */ + tag?: string; +} +/** + * Output will be returned as websocket messages. + */ +interface Ai_Cf_Deepgram_Flux_Output { + /** + * The unique identifier of the request (uuid) + */ + request_id?: string; + /** + * Starts at 0 and increments for each message the server sends to the client. + */ + sequence_id?: number; + /** + * The type of event being reported. + */ + event?: "Update" | "StartOfTurn" | "EagerEndOfTurn" | "TurnResumed" | "EndOfTurn"; + /** + * The index of the current turn + */ + turn_index?: number; + /** + * Start time in seconds of the audio range that was transcribed + */ + audio_window_start?: number; + /** + * End time in seconds of the audio range that was transcribed + */ + audio_window_end?: number; + /** + * Text that was said over the course of the current turn + */ + transcript?: string; + /** + * The words in the transcript + */ + words?: { + /** + * The individual punctuated, properly-cased word from the transcript + */ + word: string; + /** + * Confidence that this word was transcribed correctly + */ + confidence: number; + }[]; + /** + * Confidence that no more speech is coming in this turn + */ + end_of_turn_confidence?: number; +} +declare abstract class Base_Ai_Cf_Deepgram_Flux { + inputs: Ai_Cf_Deepgram_Flux_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Flux_Output; +} +interface Ai_Cf_Deepgram_Aura_2_En_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "amalthea" | "andromeda" | "apollo" | "arcas" | "aries" | "asteria" | "athena" | "atlas" | "aurora" | "callista" | "cora" | "cordelia" | "delia" | "draco" | "electra" | "harmonia" | "helena" | "hera" | "hermes" | "hyperion" | "iris" | "janus" | "juno" | "jupiter" | "luna" | "mars" | "minerva" | "neptune" | "odysseus" | "ophelia" | "orion" | "orpheus" | "pandora" | "phoebe" | "pluto" | "saturn" | "thalia" | "theia" | "vesta" | "zeus"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_En_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_En { + inputs: Ai_Cf_Deepgram_Aura_2_En_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_En_Output; +} +interface Ai_Cf_Deepgram_Aura_2_Es_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "sirio" | "nestor" | "carina" | "celeste" | "alvaro" | "diana" | "aquila" | "selena" | "estrella" | "javier"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_Es_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_Es { + inputs: Ai_Cf_Deepgram_Aura_2_Es_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_Es_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_4_7_Flash { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_5 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +interface AiModels { + "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; + "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; + "@cf/runwayml/stable-diffusion-v1-5-inpainting": BaseAiTextToImage; + "@cf/runwayml/stable-diffusion-v1-5-img2img": BaseAiTextToImage; + "@cf/lykon/dreamshaper-8-lcm": BaseAiTextToImage; + "@cf/bytedance/stable-diffusion-xl-lightning": BaseAiTextToImage; + "@cf/myshell-ai/melotts": BaseAiTextToSpeech; + "@cf/google/embeddinggemma-300m": BaseAiTextEmbeddings; + "@cf/microsoft/resnet-50": BaseAiImageClassification; + "@cf/meta/llama-2-7b-chat-int8": BaseAiTextGeneration; + "@cf/mistral/mistral-7b-instruct-v0.1": BaseAiTextGeneration; + "@cf/meta/llama-2-7b-chat-fp16": BaseAiTextGeneration; + "@hf/thebloke/llama-2-13b-chat-awq": BaseAiTextGeneration; + "@hf/thebloke/mistral-7b-instruct-v0.1-awq": BaseAiTextGeneration; + "@hf/thebloke/zephyr-7b-beta-awq": BaseAiTextGeneration; + "@hf/thebloke/openhermes-2.5-mistral-7b-awq": BaseAiTextGeneration; + "@hf/thebloke/neural-chat-7b-v3-1-awq": BaseAiTextGeneration; + "@hf/thebloke/deepseek-coder-6.7b-base-awq": BaseAiTextGeneration; + "@hf/thebloke/deepseek-coder-6.7b-instruct-awq": BaseAiTextGeneration; + "@cf/deepseek-ai/deepseek-math-7b-instruct": BaseAiTextGeneration; + "@cf/defog/sqlcoder-7b-2": BaseAiTextGeneration; + "@cf/openchat/openchat-3.5-0106": BaseAiTextGeneration; + "@cf/tiiuae/falcon-7b-instruct": BaseAiTextGeneration; + "@cf/thebloke/discolm-german-7b-v1-awq": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-0.5b-chat": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-7b-chat-awq": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-14b-chat-awq": BaseAiTextGeneration; + "@cf/tinyllama/tinyllama-1.1b-chat-v1.0": BaseAiTextGeneration; + "@cf/microsoft/phi-2": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-1.8b-chat": BaseAiTextGeneration; + "@cf/mistral/mistral-7b-instruct-v0.2-lora": BaseAiTextGeneration; + "@hf/nousresearch/hermes-2-pro-mistral-7b": BaseAiTextGeneration; + "@hf/nexusflow/starling-lm-7b-beta": BaseAiTextGeneration; + "@hf/google/gemma-7b-it": BaseAiTextGeneration; + "@cf/meta-llama/llama-2-7b-chat-hf-lora": BaseAiTextGeneration; + "@cf/google/gemma-2b-it-lora": BaseAiTextGeneration; + "@cf/google/gemma-7b-it-lora": BaseAiTextGeneration; + "@hf/mistral/mistral-7b-instruct-v0.2": BaseAiTextGeneration; + "@cf/meta/llama-3-8b-instruct": BaseAiTextGeneration; + "@cf/fblgit/una-cybertron-7b-v2-bf16": BaseAiTextGeneration; + "@cf/meta/llama-3-8b-instruct-awq": BaseAiTextGeneration; + "@cf/meta/llama-3.1-8b-instruct-fp8": BaseAiTextGeneration; + "@cf/meta/llama-3.1-8b-instruct-awq": BaseAiTextGeneration; + "@cf/meta/llama-3.2-3b-instruct": BaseAiTextGeneration; + "@cf/meta/llama-3.2-1b-instruct": BaseAiTextGeneration; + "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": BaseAiTextGeneration; + "@cf/ibm-granite/granite-4.0-h-micro": BaseAiTextGeneration; + "@cf/facebook/bart-large-cnn": BaseAiSummarization; + "@cf/llava-hf/llava-1.5-7b-hf": BaseAiImageToText; + "@cf/baai/bge-base-en-v1.5": Base_Ai_Cf_Baai_Bge_Base_En_V1_5; + "@cf/openai/whisper": Base_Ai_Cf_Openai_Whisper; + "@cf/meta/m2m100-1.2b": Base_Ai_Cf_Meta_M2M100_1_2B; + "@cf/baai/bge-small-en-v1.5": Base_Ai_Cf_Baai_Bge_Small_En_V1_5; + "@cf/baai/bge-large-en-v1.5": Base_Ai_Cf_Baai_Bge_Large_En_V1_5; + "@cf/unum/uform-gen2-qwen-500m": Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M; + "@cf/openai/whisper-tiny-en": Base_Ai_Cf_Openai_Whisper_Tiny_En; + "@cf/openai/whisper-large-v3-turbo": Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo; + "@cf/baai/bge-m3": Base_Ai_Cf_Baai_Bge_M3; + "@cf/black-forest-labs/flux-1-schnell": Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell; + "@cf/meta/llama-3.2-11b-vision-instruct": Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct; + "@cf/meta/llama-3.3-70b-instruct-fp8-fast": Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast; + "@cf/meta/llama-guard-3-8b": Base_Ai_Cf_Meta_Llama_Guard_3_8B; + "@cf/baai/bge-reranker-base": Base_Ai_Cf_Baai_Bge_Reranker_Base; + "@cf/qwen/qwen2.5-coder-32b-instruct": Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct; + "@cf/qwen/qwq-32b": Base_Ai_Cf_Qwen_Qwq_32B; + "@cf/mistralai/mistral-small-3.1-24b-instruct": Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct; + "@cf/google/gemma-3-12b-it": Base_Ai_Cf_Google_Gemma_3_12B_It; + "@cf/meta/llama-4-scout-17b-16e-instruct": Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct; + "@cf/qwen/qwen3-30b-a3b-fp8": Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8; + "@cf/deepgram/nova-3": Base_Ai_Cf_Deepgram_Nova_3; + "@cf/qwen/qwen3-embedding-0.6b": Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B; + "@cf/pipecat-ai/smart-turn-v2": Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2; + "@cf/openai/gpt-oss-120b": Base_Ai_Cf_Openai_Gpt_Oss_120B; + "@cf/openai/gpt-oss-20b": Base_Ai_Cf_Openai_Gpt_Oss_20B; + "@cf/leonardo/phoenix-1.0": Base_Ai_Cf_Leonardo_Phoenix_1_0; + "@cf/leonardo/lucid-origin": Base_Ai_Cf_Leonardo_Lucid_Origin; + "@cf/deepgram/aura-1": Base_Ai_Cf_Deepgram_Aura_1; + "@cf/ai4bharat/indictrans2-en-indic-1B": Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B; + "@cf/aisingapore/gemma-sea-lion-v4-27b-it": Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It; + "@cf/pfnet/plamo-embedding-1b": Base_Ai_Cf_Pfnet_Plamo_Embedding_1B; + "@cf/deepgram/flux": Base_Ai_Cf_Deepgram_Flux; + "@cf/deepgram/aura-2-en": Base_Ai_Cf_Deepgram_Aura_2_En; + "@cf/deepgram/aura-2-es": Base_Ai_Cf_Deepgram_Aura_2_Es; + "@cf/black-forest-labs/flux-2-dev": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev; + "@cf/black-forest-labs/flux-2-klein-4b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B; + "@cf/black-forest-labs/flux-2-klein-9b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B; + "@cf/zai-org/glm-4.7-flash": Base_Ai_Cf_Zai_Org_Glm_4_7_Flash; + "@cf/moonshotai/kimi-k2.5": Base_Ai_Cf_Moonshotai_Kimi_K2_5; + "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; +} +type AiOptions = { + /** + * Send requests as an asynchronous batch job, only works for supported models + * https://developers.cloudflare.com/workers-ai/features/batch-api + */ + queueRequest?: boolean; + /** + * Establish websocket connections, only works for supported models + */ + websocket?: boolean; + /** + * Tag your requests to group and view them in Cloudflare dashboard. + * + * Rules: + * Tags must only contain letters, numbers, and the symbols: : - . / @ + * Each tag can have maximum 50 characters. + * Maximum 5 tags are allowed each request. + * Duplicate tags will removed. + */ + tags?: string[]; + gateway?: GatewayOptions; + returnRawResponse?: boolean; + prefix?: string; + extraHeaders?: object; + signal?: AbortSignal; +}; +type AiModelsSearchParams = { + author?: string; + hide_experimental?: boolean; + page?: number; + per_page?: number; + search?: string; + source?: number; + task?: string; +}; +type AiModelsSearchObject = { + id: string; + source: number; + name: string; + description: string; + task: { + id: string; + name: string; + description: string; + }; + tags: string[]; + properties: { + property_id: string; + value: string; + }[]; +}; +type ChatCompletionsBase = XOR; +type ChatCompletionsInput = XOR; +interface InferenceUpstreamError extends Error { +} +interface AiInternalError extends Error { +} +type AiModelListType = Record; +type AiAsyncBatchResponse = { + request_id: string; +}; +declare abstract class Ai { + aiGatewayLogId: string | null; + gateway(gatewayId: string): AiGateway; + /** + * @deprecated Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(): AiSearchNamespace; + /** + * @deprecated AutoRAG has been replaced by AI Search. + * Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + * + * @param autoragId Instance ID + */ + autorag(autoragId: string): AutoRAG; + // Batch request + run(model: Name, inputs: { + requests: AiModelList[Name]['inputs'][]; + }, options: AiOptions & { + queueRequest: true; + }): Promise; + // Raw response + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { + returnRawResponse: true; + }): Promise; + // WebSocket + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { + websocket: true; + }): Promise; + // Streaming + run(model: Name, inputs: AiModelList[Name]['inputs'] & { + stream: true; + }, options?: AiOptions): Promise; + // Normal (default) - known model + run(model: Name, inputs: AiModelList[Name]['inputs'], options?: AiOptions): Promise; + // Unknown model (gateway fallback) + run(model: string & {}, inputs: Record, options?: AiOptions): Promise>; + models(params?: AiModelsSearchParams): Promise; + toMarkdown(): ToMarkdownService; + toMarkdown(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise; + toMarkdown(files: MarkdownDocument, options?: ConversionRequestOptions): Promise; +} +type GatewayRetries = { + maxAttempts?: 1 | 2 | 3 | 4 | 5; + retryDelayMs?: number; + backoff?: 'constant' | 'linear' | 'exponential'; +}; +type GatewayOptions = { + id: string; + cacheKey?: string; + cacheTtl?: number; + skipCache?: boolean; + metadata?: Record; + collectLog?: boolean; + eventId?: string; + requestTimeoutMs?: number; + retries?: GatewayRetries; +}; +type UniversalGatewayOptions = Exclude & { + /** + ** @deprecated + */ + id?: string; +}; +type AiGatewayPatchLog = { + score?: number | null; + feedback?: -1 | 1 | null; + metadata?: Record | null; +}; +type AiGatewayLog = { + id: string; + provider: string; + model: string; + model_type?: string; + path: string; + duration: number; + request_type?: string; + request_content_type?: string; + status_code: number; + response_content_type?: string; + success: boolean; + cached: boolean; + tokens_in?: number; + tokens_out?: number; + metadata?: Record; + step?: number; + cost?: number; + custom_cost?: boolean; + request_size: number; + request_head?: string; + request_head_complete: boolean; + response_size: number; + response_head?: string; + response_head_complete: boolean; + created_at: Date; +}; +type AIGatewayProviders = 'workers-ai' | 'anthropic' | 'aws-bedrock' | 'azure-openai' | 'google-vertex-ai' | 'huggingface' | 'openai' | 'perplexity-ai' | 'replicate' | 'groq' | 'cohere' | 'google-ai-studio' | 'mistral' | 'grok' | 'openrouter' | 'deepseek' | 'cerebras' | 'cartesia' | 'elevenlabs' | 'adobe-firefly'; +type AIGatewayHeaders = { + 'cf-aig-metadata': Record | string; + 'cf-aig-custom-cost': { + per_token_in?: number; + per_token_out?: number; + } | { + total_cost?: number; + } | string; + 'cf-aig-cache-ttl': number | string; + 'cf-aig-skip-cache': boolean | string; + 'cf-aig-cache-key': string; + 'cf-aig-event-id': string; + 'cf-aig-request-timeout': number | string; + 'cf-aig-max-attempts': number | string; + 'cf-aig-retry-delay': number | string; + 'cf-aig-backoff': string; + 'cf-aig-collect-log': boolean | string; + Authorization: string; + 'Content-Type': string; + [key: string]: string | number | boolean | object; +}; +type AIGatewayUniversalRequest = { + provider: AIGatewayProviders | string; // eslint-disable-line + endpoint: string; + headers: Partial; + query: unknown; +}; +interface AiGatewayInternalError extends Error { +} +interface AiGatewayLogNotFound extends Error { +} +declare abstract class AiGateway { + patchLog(logId: string, data: AiGatewayPatchLog): Promise; + getLog(logId: string): Promise; + run(data: AIGatewayUniversalRequest | AIGatewayUniversalRequest[], options?: { + gateway?: UniversalGatewayOptions; + extraHeaders?: object; + signal?: AbortSignal; + }): Promise; + getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line +} +// Copyright (c) 2022-2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +/** + * Artifacts — Git-compatible file storage on Cloudflare Workers. + * + * Provides programmatic access to create, manage, and fork repositories, + * and to issue and revoke scoped access tokens. + */ +/** Information about a repository. */ +interface ArtifactsRepoInfo { + /** Unique repository ID. */ + id: string; + /** Repository name. */ + name: string; + /** Repository description, or null if not set. */ + description: string | null; + /** Default branch name (e.g. "main"). */ + defaultBranch: string; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt: string; + /** ISO 8601 timestamp of the last push, or null if never pushed. */ + lastPushAt: string | null; + /** Fork source (e.g. "github:owner/repo", "artifacts:namespace/repo"), or null if not a fork. */ + source: string | null; + /** Whether the repository is read-only. */ + readOnly: boolean; + /** HTTPS git remote URL. */ + remote: string; +} +/** Result of creating a repository — includes the initial access token. */ +interface ArtifactsCreateRepoResult { + /** Unique repository ID. */ + id: string; + /** Repository name. */ + name: string; + /** Repository description, or null if not set. */ + description: string | null; + /** Default branch name. */ + defaultBranch: string; + /** HTTPS git remote URL. */ + remote: string; + /** Plaintext access token (only returned at creation time). */ + token: string; + /** ISO 8601 token expiry timestamp. */ + tokenExpiresAt: string; +} +/** Paginated list of repositories. */ +interface ArtifactsRepoListResult { + /** Repositories in this page (without the `remote` field). */ + repos: Omit[]; + /** Total number of repositories in the namespace. */ + total: number; + /** Cursor for the next page, if there are more results. */ + cursor?: string; +} +/** Result of creating an access token. */ +interface ArtifactsCreateTokenResult { + /** Unique token ID. */ + id: string; + /** Plaintext token (only returned at creation time). */ + plaintext: string; + /** Token scope: "read" or "write". */ + scope: 'read' | 'write'; + /** ISO 8601 token expiry timestamp. */ + expiresAt: string; +} +/** Token metadata (no plaintext). */ +interface ArtifactsTokenInfo { + /** Unique token ID. */ + id: string; + /** Token scope: "read" or "write". */ + scope: 'read' | 'write'; + /** Token state: "active", "expired", or "revoked". */ + state: 'active' | 'expired' | 'revoked'; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 expiry timestamp. */ + expiresAt: string; +} +/** Paginated list of tokens for a repository. */ +interface ArtifactsTokenListResult { + /** Tokens in this page. */ + tokens: ArtifactsTokenInfo[]; + /** Total number of tokens for the repository. */ + total: number; +} +/** Handle for a single repository. Returned by Artifacts.get(). */ +interface ArtifactsRepo extends ArtifactsRepoInfo { + /** + * Create an access token for this repo. + * @param scope Token scope: "write" (default) or "read". + * @param ttl Time-to-live in seconds (default 86400, min 60, max 31536000). + */ + createToken(scope?: 'write' | 'read', ttl?: number): Promise; + /** List tokens for this repo (metadata only, no plaintext). */ + listTokens(): Promise; + /** + * Revoke a token by plaintext or ID. + * @param tokenOrId Plaintext token or token ID. + * @returns true if revoked, false if not found. + */ + revokeToken(tokenOrId: string): Promise; + // ── Fork ── + /** + * Fork this repo to a new repo. + * @param name Target repository name. + * @param opts Optional: description, readOnly flag, defaultBranchOnly (default true). + */ + fork(name: string, opts?: { + description?: string; + readOnly?: boolean; + defaultBranchOnly?: boolean; + }): Promise; +} +/** Artifacts binding — namespace-level operations. */ +interface Artifacts { + /** + * Create a new repository with an initial access token. + * @param name Repository name (alphanumeric, dots, hyphens, underscores). + * @param opts Optional: readOnly flag, description, default branch name. + * @returns Repo metadata with initial token. + */ + create(name: string, opts?: { + readOnly?: boolean; + description?: string; + setDefaultBranch?: string; + }): Promise; + /** + * Get a handle to an existing repository. + * @param name Repository name. + * @returns Repo handle. + */ + get(name: string): Promise; + /** + * Import a repository from an external git remote. + * @param params Source URL and optional branch/depth, plus target name and options. + * @returns Repo metadata with initial token. + */ + import(params: { + source: { + url: string; + branch?: string; + depth?: number; + }; + target: { + name: string; + opts?: { + description?: string; + readOnly?: boolean; + }; + }; + }): Promise; + /** + * List repositories with cursor-based pagination. + * @param opts Optional: limit (1–200, default 50), cursor for next page. + */ + list(opts?: { + limit?: number; + cursor?: string; + }): Promise; + /** + * Delete a repository and all associated tokens. + * @param name Repository name. + * @returns true if deleted, false if not found. + */ + delete(name: string): Promise; +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGInternalError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGNotFoundError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGUnauthorizedError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGNameNotSetError extends Error { +} +type ComparisonFilter = { + key: string; + type: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; + value: string | number | boolean; +}; +type CompoundFilter = { + type: 'and' | 'or'; + filters: ComparisonFilter[]; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagSearchRequest = { + query: string; + filters?: CompoundFilter | ComparisonFilter; + max_num_results?: number; + ranking_options?: { + ranker?: string; + score_threshold?: number; + }; + reranking?: { + enabled?: boolean; + model?: string; + }; + rewrite_query?: boolean; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchRequest = AutoRagSearchRequest & { + stream?: boolean; + system_prompt?: string; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchRequestStreaming = Omit & { + stream: true; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagSearchResponse = { + object: 'vector_store.search_results.page'; + search_query: string; + data: { + file_id: string; + filename: string; + score: number; + attributes: Record; + content: { + type: 'text'; + text: string; + }[]; + }[]; + has_more: boolean; + next_page: string | null; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagListResponse = { + id: string; + enable: boolean; + type: string; + source: string; + vectorize_name: string; + paused: boolean; + status: string; +}[]; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchResponse = AutoRagSearchResponse & { + response: string; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +declare abstract class AutoRAG { + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + list(): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + search(params: AutoRagSearchRequest): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequestStreaming): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequest): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequest): Promise; +} +interface BasicImageTransformations { + /** + * Maximum width in image pixels. The value must be an integer. + */ + width?: number; + /** + * Maximum height in image pixels. The value must be an integer. + */ + height?: number; + /** + * Resizing mode as a string. It affects interpretation of width and height + * options: + * - scale-down: Similar to contain, but the image is never enlarged. If + * the image is larger than given width or height, it will be resized. + * Otherwise its original size will be kept. + * - contain: Resizes to maximum size that fits within the given width and + * height. If only a single dimension is given (e.g. only width), the + * image will be shrunk or enlarged to exactly match that dimension. + * Aspect ratio is always preserved. + * - cover: Resizes (shrinks or enlarges) to fill the entire area of width + * and height. If the image has an aspect ratio different from the ratio + * of width and height, it will be cropped to fit. + * - crop: The image will be shrunk and cropped to fit within the area + * specified by width and height. The image will not be enlarged. For images + * smaller than the given dimensions it's the same as scale-down. For + * images larger than the given dimensions, it's the same as cover. + * See also trim. + * - pad: Resizes to the maximum size that fits within the given width and + * height, and then fills the remaining area with a background color + * (white by default). Use of this mode is not recommended, as the same + * effect can be more efficiently achieved with the contain mode and the + * CSS object-fit: contain property. + * - squeeze: Stretches and deforms to the width and height given, even if it + * breaks aspect ratio + */ + fit?: "scale-down" | "contain" | "cover" | "crop" | "pad" | "squeeze"; + /** + * Image segmentation using artificial intelligence models. Sets pixels not + * within selected segment area to transparent e.g "foreground" sets every + * background pixel as transparent. + */ + segment?: "foreground"; + /** + * When cropping with fit: "cover", this defines the side or point that should + * be left uncropped. The value is either a string + * "left", "right", "top", "bottom", "auto", or "center" (the default), + * or an object {x, y} containing focal point coordinates in the original + * image expressed as fractions ranging from 0.0 (top or left) to 1.0 + * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will + * crop bottom or left and right sides as necessary, but won’t crop anything + * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to + * preserve as much as possible around a point at 20% of the height of the + * source image. + */ + gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates; + /** + * Background color to add underneath the image. Applies only to images with + * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), + * hsl(…), etc.) + */ + background?: string; + /** + * Number of degrees (90, 180, 270) to rotate the image by. width and height + * options refer to axes after rotation. + */ + rotate?: 0 | 90 | 180 | 270 | 360; +} +interface BasicImageTransformationsGravityCoordinates { + x?: number; + y?: number; + mode?: 'remainder' | 'box-center'; +} +/** + * In addition to the properties you can set in the RequestInit dict + * that you pass as an argument to the Request constructor, you can + * set certain properties of a `cf` object to control how Cloudflare + * features are applied to that new Request. + * + * Note: Currently, these properties cannot be tested in the + * playground. + */ +interface RequestInitCfProperties extends Record { + cacheEverything?: boolean; + /** + * A request's cache key is what determines if two requests are + * "the same" for caching purposes. If a request has the same cache key + * as some previous request, then we can serve the same cached response for + * both. (e.g. 'some-key') + * + * Only available for Enterprise customers. + */ + cacheKey?: string; + /** + * This allows you to append additional Cache-Tag response headers + * to the origin response without modifications to the origin server. + * This will allow for greater control over the Purge by Cache Tag feature + * utilizing changes only in the Workers process. + * + * Only available for Enterprise customers. + */ + cacheTags?: string[]; + /** + * Force response to be cached for a given number of seconds. (e.g. 300) + */ + cacheTtl?: number; + /** + * Force response to be cached for a given number of seconds based on the Origin status code. + * (e.g. { '200-299': 86400, '404': 1, '500-599': 0 }) + */ + cacheTtlByStatus?: Record; + /** + * Explicit Cache-Control header value to set on the response stored in cache. + * This gives full control over cache directives (e.g. 'public, max-age=3600, s-maxage=86400'). + * + * Cannot be used together with `cacheTtl` or the `cache` request option (`no-store`/`no-cache`), + * as these are mutually exclusive cache control mechanisms. Setting both will throw a TypeError. + * + * Can be used together with `cacheTtlByStatus`. + */ + cacheControl?: string; + /** + * Whether the response should be eligible for Cache Reserve storage. + */ + cacheReserveEligible?: boolean; + /** + * Whether to respect strong ETags (as opposed to weak ETags) from the origin. + */ + respectStrongEtag?: boolean; + /** + * Whether to strip ETag headers from the origin response before caching. + */ + stripEtags?: boolean; + /** + * Whether to strip Last-Modified headers from the origin response before caching. + */ + stripLastModified?: boolean; + /** + * Whether to enable Cache Deception Armor, which protects against web cache + * deception attacks by verifying the Content-Type matches the URL extension. + */ + cacheDeceptionArmor?: boolean; + /** + * Minimum file size in bytes for a response to be eligible for Cache Reserve storage. + */ + cacheReserveMinimumFileSize?: number; + scrapeShield?: boolean; + apps?: boolean; + image?: RequestInitCfPropertiesImage; + minify?: RequestInitCfPropertiesImageMinify; + mirage?: boolean; + polish?: "lossy" | "lossless" | "off"; + r2?: RequestInitCfPropertiesR2; + /** + * Redirects the request to an alternate origin server. You can use this, + * for example, to implement load balancing across several origins. + * (e.g.us-east.example.com) + * + * Note - For security reasons, the hostname set in resolveOverride must + * be proxied on the same Cloudflare zone of the incoming request. + * Otherwise, the setting is ignored. CNAME hosts are allowed, so to + * resolve to a host under a different domain or a DNS only domain first + * declare a CNAME record within your own zone’s DNS mapping to the + * external hostname, set proxy on Cloudflare, then set resolveOverride + * to point to that CNAME record. + */ + resolveOverride?: string; +} +interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { + /** + * Absolute URL of the image file to use for the drawing. It can be any of + * the supported file formats. For drawing of watermarks or non-rectangular + * overlays we recommend using PNG or WebP images. + */ + url: string; + /** + * Floating-point number between 0 (transparent) and 1 (opaque). + * For example, opacity: 0.5 makes overlay semitransparent. + */ + opacity?: number; + /** + * - If set to true, the overlay image will be tiled to cover the entire + * area. This is useful for stock-photo-like watermarks. + * - If set to "x", the overlay image will be tiled horizontally only + * (form a line). + * - If set to "y", the overlay image will be tiled vertically only + * (form a line). + */ + repeat?: true | "x" | "y"; + /** + * Position of the overlay image relative to a given edge. Each property is + * an offset in pixels. 0 aligns exactly to the edge. For example, left: 10 + * positions left side of the overlay 10 pixels from the left edge of the + * image it's drawn over. bottom: 0 aligns bottom of the overlay with bottom + * of the background image. + * + * Setting both left & right, or both top & bottom is an error. + * + * If no position is specified, the image will be centered. + */ + top?: number; + left?: number; + bottom?: number; + right?: number; +} +interface RequestInitCfPropertiesImage extends BasicImageTransformations { + /** + * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it + * easier to specify higher-DPI sizes in . + */ + dpr?: number; + /** + * Allows you to trim your image. Takes dpr into account and is performed before + * resizing or rotation. + * + * It can be used as: + * - left, top, right, bottom - it will specify the number of pixels to cut + * off each side + * - width, height - the width/height you'd like to end up with - can be used + * in combination with the properties above + * - border - this will automatically trim the surroundings of an image based on + * it's color. It consists of three properties: + * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) + * - tolerance: difference from color to treat as color + * - keep: the number of pixels of border to keep + */ + trim?: "border" | { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; + border?: boolean | { + color?: string; + tolerance?: number; + keep?: number; + }; + }; + /** + * Quality setting from 1-100 (useful values are in 60-90 range). Lower values + * make images look worse, but load faster. The default is 85. It applies only + * to JPEG and WebP images. It doesn’t have any effect on PNG. + */ + quality?: number | "low" | "medium-low" | "medium-high" | "high"; + /** + * Output format to generate. It can be: + * - avif: generate images in AVIF format. + * - webp: generate images in Google WebP format. Set quality to 100 to get + * the WebP-lossless format. + * - json: instead of generating an image, outputs information about the + * image, in JSON format. The JSON object will contain image size + * (before and after resizing), source image’s MIME type, file size, etc. + * - jpeg: generate images in JPEG format. + * - png: generate images in PNG format. + */ + format?: "avif" | "webp" | "json" | "jpeg" | "png" | "baseline-jpeg" | "png-force" | "svg"; + /** + * Whether to preserve animation frames from input files. Default is true. + * Setting it to false reduces animations to still images. This setting is + * recommended when enlarging images or processing arbitrary user content, + * because large GIF animations can weigh tens or even hundreds of megabytes. + * It is also useful to set anim:false when using format:"json" to get the + * response quicker without the number of frames. + */ + anim?: boolean; + /** + * What EXIF data should be preserved in the output image. Note that EXIF + * rotation and embedded color profiles are always applied ("baked in" into + * the image), and aren't affected by this option. Note that if the Polish + * feature is enabled, all metadata may have been removed already and this + * option may have no effect. + * - keep: Preserve most of EXIF metadata, including GPS location if there's + * any. + * - copyright: Only keep the copyright tag, and discard everything else. + * This is the default behavior for JPEG files. + * - none: Discard all invisible EXIF metadata. Currently WebP and PNG + * output formats always discard metadata. + */ + metadata?: "keep" | "copyright" | "none"; + /** + * Strength of sharpening filter to apply to the image. Floating-point + * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a + * recommended value for downscaled images. + */ + sharpen?: number; + /** + * Radius of a blur filter (approximate gaussian). Maximum supported radius + * is 250. + */ + blur?: number; + /** + * Overlays are drawn in the order they appear in the array (last array + * entry is the topmost layer). + */ + draw?: RequestInitCfPropertiesImageDraw[]; + /** + * Fetching image from authenticated origin. Setting this property will + * pass authentication headers (Authorization, Cookie, etc.) through to + * the origin. + */ + "origin-auth"?: "share-publicly"; + /** + * Adds a border around the image. The border is added after resizing. Border + * width takes dpr into account, and can be specified either using a single + * width property, or individually for each side. + */ + border?: { + color: string; + width: number; + } | { + color: string; + top: number; + right: number; + bottom: number; + left: number; + }; + /** + * Increase brightness by a factor. A value of 1.0 equals no change, a value + * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. + * 0 is ignored. + */ + brightness?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + contrast?: number; + /** + * Increase exposure by a factor. A value of 1.0 equals no change, a value of + * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. + */ + gamma?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + saturation?: number; + /** + * Flips the images horizontally, vertically, or both. Flipping is applied before + * rotation, so if you apply flip=h,rotate=90 then the image will be flipped + * horizontally, then rotated by 90 degrees. + */ + flip?: 'h' | 'v' | 'hv'; + /** + * Slightly reduces latency on a cache miss by selecting a + * quickest-to-compress file format, at a cost of increased file size and + * lower image quality. It will usually override the format option and choose + * JPEG over WebP or AVIF. We do not recommend using this option, except in + * unusual circumstances like resizing uncacheable dynamically-generated + * images. + */ + compression?: "fast"; +} +interface RequestInitCfPropertiesImageMinify { + javascript?: boolean; + css?: boolean; + html?: boolean; +} +interface RequestInitCfPropertiesR2 { + /** + * Colo id of bucket that an object is stored in + */ + bucketColoId?: number; +} +/** + * Request metadata provided by Cloudflare's edge. + */ +type IncomingRequestCfProperties = IncomingRequestCfPropertiesBase & IncomingRequestCfPropertiesBotManagementEnterprise & IncomingRequestCfPropertiesCloudflareForSaaSEnterprise & IncomingRequestCfPropertiesGeographicInformation & IncomingRequestCfPropertiesCloudflareAccessOrApiShield; +interface IncomingRequestCfPropertiesBase extends Record { + /** + * [ASN](https://www.iana.org/assignments/as-numbers/as-numbers.xhtml) of the incoming request. + * + * @example 395747 + */ + asn?: number; + /** + * The organization which owns the ASN of the incoming request. + * + * @example "Google Cloud" + */ + asOrganization?: string; + /** + * The original value of the `Accept-Encoding` header if Cloudflare modified it. + * + * @example "gzip, deflate, br" + */ + clientAcceptEncoding?: string; + /** + * The number of milliseconds it took for the request to reach your worker. + * + * @example 22 + */ + clientTcpRtt?: number; + /** + * The three-letter [IATA](https://en.wikipedia.org/wiki/IATA_airport_code) + * airport code of the data center that the request hit. + * + * @example "DFW" + */ + colo: string; + /** + * Represents the upstream's response to a + * [TCP `keepalive` message](https://tldp.org/HOWTO/TCP-Keepalive-HOWTO/overview.html) + * from cloudflare. + * + * For workers with no upstream, this will always be `1`. + * + * @example 3 + */ + edgeRequestKeepAliveStatus: IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus; + /** + * The HTTP Protocol the request used. + * + * @example "HTTP/2" + */ + httpProtocol: string; + /** + * The browser-requested prioritization information in the request object. + * + * If no information was set, defaults to the empty string `""` + * + * @example "weight=192;exclusive=0;group=3;group-weight=127" + * @default "" + */ + requestPriority: string; + /** + * The TLS version of the connection to Cloudflare. + * In requests served over plaintext (without TLS), this property is the empty string `""`. + * + * @example "TLSv1.3" + */ + tlsVersion: string; + /** + * The cipher for the connection to Cloudflare. + * In requests served over plaintext (without TLS), this property is the empty string `""`. + * + * @example "AEAD-AES128-GCM-SHA256" + */ + tlsCipher: string; + /** + * Metadata containing the [`HELLO`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2) and [`FINISHED`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9) messages from this request's TLS handshake. + * + * If the incoming request was served over plaintext (without TLS) this field is undefined. + */ + tlsExportedAuthenticator?: IncomingRequestCfPropertiesExportedAuthenticatorMetadata; +} +interface IncomingRequestCfPropertiesBotManagementBase { + /** + * Cloudflare’s [level of certainty](https://developers.cloudflare.com/bots/concepts/bot-score/) that a request comes from a bot, + * represented as an integer percentage between `1` (almost certainly a bot) and `99` (almost certainly human). + * + * @example 54 + */ + score: number; + /** + * A boolean value that is true if the request comes from a good bot, like Google or Bing. + * Most customers choose to allow this traffic. For more details, see [Traffic from known bots](https://developers.cloudflare.com/firewall/known-issues-and-faq/#how-does-firewall-rules-handle-traffic-from-known-bots). + */ + verifiedBot: boolean; + /** + * A boolean value that is true if the request originates from a + * Cloudflare-verified proxy service. + */ + corporateProxy: boolean; + /** + * A boolean value that's true if the request matches [file extensions](https://developers.cloudflare.com/bots/reference/static-resources/) for many types of static resources. + */ + staticResource: boolean; + /** + * List of IDs that correlate to the Bot Management heuristic detections made on a request (you can have multiple heuristic detections on the same request). + */ + detectionIds: number[]; +} +interface IncomingRequestCfPropertiesBotManagement { + /** + * Results of Cloudflare's Bot Management analysis + */ + botManagement: IncomingRequestCfPropertiesBotManagementBase; + /** + * Duplicate of `botManagement.score`. + * + * @deprecated + */ + clientTrustScore: number; +} +interface IncomingRequestCfPropertiesBotManagementEnterprise extends IncomingRequestCfPropertiesBotManagement { + /** + * Results of Cloudflare's Bot Management analysis + */ + botManagement: IncomingRequestCfPropertiesBotManagementBase & { + /** + * A [JA3 Fingerprint](https://developers.cloudflare.com/bots/concepts/ja3-fingerprint/) to help profile specific SSL/TLS clients + * across different destination IPs, Ports, and X509 certificates. + */ + ja3Hash: string; + }; +} +interface IncomingRequestCfPropertiesCloudflareForSaaSEnterprise { + /** + * Custom metadata set per-host in [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/). + * + * This field is only present if you have Cloudflare for SaaS enabled on your account + * and you have followed the [required steps to enable it]((https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/custom-metadata/)). + */ + hostMetadata?: HostMetadata; +} +interface IncomingRequestCfPropertiesCloudflareAccessOrApiShield { + /** + * Information about the client certificate presented to Cloudflare. + * + * This is populated when the incoming request is served over TLS using + * either Cloudflare Access or API Shield (mTLS) + * and the presented SSL certificate has a valid + * [Certificate Serial Number](https://ldapwiki.com/wiki/Certificate%20Serial%20Number) + * (i.e., not `null` or `""`). + * + * Otherwise, a set of placeholder values are used. + * + * The property `certPresented` will be set to `"1"` when + * the object is populated (i.e. the above conditions were met). + */ + tlsClientAuth: IncomingRequestCfPropertiesTLSClientAuth | IncomingRequestCfPropertiesTLSClientAuthPlaceholder; +} +/** + * Metadata about the request's TLS handshake + */ +interface IncomingRequestCfPropertiesExportedAuthenticatorMetadata { + /** + * The client's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal + * + * @example "44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d" + */ + clientHandshake: string; + /** + * The server's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal + * + * @example "44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d" + */ + serverHandshake: string; + /** + * The client's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal + * + * @example "084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b" + */ + clientFinished: string; + /** + * The server's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal + * + * @example "084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b" + */ + serverFinished: string; +} +/** + * Geographic data about the request's origin. + */ +interface IncomingRequestCfPropertiesGeographicInformation { + /** + * The [ISO 3166-1 Alpha 2](https://www.iso.org/iso-3166-country-codes.html) country code the request originated from. + * + * If your worker is [configured to accept TOR connections](https://support.cloudflare.com/hc/en-us/articles/203306930-Understanding-Cloudflare-Tor-support-and-Onion-Routing), this may also be `"T1"`, indicating a request that originated over TOR. + * + * If Cloudflare is unable to determine where the request originated this property is omitted. + * + * The country code `"T1"` is used for requests originating on TOR. + * + * @example "GB" + */ + country?: Iso3166Alpha2Code | "T1"; + /** + * If present, this property indicates that the request originated in the EU + * + * @example "1" + */ + isEUCountry?: "1"; + /** + * A two-letter code indicating the continent the request originated from. + * + * @example "AN" + */ + continent?: ContinentCode; + /** + * The city the request originated from + * + * @example "Austin" + */ + city?: string; + /** + * Postal code of the incoming request + * + * @example "78701" + */ + postalCode?: string; + /** + * Latitude of the incoming request + * + * @example "30.27130" + */ + latitude?: string; + /** + * Longitude of the incoming request + * + * @example "-97.74260" + */ + longitude?: string; + /** + * Timezone of the incoming request + * + * @example "America/Chicago" + */ + timezone?: string; + /** + * If known, the ISO 3166-2 name for the first level region associated with + * the IP address of the incoming request + * + * @example "Texas" + */ + region?: string; + /** + * If known, the ISO 3166-2 code for the first-level region associated with + * the IP address of the incoming request + * + * @example "TX" + */ + regionCode?: string; + /** + * Metro code (DMA) of the incoming request + * + * @example "635" + */ + metroCode?: string; +} +/** Data about the incoming request's TLS certificate */ +interface IncomingRequestCfPropertiesTLSClientAuth { + /** Always `"1"`, indicating that the certificate was presented */ + certPresented: "1"; + /** + * Result of certificate verification. + * + * @example "FAILED:self signed certificate" + */ + certVerified: Exclude; + /** The presented certificate's revokation status. + * + * - A value of `"1"` indicates the certificate has been revoked + * - A value of `"0"` indicates the certificate has not been revoked + */ + certRevoked: "1" | "0"; + /** + * The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) + * + * @example "CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certIssuerDN: string; + /** + * The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) + * + * @example "CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certSubjectDN: string; + /** + * The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted) + * + * @example "CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certIssuerDNRFC2253: string; + /** + * The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted) + * + * @example "CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certSubjectDNRFC2253: string; + /** The certificate issuer's distinguished name (legacy policies) */ + certIssuerDNLegacy: string; + /** The certificate subject's distinguished name (legacy policies) */ + certSubjectDNLegacy: string; + /** + * The certificate's serial number + * + * @example "00936EACBE07F201DF" + */ + certSerial: string; + /** + * The certificate issuer's serial number + * + * @example "2489002934BDFEA34" + */ + certIssuerSerial: string; + /** + * The certificate's Subject Key Identifier + * + * @example "BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4" + */ + certSKI: string; + /** + * The certificate issuer's Subject Key Identifier + * + * @example "BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4" + */ + certIssuerSKI: string; + /** + * The certificate's SHA-1 fingerprint + * + * @example "6b9109f323999e52259cda7373ff0b4d26bd232e" + */ + certFingerprintSHA1: string; + /** + * The certificate's SHA-256 fingerprint + * + * @example "acf77cf37b4156a2708e34c4eb755f9b5dbbe5ebb55adfec8f11493438d19e6ad3f157f81fa3b98278453d5652b0c1fd1d71e5695ae4d709803a4d3f39de9dea" + */ + certFingerprintSHA256: string; + /** + * The effective starting date of the certificate + * + * @example "Dec 22 19:39:00 2018 GMT" + */ + certNotBefore: string; + /** + * The effective expiration date of the certificate + * + * @example "Dec 22 19:39:00 2018 GMT" + */ + certNotAfter: string; +} +/** Placeholder values for TLS Client Authorization */ +interface IncomingRequestCfPropertiesTLSClientAuthPlaceholder { + certPresented: "0"; + certVerified: "NONE"; + certRevoked: "0"; + certIssuerDN: ""; + certSubjectDN: ""; + certIssuerDNRFC2253: ""; + certSubjectDNRFC2253: ""; + certIssuerDNLegacy: ""; + certSubjectDNLegacy: ""; + certSerial: ""; + certIssuerSerial: ""; + certSKI: ""; + certIssuerSKI: ""; + certFingerprintSHA1: ""; + certFingerprintSHA256: ""; + certNotBefore: ""; + certNotAfter: ""; +} +/** Possible outcomes of TLS verification */ +declare type CertVerificationStatus = +/** Authentication succeeded */ +"SUCCESS" +/** No certificate was presented */ + | "NONE" +/** Failed because the certificate was self-signed */ + | "FAILED:self signed certificate" +/** Failed because the certificate failed a trust chain check */ + | "FAILED:unable to verify the first certificate" +/** Failed because the certificate not yet valid */ + | "FAILED:certificate is not yet valid" +/** Failed because the certificate is expired */ + | "FAILED:certificate has expired" +/** Failed for another unspecified reason */ + | "FAILED"; +/** + * An upstream endpoint's response to a TCP `keepalive` message from Cloudflare. + */ +declare type IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus = 0 /** Unknown */ | 1 /** no keepalives (not found) */ | 2 /** no connection re-use, opening keepalive connection failed */ | 3 /** no connection re-use, keepalive accepted and saved */ | 4 /** connection re-use, refused by the origin server (`TCP FIN`) */ | 5; /** connection re-use, accepted by the origin server */ +/** ISO 3166-1 Alpha-2 codes */ +declare type Iso3166Alpha2Code = "AD" | "AE" | "AF" | "AG" | "AI" | "AL" | "AM" | "AO" | "AQ" | "AR" | "AS" | "AT" | "AU" | "AW" | "AX" | "AZ" | "BA" | "BB" | "BD" | "BE" | "BF" | "BG" | "BH" | "BI" | "BJ" | "BL" | "BM" | "BN" | "BO" | "BQ" | "BR" | "BS" | "BT" | "BV" | "BW" | "BY" | "BZ" | "CA" | "CC" | "CD" | "CF" | "CG" | "CH" | "CI" | "CK" | "CL" | "CM" | "CN" | "CO" | "CR" | "CU" | "CV" | "CW" | "CX" | "CY" | "CZ" | "DE" | "DJ" | "DK" | "DM" | "DO" | "DZ" | "EC" | "EE" | "EG" | "EH" | "ER" | "ES" | "ET" | "FI" | "FJ" | "FK" | "FM" | "FO" | "FR" | "GA" | "GB" | "GD" | "GE" | "GF" | "GG" | "GH" | "GI" | "GL" | "GM" | "GN" | "GP" | "GQ" | "GR" | "GS" | "GT" | "GU" | "GW" | "GY" | "HK" | "HM" | "HN" | "HR" | "HT" | "HU" | "ID" | "IE" | "IL" | "IM" | "IN" | "IO" | "IQ" | "IR" | "IS" | "IT" | "JE" | "JM" | "JO" | "JP" | "KE" | "KG" | "KH" | "KI" | "KM" | "KN" | "KP" | "KR" | "KW" | "KY" | "KZ" | "LA" | "LB" | "LC" | "LI" | "LK" | "LR" | "LS" | "LT" | "LU" | "LV" | "LY" | "MA" | "MC" | "MD" | "ME" | "MF" | "MG" | "MH" | "MK" | "ML" | "MM" | "MN" | "MO" | "MP" | "MQ" | "MR" | "MS" | "MT" | "MU" | "MV" | "MW" | "MX" | "MY" | "MZ" | "NA" | "NC" | "NE" | "NF" | "NG" | "NI" | "NL" | "NO" | "NP" | "NR" | "NU" | "NZ" | "OM" | "PA" | "PE" | "PF" | "PG" | "PH" | "PK" | "PL" | "PM" | "PN" | "PR" | "PS" | "PT" | "PW" | "PY" | "QA" | "RE" | "RO" | "RS" | "RU" | "RW" | "SA" | "SB" | "SC" | "SD" | "SE" | "SG" | "SH" | "SI" | "SJ" | "SK" | "SL" | "SM" | "SN" | "SO" | "SR" | "SS" | "ST" | "SV" | "SX" | "SY" | "SZ" | "TC" | "TD" | "TF" | "TG" | "TH" | "TJ" | "TK" | "TL" | "TM" | "TN" | "TO" | "TR" | "TT" | "TV" | "TW" | "TZ" | "UA" | "UG" | "UM" | "US" | "UY" | "UZ" | "VA" | "VC" | "VE" | "VG" | "VI" | "VN" | "VU" | "WF" | "WS" | "YE" | "YT" | "ZA" | "ZM" | "ZW"; +/** The 2-letter continent codes Cloudflare uses */ +declare type ContinentCode = "AF" | "AN" | "AS" | "EU" | "NA" | "OC" | "SA"; +type CfProperties = IncomingRequestCfProperties | RequestInitCfProperties; +interface D1Meta { + duration: number; + size_after: number; + rows_read: number; + rows_written: number; + last_row_id: number; + changed_db: boolean; + changes: number; + /** + * The region of the database instance that executed the query. + */ + served_by_region?: string; + /** + * The three letters airport code of the colo that executed the query. + */ + served_by_colo?: string; + /** + * True if-and-only-if the database instance that executed the query was the primary. + */ + served_by_primary?: boolean; + timings?: { + /** + * The duration of the SQL query execution by the database instance. It doesn't include any network time. + */ + sql_duration_ms: number; + }; + /** + * Number of total attempts to execute the query, due to automatic retries. + * Note: All other fields in the response like `timings` only apply to the last attempt. + */ + total_attempts?: number; +} +interface D1Response { + success: true; + meta: D1Meta & Record; + error?: never; +} +type D1Result = D1Response & { + results: T[]; +}; +interface D1ExecResult { + count: number; + duration: number; +} +type D1SessionConstraint = +// Indicates that the first query should go to the primary, and the rest queries +// using the same D1DatabaseSession will go to any replica that is consistent with +// the bookmark maintained by the session (returned by the first query). +'first-primary' +// Indicates that the first query can go anywhere (primary or replica), and the rest queries +// using the same D1DatabaseSession will go to any replica that is consistent with +// the bookmark maintained by the session (returned by the first query). + | 'first-unconstrained'; +type D1SessionBookmark = string; +declare abstract class D1Database { + prepare(query: string): D1PreparedStatement; + batch(statements: D1PreparedStatement[]): Promise[]>; + exec(query: string): Promise; + /** + * Creates a new D1 Session anchored at the given constraint or the bookmark. + * All queries executed using the created session will have sequential consistency, + * meaning that all writes done through the session will be visible in subsequent reads. + * + * @param constraintOrBookmark Either the session constraint or the explicit bookmark to anchor the created session. + */ + withSession(constraintOrBookmark?: D1SessionBookmark | D1SessionConstraint): D1DatabaseSession; + /** + * @deprecated dump() will be removed soon, only applies to deprecated alpha v1 databases. + */ + dump(): Promise; +} +declare abstract class D1DatabaseSession { + prepare(query: string): D1PreparedStatement; + batch(statements: D1PreparedStatement[]): Promise[]>; + /** + * @returns The latest session bookmark across all executed queries on the session. + * If no query has been executed yet, `null` is returned. + */ + getBookmark(): D1SessionBookmark | null; +} +declare abstract class D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement; + first(colName: string): Promise; + first>(): Promise; + run>(): Promise>; + all>(): Promise>; + raw(options: { + columnNames: true; + }): Promise<[ + string[], + ...T[] + ]>; + raw(options?: { + columnNames?: false; + }): Promise; +} +// `Disposable` was added to TypeScript's standard lib types in version 5.2. +// To support older TypeScript versions, define an empty `Disposable` interface. +// Users won't be able to use `using`/`Symbol.dispose` without upgrading to 5.2, +// but this will ensure type checking on older versions still passes. +// TypeScript's interface merging will ensure our empty interface is effectively +// ignored when `Disposable` is included in the standard lib. +interface Disposable { +} +/** + * The returned data after sending an email + */ +interface EmailSendResult { + /** + * The Email Message ID + */ + messageId: string; +} +/** + * An email message that can be sent from a Worker. + */ +interface EmailMessage { + /** + * Envelope From attribute of the email message. + */ + readonly from: string; + /** + * Envelope To attribute of the email message. + */ + readonly to: string; +} +/** + * An email message that is sent to a consumer Worker and can be rejected/forwarded. + */ +interface ForwardableEmailMessage extends EmailMessage { + /** + * Stream of the email message content. + */ + readonly raw: ReadableStream; + /** + * An [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). + */ + readonly headers: Headers; + /** + * Size of the email message content. + */ + readonly rawSize: number; + /** + * Reject this email message by returning a permanent SMTP error back to the connecting client including the given reason. + * @param reason The reject reason. + * @returns void + */ + setReject(reason: string): void; + /** + * Forward this email message to a verified destination address of the account. + * @param rcptTo Verified destination address. + * @param headers A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). + * @returns A promise that resolves when the email message is forwarded. + */ + forward(rcptTo: string, headers?: Headers): Promise; + /** + * Reply to the sender of this email message with a new EmailMessage object. + * @param message The reply message. + * @returns A promise that resolves when the email message is replied. + */ + reply(message: EmailMessage): Promise; +} +/** A file attachment for an email message */ +type EmailAttachment = { + disposition: 'inline'; + contentId: string; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; +} | { + disposition: 'attachment'; + contentId?: undefined; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; +}; +/** An Email Address */ +interface EmailAddress { + name: string; + email: string; +} +/** + * A binding that allows a Worker to send email messages. + */ +interface SendEmail { + send(message: EmailMessage): Promise; + send(builder: { + from: string | EmailAddress; + to: string | string[]; + subject: string; + replyTo?: string | EmailAddress; + cc?: string | string[]; + bcc?: string | string[]; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; + }): Promise; +} +declare abstract class EmailEvent extends ExtendableEvent { + readonly message: ForwardableEmailMessage; +} +declare type EmailExportedHandler = (message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) => void | Promise; +declare module "cloudflare:email" { + let _EmailMessage: { + prototype: EmailMessage; + new (from: string, to: string, raw: ReadableStream | string): EmailMessage; + }; + export { _EmailMessage as EmailMessage }; +} +/** + * Evaluation context for targeting rules. + * Keys are attribute names (e.g. "userId", "country"), values are the attribute values. + */ +type FlagshipEvaluationContext = Record; +interface FlagshipEvaluationDetails { + flagKey: string; + value: T; + variant?: string | undefined; + reason?: string | undefined; + errorCode?: string | undefined; + errorMessage?: string | undefined; +} +interface FlagshipEvaluationError extends Error { +} +/** + * Feature flags binding for evaluating feature flags from a Cloudflare Workers script. + * + * @example + * ```typescript + * // Get a boolean flag value with a default + * const enabled = await env.FLAGS.getBooleanValue('my-feature', false); + * + * // Get a flag value with evaluation context for targeting + * const variant = await env.FLAGS.getStringValue('experiment', 'control', { + * userId: 'user-123', + * country: 'US', + * }); + * + * // Get full evaluation details including variant and reason + * const details = await env.FLAGS.getBooleanDetails('my-feature', false); + * console.log(details.variant, details.reason); + * ``` + */ +declare abstract class Flagship { + /** + * Get a flag value without type checking. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Optional default value returned when evaluation fails. + * @param context Optional evaluation context for targeting rules. + */ + get(flagKey: string, defaultValue?: unknown, context?: FlagshipEvaluationContext): Promise; + /** + * Get a boolean flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getBooleanValue(flagKey: string, defaultValue: boolean, context?: FlagshipEvaluationContext): Promise; + /** + * Get a string flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getStringValue(flagKey: string, defaultValue: string, context?: FlagshipEvaluationContext): Promise; + /** + * Get a number flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getNumberValue(flagKey: string, defaultValue: number, context?: FlagshipEvaluationContext): Promise; + /** + * Get an object flag value. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getObjectValue(flagKey: string, defaultValue: T, context?: FlagshipEvaluationContext): Promise; + /** + * Get a boolean flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getBooleanDetails(flagKey: string, defaultValue: boolean, context?: FlagshipEvaluationContext): Promise>; + /** + * Get a string flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getStringDetails(flagKey: string, defaultValue: string, context?: FlagshipEvaluationContext): Promise>; + /** + * Get a number flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getNumberDetails(flagKey: string, defaultValue: number, context?: FlagshipEvaluationContext): Promise>; + /** + * Get an object flag value with full evaluation details. + * @param flagKey The key of the flag to evaluate. + * @param defaultValue Default value returned when evaluation fails or the flag type does not match. + * @param context Optional evaluation context for targeting rules. + */ + getObjectDetails(flagKey: string, defaultValue: T, context?: FlagshipEvaluationContext): Promise>; +} +/** + * Hello World binding to serve as an explanatory example. DO NOT USE + */ +interface HelloWorldBinding { + /** + * Retrieve the current stored value + */ + get(): Promise<{ + value: string; + ms?: number; + }>; + /** + * Set a new stored value + */ + set(value: string): Promise; +} +interface Hyperdrive { + /** + * Connect directly to Hyperdrive as if it's your database, returning a TCP socket. + * + * Calling this method returns an identical socket to if you call + * `connect("host:port")` using the `host` and `port` fields from this object. + * Pick whichever approach works better with your preferred DB client library. + * + * Note that this socket is not yet authenticated -- it's expected that your + * code (or preferably, the client library of your choice) will authenticate + * using the information in this class's readonly fields. + */ + connect(): Socket; + /** + * A valid DB connection string that can be passed straight into the typical + * client library/driver/ORM. This will typically be the easiest way to use + * Hyperdrive. + */ + readonly connectionString: string; + /* + * A randomly generated hostname that is only valid within the context of the + * currently running Worker which, when passed into `connect()` function from + * the "cloudflare:sockets" module, will connect to the Hyperdrive instance + * for your database. + */ + readonly host: string; + /* + * The port that must be paired the the host field when connecting. + */ + readonly port: number; + /* + * The username to use when authenticating to your database via Hyperdrive. + * Unlike the host and password, this will be the same every time + */ + readonly user: string; + /* + * The randomly generated password to use when authenticating to your + * database via Hyperdrive. Like the host field, this password is only valid + * within the context of the currently running Worker instance from which + * it's read. + */ + readonly password: string; + /* + * The name of the database to connect to. + */ + readonly database: string; +} +// Copyright (c) 2024 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +type ImageInfoResponse = { + format: 'image/svg+xml'; +} | { + format: string; + fileSize: number; + width: number; + height: number; +}; +type ImageTransform = { + width?: number; + height?: number; + background?: string; + blur?: number; + border?: { + color?: string; + width?: number; + } | { + top?: number; + bottom?: number; + left?: number; + right?: number; + }; + brightness?: number; + contrast?: number; + fit?: 'scale-down' | 'contain' | 'pad' | 'squeeze' | 'cover' | 'crop'; + flip?: 'h' | 'v' | 'hv'; + gamma?: number; + segment?: 'foreground'; + gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | { + x?: number; + y?: number; + mode: 'remainder' | 'box-center'; + }; + rotate?: 0 | 90 | 180 | 270; + saturation?: number; + sharpen?: number; + trim?: 'border' | { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; + border?: boolean | { + color?: string; + tolerance?: number; + keep?: number; + }; + }; +}; +type ImageDrawOptions = { + opacity?: number; + repeat?: boolean | string; + top?: number; + left?: number; + bottom?: number; + right?: number; +}; +type ImageInputOptions = { + encoding?: 'base64'; +}; +type ImageOutputOptions = { + format: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | 'image/avif' | 'rgb' | 'rgba'; + quality?: number; + background?: string; + anim?: boolean; +}; +interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} +interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + encoding?: 'base64'; +} +interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; +} +interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: 'asc' | 'desc'; + creator?: string; +} +interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} +interface ImageHandle { + /** + * Get metadata for a hosted image + * @returns Image metadata, or null if not found + */ + details(): Promise; + /** + * Get the raw image data for a hosted image + * @returns ReadableStream of image bytes, or null if not found + */ + bytes(): Promise | null>; + /** + * Update hosted image metadata + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(options: ImageUpdateOptions): Promise; + /** + * Delete a hosted image + * @returns True if deleted, false if not found + */ + delete(): Promise; +} +interface HostedImagesBinding { + /** + * Get a handle for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns A handle for per-image operations + */ + image(imageId: string): ImageHandle; + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload(image: ReadableStream | ArrayBuffer, options?: ImageUploadOptions): Promise; + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; +} +interface ImagesBinding { + /** + * Get image metadata (type, width and height) + * @throws {@link ImagesError} with code 9412 if input is not an image + * @param stream The image bytes + */ + info(stream: ReadableStream, options?: ImageInputOptions): Promise; + /** + * Begin applying a series of transformations to an image + * @param stream The image bytes + * @returns A transform handle + */ + input(stream: ReadableStream, options?: ImageInputOptions): ImageTransformer; + /** + * Access hosted images CRUD operations + */ + readonly hosted: HostedImagesBinding; +} +interface ImageTransformer { + /** + * Apply transform next, returning a transform handle. + * You can then apply more transformations, draw, or retrieve the output. + * @param transform + */ + transform(transform: ImageTransform): ImageTransformer; + /** + * Draw an image on this transformer, returning a transform handle. + * You can then apply more transformations, draw, or retrieve the output. + * @param image The image (or transformer that will give the image) to draw + * @param options The options configuring how to draw the image + */ + draw(image: ReadableStream | ImageTransformer, options?: ImageDrawOptions): ImageTransformer; + /** + * Retrieve the image that results from applying the transforms to the + * provided input + * @param options Options that apply to the output e.g. output format + */ + output(options: ImageOutputOptions): Promise; +} +type ImageTransformationOutputOptions = { + encoding?: 'base64'; +}; +interface ImageTransformationResult { + /** + * The image as a response, ready to store in cache or return to users + */ + response(): Response; + /** + * The content type of the returned image + */ + contentType(): string; + /** + * The bytes of the response + */ + image(options?: ImageTransformationOutputOptions): ReadableStream; +} +interface ImagesError extends Error { + readonly code: number; + readonly message: string; + readonly stack?: string; +} +/** + * Media binding for transforming media streams. + * Provides the entry point for media transformation operations. + */ +interface MediaBinding { + /** + * Creates a media transformer from an input stream. + * @param media - The input media bytes + * @returns A MediaTransformer instance for applying transformations + */ + input(media: ReadableStream): MediaTransformer; +} +/** + * Media transformer for applying transformation operations to media content. + * Handles sizing, fitting, and other input transformation parameters. + */ +interface MediaTransformer { + /** + * Applies transformation options to the media content. + * @param transform - Configuration for how the media should be transformed + * @returns A generator for producing the transformed media output + */ + transform(transform?: MediaTransformationInputOptions): MediaTransformationGenerator; + /** + * Generates the final media output with specified options. + * @param output - Configuration for the output format and parameters + * @returns The final transformation result containing the transformed media + */ + output(output?: MediaTransformationOutputOptions): MediaTransformationResult; +} +/** + * Generator for producing media transformation results. + * Configures the output format and parameters for the transformed media. + */ +interface MediaTransformationGenerator { + /** + * Generates the final media output with specified options. + * @param output - Configuration for the output format and parameters + * @returns The final transformation result containing the transformed media + */ + output(output?: MediaTransformationOutputOptions): MediaTransformationResult; +} +/** + * Result of a media transformation operation. + * Provides multiple ways to access the transformed media content. + */ +interface MediaTransformationResult { + /** + * Returns the transformed media as a readable stream of bytes. + * @returns A promise containing a readable stream with the transformed media + */ + media(): Promise>; + /** + * Returns the transformed media as an HTTP response object. + * @returns The transformed media as a Promise, ready to store in cache or return to users + */ + response(): Promise; + /** + * Returns the MIME type of the transformed media. + * @returns A promise containing the content type string (e.g., 'image/jpeg', 'video/mp4') + */ + contentType(): Promise; +} +/** + * Configuration options for transforming media input. + * Controls how the media should be resized and fitted. + */ +type MediaTransformationInputOptions = { + /** How the media should be resized to fit the specified dimensions */ + fit?: 'contain' | 'cover' | 'scale-down'; + /** Target width in pixels */ + width?: number; + /** Target height in pixels */ + height?: number; +}; +/** + * Configuration options for Media Transformations output. + * Controls the format, timing, and type of the generated output. + */ +type MediaTransformationOutputOptions = { + /** + * Output mode determining the type of media to generate + */ + mode?: 'video' | 'spritesheet' | 'frame' | 'audio'; + /** Whether to include audio in the output */ + audio?: boolean; + /** + * Starting timestamp for frame extraction or start time for clips. (e.g. '2s'). + */ + time?: string; + /** + * Duration for video clips, audio extraction, and spritesheet generation (e.g. '5s'). + */ + duration?: string; + /** + * Number of frames in the spritesheet. + */ + imageCount?: number; + /** + * Output format for the generated media. + */ + format?: 'jpg' | 'png' | 'm4a'; +}; +/** + * Error object for media transformation operations. + * Extends the standard Error interface with additional media-specific information. + */ +interface MediaError extends Error { + readonly code: number; + readonly message: string; + readonly stack?: string; +} +declare module 'cloudflare:node' { + interface NodeStyleServer { + listen(...args: unknown[]): this; + address(): { + port?: number | null | undefined; + }; + } + export function httpServerHandler(port: number): ExportedHandler; + export function httpServerHandler(options: { + port: number; + }): ExportedHandler; + export function httpServerHandler(server: NodeStyleServer): ExportedHandler; +} +type Params

= Record; +type EventContext = { + request: Request>; + functionPath: string; + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; + next: (input?: Request | string, init?: RequestInit) => Promise; + env: Env & { + ASSETS: { + fetch: typeof fetch; + }; + }; + params: Params

; + data: Data; +}; +type PagesFunction = Record> = (context: EventContext) => Response | Promise; +type EventPluginContext = { + request: Request>; + functionPath: string; + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; + next: (input?: Request | string, init?: RequestInit) => Promise; + env: Env & { + ASSETS: { + fetch: typeof fetch; + }; + }; + params: Params

; + data: Data; + pluginArgs: PluginArgs; +}; +type PagesPluginFunction = Record, PluginArgs = unknown> = (context: EventPluginContext) => Response | Promise; +declare module "assets:*" { + export const onRequest: PagesFunction; +} +// Copyright (c) 2022-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +declare module "cloudflare:pipelines" { + export abstract class PipelineTransformationEntrypoint { + protected env: Env; + protected ctx: ExecutionContext; + constructor(ctx: ExecutionContext, env: Env); + /** + * run receives an array of PipelineRecord which can be + * transformed and returned to the pipeline + * @param records Incoming records from the pipeline to be transformed + * @param metadata Information about the specific pipeline calling the transformation entrypoint + * @returns A promise containing the transformed PipelineRecord array + */ + public run(records: I[], metadata: PipelineBatchMetadata): Promise; + } + export type PipelineRecord = Record; + export type PipelineBatchMetadata = { + pipelineId: string; + pipelineName: string; + }; + export interface Pipeline { + /** + * The Pipeline interface represents the type of a binding to a Pipeline + * + * @param records The records to send to the pipeline + */ + send(records: T[]): Promise; + } +} +// PubSubMessage represents an incoming PubSub message. +// The message includes metadata about the broker, the client, and the payload +// itself. +// https://developers.cloudflare.com/pub-sub/ +interface PubSubMessage { + // Message ID + readonly mid: number; + // MQTT broker FQDN in the form mqtts://BROKER.NAMESPACE.cloudflarepubsub.com:PORT + readonly broker: string; + // The MQTT topic the message was sent on. + readonly topic: string; + // The client ID of the client that published this message. + readonly clientId: string; + // The unique identifier (JWT ID) used by the client to authenticate, if token + // auth was used. + readonly jti?: string; + // A Unix timestamp (seconds from Jan 1, 1970), set when the Pub/Sub Broker + // received the message from the client. + readonly receivedAt: number; + // An (optional) string with the MIME type of the payload, if set by the + // client. + readonly contentType: string; + // Set to 1 when the payload is a UTF-8 string + // https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901063 + readonly payloadFormatIndicator: number; + // Pub/Sub (MQTT) payloads can be UTF-8 strings, or byte arrays. + // You can use payloadFormatIndicator to inspect this before decoding. + payload: string | Uint8Array; +} +// JsonWebKey extended by kid parameter +interface JsonWebKeyWithKid extends JsonWebKey { + // Key Identifier of the JWK + readonly kid: string; +} +interface RateLimitOptions { + key: string; +} +interface RateLimitOutcome { + success: boolean; +} +interface RateLimit { + /** + * Rate limit a request based on the provided options. + * @see https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/ + * @returns A promise that resolves with the outcome of the rate limit. + */ + limit(options: RateLimitOptions): Promise; +} +// Namespace for RPC utility types. Unfortunately, we can't use a `module` here as these types need +// to referenced by `Fetcher`. This is included in the "importable" version of the types which +// strips all `module` blocks. +declare namespace Rpc { + // Branded types for identifying `WorkerEntrypoint`/`DurableObject`/`Target`s. + // TypeScript uses *structural* typing meaning anything with the same shape as type `T` is a `T`. + // For the classes exported by `cloudflare:workers` we want *nominal* typing (i.e. we only want to + // accept `WorkerEntrypoint` from `cloudflare:workers`, not any other class with the same shape) + export const __RPC_STUB_BRAND: '__RPC_STUB_BRAND'; + export const __RPC_TARGET_BRAND: '__RPC_TARGET_BRAND'; + export const __WORKER_ENTRYPOINT_BRAND: '__WORKER_ENTRYPOINT_BRAND'; + export const __DURABLE_OBJECT_BRAND: '__DURABLE_OBJECT_BRAND'; + export const __WORKFLOW_ENTRYPOINT_BRAND: '__WORKFLOW_ENTRYPOINT_BRAND'; + export interface RpcTargetBranded { + [__RPC_TARGET_BRAND]: never; + } + export interface WorkerEntrypointBranded { + [__WORKER_ENTRYPOINT_BRAND]: never; + } + export interface DurableObjectBranded { + [__DURABLE_OBJECT_BRAND]: never; + } + export interface WorkflowEntrypointBranded { + [__WORKFLOW_ENTRYPOINT_BRAND]: never; + } + export type EntrypointBranded = WorkerEntrypointBranded | DurableObjectBranded | WorkflowEntrypointBranded; + // Types that can be used through `Stub`s + export type Stubable = RpcTargetBranded | ((...args: any[]) => any); + // Types that can be passed over RPC + // The reason for using a generic type here is to build a serializable subset of structured + // cloneable composite types. This allows types defined with the "interface" keyword to pass the + // serializable check as well. Otherwise, only types defined with the "type" keyword would pass. + type Serializable = + // Structured cloneables + BaseType + // Structured cloneable composites + | Map ? Serializable : never, T extends Map ? Serializable : never> | Set ? Serializable : never> | ReadonlyArray ? Serializable : never> | { + [K in keyof T]: K extends number | string ? Serializable : never; + } + // Special types + | Stub + // Serialized as stubs, see `Stubify` + | Stubable; + // Base type for all RPC stubs, including common memory management methods. + // `T` is used as a marker type for unwrapping `Stub`s later. + interface StubBase extends Disposable { + [__RPC_STUB_BRAND]: T; + dup(): this; + } + export type Stub = Provider & StubBase; + // This represents all the types that can be sent as-is over an RPC boundary + type BaseType = void | undefined | null | boolean | number | bigint | string | TypedArray | ArrayBuffer | DataView | Date | Error | RegExp | ReadableStream | WritableStream | Request | Response | Headers; + // Recursively rewrite all `Stubable` types with `Stub`s + // prettier-ignore + type Stubify = T extends Stubable ? Stub : T extends Map ? Map, Stubify> : T extends Set ? Set> : T extends Array ? Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends BaseType ? T : T extends { + [key: string | number]: any; + } ? { + [K in keyof T]: Stubify; + } : T; + // Recursively rewrite all `Stub`s with the corresponding `T`s. + // Note we use `StubBase` instead of `Stub` here to avoid circular dependencies: + // `Stub` depends on `Provider`, which depends on `Unstubify`, which would depend on `Stub`. + // prettier-ignore + type Unstubify = T extends StubBase ? V : T extends Map ? Map, Unstubify> : T extends Set ? Set> : T extends Array ? Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends BaseType ? T : T extends { + [key: string | number]: unknown; + } ? { + [K in keyof T]: Unstubify; + } : T; + type UnstubifyAll = { + [I in keyof A]: Unstubify; + }; + // Utility type for adding `Provider`/`Disposable`s to `object` types only. + // Note `unknown & T` is equivalent to `T`. + type MaybeProvider = T extends object ? Provider : unknown; + type MaybeDisposable = T extends object ? Disposable : unknown; + // Type for method return or property on an RPC interface. + // - Stubable types are replaced by stubs. + // - Serializable types are passed by value, with stubable types replaced by stubs + // and a top-level `Disposer`. + // Everything else can't be passed over PRC. + // Technically, we use custom thenables here, but they quack like `Promise`s. + // Intersecting with `(Maybe)Provider` allows pipelining. + // prettier-ignore + type Result = R extends Stubable ? Promise> & Provider : R extends Serializable ? Promise & MaybeDisposable> & MaybeProvider : never; + // Type for method or property on an RPC interface. + // For methods, unwrap `Stub`s in parameters, and rewrite returns to be `Result`s. + // Unwrapping `Stub`s allows calling with `Stubable` arguments. + // For properties, rewrite types to be `Result`s. + // In each case, unwrap `Promise`s. + type MethodOrProperty = V extends (...args: infer P) => infer R ? (...args: UnstubifyAll

) => Result> : Result>; + // Type for the callable part of an `Provider` if `T` is callable. + // This is intersected with methods/properties. + type MaybeCallableProvider = T extends (...args: any[]) => any ? MethodOrProperty : unknown; + // Base type for all other types providing RPC-like interfaces. + // Rewrites all methods/properties to be `MethodOrProperty`s, while preserving callable types. + // `Reserved` names (e.g. stub method names like `dup()`) and symbols can't be accessed over RPC. + export type Provider = MaybeCallableProvider & Pick<{ + [K in keyof T]: MethodOrProperty; + }, Exclude>>; +} +declare namespace Cloudflare { + // Type of `env`. + // + // The specific project can extend `Env` by redeclaring it in project-specific files. Typescript + // will merge all declarations. + // + // You can use `wrangler types` to generate the `Env` type automatically. + interface Env { + } + // Project-specific parameters used to inform types. + // + // This interface is, again, intended to be declared in project-specific files, and then that + // declaration will be merged with this one. + // + // A project should have a declaration like this: + // + // interface GlobalProps { + // // Declares the main module's exports. Used to populate Cloudflare.Exports aka the type + // // of `ctx.exports`. + // mainModule: typeof import("my-main-module"); + // + // // Declares which of the main module's exports are configured with durable storage, and + // // thus should behave as Durable Object namsepace bindings. + // durableNamespaces: "MyDurableObject" | "AnotherDurableObject"; + // } + // + // You can use `wrangler types` to generate `GlobalProps` automatically. + interface GlobalProps { + } + // Evaluates to the type of a property in GlobalProps, defaulting to `Default` if it is not + // present. + type GlobalProp = K extends keyof GlobalProps ? GlobalProps[K] : Default; + // The type of the program's main module exports, if known. Requires `GlobalProps` to declare the + // `mainModule` property. + type MainModule = GlobalProp<"mainModule", {}>; + // The type of ctx.exports, which contains loopback bindings for all top-level exports. + type Exports = { + [K in keyof MainModule]: LoopbackForExport + // If the export is listed in `durableNamespaces`, then it is also a + // DurableObjectNamespace. + & (K extends GlobalProp<"durableNamespaces", never> ? MainModule[K] extends new (...args: any[]) => infer DoInstance ? DoInstance extends Rpc.DurableObjectBranded ? DurableObjectNamespace : DurableObjectNamespace : DurableObjectNamespace : {}); + }; +} +declare namespace CloudflareWorkersModule { + export type RpcStub = Rpc.Stub; + export const RpcStub: { + new (value: T): Rpc.Stub; + }; + export abstract class RpcTarget implements Rpc.RpcTargetBranded { + [Rpc.__RPC_TARGET_BRAND]: never; + } + // `protected` fields don't appear in `keyof`s, so can't be accessed over RPC + export abstract class WorkerEntrypoint implements Rpc.WorkerEntrypointBranded { + [Rpc.__WORKER_ENTRYPOINT_BRAND]: never; + protected ctx: ExecutionContext; + protected env: Env; + constructor(ctx: ExecutionContext, env: Env); + email?(message: ForwardableEmailMessage): void | Promise; + fetch?(request: Request): Response | Promise; + connect?(socket: Socket): void | Promise; + queue?(batch: MessageBatch): void | Promise; + scheduled?(controller: ScheduledController): void | Promise; + tail?(events: TraceItem[]): void | Promise; + tailStream?(event: TailStream.TailEvent): TailStream.TailEventHandlerType | Promise; + test?(controller: TestController): void | Promise; + trace?(traces: TraceItem[]): void | Promise; + } + export abstract class DurableObject implements Rpc.DurableObjectBranded { + [Rpc.__DURABLE_OBJECT_BRAND]: never; + protected ctx: DurableObjectState; + protected env: Env; + constructor(ctx: DurableObjectState, env: Env); + alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise; + fetch?(request: Request): Response | Promise; + connect?(socket: Socket): void | Promise; + webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise; + webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise; + webSocketError?(ws: WebSocket, error: unknown): void | Promise; + } + export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; + export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; + export type WorkflowDelayDuration = WorkflowSleepDuration; + export type WorkflowTimeoutDuration = WorkflowSleepDuration; + export type WorkflowRetentionDuration = WorkflowSleepDuration; + export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; + export type WorkflowStepConfig = { + retries?: { + limit: number; + delay: WorkflowDelayDuration | number; + backoff?: WorkflowBackoff; + }; + timeout?: WorkflowTimeoutDuration | number; + }; + export type WorkflowEvent = { + payload: Readonly; + timestamp: Date; + instanceId: string; + }; + export type WorkflowStepEvent = { + payload: Readonly; + timestamp: Date; + type: string; + }; + export type WorkflowStepContext = { + step: { + name: string; + count: number; + }; + attempt: number; + config: WorkflowStepConfig; + }; + export abstract class WorkflowStep { + do>(name: string, callback: (ctx: WorkflowStepContext) => Promise): Promise; + do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise): Promise; + sleep: (name: string, duration: WorkflowSleepDuration) => Promise; + sleepUntil: (name: string, timestamp: Date | number) => Promise; + waitForEvent>(name: string, options: { + type: string; + timeout?: WorkflowTimeoutDuration | number; + }): Promise>; + } + export type WorkflowInstanceStatus = 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'waitingForPause' | 'unknown'; + export abstract class WorkflowEntrypoint | unknown = unknown> implements Rpc.WorkflowEntrypointBranded { + [Rpc.__WORKFLOW_ENTRYPOINT_BRAND]: never; + protected ctx: ExecutionContext; + protected env: Env; + constructor(ctx: ExecutionContext, env: Env); + run(event: Readonly>, step: WorkflowStep): Promise; + } + export function waitUntil(promise: Promise): void; + export function withEnv(newEnv: unknown, fn: () => unknown): unknown; + export function withExports(newExports: unknown, fn: () => unknown): unknown; + export function withEnvAndExports(newEnv: unknown, newExports: unknown, fn: () => unknown): unknown; + export const env: Cloudflare.Env; + export const exports: Cloudflare.Exports; + export const cache: CacheContext; + export const tracing: Tracing; +} +declare module 'cloudflare:workers' { + export = CloudflareWorkersModule; +} +interface SecretsStoreSecret { + /** + * Get a secret from the Secrets Store, returning a string of the secret value + * if it exists, or throws an error if it does not exist + */ + get(): Promise; +} +declare module "cloudflare:sockets" { + function _connect(address: string | SocketAddress, options?: SocketOptions): Socket; + export { _connect as connect }; +} +/** + * Binding entrypoint for Cloudflare Stream. + * + * Usage: + * - Binding-level operations: + * `await env.STREAM.videos.upload` + * `await env.STREAM.videos.createDirectUpload` + * `await env.STREAM.videos.*` + * `await env.STREAM.watermarks.*` + * - Per-video operations: + * `await env.STREAM.video(id).downloads.*` + * `await env.STREAM.video(id).captions.*` + * + * Example usage: + * ```ts + * await env.STREAM.video(id).downloads.generate(); + * + * const video = env.STREAM.video(id) + * const captions = video.captions.list(); + * const videoDetails = video.details() + * ``` + */ +interface StreamBinding { + /** + * Returns a handle scoped to a single video for per-video operations. + * @param id The unique identifier for the video. + * @returns A handle for per-video operations. + */ + video(id: string): StreamVideoHandle; + /** + * Uploads a new video from a provided URL. + * @param url The URL to upload from. + * @param params Optional upload parameters. + * @returns The uploaded video details. + * @throws {BadRequestError} if the upload parameter is invalid or the URL is invalid + * @throws {QuotaReachedError} if the account storage capacity is exceeded + * @throws {MaxFileSizeError} if the file size is too large + * @throws {RateLimitedError} if the server received too many requests + * @throws {AlreadyUploadedError} if a video was already uploaded to this URL + * @throws {InternalError} if an unexpected error occurs + */ + upload(url: string, params?: StreamUrlUploadParams): Promise; + /** + * Creates a direct upload that allows video uploads without an API key. + * @param params Parameters for the direct upload + * @returns The direct upload details. + * @throws {BadRequestError} if the parameters are invalid + * @throws {RateLimitedError} if the server received too many requests + * @throws {InternalError} if an unexpected error occurs + */ + createDirectUpload(params: StreamDirectUploadCreateParams): Promise; + videos: StreamVideos; + watermarks: StreamWatermarks; +} +/** + * Handle for operations scoped to a single Stream video. + */ +interface StreamVideoHandle { + /** + * The unique identifier for the video. + */ + id: string; + /** + * Get a full videos details + * @returns The full video details. + * @throws {NotFoundError} if the video is not found + * @throws {InternalError} if an unexpected error occurs + */ + details(): Promise; + /** + * Update details for a single video. + * @param params The fields to update for the video. + * @returns The updated video details. + * @throws {NotFoundError} if the video is not found + * @throws {BadRequestError} if the parameters are invalid + * @throws {InternalError} if an unexpected error occurs + */ + update(params: StreamUpdateVideoParams): Promise; + /** + * Deletes a video and its copies from Cloudflare Stream. + * @returns A promise that resolves when deletion completes. + * @throws {NotFoundError} if the video is not found + * @throws {InternalError} if an unexpected error occurs + */ + delete(): Promise; + /** + * Creates a signed URL token for a video. + * @returns The signed token that was created. + * @throws {InternalError} if the signing key cannot be retrieved or the token cannot be signed + */ + generateToken(): Promise; + downloads: StreamScopedDownloads; + captions: StreamScopedCaptions; +} +interface StreamVideo { + /** + * The unique identifier for the video. + */ + id: string; + /** + * A user-defined identifier for the media creator. + */ + creator: string | null; + /** + * The thumbnail URL for the video. + */ + thumbnail: string; + /** + * The thumbnail timestamp percentage. + */ + thumbnailTimestampPct: number; + /** + * Indicates whether the video is ready to stream. + */ + readyToStream: boolean; + /** + * The date and time the video became ready to stream. + */ + readyToStreamAt: string | null; + /** + * Processing status information. + */ + status: StreamVideoStatus; + /** + * A user modifiable key-value store. + */ + meta: Record; + /** + * The date and time the video was created. + */ + created: string; + /** + * The date and time the video was last modified. + */ + modified: string; + /** + * The date and time at which the video will be deleted. + */ + scheduledDeletion: string | null; + /** + * The size of the video in bytes. + */ + size: number; + /** + * The preview URL for the video. + */ + preview?: string; + /** + * Origins allowed to display the video. + */ + allowedOrigins: Array; + /** + * Indicates whether signed URLs are required. + */ + requireSignedURLs: boolean | null; + /** + * The date and time the video was uploaded. + */ + uploaded: string | null; + /** + * The date and time when the upload URL expires. + */ + uploadExpiry: string | null; + /** + * The maximum size in bytes for direct uploads. + */ + maxSizeBytes: number | null; + /** + * The maximum duration in seconds for direct uploads. + */ + maxDurationSeconds: number | null; + /** + * The video duration in seconds. -1 indicates unknown. + */ + duration: number; + /** + * Input metadata for the original upload. + */ + input: StreamVideoInput; + /** + * Playback URLs for the video. + */ + hlsPlaybackUrl: string; + dashPlaybackUrl: string; + /** + * The watermark applied to the video, if any. + */ + watermark: StreamWatermark | null; + /** + * The live input id associated with the video, if any. + */ + liveInputId?: string | null; + /** + * The source video id if this is a clip. + */ + clippedFromId: string | null; + /** + * Public details associated with the video. + */ + publicDetails: StreamPublicDetails | null; +} +type StreamVideoStatus = { + /** + * The current processing state. + */ + state: string; + /** + * The current processing step. + */ + step?: string; + /** + * The percent complete as a string. + */ + pctComplete?: string; + /** + * An error reason code, if applicable. + */ + errorReasonCode: string; + /** + * An error reason text, if applicable. + */ + errorReasonText: string; +}; +type StreamVideoInput = { + /** + * The input width in pixels. + */ + width: number; + /** + * The input height in pixels. + */ + height: number; +}; +type StreamPublicDetails = { + /** + * The public title for the video. + */ + title: string | null; + /** + * The public share link. + */ + share_link: string | null; + /** + * The public channel link. + */ + channel_link: string | null; + /** + * The public logo URL. + */ + logo: string | null; +}; +type StreamDirectUpload = { + /** + * The URL an unauthenticated upload can use for a single multipart request. + */ + uploadURL: string; + /** + * A Cloudflare-generated unique identifier for a media item. + */ + id: string; + /** + * The watermark profile applied to the upload. + */ + watermark: StreamWatermark | null; + /** + * The scheduled deletion time, if any. + */ + scheduledDeletion: string | null; +}; +type StreamDirectUploadCreateParams = { + /** + * The maximum duration in seconds for a video upload. + */ + maxDurationSeconds: number; + /** + * The date and time after upload when videos will not be accepted. + */ + expiry?: string; + /** + * A user-defined identifier for the media creator. + */ + creator?: string; + /** + * A user modifiable key-value store used to reference other systems of record for + * managing videos. + */ + meta?: Record; + /** + * Lists the origins allowed to display the video. + */ + allowedOrigins?: Array; + /** + * Indicates whether the video can be accessed using the id. When set to `true`, + * a signed token must be generated with a signing key to view the video. + */ + requireSignedURLs?: boolean; + /** + * The thumbnail timestamp percentage. + */ + thumbnailTimestampPct?: number; + /** + * The date and time at which the video will be deleted. Include `null` to remove + * a scheduled deletion. + */ + scheduledDeletion?: string | null; + /** + * The watermark profile to apply. + */ + watermark?: StreamDirectUploadWatermark; +}; +type StreamDirectUploadWatermark = { + /** + * The unique identifier for the watermark profile. + */ + id: string; +}; +type StreamUrlUploadParams = { + /** + * Lists the origins allowed to display the video. Enter allowed origin + * domains in an array and use `*` for wildcard subdomains. Empty arrays allow the + * video to be viewed on any origin. + */ + allowedOrigins?: Array; + /** + * A user-defined identifier for the media creator. + */ + creator?: string; + /** + * A user modifiable key-value store used to reference other systems of + * record for managing videos. + */ + meta?: Record; + /** + * Indicates whether the video can be a accessed using the id. When + * set to `true`, a signed token must be generated with a signing key to view the + * video. + */ + requireSignedURLs?: boolean; + /** + * Indicates the date and time at which the video will be deleted. Omit + * the field to indicate no change, or include with a `null` value to remove an + * existing scheduled deletion. If specified, must be at least 30 days from upload + * time. + */ + scheduledDeletion?: string | null; + /** + * The timestamp for a thumbnail image calculated as a percentage value + * of the video's duration. To convert from a second-wise timestamp to a + * percentage, divide the desired timestamp by the total duration of the video. If + * this value is not set, the default thumbnail image is taken from 0s of the + * video. + */ + thumbnailTimestampPct?: number; + /** + * The identifier for the watermark profile + */ + watermarkId?: string; +}; +interface StreamScopedCaptions { + /** + * Uploads the caption or subtitle file to the endpoint for a specific BCP47 language. + * One caption or subtitle file per language is allowed. + * @param language The BCP 47 language tag for the caption or subtitle. + * @param input The caption or subtitle stream to upload. + * @returns The created caption entry. + * @throws {NotFoundError} if the video is not found + * @throws {BadRequestError} if the language or file is invalid + * @throws {InternalError} if an unexpected error occurs + */ + upload(language: string, input: ReadableStream): Promise; + /** + * Generate captions or subtitles for the provided language via AI. + * @param language The BCP 47 language tag to generate. + * @returns The generated caption entry. + * @throws {NotFoundError} if the video is not found + * @throws {BadRequestError} if the language is invalid + * @throws {StreamError} if a generated caption already exists + * @throws {StreamError} if the video duration is too long + * @throws {StreamError} if the video is missing audio + * @throws {StreamError} if the requested language is not supported + * @throws {InternalError} if an unexpected error occurs + */ + generate(language: string): Promise; + /** + * Lists the captions or subtitles. + * Use the language parameter to filter by a specific language. + * @param language The optional BCP 47 language tag to filter by. + * @returns The list of captions or subtitles. + * @throws {NotFoundError} if the video or caption is not found + * @throws {InternalError} if an unexpected error occurs + */ + list(language?: string): Promise; + /** + * Removes the captions or subtitles from a video. + * @param language The BCP 47 language tag to remove. + * @returns A promise that resolves when deletion completes. + * @throws {NotFoundError} if the video or caption is not found + * @throws {InternalError} if an unexpected error occurs + */ + delete(language: string): Promise; +} +interface StreamScopedDownloads { + /** + * Generates a download for a video when a video is ready to view. Available + * types are `default` and `audio`. Defaults to `default` when omitted. + * @param downloadType The download type to create. + * @returns The current downloads for the video. + * @throws {NotFoundError} if the video is not found + * @throws {BadRequestError} if the download type is invalid + * @throws {StreamError} if the video duration is too long to generate a download + * @throws {StreamError} if the video is not ready to stream + * @throws {InternalError} if an unexpected error occurs + */ + generate(downloadType?: StreamDownloadType): Promise; + /** + * Lists the downloads created for a video. + * @returns The current downloads for the video. + * @throws {NotFoundError} if the video or downloads are not found + * @throws {InternalError} if an unexpected error occurs + */ + get(): Promise; + /** + * Delete the downloads for a video. Available types are `default` and `audio`. + * Defaults to `default` when omitted. + * @param downloadType The download type to delete. + * @returns A promise that resolves when deletion completes. + * @throws {NotFoundError} if the video or downloads are not found + * @throws {InternalError} if an unexpected error occurs + */ + delete(downloadType?: StreamDownloadType): Promise; +} +interface StreamVideos { + /** + * Lists all videos in a users account. + * @returns The list of videos. + * @throws {BadRequestError} if the parameters are invalid + * @throws {InternalError} if an unexpected error occurs + */ + list(params?: StreamVideosListParams): Promise; +} +interface StreamWatermarks { + /** + * Generate a new watermark profile + * @param input The image stream to upload + * @param params The watermark creation parameters. + * @returns The created watermark profile. + * @throws {BadRequestError} if the parameters are invalid + * @throws {InvalidURLError} if the URL is invalid + * @throws {TooManyWatermarksError} if the number of allowed watermarks is reached + * @throws {InternalError} if an unexpected error occurs + */ + generate(input: ReadableStream, params: StreamWatermarkCreateParams): Promise; + /** + * Generate a new watermark profile + * @param url The image url to upload + * @param params The watermark creation parameters. + * @returns The created watermark profile. + * @throws {BadRequestError} if the parameters are invalid + * @throws {InvalidURLError} if the URL is invalid + * @throws {TooManyWatermarksError} if the number of allowed watermarks is reached + * @throws {InternalError} if an unexpected error occurs + */ + generate(url: string, params: StreamWatermarkCreateParams): Promise; + /** + * Lists all watermark profiles for an account. + * @returns The list of watermark profiles. + * @throws {InternalError} if an unexpected error occurs + */ + list(): Promise; + /** + * Retrieves details for a single watermark profile. + * @param watermarkId The watermark profile identifier. + * @returns The watermark profile details. + * @throws {NotFoundError} if the watermark is not found + * @throws {InternalError} if an unexpected error occurs + */ + get(watermarkId: string): Promise; + /** + * Deletes a watermark profile. + * @param watermarkId The watermark profile identifier. + * @returns A promise that resolves when deletion completes. + * @throws {NotFoundError} if the watermark is not found + * @throws {InternalError} if an unexpected error occurs + */ + delete(watermarkId: string): Promise; +} +type StreamUpdateVideoParams = { + /** + * Lists the origins allowed to display the video. Enter allowed origin + * domains in an array and use `*` for wildcard subdomains. Empty arrays allow the + * video to be viewed on any origin. + */ + allowedOrigins?: Array; + /** + * A user-defined identifier for the media creator. + */ + creator?: string; + /** + * The maximum duration in seconds for a video upload. Can be set for a + * video that is not yet uploaded to limit its duration. Uploads that exceed the + * specified duration will fail during processing. A value of `-1` means the value + * is unknown. + */ + maxDurationSeconds?: number; + /** + * A user modifiable key-value store used to reference other systems of + * record for managing videos. + */ + meta?: Record; + /** + * Indicates whether the video can be a accessed using the id. When + * set to `true`, a signed token must be generated with a signing key to view the + * video. + */ + requireSignedURLs?: boolean; + /** + * Indicates the date and time at which the video will be deleted. Omit + * the field to indicate no change, or include with a `null` value to remove an + * existing scheduled deletion. If specified, must be at least 30 days from upload + * time. + */ + scheduledDeletion?: string | null; + /** + * The timestamp for a thumbnail image calculated as a percentage value + * of the video's duration. To convert from a second-wise timestamp to a + * percentage, divide the desired timestamp by the total duration of the video. If + * this value is not set, the default thumbnail image is taken from 0s of the + * video. + */ + thumbnailTimestampPct?: number; +}; +type StreamCaption = { + /** + * Whether the caption was generated via AI. + */ + generated?: boolean; + /** + * The language label displayed in the native language to users. + */ + label: string; + /** + * The language tag in BCP 47 format. + */ + language: string; + /** + * The status of a generated caption. + */ + status?: 'ready' | 'inprogress' | 'error'; +}; +type StreamDownloadStatus = 'ready' | 'inprogress' | 'error'; +type StreamDownloadType = 'default' | 'audio'; +type StreamDownload = { + /** + * Indicates the progress as a percentage between 0 and 100. + */ + percentComplete: number; + /** + * The status of a generated download. + */ + status: StreamDownloadStatus; + /** + * The URL to access the generated download. + */ + url?: string; +}; +/** + * An object with download type keys. Each key is optional and only present if that + * download type has been created. + */ +type StreamDownloadGetResponse = { + /** + * The audio-only download. Only present if this download type has been created. + */ + audio?: StreamDownload; + /** + * The default video download. Only present if this download type has been created. + */ + default?: StreamDownload; +}; +type StreamWatermarkPosition = 'upperRight' | 'upperLeft' | 'lowerLeft' | 'lowerRight' | 'center'; +type StreamWatermark = { + /** + * The unique identifier for a watermark profile. + */ + id: string; + /** + * The size of the image in bytes. + */ + size: number; + /** + * The height of the image in pixels. + */ + height: number; + /** + * The width of the image in pixels. + */ + width: number; + /** + * The date and a time a watermark profile was created. + */ + created: string; + /** + * The source URL for a downloaded image. If the watermark profile was created via + * direct upload, this field is null. + */ + downloadedFrom: string | null; + /** + * A short description of the watermark profile. + */ + name: string; + /** + * The translucency of the image. A value of `0.0` makes the image completely + * transparent, and `1.0` makes the image completely opaque. Note that if the image + * is already semi-transparent, setting this to `1.0` will not make the image + * completely opaque. + */ + opacity: number; + /** + * The whitespace between the adjacent edges (determined by position) of the video + * and the image. `0.0` indicates no padding, and `1.0` indicates a fully padded + * video width or length, as determined by the algorithm. + */ + padding: number; + /** + * The size of the image relative to the overall size of the video. This parameter + * will adapt to horizontal and vertical videos automatically. `0.0` indicates no + * scaling (use the size of the image as-is), and `1.0 `fills the entire video. + */ + scale: number; + /** + * The location of the image. Valid positions are: `upperRight`, `upperLeft`, + * `lowerLeft`, `lowerRight`, and `center`. Note that `center` ignores the + * `padding` parameter. + */ + position: StreamWatermarkPosition; +}; +type StreamWatermarkCreateParams = { + /** + * A short description of the watermark profile. + */ + name?: string; + /** + * The translucency of the image. A value of `0.0` makes the image completely + * transparent, and `1.0` makes the image completely opaque. Note that if the + * image is already semi-transparent, setting this to `1.0` will not make the + * image completely opaque. + */ + opacity?: number; + /** + * The whitespace between the adjacent edges (determined by position) of the + * video and the image. `0.0` indicates no padding, and `1.0` indicates a fully + * padded video width or length, as determined by the algorithm. + */ + padding?: number; + /** + * The size of the image relative to the overall size of the video. This + * parameter will adapt to horizontal and vertical videos automatically. `0.0` + * indicates no scaling (use the size of the image as-is), and `1.0 `fills the + * entire video. + */ + scale?: number; + /** + * The location of the image. + */ + position?: StreamWatermarkPosition; +}; +type StreamVideosListParams = { + /** + * The maximum number of videos to return. + */ + limit?: number; + /** + * Return videos created before this timestamp. + * (RFC3339/RFC3339Nano) + */ + before?: string; + /** + * Comparison operator for the `before` field. + * @default 'lt' + */ + beforeComp?: StreamPaginationComparison; + /** + * Return videos created after this timestamp. + * (RFC3339/RFC3339Nano) + */ + after?: string; + /** + * Comparison operator for the `after` field. + * @default 'gte' + */ + afterComp?: StreamPaginationComparison; +}; +type StreamPaginationComparison = 'eq' | 'gt' | 'gte' | 'lt' | 'lte'; +/** + * Error object for Stream binding operations. + */ +interface StreamError extends Error { + readonly code: number; + readonly statusCode: number; + readonly message: string; + readonly stack?: string; +} +interface InternalError extends StreamError { + name: 'InternalError'; +} +interface BadRequestError extends StreamError { + name: 'BadRequestError'; +} +interface NotFoundError extends StreamError { + name: 'NotFoundError'; +} +interface ForbiddenError extends StreamError { + name: 'ForbiddenError'; +} +interface RateLimitedError extends StreamError { + name: 'RateLimitedError'; +} +interface QuotaReachedError extends StreamError { + name: 'QuotaReachedError'; +} +interface MaxFileSizeError extends StreamError { + name: 'MaxFileSizeError'; +} +interface InvalidURLError extends StreamError { + name: 'InvalidURLError'; +} +interface AlreadyUploadedError extends StreamError { + name: 'AlreadyUploadedError'; +} +interface TooManyWatermarksError extends StreamError { + name: 'TooManyWatermarksError'; +} +type MarkdownDocument = { + name: string; + blob: Blob; +}; +type ConversionResponse = { + id: string; + name: string; + mimeType: string; + format: 'markdown'; + tokens: number; + data: string; +} | { + id: string; + name: string; + mimeType: string; + format: 'error'; + error: string; +}; +type ImageConversionOptions = { + descriptionLanguage?: 'en' | 'es' | 'fr' | 'it' | 'pt' | 'de'; +}; +type EmbeddedImageConversionOptions = ImageConversionOptions & { + convert?: boolean; + maxConvertedImages?: number; +}; +type ConversionOptions = { + html?: { + images?: EmbeddedImageConversionOptions & { + convertOGImage?: boolean; + }; + hostname?: string; + cssSelector?: string; + }; + docx?: { + images?: EmbeddedImageConversionOptions; + }; + image?: ImageConversionOptions; + pdf?: { + images?: EmbeddedImageConversionOptions; + metadata?: boolean; + }; +}; +type ConversionRequestOptions = { + gateway?: GatewayOptions; + extraHeaders?: object; + conversionOptions?: ConversionOptions; +}; +type SupportedFileFormat = { + mimeType: string; + extension: string; +}; +declare abstract class ToMarkdownService { + transform(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise; + transform(files: MarkdownDocument, options?: ConversionRequestOptions): Promise; + supported(): Promise; +} +declare namespace TailStream { + interface Header { + readonly name: string; + readonly value: string; + } + interface FetchEventInfo { + readonly type: "fetch"; + readonly method: string; + readonly url: string; + readonly cfJson?: object; + readonly headers: Header[]; + } + interface JsRpcEventInfo { + readonly type: "jsrpc"; + } + interface ScheduledEventInfo { + readonly type: "scheduled"; + readonly scheduledTime: Date; + readonly cron: string; + } + interface AlarmEventInfo { + readonly type: "alarm"; + readonly scheduledTime: Date; + } + interface QueueEventInfo { + readonly type: "queue"; + readonly queueName: string; + readonly batchSize: number; + } + interface EmailEventInfo { + readonly type: "email"; + readonly mailFrom: string; + readonly rcptTo: string; + readonly rawSize: number; + } + interface TraceEventInfo { + readonly type: "trace"; + readonly traces: (string | null)[]; + } + interface HibernatableWebSocketEventInfoMessage { + readonly type: "message"; + } + interface HibernatableWebSocketEventInfoError { + readonly type: "error"; + } + interface HibernatableWebSocketEventInfoClose { + readonly type: "close"; + readonly code: number; + readonly wasClean: boolean; + } + interface HibernatableWebSocketEventInfo { + readonly type: "hibernatableWebSocket"; + readonly info: HibernatableWebSocketEventInfoClose | HibernatableWebSocketEventInfoError | HibernatableWebSocketEventInfoMessage; + } + interface CustomEventInfo { + readonly type: "custom"; + } + interface FetchResponseInfo { + readonly type: "fetch"; + readonly statusCode: number; + } + interface ConnectEventInfo { + readonly type: "connect"; + } + type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError"; + interface ScriptVersion { + readonly id: string; + readonly tag?: string; + readonly message?: string; + } + interface TracePreviewInfo { + readonly id: string; + readonly slug: string; + readonly name: string; + } + interface Onset { + readonly type: "onset"; + readonly attributes: Attribute[]; + // id for the span being opened by this Onset event. + readonly spanId: string; + readonly dispatchNamespace?: string; + readonly entrypoint?: string; + readonly executionModel: string; + readonly scriptName?: string; + readonly scriptTags?: string[]; + readonly scriptVersion?: ScriptVersion; + readonly preview?: TracePreviewInfo; + readonly info: FetchEventInfo | ConnectEventInfo | JsRpcEventInfo | ScheduledEventInfo | AlarmEventInfo | QueueEventInfo | EmailEventInfo | TraceEventInfo | HibernatableWebSocketEventInfo | CustomEventInfo; + } + interface Outcome { + readonly type: "outcome"; + readonly outcome: EventOutcome; + readonly cpuTime: number; + readonly wallTime: number; + } + interface SpanOpen { + readonly type: "spanOpen"; + readonly name: string; + // id for the span being opened by this SpanOpen event. + readonly spanId: string; + readonly info?: FetchEventInfo | JsRpcEventInfo | Attributes; + } + interface SpanClose { + readonly type: "spanClose"; + readonly outcome: EventOutcome; + } + interface DiagnosticChannelEvent { + readonly type: "diagnosticChannel"; + readonly channel: string; + readonly message: any; + } + interface Exception { + readonly type: "exception"; + readonly name: string; + readonly message: string; + readonly stack?: string; + } + interface Log { + readonly type: "log"; + readonly level: "debug" | "error" | "info" | "log" | "warn"; + readonly message: object; + } + interface DroppedEventsDiagnostic { + readonly diagnosticsType: "droppedEvents"; + readonly count: number; + } + interface StreamDiagnostic { + readonly type: 'streamDiagnostic'; + // To add new diagnostic types, define a new interface and add it to this union type. + readonly diagnostic: DroppedEventsDiagnostic; + } + // This marks the worker handler return information. + // This is separate from Outcome because the worker invocation can live for a long time after + // returning. For example - Websockets that return an http upgrade response but then continue + // streaming information or SSE http connections. + interface Return { + readonly type: "return"; + readonly info?: FetchResponseInfo; + } + interface Attribute { + readonly name: string; + readonly value: string | string[] | boolean | boolean[] | number | number[] | bigint | bigint[]; + } + interface Attributes { + readonly type: "attributes"; + readonly info: Attribute[]; + } + type EventType = Onset | Outcome | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | StreamDiagnostic | Return | Attributes; + // Context in which this trace event lives. + interface SpanContext { + // Single id for the entire top-level invocation + // This should be a new traceId for the first worker stage invoked in the eyeball request and then + // same-account service-bindings should reuse the same traceId but cross-account service-bindings + // should use a new traceId. + readonly traceId: string; + // spanId in which this event is handled + // for Onset and SpanOpen events this would be the parent span id + // for Outcome and SpanClose these this would be the span id of the opening Onset and SpanOpen events + // For Hibernate and Mark this would be the span under which they were emitted. + // spanId is not set ONLY if: + // 1. This is an Onset event + // 2. We are not inheriting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation) + readonly spanId?: string; + } + interface TailEvent { + // invocation id of the currently invoked worker stage. + // invocation id will always be unique to every Onset event and will be the same until the Outcome event. + readonly invocationId: string; + // Inherited spanContext for this event. + readonly spanContext: SpanContext; + readonly timestamp: Date; + readonly sequence: number; + readonly event: Event; + } + type TailEventHandler = (event: TailEvent) => void | Promise; + type TailEventHandlerObject = { + outcome?: TailEventHandler; + spanOpen?: TailEventHandler; + spanClose?: TailEventHandler; + diagnosticChannel?: TailEventHandler; + exception?: TailEventHandler; + log?: TailEventHandler; + return?: TailEventHandler; + attributes?: TailEventHandler; + }; + type TailEventHandlerType = TailEventHandler | TailEventHandlerObject; +} +// Copyright (c) 2022-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +/** + * Data types supported for holding vector metadata. + */ +type VectorizeVectorMetadataValue = string | number | boolean | string[]; +/** + * Additional information to associate with a vector. + */ +type VectorizeVectorMetadata = VectorizeVectorMetadataValue | Record; +type VectorFloatArray = Float32Array | Float64Array; +interface VectorizeError { + code?: number; + error: string; +} +/** + * Comparison logic/operation to use for metadata filtering. + * + * This list is expected to grow as support for more operations are released. + */ +type VectorizeVectorMetadataFilterOp = '$eq' | '$ne' | '$lt' | '$lte' | '$gt' | '$gte'; +type VectorizeVectorMetadataFilterCollectionOp = '$in' | '$nin'; +/** + * Filter criteria for vector metadata used to limit the retrieved query result set. + */ +type VectorizeVectorMetadataFilter = { + [field: string]: Exclude | null | { + [Op in VectorizeVectorMetadataFilterOp]?: Exclude | null; + } | { + [Op in VectorizeVectorMetadataFilterCollectionOp]?: Exclude[]; + }; +}; +/** + * Supported distance metrics for an index. + * Distance metrics determine how other "similar" vectors are determined. + */ +type VectorizeDistanceMetric = "euclidean" | "cosine" | "dot-product"; +/** + * Metadata return levels for a Vectorize query. + * + * Default to "none". + * + * @property all Full metadata for the vector return set, including all fields (including those un-indexed) without truncation. This is a more expensive retrieval, as it requires additional fetching & reading of un-indexed data. + * @property indexed Return all metadata fields configured for indexing in the vector return set. This level of retrieval is "free" in that no additional overhead is incurred returning this data. However, note that indexed metadata is subject to truncation (especially for larger strings). + * @property none No indexed metadata will be returned. + */ +type VectorizeMetadataRetrievalLevel = "all" | "indexed" | "none"; +interface VectorizeQueryOptions { + topK?: number; + namespace?: string; + returnValues?: boolean; + returnMetadata?: boolean | VectorizeMetadataRetrievalLevel; + filter?: VectorizeVectorMetadataFilter; +} +/** + * Information about the configuration of an index. + */ +type VectorizeIndexConfig = { + dimensions: number; + metric: VectorizeDistanceMetric; +} | { + preset: string; // keep this generic, as we'll be adding more presets in the future and this is only in a read capacity +}; +/** + * Metadata about an existing index. + * + * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released. + * See {@link VectorizeIndexInfo} for its post-beta equivalent. + */ +interface VectorizeIndexDetails { + /** The unique ID of the index */ + readonly id: string; + /** The name of the index. */ + name: string; + /** (optional) A human readable description for the index. */ + description?: string; + /** The index configuration, including the dimension size and distance metric. */ + config: VectorizeIndexConfig; + /** The number of records containing vectors within the index. */ + vectorsCount: number; +} +/** + * Metadata about an existing index. + */ +interface VectorizeIndexInfo { + /** The number of records containing vectors within the index. */ + vectorCount: number; + /** Number of dimensions the index has been configured for. */ + dimensions: number; + /** ISO 8601 datetime of the last processed mutation on in the index. All changes before this mutation will be reflected in the index state. */ + processedUpToDatetime: number; + /** UUIDv4 of the last mutation processed by the index. All changes before this mutation will be reflected in the index state. */ + processedUpToMutation: number; +} +/** + * Represents a single vector value set along with its associated metadata. + */ +interface VectorizeVector { + /** The ID for the vector. This can be user-defined, and must be unique. It should uniquely identify the object, and is best set based on the ID of what the vector represents. */ + id: string; + /** The vector values */ + values: VectorFloatArray | number[]; + /** The namespace this vector belongs to. */ + namespace?: string; + /** Metadata associated with the vector. Includes the values of other fields and potentially additional details. */ + metadata?: Record; +} +/** + * Represents a matched vector for a query along with its score and (if specified) the matching vector information. + */ +type VectorizeMatch = Pick, "values"> & Omit & { + /** The score or rank for similarity, when returned as a result */ + score: number; +}; +/** + * A set of matching {@link VectorizeMatch} for a particular query. + */ +interface VectorizeMatches { + matches: VectorizeMatch[]; + count: number; +} +/** + * Results of an operation that performed a mutation on a set of vectors. + * Here, `ids` is a list of vectors that were successfully processed. + * + * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released. + * See {@link VectorizeAsyncMutation} for its post-beta equivalent. + */ +interface VectorizeVectorMutation { + /* List of ids of vectors that were successfully processed. */ + ids: string[]; + /* Total count of the number of processed vectors. */ + count: number; +} +/** + * Result type indicating a mutation on the Vectorize Index. + * Actual mutations are processed async where the `mutationId` is the unique identifier for the operation. + */ +interface VectorizeAsyncMutation { + /** The unique identifier for the async mutation operation containing the changeset. */ + mutationId: string; +} +/** + * A Vectorize Vector Search Index for querying vectors/embeddings. + * + * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released. + * See {@link Vectorize} for its new implementation. + */ +declare abstract class VectorizeIndex { + /** + * Get information about the currently bound index. + * @returns A promise that resolves with information about the current index. + */ + public describe(): Promise; + /** + * Use the provided vector to perform a similarity search across the index. + * @param vector Input vector that will be used to drive the similarity search. + * @param options Configuration options to massage the returned data. + * @returns A promise that resolves with matched and scored vectors. + */ + public query(vector: VectorFloatArray | number[], options?: VectorizeQueryOptions): Promise; + /** + * Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown. + * @param vectors List of vectors that will be inserted. + * @returns A promise that resolves with the ids & count of records that were successfully processed. + */ + public insert(vectors: VectorizeVector[]): Promise; + /** + * Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values. + * @param vectors List of vectors that will be upserted. + * @returns A promise that resolves with the ids & count of records that were successfully processed. + */ + public upsert(vectors: VectorizeVector[]): Promise; + /** + * Delete a list of vectors with a matching id. + * @param ids List of vector ids that should be deleted. + * @returns A promise that resolves with the ids & count of records that were successfully processed (and thus deleted). + */ + public deleteByIds(ids: string[]): Promise; + /** + * Get a list of vectors with a matching id. + * @param ids List of vector ids that should be returned. + * @returns A promise that resolves with the raw unscored vectors matching the id set. + */ + public getByIds(ids: string[]): Promise; +} +/** + * A Vectorize Vector Search Index for querying vectors/embeddings. + * + * Mutations in this version are async, returning a mutation id. + */ +declare abstract class Vectorize { + /** + * Get information about the currently bound index. + * @returns A promise that resolves with information about the current index. + */ + public describe(): Promise; + /** + * Use the provided vector to perform a similarity search across the index. + * @param vector Input vector that will be used to drive the similarity search. + * @param options Configuration options to massage the returned data. + * @returns A promise that resolves with matched and scored vectors. + */ + public query(vector: VectorFloatArray | number[], options?: VectorizeQueryOptions): Promise; + /** + * Use the provided vector-id to perform a similarity search across the index. + * @param vectorId Id for a vector in the index against which the index should be queried. + * @param options Configuration options to massage the returned data. + * @returns A promise that resolves with matched and scored vectors. + */ + public queryById(vectorId: string, options?: VectorizeQueryOptions): Promise; + /** + * Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown. + * @param vectors List of vectors that will be inserted. + * @returns A promise that resolves with a unique identifier of a mutation containing the insert changeset. + */ + public insert(vectors: VectorizeVector[]): Promise; + /** + * Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values. + * @param vectors List of vectors that will be upserted. + * @returns A promise that resolves with a unique identifier of a mutation containing the upsert changeset. + */ + public upsert(vectors: VectorizeVector[]): Promise; + /** + * Delete a list of vectors with a matching id. + * @param ids List of vector ids that should be deleted. + * @returns A promise that resolves with a unique identifier of a mutation containing the delete changeset. + */ + public deleteByIds(ids: string[]): Promise; + /** + * Get a list of vectors with a matching id. + * @param ids List of vector ids that should be returned. + * @returns A promise that resolves with the raw unscored vectors matching the id set. + */ + public getByIds(ids: string[]): Promise; +} +/** + * The interface for "version_metadata" binding + * providing metadata about the Worker Version using this binding. + */ +type WorkerVersionMetadata = { + /** The ID of the Worker Version using this binding */ + id: string; + /** The tag of the Worker Version using this binding */ + tag: string; + /** The timestamp of when the Worker Version was uploaded */ + timestamp: string; +}; +interface DynamicDispatchLimits { + /** + * Limit CPU time in milliseconds. + */ + cpuMs?: number; + /** + * Limit number of subrequests. + */ + subRequests?: number; +} +interface DynamicDispatchOptions { + /** + * Limit resources of invoked Worker script. + */ + limits?: DynamicDispatchLimits; + /** + * Arguments for outbound Worker script, if configured. + */ + outbound?: { + [key: string]: any; + }; +} +interface DispatchNamespace { + /** + * @param name Name of the Worker script. + * @param args Arguments to Worker script. + * @param options Options for Dynamic Dispatch invocation. + * @returns A Fetcher object that allows you to send requests to the Worker script. + * @throws If the Worker script does not exist in this dispatch namespace, an error will be thrown. + */ + get(name: string, args?: { + [key: string]: any; + }, options?: DynamicDispatchOptions): Fetcher; +} +declare module 'cloudflare:workflows' { + /** + * NonRetryableError allows for a user to throw a fatal error + * that makes a Workflow instance fail immediately without triggering a retry + */ + export class NonRetryableError extends Error { + public constructor(message: string, name?: string); + } +} +declare abstract class Workflow { + /** + * Get a handle to an existing instance of the Workflow. + * @param id Id for the instance of this Workflow + * @returns A promise that resolves with a handle for the Instance + */ + public get(id: string): Promise; + /** + * Create a new instance and return a handle to it. If a provided id exists, an error will be thrown. + * @param options Options when creating an instance including id and params + * @returns A promise that resolves with a handle for the Instance + */ + public create(options?: WorkflowInstanceCreateOptions): Promise; + /** + * Create a batch of instances and return handle for all of them. If a provided id exists, an error will be thrown. + * `createBatch` is limited at 100 instances at a time or when the RPC limit for the batch (1MiB) is reached. + * @param batch List of Options when creating an instance including name and params + * @returns A promise that resolves with a list of handles for the created instances. + */ + public createBatch(batch: WorkflowInstanceCreateOptions[]): Promise; +} +type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; +type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; +type WorkflowRetentionDuration = WorkflowSleepDuration; +interface WorkflowInstanceCreateOptions { + /** + * An id for your Workflow instance. Must be unique within the Workflow. + */ + id?: string; + /** + * The event payload the Workflow instance is triggered with + */ + params?: PARAMS; + /** + * The retention policy for Workflow instance. + * Defaults to the maximum retention period available for the owner's account. + */ + retention?: { + successRetention?: WorkflowRetentionDuration; + errorRetention?: WorkflowRetentionDuration; + }; +} +type InstanceStatus = { + status: 'queued' // means that instance is waiting to be started (see concurrency limits) + | 'running' | 'paused' | 'errored' | 'terminated' // user terminated the instance while it was running + | 'complete' | 'waiting' // instance is hibernating and waiting for sleep or event to finish + | 'waitingForPause' // instance is finishing the current work to pause + | 'unknown'; + error?: { + name: string; + message: string; + }; + output?: unknown; +}; +interface WorkflowError { + code?: number; + message: string; +} +declare abstract class WorkflowInstance { + public id: string; + /** + * Pause the instance. + */ + public pause(): Promise; + /** + * Resume the instance. If it is already running, an error will be thrown. + */ + public resume(): Promise; + /** + * Terminate the instance. If it is errored, terminated or complete, an error will be thrown. + */ + public terminate(): Promise; + /** + * Restart the instance. + */ + public restart(): Promise; + /** + * Returns the current status of the instance. + */ + public status(): Promise; + /** + * Send an event to this instance. + */ + public sendEvent({ type, payload, }: { + type: string; + payload: unknown; + }): Promise; +} diff --git a/edge-api/wrangler.jsonc b/edge-api/wrangler.jsonc new file mode 100644 index 0000000000..993b4f4ebf --- /dev/null +++ b/edge-api/wrangler.jsonc @@ -0,0 +1,32 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "edge-api", + "main": "src/index.ts", + "compatibility_date": "2026-05-10", + "compatibility_flags": [ + "nodejs_compat" + ], + "workers_dev": true, + "observability": { + "enabled": true + }, + "upload_source_maps": true, + "vars": { + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core S26", + "APP_VERSION": "1.0.1", + "ENVIRONMENT": "production" + }, + "secrets": { + "required": [ + "API_TOKEN", + "ADMIN_EMAIL" + ] + }, + "kv_namespaces": [ + { + "binding": "SETTINGS", + "id": "a7dd06a571e7468d9906db5d52c32d12" + } + ] +} 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/MONITORING.md b/k8s/MONITORING.md new file mode 100644 index 0000000000..dc432604cc --- /dev/null +++ b/k8s/MONITORING.md @@ -0,0 +1,29 @@ +# Monitoring Notes + +This file is the Lab 16 entry point for Kubernetes monitoring and init containers. + +## Lab 16 Documentation + +The full report is in [docs/LAB16.md](docs/LAB16.md). It includes: + +- kube-prometheus-stack component descriptions and installation evidence. +- Grafana dashboard answers for pod resources, namespace CPU, node metrics, kubelet counts, default namespace network traffic, and active alerts. +- Init container implementation and proof that the main container can read the downloaded file. +- Bonus ServiceMonitor configuration and Prometheus query evidence for the app's `/metrics` endpoint. + +## Screenshots + +- [Grafana StatefulSet resources](docs/img/lab16_grafana_statefulset_resources.png) +- [Grafana node metrics](docs/img/lab16_grafana_node_metrics.png) +- [Grafana kubelet dashboard](docs/img/lab16_grafana_kubelet.png) +- [Alertmanager active alerts](docs/img/lab16_alertmanager_alerts.png) +- [Prometheus application metrics](docs/img/lab16_prometheus_app_metrics.png) + +## Current Results + +- Monitoring stack: `monitoring` Helm release on kube-prometheus-stack `84.5.0`. +- App release: existing `lab15` StatefulSet release upgraded with chart `devops-app-py` `0.8.0`. +- Init containers: `wait-for-headless-service` and `init-download`, both completed successfully. +- ServiceMonitor: `lab15-devops-app-py` in namespace `lab15`, labeled `release: monitoring`. +- Prometheus targets: six healthy app targets because both normal and headless Services match the ServiceMonitor selector. + diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000000..cfccccea39 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,31 @@ +# 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. +- `ROLLOUTS.md`: compatibility entry point for the Lab 14 Argo Rollouts report. +- `STATEFULSET.md`: compatibility entry point for the Lab 15 StatefulSet report. +- `MONITORING.md`: compatibility entry point for the Lab 16 monitoring and init container report. +- `docs/`: lab documentation split by assignment. + +## Documentation + +- [Helm Notes](HELM.md) +- [Secrets Notes](SECRETS.md) +- [ConfigMap Notes](CONFIGMAPS.md) +- [ArgoCD Notes](ARGOCD.md) +- [Argo Rollouts Notes](ROLLOUTS.md) +- [StatefulSet Notes](STATEFULSET.md) +- [Monitoring Notes](MONITORING.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) +- [Lab 14 - Progressive Delivery with Argo Rollouts](docs/LAB14.md) +- [Lab 15 - StatefulSets and Persistent Storage](docs/LAB15.md) +- [Lab 16 - Monitoring and Init Containers](docs/LAB16.md) diff --git a/k8s/ROLLOUTS.md b/k8s/ROLLOUTS.md new file mode 100644 index 0000000000..46b67599b8 --- /dev/null +++ b/k8s/ROLLOUTS.md @@ -0,0 +1,16 @@ +# Argo Rollouts Notes + +This file exists to satisfy the Lab 14 requirement for a dedicated Argo Rollouts document without flattening the Kubernetes module back into one large documentation directory. + +## Lab 14 Documentation + +The full Lab 14 write-up, Rollout-enabled Helm chart changes, canary and blue-green values files, automated analysis notes, command transcripts, and screenshot references are kept in [docs/LAB14.md](docs/LAB14.md). + +## Why This Structure Is Better + +- `k8s/README.md` stays short and useful as the Kubernetes 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), [docs/LAB13.md](docs/LAB13.md), and [docs/LAB14.md](docs/LAB14.md) keep each Kubernetes lab self-contained. +- Raw manifests, Helm chart files, ArgoCD applications, Rollout values, and documentation stay separated. +- `k8s/ROLLOUTS.md` provides the compatibility filename the lab expects while the actual report remains in the `docs/` hierarchy. + +In short, `ROLLOUTS.md` is the compatibility layer, and `k8s/docs/` remains the maintainable long-term structure. 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/STATEFULSET.md b/k8s/STATEFULSET.md new file mode 100644 index 0000000000..e3fb59d72f --- /dev/null +++ b/k8s/STATEFULSET.md @@ -0,0 +1,16 @@ +# StatefulSet Notes + +This file exists to satisfy the Lab 15 requirement for a dedicated StatefulSet document without flattening the Kubernetes module back into one large documentation directory. + +## Lab 15 Documentation + +The full Lab 15 write-up, StatefulSet-enabled Helm chart changes, headless Service notes, PVC evidence, DNS verification, persistence proof, and bonus update strategy transcripts are kept in [docs/LAB15.md](docs/LAB15.md). + +## Why This Structure Is Better + +- `k8s/README.md` stays short and useful as the Kubernetes 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), [docs/LAB13.md](docs/LAB13.md), [docs/LAB14.md](docs/LAB14.md), and [docs/LAB15.md](docs/LAB15.md) keep each Kubernetes lab self-contained. +- Raw manifests, Helm chart files, ArgoCD applications, Rollout values, StatefulSet values, and documentation stay separated. +- `k8s/STATEFULSET.md` provides the compatibility filename the lab expects while the actual report remains in the `docs/` hierarchy. + +In short, `STATEFULSET.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..426715c77c --- /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.8.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..f805ea2bc9 --- /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 127.0.0.1:8080/health | jq + curl -fsSL 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..6862f4fde5 --- /dev/null +++ b/k8s/devops-app-py/templates/_helpers.tpl @@ -0,0 +1,281 @@ +{{/* +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 headless service name for StatefulSet pod identity. +*/}} +{{- define "devops-app-py.headlessServiceName" -}} +{{- printf "%s-headless" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the blue-green preview service name. +*/}} +{{- define "devops-app-py.previewServiceName" -}} +{{- default (printf "%s-preview" (include "devops-app-py.serviceName" .)) .Values.rollout.blueGreen.previewService.name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the AnalysisTemplate name. +*/}} +{{- define "devops-app-py.analysisTemplateName" -}} +{{- default (printf "%s-health-check" (include "devops-app-py.fullname" .)) .Values.rollout.analysis.templateName | 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 the workload pod template shared by Deployments and Rollouts. +*/}} +{{- define "devops-app-py.podTemplate" -}} +{{- $envVars := include "devops-app-py.envVars" . | trim }} +{{- $vaultAnnotations := include "devops-app-py.vaultAnnotations" . | trim }} +{{- $configChecksums := include "devops-app-py.configChecksums" . | trim }} +{{- $usesStatefulSet := and .Values.statefulset.enabled (not .Values.rollout.enabled) }} +metadata: + {{- if or $vaultAnnotations $configChecksums .Values.podAnnotations }} + annotations: + {{- if $vaultAnnotations }} + {{- $vaultAnnotations | nindent 4 }} + {{- end }} + {{- if $configChecksums }} + {{- $configChecksums | nindent 4 }} + {{- end }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} + labels: + {{- include "devops-app-py.selectorLabels" . | nindent 4 }} + app.kubernetes.io/part-of: {{ .Values.partOf }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + serviceAccountName: {{ include "devops-app-py.serviceAccountName" . }} + {{- with .Values.initContainers }} + initContainers: + {{- toYaml . | nindent 4 }} + {{- end }} + 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 .Values.extraVolumeMounts }} + 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 }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 8 }} + {{- 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 8 }} + {{- end }} + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if or .Values.config.file.enabled (and .Values.persistence.enabled (not $usesStatefulSet)) .Values.extraVolumes }} + volumes: + {{- if .Values.config.file.enabled }} + - name: config-volume + configMap: + name: {{ include "devops-app-py.fileConfigMapName" . }} + {{- end }} + {{- if and .Values.persistence.enabled (not $usesStatefulSet) }} + - name: data-volume + persistentVolumeClaim: + claimName: {{ include "devops-app-py.pvcName" . }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- 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/analysis-template.yaml b/k8s/devops-app-py/templates/analysis-template.yaml new file mode 100644 index 0000000000..15b7cc96b9 --- /dev/null +++ b/k8s/devops-app-py/templates/analysis-template.yaml @@ -0,0 +1,24 @@ +{{- if and .Values.rollout.enabled .Values.rollout.analysis.enabled }} +{{- $serviceName := include "devops-app-py.serviceName" . }} +{{- $path := .Values.rollout.analysis.path | default "/health" }} +{{- $url := .Values.rollout.analysis.url | default (printf "http://%s.%s.svc:%v%s" $serviceName .Release.Namespace .Values.service.port $path) }} +{{- $successCondition := .Values.rollout.analysis.successCondition | default (printf "result == \"%s\"" .Values.rollout.analysis.expectedStatus) }} +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +metadata: + name: {{ include "devops-app-py.analysisTemplateName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + metrics: + - name: {{ .Values.rollout.analysis.metricName }} + interval: {{ .Values.rollout.analysis.interval | quote }} + count: {{ .Values.rollout.analysis.count }} + failureLimit: {{ .Values.rollout.analysis.failureLimit }} + successCondition: {{ $successCondition | quote }} + provider: + web: + url: {{ $url | quote }} + timeoutSeconds: {{ .Values.rollout.analysis.timeoutSeconds }} + jsonPath: {{ .Values.rollout.analysis.jsonPath | quote }} +{{- 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..33511cb299 --- /dev/null +++ b/k8s/devops-app-py/templates/deployment.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.deployment.enabled (not .Values.rollout.enabled) (not .Values.statefulset.enabled) }} +apiVersion: apps/v1 +kind: Deployment +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: + {{- include "devops-app-py.podTemplate" . | nindent 4 }} +{{- 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..2148f467df --- /dev/null +++ b/k8s/devops-app-py/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not (and .Values.statefulset.enabled (not .Values.rollout.enabled))) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "devops-app-py.pvcName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/k8s/devops-app-py/templates/rollout.yaml b/k8s/devops-app-py/templates/rollout.yaml new file mode 100644 index 0000000000..0449fe5711 --- /dev/null +++ b/k8s/devops-app-py/templates/rollout.yaml @@ -0,0 +1,43 @@ +{{- if .Values.rollout.enabled }} +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: {{ include "devops-app-py.fullname" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }} + selector: + matchLabels: + {{- include "devops-app-py.selectorLabels" . | nindent 6 }} + strategy: + {{- if eq .Values.rollout.strategy "blueGreen" }} + blueGreen: + activeService: {{ default (include "devops-app-py.serviceName" .) .Values.rollout.blueGreen.activeService }} + {{- if .Values.rollout.blueGreen.previewService.enabled }} + previewService: {{ include "devops-app-py.previewServiceName" . }} + {{- end }} + autoPromotionEnabled: {{ .Values.rollout.blueGreen.autoPromotionEnabled }} + scaleDownDelaySeconds: {{ .Values.rollout.blueGreen.scaleDownDelaySeconds }} + {{- if .Values.rollout.analysis.enabled }} + prePromotionAnalysis: + templates: + - templateName: {{ include "devops-app-py.analysisTemplateName" . }} + {{- end }} + {{- else }} + canary: + {{- with .Values.rollout.canary.steps }} + steps: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.rollout.analysis.enabled }} + analysis: + startingStep: {{ .Values.rollout.analysis.startingStep }} + templates: + - templateName: {{ include "devops-app-py.analysisTemplateName" . }} + {{- end }} + {{- end }} + template: + {{- include "devops-app-py.podTemplate" . | nindent 4 }} +{{- 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-headless.yaml b/k8s/devops-app-py/templates/service-headless.yaml new file mode 100644 index 0000000000..78fe84e805 --- /dev/null +++ b/k8s/devops-app-py/templates/service-headless.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.statefulset.enabled (not .Values.rollout.enabled) }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-app-py.headlessServiceName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + clusterIP: None + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + selector: + {{- include "devops-app-py.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/k8s/devops-app-py/templates/service-monitor.yaml b/k8s/devops-app-py/templates/service-monitor.yaml new file mode 100644 index 0000000000..73278e6cfb --- /dev/null +++ b/k8s/devops-app-py/templates/service-monitor.yaml @@ -0,0 +1,20 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "devops-app-py.fullname" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} + {{- with .Values.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "devops-app-py.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: {{ .Values.serviceMonitor.path | quote }} + interval: {{ .Values.serviceMonitor.interval | quote }} + scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout | quote }} +{{- end }} diff --git a/k8s/devops-app-py/templates/service-preview.yaml b/k8s/devops-app-py/templates/service-preview.yaml new file mode 100644 index 0000000000..6759149c0b --- /dev/null +++ b/k8s/devops-app-py/templates/service-preview.yaml @@ -0,0 +1,18 @@ +{{- if and .Values.rollout.enabled (eq .Values.rollout.strategy "blueGreen") .Values.rollout.blueGreen.previewService.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-app-py.previewServiceName" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} + app.kubernetes.io/component: preview +spec: + type: {{ .Values.service.type }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + selector: + {{- include "devops-app-py.selectorLabels" . | nindent 4 }} +{{- 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/templates/statefulset.yaml b/k8s/devops-app-py/templates/statefulset.yaml new file mode 100644 index 0000000000..084c3c7e8a --- /dev/null +++ b/k8s/devops-app-py/templates/statefulset.yaml @@ -0,0 +1,44 @@ +{{- if and .Values.statefulset.enabled (not .Values.rollout.enabled) }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "devops-app-py.fullname" . }} + labels: + {{- include "devops-app-py.labels" . | nindent 4 }} +spec: + serviceName: {{ include "devops-app-py.headlessServiceName" . }} + replicas: {{ .Values.replicaCount }} + podManagementPolicy: {{ .Values.statefulset.podManagementPolicy }} + revisionHistoryLimit: {{ .Values.statefulset.revisionHistoryLimit }} + updateStrategy: + type: {{ .Values.statefulset.updateStrategy.type }} + {{- if eq .Values.statefulset.updateStrategy.type "RollingUpdate" }} + rollingUpdate: + partition: {{ .Values.statefulset.updateStrategy.rollingUpdate.partition }} + {{- end }} + selector: + matchLabels: + {{- include "devops-app-py.selectorLabels" . | nindent 6 }} + template: + {{- include "devops-app-py.podTemplate" . | nindent 4 }} + {{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data-volume + labels: + {{- if .Values.statefulset.volumeClaimTemplateLabels }} + {{- toYaml .Values.statefulset.volumeClaimTemplateLabels | nindent 10 }} + {{- else }} + {{- include "devops-app-py.labels" . | nindent 10 }} + {{- end }} + spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 10 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/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-lab16.yaml b/k8s/devops-app-py/values-lab16.yaml new file mode 100644 index 0000000000..bccd95fab1 --- /dev/null +++ b/k8s/devops-app-py/values-lab16.yaml @@ -0,0 +1,63 @@ +image: + tag: "1.12" + +podAnnotations: + lab15-version: "stateful-v1" + lab16-init-demo: "enabled" + +statefulset: + # Preserve the already-created Lab 15 volumeClaimTemplates metadata. Kubernetes + # rejects StatefulSet upgrades that change claim template labels. + volumeClaimTemplateLabels: + helm.sh/chart: devops-app-py-0.7.0 + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/instance: lab15 + app.kubernetes.io/version: "1.12" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/part-of: devops-core-s26 + +initContainers: + - name: wait-for-headless-service + image: busybox:1.37.0 + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - | + set -eu + until nslookup lab15-devops-app-py-headless.lab15.svc.cluster.local; do + echo "waiting for headless service" + sleep 2 + done + echo "headless service resolved" + - name: init-download + image: busybox:1.37.0 + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - | + set -eu + wget -O /init-data/lab16-init.txt http://lab15-devops-app-py-service.lab15.svc.cluster.local/ready + echo >> /init-data/lab16-init.txt + echo "downloaded-by=init-download" >> /init-data/lab16-init.txt + volumeMounts: + - name: init-data + mountPath: /init-data + +extraVolumes: + - name: init-data + emptyDir: {} + +extraVolumeMounts: + - name: init-data + mountPath: /init-data + readOnly: true + +serviceMonitor: + enabled: true + labels: + release: monitoring + path: /metrics + interval: 15s + scrapeTimeout: 10s 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-rollout-analysis.yaml b/k8s/devops-app-py/values-rollout-analysis.yaml new file mode 100644 index 0000000000..f9818ac99d --- /dev/null +++ b/k8s/devops-app-py/values-rollout-analysis.yaml @@ -0,0 +1,39 @@ +replicaCount: 5 + +image: + tag: "1.12" + +deployment: + revisionHistoryLimit: 5 + +podAnnotations: + lab14-version: "analysis-v1" + +rollout: + enabled: true + strategy: canary + canary: + steps: + - setWeight: 20 + - pause: + duration: 15s + - setWeight: 60 + - pause: + duration: 15s + - setWeight: 100 + analysis: + enabled: true + metricName: health-check + startingStep: 1 + interval: 5s + count: 1 + failureLimit: 0 + path: /health + jsonPath: "{$.status}" + expectedStatus: healthy + timeoutSeconds: 5 + +service: + type: ClusterIP + port: 80 + targetPort: 5000 diff --git a/k8s/devops-app-py/values-rollout-bluegreen.yaml b/k8s/devops-app-py/values-rollout-bluegreen.yaml new file mode 100644 index 0000000000..1e003eab4d --- /dev/null +++ b/k8s/devops-app-py/values-rollout-bluegreen.yaml @@ -0,0 +1,26 @@ +replicaCount: 3 + +image: + tag: "1.12" + +deployment: + revisionHistoryLimit: 5 + +podAnnotations: + lab14-version: "green-v1" + +rollout: + enabled: true + strategy: blueGreen + blueGreen: + activeService: "" + previewService: + enabled: true + name: "" + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 + +service: + type: ClusterIP + port: 80 + targetPort: 5000 diff --git a/k8s/devops-app-py/values-rollout-canary.yaml b/k8s/devops-app-py/values-rollout-canary.yaml new file mode 100644 index 0000000000..39875d3f50 --- /dev/null +++ b/k8s/devops-app-py/values-rollout-canary.yaml @@ -0,0 +1,33 @@ +replicaCount: 5 + +image: + tag: "1.12" + +deployment: + revisionHistoryLimit: 5 + +podAnnotations: + lab14-version: "canary-v1" + +rollout: + enabled: true + strategy: canary + canary: + steps: + - setWeight: 20 + - pause: {} + - setWeight: 40 + - pause: + duration: 30s + - setWeight: 60 + - pause: + duration: 30s + - setWeight: 80 + - pause: + duration: 30s + - setWeight: 100 + +service: + type: ClusterIP + port: 80 + targetPort: 5000 diff --git a/k8s/devops-app-py/values-statefulset.yaml b/k8s/devops-app-py/values-statefulset.yaml new file mode 100644 index 0000000000..09dd3868d2 --- /dev/null +++ b/k8s/devops-app-py/values-statefulset.yaml @@ -0,0 +1,36 @@ +replicaCount: 3 + +image: + tag: "1.12" + +deployment: + enabled: false + revisionHistoryLimit: 5 + +statefulset: + enabled: true + podManagementPolicy: OrderedReady + revisionHistoryLimit: 5 + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 0 + +rollout: + enabled: false + +podAnnotations: + lab15-version: "stateful-v1" + +persistence: + enabled: true + mountPath: /data + accessModes: + - ReadWriteOnce + size: 100Mi + storageClass: "" + +service: + type: ClusterIP + port: 80 + targetPort: 5000 diff --git a/k8s/devops-app-py/values.yaml b/k8s/devops-app-py/values.yaml new file mode 100644 index 0000000000..62385832e9 --- /dev/null +++ b/k8s/devops-app-py/values.yaml @@ -0,0 +1,170 @@ +replicaCount: 1 +partOf: devops-core-s26 + +image: + repository: localt0aster/devops-app-py + tag: "1.12-dev" + pullPolicy: IfNotPresent + +containerPort: 5000 + +nameOverride: "" +fullnameOverride: "" + +podAnnotations: {} +podLabels: {} + +initContainers: [] +extraVolumes: [] +extraVolumeMounts: [] + +serviceMonitor: + enabled: false + labels: {} + path: /metrics + interval: 30s + scrapeTimeout: 10s + +deployment: + enabled: false + revisionHistoryLimit: 5 + strategy: + maxSurge: 1 + maxUnavailable: 0 + +statefulset: + enabled: true + podManagementPolicy: OrderedReady + revisionHistoryLimit: 5 + volumeClaimTemplateLabels: {} + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 0 + +rollout: + enabled: false + strategy: canary + canary: + steps: [] + blueGreen: + activeService: "" + previewService: + enabled: false + name: "" + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 + analysis: + enabled: false + templateName: "" + metricName: health-check + startingStep: 1 + interval: 10s + count: 1 + failureLimit: 0 + path: /health + jsonPath: "{$.status}" + expectedStatus: healthy + successCondition: "" + url: "" + timeoutSeconds: 5 + +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 + accessModes: + - ReadWriteOnce + 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/LAB14.md b/k8s/docs/LAB14.md new file mode 100644 index 0000000000..00da88dcc2 --- /dev/null +++ b/k8s/docs/LAB14.md @@ -0,0 +1,366 @@ +# Kubernetes Lab 14 - Progressive Delivery with Argo Rollouts + +Lab 14 adds progressive delivery to the existing Helm chart without changing the Lab 13 ArgoCD applications. I kept the GitOps `dev` and `prod` applications on normal Kubernetes `Deployment` resources and added an opt-in Rollout mode to the chart. The lab demos use direct Helm releases in a separate `lab14` namespace so manual canary pauses, aborts, blue-green promotions, and failed analysis runs do not fight ArgoCD reconciliation. + +The chart now supports three workload modes: + +- Default mode: renders the original `apps/v1 Deployment` when `rollout.enabled=false`. +- Canary mode: renders an Argo `Rollout` with weighted steps and pauses. +- Blue-green mode: renders an Argo `Rollout` plus a preview `Service`. +- Bonus analysis mode: renders an `AnalysisTemplate` and attaches it to the canary strategy. + +The Rollouts controller and dashboard manifests are pinned to `v1.8.3`. I deliberately did not use `latest` URLs or release candidates because this lab should be reproducible. On this machine, Helm is `v4.1.4`; after Argo Rollouts or the Rollouts plugin mutates controller-owned fields, follow-up Helm upgrades use `--server-side=false` to avoid server-side-apply ownership conflicts. + +The local Rollouts CLI is installed from Nix instead of a manually downloaded `/tmp/lab14/bin` binary. On current nixpkgs, direct `nix run nixpkgs#argo-rollouts` is not usable because the package exposes `kubectl-argo-rollouts` and `rollouts-controller`, while `nix run` looks for `bin/argo-rollouts`. + +## Chart Changes + +The Helm chart was bumped to `0.6.0` and remains backward compatible with Labs 10-13. The pod template is now shared by Deployment and Rollout resources through the `devops-app-py.podTemplate` helper, which keeps probes, environment variables, Vault annotations, config checksum annotations, PVC mounts, and resource limits consistent. + +New chart files: + +- `templates/rollout.yaml`: renders the Argo Rollouts `Rollout` resource when `rollout.enabled=true`. +- `templates/analysis-template.yaml`: renders a web-provider `AnalysisTemplate` when automated analysis is enabled. +- `templates/service-preview.yaml`: renders the blue-green preview service. +- `values-rollout-canary.yaml`: reproducible canary demo values. +- `values-rollout-bluegreen.yaml`: reproducible blue-green demo values. +- `values-rollout-analysis.yaml`: reproducible bonus analysis demo values. + +The default `values.yaml` keeps `rollout.enabled: false`, so existing ArgoCD applications still render a `Deployment`. + +## Setup + +The intended install path is: + +```bash +kubectl create namespace argo-rollouts --dry-run=client -o yaml | kubectl apply -f - +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/download/v1.8.3/install.yaml +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/download/v1.8.3/dashboard-install.yaml + +nix profile install --impure nixpkgs#argo-rollouts + +kubectl argo rollouts version +kubectl get pods -n argo-rollouts +kubectl port-forward svc/argo-rollouts-dashboard -n argo-rollouts 3100:3100 +``` + +The dashboard is then available at `127.0.0.1:3100`. + +On April 29, 2026 the saved Docker-backed `minikube` profile was not recoverable by restart: Docker no longer had the `minikube` container, and two `minikube start -p minikube --driver=docker` attempts failed with `K8S_APISERVER_MISSING`. I deleted and recreated that broken profile only after those repair attempts failed. The repository Lab 13 ArgoCD files were left untouched, but the previous live in-cluster Lab 13 state did not survive the local profile recreation. + +The recreated cluster and Rollouts install were healthy: + +```text +$ minikube status -p minikube +minikube +type: Control Plane +host: Running +kubelet: Running +apiserver: Running +kubeconfig: Configured + +$ kubectl get pods -n argo-rollouts +NAME READY STATUS RESTARTS +argo-rollouts-7858b65d86-xrddz 1/1 Running 0 +argo-rollouts-dashboard-7d89499989-t7sx6 1/1 Running 0 + +$ command -v kubectl-argo-rollouts +/home/t0ast/.nix-profile/bin/kubectl-argo-rollouts + +$ kubectl argo rollouts version +kubectl-argo-rollouts: v99.99.99+unknown + BuildDate: 1970-01-01T00:00:00Z + GitCommit: + GitTreeState: + GoVersion: go1.26.2 + Compiler: gc + Platform: linux/amd64 +``` + +## Rollout vs Deployment + +A Rollout keeps the familiar Deployment shape: `replicas`, `selector`, and `template` still describe the desired pods. The meaningful difference is the strategy controller. A Deployment only supports Kubernetes rolling update or recreate behavior. A Rollout adds canary and blue-green strategies, manual gates, abort/retry/undo commands, service selector management, optional traffic routing integrations, and analysis-driven promotion or rollback. + +I used Rollouts only where the lab needs those progressive delivery controls. The normal chart path still uses Deployment because simple environments do not need the extra controller dependency. + +## Canary Deployment + +The canary release uses five replicas so the no-service-mesh weights map cleanly to pods: + +```yaml +rollout: + enabled: true + strategy: canary + canary: + steps: + - setWeight: 20 + - pause: {} + - setWeight: 40 + - pause: + duration: 30s + - setWeight: 60 + - pause: + duration: 30s + - setWeight: 80 + - pause: + duration: 30s + - setWeight: 100 +``` + +Without Istio, NGINX, ALB, or another traffic router, `setWeight` does not produce exact L7 traffic splitting. Argo Rollouts approximates the requested weight by scaling the stable and canary ReplicaSets behind the same Kubernetes Service. With `replicaCount: 5`, the lab's `20/40/60/80` steps map to `1/2/3/4` canary pods, which makes the behavior easy to inspect in the dashboard. + +Canary install and manual gate: + +```bash +kubectl create namespace lab14 --dry-run=client -o yaml | kubectl apply -f - +helm upgrade --install lab14-canary k8s/devops-app-py \ + -n lab14 \ + -f k8s/devops-app-py/values-rollout-canary.yaml + +cat >/tmp/lab14/canary-v2.values.yaml <<'YAML' +podAnnotations: + lab14-version: canary-v2 +YAML + +helm upgrade lab14-canary k8s/devops-app-py \ + -n lab14 \ + -f k8s/devops-app-py/values-rollout-canary.yaml \ + -f /tmp/lab14/canary-v2.values.yaml + +kubectl argo rollouts get rollout lab14-canary-devops-app-py -n lab14 +kubectl argo rollouts promote lab14-canary-devops-app-py -n lab14 +``` + +Abort and rollback demonstration: + +```bash +cat >/tmp/lab14/canary-v3.values.yaml <<'YAML' +podAnnotations: + lab14-version: canary-v3 +YAML + +helm upgrade lab14-canary k8s/devops-app-py \ + -n lab14 \ + -f k8s/devops-app-py/values-rollout-canary.yaml \ + -f /tmp/lab14/canary-v3.values.yaml + +kubectl argo rollouts abort lab14-canary-devops-app-py -n lab14 +kubectl argo rollouts undo lab14-canary-devops-app-py -n lab14 +kubectl argo rollouts status lab14-canary-devops-app-py -n lab14 +``` + +## Blue-Green Deployment + +The blue-green release uses the existing chart service as the active service and adds a preview service: + +```yaml +rollout: + enabled: true + strategy: blueGreen + blueGreen: + activeService: "" + previewService: + enabled: true + name: "" + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 +``` + +An empty `activeService` resolves to the chart's normal service name, for example `lab14-bluegreen-devops-app-py-service`. The preview service resolves to `lab14-bluegreen-devops-app-py-service-preview`. Argo Rollouts mutates the active and preview service selectors so the active service points at the stable ReplicaSet while the preview service points at the new ReplicaSet. + +Blue-green test flow: + +```bash +helm upgrade --install lab14-bluegreen k8s/devops-app-py \ + -n lab14 \ + -f k8s/devops-app-py/values-rollout-bluegreen.yaml + +cat >/tmp/lab14/green-v2.values.yaml <<'YAML' +podAnnotations: + lab14-version: green-v2 +YAML + +helm upgrade lab14-bluegreen k8s/devops-app-py \ + -n lab14 \ + -f k8s/devops-app-py/values-rollout-bluegreen.yaml \ + -f /tmp/lab14/green-v2.values.yaml \ + --server-side=false + +kubectl port-forward svc/lab14-bluegreen-devops-app-py-service -n lab14 18080:80 +kubectl port-forward svc/lab14-bluegreen-devops-app-py-service-preview -n lab14 18081:80 +curl -fsSL http://127.0.0.1:18080/health +curl -fsSL http://127.0.0.1:18081/health + +kubectl argo rollouts promote lab14-bluegreen-devops-app-py -n lab14 +kubectl argo rollouts undo lab14-bluegreen-devops-app-py -n lab14 +``` + +The blue-green rollback is faster than canary rollback because it is mostly a service selector switch. The tradeoff is resource usage: during a preview, blue-green needs the stable ReplicaSet and the preview ReplicaSet at the same time. + +The active and preview services pointed at different ReplicaSets before promotion: + +```text +$ kubectl get svc -n lab14 -l app.kubernetes.io/instance=lab14-bluegreen -o wide +NAME TYPE CLUSTER-IP PORT(S) SELECTOR +lab14-bluegreen-devops-app-py-service ClusterIP 10.110.96.230 80/TCP app.kubernetes.io/instance=lab14-bluegreen,app.kubernetes.io/name=devops-app-py,rollouts-pod-template-hash=74f979c864 +lab14-bluegreen-devops-app-py-service-preview ClusterIP 10.103.107.142 80/TCP app.kubernetes.io/instance=lab14-bluegreen,app.kubernetes.io/name=devops-app-py,rollouts-pod-template-hash=5f6f648665 +``` + +Both services returned healthy responses through separate port-forwards. + +## Automated Analysis + +The bonus uses the Argo Rollouts web analysis provider instead of Prometheus so it stays self-contained. The chart renders this template: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +spec: + metrics: + - name: health-check + interval: "5s" + count: 1 + failureLimit: 0 + successCondition: 'result == "healthy"' + provider: + web: + url: "http://lab14-analysis-devops-app-py-service.lab14.svc:80/health" + timeoutSeconds: 5 + jsonPath: "{$.status}" +``` + +The namespaced service DNS name is important because the Rollouts controller evaluates the web metric from the `argo-rollouts` namespace. The Flask app returns `{"status":"healthy"}` from `/health`, so the normal analysis run succeeds. For the intentional failure, I override the expected status to an impossible value: + +```bash +helm upgrade --install lab14-analysis k8s/devops-app-py \ + -n lab14 \ + -f k8s/devops-app-py/values-rollout-analysis.yaml + +cat >/tmp/lab14/analysis-fail.values.yaml <<'YAML' +podAnnotations: + lab14-version: analysis-fail-v2 +rollout: + analysis: + expectedStatus: impossible +YAML + +helm upgrade lab14-analysis k8s/devops-app-py \ + -n lab14 \ + -f k8s/devops-app-py/values-rollout-analysis.yaml \ + -f /tmp/lab14/analysis-fail.values.yaml \ + --server-side=false + +kubectl get analysisrun -n lab14 +kubectl argo rollouts get rollout lab14-analysis-devops-app-py -n lab14 +``` + +With `failureLimit: 0`, one failed measurement marks the AnalysisRun failed and aborts the rollout automatically. + +The final evidence included one successful AnalysisRun and one intentional failed AnalysisRun: + +```text +$ kubectl get analysisrun -n lab14 +NAME STATUS AGE +lab14-analysis-devops-app-py-7d4cc4fd77-4 Successful 3m11s +lab14-analysis-devops-app-py-994cb6c55-5 Failed 70s +``` + +## Strategy Comparison + +| Strategy | Best use | Strengths | Tradeoffs | +| ------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Canary | Risky changes where gradual exposure matters | Small blast radius, manual gates, automated analysis, progressive confidence | Slower, mixed versions run at once, exact traffic percentages need a traffic router | +| Blue-green | Releases that need pre-production validation and instant switch | Preview environment, fast promotion, fast rollback | Needs duplicate capacity during preview, switch is all-or-nothing | +| Deployment rolling update | Routine low-risk changes | Simple, native Kubernetes, no extra controller | No dashboard gates, no analysis, weak rollback workflow compared with Rollouts | + +My practical recommendation is to use canary for user-facing changes where regressions may be subtle, blue-green for changes that need manual preview or a fast service-selector rollback, and plain Deployments for internal or low-risk services where progressive delivery would add operational weight without much benefit. + +## Screenshots + +The dashboard screenshots are stored under `k8s/docs/img/`: + +![Argo Rollouts dashboard overview](img/lab14_rollouts_dashboard_overview.png) + +![Canary rollout paused at the first 20 percent gate](img/lab14_canary_manual_pause.png) + +![Canary abort and rollback to stable](img/lab14_canary_abort_rollback.png) + +![Blue-green active and preview services](img/lab14_bluegreen_preview_active.png) + +![Failed bonus AnalysisRun aborting a rollout](img/lab14_bonus_analysis_failure.png) + +## Command Reference + +```bash +helm lint k8s/devops-app-py +helm template lab14-default k8s/devops-app-py --namespace lab14 +helm template lab14-canary k8s/devops-app-py --namespace lab14 -f k8s/devops-app-py/values-rollout-canary.yaml +helm template lab14-bluegreen k8s/devops-app-py --namespace lab14 -f k8s/devops-app-py/values-rollout-bluegreen.yaml +helm template lab14-analysis k8s/devops-app-py --namespace lab14 -f k8s/devops-app-py/values-rollout-analysis.yaml + +kubectl argo rollouts get rollout lab14-canary-devops-app-py -n lab14 +kubectl argo rollouts promote lab14-canary-devops-app-py -n lab14 +kubectl argo rollouts abort lab14-canary-devops-app-py -n lab14 +kubectl argo rollouts undo lab14-canary-devops-app-py -n lab14 +kubectl argo rollouts retry rollout lab14-canary-devops-app-py -n lab14 + +kubectl argo rollouts get rollout lab14-bluegreen-devops-app-py -n lab14 +kubectl argo rollouts promote lab14-bluegreen-devops-app-py -n lab14 +kubectl argo rollouts undo lab14-bluegreen-devops-app-py -n lab14 + +kubectl get analysisrun -n lab14 +kubectl describe analysisrun -n lab14 +``` + +## Verification + +Static verification completed successfully: + +```text +$ 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 +``` + +I also rendered all four chart modes successfully: + +```text +$ rg -n "kind: (Deployment|Rollout|AnalysisTemplate|Service)$" /tmp/lab14/render-*.yaml +/tmp/lab14/render-analysis.yaml:104:kind: Service +/tmp/lab14/render-analysis.yaml:127:kind: AnalysisTemplate +/tmp/lab14/render-analysis.yaml:152:kind: Rollout +/tmp/lab14/render-bluegreen.yaml:104:kind: Service +/tmp/lab14/render-bluegreen.yaml:128:kind: Service +/tmp/lab14/render-bluegreen.yaml:151:kind: Rollout +/tmp/lab14/render-canary.yaml:104:kind: Service +/tmp/lab14/render-canary.yaml:127:kind: Rollout +/tmp/lab14/render-default.yaml:104:kind: Service +/tmp/lab14/render-default.yaml:127:kind: Deployment +``` + +Final cluster verification: + +```text +$ kubectl argo rollouts get rollout lab14-canary-devops-app-py -n lab14 +Status: Healthy +Replicas: Desired 5, Ready 5, Available 5 + +$ kubectl argo rollouts get rollout lab14-bluegreen-devops-app-py -n lab14 +Status: Healthy +Replicas: Desired 3, Ready 3, Available 3 + +$ kubectl argo rollouts get rollout lab14-analysis-devops-app-py -n lab14 +Status: Healthy +Replicas: Desired 5, Ready 5, Available 5 +``` + +## Final State + +The repository-side implementation is complete and keeps existing Lab 13 application manifests compatible. The final cluster state after evidence capture is: + +- `argo-rollouts` namespace with the controller and dashboard running. +- `lab14` namespace with healthy canary, blue-green, and analysis Rollouts. +- One successful AnalysisRun and one intentionally failed AnalysisRun retained for bonus evidence. +- Lab 13 `dev` and `prod` ArgoCD application files left untouched in the repository. diff --git a/k8s/docs/LAB15.md b/k8s/docs/LAB15.md new file mode 100644 index 0000000000..ab1d0041e8 --- /dev/null +++ b/k8s/docs/LAB15.md @@ -0,0 +1,441 @@ +# Kubernetes Lab 15 - StatefulSets and Persistent Storage + +Lab 15 converts the Helm chart's default workload from a Deployment to a StatefulSet so the Lab 12 visits counter can demonstrate stable pod identity and per-pod persistent storage. I used a separate `lab15` namespace and a direct Helm release named `lab15`, leaving the Lab 13 ArgoCD applications untouched. Those ArgoCD applications still target branch `lab13`, so this Lab 15 chart default change does not affect them unless their target revision is changed later. + +The chart is now version `0.7.0`. Default rendering creates a StatefulSet, a headless Service, the normal ClusterIP Service, and `volumeClaimTemplates`. Argo Rollouts mode still renders a Rollout and standalone PVC, and a Deployment fallback remains available with `statefulset.enabled=false` and `deployment.enabled=true`. + +## StatefulSet Concepts + +StatefulSets are useful when a workload needs identity or storage that belongs to a specific replica. Databases, queues, and distributed systems often need this. The Python app is not a database, but its `/data/visits` counter is a small, inspectable state file that makes the StatefulSet behavior easy to prove. + +| Feature | Deployment | StatefulSet | +| --- | --- | --- | +| Pod names | Random ReplicaSet suffixes | Stable ordinal names such as `app-0` | +| Storage | Usually shared or manually named PVCs | Per-pod PVCs from `volumeClaimTemplates` | +| Scaling | Any order | Ordered by default | +| Network identity | Service load balancing hides individual pods | Pod DNS names are stable through the headless Service | +| Best use | Stateless apps and progressive delivery | Stateful replicas that need stable identity or storage | + +A headless Service uses `clusterIP: None`. Instead of giving one virtual service IP, Kubernetes DNS publishes records for the selected pods. For this release the pattern is: + +```text +.lab15-devops-app-py-headless.lab15.svc.cluster.local +``` + +## Chart Implementation + +The main chart changes are: + +- `templates/statefulset.yaml` renders the default `apps/v1 StatefulSet`. +- `templates/service-headless.yaml` renders `lab15-devops-app-py-headless` with `clusterIP: None`. +- `values-statefulset.yaml` sets `replicaCount: 3`, image tag `1.12`, and the default `RollingUpdate` strategy. +- The shared pod template mounts `data-volume` in all modes, but StatefulSet mode gets that volume from `volumeClaimTemplates`; Rollout and Deployment modes still use the standalone PVC. + +The relevant values shape is: + +```yaml +deployment: + enabled: false + +statefulset: + enabled: true + podManagementPolicy: OrderedReady + revisionHistoryLimit: 5 + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 0 + +persistence: + enabled: true + mountPath: /data + accessModes: + - ReadWriteOnce + size: 100Mi +``` + +
+static Helm checks + +```text +$ 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 lab15 k8s/devops-app-py --namespace lab15 -f k8s/devops-app-py/values-statefulset.yaml | rg "^kind:|serviceName:|volumeClaimTemplates:|clusterIP:|updateStrategy:|partition:" +kind: ServiceAccount +kind: Secret +kind: ConfigMap +kind: ConfigMap +kind: Service + clusterIP: None +kind: Service +kind: StatefulSet + serviceName: lab15-devops-app-py-headless + updateStrategy: + partition: 0 + volumeClaimTemplates: +kind: Job +kind: Job + +$ helm template lab15-rollout k8s/devops-app-py --namespace lab15 -f k8s/devops-app-py/values-rollout-canary.yaml | rg "^kind:|claimName:|volumeClaimTemplates:" +kind: ServiceAccount +kind: Secret +kind: ConfigMap +kind: ConfigMap +kind: PersistentVolumeClaim +kind: Service +kind: Rollout + claimName: lab15-rollout-devops-app-py-data +kind: Job +kind: Job + +$ helm template lab15-deploy k8s/devops-app-py --namespace lab15 --set statefulset.enabled=false --set deployment.enabled=true | rg "^kind:|claimName:|volumeClaimTemplates:" +kind: ServiceAccount +kind: Secret +kind: ConfigMap +kind: ConfigMap +kind: PersistentVolumeClaim +kind: Service +kind: Deployment + claimName: lab15-deploy-devops-app-py-data +kind: Job +kind: Job +``` + +
+ +## Cluster Setup + +The saved Docker-backed `minikube` profile existed but was stopped. A normal restart recovered it; no profile deletion was needed. The `standard` storage class is the minikube hostPath provisioner, which is sufficient for this local StatefulSet lab. + +
+cluster recovery and storage class + +```text +$ minikube status -p minikube +minikube +type: Control Plane +host: Stopped +kubelet: Stopped +apiserver: Stopped +kubeconfig: Stopped + + +$ minikube start -p minikube --driver=docker +* minikube v1.38.1 on Arch +* Using the docker driver based on existing profile +* Starting "minikube" primary control-plane node in "minikube" cluster +* Pulling base image v0.0.50 ... +* Verifying Kubernetes components... + - Using image gcr.io/k8s-minikube/storage-provisioner:v5 +* Enabled addons: default-storageclass, storage-provisioner +* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default + +$ kubectl get nodes -o wide +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 8d v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 7.0.3-1-cachyos docker://29.2.1 + +$ kubectl get storageclass +NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE +standard (default) k8s.io/minikube-hostpath Delete Immediate false 8d + +$ helm version --short +v4.1.4+g05fa379 +``` + +
+ +After installation, the StatefulSet created three ordered pods and three automatically named PVCs: + +
+StatefulSet, services, and PVCs + +```text +$ kubectl wait --for=condition=Ready pod -l app.kubernetes.io/instance=lab15 -n lab15 +pod/lab15-devops-app-py-0 condition met +pod/lab15-devops-app-py-1 condition met +pod/lab15-devops-app-py-2 condition met + +$ kubectl get po,sts,svc,pvc -n lab15 -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab15-devops-app-py-0 1/1 Running 0 67s 10.244.0.65 minikube +pod/lab15-devops-app-py-1 1/1 Running 0 16s 10.244.0.66 minikube +pod/lab15-devops-app-py-2 1/1 Running 0 9s 10.244.0.67 minikube + +NAME READY AGE CONTAINERS IMAGES +statefulset.apps/lab15-devops-app-py 3/3 67s devops-app-py localt0aster/devops-app-py:1.12 + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab15-devops-app-py-headless ClusterIP None 80/TCP 67s app.kubernetes.io/instance=lab15,app.kubernetes.io/name=devops-app-py +service/lab15-devops-app-py-service ClusterIP 10.96.64.122 80/TCP 67s app.kubernetes.io/instance=lab15,app.kubernetes.io/name=devops-app-py + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE +persistentvolumeclaim/data-volume-lab15-devops-app-py-0 Bound pvc-fb42ff13-ec37-4604-9833-84381c98e194 100Mi RWO standard 67s Filesystem +persistentvolumeclaim/data-volume-lab15-devops-app-py-1 Bound pvc-e2f72f28-1577-4b27-82b4-e5b0eb001d88 100Mi RWO standard 16s Filesystem +persistentvolumeclaim/data-volume-lab15-devops-app-py-2 Bound pvc-32239c77-37ff-4eb5-89a4-86f1be4a84e7 100Mi RWO standard 9s Filesystem +``` + +
+ +## Network Identity + +The application image includes BusyBox tooling, so I could run DNS checks directly from pod 0. Pod 1 and pod 2 both resolve through the headless Service using their stable ordinal DNS names. + +
+pod DNS resolution + +```text +$ kubectl exec -n lab15 lab15-devops-app-py-0 -- nslookup lab15-devops-app-py-1.lab15-devops-app-py-headless.lab15.svc.cluster.local +Server: 10.96.0.10 +Address: 10.96.0.10:53 + + +Name: lab15-devops-app-py-1.lab15-devops-app-py-headless.lab15.svc.cluster.local +Address: 10.244.0.66 + + +$ kubectl exec -n lab15 lab15-devops-app-py-0 -- nslookup lab15-devops-app-py-2.lab15-devops-app-py-headless.lab15.svc.cluster.local +Server: 10.96.0.10 +Address: 10.96.0.10:53 + + +Name: lab15-devops-app-py-2.lab15-devops-app-py-headless.lab15.svc.cluster.local +Address: 10.244.0.67 +``` + +
+ +## Per-Pod Storage + +I port-forwarded each pod directly and hit `/` a different number of times. Each pod kept its own `/data/visits` file, proving that the PVCs are per ordinal rather than shared behind the normal Service. + +
+per-pod visit counters + +```text +$ kubectl port-forward pod/lab15-devops-app-py-0 -n lab15 18080:5000 +$ kubectl port-forward pod/lab15-devops-app-py-1 -n lab15 18081:5000 +$ kubectl port-forward pod/lab15-devops-app-py-2 -n lab15 18082:5000 + +$ curl -sS 127.0.0.1:18080/visits | jq . +{ + "visits": 0 +} + +$ curl -sS 127.0.0.1:18081/visits | jq . +{ + "visits": 0 +} + +$ curl -sS 127.0.0.1:18082/visits | jq . +{ + "visits": 0 +} + +$ curl -sS 127.0.0.1:18080/ >/dev/null + +$ curl -sS 127.0.0.1:18080/ >/dev/null + +$ curl -sS 127.0.0.1:18081/ >/dev/null + +$ curl -sS 127.0.0.1:18082/ >/dev/null + +$ curl -sS 127.0.0.1:18082/ >/dev/null + +$ curl -sS 127.0.0.1:18082/ >/dev/null + +$ curl -sS 127.0.0.1:18080/visits | jq . +{ + "visits": 2 +} + +$ curl -sS 127.0.0.1:18081/visits | jq . +{ + "visits": 1 +} + +$ curl -sS 127.0.0.1:18082/visits | jq . +{ + "visits": 3 +} +``` + +
+ +Deleting pod 0 changed its IP address but preserved the visit count because the replacement pod reused `data-volume-lab15-devops-app-py-0`. + +
+persistence after pod deletion + +```text +$ kubectl exec -n lab15 lab15-devops-app-py-0 -- cat /data/visits +2 + +$ kubectl delete pod -n lab15 lab15-devops-app-py-0 +pod "lab15-devops-app-py-0" deleted from lab15 namespace + +$ kubectl wait --for=condition=Ready pod/lab15-devops-app-py-0 -n lab15 +pod/lab15-devops-app-py-0 condition met + +$ kubectl get pod -n lab15 lab15-devops-app-py-0 -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +lab15-devops-app-py-0 1/1 Running 0 7s 10.244.0.68 minikube + +$ kubectl exec -n lab15 lab15-devops-app-py-0 -- cat /data/visits +2 +``` + +
+ +## Bonus - Update Strategies + +With `RollingUpdate` and `partition: 2`, only pods with ordinal `>= 2` update. The annotation change below moved pod 2 to `partition-v2`; pods 0 and 1 stayed on `stateful-v1`. + +
+partitioned RollingUpdate + +```text +$ cat /tmp/lab15/partition.values.yaml +podAnnotations: + lab15-version: partition-v2 +statefulset: + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 2 + +$ helm upgrade lab15 k8s/devops-app-py -n lab15 -f k8s/devops-app-py/values-statefulset.yaml -f /tmp/lab15/partition.values.yaml +Release "lab15" has been upgraded. Happy Helming! +NAME: lab15 +LAST DEPLOYED: Thu May 7 20:22:57 2026 +NAMESPACE: lab15 +STATUS: deployed +REVISION: 3 +DESCRIPTION: Upgrade complete +TEST SUITE: None +$ kubectl get pods -n lab15 -l app.kubernetes.io/instance=lab15 -o json | jq -r ... +lab15-devops-app-py-0 stateful-v1 2026-05-07T17:21:33Z +lab15-devops-app-py-1 stateful-v1 2026-05-07T17:19:58Z +lab15-devops-app-py-2 partition-v2 2026-05-07T17:22:07Z + +$ kubectl get sts lab15-devops-app-py -n lab15 -o json | jq ... +{ + "currentRevision": "lab15-devops-app-py-5d8fd446ff", + "updateRevision": "lab15-devops-app-py-5bb76d9794", + "currentReplicas": 2, + "updatedReplicas": 1, + "readyReplicas": 3 +} +``` + +
+ +`OnDelete` is useful when an operator wants to control exactly when each stateful replica restarts. After switching to `OnDelete`, no existing pod picked up `ondelete-v3`. Only the manually deleted ordinal 1 was recreated with the new annotation. + +
+OnDelete update strategy + +```text +$ cat /tmp/lab15/rolling-all.values.yaml +podAnnotations: + lab15-version: partition-v2 +statefulset: + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 0 + +$ helm upgrade lab15 k8s/devops-app-py -n lab15 -f k8s/devops-app-py/values-statefulset.yaml -f /tmp/lab15/rolling-all.values.yaml +Release "lab15" has been upgraded. Happy Helming! +NAME: lab15 +LAST DEPLOYED: Thu May 7 20:24:40 2026 +NAMESPACE: lab15 +STATUS: deployed +REVISION: 6 +DESCRIPTION: Upgrade complete +TEST SUITE: None + +$ kubectl get pods -n lab15 -l app.kubernetes.io/instance=lab15 -o json | jq -r ... +lab15-devops-app-py-0 partition-v2 2026-05-07T17:23:44Z +lab15-devops-app-py-1 partition-v2 2026-05-07T17:24:42Z +lab15-devops-app-py-2 partition-v2 2026-05-07T17:22:07Z + +$ cat /tmp/lab15/ondelete.values.yaml +podAnnotations: + lab15-version: ondelete-v3 +statefulset: + updateStrategy: + type: OnDelete + +$ helm upgrade lab15 k8s/devops-app-py -n lab15 -f k8s/devops-app-py/values-statefulset.yaml -f /tmp/lab15/ondelete.values.yaml +Release "lab15" has been upgraded. Happy Helming! +NAME: lab15 +LAST DEPLOYED: Thu May 7 20:24:43 2026 +NAMESPACE: lab15 +STATUS: deployed +REVISION: 7 +DESCRIPTION: Upgrade complete +TEST SUITE: None + +$ kubectl get pods -n lab15 -l app.kubernetes.io/instance=lab15 -o json | jq -r ... +lab15-devops-app-py-0 partition-v2 2026-05-07T17:23:44Z +lab15-devops-app-py-1 partition-v2 2026-05-07T17:24:42Z +lab15-devops-app-py-2 partition-v2 2026-05-07T17:22:07Z + +$ kubectl delete pod -n lab15 lab15-devops-app-py-1 +pod "lab15-devops-app-py-1" deleted from lab15 namespace + +$ kubectl wait --for=condition=Ready pod/lab15-devops-app-py-1 -n lab15 +pod/lab15-devops-app-py-1 condition met + +$ kubectl get pods -n lab15 -l app.kubernetes.io/instance=lab15 -o json | jq -r ... +lab15-devops-app-py-0 partition-v2 2026-05-07T17:23:44Z +lab15-devops-app-py-1 ondelete-v3 2026-05-07T17:25:15Z +lab15-devops-app-py-2 partition-v2 2026-05-07T17:22:07Z +``` + +
+ +## Final State + +After the bonus tests, I restored the release to the default Lab 15 values: `RollingUpdate`, partition `0`, and `lab15-version=stateful-v1`. All three pods are ready and all three PVCs remain bound. + +
+final healthy state + +```text +$ kubectl wait --for=condition=Ready pod -l app.kubernetes.io/instance=lab15 -n lab15 +pod/lab15-devops-app-py-0 condition met +pod/lab15-devops-app-py-1 condition met +pod/lab15-devops-app-py-2 condition met + +$ kubectl get po,sts,svc,pvc -n lab15 -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab15-devops-app-py-0 1/1 Running 0 17s 10.244.0.77 minikube +pod/lab15-devops-app-py-1 1/1 Running 0 25s 10.244.0.76 minikube +pod/lab15-devops-app-py-2 1/1 Running 0 32s 10.244.0.75 minikube + +NAME READY AGE CONTAINERS IMAGES +statefulset.apps/lab15-devops-app-py 3/3 7m1s devops-app-py localt0aster/devops-app-py:1.12 + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab15-devops-app-py-headless ClusterIP None 80/TCP 7m1s app.kubernetes.io/instance=lab15,app.kubernetes.io/name=devops-app-py +service/lab15-devops-app-py-service ClusterIP 10.96.64.122 80/TCP 7m1s app.kubernetes.io/instance=lab15,app.kubernetes.io/name=devops-app-py + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE +persistentvolumeclaim/data-volume-lab15-devops-app-py-0 Bound pvc-fb42ff13-ec37-4604-9833-84381c98e194 100Mi RWO standard 7m1s Filesystem +persistentvolumeclaim/data-volume-lab15-devops-app-py-1 Bound pvc-e2f72f28-1577-4b27-82b4-e5b0eb001d88 100Mi RWO standard 6m10s Filesystem +persistentvolumeclaim/data-volume-lab15-devops-app-py-2 Bound pvc-32239c77-37ff-4eb5-89a4-86f1be4a84e7 100Mi RWO standard 6m3s Filesystem + +$ kubectl get pods -n lab15 -l app.kubernetes.io/instance=lab15 -o json | jq -r ... +lab15-devops-app-py-0 stateful-v1 Running +lab15-devops-app-py-1 stateful-v1 Running +lab15-devops-app-py-2 stateful-v1 Running +``` + +
+ +The Lab 15 checklist is complete: StatefulSet guarantees are documented, the chart renders a StatefulSet with `volumeClaimTemplates`, the headless Service resolves pod identities, each pod has its own PVC-backed visit counter, pod deletion preserves data, and both bonus update strategies were implemented and verified. diff --git a/k8s/docs/LAB16.md b/k8s/docs/LAB16.md new file mode 100644 index 0000000000..857b37b87d --- /dev/null +++ b/k8s/docs/LAB16.md @@ -0,0 +1,443 @@ +# Kubernetes Lab 16 - Monitoring and Init Containers + +Lab 16 adds a kube-prometheus-stack monitoring installation to the existing Lab 15 StatefulSet release and implements init container support in the Helm chart. I kept the application release in the `lab15` namespace so the StatefulSet, PVCs, and stable pod identities from Lab 15 remain the monitored workload. + +The monitoring stack is installed as Helm release `monitoring` in namespace `monitoring`, pinned to kube-prometheus-stack chart `84.5.0`. The chart version for `devops-app-py` is now `0.8.0`. + +## Stack Components + +| Component | Role in this lab | +| ------------------- | -------------------------------------------------------------------------------------------- | +| Prometheus Operator | Reconciles Prometheus, Alertmanager, ServiceMonitor, and rule CRDs into running workloads. | +| Prometheus | Scrapes Kubernetes, node, and application metrics, then answers PromQL queries. | +| Alertmanager | Receives firing alerts from Prometheus and groups them in the Alertmanager UI. | +| Grafana | Provides dashboards for pod, namespace, node, kubelet, and persistent volume metrics. | +| kube-state-metrics | Exposes Kubernetes object state such as pod resources, StatefulSet status, and PVC metadata. | +| node-exporter | Exposes host CPU, memory, disk, and filesystem metrics from the minikube node. | + +## Chart Implementation + +The Helm chart now supports: + +- `initContainers`: raw Kubernetes init container specs rendered into the shared pod template. +- `extraVolumes` and `extraVolumeMounts`: reusable shared volumes for init and main containers. +- `serviceMonitor.enabled`: optional `monitoring.coreos.com/v1 ServiceMonitor` rendering. +- `statefulset.volumeClaimTemplateLabels`: an upgrade escape hatch for existing StatefulSets, because Kubernetes forbids changes to `volumeClaimTemplates`. + +The Lab 16 values file adds two init containers: + +- `wait-for-headless-service` waits until `lab15-devops-app-py-headless.lab15.svc.cluster.local` resolves. +- `init-download` uses BusyBox `wget` to save the app service's `/ready` response into `/init-data/lab16-init.txt`, an `emptyDir` volume mounted read-only by the main app container. + +I used the in-cluster `/ready` endpoint instead of an external URL so the init container proves the download pattern without depending on outside network access. + +## Static Checks + +
+Helm render checks + +```text +$ 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 lab15 k8s/devops-app-py --namespace lab15 -f k8s/devops-app-py/values-statefulset.yaml -f k8s/devops-app-py/values-lab16.yaml | rg "helm.sh/chart|volumeClaimTemplates:|initContainers:|ServiceMonitor|release: monitoring" + helm.sh/chart: devops-app-py-0.8.0 + helm.sh/chart: devops-app-py-0.8.0 + helm.sh/chart: devops-app-py-0.8.0 + helm.sh/chart: devops-app-py-0.8.0 + helm.sh/chart: devops-app-py-0.8.0 + helm.sh/chart: devops-app-py-0.8.0 + helm.sh/chart: devops-app-py-0.8.0 + initContainers: + volumeClaimTemplates: + helm.sh/chart: devops-app-py-0.7.0 +kind: ServiceMonitor + helm.sh/chart: devops-app-py-0.8.0 + release: monitoring + helm.sh/chart: devops-app-py-0.8.0 + helm.sh/chart: devops-app-py-0.8.0 +``` + +
+ +The `volumeClaimTemplates` chart label remains `devops-app-py-0.7.0` only in `values-lab16.yaml` for the already-created Lab 15 StatefulSet. Without that compatibility label, Kubernetes rejects the upgrade because the rendered claim template metadata changes. + +## Monitoring Install + +The first install succeeded, but several images initially pulled from `quay.io` slowly or failed transiently. To make the local minikube install repeatable, I upgraded the same chart version with equivalent Docker Hub and GHCR image names and preloaded those images into minikube. + +
+Monitoring release and final readiness + +```text +$ helm upgrade monitoring prometheus-community/kube-prometheus-stack --version 84.5.0 -n monitoring -f /tmp/lab16/monitoring.values.yaml +Release "monitoring" has been upgraded. Happy Helming! +NAME: monitoring +LAST DEPLOYED: Thu May 7 21:17:29 2026 +NAMESPACE: monitoring +STATUS: deployed +REVISION: 2 +DESCRIPTION: Upgrade complete +TEST SUITE: None + +$ kubectl get pods,statefulsets,deployments,daemonsets -n monitoring -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/alertmanager-monitoring-kube-prometheus-alertmanager-0 2/2 Running 0 14s 10.244.0.91 minikube +pod/monitoring-grafana-785747497f-lkmxf 3/3 Running 0 26s 10.244.0.87 minikube +pod/monitoring-kube-prometheus-operator-cf8cf4744-ml69q 1/1 Running 0 26s 10.244.0.86 minikube +pod/monitoring-kube-state-metrics-5957bd45bc-n84lk 1/1 Running 0 5m55s 10.244.0.79 minikube +pod/monitoring-prometheus-node-exporter-qq9q6 1/1 Running 0 25s 192.168.49.2 minikube +pod/prometheus-monitoring-kube-prometheus-prometheus-0 2/2 Running 0 24s 10.244.0.89 minikube + +NAME READY AGE CONTAINERS IMAGES +statefulset.apps/alertmanager-monitoring-kube-prometheus-alertmanager 1/1 2m30s alertmanager,config-reloader docker.io/prom/alertmanager:v0.32.1,ghcr.io/prometheus-operator/prometheus-config-reloader:v0.90.1 +statefulset.apps/prometheus-monitoring-kube-prometheus-prometheus 1/1 2m30s prometheus,config-reloader docker.io/prom/prometheus:v3.11.3,ghcr.io/prometheus-operator/prometheus-config-reloader:v0.90.1 + +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +deployment.apps/monitoring-grafana 1/1 1 1 5m55s grafana-sc-dashboard,grafana-sc-datasources,grafana docker.io/kiwigrid/k8s-sidecar:2.7.1,docker.io/kiwigrid/k8s-sidecar:2.7.1,docker.io/grafana/grafana:13.0.1 app.kubernetes.io/instance=monitoring,app.kubernetes.io/name=grafana +deployment.apps/monitoring-kube-prometheus-operator 1/1 1 1 5m55s kube-prometheus-stack ghcr.io/prometheus-operator/prometheus-operator:v0.90.1 app=kube-prometheus-stack-operator,release=monitoring +deployment.apps/monitoring-kube-state-metrics 1/1 1 1 5m55s kube-state-metrics registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.18.0 app.kubernetes.io/instance=monitoring,app.kubernetes.io/name=kube-state-metrics + +NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE CONTAINERS IMAGES SELECTOR +daemonset.apps/monitoring-prometheus-node-exporter 1 1 1 1 1 kubernetes.io/os=linux 5m55s node-exporter docker.io/prom/node-exporter:v1.11.1 app.kubernetes.io/instance=monitoring,app.kubernetes.io/name=prometheus-node-exporter + +$ kubectl get prometheus,alertmanager -n monitoring +NAME VERSION DESIRED READY RECONCILED AVAILABLE AGE +prometheus.monitoring.coreos.com/monitoring-kube-prometheus-prometheus v3.11.3 1 1 True True 5m55s + +NAME VERSION REPLICAS READY RECONCILED AVAILABLE AGE +alertmanager.monitoring.coreos.com/monitoring-kube-prometheus-alertmanager v0.32.1 1 1 True True 5m55s +``` + +
+ +## Init Container Proof + +
+StatefulSet upgrade and init output + +```text +$ helm status lab15 -n lab15 +NAME: lab15 +LAST DEPLOYED: Thu May 7 21:18:53 2026 +NAMESPACE: lab15 +STATUS: deployed +REVISION: 10 +DESCRIPTION: Upgrade complete + +$ kubectl get pods,statefulsets,svc,pvc,servicemonitor -n lab15 -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/lab15-devops-app-py-0 1/1 Running 0 15s 10.244.0.94 minikube +pod/lab15-devops-app-py-1 1/1 Running 0 25s 10.244.0.93 minikube +pod/lab15-devops-app-py-2 1/1 Running 0 35s 10.244.0.92 minikube + +NAME READY AGE CONTAINERS IMAGES +statefulset.apps/lab15-devops-app-py 3/3 60m devops-app-py localt0aster/devops-app-py:1.12 + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/lab15-devops-app-py-headless ClusterIP None 80/TCP 60m app.kubernetes.io/instance=lab15,app.kubernetes.io/name=devops-app-py +service/lab15-devops-app-py-service ClusterIP 10.96.64.122 80/TCP 60m app.kubernetes.io/instance=lab15,app.kubernetes.io/name=devops-app-py + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE +persistentvolumeclaim/data-volume-lab15-devops-app-py-0 Bound pvc-fb42ff13-ec37-4604-9833-84381c98e194 100Mi RWO standard 60m Filesystem +persistentvolumeclaim/data-volume-lab15-devops-app-py-1 Bound pvc-e2f72f28-1577-4b27-82b4-e5b0eb001d88 100Mi RWO standard 59m Filesystem +persistentvolumeclaim/data-volume-lab15-devops-app-py-2 Bound pvc-32239c77-37ff-4eb5-89a4-86f1be4a84e7 100Mi RWO standard 59m Filesystem + +NAME AGE +servicemonitor.monitoring.coreos.com/lab15-devops-app-py 77s + +$ kubectl get pod lab15-devops-app-py-0 -n lab15 -o json | jq '{name: .metadata.name, initContainers: [.status.initContainerStatuses[] | {name, ready, restartCount, state}], containerImages: [.spec.containers[] | {name, image}], annotations: .metadata.annotations}' +{ + "name": "lab15-devops-app-py-0", + "initContainers": [ + { + "name": "wait-for-headless-service", + "ready": true, + "restartCount": 0, + "state": { + "terminated": { + "exitCode": 0, + "reason": "Completed" + } + } + }, + { + "name": "init-download", + "ready": true, + "restartCount": 0, + "state": { + "terminated": { + "exitCode": 0, + "reason": "Completed" + } + } + } + ], + "containerImages": [ + { + "name": "devops-app-py", + "image": "localt0aster/devops-app-py:1.12" + } + ], + "annotations": { + "lab15-version": "stateful-v1", + "lab16-init-demo": "enabled" + } +} + +$ kubectl logs -n lab15 lab15-devops-app-py-0 -c wait-for-headless-service +Name: lab15-devops-app-py-headless.lab15.svc.cluster.local +Address: 10.244.0.92 +Name: lab15-devops-app-py-headless.lab15.svc.cluster.local +Address: 10.244.0.93 + +headless service resolved + +$ kubectl logs -n lab15 lab15-devops-app-py-0 -c init-download +Connecting to lab15-devops-app-py-service.lab15.svc.cluster.local (10.96.64.122:80) +saving to '/init-data/lab16-init.txt' +lab16-init.txt 100% |********************************| 86 0:00:00 ETA +'/init-data/lab16-init.txt' saved + +$ kubectl exec -n lab15 lab15-devops-app-py-0 -- sed -n '1,8p' /init-data/lab16-init.txt +{"status":"ready","timestamp":"2026-05-07T18:19:16.092960+00:00","uptime_seconds":13} + +downloaded-by=init-download +``` + +
+ +## Dashboard Answers + +I used Grafana for the dashboard views and Prometheus queries for exact values where the Grafana panel aggregated or omitted raw numbers. The default namespace had no pods at capture time, so the "default namespace" CPU and network questions have an empty result. + +| Question | Answer | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pod resources for the StatefulSet | `lab15-devops-app-py-0`, `-1`, and `-2` were about `0.63` to `0.72` millicores and `42.48` to `43.18` MiB working set. The Grafana quota panel also showed each pod requesting `0.100` CPU and limiting at `0.250` CPU. | +| Pods using most/least CPU in `default` | There were no pods in `default`, so there was no most/least CPU consumer. | +| Node metrics | Minikube node memory was about `35.99%` used, or `14323.58` MiB, with `8` logical CPU cores. | +| Kubelet | Kubelet reported `31` running pods and `66` running containers. | +| Network traffic in `default` | No `default` namespace pods existed, so the network query returned no series. | +| Alerts | Alertmanager had `5` active alerts: `Watchdog` plus kube-system alerts including `TargetDown` and `etcdInsufficientMembers`. | + +
+Dashboard answer queries + +```text +$ kubectl get pods -n default +No resources found in default namespace. + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="lab15", pod=~"lab15-devops-app-py-.*"}[5m])) * 1000' | jq '[.data.result[] | {pod: .metric.pod, cpu_millicores: .value[1]}]' +[ + { + "pod": "lab15-devops-app-py-2", + "cpu_millicores": "0.7247538053105157" + }, + { + "pod": "lab15-devops-app-py-1", + "cpu_millicores": "0.6301911409320081" + }, + { + "pod": "lab15-devops-app-py-0", + "cpu_millicores": "0.700071927370977" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=sum by (pod) (container_memory_working_set_bytes{namespace="lab15", pod=~"lab15-devops-app-py-.*"}) / 1024 / 1024' | jq '[.data.result[] | {pod: .metric.pod, memory_mib: .value[1]}]' +[ + { + "pod": "lab15-devops-app-py-2", + "memory_mib": "42.4765625" + }, + { + "pod": "lab15-devops-app-py-1", + "memory_mib": "43.18359375" + }, + { + "pod": "lab15-devops-app-py-0", + "memory_mib": "42.96875" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="default"}[5m])) * 1000' | jq '.data.result' +[] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100' | jq '[.data.result[] | {instance: .metric.instance, memory_percent: .value[1]}]' +[ + { + "instance": "192.168.49.2:9100", + "memory_percent": "35.99074072983496" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 1024 / 1024' | jq '[.data.result[] | {instance: .metric.instance, memory_mib: .value[1]}]' +[ + { + "instance": "192.168.49.2:9100", + "memory_mib": "14323.578125" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=count(count by (cpu) (node_cpu_seconds_total{mode="idle"}))' | jq '[.data.result[] | {logical_cores: .value[1]}]' +[ + { + "logical_cores": "8" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=sum(kubelet_running_pods)' | jq '[.data.result[] | {running_pods: .value[1]}]' +[ + { + "running_pods": "31" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=sum(kubelet_running_containers)' | jq '[.data.result[] | {running_containers: .value[1]}]' +[ + { + "running_containers": "66" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=sum by (namespace, pod) (rate(container_network_receive_bytes_total{namespace="default"}[5m]) + rate(container_network_transmit_bytes_total{namespace="default"}[5m]))' | jq '.data.result' +[] + +$ curl -fsS 127.0.0.1:9093/api/v2/alerts | jq 'group_by(.status.state) | map({state: .[0].status.state, count: length})' +[ + { + "state": "active", + "count": 5 + } +] +``` + +
+ +## Bonus: ServiceMonitor + +The application already exposed `/metrics`, so Lab 16 adds Helm-managed `ServiceMonitor` support instead of modifying the Python code. The ServiceMonitor has label `release: monitoring`, which matches the kube-prometheus-stack release selector. + +Because the chart has both a normal Service and a headless Service with the same selector labels, Prometheus discovered both Services and scraped all three pods through each, for six healthy targets. That is acceptable for this lab and makes the duplicate service behavior visible. + +
+ServiceMonitor and Prometheus proof + +```text +$ kubectl get servicemonitor lab15-devops-app-py -n lab15 -o yaml | sed -n '1,80p' +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + labels: + app.kubernetes.io/instance: lab15 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 + app.kubernetes.io/version: "1.12" + helm.sh/chart: devops-app-py-0.8.0 + release: monitoring + name: lab15-devops-app-py + namespace: lab15 +spec: + endpoints: + - interval: 15s + path: /metrics + port: http + scrapeTimeout: 10s + selector: + matchLabels: + app.kubernetes.io/instance: lab15 + app.kubernetes.io/name: devops-app-py + +$ curl -fsS 127.0.0.1:9090/api/v1/targets?state=active | jq '[.data.activeTargets[] | select((.labels.namespace // .discoveredLabels.__meta_kubernetes_namespace) == "lab15") | {job: .labels.job, service: (.discoveredLabels.__meta_kubernetes_service_name // ""), health: .health, scrapeUrl: .scrapeUrl}]' +[ + { + "job": "lab15-devops-app-py-headless", + "service": "lab15-devops-app-py-headless", + "health": "up", + "scrapeUrl": "http://10.244.0.94:5000/metrics" + }, + { + "job": "lab15-devops-app-py-service", + "service": "lab15-devops-app-py-service", + "health": "up", + "scrapeUrl": "http://10.244.0.92:5000/metrics" + }, + { + "job": "lab15-devops-app-py-service", + "service": "lab15-devops-app-py-service", + "health": "up", + "scrapeUrl": "http://10.244.0.93:5000/metrics" + }, + { + "job": "lab15-devops-app-py-service", + "service": "lab15-devops-app-py-service", + "health": "up", + "scrapeUrl": "http://10.244.0.94:5000/metrics" + }, + { + "job": "lab15-devops-app-py-headless", + "service": "lab15-devops-app-py-headless", + "health": "up", + "scrapeUrl": "http://10.244.0.92:5000/metrics" + }, + { + "job": "lab15-devops-app-py-headless", + "service": "lab15-devops-app-py-headless", + "health": "up", + "scrapeUrl": "http://10.244.0.93:5000/metrics" + } +] + +$ curl -fsS -G 127.0.0.1:9090/api/v1/query --data-urlencode 'query=sum by (service, pod, exported_endpoint) (http_requests_total{namespace="lab15", exported_endpoint=~"/|/visits|/metrics"})' | jq '[.data.result[] | {service: .metric.service, pod: .metric.pod, endpoint: .metric.exported_endpoint, value: .value[1]}]' +[ + { + "service": "lab15-devops-app-py-service", + "pod": "lab15-devops-app-py-2", + "endpoint": "/", + "value": "1" + }, + { + "service": "lab15-devops-app-py-service", + "pod": "lab15-devops-app-py-2", + "endpoint": "/visits", + "value": "1" + }, + { + "service": "lab15-devops-app-py-headless", + "pod": "lab15-devops-app-py-2", + "endpoint": "/", + "value": "1" + }, + { + "service": "lab15-devops-app-py-headless", + "pod": "lab15-devops-app-py-2", + "endpoint": "/visits", + "value": "1" + } +] +``` + +
+ +## Screenshots + +![Grafana StatefulSet resources](img/lab16_grafana_statefulset_resources.png) + +![Grafana node metrics](img/lab16_grafana_node_metrics.png) + +![Grafana kubelet dashboard](img/lab16_grafana_kubelet.png) + +![Alertmanager active alerts](img/lab16_alertmanager_alerts.png) + +![Prometheus application metrics](img/lab16_prometheus_app_metrics.png) + +## Final State + +The final cluster state is healthy: + +- `monitoring` namespace: Prometheus, Alertmanager, Grafana, kube-state-metrics, node-exporter, and the Prometheus Operator are ready. +- `lab15` namespace: StatefulSet `lab15-devops-app-py` is `3/3`, all PVCs remain bound, and init containers completed on the rolled pods. +- Bonus ServiceMonitor is active and Prometheus scrapes six healthy app targets from the normal and headless Services. 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/docs/img/lab14_bluegreen_preview_active.png b/k8s/docs/img/lab14_bluegreen_preview_active.png new file mode 100644 index 0000000000..523df272fe Binary files /dev/null and b/k8s/docs/img/lab14_bluegreen_preview_active.png differ diff --git a/k8s/docs/img/lab14_bonus_analysis_failure.png b/k8s/docs/img/lab14_bonus_analysis_failure.png new file mode 100644 index 0000000000..8e98f9c3ff Binary files /dev/null and b/k8s/docs/img/lab14_bonus_analysis_failure.png differ diff --git a/k8s/docs/img/lab14_canary_abort_rollback.png b/k8s/docs/img/lab14_canary_abort_rollback.png new file mode 100644 index 0000000000..245f0b6973 Binary files /dev/null and b/k8s/docs/img/lab14_canary_abort_rollback.png differ diff --git a/k8s/docs/img/lab14_canary_manual_pause.png b/k8s/docs/img/lab14_canary_manual_pause.png new file mode 100644 index 0000000000..231de776fc Binary files /dev/null and b/k8s/docs/img/lab14_canary_manual_pause.png differ diff --git a/k8s/docs/img/lab14_rollouts_dashboard_overview.png b/k8s/docs/img/lab14_rollouts_dashboard_overview.png new file mode 100644 index 0000000000..0a1fba940c Binary files /dev/null and b/k8s/docs/img/lab14_rollouts_dashboard_overview.png differ diff --git a/k8s/docs/img/lab16_alertmanager_alerts.png b/k8s/docs/img/lab16_alertmanager_alerts.png new file mode 100644 index 0000000000..48fdccc3c8 Binary files /dev/null and b/k8s/docs/img/lab16_alertmanager_alerts.png differ diff --git a/k8s/docs/img/lab16_grafana_kubelet.png b/k8s/docs/img/lab16_grafana_kubelet.png new file mode 100644 index 0000000000..7ba7585d95 Binary files /dev/null and b/k8s/docs/img/lab16_grafana_kubelet.png differ diff --git a/k8s/docs/img/lab16_grafana_node_metrics.png b/k8s/docs/img/lab16_grafana_node_metrics.png new file mode 100644 index 0000000000..466e199d59 Binary files /dev/null and b/k8s/docs/img/lab16_grafana_node_metrics.png differ diff --git a/k8s/docs/img/lab16_grafana_statefulset_resources.png b/k8s/docs/img/lab16_grafana_statefulset_resources.png new file mode 100644 index 0000000000..f67828b45d Binary files /dev/null and b/k8s/docs/img/lab16_grafana_statefulset_resources.png differ diff --git a/k8s/docs/img/lab16_prometheus_app_metrics.png b/k8s/docs/img/lab16_prometheus_app_metrics.png new file mode 100644 index 0000000000..848a923c20 Binary files /dev/null and b/k8s/docs/img/lab16_prometheus_app_metrics.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/labs/submission18.md b/labs/submission18.md new file mode 100644 index 0000000000..6707349319 --- /dev/null +++ b/labs/submission18.md @@ -0,0 +1,5 @@ +# Lab 18 Submission + +The Lab 18 reproducible-build implementation and report are kept in [`../nix/README.md`](../nix/README.md). + +This compatibility file exists because the lab handout asks for `labs/submission18.md`, while this repository keeps each major tool area in its own module directory. 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/nix/README.md b/nix/README.md new file mode 100644 index 0000000000..c4d3d495d3 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,309 @@ +# Lab 18 - Reproducible Builds with Nix + +This module contains the Lab 18 reproducible-build work. The implementation keeps the repository DRY: the flake in `nix/` packages the real `app_python/` source instead of copying it into a lab folder. + +## Summary + +- Python packaging moved from Poetry to uv while keeping Python 3.14. +- Nix uses stable `nixos-25.11`, pinned in `nix/flake.lock`. +- The Nix implementation lives entirely in `nix/`: + - `flake.nix` wires the package, Docker image, app, checks, formatter, and dev shell. + - `package.nix` builds the Flask app with `python314`. + - `docker.nix` builds a reproducible image with `dockerTools`. + - `devshell.nix` provides Python 3.14, uv, Docker, curl, and jq. + +The active app keeps its Python dependency graph in `app_python/uv.lock`. `app_python/requirements.txt` is generated from that lock file only for Snyk's pip-compatible scanner path; `uv.lock` remains the source of truth. The Nix runtime closure intentionally includes the packages imported by the Flask service: `flask`, `gunicorn`, and `prometheus-client`. + +## Why Nix + +uv is a much better Python workflow than Poetry here: it is fast, simple, and not tied to Poetry's project model. It still resolves from Python package indexes and produces an application-level lock file. + +Nix locks the system-level build closure: Python itself, build hooks, runtime libraries, and the Docker image construction tools. That is the main reproducibility difference. The uv Docker image is convenient, but its `python:3.14-alpine` base tag can move. The Nix image records the full nixpkgs revision and uses deterministic image metadata. + +## Commands + +```bash +cd nix +nix develop +cd ../app_python +uv sync --locked +uv export --locked --no-dev --no-annotate --no-header --no-hashes --format requirements.txt --output-file /tmp/lab18-requirements.txt >/dev/null +diff -u requirements.txt /tmp/lab18-requirements.txt +uv run flake8 src tests +uv run pytest --cov=src --cov-report=term-missing +``` + +```bash +cd nix +nix flake check +nix build .#default +nix run .#default +nix build .#dockerImage +docker load < result +``` + +## Evidence + +
+uv local workflow + +```text +$ cd nix + +$ nix develop -c bash <<'BASH' +cd ../app_python +uv --version +uv run python --version +uv lock --check +uv export --locked --no-dev --no-annotate --no-header --no-hashes --format requirements.txt --output-file /tmp/lab18-requirements.txt >/dev/null +diff -u requirements.txt /tmp/lab18-requirements.txt +uv sync --locked +uv run flake8 src tests +uv run pytest --cov=src --cov-report=term-missing +BASH + +uv 0.9.30 +Python 3.14.3 +Resolved 23 packages in 0.54ms +Resolved 23 packages in 0.50ms +Resolved 23 packages in 0.52ms +Audited 21 packages in 0.13ms +============================= test session starts ============================== +platform linux -- Python 3.14.3, pytest-9.0.3, pluggy-1.6.0 +rootdir: /home/t0ast/Repos/DevOps-Core-S26/app_python +configfile: pyproject.toml +plugins: 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%] + +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.3-final-0 ________________ + +Name Stmts Miss Cover Missing +----------------------------------------------------- +src/flask_instance.py 12 0 100% +src/logging_utils.py 42 8 81% 21-27, 51 +src/main.py 10 0 100% +src/metrics.py 45 1 98% 95 +src/router.py 118 7 94% 141-146, 149-153, 165-169 +----------------------------------------------------- +TOTAL 227 16 93% +============================== 19 passed in 0.11s ============================== +``` + +
+ +
+Nix package and runtime + +```text +$ cd nix + +$ nix flake metadata --json | jq '{description, resolved: .locks.nodes.nixpkgs.locked}' +{ + "description": "DevOps Core reproducible builds", + "resolved": { + "lastModified": 1778430510, + "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575", + "type": "github" + } +} + +$ nix flake check +evaluating flake... +checking flake output 'packages'... +checking derivation packages.x86_64-linux.default... +derivation evaluated to /nix/store/l19j81jw8kywfwiv1nbcppzw6z492lp0-devops-info-service-1.12.0.drv +checking derivation packages.x86_64-linux.devops-info-service... +derivation evaluated to /nix/store/l19j81jw8kywfwiv1nbcppzw6z492lp0-devops-info-service-1.12.0.drv +checking derivation packages.x86_64-linux.dockerImage... +derivation evaluated to /nix/store/dfh47gscdprzlalzfpngjggmlqr0c2x3-devops-info-service-nix.tar.gz.drv +checking flake output 'apps'... +checking app 'apps.x86_64-linux.default'... +warning: app 'apps.x86_64-linux.default' lacks attribute 'meta' +checking flake output 'checks'... +checking derivation checks.x86_64-linux.default... +derivation evaluated to /nix/store/l19j81jw8kywfwiv1nbcppzw6z492lp0-devops-info-service-1.12.0.drv +checking flake output 'devShells'... +checking derivation devShells.x86_64-linux.default... +derivation evaluated to /nix/store/qmsav41in4f6c269s4yaimqc3m0ag1ba-nix-shell.drv +checking flake output 'formatter'... +checking derivation formatter.x86_64-linux... +derivation evaluated to /nix/store/ds5xf6q419g1wq2kz63g3j020jd50j2y-format-nix.drv +running 1 flake checks... + +$ bash <<'BASH' +first=$(nix build .#default --no-link --print-out-paths) +second=$(nix build .#default --no-link --print-out-paths) +printf 'first=%s\nsecond=%s\n' "$first" "$second" +test "$first" = "$second" +BASH +first=/nix/store/x0manjqw974f50rw4z6mg0szvlda5s2p-devops-info-service-1.12.0 +second=/nix/store/x0manjqw974f50rw4z6mg0szvlda5s2p-devops-info-service-1.12.0 + +$ nix path-info -Sh .#default +/nix/store/x0manjqw974f50rw4z6mg0szvlda5s2p-devops-info-service-1.12.0 191.4 MiB + +$ nix path-info -rSh .#default | tail -n 5 +/nix/store/x9ydb0ljg6ahf8vzpyigzpy65w1ixpz0-python3.14-prometheus-client-0.22.1 181.6 MiB +/nix/store/z5sbbpr4izvdkck38bkr50k9h1k0p7hc-python3.14-click-8.2.1 182.3 MiB +/nix/store/xn69ihhpqffawlricm0vk3i7b370hhpn-python3.14-flask-3.1.2 188.6 MiB +/nix/store/93a9sdlhq8az8mwavchm7ibmp2r584jb-python3-3.14.3-env 191.3 MiB +/nix/store/x0manjqw974f50rw4z6mg0szvlda5s2p-devops-info-service-1.12.0 191.4 MiB + +$ bash <<'BASH' +app=$(nix build .#default --no-link --print-out-paths) +HOST=127.0.0.1 PORT=5018 "$app/bin/devops-info-service" > /tmp/lab18/nix-app.log 2>&1 & +app_pid=$! +for attempt in 1 2 3 4 5 6 7 8 9 10; do + response=$(curl -fsS 127.0.0.1:5018/health) && { + printf '%s\n' "$response" | jq . + break + } + sleep 1 +done +kill "$app_pid" +wait "$app_pid" || true +sed -n '1,12p' /tmp/lab18/nix-app.log +BASH +curl: (7) Failed to connect to 127.0.0.1 port 5018 after 13 ms: Could not connect to server +{ + "status": "healthy", + "timestamp": "2026-05-15T01:45:39.019501+00:00", + "uptime_seconds": 0 +} +[2026-05-15 04:45:38 +0300] [450876] [INFO] Starting gunicorn 23.0.0 +[2026-05-15 04:45:38 +0300] [450876] [INFO] Using worker: sync +[2026-05-15 04:45:38 +0300] [450881] [INFO] Booting worker with pid: 450881 +{"timestamp":"2026-05-15T01:45:38.124618Z","level":"INFO","logger":"devops_info_service","message":"application initialized","event":"startup","host":"127.0.0.1","port":5018,"debug":false} +{"timestamp":"[15/May/2026:04:45:39 +0300]","level":"INFO","logger":"gunicorn.access","client_ip":"127.0.0.1","method":"GET","path":"/health","query":"","status_code":200,"response_bytes":"87","request_time_us":1887,"user_agent":"curl/8.20.0"} +[2026-05-15 04:45:39 +0300] [450876] [INFO] Handling signal: term +[2026-05-15 04:45:39 +0300] [450881] [INFO] Worker exiting (pid: 450881) +[2026-05-15 04:45:39 +0300] [450876] [INFO] Shutting down: Master +``` + +
+ +
+Docker comparison + +```text +$ docker version --format '{{.Server.Version}}' +29.4.3 + +$ DOCKER_BUILDKIT=1 docker build -t devops-app-py:uv-lab18 app_python + +$ bash <<'BASH' +docker run --rm -d --name lab18-uv-app -p 5019:5000 devops-app-py:uv-lab18 +docker exec lab18-uv-app sh -c 'printf "HOME=%s\n" "$HOME" && id && test -w /home/appuser && test -w /data' +for attempt in 1 2 3 4 5 6 7 8 9 10; do + response=$(curl -fsS 127.0.0.1:5019/health) && { + printf '%s\n' "$response" | jq . + break + } + sleep 1 +done +docker logs lab18-uv-app | sed -n '1,8p' +docker stop lab18-uv-app +BASH +ae0f7839d5c1350ec44e5fbc77404de8609958abb03832dbf9be28fe23533466 +HOME=/home/appuser +uid=100(appuser) gid=101(appgroup) groups=101(appgroup),101(appgroup) +{ + "status": "healthy", + "timestamp": "2026-05-15T01:46:04.435915+00:00", + "uptime_seconds": 0 +} +[2026-05-15 01:46:03 +0000] [1] [INFO] Starting gunicorn 25.3.0 +[2026-05-15 01:46:03 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000 (1) +[2026-05-15 01:46:03 +0000] [1] [INFO] Using worker: sync +[2026-05-15 01:46:03 +0000] [14] [INFO] Booting worker with pid: 14 +[2026-05-15 01:46:04 +0000] [1] [INFO] Control socket listening at /home/appuser/.gunicorn/gunicorn.ctl +{"timestamp":"2026-05-15T01:46:04.082192Z","level":"INFO","logger":"devops_info_service","message":"application initialized","event":"startup","host":"0.0.0.0","port":5000,"debug":false} +{"timestamp":"[15/May/2026:01:46:04 +0000]","level":"INFO","logger":"gunicorn.access","client_ip":"172.17.0.1","method":"GET","path":"/health","query":"","status_code":200,"response_bytes":"87","request_time_us":9213,"user_agent":"curl/8.20.0"} +lab18-uv-app + +$ nix build .#dockerImage + +$ docker load < result +Loaded image: devops-info-service-nix:lab18 + +$ bash <<'BASH' +docker run --rm -d --name lab18-nix-app -p 5020:5000 devops-info-service-nix:lab18 +for attempt in 1 2 3 4 5 6 7 8 9 10; do + response=$(curl -fsS 127.0.0.1:5020/health) && { + printf '%s\n' "$response" | jq . + break + } + sleep 1 +done +docker logs lab18-nix-app | sed -n '1,8p' +docker stop lab18-nix-app +BASH +8faa6ce8669dd235be4e882523567d8b51830f4f32c6a295b0edadee35c27809 +{ + "status": "healthy", + "timestamp": "2026-05-15T01:46:17.547776+00:00", + "uptime_seconds": 0 +} +[2026-05-15 01:46:16 +0000] [1] [INFO] Starting gunicorn 23.0.0 +[2026-05-15 01:46:16 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000 (1) +[2026-05-15 01:46:16 +0000] [1] [INFO] Using worker: sync +[2026-05-15 01:46:16 +0000] [7] [INFO] Booting worker with pid: 7 +{"timestamp":"2026-05-15T01:46:16.649812Z","level":"INFO","logger":"devops_info_service","message":"application initialized","event":"startup","host":"0.0.0.0","port":5000,"debug":false} +{"timestamp":"[15/May/2026:01:46:17 +0000]","level":"INFO","logger":"gunicorn.access","client_ip":"172.17.0.1","method":"GET","path":"/health","query":"","status_code":200,"response_bytes":"87","request_time_us":1815,"user_agent":"curl/8.20.0"} +lab18-nix-app + +$ docker image inspect devops-app-py:uv-lab18 devops-info-service-nix:lab18 | jq '.[] | {repoTags: .RepoTags, id: .Id, created: .Created, size: .Size}' +{ + "repoTags": [ + "devops-app-py:uv-lab18", + "devops-info-service:uv-fixed" + ], + "id": "sha256:6559ec88f61e401b41cd8123f19da3fb84d90a124f0de705d115a7f1f30905cd", + "created": "2026-05-15T04:43:29.572991821+03:00", + "size": 120461874 +} +{ + "repoTags": [ + "devops-info-service-nix:lab18" + ], + "id": "sha256:5a01bf71a8a11af7336d34f9e9e4148ea266900345b8259d6f1a78ad5bc49462", + "created": "1970-01-01T00:00:01Z", + "size": 198565103 +} +``` + +
+ +## Comparison + +| Area | uv / Docker | Nix / dockerTools | +| ------------------- | ---------------------------------------- | ------------------------------------------------------- | +| Python dependencies | `uv.lock` pins Python packages from PyPI | nixpkgs pin locks Python packages and build tools | +| Python runtime | `python:3.14-alpine` image tag | `python314` from pinned `nixos-25.11` | +| Image timestamp | Build-time timestamp | Fixed `1970-01-01T00:00:01Z` | +| Rebuild identity | Can change when base image tag changes | Same flake input and source produce the same store path | +| Workflow | Better for day-to-day Python development | Better for full closure reproducibility | + +## Helm Pinning Comparison + +Lab 10 Helm values pin the application image tag, for example `image.tag: "1.12"`. That is useful deployment intent, but it does not prove what Python interpreter, package resolver, base image, or build tools produced the image. A mutable registry tag can also be pushed again. + +The Lab 18 flake pins nixpkgs by Git revision and NAR hash. That locks Python 3.14, package definitions, build hooks, and `dockerTools`. Helm still remains useful after the image exists, but the flake is the stronger build provenance layer. + +## Final State + +- `uv sync`, `flake8`, and `pytest` pass on Python 3.14. +- `nix flake check` passes from `nix/`. +- Repeated `nix build .#default` from `nix/` returns the same store path. +- The Nix-built app responds healthy on `127.0.0.1:5018`. +- Both the uv Docker image and the Nix Docker image respond healthy. +- The Nix image uses deterministic image metadata; the traditional Docker image records the build time. diff --git a/nix/devshell.nix b/nix/devshell.nix new file mode 100644 index 0000000000..4c5c555f5f --- /dev/null +++ b/nix/devshell.nix @@ -0,0 +1,20 @@ +{ + mkShell, + python314, + uv, + curl, + docker, + jq, +}: + +mkShell { + packages = [ + python314 + uv + curl + docker + jq + ]; + + UV_PYTHON = "${python314}/bin/python3.14"; +} diff --git a/nix/docker.nix b/nix/docker.nix new file mode 100644 index 0000000000..415ecad132 --- /dev/null +++ b/nix/docker.nix @@ -0,0 +1,34 @@ +{ + dockerTools, + appPackage, +}: + +dockerTools.buildLayeredImage { + name = "devops-info-service-nix"; + tag = "lab18"; + created = "1970-01-01T00:00:01Z"; + + contents = [ + appPackage + dockerTools.fakeNss + ]; + + extraCommands = '' + mkdir -p data tmp + chmod 0777 data tmp + ''; + + config = { + Cmd = [ "${appPackage}/bin/devops-info-service" ]; + Env = [ + "HOST=0.0.0.0" + "PORT=5000" + "PYTHONDONTWRITEBYTECODE=1" + ]; + ExposedPorts = { + "5000/tcp" = { }; + }; + WorkingDir = "/"; + User = "65534:65534"; + }; +} diff --git a/nix/flake.lock b/nix/flake.lock new file mode 100644 index 0000000000..986f6955db --- /dev/null +++ b/nix/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1778430510, + "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nix/flake.nix b/nix/flake.nix new file mode 100644 index 0000000000..7ea1519947 --- /dev/null +++ b/nix/flake.nix @@ -0,0 +1,70 @@ +{ + description = "DevOps Core reproducible builds"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + }; + + outputs = + { self, nixpkgs }: + let + lib = nixpkgs.lib; + systems = [ "x86_64-linux" ]; + forAllSystems = lib.genAttrs systems; + pkgsFor = system: import nixpkgs { inherit system; }; + in + { + packages = forAllSystems ( + system: + let + pkgs = pkgsFor system; + appPackage = pkgs.callPackage ./package.nix { + appSrc = ../app_python; + }; + in + { + default = appPackage; + devops-info-service = appPackage; + dockerImage = pkgs.callPackage ./docker.nix { + inherit appPackage; + }; + } + ); + + apps = forAllSystems (system: { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/devops-info-service"; + }; + }); + + checks = forAllSystems (system: { + default = self.packages.${system}.default; + }); + + devShells = forAllSystems (system: { + default = (pkgsFor system).callPackage ./devshell.nix { }; + }); + + formatter = forAllSystems ( + system: + let + pkgs = pkgsFor system; + in + pkgs.writeShellApplication { + name = "format-nix"; + runtimeInputs = [ pkgs.nixfmt-rfc-style ]; + text = '' + if [ "$#" -eq 0 ]; then + if [ -f flake.nix ] && [ -f package.nix ]; then + set -- flake.nix *.nix + else + set -- nix/flake.nix nix/*.nix + fi + fi + exec nixfmt "$@" + ''; + } + ); + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000000..7f816cd1bc --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,133 @@ +{ + lib, + stdenvNoCC, + python314, + appSrc ? ../app_python, +}: + +let + python = python314.override { + packageOverrides = + pyFinal: pyPrev: + let + isRuntimeOnlyInput = + input: + let + name = input.name or input.pname or ""; + dropPatterns = [ + "astor" + "cryptography-vectors" + "flaky" + "freezegun" + "gevent" + "ipython" + "jedi" + "lxml" + "mypy" + "parso" + "pytest" + "sphinx" + "watchdog" + "xdist" + ]; + in + !(lib.any (pattern: lib.hasInfix pattern name) dropPatterns); + + withoutChecks = + pkg: + pkg.overridePythonAttrs (old: { + doCheck = false; + build-system = lib.filter isRuntimeOnlyInput (old.build-system or [ ]); + checkInputs = [ ]; + nativeCheckInputs = [ ]; + nativeBuildInputs = lib.filter isRuntimeOnlyInput (old.nativeBuildInputs or [ ]); + }); + + runtimePackage = + pkg: deps: + (withoutChecks pkg).overridePythonAttrs (_old: { + dependencies = deps; + propagatedBuildInputs = deps; + }); + in + { + blinker = runtimePackage pyPrev.blinker [ ]; + click = runtimePackage pyPrev.click [ ]; + flask = runtimePackage pyPrev.flask [ + pyFinal.blinker + pyFinal.click + pyFinal.itsdangerous + pyFinal.jinja2 + pyFinal.werkzeug + ]; + gunicorn = runtimePackage pyPrev.gunicorn [ pyFinal.packaging ]; + idna = runtimePackage pyPrev.idna [ ]; + itsdangerous = runtimePackage pyPrev.itsdangerous [ ]; + jinja2 = runtimePackage pyPrev.jinja2 [ pyFinal.markupsafe ]; + markupsafe = runtimePackage pyPrev.markupsafe [ ]; + packaging = runtimePackage pyPrev.packaging [ ]; + prometheus-client = runtimePackage pyPrev.prometheus-client [ ]; + werkzeug = runtimePackage pyPrev.werkzeug [ pyFinal.markupsafe ]; + }; + }; + + pythonEnv = python.withPackages (ps: [ + ps.flask + ps.gunicorn + ps.prometheus-client + ]); + + cleanAppSrc = lib.cleanSourceWith { + src = appSrc; + filter = + path: type: + let + rel = lib.removePrefix ((toString appSrc) + "/") (toString path); + in + rel == "README.md" + || rel == "gunicorn.conf.py" + || rel == "pyproject.toml" + || rel == "uv.lock" + || rel == "src" + || lib.hasPrefix "src/" rel; + }; +in +stdenvNoCC.mkDerivation { + pname = "devops-info-service"; + version = "1.12.0"; + + src = cleanAppSrc; + + dontBuild = true; + + installPhase = '' + runHook preInstall + + app_dir="$out/share/devops-info-service" + mkdir -p "$app_dir" "$out/bin" + cp -r src gunicorn.conf.py pyproject.toml uv.lock README.md "$app_dir/" + + cat > "$out/bin/devops-info-service" <=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