From c9219ff9192edb2b91d66f54e10d1fef919f5248 Mon Sep 17 00:00:00 2001 From: Gabriel Palmar Date: Tue, 13 Jan 2026 15:47:13 -0500 Subject: [PATCH] chore(readme): Updated readme file + Terraform fixes --- .github/dependabot.yml | 60 +- .github/workflows/build_push.yml | 144 ++-- .github/workflows/build_test.yml | 92 +- .github/workflows/hadolint.yml | 40 +- .github/workflows/kube-linter.yml | 48 +- .github/workflows/pylint.yml | 82 +- .github/workflows/sonarqube.yml | 98 +-- .github/workflows/terrascan.yml | 60 +- .gitignore | 12 +- Dockerfile | 52 +- README.md | 400 ++++++++- app/config.py | 54 +- app/main.py | 110 +-- app/opensense.py | 344 ++++---- app/readiness.py | 132 +-- app/storage.py | 134 +-- helm-chart/Chart.yaml | 12 +- helm-chart/NOTES.txt | 24 +- helm-chart/templates/_helpers.tpl | 44 +- helm-chart/templates/cronjob.yaml | 106 +-- helm-chart/templates/deployment.yaml | 290 +++---- helm-chart/templates/ingress.yaml | 40 +- helm-chart/templates/service.yaml | 70 +- helm-chart/values.yaml | 128 +-- kustomize/base/cronjob.yaml | 134 +-- kustomize/base/deployment.yaml | 316 +++---- kustomize/base/ingress.yaml | 42 +- kustomize/base/kustomization.yaml | 22 +- kustomize/base/service.yaml | 70 +- kustomize/overlays/prod/cronjob-patch.yaml | 12 +- kustomize/overlays/prod/deployment-patch.yaml | 22 +- kustomize/overlays/prod/ingress-patch.yaml | 32 +- kustomize/overlays/prod/kustomization.yaml | 22 +- .../overlays/staging/deployment-patch.yaml | 22 +- kustomize/overlays/staging/ingress-patch.yaml | 32 +- kustomize/overlays/staging/kustomization.yaml | 20 +- pytest.ini | 12 +- requirements.in | 8 +- requirements.txt | 748 ++++++++-------- terraform/.gitignore | 27 + terraform/README.md | 356 ++++++++ terraform/main.tf | 122 +++ terraform/modules/eks/main.tf | 89 ++ terraform/modules/eks/outputs.tf | 45 + terraform/modules/eks/variables.tf | 90 ++ terraform/modules/elasticache/main.tf | 177 ++++ terraform/modules/elasticache/outputs.tf | 34 + terraform/modules/elasticache/variables.tf | 161 ++++ terraform/modules/iam/main.tf | 95 ++ terraform/modules/iam/outputs.tf | 24 + terraform/modules/iam/variables.tf | 34 + terraform/modules/node-group/main.tf | 113 +++ terraform/modules/node-group/outputs.tf | 24 + terraform/modules/node-group/variables.tf | 108 +++ terraform/modules/s3/main.tf | 210 +++++ terraform/modules/s3/outputs.tf | 34 + terraform/modules/s3/variables.tf | 138 +++ terraform/modules/security-groups/main.tf | 128 +++ terraform/modules/security-groups/outputs.tf | 14 + .../modules/security-groups/variables.tf | 15 + terraform/modules/vpc/main.tf | 148 ++++ terraform/modules/vpc/outputs.tf | 29 + terraform/modules/vpc/variables.tf | 22 + terraform/outputs.tf | 119 +++ terraform/terraform.tfvars.example | 50 ++ terraform/variables.tf | 129 +++ tests/fixtures/vcr_cassettes/metrics.yaml | 214 ++--- tests/fixtures/vcr_cassettes/temperature.yaml | 64 +- tests/fixtures/vcr_cassettes/version.yaml | 64 +- tests/test_main.py | 172 ++-- tests/test_modules.py | 808 +++++++++--------- 71 files changed, 5407 insertions(+), 2540 deletions(-) create mode 100644 terraform/.gitignore create mode 100644 terraform/README.md create mode 100644 terraform/main.tf create mode 100644 terraform/modules/eks/main.tf create mode 100644 terraform/modules/eks/outputs.tf create mode 100644 terraform/modules/eks/variables.tf create mode 100644 terraform/modules/elasticache/main.tf create mode 100644 terraform/modules/elasticache/outputs.tf create mode 100644 terraform/modules/elasticache/variables.tf create mode 100644 terraform/modules/iam/main.tf create mode 100644 terraform/modules/iam/outputs.tf create mode 100644 terraform/modules/iam/variables.tf create mode 100644 terraform/modules/node-group/main.tf create mode 100644 terraform/modules/node-group/outputs.tf create mode 100644 terraform/modules/node-group/variables.tf create mode 100644 terraform/modules/s3/main.tf create mode 100644 terraform/modules/s3/outputs.tf create mode 100644 terraform/modules/s3/variables.tf create mode 100644 terraform/modules/security-groups/main.tf create mode 100644 terraform/modules/security-groups/outputs.tf create mode 100644 terraform/modules/security-groups/variables.tf create mode 100644 terraform/modules/vpc/main.tf create mode 100644 terraform/modules/vpc/outputs.tf create mode 100644 terraform/modules/vpc/variables.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/terraform.tfvars.example create mode 100644 terraform/variables.tf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1ffe30d..f13785a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,30 +1,30 @@ -version: 2 - -updates: - # Python dependencies - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 10 - ignore: - - dependency-name: "setuptools" - versions: ["*"] - reviewers: - - "GabrielPalmar" - - # Docker dependencies - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - reviewers: - - "GabrielPalmar" - - # GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - reviewers: - - "GabrielPalmar" +version: 2 + +updates: + # Python dependencies + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "setuptools" + versions: ["*"] + reviewers: + - "GabrielPalmar" + + # Docker dependencies + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + reviewers: + - "GabrielPalmar" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + reviewers: + - "GabrielPalmar" diff --git a/.github/workflows/build_push.yml b/.github/workflows/build_push.yml index 85f2ba2..9233274 100644 --- a/.github/workflows/build_push.yml +++ b/.github/workflows/build_push.yml @@ -1,73 +1,73 @@ -name: Build_Push - -on: - push: - branches: - - main - -permissions: - contents: read - packages: write - -jobs: - docker_build_test: - runs-on: ubuntu-24.04 - name: Docker_build and Helm_build - steps: - - uses: actions/checkout@v5 - - - name: Build the Docker image - run: | - docker build -t ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) . - docker tag ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) ghcr.io/gabrielpalmar/hivebox:latest - - - name: Push Docker image to GHCR - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - docker push ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) - docker push ghcr.io/gabrielpalmar/hivebox:latest - - # Get the SHA256 digest - DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) | cut -d'@' -f2) - if [ -z "$DIGEST" ]; then - echo "Failed to capture Docker digest" - exit 1 - fi - echo "Image digest: $DIGEST" - echo "DOCKER_DIGEST=$DIGEST" >> $GITHUB_ENV - - - name: Install Helm - run: | - curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 - chmod 700 get_helm.sh - ./get_helm.sh - if ! command -v helm version &> /dev/null; then - echo "Helm could not be installed" - exit 1 - fi - - - name: Build Helm Chart - run: | - VERSION=$(cat version.txt) - sed -i "s/^version:.*/version: $VERSION/" ./helm-chart/Chart.yaml - sed -i "s/^appVersion:.*/appVersion: \"$VERSION\"/" ./helm-chart/Chart.yaml - sed -i "s|hivebox: ghcr.io/gabrielpalmar/hivebox:.*|hivebox: ghcr.io/gabrielpalmar/hivebox:$VERSION@$DOCKER_DIGEST|" ./helm-chart/values.yaml - helm package ./helm-chart - - - name: Push Helm Chart to GHCR - run: | - CHART_FILE="hivebox-$(cat version.txt).tgz" - if [ ! -f "$CHART_FILE" ]; then - echo "Helm chart $CHART_FILE not found" - exit 1 - fi - echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin - helm push $CHART_FILE oci://ghcr.io/gabrielpalmar/hivebox-helm-charts - - - name: Add job summary - run: | - VERSION=$(cat version.txt) - echo "## Build Summary" >> $GITHUB_STEP_SUMMARY - echo "- Docker image: ghcr.io/gabrielpalmar/hivebox:$VERSION" >> $GITHUB_STEP_SUMMARY - echo "- Image digest: $DOCKER_DIGEST" >> $GITHUB_STEP_SUMMARY +name: Build_Push + +on: + push: + branches: + - main + +permissions: + contents: read + packages: write + +jobs: + docker_build_test: + runs-on: ubuntu-24.04 + name: Docker_build and Helm_build + steps: + - uses: actions/checkout@v5 + + - name: Build the Docker image + run: | + docker build -t ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) . + docker tag ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) ghcr.io/gabrielpalmar/hivebox:latest + + - name: Push Docker image to GHCR + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker push ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) + docker push ghcr.io/gabrielpalmar/hivebox:latest + + # Get the SHA256 digest + DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/gabrielpalmar/hivebox:$(cat version.txt) | cut -d'@' -f2) + if [ -z "$DIGEST" ]; then + echo "Failed to capture Docker digest" + exit 1 + fi + echo "Image digest: $DIGEST" + echo "DOCKER_DIGEST=$DIGEST" >> $GITHUB_ENV + + - name: Install Helm + run: | + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 + chmod 700 get_helm.sh + ./get_helm.sh + if ! command -v helm version &> /dev/null; then + echo "Helm could not be installed" + exit 1 + fi + + - name: Build Helm Chart + run: | + VERSION=$(cat version.txt) + sed -i "s/^version:.*/version: $VERSION/" ./helm-chart/Chart.yaml + sed -i "s/^appVersion:.*/appVersion: \"$VERSION\"/" ./helm-chart/Chart.yaml + sed -i "s|hivebox: ghcr.io/gabrielpalmar/hivebox:.*|hivebox: ghcr.io/gabrielpalmar/hivebox:$VERSION@$DOCKER_DIGEST|" ./helm-chart/values.yaml + helm package ./helm-chart + + - name: Push Helm Chart to GHCR + run: | + CHART_FILE="hivebox-$(cat version.txt).tgz" + if [ ! -f "$CHART_FILE" ]; then + echo "Helm chart $CHART_FILE not found" + exit 1 + fi + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin + helm push $CHART_FILE oci://ghcr.io/gabrielpalmar/hivebox-helm-charts + + - name: Add job summary + run: | + VERSION=$(cat version.txt) + echo "## Build Summary" >> $GITHUB_STEP_SUMMARY + echo "- Docker image: ghcr.io/gabrielpalmar/hivebox:$VERSION" >> $GITHUB_STEP_SUMMARY + echo "- Image digest: $DOCKER_DIGEST" >> $GITHUB_STEP_SUMMARY echo "- Helm chart: hivebox-$VERSION" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index e6408a9..2db0c65 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -1,47 +1,47 @@ -name: Build_Test - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - contents: read - -jobs: - docker_build_test: - runs-on: ubuntu-24.04 - name: Docker_build - steps: - - uses: actions/checkout@v5 - - name: Build the Docker image - run: docker build -t gabrielpalmar/hivebox:$(cat version.txt) . - - - name: Run Docker container - run: docker run -d -p 5000:5000 gabrielpalmar/hivebox:$(cat version.txt) - - - name: Set up Python for testing - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install requests - pip install vcrpy - - - name: Run tests - run: | - python tests/test_main.py - TEST_EXIT_CODE=$? - if [ $TEST_EXIT_CODE -ne 0 ]; then - echo "Tests failed!" - exit $TEST_EXIT_CODE - fi - - - name: Stop Docker container +name: Build_Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + docker_build_test: + runs-on: ubuntu-24.04 + name: Docker_build + steps: + - uses: actions/checkout@v5 + - name: Build the Docker image + run: docker build -t gabrielpalmar/hivebox:$(cat version.txt) . + + - name: Run Docker container + run: docker run -d -p 5000:5000 gabrielpalmar/hivebox:$(cat version.txt) + + - name: Set up Python for testing + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + pip install vcrpy + + - name: Run tests + run: | + python tests/test_main.py + TEST_EXIT_CODE=$? + if [ $TEST_EXIT_CODE -ne 0 ]; then + echo "Tests failed!" + exit $TEST_EXIT_CODE + fi + + - name: Stop Docker container run: docker stop $(docker ps -q) \ No newline at end of file diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index 9a6de39..cfc28d1 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -1,21 +1,21 @@ -name: Hadolint - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - contents: read - -jobs: - hadolint-linting: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v5 - - uses: hadolint/hadolint-action@v3.3.0 - with: +name: Hadolint + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + hadolint-linting: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: hadolint/hadolint-action@v3.3.0 + with: dockerfile: Dockerfile \ No newline at end of file diff --git a/.github/workflows/kube-linter.yml b/.github/workflows/kube-linter.yml index 0fd3bcf..61ed874 100644 --- a/.github/workflows/kube-linter.yml +++ b/.github/workflows/kube-linter.yml @@ -1,24 +1,24 @@ -name: Kube-Linter - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - contents: read - -jobs: - linting: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Scan repo with kube-linter - uses: stackrox/kube-linter-action@v1.0.4 - with: - directory: helm-chart +name: Kube-Linter + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + linting: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Scan repo with kube-linter + uses: stackrox/kube-linter-action@v1.0.4 + with: + directory: helm-chart diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index ca25b64..7c6fe5e 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,42 +1,42 @@ -name: Pylint - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] - steps: - - uses: actions/checkout@v5 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint - pip install flask - pip install requests - pip install vcrpy - pip install prometheus_client - pip install redis - pip install minio - - name: Analysing the code with pylint - run: | - # Set PYTHONPATH so pylint can find the app module - export PYTHONPATH="${PYTHONPATH}:$(pwd)" - pylint app/ --ignore=__pycache__ - env: +name: Pylint + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v5 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + pip install flask + pip install requests + pip install vcrpy + pip install prometheus_client + pip install redis + pip install minio + - name: Analysing the code with pylint + run: | + # Set PYTHONPATH so pylint can find the app module + export PYTHONPATH="${PYTHONPATH}:$(pwd)" + pylint app/ --ignore=__pycache__ + env: PYTHONPATH: ${{ github.workspace }} \ No newline at end of file diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index cc040cb..76244e8 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,50 +1,50 @@ -name: SonarQube analysis - -on: - workflow_dispatch: {} - -permissions: - pull-requests: read - contents: read - -jobs: - Analysis: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install coverage pytest vcrpy - - - name: Run tests with coverage - run: | - export PYTHONPATH="${GITHUB_WORKSPACE}:${PYTHONPATH}" - coverage run --source=app -m pytest tests/test_modules.py -v - coverage xml -o coverage.xml - coverage report --show-missing - working-directory: ${{ github.workspace }} - - - name: Analyze with SonarQube - uses: SonarSource/sonarqube-scan-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.projectKey=GabrielPalmar_HiveBox-Project - -Dsonar.organization=gabrielpalmar - -Dsonar.sources=app - -Dsonar.tests=tests - -Dsonar.python.coverage.reportPaths=coverage.xml +name: SonarQube analysis + +on: + workflow_dispatch: {} + +permissions: + pull-requests: read + contents: read + +jobs: + Analysis: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install coverage pytest vcrpy + + - name: Run tests with coverage + run: | + export PYTHONPATH="${GITHUB_WORKSPACE}:${PYTHONPATH}" + coverage run --source=app -m pytest tests/test_modules.py -v + coverage xml -o coverage.xml + coverage report --show-missing + working-directory: ${{ github.workspace }} + + - name: Analyze with SonarQube + uses: SonarSource/sonarqube-scan-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.projectKey=GabrielPalmar_HiveBox-Project + -Dsonar.organization=gabrielpalmar + -Dsonar.sources=app + -Dsonar.tests=tests + -Dsonar.python.coverage.reportPaths=coverage.xml -Dsonar.exclusions=**/fixtures/**,**/__pycache__/**,**/venv/**,**/.git/**,docker/**,k8s/**,scripts/** \ No newline at end of file diff --git a/.github/workflows/terrascan.yml b/.github/workflows/terrascan.yml index a3acc0f..5b8f458 100644 --- a/.github/workflows/terrascan.yml +++ b/.github/workflows/terrascan.yml @@ -1,31 +1,31 @@ -name: Terrascan IaC scanner - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - pull-requests: read - contents: read - -jobs: - Analysis: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Terrascan - uses: tenable/terrascan-action@v1.5.0 - with: - iac_type: k8s - iac_version: v1 - iac_dir: k8s - policy_type: k8s - verbose: true - skip_rules: AC_K8S_0080 +name: Terrascan IaC scanner + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + pull-requests: read + contents: read + +jobs: + Analysis: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Terrascan + uses: tenable/terrascan-action@v1.5.0 + with: + iac_type: k8s + iac_version: v1 + iac_dir: k8s + policy_type: k8s + verbose: true + skip_rules: AC_K8S_0080 only_warn: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 48cc42c..24fed09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -/env -/.venv -/__pycache__ -/out.txt -/tests/__pycache__ -/.pytest_cache +/env +/.venv +/__pycache__ +/out.txt +/tests/__pycache__ +/.pytest_cache /local \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 79c27cd..f2e35ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,27 @@ -FROM python:3.13.7-alpine@sha256:9ba6d8cbebf0fb6546ae71f2a1c14f6ffd2fdab83af7fa5669734ef30ad48844 - -RUN addgroup -S appgroup && adduser -S -G appgroup appuser - -WORKDIR /app - -COPY /app/ /app/app/ -COPY version.txt requirements.txt /app/ - -RUN pip install --no-cache-dir -r /app/requirements.txt --require-hashes && \ - chown -R appuser:appgroup /app - -ENV FLASK_APP=app.main.py:app \ - PYTHONUNBUFFERED=1 \ - REDIS_PORT=6379 \ - REDIS_DB=0 \ - CACHE_TTL=300 \ - MINIO_PORT=9000 \ - MINIO_ACCESS_KEY=minioadmin \ - MINIO_SECRET_KEY=minioadmin \ - REDIS_HOST=redis \ - MINIO_HOST=minio - -USER appuser - -ENTRYPOINT [ "flask" ] +FROM python:3.13.7-alpine@sha256:9ba6d8cbebf0fb6546ae71f2a1c14f6ffd2fdab83af7fa5669734ef30ad48844 + +RUN addgroup -S appgroup && adduser -S -G appgroup appuser + +WORKDIR /app + +COPY /app/ /app/app/ +COPY version.txt requirements.txt /app/ + +RUN pip install --no-cache-dir -r /app/requirements.txt --require-hashes && \ + chown -R appuser:appgroup /app + +ENV FLASK_APP=app.main.py:app \ + PYTHONUNBUFFERED=1 \ + REDIS_PORT=6379 \ + REDIS_DB=0 \ + CACHE_TTL=300 \ + MINIO_PORT=9000 \ + MINIO_ACCESS_KEY=minioadmin \ + MINIO_SECRET_KEY=minioadmin \ + REDIS_HOST=redis \ + MINIO_HOST=minio + +USER appuser + +ENTRYPOINT [ "flask" ] CMD [ "run", "--host=0.0.0.0" ] \ No newline at end of file diff --git a/README.md b/README.md index c556a8a..c1ba59f 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,367 @@ -# HiveBox Project - -## Introduction - -DevOps project that includes most used technologies in the industry. Focusing in the CI and CD tools and integrating them simultaneously to achieve topics as the following: - -- Software Production. -- Agile Planning. -- QA and Quality Gates. -- Code and Programming. -- Operating System. -- Docker Containers. -- Kubernetes and Cloud. -- Observability and Monitoring. -- Continuous Integration/Delivery/Deployment. -- Automation and Infrastructure as Code. - -For more information please refer to the Project webpage: [HiveBox](https://devopsroadmap.io/projects/hivebox/) - -## Technologies Used -- Python (Flask, Prometheus, Redis, Requests) -- Docker -- Kubernetes (Minikube, Kind) -- Dependabot -- GitHub Actions: - - SonarQube - - Terrascan - - Pylint - - Hadolint - -## Development - -To adhere to industry standards, the Repository's Project section was used, leveraging the Kanban board to process tasks in an organized manner. - +# HiveBox Project + +![Version](https://img.shields.io/badge/version-0.7.1-blue) +![Python](https://img.shields.io/badge/python-3.13.7-blue) +![Kubernetes](https://img.shields.io/badge/kubernetes-1.31-blue) + +## Overview + +HiveBox is a production-grade Python Flask application that aggregates real-time temperature data from global sensors using the [OpenSenseMap](https://opensensemap.org/) API. The project demonstrates end-to-end DevOps practices with containerization, orchestration, and cloud deployment. + +For more information please refer to the Project webpage: [HiveBox](https://devopsroadmap.io/projects/hivebox/) + +### Application Features + +- **Temperature Data API**: Fetches and processes data from thousands of global temperature sensors. +- **Intelligent Caching**: Redis-based caching with 5-minute TTL to optimize API performance. +- **Object Storage**: MinIO (S3-compatible) for persistent temperature data storage with automated CronJob uploads every 5 minutes. +- **Observability**: Prometheus metrics exposure for monitoring and alerting. +- **Health Probes**: Kubernetes-ready readiness and liveness endpoints with sensor availability checks. + +### Technology Stack + +- **[Application](./app/)**: Python with Flask framework. + - `main.py`: API endpoints (/version, /temperature, /metrics, /store, /readyz). + - `opensense.py`: OpenSenseMap API integration with streaming support. + - `storage.py`: MinIO client for object storage operations. + - `config.py`: Redis client configuration. + - `readiness.py`: Sophisticated health check logic. + +- **[Containerization](./Dockerfile)**: Security-hardened Alpine Linux images. + - Multi-stage Docker builds with Python 3.13.7-alpine base. + - Non-root user execution (UID 1000). + - Pinned dependencies with hash verification in [requirements.txt](./requirements.txt). + - Read-only filesystem with dropped capabilities. + +- **Orchestration**: Kubernetes 1.31 with local development support. + - **[Helm Charts](./helm-chart/)**: Package management with OCI registry support. + - **[Kustomize](./kustomize/)**: Environment-specific configurations (staging/prod). + - **Local Testing**: Minikube and Kind compatibility. + +- **[Infrastructure as Code](./terraform/)**: AWS EKS deployment with Terraform. + - VPC with public/private subnets across multiple availability zones. + - EKS cluster v1.31 with managed node groups. + - Modular design: VPC, EKS, IAM, Security Groups, Node Groups. + - Optional ElastiCache and S3 modules. + +- **CI/CD Pipeline**: GitHub Actions with comprehensive quality gates. + - **Build & Test**: Automated Docker builds with integration testing. + - **Code Quality**: SonarQube analysis with coverage reporting. + - **Linting**: Pylint (Python), Hadolint (Docker), Kube-Linter (Kubernetes). + - **Security Scanning**: Terrascan for IaC security analysis. + - **Artifact Publishing**: Automatic image and Helm chart publishing to GHCR. + - **Dependency Management**: Dependabot for automated security updates. + +### Architecture Components + +The application runs as a distributed system with three main services: + +1. **HiveBox App** (2 replicas): Flask application with anti-affinity rules for high availability. +2. **Redis/Valkey** (1 replica): In-memory cache for API response optimization. +3. **MinIO** (1 replica): Object storage for historical temperature data. +4. **CronJob**: Periodic data storage trigger (every 5 minutes). + +## Architecture Diagram + +```mermaid +graph TB + subgraph K8S["Kubernetes Cluster - EKS"] + INGRESS[Ingress nginx
hivebox.local] + + SERVICE[Service: hivebox-service
ClusterIP:80 → Pod:5000] + + subgraph PODS["HiveBox Application - Anti-Affinity"] + POD1[HiveBox Pod 1
CPU: 250m
Mem: 256Mi] + POD2[HiveBox Pod 2
CPU: 250m
Mem: 256Mi] + end + + REDIS[Redis/Valkey
Cache TTL: 5min
CPU: 100m
Mem: 128Mi] + + MINIO[MinIO S3 Storage
Bucket: hivebox
CPU: 100m
Mem: 128Mi] + + CRON[CronJob
Schedule: */5 * * * *
curl /store
CPU: 10m
Mem: 16Mi] + + INGRESS --> SERVICE + SERVICE --> POD1 + SERVICE --> POD2 + + POD1 -.Anti-Affinity.-> POD2 + + POD1 --> REDIS + POD2 --> REDIS + + POD1 --> MINIO + POD2 --> MINIO + + CRON --> POD1 + end +``` + +## API Endpoints + +| Endpoint | Method | Description | Response | +|----------|--------|-------------|----------| +| `/version` | GET | Returns current application version | `Current app version: 0.7.1` | +| `/temperature` | GET | Fetches average global temperature from cached/live data | `Average temperature: XX.XX°C` + Pod IP | +| `/metrics` | GET | Prometheus metrics in text exposition format | Prometheus metrics data | +| `/store` | GET | Uploads current temperature data to MinIO S3 bucket | Storage confirmation message | +| `/readyz` | GET | Kubernetes readiness probe - checks sensor availability & cache status | `{"status": "ready"}` (200) or `{"status": "not ready"}` (503) | + +### Readiness Probe Logic + +The `/readyz` endpoint implements sophisticated health checking: +- **Sensor Check**: Validates >50% of sensors are reachable from OpenSenseMap API. +- **Cache Check**: Verifies Redis cache freshness (5-minute TTL). +- **Combined Logic**: Returns unhealthy (503) only when BOTH checks fail. +- **Use Case**: Kubernetes uses this for traffic routing decisions. + +## Quick Start + +### Prerequisites + +- **Docker**: v20.10+. +- **Kubernetes**: v1.31+ (Minikube or Kind for local development). +- **Helm**: v3.0+. +- **kubectl**: Configured and connected to cluster. +- **(Optional) Terraform**: v1.0+ for AWS EKS deployment. + +### Local Development with Docker + +```bash +# Clone the repository +git clone https://github.com/GabrielPalmar/HiveBox-Project.git +cd HiveBox-Project + +# Build the Docker image +docker build -t hivebox:0.7.1 . + +# Run the application +docker run -d -p 5000:5000 hivebox:0.7.1 + +# Test the endpoints +curl http://localhost:5000/version +curl http://localhost:5000/temperature +curl http://localhost:5000/metrics +``` + +### Kubernetes Deployment with Helm + +#### Using Helm from GHCR (Recommended) + +```bash +# Install from GitHub Container Registry +helm install hivebox oci://ghcr.io/gabrielpalmar/hivebox-helm-charts/hivebox --version 0.7.1 + +# Access the application (if using Minikube) +minikube service hivebox-service + +# Or via Ingress (add to /etc/hosts: hivebox.local) +curl http://hivebox.local/temperature +``` + +#### Using Helm from Local Chart + +```bash +# Install from local chart +helm install hivebox ./helm-chart + +# Verify deployment +kubectl get pods -l app=hivebox +kubectl get svc hivebox-service + +# Port forward for local access +kubectl port-forward svc/hivebox-service 8080:80 + +# Test the application +curl http://localhost:8080/temperature +``` + +#### Helm Configuration + +Key values in [helm-chart/values.yaml](./helm-chart/values.yaml): + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `replicas.hivebox` | 2 | Number of HiveBox pod replicas | +| `images.hivebox` | ghcr.io/gabrielpalmar/hivebox:latest | HiveBox container image with SHA digest | +| `resources.hivebox.requests.cpu` | 250m | CPU request per pod | +| `resources.hivebox.requests.memory` | 256Mi | Memory request per pod | +| `ingress.enabled` | true | Enable/disable Ingress | +| `ingress.host` | hivebox.local | Ingress hostname | + +### Kubernetes Deployment with Kustomize + +```bash +# Deploy to staging environment +kubectl apply -k kustomize/overlays/staging + +# Deploy to production environment +kubectl apply -k kustomize/overlays/prod + +# Verify deployment +kubectl get deployments,pods,services,ingress -n +``` + +### AWS EKS Deployment with Terraform + +For detailed infrastructure deployment instructions, see [terraform/README.md](./terraform/README.md). + +```bash +cd terraform + +# Initialize Terraform +terraform init + +# Review the execution plan +terraform plan + +# Deploy infrastructure (EKS cluster, VPC, IAM, etc.) +terraform apply + +# Configure kubectl +aws eks update-kubeconfig --region --name + +# Deploy application using Helm +helm install hivebox oci://ghcr.io/gabrielpalmar/hivebox-helm-charts/hivebox --version 0.7.1 +``` + +## CI/CD Pipeline + +The project uses GitHub Actions for comprehensive CI/CD automation with multiple quality gates: + +### Workflows + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| [Build & Test](.github/workflows/build_test.yml) | Push/PR to `main` | Builds Docker image, runs container, executes integration tests | +| [Build & Push](.github/workflows/build_push.yml) | Push to `main` | Builds and pushes Docker image + Helm chart to GHCR with SHA256 digest | +| [Pylint](.github/workflows/pylint.yml) | Push/PR to `main` | Python code quality and standards enforcement | +| [SonarQube](.github/workflows/sonarqube.yml) | Manual | Code quality analysis: coverage, vulnerabilities, code smells | +| [Hadolint](.github/workflows/hadolint.yml) | Push/PR to `main` | Dockerfile best practices and security validation | +| [Terrascan](.github/workflows/terrascan.yml) | Push/PR to `main` | Terraform IaC security scanning and compliance | +| [Kube-Linter](.github/workflows/kube-linter.yml) | Push/PR to `main` | Kubernetes manifest validation for security and reliability | + +### Dependency Management + +**Dependabot** ([.github/dependabot.yml](.github/dependabot.yml)) automatically updates: +- **Python dependencies**: Weekly (pip) +- **Docker base images**: Weekly +- **GitHub Actions**: Weekly + +### Quality Gates Summary + +``` +┌────────────────┐ +│ Code Push │ +└───────┬────────┘ + │ + ├─────► Pylint ────────┐ + ├─────► Hadolint ──────┤ + ├─────► Kube-Linter ───┤ + ├─────► Terrascan ─────┤── All Pass? ──┐ + ├─────► Build & Test ──┤ │ + └─────► SonarQube ─────┘ │ + ▼ + ┌───────────────┐ + │ Build & Push │ + │ to GHCR │ + └───────────────┘ + │ + ▼ + ┌───────────────┐ + │ Helm Chart │ + │ Packaged │ + └───────────────┘ +``` + +## Configuration + +### Environment Variables + +Set in [Dockerfile](./Dockerfile) and configurable in Kubernetes: + +| Variable | Default | Description | +|----------|---------|-------------| +| `FLASK_APP` | app.main:app | Flask application entry point | +| `PYTHONUNBUFFERED` | 1 | Disable Python output buffering | +| `REDIS_HOST` | redis | Redis service hostname | +| `REDIS_PORT` | 6379 | Redis service port | +| `REDIS_DB` | 0 | Redis database number | +| `CACHE_TTL` | 300 | Cache time-to-live (5 minutes) | +| `MINIO_HOST` | minio | MinIO service hostname | +| `MINIO_PORT` | 9000 | MinIO service port | +| `MINIO_ACCESS_KEY` | minioadmin | MinIO access credentials | +| `MINIO_SECRET_KEY` | minioadmin | MinIO secret credentials | + +### Security Configuration + +All containers run with hardened security contexts: + +```yaml +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault +``` + +## Testing + +### Run Integration Tests + +```bash +# Install test dependencies +pip install -r requirements.txt +pip install vcrpy + +# Run integration tests with VCR cassettes +python tests/test_main.py + +# Run unit tests +python tests/test_modules.py +``` + +### Test Coverage + +- Integration tests: API endpoint validation with mocked responses. +- Unit tests: Module-level functionality testing. +- VCR cassettes: Record/replay HTTP interactions for reproducible tests. +- CI pipeline: Automated testing on every PR/push. + +## Monitoring & Observability + +### Prometheus Metrics + +Access metrics at `/metrics` endpoint: + +```bash +curl http://hivebox.local/metrics +``` + +**Available metrics**: +- HTTP request counters. +- Response time histograms. +- Temperature data metrics. +- Cache hit/miss ratios. + +### Health Checks + +**Liveness Probe**: `/version` +- Simple endpoint to verify pod is alive. +- Checks: 60s interval, 3 failures trigger restart. + +**Readiness Probe**: `/readyz` +- Complex health check with sensor + cache validation. +- Checks: 30s delay, 600s interval. +- Removes pod from service if unhealthy. + +## Development + +To adhere to industry standards, the Repository's Project section was used, leveraging the Kanban board to process tasks in an organized manner. + [Kanban Board](https://github.com/users/GabrielPalmar/projects/1) \ No newline at end of file diff --git a/app/config.py b/app/config.py index 5f97eda..b70d662 100644 --- a/app/config.py +++ b/app/config.py @@ -1,27 +1,27 @@ -'''Shared configuration module''' -import os -import redis - -# Redis configuration -REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') -REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) -REDIS_DB = int(os.environ.get('REDIS_DB', 0)) -CACHE_TTL = int(os.environ.get('CACHE_TTL', 300)) - -def create_redis_client(): - '''Create and return Redis client with error handling''' - try: - redis_client = redis.StrictRedis( - host=REDIS_HOST, - port=REDIS_PORT, - db=REDIS_DB, - decode_responses=True, - socket_connect_timeout=240, - socket_timeout=240 - ) - redis_client.ping() - print("Connected to Redis successfully!") - return redis_client, True - except (redis.ConnectionError, redis.TimeoutError) as e: - print(f"Could not connect to Redis: {e}") - return None, False +'''Shared configuration module''' +import os +import redis + +# Redis configuration +REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') +REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) +REDIS_DB = int(os.environ.get('REDIS_DB', 0)) +CACHE_TTL = int(os.environ.get('CACHE_TTL', 300)) + +def create_redis_client(): + '''Create and return Redis client with error handling''' + try: + redis_client = redis.StrictRedis( + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB, + decode_responses=True, + socket_connect_timeout=240, + socket_timeout=240 + ) + redis_client.ping() + print("Connected to Redis successfully!") + return redis_client, True + except (redis.ConnectionError, redis.TimeoutError) as e: + print(f"Could not connect to Redis: {e}") + return None, False diff --git a/app/main.py b/app/main.py index 7d514ff..28ad0c3 100644 --- a/app/main.py +++ b/app/main.py @@ -1,55 +1,55 @@ -'''Module containing the main function of the app.''' -import os -import socket -from flask import Flask, Response -from prometheus_client import generate_latest, CONTENT_TYPE_LATEST -from app import opensense -from app import storage -from app import readiness - -app = Flask(__name__) - -HOSTNAME = socket.gethostname() -IPADDR = socket.gethostbyname(HOSTNAME) - -@app.route('/version') -def print_version(): - '''Function printing the current version of the app.''' - version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'version.txt') - - with open(version_file, 'r', encoding="utf-8") as f: - version = f.read() - - return f"Current app version: {version}\n" - -@app.route('/temperature') -def get_temperature(): - '''Function to get the current temperature.''' - result, _ = opensense.get_temperature() - return result + f"From: {IPADDR}\n" - -@app.route('/metrics') -def metrics(): - '''Function to return Prometheus metrics.''' - return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST) - -@app.route('/store') -def store(): - '''Function to store results in MinIO.''' - return storage.store_temperature_data() - -@app.route('/readyz') -def readyz(): - '''Readiness probe endpoint''' - status_code = readiness.readiness_check() - - if status_code == 200: - return {"status": "ready"}, 200 - - return { - "status": "not ready", - "error": "More than 50% of sensors unreachable and cache expired" - }, 503 - -if __name__ == "__main__": - app.run() +'''Module containing the main function of the app.''' +import os +import socket +from flask import Flask, Response +from prometheus_client import generate_latest, CONTENT_TYPE_LATEST +from app import opensense +from app import storage +from app import readiness + +app = Flask(__name__) + +HOSTNAME = socket.gethostname() +IPADDR = socket.gethostbyname(HOSTNAME) + +@app.route('/version') +def print_version(): + '''Function printing the current version of the app.''' + version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'version.txt') + + with open(version_file, 'r', encoding="utf-8") as f: + version = f.read() + + return f"Current app version: {version}\n" + +@app.route('/temperature') +def get_temperature(): + '''Function to get the current temperature.''' + result, _ = opensense.get_temperature() + return result + f"From: {IPADDR}\n" + +@app.route('/metrics') +def metrics(): + '''Function to return Prometheus metrics.''' + return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST) + +@app.route('/store') +def store(): + '''Function to store results in MinIO.''' + return storage.store_temperature_data() + +@app.route('/readyz') +def readyz(): + '''Readiness probe endpoint''' + status_code = readiness.readiness_check() + + if status_code == 200: + return {"status": "ready"}, 200 + + return { + "status": "not ready", + "error": "More than 50% of sensors unreachable and cache expired" + }, 503 + +if __name__ == "__main__": + app.run() diff --git a/app/opensense.py b/app/opensense.py index fbf322c..930240f 100644 --- a/app/opensense.py +++ b/app/opensense.py @@ -1,172 +1,172 @@ -'''Module to get entries from OpenSenseMap API and get the average temperature''' -# pylint: disable=too-many-locals,too-many-branches,too-many-statements -from datetime import datetime, timezone, timedelta -import json -import requests -import redis -from app.config import create_redis_client, CACHE_TTL - -# Use shared Redis client -redis_client, REDIS_AVAILABLE = create_redis_client() - -_sensor_stats = {"total_sensors": 0, "null_count": 0} - -def classify_temperature(average): - '''Classify temperature based on ranges using dictionary approach''' - # Define temperature ranges and their classifications - temp_classifications = { - "cold": (float('-inf'), 10, "Warning: Too cold"), - "good": (10, 36, "Good"), - "hot": (36, float('inf'), "Warning: Too hot") - } - - # Find the appropriate classification - for _, (min_temp, max_temp, status) in temp_classifications.items(): - if min_temp < average <= max_temp: - return status - - return "Unknown" # Default case - -def _parse_partial_json_array(text: str): - """Parse as many full objects as possible from a (possibly truncated) JSON array.""" - decoder = json.JSONDecoder() - items = [] - i = text.find('[') - if i == -1: - return items - i += 1 # past '[' - n = len(text) - while i < n: - while i < n and text[i].isspace(): - i += 1 - if i >= n or text[i] == ']': - break - try: - obj, end = decoder.raw_decode(text, i) - except json.JSONDecodeError: - # truncated object at the end; stop with what we have - break - items.append(obj) - i = end - while i < n and text[i].isspace(): - i += 1 - if i < n and text[i] == ',': - i += 1 - return items - -def get_temperature(): - '''Function to get the average temperature from OpenSenseMap API.''' - if REDIS_AVAILABLE: - try: - cached_data = redis_client.get("temperature_data") - if cached_data: - print("Using cached data from Redis.") - cached_result = cached_data - default_stats = {"total_sensors": 0, "null_count": 0} - return cached_result, default_stats - except redis.RedisError as e: - print(f"Redis error: {e}. Proceeding without cache.") - - print("Fetching new data from OpenSenseMap API...") - - # Ensuring that data is not older than 1 hour. - time_iso = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace("+00:00", "Z") - - params = { - "date": time_iso, - "format": "json" - } - - # Streaming configuration - max_mb = 0.5 - max_bytes = int(max_mb * 1024 * 1024) - - print('Getting data from OpenSenseMap API...') - - try: - # Stream the response and count bytes - response = requests.get( - "https://api.opensensemap.org/boxes", - params=params, - stream=True, - timeout=(180, 60) - ) - response.raise_for_status() - - downloaded = 0 - chunks = [] - truncated = False - - for chunk in response.iter_content(chunk_size=64 * 1024): # 64 KB - if not chunk: - break - chunks.append(chunk) - downloaded += len(chunk) - if downloaded >= max_bytes: - print(f"Reached {max_mb} MB limit ({downloaded:,} bytes), stopping download") - truncated = True - response.close() - break - - print(f'Bytes downloaded: {downloaded:,}') - print('Data retrieved successfully!' + (" (partial)" if truncated else "")) - - # Build body and parse JSON - body = b"".join(chunks) - text = body.decode(response.encoding or "utf-8", errors="replace") - - try: - data = json.loads(text) - except json.JSONDecodeError: - if not truncated: - print("Warning: Unexpected JSON parse error. Trying partial parse.") - data = _parse_partial_json_array(text) - if not data: - return "Error: Failed to parse JSON and no partial objects found\n", { - "total_sensors": 0, - "null_count": 0 - } - - except requests.Timeout: - print("API request timed out") - return "Error: API request timed out\n", {"total_sensors": 0, "null_count": 0} - except requests.RequestException as e: - print(f"API request failed: {e}") - return f"Error: API request failed - {e}\n", {"total_sensors": 0, "null_count": 0} - - # Process the data (keeping the existing logic) - _sensor_stats["total_sensors"] = sum(1 for d in data if isinstance(d, dict) and "sensors" in d) - res = [d.get('sensors') for d in data if isinstance(d, dict) and 'sensors' in d] - - temp_list = [] - _sensor_stats["null_count"] = 0 - - for sensor_list in res: - for measure in sensor_list: - if measure.get('unit') == "°C" and 'lastMeasurement' in measure: - last = measure['lastMeasurement'] - if last is not None and isinstance(last, dict) and 'value' in last: - try: - temp_list.append(float(last['value'])) - except (TypeError, ValueError): - _sensor_stats["null_count"] += 1 - else: - _sensor_stats["null_count"] += 1 - - average = sum(temp_list) / len(temp_list) if temp_list else 0.0 - - if not temp_list: - print("Warning: No valid temperature readings found") - - # Use the dictionary-based classification - status = classify_temperature(average) - result = f'Average temperature: {average:.2f} °C ({status})\n' - - if REDIS_AVAILABLE: - try: - redis_client.setex("temperature_data", CACHE_TTL, result) - print("Data cached in Redis.") - except redis.RedisError as e: - print(f"Redis error while caching data: {e}") - - return result, _sensor_stats +'''Module to get entries from OpenSenseMap API and get the average temperature''' +# pylint: disable=too-many-locals,too-many-branches,too-many-statements +from datetime import datetime, timezone, timedelta +import json +import requests +import redis +from app.config import create_redis_client, CACHE_TTL + +# Use shared Redis client +redis_client, REDIS_AVAILABLE = create_redis_client() + +_sensor_stats = {"total_sensors": 0, "null_count": 0} + +def classify_temperature(average): + '''Classify temperature based on ranges using dictionary approach''' + # Define temperature ranges and their classifications + temp_classifications = { + "cold": (float('-inf'), 10, "Warning: Too cold"), + "good": (10, 36, "Good"), + "hot": (36, float('inf'), "Warning: Too hot") + } + + # Find the appropriate classification + for _, (min_temp, max_temp, status) in temp_classifications.items(): + if min_temp < average <= max_temp: + return status + + return "Unknown" # Default case + +def _parse_partial_json_array(text: str): + """Parse as many full objects as possible from a (possibly truncated) JSON array.""" + decoder = json.JSONDecoder() + items = [] + i = text.find('[') + if i == -1: + return items + i += 1 # past '[' + n = len(text) + while i < n: + while i < n and text[i].isspace(): + i += 1 + if i >= n or text[i] == ']': + break + try: + obj, end = decoder.raw_decode(text, i) + except json.JSONDecodeError: + # truncated object at the end; stop with what we have + break + items.append(obj) + i = end + while i < n and text[i].isspace(): + i += 1 + if i < n and text[i] == ',': + i += 1 + return items + +def get_temperature(): + '''Function to get the average temperature from OpenSenseMap API.''' + if REDIS_AVAILABLE: + try: + cached_data = redis_client.get("temperature_data") + if cached_data: + print("Using cached data from Redis.") + cached_result = cached_data + default_stats = {"total_sensors": 0, "null_count": 0} + return cached_result, default_stats + except redis.RedisError as e: + print(f"Redis error: {e}. Proceeding without cache.") + + print("Fetching new data from OpenSenseMap API...") + + # Ensuring that data is not older than 1 hour. + time_iso = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace("+00:00", "Z") + + params = { + "date": time_iso, + "format": "json" + } + + # Streaming configuration + max_mb = 0.5 + max_bytes = int(max_mb * 1024 * 1024) + + print('Getting data from OpenSenseMap API...') + + try: + # Stream the response and count bytes + response = requests.get( + "https://api.opensensemap.org/boxes", + params=params, + stream=True, + timeout=(180, 60) + ) + response.raise_for_status() + + downloaded = 0 + chunks = [] + truncated = False + + for chunk in response.iter_content(chunk_size=64 * 1024): # 64 KB + if not chunk: + break + chunks.append(chunk) + downloaded += len(chunk) + if downloaded >= max_bytes: + print(f"Reached {max_mb} MB limit ({downloaded:,} bytes), stopping download") + truncated = True + response.close() + break + + print(f'Bytes downloaded: {downloaded:,}') + print('Data retrieved successfully!' + (" (partial)" if truncated else "")) + + # Build body and parse JSON + body = b"".join(chunks) + text = body.decode(response.encoding or "utf-8", errors="replace") + + try: + data = json.loads(text) + except json.JSONDecodeError: + if not truncated: + print("Warning: Unexpected JSON parse error. Trying partial parse.") + data = _parse_partial_json_array(text) + if not data: + return "Error: Failed to parse JSON and no partial objects found\n", { + "total_sensors": 0, + "null_count": 0 + } + + except requests.Timeout: + print("API request timed out") + return "Error: API request timed out\n", {"total_sensors": 0, "null_count": 0} + except requests.RequestException as e: + print(f"API request failed: {e}") + return f"Error: API request failed - {e}\n", {"total_sensors": 0, "null_count": 0} + + # Process the data (keeping the existing logic) + _sensor_stats["total_sensors"] = sum(1 for d in data if isinstance(d, dict) and "sensors" in d) + res = [d.get('sensors') for d in data if isinstance(d, dict) and 'sensors' in d] + + temp_list = [] + _sensor_stats["null_count"] = 0 + + for sensor_list in res: + for measure in sensor_list: + if measure.get('unit') == "°C" and 'lastMeasurement' in measure: + last = measure['lastMeasurement'] + if last is not None and isinstance(last, dict) and 'value' in last: + try: + temp_list.append(float(last['value'])) + except (TypeError, ValueError): + _sensor_stats["null_count"] += 1 + else: + _sensor_stats["null_count"] += 1 + + average = sum(temp_list) / len(temp_list) if temp_list else 0.0 + + if not temp_list: + print("Warning: No valid temperature readings found") + + # Use the dictionary-based classification + status = classify_temperature(average) + result = f'Average temperature: {average:.2f} °C ({status})\n' + + if REDIS_AVAILABLE: + try: + redis_client.setex("temperature_data", CACHE_TTL, result) + print("Data cached in Redis.") + except redis.RedisError as e: + print(f"Redis error while caching data: {e}") + + return result, _sensor_stats diff --git a/app/readiness.py b/app/readiness.py index d638608..2a6088f 100644 --- a/app/readiness.py +++ b/app/readiness.py @@ -1,66 +1,66 @@ -'''Module to check the readiness of the stored information''' -import json -import requests -import redis -from app.opensense import get_temperature -from app.config import create_redis_client - -redis_client, REDIS_AVAILABLE = create_redis_client() - -def check_caching(): - '''Check if caching content is older than 5 minutes''' - if not REDIS_AVAILABLE: - return True - - try: - cache_key = "temperature_data" - ttl = redis_client.ttl(cache_key) - - if ttl in (-2, -1): - return True - - return False - except redis.RedisError as e: - print(f"Redis error while checking cache: {e}") - return True - -def reachable_boxes(): - '''Check if more than 50% of sensor boxes are reachable''' - try: - _, sensor_stats = get_temperature() - total_boxes = sensor_stats.get('total_sensors', 0) - unreachable = sensor_stats.get('null_count', 0) - - # No sensors configured => treat as healthy - if total_boxes == 0: - return 200 - - percentage_unreachable = (unreachable / total_boxes) * 100 - - # Fail only if strictly more than 50% are unreachable - if percentage_unreachable > 50: - return 400 - return 200 - - except (json.JSONDecodeError, requests.exceptions.RequestException, redis.RedisError) as e: - print(f"Error checking reachable boxes: {e}") - return 200 - except (ValueError, TypeError, KeyError) as e: - print(f"Data error checking reachable boxes: {e}") - return 400 - -def readiness_check(): - '''Combined readiness check for the /readyz endpoint''' - try: - boxes_status = reachable_boxes() - cache_is_old = check_caching() - - # Only fail if BOTH conditions are bad - if boxes_status == 400 and cache_is_old: - return 503 - - return 200 - except redis.RedisError as e: - # If Redis is completely unavailable, still allow the service to be ready - print(f"Redis error during readiness check: {e}") - return 200 +'''Module to check the readiness of the stored information''' +import json +import requests +import redis +from app.opensense import get_temperature +from app.config import create_redis_client + +redis_client, REDIS_AVAILABLE = create_redis_client() + +def check_caching(): + '''Check if caching content is older than 5 minutes''' + if not REDIS_AVAILABLE: + return True + + try: + cache_key = "temperature_data" + ttl = redis_client.ttl(cache_key) + + if ttl in (-2, -1): + return True + + return False + except redis.RedisError as e: + print(f"Redis error while checking cache: {e}") + return True + +def reachable_boxes(): + '''Check if more than 50% of sensor boxes are reachable''' + try: + _, sensor_stats = get_temperature() + total_boxes = sensor_stats.get('total_sensors', 0) + unreachable = sensor_stats.get('null_count', 0) + + # No sensors configured => treat as healthy + if total_boxes == 0: + return 200 + + percentage_unreachable = (unreachable / total_boxes) * 100 + + # Fail only if strictly more than 50% are unreachable + if percentage_unreachable > 50: + return 400 + return 200 + + except (json.JSONDecodeError, requests.exceptions.RequestException, redis.RedisError) as e: + print(f"Error checking reachable boxes: {e}") + return 200 + except (ValueError, TypeError, KeyError) as e: + print(f"Data error checking reachable boxes: {e}") + return 400 + +def readiness_check(): + '''Combined readiness check for the /readyz endpoint''' + try: + boxes_status = reachable_boxes() + cache_is_old = check_caching() + + # Only fail if BOTH conditions are bad + if boxes_status == 400 and cache_is_old: + return 503 + + return 200 + except redis.RedisError as e: + # If Redis is completely unavailable, still allow the service to be ready + print(f"Redis error during readiness check: {e}") + return 200 diff --git a/app/storage.py b/app/storage.py index d93d60f..8708972 100644 --- a/app/storage.py +++ b/app/storage.py @@ -1,67 +1,67 @@ -'''This script uploads the output to a MinIO bucket.''' -import os -import io -import datetime -from minio import Minio -from minio.error import S3Error, InvalidResponseError -from app import opensense - -MINIO_HOST = os.getenv('MINIO_HOST', 'localhost') -MINIO_PORT = int(os.environ.get('MINIO_PORT', 9000)) -MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', 'minioadmin') -MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', 'minioadmin') - -def store_temperature_data(): - '''Function to upload temperature data to MinIO.''' - try: - client = Minio(f"{MINIO_HOST}:{MINIO_PORT}", - access_key=MINIO_ACCESS_KEY, - secret_key=MINIO_SECRET_KEY, - secure=False - ) - - # Check if the MinIO server is reachable - try: - client.list_buckets() - except ConnectionError as conn_exc: - error_msg = f"Cannot connect to MinIO server: {conn_exc}" - print(error_msg) - return error_msg - - bucket_name = "temperature-data" - destination_file = f"temperature_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S%f')}.txt" - - # Get the temperature data - unpack the tuple - temperature_result, _ = opensense.get_temperature() - - text_bytes = temperature_result.encode('utf-8') - text_stream = io.BytesIO(text_bytes) - - # Make the bucket if it doesn't exist. - found = client.bucket_exists(bucket_name) - if not found: - client.make_bucket(bucket_name) - print("Created bucket", bucket_name) - else: - print("Bucket", bucket_name, "already exists") - - # Upload the data - client.put_object( - bucket_name, - destination_file, - text_stream, - length=len(text_bytes), - content_type='text/plain' - ) - - return (f'Temperature data successfully uploaded as ' - f'{destination_file} to bucket {bucket_name}\n') - - except (S3Error, InvalidResponseError) as exc: - error_msg = f"MinIO S3 error occurred: {exc}" - print(error_msg) - return error_msg - -if __name__ == "__main__": - RESULT = store_temperature_data() - print(RESULT) +'''This script uploads the output to a MinIO bucket.''' +import os +import io +import datetime +from minio import Minio +from minio.error import S3Error, InvalidResponseError +from app import opensense + +MINIO_HOST = os.getenv('MINIO_HOST', 'localhost') +MINIO_PORT = int(os.environ.get('MINIO_PORT', 9000)) +MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', 'minioadmin') +MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', 'minioadmin') + +def store_temperature_data(): + '''Function to upload temperature data to MinIO.''' + try: + client = Minio(f"{MINIO_HOST}:{MINIO_PORT}", + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=False + ) + + # Check if the MinIO server is reachable + try: + client.list_buckets() + except ConnectionError as conn_exc: + error_msg = f"Cannot connect to MinIO server: {conn_exc}" + print(error_msg) + return error_msg + + bucket_name = "temperature-data" + destination_file = f"temperature_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S%f')}.txt" + + # Get the temperature data - unpack the tuple + temperature_result, _ = opensense.get_temperature() + + text_bytes = temperature_result.encode('utf-8') + text_stream = io.BytesIO(text_bytes) + + # Make the bucket if it doesn't exist. + found = client.bucket_exists(bucket_name) + if not found: + client.make_bucket(bucket_name) + print("Created bucket", bucket_name) + else: + print("Bucket", bucket_name, "already exists") + + # Upload the data + client.put_object( + bucket_name, + destination_file, + text_stream, + length=len(text_bytes), + content_type='text/plain' + ) + + return (f'Temperature data successfully uploaded as ' + f'{destination_file} to bucket {bucket_name}\n') + + except (S3Error, InvalidResponseError) as exc: + error_msg = f"MinIO S3 error occurred: {exc}" + print(error_msg) + return error_msg + +if __name__ == "__main__": + RESULT = store_temperature_data() + print(RESULT) diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index 5bab78e..80a405e 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -1,6 +1,6 @@ -apiVersion: v2 -name: hivebox -description: A Helm chart for HiveBox application -type: application -version: 0.1.0 -appVersion: "0.7.1" +apiVersion: v2 +name: hivebox +description: A Helm chart for HiveBox application +type: application +version: 0.1.0 +appVersion: "0.7.1" diff --git a/helm-chart/NOTES.txt b/helm-chart/NOTES.txt index 9bcfc68..f95cb6a 100644 --- a/helm-chart/NOTES.txt +++ b/helm-chart/NOTES.txt @@ -1,13 +1,13 @@ -# filepath: helm-chart/NOTES.txt -1. Get the application URL by running these commands: -{{- if contains "NodePort" .Values.service.type }} - export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services hivebox-service) - export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") - echo http://$NODE_IP:$NODE_PORT -{{- else if contains "ClusterIP" .Values.service.type }} - kubectl port-forward --namespace {{ .Release.Namespace }} svc/hivebox-service 8080:80 - echo "Visit http://127.0.0.1:8080 to use your application" -{{- end }} - -2. Check the application status: +# filepath: helm-chart/NOTES.txt +1. Get the application URL by running these commands: +{{- if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services hivebox-service) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "ClusterIP" .Values.service.type }} + kubectl port-forward --namespace {{ .Release.Namespace }} svc/hivebox-service 8080:80 + echo "Visit http://127.0.0.1:8080 to use your application" +{{- end }} + +2. Check the application status: kubectl get pods --namespace {{ .Release.Namespace }} -l "app=hivebox" \ No newline at end of file diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl index 2a8d0a5..d1020db 100644 --- a/helm-chart/templates/_helpers.tpl +++ b/helm-chart/templates/_helpers.tpl @@ -1,22 +1,22 @@ -{{/* Pod-level securityContext */}} -{{- define "common.podSecurityContext" -}} -{{- with .Values.podSecurityContext }} -{{ toYaml . }} -{{- end }} -{{- end }} - -{{/* Container-level securityContext */}} -{{- define "common.containerSecurityContext" -}} -{{- with .Values.containerSecurityContext }} -{{ toYaml . }} -{{- end }} -{{- end }} - -{{/* Resources per workload (hivebox, redis, minio, cronjob) */}} -{{- define "common.resources" -}} -{{- $vals := .Values -}} -{{- $name := .name -}} -{{- with (index $vals.resources $name) }} -{{ toYaml . }} -{{- end }} -{{- end }} +{{/* Pod-level securityContext */}} +{{- define "common.podSecurityContext" -}} +{{- with .Values.podSecurityContext }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{/* Container-level securityContext */}} +{{- define "common.containerSecurityContext" -}} +{{- with .Values.containerSecurityContext }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{/* Resources per workload (hivebox, redis, minio, cronjob) */}} +{{- define "common.resources" -}} +{{- $vals := .Values -}} +{{- $name := .name -}} +{{- with (index $vals.resources $name) }} +{{ toYaml . }} +{{- end }} +{{- end }} diff --git a/helm-chart/templates/cronjob.yaml b/helm-chart/templates/cronjob.yaml index 769e044..0894aab 100644 --- a/helm-chart/templates/cronjob.yaml +++ b/helm-chart/templates/cronjob.yaml @@ -1,53 +1,53 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - name: temperature-storage-cronjob - labels: - app: hivebox-cronjob -spec: - schedule: "*/5 * * * *" - concurrencyPolicy: Forbid - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - securityContext: - {{- include "common.podSecurityContext" . | nindent 12 }} - initContainers: - - name: wait-for-start - image: {{ .Values.images.cronjob }} - command: ["/bin/sh", "-c"] - args: - - | - set -eu - while true; do - if curl -sSf -m 3 http://hivebox-service/version >/dev/null; then - echo "Hivebox service is up!" - exit 0 - else - echo "Waiting for Hivebox service to be available..." - sleep 5 - fi - done - securityContext: - {{- include "common.containerSecurityContext" . | nindent 16 }} - resources: - {{- include "common.resources" (dict "Values" .Values "name" "cronjob") | nindent 16 }} - containers: - - name: temperature-storage - image: {{ .Values.images.cronjob }} - command: ["curl"] - args: - - "-f" - - "-s" - - "-S" - - "--max-time" - - "60" - - "http://hivebox-service/store" - securityContext: - {{- include "common.containerSecurityContext" . | nindent 16 }} - resources: - {{- include "common.resources" (dict "Values" .Values "name" "cronjob") | nindent 16 }} - successfulJobsHistoryLimit: 3 - failedJobsHistoryLimit: 1 +apiVersion: batch/v1 +kind: CronJob +metadata: + name: temperature-storage-cronjob + labels: + app: hivebox-cronjob +spec: + schedule: "*/5 * * * *" + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + securityContext: + {{- include "common.podSecurityContext" . | nindent 12 }} + initContainers: + - name: wait-for-start + image: {{ .Values.images.cronjob }} + command: ["/bin/sh", "-c"] + args: + - | + set -eu + while true; do + if curl -sSf -m 3 http://hivebox-service/version >/dev/null; then + echo "Hivebox service is up!" + exit 0 + else + echo "Waiting for Hivebox service to be available..." + sleep 5 + fi + done + securityContext: + {{- include "common.containerSecurityContext" . | nindent 16 }} + resources: + {{- include "common.resources" (dict "Values" .Values "name" "cronjob") | nindent 16 }} + containers: + - name: temperature-storage + image: {{ .Values.images.cronjob }} + command: ["curl"] + args: + - "-f" + - "-s" + - "-S" + - "--max-time" + - "60" + - "http://hivebox-service/store" + securityContext: + {{- include "common.containerSecurityContext" . | nindent 16 }} + resources: + {{- include "common.resources" (dict "Values" .Values "name" "cronjob") | nindent 16 }} + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 diff --git a/helm-chart/templates/deployment.yaml b/helm-chart/templates/deployment.yaml index de4c439..a133bae 100644 --- a/helm-chart/templates/deployment.yaml +++ b/helm-chart/templates/deployment.yaml @@ -1,145 +1,145 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hivebox - labels: - app: hivebox -spec: - replicas: {{ .Values.replicas.hivebox }} - selector: - matchLabels: - app: hivebox - template: - metadata: - labels: - app: hivebox - spec: - {{- if gt (int .Values.replicas.hivebox) 1 }} - affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 100 - podAffinityTerm: - labelSelector: - matchExpressions: - - key: app - operator: In - values: - - hivebox - topologyKey: kubernetes.io/hostname - {{- end }} - securityContext: - {{- include "common.podSecurityContext" . | nindent 8 }} - containers: - - name: hivebox - image: {{ .Values.images.hivebox }} - ports: - - containerPort: 5000 - env: - - name: REDIS_HOST - value: {{ .Values.services.redis | quote }} - - name: MINIO_HOST - value: {{ .Values.services.minio | quote }} - securityContext: - {{- include "common.containerSecurityContext" . | nindent 12 }} - resources: - {{- include "common.resources" (dict "Values" .Values "name" "hivebox") | nindent 12 }} - readinessProbe: - httpGet: - path: /readyz - port: 5000 - initialDelaySeconds: 30 - timeoutSeconds: 480 - failureThreshold: 3 - periodSeconds: 600 - livenessProbe: - httpGet: - path: /version - port: 5000 - timeoutSeconds: 3 - failureThreshold: 3 - periodSeconds: 60 - volumeMounts: - - name: tmp-volume - mountPath: /tmp - volumes: - - name: tmp-volume - emptyDir: {} - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: redis - labels: - app: redis -spec: - replicas: {{ .Values.replicas.redis }} - selector: - matchLabels: - app: redis - template: - metadata: - labels: - app: redis - spec: - securityContext: - {{- include "common.podSecurityContext" . | nindent 10 }} - containers: - - name: valkey - image: {{ .Values.images.redis }} - ports: - - containerPort: 6379 - command: ["valkey-server"] - args: ["--save", "", "--appendonly", "no"] - securityContext: - {{- include "common.containerSecurityContext" . | nindent 12 }} - resources: - {{- include "common.resources" (dict "Values" .Values "name" "redis") | nindent 12 }} - volumeMounts: - - name: valkey-data - mountPath: /data - volumes: - - name: valkey-data - emptyDir: {} - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: minio - labels: - app: minio -spec: - replicas: {{ .Values.replicas.minio }} - selector: - matchLabels: - app: minio - template: - metadata: - labels: - app: minio - spec: - securityContext: - {{- include "common.podSecurityContext" . | nindent 10 }} - containers: - - name: minio - image: {{ .Values.images.minio }} - ports: - - containerPort: 9000 - command: ["minio", "server", "/data"] - env: - - name: MINIO_ROOT_USER - value: {{ .Values.minio.accessKey | quote }} - - name: MINIO_ROOT_PASSWORD - value: {{ .Values.minio.secretKey | quote }} - securityContext: - {{- include "common.containerSecurityContext" . | nindent 12 }} - resources: - {{- include "common.resources" (dict "Values" .Values "name" "minio") | nindent 12 }} - volumeMounts: - - name: minio-data - mountPath: /data - volumes: - - name: minio-data - emptyDir: {} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hivebox + labels: + app: hivebox +spec: + replicas: {{ .Values.replicas.hivebox }} + selector: + matchLabels: + app: hivebox + template: + metadata: + labels: + app: hivebox + spec: + {{- if gt (int .Values.replicas.hivebox) 1 }} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - hivebox + topologyKey: kubernetes.io/hostname + {{- end }} + securityContext: + {{- include "common.podSecurityContext" . | nindent 8 }} + containers: + - name: hivebox + image: {{ .Values.images.hivebox }} + ports: + - containerPort: 5000 + env: + - name: REDIS_HOST + value: {{ .Values.services.redis | quote }} + - name: MINIO_HOST + value: {{ .Values.services.minio | quote }} + securityContext: + {{- include "common.containerSecurityContext" . | nindent 12 }} + resources: + {{- include "common.resources" (dict "Values" .Values "name" "hivebox") | nindent 12 }} + readinessProbe: + httpGet: + path: /readyz + port: 5000 + initialDelaySeconds: 30 + timeoutSeconds: 480 + failureThreshold: 3 + periodSeconds: 600 + livenessProbe: + httpGet: + path: /version + port: 5000 + timeoutSeconds: 3 + failureThreshold: 3 + periodSeconds: 60 + volumeMounts: + - name: tmp-volume + mountPath: /tmp + volumes: + - name: tmp-volume + emptyDir: {} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + labels: + app: redis +spec: + replicas: {{ .Values.replicas.redis }} + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + securityContext: + {{- include "common.podSecurityContext" . | nindent 10 }} + containers: + - name: valkey + image: {{ .Values.images.redis }} + ports: + - containerPort: 6379 + command: ["valkey-server"] + args: ["--save", "", "--appendonly", "no"] + securityContext: + {{- include "common.containerSecurityContext" . | nindent 12 }} + resources: + {{- include "common.resources" (dict "Values" .Values "name" "redis") | nindent 12 }} + volumeMounts: + - name: valkey-data + mountPath: /data + volumes: + - name: valkey-data + emptyDir: {} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio + labels: + app: minio +spec: + replicas: {{ .Values.replicas.minio }} + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + securityContext: + {{- include "common.podSecurityContext" . | nindent 10 }} + containers: + - name: minio + image: {{ .Values.images.minio }} + ports: + - containerPort: 9000 + command: ["minio", "server", "/data"] + env: + - name: MINIO_ROOT_USER + value: {{ .Values.minio.accessKey | quote }} + - name: MINIO_ROOT_PASSWORD + value: {{ .Values.minio.secretKey | quote }} + securityContext: + {{- include "common.containerSecurityContext" . | nindent 12 }} + resources: + {{- include "common.resources" (dict "Values" .Values "name" "minio") | nindent 12 }} + volumeMounts: + - name: minio-data + mountPath: /data + volumes: + - name: minio-data + emptyDir: {} diff --git a/helm-chart/templates/ingress.yaml b/helm-chart/templates/ingress.yaml index 7978576..eaaff86 100644 --- a/helm-chart/templates/ingress.yaml +++ b/helm-chart/templates/ingress.yaml @@ -1,21 +1,21 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: hivebox-ingress - annotations: - {{- if .Values.ingress.annotations }} - {{- toYaml .Values.ingress.annotations | nindent 4 }} - {{- end }} -spec: - ingressClassName: {{ .Values.ingress.className | default "nginx" }} - rules: - - host: {{ .Values.ingress.host | quote }} - http: - paths: - - path: {{ .Values.ingress.path | default "/" }} - pathType: Prefix - backend: - service: - name: {{ .Values.ingress.serviceName | default "hivebox-service" }} - port: +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hivebox-ingress + annotations: + {{- if .Values.ingress.annotations }} + {{- toYaml .Values.ingress.annotations | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className | default "nginx" }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | default "/" }} + pathType: Prefix + backend: + service: + name: {{ .Values.ingress.serviceName | default "hivebox-service" }} + port: number: {{ .Values.ingress.servicePort | default 80 }} \ No newline at end of file diff --git a/helm-chart/templates/service.yaml b/helm-chart/templates/service.yaml index c17f307..a42b3f3 100644 --- a/helm-chart/templates/service.yaml +++ b/helm-chart/templates/service.yaml @@ -1,36 +1,36 @@ -apiVersion: v1 -kind: Service -metadata: - name: hivebox-service - labels: - app: hivebox -spec: - selector: - app: hivebox - ports: - - port: 80 - targetPort: 5000 - protocol: TCP - type: ClusterIP ---- -apiVersion: v1 -kind: Service -metadata: - name: redis-service -spec: - selector: - app: redis - ports: - - port: 6379 - targetPort: 6379 ---- -apiVersion: v1 -kind: Service -metadata: - name: minio-service -spec: - selector: - app: minio - ports: - - port: 9000 +apiVersion: v1 +kind: Service +metadata: + name: hivebox-service + labels: + app: hivebox +spec: + selector: + app: hivebox + ports: + - port: 80 + targetPort: 5000 + protocol: TCP + type: ClusterIP +--- +apiVersion: v1 +kind: Service +metadata: + name: redis-service +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 +--- +apiVersion: v1 +kind: Service +metadata: + name: minio-service +spec: + selector: + app: minio + ports: + - port: 9000 targetPort: 9000 \ No newline at end of file diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 4f15594..9bf1671 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -1,64 +1,64 @@ -# Global, reused everywhere -podSecurityContext: - fsGroup: 1000 - seccompProfile: - type: RuntimeDefault - -containerSecurityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsGroup: 1000 - runAsUser: 1000 - capabilities: - drop: ["ALL"] - -images: - hivebox: ghcr.io/gabrielpalmar/hivebox:latest@sha256:c731999c3fd9b757e2fd816e3c9dcf645dba56647d8a921cb567ece3cf378dc3 - redis: valkey/valkey:8-alpine3.22@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4 - minio: minio/minio:RELEASE.2025-07-23T15-54-02Z@sha256:d249d1fb6966de4d8ad26c04754b545205ff15a62e4fd19ebd0f26fa5baacbc0 - cronjob: curlimages/curl:8.15.0@sha256:4026b29997dc7c823b51c164b71e2b51e0fd95cce4601f78202c513d97da2922 - -replicas: - hivebox: 2 - redis: 1 - minio: 1 - -resources: - hivebox: - limits: { memory: "512Mi", cpu: "500m" } - requests: { memory: "256Mi", cpu: "250m" } - redis: - limits: { memory: "256Mi", cpu: "250m" } - requests: { memory: "128Mi", cpu: "100m" } - minio: - limits: { memory: "256Mi", cpu: "250m" } - requests: { memory: "128Mi", cpu: "100m" } - cronjob: - limits: { memory: "32Mi", cpu: "50m" } - requests: { memory: "16Mi", cpu: "10m" } - -services: - redis: redis-service - minio: minio-service - -service: - type: ClusterIP - port: 80 - targetPort: 5000 - -minio: - accessKey: minioadmin - secretKey: minioadmin - -ingress: - enabled: true - host: hivebox.local - path: / - className: nginx - serviceName: hivebox-service - servicePort: 80 - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / - nginx.ingress.kubernetes.io/ssl-redirect: "false" - nginx.ingress.kubernetes.io/proxy-body-size: "10m" +# Global, reused everywhere +podSecurityContext: + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsGroup: 1000 + runAsUser: 1000 + capabilities: + drop: ["ALL"] + +images: + hivebox: ghcr.io/gabrielpalmar/hivebox:latest@sha256:c731999c3fd9b757e2fd816e3c9dcf645dba56647d8a921cb567ece3cf378dc3 + redis: valkey/valkey:8-alpine3.22@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4 + minio: minio/minio:RELEASE.2025-07-23T15-54-02Z@sha256:d249d1fb6966de4d8ad26c04754b545205ff15a62e4fd19ebd0f26fa5baacbc0 + cronjob: curlimages/curl:8.15.0@sha256:4026b29997dc7c823b51c164b71e2b51e0fd95cce4601f78202c513d97da2922 + +replicas: + hivebox: 2 + redis: 1 + minio: 1 + +resources: + hivebox: + limits: { memory: "512Mi", cpu: "500m" } + requests: { memory: "256Mi", cpu: "250m" } + redis: + limits: { memory: "256Mi", cpu: "250m" } + requests: { memory: "128Mi", cpu: "100m" } + minio: + limits: { memory: "256Mi", cpu: "250m" } + requests: { memory: "128Mi", cpu: "100m" } + cronjob: + limits: { memory: "32Mi", cpu: "50m" } + requests: { memory: "16Mi", cpu: "10m" } + +services: + redis: redis-service + minio: minio-service + +service: + type: ClusterIP + port: 80 + targetPort: 5000 + +minio: + accessKey: minioadmin + secretKey: minioadmin + +ingress: + enabled: true + host: hivebox.local + path: / + className: nginx + serviceName: hivebox-service + servicePort: 80 + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" diff --git a/kustomize/base/cronjob.yaml b/kustomize/base/cronjob.yaml index dc05595..4e4ed5a 100644 --- a/kustomize/base/cronjob.yaml +++ b/kustomize/base/cronjob.yaml @@ -1,67 +1,67 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - name: temperature-storage-cronjob - labels: - app: hivebox-cronjob -spec: - schedule: "*/5 * * * *" - concurrencyPolicy: Forbid - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - securityContext: - fsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - initContainers: - - name: wait-for-start - image: curlimages/curl:8.15.0@sha256:4026b29997dc7c823b51c164b71e2b51e0fd95cce4601f78202c513d97da2922 - command: ["/bin/sh", "-c"] - args: - - | - set -eu - while true; do - if curl -sSf -m 3 http://hivebox-service/version >/dev/null; then - echo "Hivebox service is up!" - exit 0 - else - echo "Waiting for Hivebox service to be available..." - sleep 5 - fi - done - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsGroup: 1000 - runAsUser: 1000 - capabilities: - drop: ["ALL"] - containers: - - name: temperature-storage - image: curlimages/curl:8.15.0@sha256:4026b29997dc7c823b51c164b71e2b51e0fd95cce4601f78202c513d97da2922 - command: ["curl"] - args: - - "-f" - - "-s" - - "-S" - - "--max-time" - - "60" - - "http://hivebox-service/store" - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsGroup: 1000 - runAsUser: 1000 - capabilities: - drop: ["ALL"] - resources: - limits: { memory: "32Mi", cpu: "50m" } - requests: { memory: "16Mi", cpu: "10m" } - successfulJobsHistoryLimit: 3 - failedJobsHistoryLimit: 1 +apiVersion: batch/v1 +kind: CronJob +metadata: + name: temperature-storage-cronjob + labels: + app: hivebox-cronjob +spec: + schedule: "*/5 * * * *" + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + initContainers: + - name: wait-for-start + image: curlimages/curl:8.15.0@sha256:4026b29997dc7c823b51c164b71e2b51e0fd95cce4601f78202c513d97da2922 + command: ["/bin/sh", "-c"] + args: + - | + set -eu + while true; do + if curl -sSf -m 3 http://hivebox-service/version >/dev/null; then + echo "Hivebox service is up!" + exit 0 + else + echo "Waiting for Hivebox service to be available..." + sleep 5 + fi + done + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsGroup: 1000 + runAsUser: 1000 + capabilities: + drop: ["ALL"] + containers: + - name: temperature-storage + image: curlimages/curl:8.15.0@sha256:4026b29997dc7c823b51c164b71e2b51e0fd95cce4601f78202c513d97da2922 + command: ["curl"] + args: + - "-f" + - "-s" + - "-S" + - "--max-time" + - "60" + - "http://hivebox-service/store" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsGroup: 1000 + runAsUser: 1000 + capabilities: + drop: ["ALL"] + resources: + limits: { memory: "32Mi", cpu: "50m" } + requests: { memory: "16Mi", cpu: "10m" } + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 diff --git a/kustomize/base/deployment.yaml b/kustomize/base/deployment.yaml index ad17429..a3a2db0 100644 --- a/kustomize/base/deployment.yaml +++ b/kustomize/base/deployment.yaml @@ -1,158 +1,158 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hivebox - labels: - app: hivebox -spec: - replicas: 2 - selector: - matchLabels: - app: hivebox - template: - metadata: - labels: - app: hivebox - spec: - securityContext: - fsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - containers: - - name: hivebox - image: ghcr.io/gabrielpalmar/hivebox:0.7.1@sha256:c731999c6fac6f2f17f746aea7fafe073cf608c49729eb1e189ecf3551c62646 - ports: - - containerPort: 5000 - env: - - name: REDIS_HOST - value: redis-service - - name: MINIO_HOST - value: minio-service - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] - resources: - limits: { memory: "512Mi", cpu: "500m" } - requests: { memory: "256Mi", cpu: "250m" } - readinessProbe: - httpGet: - path: /readyz - port: 5000 - initialDelaySeconds: 30 - timeoutSeconds: 480 - failureThreshold: 3 - periodSeconds: 600 - livenessProbe: - httpGet: - path: /version - port: 5000 - timeoutSeconds: 3 - failureThreshold: 3 - periodSeconds: 60 - volumeMounts: - - name: tmp-volume - mountPath: /tmp - volumes: - - name: tmp-volume - emptyDir: {} - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: redis - labels: - app: redis -spec: - replicas: 1 - selector: - matchLabels: - app: redis - template: - metadata: - labels: - app: redis - spec: - securityContext: - fsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - containers: - - name: valkey - image: valkey/valkey:8-alpine3.22@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4 - ports: - - containerPort: 6379 - command: ["valkey-server"] - args: ["--save", "", "--appendonly", "no"] - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsGroup: 1000 - runAsUser: 1000 - capabilities: - drop: ["ALL"] - resources: - limits: { memory: "256Mi", cpu: "250m" } - requests: { memory: "128Mi", cpu: "100m" } - volumeMounts: - - name: valkey-data - mountPath: /data - volumes: - - name: valkey-data - emptyDir: {} - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: minio - labels: - app: minio -spec: - replicas: 1 - selector: - matchLabels: - app: minio - template: - metadata: - labels: - app: minio - spec: - securityContext: - fsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - containers: - - name: minio - image: minio/minio:RELEASE.2025-07-23T15-54-02Z@sha256:d249d1fb6966de4d8ad26c04754b545205ff15a62e4fd19ebd0f26fa5baacbc0 - ports: - - containerPort: 9000 - command: ["minio", "server", "/data"] - env: - - name: MINIO_ROOT_USER - value: minioadmin - - name: MINIO_ROOT_PASSWORD - value: minioadmin - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsGroup: 1000 - runAsUser: 1000 - capabilities: - drop: ["ALL"] - resources: - limits: { memory: "256Mi", cpu: "250m" } - requests: { memory: "128Mi", cpu: "100m" } - volumeMounts: - - name: minio-data - mountPath: /data - volumes: - - name: minio-data - emptyDir: {} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hivebox + labels: + app: hivebox +spec: + replicas: 2 + selector: + matchLabels: + app: hivebox + template: + metadata: + labels: + app: hivebox + spec: + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + containers: + - name: hivebox + image: ghcr.io/gabrielpalmar/hivebox:0.7.1@sha256:c731999c6fac6f2f17f746aea7fafe073cf608c49729eb1e189ecf3551c62646 + ports: + - containerPort: 5000 + env: + - name: REDIS_HOST + value: redis-service + - name: MINIO_HOST + value: minio-service + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + limits: { memory: "512Mi", cpu: "500m" } + requests: { memory: "256Mi", cpu: "250m" } + readinessProbe: + httpGet: + path: /readyz + port: 5000 + initialDelaySeconds: 30 + timeoutSeconds: 480 + failureThreshold: 3 + periodSeconds: 600 + livenessProbe: + httpGet: + path: /version + port: 5000 + timeoutSeconds: 3 + failureThreshold: 3 + periodSeconds: 60 + volumeMounts: + - name: tmp-volume + mountPath: /tmp + volumes: + - name: tmp-volume + emptyDir: {} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + labels: + app: redis +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + containers: + - name: valkey + image: valkey/valkey:8-alpine3.22@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4 + ports: + - containerPort: 6379 + command: ["valkey-server"] + args: ["--save", "", "--appendonly", "no"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsGroup: 1000 + runAsUser: 1000 + capabilities: + drop: ["ALL"] + resources: + limits: { memory: "256Mi", cpu: "250m" } + requests: { memory: "128Mi", cpu: "100m" } + volumeMounts: + - name: valkey-data + mountPath: /data + volumes: + - name: valkey-data + emptyDir: {} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio + labels: + app: minio +spec: + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + containers: + - name: minio + image: minio/minio:RELEASE.2025-07-23T15-54-02Z@sha256:d249d1fb6966de4d8ad26c04754b545205ff15a62e4fd19ebd0f26fa5baacbc0 + ports: + - containerPort: 9000 + command: ["minio", "server", "/data"] + env: + - name: MINIO_ROOT_USER + value: minioadmin + - name: MINIO_ROOT_PASSWORD + value: minioadmin + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsGroup: 1000 + runAsUser: 1000 + capabilities: + drop: ["ALL"] + resources: + limits: { memory: "256Mi", cpu: "250m" } + requests: { memory: "128Mi", cpu: "100m" } + volumeMounts: + - name: minio-data + mountPath: /data + volumes: + - name: minio-data + emptyDir: {} diff --git a/kustomize/base/ingress.yaml b/kustomize/base/ingress.yaml index 757ef7f..58b9f7d 100644 --- a/kustomize/base/ingress.yaml +++ b/kustomize/base/ingress.yaml @@ -1,21 +1,21 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: hivebox-ingress - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / - nginx.ingress.kubernetes.io/ssl-redirect: "false" - nginx.ingress.kubernetes.io/proxy-body-size: "10m" -spec: - ingressClassName: nginx - rules: - - host: hivebox.local - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: hivebox-service - port: - number: 80 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hivebox-ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" +spec: + ingressClassName: nginx + rules: + - host: hivebox.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: hivebox-service + port: + number: 80 diff --git a/kustomize/base/kustomization.yaml b/kustomize/base/kustomization.yaml index a64e1fd..571af57 100644 --- a/kustomize/base/kustomization.yaml +++ b/kustomize/base/kustomization.yaml @@ -1,11 +1,11 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: hivebox -resources: - - deployment.yaml - - service.yaml - - ingress.yaml - - cronjob.yaml -metadata: - labels: - app: hivebox +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: hivebox +resources: + - deployment.yaml + - service.yaml + - ingress.yaml + - cronjob.yaml +metadata: + labels: + app: hivebox diff --git a/kustomize/base/service.yaml b/kustomize/base/service.yaml index c17f307..a42b3f3 100644 --- a/kustomize/base/service.yaml +++ b/kustomize/base/service.yaml @@ -1,36 +1,36 @@ -apiVersion: v1 -kind: Service -metadata: - name: hivebox-service - labels: - app: hivebox -spec: - selector: - app: hivebox - ports: - - port: 80 - targetPort: 5000 - protocol: TCP - type: ClusterIP ---- -apiVersion: v1 -kind: Service -metadata: - name: redis-service -spec: - selector: - app: redis - ports: - - port: 6379 - targetPort: 6379 ---- -apiVersion: v1 -kind: Service -metadata: - name: minio-service -spec: - selector: - app: minio - ports: - - port: 9000 +apiVersion: v1 +kind: Service +metadata: + name: hivebox-service + labels: + app: hivebox +spec: + selector: + app: hivebox + ports: + - port: 80 + targetPort: 5000 + protocol: TCP + type: ClusterIP +--- +apiVersion: v1 +kind: Service +metadata: + name: redis-service +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 +--- +apiVersion: v1 +kind: Service +metadata: + name: minio-service +spec: + selector: + app: minio + ports: + - port: 9000 targetPort: 9000 \ No newline at end of file diff --git a/kustomize/overlays/prod/cronjob-patch.yaml b/kustomize/overlays/prod/cronjob-patch.yaml index 8a65220..0e0d7ad 100644 --- a/kustomize/overlays/prod/cronjob-patch.yaml +++ b/kustomize/overlays/prod/cronjob-patch.yaml @@ -1,6 +1,6 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - name: temperature-storage-cronjob -spec: - schedule: "*/10 * * * *" +apiVersion: batch/v1 +kind: CronJob +metadata: + name: temperature-storage-cronjob +spec: + schedule: "*/10 * * * *" diff --git a/kustomize/overlays/prod/deployment-patch.yaml b/kustomize/overlays/prod/deployment-patch.yaml index 4208c17..0ce608f 100644 --- a/kustomize/overlays/prod/deployment-patch.yaml +++ b/kustomize/overlays/prod/deployment-patch.yaml @@ -1,11 +1,11 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hivebox -spec: - replicas: 3 - template: - spec: - containers: - - name: hivebox - image: ghcr.io/gabrielpalmar/hivebox:0.7.1@sha256:c731999c6fac6f2f17f746aea7fafe073cf608c49729eb1e189ecf3551c62646 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hivebox +spec: + replicas: 3 + template: + spec: + containers: + - name: hivebox + image: ghcr.io/gabrielpalmar/hivebox:0.7.1@sha256:c731999c6fac6f2f17f746aea7fafe073cf608c49729eb1e189ecf3551c62646 diff --git a/kustomize/overlays/prod/ingress-patch.yaml b/kustomize/overlays/prod/ingress-patch.yaml index 5226119..ef9eb64 100644 --- a/kustomize/overlays/prod/ingress-patch.yaml +++ b/kustomize/overlays/prod/ingress-patch.yaml @@ -1,16 +1,16 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: hivebox-ingress -spec: - rules: - - host: hivebox.example.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: hivebox-service - port: - number: 80 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hivebox-ingress +spec: + rules: + - host: hivebox.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: hivebox-service + port: + number: 80 diff --git a/kustomize/overlays/prod/kustomization.yaml b/kustomize/overlays/prod/kustomization.yaml index 33f48d5..0bfbd3f 100644 --- a/kustomize/overlays/prod/kustomization.yaml +++ b/kustomize/overlays/prod/kustomization.yaml @@ -1,11 +1,11 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - ../../base -labels: - - pairs: - env: prod -patches: - - path: deployment-patch.yaml - - path: ingress-patch.yaml - - path: cronjob-patch.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../base +labels: + - pairs: + env: prod +patches: + - path: deployment-patch.yaml + - path: ingress-patch.yaml + - path: cronjob-patch.yaml diff --git a/kustomize/overlays/staging/deployment-patch.yaml b/kustomize/overlays/staging/deployment-patch.yaml index effb6af..8be5890 100644 --- a/kustomize/overlays/staging/deployment-patch.yaml +++ b/kustomize/overlays/staging/deployment-patch.yaml @@ -1,11 +1,11 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hivebox -spec: - replicas: 1 - template: - spec: - containers: - - name: hivebox - image: ghcr.io/gabrielpalmar/hivebox:0.7.1@sha256:c731999c6fac6f2f17f746aea7fafe073cf608c49729eb1e189ecf3551c62646 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hivebox +spec: + replicas: 1 + template: + spec: + containers: + - name: hivebox + image: ghcr.io/gabrielpalmar/hivebox:0.7.1@sha256:c731999c6fac6f2f17f746aea7fafe073cf608c49729eb1e189ecf3551c62646 diff --git a/kustomize/overlays/staging/ingress-patch.yaml b/kustomize/overlays/staging/ingress-patch.yaml index 7d01cb9..46535ef 100644 --- a/kustomize/overlays/staging/ingress-patch.yaml +++ b/kustomize/overlays/staging/ingress-patch.yaml @@ -1,16 +1,16 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: hivebox-ingress -spec: - rules: - - host: staging.hivebox.local - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: hivebox-service - port: - number: 80 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hivebox-ingress +spec: + rules: + - host: staging.hivebox.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: hivebox-service + port: + number: 80 diff --git a/kustomize/overlays/staging/kustomization.yaml b/kustomize/overlays/staging/kustomization.yaml index 1ec5d1b..9c6d752 100644 --- a/kustomize/overlays/staging/kustomization.yaml +++ b/kustomize/overlays/staging/kustomization.yaml @@ -1,10 +1,10 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - ../../base -labels: - - pairs: - env: staging -patches: - - path: deployment-patch.yaml - - path: ingress-patch.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../base +labels: + - pairs: + env: staging +patches: + - path: deployment-patch.yaml + - path: ingress-patch.yaml diff --git a/pytest.ini b/pytest.ini index d810fe3..6d744d1 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,7 @@ -[tool:pytest] -testpaths = tests -pythonpath = . -python_files = test_*.py -python_classes = Test* -python_functions = test_* +[tool:pytest] +testpaths = tests +pythonpath = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* addopts = --verbose --tb=short \ No newline at end of file diff --git a/requirements.in b/requirements.in index 397163d..46e43c0 100644 --- a/requirements.in +++ b/requirements.in @@ -1,5 +1,5 @@ -Flask==3.1.2 -requests==2.32.5 -prometheus-client==0.23.1 -redis==6.4.0 +Flask==3.1.2 +requests==2.32.5 +prometheus-client==0.23.1 +redis==6.4.0 minio==7.2.16 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7668db4..0242e3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,374 +1,374 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --generate-hashes --output-file=requirements.txt requirements.in -# -argon2-cffi==25.1.0 \ - --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \ - --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741 - # via minio -argon2-cffi-bindings==25.1.0 \ - --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \ - --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \ - --hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \ - --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \ - --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \ - --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \ - --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \ - --hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \ - --hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \ - --hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \ - --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \ - --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \ - --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \ - --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \ - --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \ - --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \ - --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \ - --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \ - --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \ - --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \ - --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \ - --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \ - --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \ - --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \ - --hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \ - --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb - # via argon2-cffi -blinker==1.9.0 \ - --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ - --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc - # via flask -certifi==2025.7.14 \ - --hash=sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2 \ - --hash=sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995 - # via - # minio - # requests -cffi==1.17.1 \ - --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ - --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ - --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ - --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ - --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ - --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ - --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ - --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ - --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ - --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ - --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ - --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ - --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ - --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ - --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ - --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ - --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ - --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ - --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ - --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ - --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ - --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ - --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ - --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ - --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ - --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ - --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ - --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ - --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ - --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ - --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ - --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ - --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ - --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ - --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ - --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ - --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ - --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ - --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ - --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ - --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ - --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ - --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ - --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ - --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ - --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ - --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ - --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ - --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ - --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ - --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ - --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ - --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ - --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ - --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ - --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ - --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ - --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ - --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ - --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ - --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ - --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ - --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ - --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ - --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ - --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ - --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b - # via argon2-cffi-bindings -charset-normalizer==3.4.2 \ - --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ - --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ - --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ - --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ - --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ - --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ - --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ - --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ - --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ - --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ - --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ - --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ - --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ - --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ - --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ - --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ - --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ - --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ - --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ - --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ - --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ - --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ - --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ - --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ - --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ - --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ - --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ - --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ - --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ - --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ - --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ - --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ - --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ - --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ - --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ - --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ - --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ - --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ - --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ - --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ - --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ - --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ - --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ - --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ - --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ - --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ - --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ - --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ - --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ - --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ - --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ - --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ - --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ - --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ - --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ - --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ - --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ - --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ - --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ - --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ - --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ - --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ - --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ - --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ - --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ - --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ - --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ - --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ - --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ - --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ - --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ - --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ - --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ - --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ - --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ - --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ - --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ - --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ - --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ - --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ - --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ - --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ - --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ - --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ - --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ - --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ - --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ - --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ - --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ - --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ - --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ - --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f - # via requests -click==8.2.1 \ - --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ - --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b - # via flask -flask==3.1.2 \ - --hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \ - --hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c - # via -r requirements.in -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 - # via requests -itsdangerous==2.2.0 \ - --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ - --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 - # via flask -jinja2==3.1.6 \ - --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ - --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - # via flask -markupsafe==3.0.2 \ - --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ - --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ - --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ - --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ - --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ - --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ - --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ - --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ - --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ - --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ - --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ - --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ - --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ - --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ - --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ - --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ - --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ - --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ - --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ - --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ - --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ - --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ - --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ - --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ - --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ - --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ - --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ - --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ - --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ - --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ - --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ - --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ - --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ - --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ - --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ - --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ - --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ - --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ - --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ - --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ - --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ - --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ - --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ - --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ - --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ - --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ - --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ - --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ - --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ - --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ - --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ - --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ - --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ - --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ - --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ - --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ - --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ - --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ - --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ - --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ - --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 - # via - # flask - # jinja2 - # werkzeug -minio==7.2.16 \ - --hash=sha256:81e365c8494d591d8204a63ee7596bfdf8a7d06ad1b1507d6b9c1664a95f299a \ - --hash=sha256:9288ab988ca57c181eb59a4c96187b293131418e28c164392186c2b89026b223 - # via -r requirements.in -prometheus-client==0.23.1 \ - --hash=sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce \ - --hash=sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99 - # via -r requirements.in -pycparser==2.22 \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc - # via cffi -pycryptodome==3.23.0 \ - --hash=sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4 \ - --hash=sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c \ - --hash=sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630 \ - --hash=sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f \ - --hash=sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27 \ - --hash=sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a \ - --hash=sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56 \ - --hash=sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef \ - --hash=sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5 \ - --hash=sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477 \ - --hash=sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886 \ - --hash=sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a \ - --hash=sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75 \ - --hash=sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720 \ - --hash=sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339 \ - --hash=sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625 \ - --hash=sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490 \ - --hash=sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8 \ - --hash=sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b \ - --hash=sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818 \ - --hash=sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a \ - --hash=sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002 \ - --hash=sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae \ - --hash=sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7 \ - --hash=sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d \ - --hash=sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265 \ - --hash=sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39 \ - --hash=sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566 \ - --hash=sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353 \ - --hash=sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b \ - --hash=sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4 \ - --hash=sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2 \ - --hash=sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575 \ - --hash=sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6 \ - --hash=sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843 \ - --hash=sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4 \ - --hash=sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446 \ - --hash=sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379 \ - --hash=sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa \ - --hash=sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be \ - --hash=sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7 - # via minio -redis==6.4.0 \ - --hash=sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010 \ - --hash=sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f - # via -r requirements.in -requests==2.32.5 \ - --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ - --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf - # via -r requirements.in -typing-extensions==4.14.1 \ - --hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ - --hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76 - # via minio -urllib3==2.5.0 \ - --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \ - --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc - # via - # minio - # requests -werkzeug==3.1.3 \ - --hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \ - --hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746 - # via flask +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --generate-hashes --output-file=requirements.txt requirements.in +# +argon2-cffi==25.1.0 \ + --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \ + --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741 + # via minio +argon2-cffi-bindings==25.1.0 \ + --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \ + --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \ + --hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \ + --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \ + --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \ + --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \ + --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \ + --hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \ + --hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \ + --hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \ + --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \ + --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \ + --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \ + --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \ + --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \ + --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \ + --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \ + --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \ + --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \ + --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \ + --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \ + --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \ + --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \ + --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \ + --hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \ + --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb + # via argon2-cffi +blinker==1.9.0 \ + --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ + --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc + # via flask +certifi==2025.7.14 \ + --hash=sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2 \ + --hash=sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995 + # via + # minio + # requests +cffi==1.17.1 \ + --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ + --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ + --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ + --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ + --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ + --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ + --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ + --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ + --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ + --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ + --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ + --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ + --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ + --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ + --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ + --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ + --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ + --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ + --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ + --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ + --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ + --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ + --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ + --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ + --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ + --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ + --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ + --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ + --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ + --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ + --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ + --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ + --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ + --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ + --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ + --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ + --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ + --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ + --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ + --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ + --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ + --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ + --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ + --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ + --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ + --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ + --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ + --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ + --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ + --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ + --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ + --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ + --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ + --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ + --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ + --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ + --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ + --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ + --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ + --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ + --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ + --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ + --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ + --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ + --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ + --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ + --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b + # via argon2-cffi-bindings +charset-normalizer==3.4.2 \ + --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ + --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ + --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ + --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ + --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ + --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ + --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ + --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ + --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ + --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ + --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ + --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ + --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ + --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ + --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ + --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ + --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ + --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ + --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ + --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ + --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ + --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ + --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ + --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ + --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ + --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ + --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ + --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ + --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ + --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ + --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ + --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ + --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ + --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ + --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ + --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ + --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ + --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ + --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ + --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ + --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ + --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ + --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ + --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ + --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ + --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ + --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ + --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ + --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ + --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ + --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ + --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ + --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ + --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ + --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ + --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ + --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ + --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ + --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ + --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ + --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ + --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ + --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ + --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ + --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ + --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ + --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ + --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ + --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ + --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ + --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ + --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ + --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ + --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ + --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ + --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ + --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ + --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ + --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ + --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ + --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ + --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ + --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ + --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ + --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ + --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ + --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ + --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ + --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ + --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ + --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ + --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f + # via requests +click==8.2.1 \ + --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ + --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b + # via flask +flask==3.1.2 \ + --hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \ + --hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c + # via -r requirements.in +idna==3.10 \ + --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ + --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 + # via requests +itsdangerous==2.2.0 \ + --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ + --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 + # via flask +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via flask +markupsafe==3.0.2 \ + --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ + --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ + --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ + --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ + --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ + --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ + --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ + --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ + --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ + --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ + --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ + --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ + --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ + --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ + --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ + --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ + --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ + --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ + --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ + --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ + --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ + --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ + --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ + --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ + --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ + --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ + --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ + --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ + --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ + --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ + --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ + --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ + --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ + --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ + --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ + --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ + --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ + --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ + --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ + --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ + --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ + --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ + --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ + --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ + --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ + --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ + --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ + --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ + --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ + --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ + --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ + --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ + --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ + --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ + --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ + --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ + --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ + --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ + --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ + --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ + --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 + # via + # flask + # jinja2 + # werkzeug +minio==7.2.16 \ + --hash=sha256:81e365c8494d591d8204a63ee7596bfdf8a7d06ad1b1507d6b9c1664a95f299a \ + --hash=sha256:9288ab988ca57c181eb59a4c96187b293131418e28c164392186c2b89026b223 + # via -r requirements.in +prometheus-client==0.23.1 \ + --hash=sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce \ + --hash=sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99 + # via -r requirements.in +pycparser==2.22 \ + --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ + --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc + # via cffi +pycryptodome==3.23.0 \ + --hash=sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4 \ + --hash=sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c \ + --hash=sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630 \ + --hash=sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f \ + --hash=sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27 \ + --hash=sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a \ + --hash=sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56 \ + --hash=sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef \ + --hash=sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5 \ + --hash=sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477 \ + --hash=sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886 \ + --hash=sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a \ + --hash=sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75 \ + --hash=sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720 \ + --hash=sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339 \ + --hash=sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625 \ + --hash=sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490 \ + --hash=sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8 \ + --hash=sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b \ + --hash=sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818 \ + --hash=sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a \ + --hash=sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002 \ + --hash=sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae \ + --hash=sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7 \ + --hash=sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d \ + --hash=sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265 \ + --hash=sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39 \ + --hash=sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566 \ + --hash=sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353 \ + --hash=sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b \ + --hash=sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4 \ + --hash=sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2 \ + --hash=sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575 \ + --hash=sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6 \ + --hash=sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843 \ + --hash=sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4 \ + --hash=sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446 \ + --hash=sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379 \ + --hash=sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa \ + --hash=sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be \ + --hash=sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7 + # via minio +redis==6.4.0 \ + --hash=sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010 \ + --hash=sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f + # via -r requirements.in +requests==2.32.5 \ + --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ + --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf + # via -r requirements.in +typing-extensions==4.14.1 \ + --hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ + --hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76 + # via minio +urllib3==2.5.0 \ + --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \ + --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc + # via + # minio + # requests +werkzeug==3.1.3 \ + --hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \ + --hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746 + # via flask diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..73d14d6 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,27 @@ +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +# Ignore lock file (optional - some teams commit this) +.terraform.lock.hcl diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..8dcc82f --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,356 @@ +# HiveBox EKS Terraform Infrastructure + +This directory contains Terraform modules for deploying the HiveBox application infrastructure on AWS using Amazon EKS (Elastic Kubernetes Service). + +## Architecture Overview + +The infrastructure creates: + +- **VPC**: Multi-AZ VPC with public and private subnets, NAT Gateways, and Internet Gateway +- **EKS Cluster**: Managed Kubernetes control plane with configurable logging and addons +- **EKS Node Group**: Auto-scaling worker nodes with customizable instance types +- **Security Groups**: Network security for cluster, nodes, and load balancer +- **IAM Roles**: Proper IAM roles for cluster and nodes, with optional IRSA support + +**In-Cluster Services** (deployed via Helm/Kustomize): +- **Valkey/Redis**: Runs as a deployment inside the cluster (redis-service) +- **MinIO**: Runs as a deployment inside the cluster (minio-service) +- **HiveBox Application**: Your Flask application with all components + +## Module Structure + +``` +terraform/ +├── main.tf # Root module configuration +├── variables.tf # Input variables +├── outputs.tf # Output values +├── terraform.tfvars.example # Example configuration values +├── README.md # This file +└── modules/ + ├── vpc/ # VPC and networking + ├── eks/ # EKS cluster + ├── node-group/ # EKS node group + ├── security-groups/ # Security groups + └── iam/ # IAM roles and policies +``` + +## Prerequisites + +Before deploying, ensure you have: + +1. **AWS CLI** configured with appropriate credentials + ```bash + aws configure --profile Gabriel-Admin + ``` + +2. **Terraform** version >= 1.5.0 + ```bash + terraform version + ``` + +3. **kubectl** for Kubernetes management + ```bash + kubectl version --client + ``` + +4. **Sufficient AWS permissions** to create VPC, EKS, IAM roles, and security groups + +## Quick Start + +### 1. Configure Variables + +```bash +cd terraform + +# Copy example configuration +cp terraform.tfvars.example terraform.tfvars + +# Edit configuration +# Update these values in terraform.tfvars: +# - cluster_public_access_cidrs: Restrict to your IP for security +# - common_tags: Add your team/owner information +``` + +### 2. Deploy Infrastructure + +```bash +# Initialize Terraform +terraform init + +# Review planned changes +terraform plan + +# Deploy infrastructure +terraform apply +# Type 'yes' when prompted +``` + +**Deployment time:** 15-20 minutes + +### 3. Configure kubectl + +After deployment completes: + +```bash +# Use the output command +terraform output -raw configure_kubectl | bash + +# Or manually +aws eks update-kubeconfig \ + --region us-east-2 \ + --name hivebox-eks \ + --profile Gabriel-Admin + +# Verify access +kubectl get nodes +``` + +### 4. Deploy HiveBox Application + +The Terraform infrastructure only creates the EKS cluster. Your application components run inside Kubernetes. + +#### Using Helm + +```bash +cd ../helm-chart + +helm upgrade --install hivebox . --namespace hivebox --create-namespace +``` + +#### Using Kustomize + +```bash +cd ../kustomize + +# For production +kubectl apply -k overlays/prod + +# For staging +kubectl apply -k overlays/staging +``` + +#### Verify Deployment + +```bash +# Check all resources +kubectl get all -n hivebox + +# Check pods +kubectl get pods -n hivebox + +# Expected pods: +# - hivebox-app (2 replicas) +# - redis/valkey (1 replica) +# - minio (1 replica) + +# View logs +kubectl logs -n hivebox -l app=hivebox --tail=50 +``` + +## Architecture Details + +### Network Architecture + +``` +Internet + │ + ▼ +┌─────────────────┐ +│ Internet Gateway│ +└────────┬────────┘ + │ + ▼ +┌──────────────────────────────────────┐ +│ Public Subnets (2 AZs) │ +│ - NAT Gateways │ +└────────┬─────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────┐ +│ Private Subnets (2 AZs) │ +│ - EKS Worker Nodes │ +│ - HiveBox Pods │ +│ - Redis/Valkey Pods │ +│ - MinIO Pods │ +└──────────────────────────────────────┘ +``` + +### In-Cluster Services + +All application components run inside Kubernetes: + +| Service | Type | Port | Purpose | +|---------|------|------|---------| +| **HiveBox** | Deployment (2 replicas) | 5000 | Main Flask application | +| **Valkey/Redis** | Deployment (1 replica) | 6379 | In-memory caching | +| **MinIO** | Deployment (1 replica) | 9000 | Object storage | + +Communication between services uses Kubernetes ClusterIP services (DNS-based service discovery). + +## Important Configuration Variables + +### EKS Cluster + +| Variable | Default | Description | +|----------|---------|-------------| +| `cluster_name` | `hivebox-eks` | Name of the EKS cluster | +| `kubernetes_version` | `1.31` | Kubernetes version | +| `cluster_endpoint_public_access` | `true` | Enable public API endpoint | +| `cluster_public_access_cidrs` | `["0.0.0.0/0"]` | IPs allowed to access API | + +### Node Group + +| Variable | Default | Description | +|----------|---------|-------------| +| `node_group_desired_size` | `2` | Desired number of nodes | +| `node_group_min_size` | `1` | Minimum nodes | +| `node_group_max_size` | `4` | Maximum nodes | +| `node_group_instance_types` | `["t3.medium"]` | EC2 instance types | + +## Outputs + +After deployment, view outputs: + +```bash +# View all outputs +terraform output + +# Important outputs: +terraform output cluster_name +terraform output cluster_endpoint +terraform output in_cluster_services +``` + +## Infrastructure Costs + +### Current Configuration (~$140/month) +- **EKS Cluster**: $73/month (control plane) +- **2x t3.medium instances**: ~$60/month +- **2x NAT Gateways**: ~$65/month +- **Data Transfer**: ~$5/month +- **Total**: ~$203/month + +### Cost Optimization Tips + +For development/testing: + +```hcl +# Use smaller instances +node_group_instance_types = ["t3.small"] + +# Use SPOT instances (save 70%) +node_group_capacity_type = "SPOT" + +# Reduce node count +node_group_desired_size = 1 +node_group_min_size = 1 +``` + +## Cleanup + +To destroy all resources: + +```bash +cd terraform + +# IMPORTANT: Delete Kubernetes resources first +kubectl delete namespace hivebox + +# Destroy Terraform infrastructure +terraform destroy +# Type 'yes' when prompted +``` + +**Warning:** This is irreversible. Ensure you have backups of any important data. + +## Security Best Practices + +1. **Restrict API Access**: Update `cluster_public_access_cidrs` to your IP + ```hcl + cluster_public_access_cidrs = ["YOUR_IP/32"] + ``` + +2. **Use Private Subnets**: EKS nodes run in private subnets by default + +3. **Enable Logging**: All control plane logs are enabled for auditing + +4. **IAM Roles**: Least privilege IAM roles for cluster and nodes + +## Troubleshooting + +### Cannot create cluster + +**Error**: `Error creating EKS Cluster` + +**Solution**: Check AWS credentials and IAM permissions + +### Nodes not joining cluster + +**Solution**: +1. Verify security groups allow communication +2. Check IAM role has required policies +3. Verify VPC CNI addon is installed + +```bash +kubectl get pods -n kube-system | grep aws-node +``` + +### Application cannot connect to Redis/MinIO + +**Solution**: Ensure services are deployed and running + +```bash +# Check services +kubectl get svc -n hivebox + +# Should see: +# - hivebox-service +# - redis-service +# - minio-service + +# Check pods +kubectl get pods -n hivebox +``` + +## Maintenance + +### Updating Kubernetes Version + +1. Update `kubernetes_version` in `terraform.tfvars` +2. Apply changes: + ```bash + terraform apply + ``` + +**Note**: Upgrade one minor version at a time (1.30 → 1.31). + +### Scaling Nodes + +Update node count: + +```hcl +node_group_desired_size = 3 +``` + +Then apply: +```bash +terraform apply +``` + +## Additional Resources + +- [AWS EKS Documentation](https://docs.aws.amazon.com/eks/) +- [Terraform AWS Provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) +- [Kubernetes Documentation](https://kubernetes.io/docs/) + +## Support + +For issues or questions: +1. Check Terraform plan output +2. Review CloudWatch logs +3. Verify AWS service quotas +4. Consult module documentation in `modules/*/` + +## License + +This infrastructure code is part of the HiveBox project. diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..a019e63 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,122 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } + } + + # Optional: Configure S3 backend for state management + # Uncomment and configure this after creating the backend resources + # backend "s3" { + # bucket = "hivebox-terraform-state" + # key = "eks/terraform.tfstate" + # region = "us-east-2" + # encrypt = true + # dynamodb_table = "terraform-state-lock" + # } +} + +# Configure the AWS Provider +provider "aws" { + region = var.aws_region + profile = var.aws_profile + + default_tags { + tags = var.common_tags + } +} + +# Local variables +locals { + cluster_name = var.cluster_name + + common_tags = merge( + var.common_tags, + { + ManagedBy = "Terraform" + Project = "HiveBox" + } + ) +} + +# VPC Module +module "vpc" { + source = "./modules/vpc" + + cluster_name = local.cluster_name + vpc_cidr = var.vpc_cidr + availability_zones_count = var.availability_zones_count + tags = local.common_tags +} + +# IAM Roles Module (must be created before EKS) +module "iam" { + source = "./modules/iam" + + cluster_name = local.cluster_name + oidc_provider_arn = "" # Will be populated after EKS cluster creation + namespace = var.kubernetes_namespace + service_account_name = var.kubernetes_service_account + create_irsa_role = var.create_irsa_role + tags = local.common_tags +} + +# Security Groups Module +module "security_groups" { + source = "./modules/security-groups" + + cluster_name = local.cluster_name + vpc_id = module.vpc.vpc_id + tags = local.common_tags + + depends_on = [module.vpc] +} + +# EKS Cluster Module +module "eks" { + source = "./modules/eks" + + cluster_name = local.cluster_name + cluster_version = var.kubernetes_version + cluster_role_arn = module.iam.cluster_role_arn + private_subnet_ids = module.vpc.private_subnet_ids + public_subnet_ids = module.vpc.public_subnet_ids + cluster_security_group_id = module.security_groups.cluster_security_group_id + endpoint_private_access = var.cluster_endpoint_private_access + endpoint_public_access = var.cluster_endpoint_public_access + public_access_cidrs = var.cluster_public_access_cidrs + enabled_cluster_log_types = var.cluster_log_types + tags = local.common_tags + + depends_on = [module.vpc, module.iam, module.security_groups] +} + +# EKS Node Group Module +module "node_group" { + source = "./modules/node-group" + + cluster_name = module.eks.cluster_name + node_role_arn = module.iam.node_group_role_arn + subnet_ids = module.vpc.private_subnet_ids + kubernetes_version = var.kubernetes_version + desired_size = var.node_group_desired_size + max_size = var.node_group_max_size + min_size = var.node_group_min_size + instance_types = var.node_group_instance_types + capacity_type = var.node_group_capacity_type + disk_size = var.node_group_disk_size + tags = local.common_tags + + depends_on = [module.eks] +} + +# Note: MinIO and Valkey/Redis will be deployed as in-cluster Kubernetes resources +# using your existing Helm charts or Kustomize manifests. +# No AWS ElastiCache or S3 resources are created by this Terraform configuration. diff --git a/terraform/modules/eks/main.tf b/terraform/modules/eks/main.tf new file mode 100644 index 0000000..27668c7 --- /dev/null +++ b/terraform/modules/eks/main.tf @@ -0,0 +1,89 @@ +# EKS Cluster Module + +resource "aws_eks_cluster" "main" { + name = var.cluster_name + version = var.cluster_version + role_arn = var.cluster_role_arn + + vpc_config { + subnet_ids = concat(var.private_subnet_ids, var.public_subnet_ids) + endpoint_private_access = var.endpoint_private_access + endpoint_public_access = var.endpoint_public_access + public_access_cidrs = var.public_access_cidrs + security_group_ids = [var.cluster_security_group_id] + } + + enabled_cluster_log_types = var.enabled_cluster_log_types + + encryption_config { + provider { + key_arn = var.kms_key_arn + } + resources = ["secrets"] + } + + tags = var.tags + + depends_on = [ + var.cluster_role_arn + ] +} + +# OIDC Provider for IRSA (IAM Roles for Service Accounts) +data "tls_certificate" "cluster" { + url = aws_eks_cluster.main.identity[0].oidc[0].issuer +} + +resource "aws_iam_openid_connect_provider" "cluster" { + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.cluster.certificates[0].sha1_fingerprint] + url = aws_eks_cluster.main.identity[0].oidc[0].issuer + + tags = var.tags +} + +# EKS Add-ons +resource "aws_eks_addon" "vpc_cni" { + cluster_name = aws_eks_cluster.main.name + addon_name = "vpc-cni" + addon_version = var.vpc_cni_version + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + tags = var.tags +} + +resource "aws_eks_addon" "coredns" { + cluster_name = aws_eks_cluster.main.name + addon_name = "coredns" + addon_version = var.coredns_version + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + tags = var.tags + + depends_on = [ + aws_eks_addon.vpc_cni + ] +} + +resource "aws_eks_addon" "kube_proxy" { + cluster_name = aws_eks_cluster.main.name + addon_name = "kube-proxy" + addon_version = var.kube_proxy_version + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + tags = var.tags +} + +# EBS CSI Driver for persistent storage +resource "aws_eks_addon" "ebs_csi_driver" { + cluster_name = aws_eks_cluster.main.name + addon_name = "aws-ebs-csi-driver" + addon_version = var.ebs_csi_driver_version + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + tags = var.tags +} diff --git a/terraform/modules/eks/outputs.tf b/terraform/modules/eks/outputs.tf new file mode 100644 index 0000000..405444a --- /dev/null +++ b/terraform/modules/eks/outputs.tf @@ -0,0 +1,45 @@ +output "cluster_id" { + description = "ID of the EKS cluster" + value = aws_eks_cluster.main.id +} + +output "cluster_name" { + description = "Name of the EKS cluster" + value = aws_eks_cluster.main.name +} + +output "cluster_arn" { + description = "ARN of the EKS cluster" + value = aws_eks_cluster.main.arn +} + +output "cluster_endpoint" { + description = "Endpoint for EKS cluster API server" + value = aws_eks_cluster.main.endpoint +} + +output "cluster_version" { + description = "Kubernetes version of the cluster" + value = aws_eks_cluster.main.version +} + +output "cluster_certificate_authority_data" { + description = "Base64 encoded certificate data for cluster authentication" + value = aws_eks_cluster.main.certificate_authority[0].data + sensitive = true +} + +output "cluster_oidc_issuer_url" { + description = "OIDC issuer URL for the cluster" + value = aws_eks_cluster.main.identity[0].oidc[0].issuer +} + +output "oidc_provider_arn" { + description = "ARN of the OIDC provider for IRSA" + value = aws_iam_openid_connect_provider.cluster.arn +} + +output "cluster_security_group_id" { + description = "Security group ID attached to the EKS cluster" + value = aws_eks_cluster.main.vpc_config[0].cluster_security_group_id +} diff --git a/terraform/modules/eks/variables.tf b/terraform/modules/eks/variables.tf new file mode 100644 index 0000000..f578c31 --- /dev/null +++ b/terraform/modules/eks/variables.tf @@ -0,0 +1,90 @@ +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "cluster_version" { + description = "Kubernetes version for EKS cluster" + type = string + default = "1.31" +} + +variable "cluster_role_arn" { + description = "ARN of the IAM role for EKS cluster" + type = string +} + +variable "private_subnet_ids" { + description = "List of private subnet IDs" + type = list(string) +} + +variable "public_subnet_ids" { + description = "List of public subnet IDs" + type = list(string) +} + +variable "cluster_security_group_id" { + description = "ID of the cluster security group" + type = string +} + +variable "endpoint_private_access" { + description = "Enable private API server endpoint" + type = bool + default = true +} + +variable "endpoint_public_access" { + description = "Enable public API server endpoint" + type = bool + default = true +} + +variable "public_access_cidrs" { + description = "List of CIDR blocks that can access the public API server endpoint" + type = list(string) + default = ["0.0.0.0/0"] +} + +variable "enabled_cluster_log_types" { + description = "List of control plane logging types to enable" + type = list(string) + default = ["api", "audit", "authenticator", "controllerManager", "scheduler"] +} + +variable "kms_key_arn" { + description = "ARN of KMS key for secrets encryption (optional)" + type = string + default = "" +} + +variable "vpc_cni_version" { + description = "Version of VPC CNI addon" + type = string + default = null +} + +variable "coredns_version" { + description = "Version of CoreDNS addon" + type = string + default = null +} + +variable "kube_proxy_version" { + description = "Version of kube-proxy addon" + type = string + default = null +} + +variable "ebs_csi_driver_version" { + description = "Version of EBS CSI driver addon" + type = string + default = null +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/elasticache/main.tf b/terraform/modules/elasticache/main.tf new file mode 100644 index 0000000..28ff48a --- /dev/null +++ b/terraform/modules/elasticache/main.tf @@ -0,0 +1,177 @@ +# ElastiCache Redis Module +# Replaces the in-cluster Valkey/Redis deployment + +resource "aws_elasticache_subnet_group" "redis" { + name = "${var.cluster_name}-redis-subnet-group" + subnet_ids = var.subnet_ids + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-redis-subnet-group" + } + ) +} + +resource "aws_elasticache_parameter_group" "redis" { + name = "${var.cluster_name}-redis-params" + family = var.parameter_group_family + + # Custom parameters based on application requirements + dynamic "parameter" { + for_each = var.parameters + content { + name = parameter.value.name + value = parameter.value.value + } + } + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-redis-params" + } + ) +} + +# Redis Replication Group (supports both single node and cluster mode) +resource "aws_elasticache_replication_group" "redis" { + replication_group_id = "${var.cluster_name}-redis" + description = "Redis cache for ${var.cluster_name} application" + + engine = "redis" + engine_version = var.redis_version + node_type = var.node_type + num_cache_clusters = var.num_cache_nodes + port = 6379 + parameter_group_name = aws_elasticache_parameter_group.redis.name + subnet_group_name = aws_elasticache_subnet_group.redis.name + security_group_ids = [var.security_group_id] + + # Automatic failover must be enabled for multi-AZ + automatic_failover_enabled = var.num_cache_nodes > 1 ? true : false + multi_az_enabled = var.multi_az_enabled + + # Backup and maintenance + snapshot_retention_limit = var.snapshot_retention_limit + snapshot_window = var.snapshot_window + maintenance_window = var.maintenance_window + auto_minor_version_upgrade = var.auto_minor_version_upgrade + + # Encryption + at_rest_encryption_enabled = var.at_rest_encryption_enabled + transit_encryption_enabled = var.transit_encryption_enabled + auth_token = var.auth_token_enabled ? var.auth_token : null + + # Logging + dynamic "log_delivery_configuration" { + for_each = var.enable_cloudwatch_logs ? [1] : [] + content { + destination = aws_cloudwatch_log_group.redis[0].name + destination_type = "cloudwatch-logs" + log_format = "json" + log_type = "slow-log" + } + } + + dynamic "log_delivery_configuration" { + for_each = var.enable_cloudwatch_logs ? [1] : [] + content { + destination = aws_cloudwatch_log_group.redis[0].name + destination_type = "cloudwatch-logs" + log_format = "json" + log_type = "engine-log" + } + } + + # Notification topic for events (optional) + notification_topic_arn = var.notification_topic_arn + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-redis" + } + ) +} + +# CloudWatch Log Group for Redis logs +resource "aws_cloudwatch_log_group" "redis" { + count = var.enable_cloudwatch_logs ? 1 : 0 + name = "/aws/elasticache/${var.cluster_name}-redis" + retention_in_days = var.log_retention_days + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-redis-logs" + } + ) +} + +# CloudWatch alarms for monitoring +resource "aws_cloudwatch_metric_alarm" "redis_cpu" { + count = var.enable_alarms ? 1 : 0 + alarm_name = "${var.cluster_name}-redis-cpu-utilization" + alarm_description = "Redis CPU utilization is too high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = var.cpu_threshold + treat_missing_data = "notBreaching" + + dimensions = { + ReplicationGroupId = aws_elasticache_replication_group.redis.id + } + + alarm_actions = var.alarm_actions + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "redis_memory" { + count = var.enable_alarms ? 1 : 0 + alarm_name = "${var.cluster_name}-redis-memory-utilization" + alarm_description = "Redis memory utilization is too high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseMemoryUsagePercentage" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = var.memory_threshold + treat_missing_data = "notBreaching" + + dimensions = { + ReplicationGroupId = aws_elasticache_replication_group.redis.id + } + + alarm_actions = var.alarm_actions + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "redis_evictions" { + count = var.enable_alarms ? 1 : 0 + alarm_name = "${var.cluster_name}-redis-evictions" + alarm_description = "Redis evictions are too high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "Evictions" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = var.evictions_threshold + treat_missing_data = "notBreaching" + + dimensions = { + ReplicationGroupId = aws_elasticache_replication_group.redis.id + } + + alarm_actions = var.alarm_actions + + tags = var.tags +} diff --git a/terraform/modules/elasticache/outputs.tf b/terraform/modules/elasticache/outputs.tf new file mode 100644 index 0000000..7a2ba6f --- /dev/null +++ b/terraform/modules/elasticache/outputs.tf @@ -0,0 +1,34 @@ +output "redis_endpoint" { + description = "Primary endpoint for Redis cluster" + value = aws_elasticache_replication_group.redis.primary_endpoint_address +} + +output "redis_port" { + description = "Port number for Redis" + value = aws_elasticache_replication_group.redis.port +} + +output "redis_reader_endpoint" { + description = "Reader endpoint for Redis cluster (for read replicas)" + value = aws_elasticache_replication_group.redis.reader_endpoint_address +} + +output "redis_configuration_endpoint" { + description = "Configuration endpoint for Redis cluster (cluster mode enabled)" + value = aws_elasticache_replication_group.redis.configuration_endpoint_address +} + +output "redis_replication_group_id" { + description = "ID of the Redis replication group" + value = aws_elasticache_replication_group.redis.id +} + +output "redis_replication_group_arn" { + description = "ARN of the Redis replication group" + value = aws_elasticache_replication_group.redis.arn +} + +output "redis_member_clusters" { + description = "List of member cluster IDs" + value = aws_elasticache_replication_group.redis.member_clusters +} diff --git a/terraform/modules/elasticache/variables.tf b/terraform/modules/elasticache/variables.tf new file mode 100644 index 0000000..69ab22d --- /dev/null +++ b/terraform/modules/elasticache/variables.tf @@ -0,0 +1,161 @@ +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "subnet_ids" { + description = "List of subnet IDs for ElastiCache" + type = list(string) +} + +variable "security_group_id" { + description = "Security group ID for ElastiCache" + type = string +} + +variable "redis_version" { + description = "Redis engine version" + type = string + default = "7.1" +} + +variable "node_type" { + description = "Instance type for Redis nodes" + type = string + default = "cache.t3.micro" +} + +variable "num_cache_nodes" { + description = "Number of cache nodes (1 for standalone, 2+ for replication)" + type = number + default = 2 +} + +variable "parameter_group_family" { + description = "Redis parameter group family" + type = string + default = "redis7" +} + +variable "parameters" { + description = "List of Redis parameters to apply" + type = list(object({ + name = string + value = string + })) + default = [ + { + name = "maxmemory-policy" + value = "allkeys-lru" + } + ] +} + +variable "multi_az_enabled" { + description = "Enable Multi-AZ for automatic failover" + type = bool + default = true +} + +variable "snapshot_retention_limit" { + description = "Number of days to retain snapshots" + type = number + default = 5 +} + +variable "snapshot_window" { + description = "Daily time range for snapshots (UTC)" + type = string + default = "03:00-05:00" +} + +variable "maintenance_window" { + description = "Weekly time range for maintenance (UTC)" + type = string + default = "sun:05:00-sun:07:00" +} + +variable "auto_minor_version_upgrade" { + description = "Enable automatic minor version upgrades" + type = bool + default = true +} + +variable "at_rest_encryption_enabled" { + description = "Enable encryption at rest" + type = bool + default = true +} + +variable "transit_encryption_enabled" { + description = "Enable encryption in transit (TLS)" + type = bool + default = true +} + +variable "auth_token_enabled" { + description = "Enable Redis AUTH token" + type = bool + default = false +} + +variable "auth_token" { + description = "Redis AUTH token (required if auth_token_enabled is true)" + type = string + default = null + sensitive = true +} + +variable "enable_cloudwatch_logs" { + description = "Enable CloudWatch logging" + type = bool + default = true +} + +variable "log_retention_days" { + description = "CloudWatch log retention in days" + type = number + default = 7 +} + +variable "notification_topic_arn" { + description = "ARN of SNS topic for notifications" + type = string + default = "" +} + +variable "enable_alarms" { + description = "Enable CloudWatch alarms" + type = bool + default = true +} + +variable "cpu_threshold" { + description = "CPU utilization threshold for alarm" + type = number + default = 75 +} + +variable "memory_threshold" { + description = "Memory utilization threshold for alarm" + type = number + default = 80 +} + +variable "evictions_threshold" { + description = "Evictions threshold for alarm" + type = number + default = 1000 +} + +variable "alarm_actions" { + description = "List of ARNs to notify when alarm triggers" + type = list(string) + default = [] +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/iam/main.tf b/terraform/modules/iam/main.tf new file mode 100644 index 0000000..14f2206 --- /dev/null +++ b/terraform/modules/iam/main.tf @@ -0,0 +1,95 @@ +# IAM Roles for EKS Cluster and Node Groups + +# EKS Cluster IAM Role +resource "aws_iam_role" "cluster" { + name = "${var.cluster_name}-cluster-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "eks.amazonaws.com" + } + }] + }) + + tags = var.tags +} + +# Attach required policies to EKS cluster role +resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSClusterPolicy" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" + role = aws_iam_role.cluster.name +} + +resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSVPCResourceController" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController" + role = aws_iam_role.cluster.name +} + +# EKS Node Group IAM Role +resource "aws_iam_role" "node_group" { + name = "${var.cluster_name}-node-group-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + }] + }) + + tags = var.tags +} + +# Attach required policies to node group role +resource "aws_iam_role_policy_attachment" "node_group_AmazonEKSWorkerNodePolicy" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy" + role = aws_iam_role.node_group.name +} + +resource "aws_iam_role_policy_attachment" "node_group_AmazonEKS_CNI_Policy" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy" + role = aws_iam_role.node_group.name +} + +resource "aws_iam_role_policy_attachment" "node_group_AmazonEC2ContainerRegistryReadOnly" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" + role = aws_iam_role.node_group.name +} + +# EBS CSI Driver policy (for persistent volumes) +resource "aws_iam_role_policy_attachment" "node_group_AmazonEBSCSIDriverPolicy" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy" + role = aws_iam_role.node_group.name +} + +# IRSA (IAM Roles for Service Accounts) Role for application pods (optional) +resource "aws_iam_role" "pod_execution" { + count = var.create_irsa_role ? 1 : 0 + name = "${var.cluster_name}-pod-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRoleWithWebIdentity" + Effect = "Allow" + Principal = { + Federated = var.oidc_provider_arn + } + Condition = { + StringEquals = { + "${replace(var.oidc_provider_arn, "/^(.*provider/)/", "")}:sub" = "system:serviceaccount:${var.namespace}:${var.service_account_name}" + "${replace(var.oidc_provider_arn, "/^(.*provider/)/", "")}:aud" = "sts.amazonaws.com" + } + } + }] + }) + + tags = var.tags +} diff --git a/terraform/modules/iam/outputs.tf b/terraform/modules/iam/outputs.tf new file mode 100644 index 0000000..f627dc4 --- /dev/null +++ b/terraform/modules/iam/outputs.tf @@ -0,0 +1,24 @@ +output "cluster_role_arn" { + description = "ARN of the EKS cluster IAM role" + value = aws_iam_role.cluster.arn +} + +output "cluster_role_name" { + description = "Name of the EKS cluster IAM role" + value = aws_iam_role.cluster.name +} + +output "node_group_role_arn" { + description = "ARN of the EKS node group IAM role" + value = aws_iam_role.node_group.arn +} + +output "node_group_role_name" { + description = "Name of the EKS node group IAM role" + value = aws_iam_role.node_group.name +} + +output "pod_execution_role_arn" { + description = "ARN of the pod execution IAM role (IRSA - if created)" + value = var.create_irsa_role ? aws_iam_role.pod_execution[0].arn : null +} diff --git a/terraform/modules/iam/variables.tf b/terraform/modules/iam/variables.tf new file mode 100644 index 0000000..1621166 --- /dev/null +++ b/terraform/modules/iam/variables.tf @@ -0,0 +1,34 @@ +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "oidc_provider_arn" { + description = "ARN of the OIDC provider for IRSA" + type = string + default = "" +} + +variable "namespace" { + description = "Kubernetes namespace for the application" + type = string + default = "hivebox" +} + +variable "service_account_name" { + description = "Name of the Kubernetes service account" + type = string + default = "hivebox-sa" +} + +variable "create_irsa_role" { + description = "Whether to create IRSA role for service accounts" + type = bool + default = false +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/node-group/main.tf b/terraform/modules/node-group/main.tf new file mode 100644 index 0000000..83ee6ae --- /dev/null +++ b/terraform/modules/node-group/main.tf @@ -0,0 +1,113 @@ +# EKS Node Group Module + +resource "aws_eks_node_group" "main" { + cluster_name = var.cluster_name + node_group_name = "${var.cluster_name}-node-group" + node_role_arn = var.node_role_arn + subnet_ids = var.subnet_ids + version = var.kubernetes_version + + scaling_config { + desired_size = var.desired_size + max_size = var.max_size + min_size = var.min_size + } + + update_config { + max_unavailable_percentage = var.max_unavailable_percentage + } + + instance_types = var.instance_types + capacity_type = var.capacity_type + disk_size = var.disk_size + + # Remote access configuration (optional) + dynamic "remote_access" { + for_each = var.ec2_ssh_key != "" ? [1] : [] + content { + ec2_ssh_key = var.ec2_ssh_key + source_security_group_ids = var.ssh_security_group_ids + } + } + + # Launch template configuration + dynamic "launch_template" { + for_each = var.launch_template_id != "" ? [1] : [] + content { + id = var.launch_template_id + version = var.launch_template_version + } + } + + labels = merge( + var.labels, + { + "node-group" = "${var.cluster_name}-node-group" + } + ) + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-node-group" + } + ) + + # Taint configuration for dedicated workloads (optional) + dynamic "taint" { + for_each = var.taints + content { + key = taint.value.key + value = taint.value.value + effect = taint.value.effect + } + } + + lifecycle { + create_before_destroy = true + ignore_changes = [scaling_config[0].desired_size] + } + + depends_on = [ + var.node_role_arn + ] +} + +# Auto Scaling Group Tags for Cluster Autoscaler +resource "aws_autoscaling_group_tag" "cluster_autoscaler_enabled" { + for_each = toset( + data.aws_autoscaling_groups.node_group.names + ) + + autoscaling_group_name = each.value + + tag { + key = "k8s.io/cluster-autoscaler/enabled" + value = "true" + propagate_at_launch = false + } +} + +resource "aws_autoscaling_group_tag" "cluster_autoscaler_cluster_name" { + for_each = toset( + data.aws_autoscaling_groups.node_group.names + ) + + autoscaling_group_name = each.value + + tag { + key = "k8s.io/cluster-autoscaler/${var.cluster_name}" + value = "owned" + propagate_at_launch = false + } +} + +# Data source to get ASG names +data "aws_autoscaling_groups" "node_group" { + filter { + name = "tag:eks:nodegroup-name" + values = [aws_eks_node_group.main.node_group_name] + } + + depends_on = [aws_eks_node_group.main] +} diff --git a/terraform/modules/node-group/outputs.tf b/terraform/modules/node-group/outputs.tf new file mode 100644 index 0000000..e680aad --- /dev/null +++ b/terraform/modules/node-group/outputs.tf @@ -0,0 +1,24 @@ +output "node_group_id" { + description = "ID of the EKS node group" + value = aws_eks_node_group.main.id +} + +output "node_group_arn" { + description = "ARN of the EKS node group" + value = aws_eks_node_group.main.arn +} + +output "node_group_status" { + description = "Status of the EKS node group" + value = aws_eks_node_group.main.status +} + +output "node_group_resources" { + description = "Resources associated with the node group" + value = aws_eks_node_group.main.resources +} + +output "autoscaling_group_names" { + description = "Names of the Auto Scaling Groups" + value = data.aws_autoscaling_groups.node_group.names +} diff --git a/terraform/modules/node-group/variables.tf b/terraform/modules/node-group/variables.tf new file mode 100644 index 0000000..2dafb4f --- /dev/null +++ b/terraform/modules/node-group/variables.tf @@ -0,0 +1,108 @@ +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "node_role_arn" { + description = "ARN of the IAM role for node group" + type = string +} + +variable "subnet_ids" { + description = "List of subnet IDs for node group" + type = list(string) +} + +variable "kubernetes_version" { + description = "Kubernetes version for node group" + type = string + default = null +} + +variable "desired_size" { + description = "Desired number of worker nodes" + type = number + default = 2 +} + +variable "max_size" { + description = "Maximum number of worker nodes" + type = number + default = 4 +} + +variable "min_size" { + description = "Minimum number of worker nodes" + type = number + default = 1 +} + +variable "max_unavailable_percentage" { + description = "Maximum percentage of nodes unavailable during update" + type = number + default = 33 +} + +variable "instance_types" { + description = "List of instance types for node group" + type = list(string) + default = ["t3.medium"] +} + +variable "capacity_type" { + description = "Type of capacity (ON_DEMAND or SPOT)" + type = string + default = "ON_DEMAND" +} + +variable "disk_size" { + description = "Disk size in GiB for worker nodes" + type = number + default = 20 +} + +variable "ec2_ssh_key" { + description = "EC2 Key Pair name for SSH access to nodes" + type = string + default = "" +} + +variable "ssh_security_group_ids" { + description = "List of security group IDs allowed to SSH to nodes" + type = list(string) + default = [] +} + +variable "launch_template_id" { + description = "ID of custom launch template (optional)" + type = string + default = "" +} + +variable "launch_template_version" { + description = "Version of launch template to use" + type = string + default = "$Latest" +} + +variable "labels" { + description = "Kubernetes labels to apply to nodes" + type = map(string) + default = {} +} + +variable "taints" { + description = "List of Kubernetes taints to apply to nodes" + type = list(object({ + key = string + value = string + effect = string + })) + default = [] +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/s3/main.tf b/terraform/modules/s3/main.tf new file mode 100644 index 0000000..a60c366 --- /dev/null +++ b/terraform/modules/s3/main.tf @@ -0,0 +1,210 @@ +# S3 Module for Object Storage +# Replaces MinIO for temperature data storage + +resource "aws_s3_bucket" "main" { + bucket = var.bucket_name + force_destroy = var.force_destroy + + tags = merge( + var.tags, + { + Name = var.bucket_name + } + ) +} + +# Bucket versioning +resource "aws_s3_bucket_versioning" "main" { + bucket = aws_s3_bucket.main.id + + versioning_configuration { + status = var.versioning_enabled ? "Enabled" : "Disabled" + } +} + +# Server-side encryption +resource "aws_s3_bucket_server_side_encryption_configuration" "main" { + bucket = aws_s3_bucket.main.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = var.kms_key_id != "" ? "aws:kms" : "AES256" + kms_master_key_id = var.kms_key_id != "" ? var.kms_key_id : null + } + bucket_key_enabled = var.kms_key_id != "" ? true : false + } +} + +# Block public access +resource "aws_s3_bucket_public_access_block" "main" { + bucket = aws_s3_bucket.main.id + + block_public_acls = var.block_public_access + block_public_policy = var.block_public_access + ignore_public_acls = var.block_public_access + restrict_public_buckets = var.block_public_access +} + +# Lifecycle rules for data management +resource "aws_s3_bucket_lifecycle_configuration" "main" { + count = var.enable_lifecycle_rules ? 1 : 0 + bucket = aws_s3_bucket.main.id + + rule { + id = "transition-to-ia" + status = "Enabled" + + transition { + days = var.transition_to_ia_days + storage_class = "STANDARD_IA" + } + + filter { + prefix = var.lifecycle_prefix + } + } + + rule { + id = "transition-to-glacier" + status = "Enabled" + + transition { + days = var.transition_to_glacier_days + storage_class = "GLACIER" + } + + filter { + prefix = var.lifecycle_prefix + } + } + + rule { + id = "expire-old-data" + status = var.enable_expiration ? "Enabled" : "Disabled" + + expiration { + days = var.expiration_days + } + + filter { + prefix = var.lifecycle_prefix + } + } + + # Clean up incomplete multipart uploads + rule { + id = "abort-incomplete-multipart-upload" + status = "Enabled" + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + + filter {} + } +} + +# CORS configuration (if needed for web access) +resource "aws_s3_bucket_cors_configuration" "main" { + count = var.enable_cors ? 1 : 0 + bucket = aws_s3_bucket.main.id + + cors_rule { + allowed_headers = var.cors_allowed_headers + allowed_methods = var.cors_allowed_methods + allowed_origins = var.cors_allowed_origins + expose_headers = var.cors_expose_headers + max_age_seconds = var.cors_max_age_seconds + } +} + +# Bucket policy for access control +resource "aws_s3_bucket_policy" "main" { + bucket = aws_s3_bucket.main.id + policy = data.aws_iam_policy_document.bucket_policy.json +} + +data "aws_iam_policy_document" "bucket_policy" { + # Allow SSL requests only + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = [ + "s3:*" + ] + + resources = [ + aws_s3_bucket.main.arn, + "${aws_s3_bucket.main.arn}/*" + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } + + # Additional custom policy statements + dynamic "statement" { + for_each = var.additional_policy_statements + content { + sid = statement.value.sid + effect = statement.value.effect + actions = statement.value.actions + resources = statement.value.resources + + dynamic "principals" { + for_each = statement.value.principals + content { + type = principals.value.type + identifiers = principals.value.identifiers + } + } + + dynamic "condition" { + for_each = lookup(statement.value, "conditions", []) + content { + test = condition.value.test + variable = condition.value.variable + values = condition.value.values + } + } + } + } +} + +# Logging bucket (optional) +resource "aws_s3_bucket" "logs" { + count = var.enable_logging ? 1 : 0 + bucket = "${var.bucket_name}-logs" + force_destroy = var.force_destroy + + tags = merge( + var.tags, + { + Name = "${var.bucket_name}-logs" + } + ) +} + +resource "aws_s3_bucket_logging" "main" { + count = var.enable_logging ? 1 : 0 + bucket = aws_s3_bucket.main.id + + target_bucket = aws_s3_bucket.logs[0].id + target_prefix = "s3-access-logs/" +} + +# CloudWatch metrics for monitoring +resource "aws_s3_bucket_metric" "main" { + count = var.enable_metrics ? 1 : 0 + bucket = aws_s3_bucket.main.id + name = "EntireBucket" +} diff --git a/terraform/modules/s3/outputs.tf b/terraform/modules/s3/outputs.tf new file mode 100644 index 0000000..97bde69 --- /dev/null +++ b/terraform/modules/s3/outputs.tf @@ -0,0 +1,34 @@ +output "bucket_id" { + description = "ID of the S3 bucket" + value = aws_s3_bucket.main.id +} + +output "bucket_arn" { + description = "ARN of the S3 bucket" + value = aws_s3_bucket.main.arn +} + +output "bucket_domain_name" { + description = "Domain name of the S3 bucket" + value = aws_s3_bucket.main.bucket_domain_name +} + +output "bucket_regional_domain_name" { + description = "Regional domain name of the S3 bucket" + value = aws_s3_bucket.main.bucket_regional_domain_name +} + +output "bucket_region" { + description = "Region of the S3 bucket" + value = aws_s3_bucket.main.region +} + +output "logs_bucket_id" { + description = "ID of the S3 logs bucket (if enabled)" + value = var.enable_logging ? aws_s3_bucket.logs[0].id : null +} + +output "logs_bucket_arn" { + description = "ARN of the S3 logs bucket (if enabled)" + value = var.enable_logging ? aws_s3_bucket.logs[0].arn : null +} diff --git a/terraform/modules/s3/variables.tf b/terraform/modules/s3/variables.tf new file mode 100644 index 0000000..0fcf669 --- /dev/null +++ b/terraform/modules/s3/variables.tf @@ -0,0 +1,138 @@ +variable "bucket_name" { + description = "Name of the S3 bucket" + type = string +} + +variable "force_destroy" { + description = "Allow bucket to be destroyed even if it contains objects" + type = bool + default = false +} + +variable "versioning_enabled" { + description = "Enable bucket versioning" + type = bool + default = true +} + +variable "kms_key_id" { + description = "KMS key ID for encryption (optional, uses AES256 if not specified)" + type = string + default = "" +} + +variable "block_public_access" { + description = "Block all public access to the bucket" + type = bool + default = true +} + +variable "enable_lifecycle_rules" { + description = "Enable lifecycle rules for data management" + type = bool + default = true +} + +variable "lifecycle_prefix" { + description = "Prefix for lifecycle rules" + type = string + default = "" +} + +variable "transition_to_ia_days" { + description = "Days until transition to Infrequent Access storage" + type = number + default = 30 +} + +variable "transition_to_glacier_days" { + description = "Days until transition to Glacier storage" + type = number + default = 90 +} + +variable "enable_expiration" { + description = "Enable object expiration" + type = bool + default = false +} + +variable "expiration_days" { + description = "Days until objects expire (deleted)" + type = number + default = 365 +} + +variable "enable_cors" { + description = "Enable CORS configuration" + type = bool + default = false +} + +variable "cors_allowed_headers" { + description = "List of allowed headers for CORS" + type = list(string) + default = ["*"] +} + +variable "cors_allowed_methods" { + description = "List of allowed methods for CORS" + type = list(string) + default = ["GET", "PUT", "POST", "DELETE"] +} + +variable "cors_allowed_origins" { + description = "List of allowed origins for CORS" + type = list(string) + default = ["*"] +} + +variable "cors_expose_headers" { + description = "List of headers to expose in CORS" + type = list(string) + default = ["ETag"] +} + +variable "cors_max_age_seconds" { + description = "Max age for CORS preflight requests" + type = number + default = 3000 +} + +variable "additional_policy_statements" { + description = "Additional IAM policy statements for bucket policy" + type = list(object({ + sid = string + effect = string + actions = list(string) + resources = list(string) + principals = list(object({ + type = string + identifiers = list(string) + })) + conditions = optional(list(object({ + test = string + variable = string + values = list(string) + }))) + })) + default = [] +} + +variable "enable_logging" { + description = "Enable S3 access logging" + type = bool + default = false +} + +variable "enable_metrics" { + description = "Enable CloudWatch metrics" + type = bool + default = true +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/security-groups/main.tf b/terraform/modules/security-groups/main.tf new file mode 100644 index 0000000..5ef4a4c --- /dev/null +++ b/terraform/modules/security-groups/main.tf @@ -0,0 +1,128 @@ +# Security Groups for EKS and Application Load Balancer + +# EKS Cluster Security Group +resource "aws_security_group" "cluster" { + name = "${var.cluster_name}-cluster-sg" + description = "Security group for EKS cluster" + vpc_id = var.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-cluster-sg" + } + ) +} + +# Node Group Security Group +resource "aws_security_group" "node_group" { + name = "${var.cluster_name}-node-sg" + description = "Security group for EKS node group" + vpc_id = var.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-node-sg" + "kubernetes.io/cluster/${var.cluster_name}" = "owned" + } + ) +} + +# Allow nodes to communicate with each other +resource "aws_security_group_rule" "node_to_node" { + type = "ingress" + from_port = 0 + to_port = 65535 + protocol = "-1" + security_group_id = aws_security_group.node_group.id + source_security_group_id = aws_security_group.node_group.id + description = "Allow nodes to communicate with each other" +} + +# Allow worker nodes to receive communication from cluster control plane +resource "aws_security_group_rule" "cluster_to_node" { + type = "ingress" + from_port = 1025 + to_port = 65535 + protocol = "tcp" + security_group_id = aws_security_group.node_group.id + source_security_group_id = aws_security_group.cluster.id + description = "Allow worker nodes to receive communication from cluster control plane" +} + +# Allow cluster control plane to receive communication from worker nodes +resource "aws_security_group_rule" "node_to_cluster" { + type = "ingress" + from_port = 443 + to_port = 443 + protocol = "tcp" + security_group_id = aws_security_group.cluster.id + source_security_group_id = aws_security_group.node_group.id + description = "Allow cluster control plane to receive communication from worker nodes" +} + +# Application Load Balancer Security Group +resource "aws_security_group" "alb" { + name = "${var.cluster_name}-alb-sg" + description = "Security group for Application Load Balancer" + vpc_id = var.vpc_id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow HTTP traffic from internet" + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow HTTPS traffic from internet" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-alb-sg" + } + ) +} + +# Allow ALB to communicate with nodes +resource "aws_security_group_rule" "alb_to_nodes" { + type = "ingress" + from_port = 0 + to_port = 65535 + protocol = "tcp" + security_group_id = aws_security_group.node_group.id + source_security_group_id = aws_security_group.alb.id + description = "Allow ALB to communicate with nodes" +} diff --git a/terraform/modules/security-groups/outputs.tf b/terraform/modules/security-groups/outputs.tf new file mode 100644 index 0000000..35c5f2b --- /dev/null +++ b/terraform/modules/security-groups/outputs.tf @@ -0,0 +1,14 @@ +output "cluster_security_group_id" { + description = "ID of the EKS cluster security group" + value = aws_security_group.cluster.id +} + +output "node_security_group_id" { + description = "ID of the EKS node group security group" + value = aws_security_group.node_group.id +} + +output "alb_security_group_id" { + description = "ID of the Application Load Balancer security group" + value = aws_security_group.alb.id +} diff --git a/terraform/modules/security-groups/variables.tf b/terraform/modules/security-groups/variables.tf new file mode 100644 index 0000000..ee1bc82 --- /dev/null +++ b/terraform/modules/security-groups/variables.tf @@ -0,0 +1,15 @@ +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "vpc_id" { + description = "ID of the VPC" + type = string +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/vpc/main.tf b/terraform/modules/vpc/main.tf new file mode 100644 index 0000000..7924307 --- /dev/null +++ b/terraform/modules/vpc/main.tf @@ -0,0 +1,148 @@ +# VPC Module for EKS Cluster +# Creates a VPC with public and private subnets across multiple AZs + +data "aws_availability_zones" "available" { + state = "available" +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-vpc" + "kubernetes.io/cluster/${var.cluster_name}" = "shared" + } + ) +} + +# Internet Gateway +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-igw" + } + ) +} + +# Public Subnets +resource "aws_subnet" "public" { + count = var.availability_zones_count + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) + availability_zone = data.aws_availability_zones.available.names[count.index] + map_public_ip_on_launch = true + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-public-${data.aws_availability_zones.available.names[count.index]}" + "kubernetes.io/cluster/${var.cluster_name}" = "shared" + "kubernetes.io/role/elb" = "1" + } + ) +} + +# Private Subnets +resource "aws_subnet" "private" { + count = var.availability_zones_count + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + var.availability_zones_count) + availability_zone = data.aws_availability_zones.available.names[count.index] + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-private-${data.aws_availability_zones.available.names[count.index]}" + "kubernetes.io/cluster/${var.cluster_name}" = "shared" + "kubernetes.io/role/internal-elb" = "1" + } + ) +} + +# Elastic IPs for NAT Gateways +resource "aws_eip" "nat" { + count = var.availability_zones_count + domain = "vpc" + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-nat-eip-${count.index + 1}" + } + ) + + depends_on = [aws_internet_gateway.main] +} + +# NAT Gateways +resource "aws_nat_gateway" "main" { + count = var.availability_zones_count + allocation_id = aws_eip.nat[count.index].id + subnet_id = aws_subnet.public[count.index].id + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-nat-${count.index + 1}" + } + ) + + depends_on = [aws_internet_gateway.main] +} + +# Route Table for Public Subnets +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-public-rt" + } + ) +} + +# Route Table Associations for Public Subnets +resource "aws_route_table_association" "public" { + count = var.availability_zones_count + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +# Route Tables for Private Subnets +resource "aws_route_table" "private" { + count = var.availability_zones_count + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main[count.index].id + } + + tags = merge( + var.tags, + { + Name = "${var.cluster_name}-private-rt-${count.index + 1}" + } + ) +} + +# Route Table Associations for Private Subnets +resource "aws_route_table_association" "private" { + count = var.availability_zones_count + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[count.index].id +} diff --git a/terraform/modules/vpc/outputs.tf b/terraform/modules/vpc/outputs.tf new file mode 100644 index 0000000..ea9543d --- /dev/null +++ b/terraform/modules/vpc/outputs.tf @@ -0,0 +1,29 @@ +output "vpc_id" { + description = "ID of the VPC" + value = aws_vpc.main.id +} + +output "vpc_cidr" { + description = "CIDR block of the VPC" + value = aws_vpc.main.cidr_block +} + +output "public_subnet_ids" { + description = "IDs of the public subnets" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "IDs of the private subnets" + value = aws_subnet.private[*].id +} + +output "nat_gateway_ids" { + description = "IDs of the NAT Gateways" + value = aws_nat_gateway.main[*].id +} + +output "internet_gateway_id" { + description = "ID of the Internet Gateway" + value = aws_internet_gateway.main.id +} diff --git a/terraform/modules/vpc/variables.tf b/terraform/modules/vpc/variables.tf new file mode 100644 index 0000000..48876d3 --- /dev/null +++ b/terraform/modules/vpc/variables.tf @@ -0,0 +1,22 @@ +variable "vpc_cidr" { + description = "CIDR block for VPC" + type = string + default = "10.0.0.0/16" +} + +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "availability_zones_count" { + description = "Number of availability zones to use" + type = number + default = 2 +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..744cb23 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,119 @@ +# VPC Outputs +output "vpc_id" { + description = "ID of the VPC" + value = module.vpc.vpc_id +} + +output "vpc_cidr" { + description = "CIDR block of the VPC" + value = module.vpc.vpc_cidr +} + +output "private_subnet_ids" { + description = "IDs of the private subnets" + value = module.vpc.private_subnet_ids +} + +output "public_subnet_ids" { + description = "IDs of the public subnets" + value = module.vpc.public_subnet_ids +} + +# EKS Cluster Outputs +output "cluster_name" { + description = "Name of the EKS cluster" + value = module.eks.cluster_name +} + +output "cluster_endpoint" { + description = "Endpoint for EKS cluster API server" + value = module.eks.cluster_endpoint +} + +output "cluster_version" { + description = "Kubernetes version of the cluster" + value = module.eks.cluster_version +} + +output "cluster_certificate_authority_data" { + description = "Base64 encoded certificate data for cluster authentication" + value = module.eks.cluster_certificate_authority_data + sensitive = true +} + +output "cluster_oidc_issuer_url" { + description = "OIDC issuer URL for the cluster" + value = module.eks.cluster_oidc_issuer_url +} + +output "oidc_provider_arn" { + description = "ARN of the OIDC provider for IRSA" + value = module.eks.oidc_provider_arn +} + +# Node Group Outputs +output "node_group_id" { + description = "ID of the EKS node group" + value = module.node_group.node_group_id +} + +output "node_group_status" { + description = "Status of the EKS node group" + value = module.node_group.node_group_status +} + +output "autoscaling_group_names" { + description = "Names of the Auto Scaling Groups" + value = module.node_group.autoscaling_group_names +} + +# IAM Outputs +output "cluster_role_arn" { + description = "ARN of the EKS cluster IAM role" + value = module.iam.cluster_role_arn +} + +output "node_group_role_arn" { + description = "ARN of the EKS node group IAM role" + value = module.iam.node_group_role_arn +} + +output "pod_execution_role_arn" { + description = "ARN of the pod execution IAM role (IRSA - if created)" + value = module.iam.pod_execution_role_arn +} + +# Security Group Outputs +output "cluster_security_group_id" { + description = "ID of the EKS cluster security group" + value = module.security_groups.cluster_security_group_id +} + +output "node_security_group_id" { + description = "ID of the EKS node group security group" + value = module.security_groups.node_security_group_id +} + +output "alb_security_group_id" { + description = "ID of the Application Load Balancer security group" + value = module.security_groups.alb_security_group_id +} + +# Kubeconfig Command +output "configure_kubectl" { + description = "Command to configure kubectl for the EKS cluster" + value = "aws eks update-kubeconfig --region ${var.aws_region} --name ${module.eks.cluster_name} --profile ${var.aws_profile}" +} + +# In-Cluster Services Note +output "in_cluster_services" { + description = "Services running inside the Kubernetes cluster" + value = { + note = "MinIO and Valkey/Redis run as in-cluster services. Deploy them using Helm or Kustomize." + redis_host = "redis-service (ClusterIP service in hivebox namespace)" + redis_port = 6379 + minio_host = "minio-service (ClusterIP service in hivebox namespace)" + minio_port = 9000 + namespace = var.kubernetes_namespace + } +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..4047507 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,50 @@ +# AWS Provider Configuration +# Copy this file to terraform.tfvars and customize the values +# DO NOT commit terraform.tfvars to version control + +aws_region = "us-east-2" +aws_profile = "Gabriel-Admin" + +# Common Tags +common_tags = { + Environment = "production" + Project = "HiveBox" + Team = "DevOps" + Owner = "YourName" + ManagedBy = "Terraform" +} + +# EKS Cluster Configuration +cluster_name = "hivebox-eks" +kubernetes_version = "1.31" +cluster_endpoint_private_access = true +cluster_endpoint_public_access = true + +# Restrict public access to specific IPs for better security +# cluster_public_access_cidrs = ["YOUR_IP/32"] +cluster_public_access_cidrs = ["0.0.0.0/0"] + +cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"] + +# VPC Configuration +vpc_cidr = "10.0.0.0/16" +availability_zones_count = 2 # Use 2 AZs for high availability + +# Node Group Configuration +node_group_desired_size = 2 +node_group_max_size = 4 +node_group_min_size = 1 +node_group_instance_types = ["t3.medium"] # Suitable for moderate workloads +node_group_capacity_type = "ON_DEMAND" # or "SPOT" for cost savings +node_group_disk_size = 20 + +# Kubernetes Application Configuration +kubernetes_namespace = "hivebox" +kubernetes_service_account = "hivebox-sa" +create_irsa_role = false # Set to true if you need IRSA for AWS service access + +# NOTE: MinIO and Valkey/Redis will run as in-cluster services +# Deploy them using your existing Helm charts or Kustomize manifests +# They will use: +# - redis-service (ClusterIP) on port 6379 +# - minio-service (ClusterIP) on port 9000 diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..3ff3754 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,129 @@ +# AWS Provider Configuration +variable "aws_region" { + description = "AWS region for resources" + type = string + default = "us-east-2" +} + +variable "aws_profile" { + description = "AWS CLI profile to use" + type = string + default = "Gabriel-Admin" +} + +# Common Tags +variable "common_tags" { + description = "Common tags to apply to all resources" + type = map(string) + default = { + Environment = "production" + Project = "HiveBox" + ManagedBy = "Terraform" + } +} + +# EKS Cluster Configuration +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string + default = "hivebox-eks" +} + +variable "kubernetes_version" { + description = "Kubernetes version for EKS cluster" + type = string + default = "1.31" +} + +variable "cluster_endpoint_private_access" { + description = "Enable private API server endpoint" + type = bool + default = true +} + +variable "cluster_endpoint_public_access" { + description = "Enable public API server endpoint" + type = bool + default = true +} + +variable "cluster_public_access_cidrs" { + description = "List of CIDR blocks that can access the public API server endpoint" + type = list(string) + default = ["0.0.0.0/0"] +} + +variable "cluster_log_types" { + description = "List of control plane logging types to enable" + type = list(string) + default = ["api", "audit", "authenticator", "controllerManager", "scheduler"] +} + +# VPC Configuration +variable "vpc_cidr" { + description = "CIDR block for VPC" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones_count" { + description = "Number of availability zones to use" + type = number + default = 2 +} + +# Node Group Configuration +variable "node_group_desired_size" { + description = "Desired number of worker nodes" + type = number + default = 2 +} + +variable "node_group_max_size" { + description = "Maximum number of worker nodes" + type = number + default = 4 +} + +variable "node_group_min_size" { + description = "Minimum number of worker nodes" + type = number + default = 1 +} + +variable "node_group_instance_types" { + description = "List of instance types for node group" + type = list(string) + default = ["t3.medium"] +} + +variable "node_group_capacity_type" { + description = "Type of capacity (ON_DEMAND or SPOT)" + type = string + default = "ON_DEMAND" +} + +variable "node_group_disk_size" { + description = "Disk size in GiB for worker nodes" + type = number + default = 20 +} + +# Kubernetes Application Configuration +variable "kubernetes_namespace" { + description = "Kubernetes namespace for the HiveBox application" + type = string + default = "hivebox" +} + +variable "kubernetes_service_account" { + description = "Name of the Kubernetes service account for IRSA" + type = string + default = "hivebox-sa" +} + +variable "create_irsa_role" { + description = "Whether to create IRSA role for service accounts" + type = bool + default = false +} diff --git a/tests/fixtures/vcr_cassettes/metrics.yaml b/tests/fixtures/vcr_cassettes/metrics.yaml index 65879d0..01c4d69 100644 --- a/tests/fixtures/vcr_cassettes/metrics.yaml +++ b/tests/fixtures/vcr_cassettes/metrics.yaml @@ -1,107 +1,107 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - python-requests/2.31.0 - method: GET - uri: http://127.0.0.1:5000/metrics - response: - body: - string: '# HELP python_gc_objects_collected_total Objects collected during gc - - # TYPE python_gc_objects_collected_total counter - - python_gc_objects_collected_total{generation="0"} 352.0 - - python_gc_objects_collected_total{generation="1"} 25.0 - - python_gc_objects_collected_total{generation="2"} 0.0 - - # HELP python_gc_objects_uncollectable_total Uncollectable objects found during - GC - - # TYPE python_gc_objects_uncollectable_total counter - - python_gc_objects_uncollectable_total{generation="0"} 0.0 - - python_gc_objects_uncollectable_total{generation="1"} 0.0 - - python_gc_objects_uncollectable_total{generation="2"} 0.0 - - # HELP python_gc_collections_total Number of times this generation was collected - - # TYPE python_gc_collections_total counter - - python_gc_collections_total{generation="0"} 30.0 - - python_gc_collections_total{generation="1"} 2.0 - - python_gc_collections_total{generation="2"} 0.0 - - # HELP python_info Python platform information - - # TYPE python_info gauge - - python_info{implementation="CPython",major="3",minor="13",patchlevel="5",version="3.13.5"} - 1.0 - - # HELP process_virtual_memory_bytes Virtual memory size in bytes. - - # TYPE process_virtual_memory_bytes gauge - - process_virtual_memory_bytes 4.7783936e+07 - - # HELP process_resident_memory_bytes Resident memory size in bytes. - - # TYPE process_resident_memory_bytes gauge - - process_resident_memory_bytes 4.12672e+07 - - # HELP process_start_time_seconds Start time of the process since unix epoch - in seconds. - - # TYPE process_start_time_seconds gauge - - process_start_time_seconds 1.75087194069e+09 - - # HELP process_cpu_seconds_total Total user and system CPU time spent in seconds. - - # TYPE process_cpu_seconds_total counter - - process_cpu_seconds_total 1.21 - - # HELP process_open_fds Number of open file descriptors. - - # TYPE process_open_fds gauge - - process_open_fds 6.0 - - # HELP process_max_fds Maximum number of open file descriptors. - - # TYPE process_max_fds gauge - - process_max_fds 1.048576e+06 - - ' - headers: - Connection: - - close - Content-Length: - - '1891' - Content-Type: - - text/plain; version=0.0.4; charset=utf-8; charset=utf-8 - Date: - - Wed, 25 Jun 2025 17:21:04 GMT - Server: - - Werkzeug/3.1.3 Python/3.13.5 - status: - code: 200 - message: OK -version: 1 +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://127.0.0.1:5000/metrics + response: + body: + string: '# HELP python_gc_objects_collected_total Objects collected during gc + + # TYPE python_gc_objects_collected_total counter + + python_gc_objects_collected_total{generation="0"} 352.0 + + python_gc_objects_collected_total{generation="1"} 25.0 + + python_gc_objects_collected_total{generation="2"} 0.0 + + # HELP python_gc_objects_uncollectable_total Uncollectable objects found during + GC + + # TYPE python_gc_objects_uncollectable_total counter + + python_gc_objects_uncollectable_total{generation="0"} 0.0 + + python_gc_objects_uncollectable_total{generation="1"} 0.0 + + python_gc_objects_uncollectable_total{generation="2"} 0.0 + + # HELP python_gc_collections_total Number of times this generation was collected + + # TYPE python_gc_collections_total counter + + python_gc_collections_total{generation="0"} 30.0 + + python_gc_collections_total{generation="1"} 2.0 + + python_gc_collections_total{generation="2"} 0.0 + + # HELP python_info Python platform information + + # TYPE python_info gauge + + python_info{implementation="CPython",major="3",minor="13",patchlevel="5",version="3.13.5"} + 1.0 + + # HELP process_virtual_memory_bytes Virtual memory size in bytes. + + # TYPE process_virtual_memory_bytes gauge + + process_virtual_memory_bytes 4.7783936e+07 + + # HELP process_resident_memory_bytes Resident memory size in bytes. + + # TYPE process_resident_memory_bytes gauge + + process_resident_memory_bytes 4.12672e+07 + + # HELP process_start_time_seconds Start time of the process since unix epoch + in seconds. + + # TYPE process_start_time_seconds gauge + + process_start_time_seconds 1.75087194069e+09 + + # HELP process_cpu_seconds_total Total user and system CPU time spent in seconds. + + # TYPE process_cpu_seconds_total counter + + process_cpu_seconds_total 1.21 + + # HELP process_open_fds Number of open file descriptors. + + # TYPE process_open_fds gauge + + process_open_fds 6.0 + + # HELP process_max_fds Maximum number of open file descriptors. + + # TYPE process_max_fds gauge + + process_max_fds 1.048576e+06 + + ' + headers: + Connection: + - close + Content-Length: + - '1891' + Content-Type: + - text/plain; version=0.0.4; charset=utf-8; charset=utf-8 + Date: + - Wed, 25 Jun 2025 17:21:04 GMT + Server: + - Werkzeug/3.1.3 Python/3.13.5 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/fixtures/vcr_cassettes/temperature.yaml b/tests/fixtures/vcr_cassettes/temperature.yaml index e3ed554..b4329d4 100644 --- a/tests/fixtures/vcr_cassettes/temperature.yaml +++ b/tests/fixtures/vcr_cassettes/temperature.yaml @@ -1,32 +1,32 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - python-requests/2.31.0 - method: GET - uri: http://127.0.0.1:5000/temperature - response: - body: - string: "Average temperature: 21.01 \xB0C (Good)\n" - headers: - Connection: - - close - Content-Length: - - '38' - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 25 Jun 2025 17:21:04 GMT - Server: - - Werkzeug/3.1.3 Python/3.13.5 - status: - code: 200 - message: OK -version: 1 +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://127.0.0.1:5000/temperature + response: + body: + string: "Average temperature: 21.01 \xB0C (Good)\n" + headers: + Connection: + - close + Content-Length: + - '38' + Content-Type: + - text/html; charset=utf-8 + Date: + - Wed, 25 Jun 2025 17:21:04 GMT + Server: + - Werkzeug/3.1.3 Python/3.13.5 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/fixtures/vcr_cassettes/version.yaml b/tests/fixtures/vcr_cassettes/version.yaml index 849ca34..7f67808 100644 --- a/tests/fixtures/vcr_cassettes/version.yaml +++ b/tests/fixtures/vcr_cassettes/version.yaml @@ -1,32 +1,32 @@ -interactions: - - request: - body: null - headers: - Accept: - - "*/*" - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - python-requests/2.31.0 - method: GET - uri: http://127.0.0.1:5000/version - response: - body: - string: "Current app version: 0.7.0" - headers: - Connection: - - close - Content-Length: - - "27" - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 25 Jun 2025 17:19:51 GMT - Server: - - Werkzeug/3.1.3 Python/3.13.5 - status: - code: 200 - message: OK -version: 1 +interactions: + - request: + body: null + headers: + Accept: + - "*/*" + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://127.0.0.1:5000/version + response: + body: + string: "Current app version: 0.7.0" + headers: + Connection: + - close + Content-Length: + - "27" + Content-Type: + - text/html; charset=utf-8 + Date: + - Wed, 25 Jun 2025 17:19:51 GMT + Server: + - Werkzeug/3.1.3 Python/3.13.5 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_main.py b/tests/test_main.py index ac133a7..aaf804a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,86 +1,86 @@ -'''This module contains the test cases for the main module with VCR cassettes.''' -import sys -import re -import os -import requests -import vcr - -API_HOST = os.environ.get('API_HOST', 'http://127.0.0.1:5000') - -my_vcr = vcr.VCR( - cassette_library_dir='tests/fixtures/vcr_cassettes', - record_mode='once', - match_on=['uri', 'method'], -) - -def make_request(url, expected_pattern): - '''Reusable function to make requests and assert responses.''' - try: - response = requests.get(url, timeout=240) - print(f"Response: {response.text}") - - # Check status code - assert response.status_code == 200, "The request was not successful." - - # Check response content - match = re.search(expected_pattern, response.text) - assert match is not None, f"Pattern '{expected_pattern}' not found in response" - - return True, match - - except requests.exceptions.RequestException as e: - print(f"❌ TEST FAILED: Network error: {str(e)}") - return False, None - except (AssertionError, TypeError, ValueError) as e: - print(f"❌ TEST FAILED: {str(e)}") - return False, None - -@my_vcr.use_cassette('version.yaml') -def test_get_version(): - '''Function to test the get_version function.''' - url = f"{API_HOST}/version" - pattern = r"Current app version: (\d+\.\d+\.\d+)" - version_success, match = make_request(url, pattern) - - if version_success and match: - version = match.group(1) - print(f"✅ Version test passed: {version}\n") - - return version_success - -@my_vcr.use_cassette('temperature.yaml') -def test_get_temperature(): - '''Function to test the get_temperature function from the opensense module.''' - url = f"{API_HOST}/temperature" - pattern = r"Average temperature: (\d+\.\d+) °C" - temperature_success, match = make_request(url, pattern) - - if temperature_success and match: - temperature = float(match.group(1)) - print(f"✅ Temperature test passed: {temperature:.2f}°C\n") - - return temperature_success - -@my_vcr.use_cassette('metrics.yaml') -def test_get_metrics(): - '''Function to test the metrics function.''' - url = f"{API_HOST}/metrics" - pattern = r"python_info" - metrics_success, match = make_request(url, pattern) - - if metrics_success and match: - print("✅ Metrics test passed: Prometheus metrics found\n") - - return metrics_success - -def run_all_tests(): - '''Run all tests and return overall success status.''' - version_success = test_get_version() - temperature_success = test_get_temperature() - metrics_success = test_get_metrics() - - return version_success and temperature_success and metrics_success - -if __name__ == "__main__": - SUCCESS = run_all_tests() - sys.exit(0 if SUCCESS else 1) +'''This module contains the test cases for the main module with VCR cassettes.''' +import sys +import re +import os +import requests +import vcr + +API_HOST = os.environ.get('API_HOST', 'http://127.0.0.1:5000') + +my_vcr = vcr.VCR( + cassette_library_dir='tests/fixtures/vcr_cassettes', + record_mode='once', + match_on=['uri', 'method'], +) + +def make_request(url, expected_pattern): + '''Reusable function to make requests and assert responses.''' + try: + response = requests.get(url, timeout=240) + print(f"Response: {response.text}") + + # Check status code + assert response.status_code == 200, "The request was not successful." + + # Check response content + match = re.search(expected_pattern, response.text) + assert match is not None, f"Pattern '{expected_pattern}' not found in response" + + return True, match + + except requests.exceptions.RequestException as e: + print(f"❌ TEST FAILED: Network error: {str(e)}") + return False, None + except (AssertionError, TypeError, ValueError) as e: + print(f"❌ TEST FAILED: {str(e)}") + return False, None + +@my_vcr.use_cassette('version.yaml') +def test_get_version(): + '''Function to test the get_version function.''' + url = f"{API_HOST}/version" + pattern = r"Current app version: (\d+\.\d+\.\d+)" + version_success, match = make_request(url, pattern) + + if version_success and match: + version = match.group(1) + print(f"✅ Version test passed: {version}\n") + + return version_success + +@my_vcr.use_cassette('temperature.yaml') +def test_get_temperature(): + '''Function to test the get_temperature function from the opensense module.''' + url = f"{API_HOST}/temperature" + pattern = r"Average temperature: (\d+\.\d+) °C" + temperature_success, match = make_request(url, pattern) + + if temperature_success and match: + temperature = float(match.group(1)) + print(f"✅ Temperature test passed: {temperature:.2f}°C\n") + + return temperature_success + +@my_vcr.use_cassette('metrics.yaml') +def test_get_metrics(): + '''Function to test the metrics function.''' + url = f"{API_HOST}/metrics" + pattern = r"python_info" + metrics_success, match = make_request(url, pattern) + + if metrics_success and match: + print("✅ Metrics test passed: Prometheus metrics found\n") + + return metrics_success + +def run_all_tests(): + '''Run all tests and return overall success status.''' + version_success = test_get_version() + temperature_success = test_get_temperature() + metrics_success = test_get_metrics() + + return version_success and temperature_success and metrics_success + +if __name__ == "__main__": + SUCCESS = run_all_tests() + sys.exit(0 if SUCCESS else 1) diff --git a/tests/test_modules.py b/tests/test_modules.py index 9521b83..71332c6 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,404 +1,404 @@ -'''This module contains tests for the Flask and OpenSense modules.''' -import re -import unittest -import unittest.mock as mock -import requests # added -import redis # added -from minio.error import S3Error, InvalidResponseError -from app.storage import store_temperature_data -from app.main import app -from app import opensense -from app import readiness - -class TestFlaskApp(unittest.TestCase): - """Test cases for Flask application endpoints""" - - def setUp(self): - """Set up test client""" - self.client = app.test_client() - self.app_context = app.app_context() - self.app_context.push() - - def tearDown(self): - """Clean up after tests""" - self.app_context.pop() - - def test_app_exists(self): - """Test that the Flask app exists""" - self.assertIsNotNone(app) - - def test_version_endpoint(self): - """Test version endpoint returns 200""" - response = self.client.get('/version') - self.assertEqual(response.status_code, 200) - - def test_temperature_endpoint(self): - """Test temperature endpoint returns 200 or 500""" - with mock.patch('app.opensense.requests.get', - return_value=MockOpenSenseResponse(20)): - response = self.client.get('/temperature') - self.assertIn(response.status_code, [200, 500]) - - def test_metrics_endpoint(self): - """Test metrics endpoint returns 200""" - response = self.client.get('/metrics') - self.assertEqual(response.status_code, 200) - - def test_store_endpoint_success(self): - """Test store endpoint with successful storage""" - with mock.patch('app.storage.store_temperature_data') as mock_store: - mock_store.return_value = "Temperature data successfully uploaded" - - response = self.client.get('/store') - - self.assertEqual(response.status_code, 200) - self.assertIn("successfully uploaded", response.get_data(as_text=True)) - mock_store.assert_called_once() - - def test_readyz_endpoint_ready(self): - """Test /readyz endpoint when service is ready""" - with mock.patch('app.readiness.readiness_check', return_value=200): - response = self.client.get('/readyz') - self.assertEqual(response.status_code, 200) - data = response.get_json() - self.assertEqual(data['status'], 'ready') - - def test_readyz_endpoint_not_ready(self): - """Test /readyz endpoint when service is not ready""" - with mock.patch('app.readiness.readiness_check', return_value=503): - response = self.client.get('/readyz') - self.assertEqual(response.status_code, 503) - data = response.get_json() - self.assertEqual(data['status'], 'not ready') - self.assertIn('error', data) - - -class MockOpenSenseResponse: - """Mock response class to simulate OpenSenseMap API response.""" - - def __init__(self, temp_value): - self.text = "mock response text" - self.temp_value = temp_value - - def json(self): - """Return a mock JSON response.""" - return [{ - 'sensors': [ - { - 'title': 'Temperatur', - 'unit': '°C', - 'lastMeasurement': {'value': str(self.temp_value)} - } - ] - }] - - -class TestOpenSense(unittest.TestCase): - """Test cases for OpenSense module""" - - def test_get_temperature_returns_tuple(self): - """Test that opensense.get_temperature returns a tuple with correct format""" - with mock.patch('app.opensense.requests.get', - return_value=MockOpenSenseResponse(20)): - result, stats = opensense.get_temperature() - self.assertIsInstance(result, str) - self.assertIsInstance(stats, dict) - self.assertIn('total_sensors', stats) - self.assertIn('null_count', stats) - self.assertIsNotNone(re.match( - r"Average temperature: (\d+\.\d{2}) °C \((Warning: Too cold|Good|Warning: Too hot)\)", - result - )) - - def test_temperature_too_cold(self): - """Test opensense.get_temperature for too cold condition (< 10°C)""" - with mock.patch('app.opensense.requests.get', - return_value=MockOpenSenseResponse(5)): - result, _ = opensense.get_temperature() - self.assertIn('Too cold', result) - - def test_temperature_good_range(self): - """Test opensense.get_temperature for good temperature range (10-30°C)""" - with mock.patch('app.opensense.requests.get', - return_value=MockOpenSenseResponse(20)): - result, _ = opensense.get_temperature() - self.assertIn('Good', result) - - def test_temperature_too_hot(self): - """Test opensense.get_temperature for too hot condition (> 30°C)""" - with mock.patch('app.opensense.requests.get', - return_value=MockOpenSenseResponse(40)): - result, _ = opensense.get_temperature() - self.assertIn('Too hot', result) - - def test_cache_hit(self): - """Test that cached data is returned when available""" - mock_redis_client = mock.MagicMock() - mock_redis_client.get.return_value = "cached_result" - - with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ - mock.patch('app.opensense.redis_client', mock_redis_client), \ - mock.patch('app.opensense.requests.get') as mock_requests: - - result, _ = opensense.get_temperature() - self.assertEqual(result, "cached_result") - mock_requests.assert_not_called() - mock_redis_client.get.assert_called_once_with("temperature_data") - - def test_cache_miss_and_store(self): - """Test that data is fetched and cached on cache miss""" - mock_redis_client = mock.MagicMock() - mock_redis_client.get.return_value = None - - with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ - mock.patch('app.opensense.redis_client', mock_redis_client), \ - mock.patch('app.opensense.requests.get', - return_value=MockOpenSenseResponse(25)): - - result, _ = opensense.get_temperature() - self.assertIn('Average temperature', result) - mock_redis_client.setex.assert_called() - # Verify cache key and TTL - call_args = mock_redis_client.setex.call_args - self.assertEqual(call_args[0][0], "temperature_data") - self.assertGreater(call_args[0][1], 0) # TTL should be positive - - -class TestStorage(unittest.TestCase): - """Test cases for storage functionality""" - - def setUp(self): - """Set up common test data""" - self.mock_temp_data = ( - "Average temperature: 22.5 °C (Good)\nFrom: test\n", - {"total_sensors": 10, "null_count": 1} - ) - - def test_store_temperature_data_success(self): - """Test successful temperature data storage""" - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - mock_client.bucket_exists.return_value = True - mock_client.list_buckets.return_value = [] - - with mock.patch('app.storage.opensense.get_temperature', - return_value=self.mock_temp_data): - result = store_temperature_data() - - self.assertIn("successfully uploaded", result) - mock_client.put_object.assert_called_once() - - def test_store_temperature_data_create_bucket(self): - """Test bucket creation when it doesn't exist""" - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - mock_client.bucket_exists.return_value = False - mock_client.list_buckets.return_value = [] - - with mock.patch('app.storage.opensense.get_temperature', - return_value=self.mock_temp_data): - result = store_temperature_data() - - mock_client.make_bucket.assert_called_once_with("temperature-data") - self.assertIn("successfully uploaded", result) - - def test_store_temperature_data_s3_error(self): - """Test S3Error exception handling""" - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - mock_client.list_buckets.return_value = [] - mock_client.bucket_exists.side_effect = S3Error( - code="AccessDenied", - message="Access denied", - resource="/temperature-data", - request_id="test-request-id", - host_id="test-host-id", - response=None - ) - - with mock.patch('app.storage.opensense.get_temperature', - return_value=self.mock_temp_data): - result = store_temperature_data() - - self.assertIn("MinIO S3 error occurred", result) - self.assertIn("Access denied", result) - - def test_store_temperature_data_invalid_response(self): - """Test InvalidResponseError exception handling""" - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - mock_client.list_buckets.return_value = [] - mock_client.bucket_exists.return_value = True - mock_client.put_object.side_effect = InvalidResponseError( - "Invalid response", - content_type="application/json", - body=b"{}" - ) - - with mock.patch('app.storage.opensense.get_temperature', - return_value=self.mock_temp_data): - result = store_temperature_data() - - self.assertIn("MinIO S3 error occurred", result) - self.assertIn("Invalid response", result) - - def test_store_temperature_data_connection_error(self): - """Test connection error handling""" - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - mock_client.list_buckets.side_effect = ConnectionError("Network unreachable") - with mock.patch('app.storage.opensense.get_temperature', - return_value=self.mock_temp_data): - result = store_temperature_data() - - self.assertIn("Cannot connect to MinIO server", result) - self.assertIn("Network unreachable", result) - - -class TestReadiness(unittest.TestCase): - """Test cases for readiness checks""" - - def test_check_caching_redis_unavailable(self): - """Test check_caching when Redis is not available""" - with mock.patch('app.readiness.REDIS_AVAILABLE', False): - result = readiness.check_caching() - self.assertTrue(result) # No Redis = cache is old - - def test_check_caching_key_not_exists(self): - """Test check_caching when cache key doesn't exist""" - mock_redis_client = mock.MagicMock() - mock_redis_client.ttl.return_value = -2 # Key doesn't exist - - with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ - mock.patch('app.readiness.redis_client', mock_redis_client): - result = readiness.check_caching() - self.assertTrue(result) - - def test_check_caching_key_no_expiry(self): - """Test check_caching when cache key has no expiry""" - mock_redis_client = mock.MagicMock() - mock_redis_client.ttl.return_value = -1 # Key exists but no expiry - - with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ - mock.patch('app.readiness.redis_client', mock_redis_client): - result = readiness.check_caching() - self.assertTrue(result) - - def test_check_caching_fresh_cache(self): - """Test check_caching when cache is fresh""" - mock_redis_client = mock.MagicMock() - mock_redis_client.ttl.return_value = 150 # 2.5 minutes remaining - - with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ - mock.patch('app.readiness.redis_client', mock_redis_client): - result = readiness.check_caching() - self.assertFalse(result) # Cache is fresh - - def test_check_caching_redis_error(self): - """TTL raises RedisError -> treated as old cache (True)""" - mock_redis_client = mock.MagicMock() - mock_redis_client.ttl.side_effect = redis.RedisError("boom") - - with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ - mock.patch('app.readiness.redis_client', mock_redis_client), \ - mock.patch('builtins.print'): - result = readiness.check_caching() - self.assertTrue(result) - - def test_reachable_boxes_healthy(self): - """Test reachable_boxes when most sensors are working""" - mock_stats = {"total_sensors": 100, "null_count": 10} - - with mock.patch('app.readiness.get_temperature', - return_value=("temp_result", mock_stats)): - result = readiness.reachable_boxes() - self.assertEqual(result, 200) - - def test_reachable_boxes_unhealthy(self): - """Test reachable_boxes when > 50% sensors are unreachable""" - mock_stats = {"total_sensors": 100, "null_count": 51} - - with mock.patch('app.readiness.get_temperature', - return_value=("temp_result", mock_stats)), \ - mock.patch('builtins.print'): # Suppress print output - result = readiness.reachable_boxes() - self.assertEqual(result, 400) - - def test_reachable_boxes_edge_cases(self): - """Test reachable_boxes edge cases""" - # No sensors - with mock.patch('app.readiness.get_temperature', - return_value=("temp", {"total_sensors": 0, "null_count": 0})): - self.assertEqual(readiness.reachable_boxes(), 200) - - # Exactly 50% unreachable (should be OK) - with mock.patch('app.readiness.get_temperature', - return_value=("temp", {"total_sensors": 100, "null_count": 50})): - self.assertEqual(readiness.reachable_boxes(), 200) - - def test_reachable_boxes_network_error(self): - """requests exceptions -> treated as healthy (200)""" - with mock.patch('app.readiness.get_temperature', - side_effect=requests.exceptions.RequestException("net")), \ - mock.patch('builtins.print'): - self.assertEqual(readiness.reachable_boxes(), 200) - - def test_reachable_boxes_redis_error(self): - """Redis errors inside get_temperature -> treated as healthy (200)""" - with mock.patch('app.readiness.get_temperature', - side_effect=redis.RedisError("redis down")), \ - mock.patch('builtins.print'): - self.assertEqual(readiness.reachable_boxes(), 200) - - def test_reachable_boxes_data_error(self): - """Data parsing error -> returns 400""" - # Cause a TypeError during percentage calculation - bad_stats = {"total_sensors": 2, "null_count": "x"} - with mock.patch('app.readiness.get_temperature', - return_value=("temp", bad_stats)), \ - mock.patch('builtins.print'): - self.assertEqual(readiness.reachable_boxes(), 400) - - def test_readiness_check_redis_error_top_level(self): - """Top-level Redis error in readiness_check -> returns 200""" - with mock.patch('app.readiness.check_caching', - side_effect=redis.RedisError("ttl failed")), \ - mock.patch('builtins.print'): - self.assertEqual(readiness.readiness_check(), 200) - - def test_readiness_check_all_good(self): - """Test readiness_check when everything is healthy""" - with mock.patch('app.readiness.check_caching', return_value=False), \ - mock.patch('app.readiness.reachable_boxes', return_value=200): - result = readiness.readiness_check() - self.assertEqual(result, 200) - - def test_readiness_check_both_bad(self): - """Test readiness_check when both checks fail""" - with mock.patch('app.readiness.check_caching', return_value=True), \ - mock.patch('app.readiness.reachable_boxes', return_value=400): - result = readiness.readiness_check() - self.assertEqual(result, 503) - - def test_readiness_check_partial_failure(self): - """Test readiness_check when only one check fails""" - # Only cache is old - with mock.patch('app.readiness.check_caching', return_value=True), \ - mock.patch('app.readiness.reachable_boxes', return_value=200): - result = readiness.readiness_check() - self.assertEqual(result, 200) - - # Only sensors are unreachable - with mock.patch('app.readiness.check_caching', return_value=False), \ - mock.patch('app.readiness.reachable_boxes', return_value=400): - result = readiness.readiness_check() - self.assertEqual(result, 200) - - -if __name__ == '__main__': - unittest.main() +'''This module contains tests for the Flask and OpenSense modules.''' +import re +import unittest +import unittest.mock as mock +import requests # added +import redis # added +from minio.error import S3Error, InvalidResponseError +from app.storage import store_temperature_data +from app.main import app +from app import opensense +from app import readiness + +class TestFlaskApp(unittest.TestCase): + """Test cases for Flask application endpoints""" + + def setUp(self): + """Set up test client""" + self.client = app.test_client() + self.app_context = app.app_context() + self.app_context.push() + + def tearDown(self): + """Clean up after tests""" + self.app_context.pop() + + def test_app_exists(self): + """Test that the Flask app exists""" + self.assertIsNotNone(app) + + def test_version_endpoint(self): + """Test version endpoint returns 200""" + response = self.client.get('/version') + self.assertEqual(response.status_code, 200) + + def test_temperature_endpoint(self): + """Test temperature endpoint returns 200 or 500""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(20)): + response = self.client.get('/temperature') + self.assertIn(response.status_code, [200, 500]) + + def test_metrics_endpoint(self): + """Test metrics endpoint returns 200""" + response = self.client.get('/metrics') + self.assertEqual(response.status_code, 200) + + def test_store_endpoint_success(self): + """Test store endpoint with successful storage""" + with mock.patch('app.storage.store_temperature_data') as mock_store: + mock_store.return_value = "Temperature data successfully uploaded" + + response = self.client.get('/store') + + self.assertEqual(response.status_code, 200) + self.assertIn("successfully uploaded", response.get_data(as_text=True)) + mock_store.assert_called_once() + + def test_readyz_endpoint_ready(self): + """Test /readyz endpoint when service is ready""" + with mock.patch('app.readiness.readiness_check', return_value=200): + response = self.client.get('/readyz') + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data['status'], 'ready') + + def test_readyz_endpoint_not_ready(self): + """Test /readyz endpoint when service is not ready""" + with mock.patch('app.readiness.readiness_check', return_value=503): + response = self.client.get('/readyz') + self.assertEqual(response.status_code, 503) + data = response.get_json() + self.assertEqual(data['status'], 'not ready') + self.assertIn('error', data) + + +class MockOpenSenseResponse: + """Mock response class to simulate OpenSenseMap API response.""" + + def __init__(self, temp_value): + self.text = "mock response text" + self.temp_value = temp_value + + def json(self): + """Return a mock JSON response.""" + return [{ + 'sensors': [ + { + 'title': 'Temperatur', + 'unit': '°C', + 'lastMeasurement': {'value': str(self.temp_value)} + } + ] + }] + + +class TestOpenSense(unittest.TestCase): + """Test cases for OpenSense module""" + + def test_get_temperature_returns_tuple(self): + """Test that opensense.get_temperature returns a tuple with correct format""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(20)): + result, stats = opensense.get_temperature() + self.assertIsInstance(result, str) + self.assertIsInstance(stats, dict) + self.assertIn('total_sensors', stats) + self.assertIn('null_count', stats) + self.assertIsNotNone(re.match( + r"Average temperature: (\d+\.\d{2}) °C \((Warning: Too cold|Good|Warning: Too hot)\)", + result + )) + + def test_temperature_too_cold(self): + """Test opensense.get_temperature for too cold condition (< 10°C)""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(5)): + result, _ = opensense.get_temperature() + self.assertIn('Too cold', result) + + def test_temperature_good_range(self): + """Test opensense.get_temperature for good temperature range (10-30°C)""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(20)): + result, _ = opensense.get_temperature() + self.assertIn('Good', result) + + def test_temperature_too_hot(self): + """Test opensense.get_temperature for too hot condition (> 30°C)""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(40)): + result, _ = opensense.get_temperature() + self.assertIn('Too hot', result) + + def test_cache_hit(self): + """Test that cached data is returned when available""" + mock_redis_client = mock.MagicMock() + mock_redis_client.get.return_value = "cached_result" + + with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ + mock.patch('app.opensense.redis_client', mock_redis_client), \ + mock.patch('app.opensense.requests.get') as mock_requests: + + result, _ = opensense.get_temperature() + self.assertEqual(result, "cached_result") + mock_requests.assert_not_called() + mock_redis_client.get.assert_called_once_with("temperature_data") + + def test_cache_miss_and_store(self): + """Test that data is fetched and cached on cache miss""" + mock_redis_client = mock.MagicMock() + mock_redis_client.get.return_value = None + + with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ + mock.patch('app.opensense.redis_client', mock_redis_client), \ + mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(25)): + + result, _ = opensense.get_temperature() + self.assertIn('Average temperature', result) + mock_redis_client.setex.assert_called() + # Verify cache key and TTL + call_args = mock_redis_client.setex.call_args + self.assertEqual(call_args[0][0], "temperature_data") + self.assertGreater(call_args[0][1], 0) # TTL should be positive + + +class TestStorage(unittest.TestCase): + """Test cases for storage functionality""" + + def setUp(self): + """Set up common test data""" + self.mock_temp_data = ( + "Average temperature: 22.5 °C (Good)\nFrom: test\n", + {"total_sensors": 10, "null_count": 1} + ) + + def test_store_temperature_data_success(self): + """Test successful temperature data storage""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.bucket_exists.return_value = True + mock_client.list_buckets.return_value = [] + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("successfully uploaded", result) + mock_client.put_object.assert_called_once() + + def test_store_temperature_data_create_bucket(self): + """Test bucket creation when it doesn't exist""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.bucket_exists.return_value = False + mock_client.list_buckets.return_value = [] + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + mock_client.make_bucket.assert_called_once_with("temperature-data") + self.assertIn("successfully uploaded", result) + + def test_store_temperature_data_s3_error(self): + """Test S3Error exception handling""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.list_buckets.return_value = [] + mock_client.bucket_exists.side_effect = S3Error( + code="AccessDenied", + message="Access denied", + resource="/temperature-data", + request_id="test-request-id", + host_id="test-host-id", + response=None + ) + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("MinIO S3 error occurred", result) + self.assertIn("Access denied", result) + + def test_store_temperature_data_invalid_response(self): + """Test InvalidResponseError exception handling""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.list_buckets.return_value = [] + mock_client.bucket_exists.return_value = True + mock_client.put_object.side_effect = InvalidResponseError( + "Invalid response", + content_type="application/json", + body=b"{}" + ) + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("MinIO S3 error occurred", result) + self.assertIn("Invalid response", result) + + def test_store_temperature_data_connection_error(self): + """Test connection error handling""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.list_buckets.side_effect = ConnectionError("Network unreachable") + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("Cannot connect to MinIO server", result) + self.assertIn("Network unreachable", result) + + +class TestReadiness(unittest.TestCase): + """Test cases for readiness checks""" + + def test_check_caching_redis_unavailable(self): + """Test check_caching when Redis is not available""" + with mock.patch('app.readiness.REDIS_AVAILABLE', False): + result = readiness.check_caching() + self.assertTrue(result) # No Redis = cache is old + + def test_check_caching_key_not_exists(self): + """Test check_caching when cache key doesn't exist""" + mock_redis_client = mock.MagicMock() + mock_redis_client.ttl.return_value = -2 # Key doesn't exist + + with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ + mock.patch('app.readiness.redis_client', mock_redis_client): + result = readiness.check_caching() + self.assertTrue(result) + + def test_check_caching_key_no_expiry(self): + """Test check_caching when cache key has no expiry""" + mock_redis_client = mock.MagicMock() + mock_redis_client.ttl.return_value = -1 # Key exists but no expiry + + with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ + mock.patch('app.readiness.redis_client', mock_redis_client): + result = readiness.check_caching() + self.assertTrue(result) + + def test_check_caching_fresh_cache(self): + """Test check_caching when cache is fresh""" + mock_redis_client = mock.MagicMock() + mock_redis_client.ttl.return_value = 150 # 2.5 minutes remaining + + with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ + mock.patch('app.readiness.redis_client', mock_redis_client): + result = readiness.check_caching() + self.assertFalse(result) # Cache is fresh + + def test_check_caching_redis_error(self): + """TTL raises RedisError -> treated as old cache (True)""" + mock_redis_client = mock.MagicMock() + mock_redis_client.ttl.side_effect = redis.RedisError("boom") + + with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ + mock.patch('app.readiness.redis_client', mock_redis_client), \ + mock.patch('builtins.print'): + result = readiness.check_caching() + self.assertTrue(result) + + def test_reachable_boxes_healthy(self): + """Test reachable_boxes when most sensors are working""" + mock_stats = {"total_sensors": 100, "null_count": 10} + + with mock.patch('app.readiness.get_temperature', + return_value=("temp_result", mock_stats)): + result = readiness.reachable_boxes() + self.assertEqual(result, 200) + + def test_reachable_boxes_unhealthy(self): + """Test reachable_boxes when > 50% sensors are unreachable""" + mock_stats = {"total_sensors": 100, "null_count": 51} + + with mock.patch('app.readiness.get_temperature', + return_value=("temp_result", mock_stats)), \ + mock.patch('builtins.print'): # Suppress print output + result = readiness.reachable_boxes() + self.assertEqual(result, 400) + + def test_reachable_boxes_edge_cases(self): + """Test reachable_boxes edge cases""" + # No sensors + with mock.patch('app.readiness.get_temperature', + return_value=("temp", {"total_sensors": 0, "null_count": 0})): + self.assertEqual(readiness.reachable_boxes(), 200) + + # Exactly 50% unreachable (should be OK) + with mock.patch('app.readiness.get_temperature', + return_value=("temp", {"total_sensors": 100, "null_count": 50})): + self.assertEqual(readiness.reachable_boxes(), 200) + + def test_reachable_boxes_network_error(self): + """requests exceptions -> treated as healthy (200)""" + with mock.patch('app.readiness.get_temperature', + side_effect=requests.exceptions.RequestException("net")), \ + mock.patch('builtins.print'): + self.assertEqual(readiness.reachable_boxes(), 200) + + def test_reachable_boxes_redis_error(self): + """Redis errors inside get_temperature -> treated as healthy (200)""" + with mock.patch('app.readiness.get_temperature', + side_effect=redis.RedisError("redis down")), \ + mock.patch('builtins.print'): + self.assertEqual(readiness.reachable_boxes(), 200) + + def test_reachable_boxes_data_error(self): + """Data parsing error -> returns 400""" + # Cause a TypeError during percentage calculation + bad_stats = {"total_sensors": 2, "null_count": "x"} + with mock.patch('app.readiness.get_temperature', + return_value=("temp", bad_stats)), \ + mock.patch('builtins.print'): + self.assertEqual(readiness.reachable_boxes(), 400) + + def test_readiness_check_redis_error_top_level(self): + """Top-level Redis error in readiness_check -> returns 200""" + with mock.patch('app.readiness.check_caching', + side_effect=redis.RedisError("ttl failed")), \ + mock.patch('builtins.print'): + self.assertEqual(readiness.readiness_check(), 200) + + def test_readiness_check_all_good(self): + """Test readiness_check when everything is healthy""" + with mock.patch('app.readiness.check_caching', return_value=False), \ + mock.patch('app.readiness.reachable_boxes', return_value=200): + result = readiness.readiness_check() + self.assertEqual(result, 200) + + def test_readiness_check_both_bad(self): + """Test readiness_check when both checks fail""" + with mock.patch('app.readiness.check_caching', return_value=True), \ + mock.patch('app.readiness.reachable_boxes', return_value=400): + result = readiness.readiness_check() + self.assertEqual(result, 503) + + def test_readiness_check_partial_failure(self): + """Test readiness_check when only one check fails""" + # Only cache is old + with mock.patch('app.readiness.check_caching', return_value=True), \ + mock.patch('app.readiness.reachable_boxes', return_value=200): + result = readiness.readiness_check() + self.assertEqual(result, 200) + + # Only sensors are unreachable + with mock.patch('app.readiness.check_caching', return_value=False), \ + mock.patch('app.readiness.reachable_boxes', return_value=400): + result = readiness.readiness_check() + self.assertEqual(result, 200) + + +if __name__ == '__main__': + unittest.main()