diff --git "a/.github/ISSUE_TEMPLATE/deploy-\353\260\260\355\217\254-\355\205\234\355\224\214\353\246\277.md" "b/.github/ISSUE_TEMPLATE/deploy-\353\260\260\355\217\254-\355\205\234\355\224\214\353\246\277.md" new file mode 100644 index 0000000..95e07c9 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/deploy-\353\260\260\355\217\254-\355\205\234\355\224\214\353\246\277.md" @@ -0,0 +1,18 @@ +--- +name: Deploy 이슈 템플릿 +about: 배포를 위한 이슈를 생성합니다. +title: "[DEPLOY] production-release(v.0.0.0)" +labels: '' +assignees: '' + +--- + +## 🚀 배포 설명 + + +## 🛠 하위 태스크 + +- [ ] + +## ✅ 추가 사항 + diff --git a/.github/workflows/_reusable-eb-deploy.yml b/.github/workflows/_reusable-eb-deploy.yml new file mode 100644 index 0000000..8c21903 --- /dev/null +++ b/.github/workflows/_reusable-eb-deploy.yml @@ -0,0 +1,186 @@ +name: Reusable EB Deploy + +on: + workflow_call: + inputs: + application_name: + required: true + type: string + environment_name: + required: true + type: string + health_url: + required: true + type: string + checkout_ref: + required: true + type: string + secrets: + DEPLOY_ENV: + required: true + +permissions: + contents: read + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + + env: + AWS_DEFAULT_REGION: ap-northeast-2 + HEALTH_URL: ${{ inputs.health_url }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.checkout_ref }} + + - name: Create .env file from secret + run: | + if [ -z "${{ secrets.DEPLOY_ENV }}" ]; then + echo "DEPLOY_ENV secret is empty" + exit 1 + fi + printf '%s' "${{ secrets.DEPLOY_ENV }}" > .env + chmod 600 .env + + - name: Get current time + uses: josStorer/get-current-time@v2 + id: current-time + with: + format: 'YYYYMMDD-HHmmss' + utcOffset: '+09:00' + + - name: Generate deployment package + run: | + mkdir -p deploy + rsync -a \ + --exclude='.git' \ + --exclude='deploy' \ + --exclude='__pycache__' \ + --exclude='.venv' \ + --exclude='venv' \ + --exclude='.idea' \ + --exclude='.cursor' \ + --exclude='.github' \ + --exclude='tests' \ + --exclude='rag/raw' \ + --exclude='docs' \ + --exclude='*.pyc' \ + ./ deploy/ + (cd deploy && zip -r deploy.zip .) + + - name: Verify deploy zip content + run: | + unzip -l deploy/deploy.zip | head -n 50 + unzip -l deploy/deploy.zip | egrep -i "\.git/|\.venv/|__pycache__|tests/" && exit 1 || echo "OK" + + - name: Configure AWS credentials + id: aws-creds + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ap-northeast-2 + output-credentials: true + + - name: Save current version + run: | + CURRENT=$(aws elasticbeanstalk describe-environments \ + --environment-names "${{ inputs.environment_name }}" \ + --query 'Environments[0].VersionLabel' \ + --output text) + + echo "CURRENT_VERSION=$CURRENT" + + if [ "$CURRENT" = "None" ] || [ "$CURRENT" = "null" ]; then + CURRENT="" + fi + + echo "PREVIOUS_VERSION=$CURRENT" >> $GITHUB_ENV + + - name: Deploy to Elastic Beanstalk + id: deploy + uses: einaregilsson/beanstalk-deploy@v22 + with: + aws_access_key: ${{ steps.aws-creds.outputs.aws-access-key-id }} + aws_secret_key: ${{ steps.aws-creds.outputs.aws-secret-access-key }} + aws_session_token: ${{ steps.aws-creds.outputs.aws-session-token }} + application_name: ${{ inputs.application_name }} + environment_name: ${{ inputs.environment_name }} + region: ap-northeast-2 + version_label: github-action-${{ steps.current-time.outputs.formattedTime }} + version_description: ${{ github.ref_name }}@${{ github.sha }} + deployment_package: deploy/deploy.zip + wait_for_deployment: true + wait_for_environment_recovery: 180 + + - name: Smoke test with retry + id: smoke_test + run: | + MAX_RETRY=3 + + if [ -z "$HEALTH_URL" ]; then + echo "health_url input is not set." + exit 1 + fi + + echo "HEALTH_URL=$HEALTH_URL" + + for attempt in $(seq 1 $MAX_RETRY); do + echo "=== 스모크 테스트 시도 $attempt / $MAX_RETRY ===" + + for i in {1..30}; do + code="$(curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)" + echo "health check $i: $code" + + if [ "$code" = "200" ]; then + echo "health OK (시도 $attempt)" + exit 0 + fi + + sleep 10 + done + + echo "시도 $attempt 실패" + + if [ "$attempt" -lt "$MAX_RETRY" ]; then + echo "30초 후 재시도..." + sleep 30 + fi + done + + echo "최대 재시도 횟수($MAX_RETRY) 초과 - 배포 실패" + exit 1 + + - name: Rollback on failure + if: failure() && env.PREVIOUS_VERSION != '' && env.PREVIOUS_VERSION != 'None' && (steps.deploy.outcome == 'failure' || steps.smoke_test.outcome == 'failure') + run: | + echo "배포 실패 - $PREVIOUS_VERSION 으로 롤백" + + aws elasticbeanstalk update-environment \ + --environment-name "${{ inputs.environment_name }}" \ + --version-label "$PREVIOUS_VERSION" + + echo "롤백 배포 완료 대기" + aws elasticbeanstalk wait environment-updated \ + --environment-names "${{ inputs.environment_name }}" + + echo "롤백 후 smoke test 시작" + echo "HEALTH_URL=$HEALTH_URL" + + for i in {1..30}; do + code="$(curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)" + echo "rollback health check $i: $code" + + if [ "$code" = "200" ]; then + echo "rollback health OK" + exit 0 + fi + + sleep 10 + done + + echo "롤백 후에도 health check 실패" + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77f218e..6cba4a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,17 @@ -name: CI +# PR / push 시 FastAPI 통합 CI +# 서비스 컨테이너(Postgres/pgvector / Redis) → infra smoke → unittest → uvicorn /health 200 확인 + +name: IssueIssyu AI CI on: push: + branches: + - develop pull_request: + branches: + - develop + - main + workflow_dispatch: jobs: build: @@ -22,6 +31,7 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 10 + redis: image: redis:7-alpine ports: @@ -32,36 +42,36 @@ jobs: --health-timeout 5s --health-retries 10 - env: - APP_ENV: local - LOCAL_DB_HOST: localhost - LOCAL_DB_PORT: 5432 - LOCAL_DB_NAME: app - LOCAL_DB_USER: postgres - LOCAL_DB_PASSWORD: postgres - steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Create .env file from secret + - name: Create .env from secret (CI_ENV) run: | if [ -z "${{ secrets.CI_ENV }}" ]; then - echo "ENV_FILE secret is empty" + echo "Repository secret CI_ENV 가 비어 있습니다." exit 1 fi printf '%s' "${{ secrets.CI_ENV }}" > .env - if ! grep -q '^REDIS_LOCAL_HOST=' .env && grep -q '^LOCAL_REDIS_HOST=' .env; then - echo "REDIS_LOCAL_HOST=$(awk -F= '/^LOCAL_REDIS_HOST=/{print $2; exit}' .env)" >> .env - fi - if ! grep -q '^REDIS_LOCAL_PORT=' .env && grep -q '^LOCAL_REDIS_PORT=' .env; then - echo "REDIS_LOCAL_PORT=$(awk -F= '/^LOCAL_REDIS_PORT=/{print $2; exit}' .env)" >> .env - fi chmod 600 .env + - name: Override datasource and Redis for CI services + run: | + cat >> .env << 'EOF' + APP_ENV=local + LOCAL_DB_HOST=127.0.0.1 + LOCAL_DB_PORT=5432 + LOCAL_DB_NAME=app + LOCAL_DB_USER=postgres + LOCAL_DB_PASSWORD=postgres + REDIS_LOCAL_HOST=127.0.0.1 + REDIS_LOCAL_PORT=6379 + EOF + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.14" cache: pip cache-dependency-path: requirements.txt @@ -83,76 +93,42 @@ jobs: - name: Syntax check (compileall) run: python -m compileall -q app - - name: Infra smoke test (PostgreSQL/Redis/S3) - run: | - python - <<'PY' - import asyncio - import os - import uuid - - import asyncpg - import boto3 - from dotenv import dotenv_values - from redis import Redis - - env = dotenv_values(".env") - - async def check_postgres() -> None: - conn = await asyncpg.connect( - user=os.getenv("LOCAL_DB_USER", "postgres"), - password=os.getenv("LOCAL_DB_PASSWORD", "postgres"), - database="app", - host=os.getenv("LOCAL_DB_HOST", "127.0.0.1"), - port=int(os.getenv("LOCAL_DB_PORT", "5432")), - ) - try: - result = await conn.fetchval("SELECT 1;") - assert result == 1 - finally: - await conn.close() - - def get_env_value(*keys: str, required: bool = False) -> str | None: - for key in keys: - value = env.get(key) or os.getenv(key) - if value: - return value - if required: - joined_keys = ", ".join(keys) - raise RuntimeError(f"Missing required env key: {joined_keys}") - return None + - name: Infra smoke (PostgreSQL/Redis/S3) + run: python scripts/ci_infra_smoke.py - asyncio.run(check_postgres()) + - name: Run unit tests + run: python -m unittest discover -s tests -p "test_*.py" -v - redis_host = get_env_value("REDIS_LOCAL_HOST", "LOCAL_REDIS_HOST") or "localhost" - redis_port = int(get_env_value("REDIS_LOCAL_PORT", "LOCAL_REDIS_PORT") or "6379") - redis_client = Redis(host=redis_host, port=redis_port, db=0, decode_responses=True) - assert redis_client.ping() is True - - aws_access_key = get_env_value("AWS_ACCESS_KEY", required=True) - aws_secret_key = get_env_value("AWS_SECRET_KEY", required=True) - aws_region = get_env_value("AWS_REGION", required=True) - aws_bucket = get_env_value("AWS_BUCKET", required=True) - - s3 = boto3.client( - "s3", - aws_access_key_id=aws_access_key, - aws_secret_access_key=aws_secret_key, - region_name=aws_region, - ) - s3.head_bucket(Bucket=aws_bucket) - key = f"ci-smoke/{uuid.uuid4().hex}.txt" - body = b"ci-smoke-test" - s3.put_object(Bucket=aws_bucket, Key=key, Body=body, ContentType="text/plain") - obj = s3.get_object(Bucket=aws_bucket, Key=key) - assert obj["Body"].read() == body - s3.delete_object(Bucket=aws_bucket, Key=key) - PY - - - name: Uvicorn 실행 후 /health 200 확인 (port 8000) + - name: Start app and verify /health + env: + APP_ENV: local + LOCAL_DB_HOST: 127.0.0.1 + LOCAL_DB_PORT: 5432 + LOCAL_DB_NAME: app + LOCAL_DB_USER: postgres + LOCAL_DB_PASSWORD: postgres + REDIS_LOCAL_HOST: 127.0.0.1 + REDIS_LOCAL_PORT: "6379" run: | set -euo pipefail - uvicorn app.main:app --host 127.0.0.1 --port 8000 & + LOG=/tmp/ai-boot.log + : >"$LOG" + uvicorn app.main:app --host 127.0.0.1 --port 8000 >>"$LOG" 2>&1 & PID=$! trap 'kill "$PID" 2>/dev/null || true; wait "$PID" 2>/dev/null || true' EXIT - code="$(curl --retry 30 --retry-delay 1 --retry-connrefused -sS -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/health)" - test "$code" = "200" + for i in $(seq 1 60); do + if ! kill -0 "$PID" 2>/dev/null; then + echo "Uvicorn process exited early (iteration $i)" + break + fi + code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/health || true)" + if [ "$code" = "200" ]; then + echo "health OK" + exit 0 + fi + sleep 2 + done + echo "health check failed (last HTTP code=${code:-n/a})" + echo "---- last 250 lines of boot log ----" + tail -n 250 "$LOG" || true + exit 1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 01c9e9f..3978703 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,5 +1,5 @@ -# PR이 main 또는 test/CICD 에 merge 되었을 때 배포 + 수동 실행(workflow_dispatch). -# 배포 zip에는 저장소 루트의 .ebextensions 만 포함됩니다(.ebextensions_dev 미사용). +# main 브랜치로 PR이 머지되었을 때 dev EB 배포 + 수동 실행(workflow_dispatch). + name: Issueissyu Dev CI/CD on: @@ -11,56 +11,20 @@ on: permissions: contents: read +concurrency: + group: issueissyu-ai-dev-deploy + cancel-in-progress: false + jobs: deploy: - runs-on: ubuntu-latest if: | github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && (github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'test/CICD')) - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Create .env file from secret - env: - DEV_ENV: ${{ secrets.DEV_ENV }} - run: | - printf '%s' "$DEV_ENV" > .env - chmod 600 .env - - - name: Get current time - uses: josStorer/get-current-time@v2 - id: current-time - with: - format: YYYY-MM-DDTHH:mm:ss - utcOffset: "+09:00" - - - name: Generate deployment package - run: | - mkdir -p deploy - rsync -a \ - --exclude='.git' \ - --exclude='deploy' \ - --exclude='__pycache__' \ - --exclude='.venv' \ - --exclude='venv' \ - --exclude='.idea' \ - --exclude='.cursor' \ - --exclude='.github' \ - --exclude='*.pyc' \ - ./ deploy/ - (cd deploy && zip -r deploy.zip .) - - - name: Deploy to Elastic Beanstalk - uses: einaregilsson/beanstalk-deploy@v22 - with: - aws_access_key: ${{ secrets.AWS_ACTION_ACCESS_KEY_ID }} - aws_secret_key: ${{ secrets.AWS_ACTION_SECRET_ACCESS_KEY }} - application_name: 'issueissyu-ai-dev' - environment_name: 'issueissyu-ai-dev-env' - region: 'ap-northeast-2' - version_label: github-action-${{ steps.current-time.outputs.formattedTime }} - version_description: ${{ github.ref_name }}@${{ github.sha }} - deployment_package: deploy/deploy.zip - wait_for_deployment: false + (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main') + uses: ./.github/workflows/_reusable-eb-deploy.yml + with: + application_name: issueissyu-ai-dev + environment_name: issueissyu-ai-dev-env + health_url: ${{ vars.DEV_HEALTH_URL }} + checkout_ref: main + secrets: + DEPLOY_ENV: ${{ secrets.DEV_ENV }} diff --git a/.github/workflows/prod_deploy.yml b/.github/workflows/prod_deploy.yml index b5d2cf9..2835c12 100644 --- a/.github/workflows/prod_deploy.yml +++ b/.github/workflows/prod_deploy.yml @@ -1,3 +1,5 @@ +# prod 브랜치로 PR이 머지되었을 때 prod EB 배포 + 수동 실행(workflow_dispatch). + name: Issueissyu Prod CI/CD on: @@ -9,56 +11,20 @@ on: permissions: contents: read +concurrency: + group: issueissyu-ai-prod-deploy + cancel-in-progress: false + jobs: deploy: - runs-on: ubuntu-latest if: | github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && (github.event.pull_request.base.ref == 'prod' || github.event.pull_request.base.ref == 'test/CICD')) - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Create .env file from secret - env: - DEV_ENV: ${{ secrets.PROD_ENV }} - run: | - printf '%s' "$DEV_ENV" > .env - chmod 600 .env - - - name: Get current time - uses: josStorer/get-current-time@v2 - id: current-time - with: - format: YYYY-MM-DDTHH:mm:ss - utcOffset: "+09:00" - - - name: Generate deployment package - run: | - mkdir -p deploy - rsync -a \ - --exclude='.git' \ - --exclude='deploy' \ - --exclude='__pycache__' \ - --exclude='.venv' \ - --exclude='venv' \ - --exclude='.idea' \ - --exclude='.cursor' \ - --exclude='.github' \ - --exclude='*.pyc' \ - ./ deploy/ - (cd deploy && zip -r deploy.zip .) - - - name: Deploy to Elastic Beanstalk - uses: einaregilsson/beanstalk-deploy@v22 - with: - aws_access_key: ${{ secrets.AWS_PROD_ACCESS_KEY }} - aws_secret_key: ${{ secrets.AWS_PROD_SECRET_KEY }} - application_name: 'issueissyu-ai-prod' - environment_name: 'issueissyu-ai-env' - region: 'ap-northeast-2' - version_label: github-action-${{ steps.current-time.outputs.formattedTime }} - version_description: ${{ github.ref_name }}@${{ github.sha }} - deployment_package: deploy/deploy.zip - wait_for_deployment: false + (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'prod') + uses: ./.github/workflows/_reusable-eb-deploy.yml + with: + application_name: issueissyu-ai-prod + environment_name: issueissyu-ai-env + health_url: ${{ vars.PROD_HEALTH_URL }} + checkout_ref: prod + secrets: + DEPLOY_ENV: ${{ secrets.PROD_ENV }} diff --git a/docs/cicd-oidc-setup.md b/docs/cicd-oidc-setup.md new file mode 100644 index 0000000..a82fca4 --- /dev/null +++ b/docs/cicd-oidc-setup.md @@ -0,0 +1,70 @@ +# CI/CD OIDC 설정 가이드 + +워크플로는 `vars.AWS_ROLE_ARN` 기반 OIDC를 사용합니다. 아래 설정을 GitHub/AWS에 적용한 뒤 static access key secret을 제거하세요. + +## GitHub Repository Variables + +| 이름 | 예시 | 용도 | +|------|------|------| +| `AWS_ROLE_ARN` | `arn:aws:iam::123456789012:role/github-actions-issueissyu-ai` | OIDC assume role | +| `DEV_HEALTH_URL` | `https://dev-api.example.com/health` | dev EB 배포 후 스모크 | +| `PROD_HEALTH_URL` | `https://api.example.com/health` | prod EB 배포 후 스모크 | + +## GitHub Repository Secrets (유지) + +| 이름 | 용도 | +|------|------| +| `CI_ENV` | CI용 `.env` | +| `DEV_ENV` | dev EB `.env` | +| `PROD_ENV` | prod EB `.env` | + +## 제거 대상 Secrets (OIDC 전환 후) + +- `AWS_ACTION_ACCESS_KEY_ID` +- `AWS_ACTION_SECRET_ACCESS_KEY` +- `AWS_PROD_ACCESS_KEY` +- `AWS_PROD_SECRET_KEY` + +## IAM Role 신뢰 정책 (Trust Policy) + +`{org}`를 GitHub org/사용자명으로 바꿉니다. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::{account_id}:oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:{org}/issueissyu-AI:*" + } + } + } + ] +} +``` + +## IAM Role 권한 (최소 예시) + +dev/prod EB 환경 모두에 배포할 수 있도록 `issueissyu-ai-dev`, `issueissyu-ai-prod` 애플리케이션 및 artifact S3 bucket에 대한 권한이 필요합니다. + +- `elasticbeanstalk:CreateApplicationVersion` +- `elasticbeanstalk:UpdateEnvironment` +- `elasticbeanstalk:DescribeEnvironments` +- `elasticbeanstalk:DescribeApplicationVersions` +- `s3:*` (EB deployment artifact bucket) +- `cloudformation:Describe*` +- `ec2:Describe*` +- `autoscaling:Describe*` + +## EB Python 플랫폼 + +`runtime.txt`에 `python-3.14`를 명시했습니다. dev/prod EB 환경이 Python 3.14 플랫폼을 지원하는지 확인하고, 미지원 시 플랫폼 업그레이드를 선행하세요. diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..2278a66 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.14 diff --git a/scripts/ci_infra_smoke.py b/scripts/ci_infra_smoke.py new file mode 100644 index 0000000..1692496 --- /dev/null +++ b/scripts/ci_infra_smoke.py @@ -0,0 +1,80 @@ +"""CI infrastructure smoke test: PostgreSQL, Redis, S3.""" + +from __future__ import annotations + +import asyncio +import os +import sys +import uuid + +import asyncpg +import boto3 +from dotenv import dotenv_values +from redis import Redis + + +def get_env_value(env: dict[str, str | None], *keys: str, required: bool = False) -> str | None: + for key in keys: + value = env.get(key) or os.getenv(key) + if value: + return value + if required: + joined_keys = ", ".join(keys) + raise RuntimeError(f"Missing required env key: {joined_keys}") + return None + + +async def check_postgres() -> None: + conn = await asyncpg.connect( + user=os.getenv("LOCAL_DB_USER", "postgres"), + password=os.getenv("LOCAL_DB_PASSWORD", "postgres"), + database=os.getenv("LOCAL_DB_NAME", "app"), + host=os.getenv("LOCAL_DB_HOST", "127.0.0.1"), + port=int(os.getenv("LOCAL_DB_PORT", "5432")), + ) + try: + result = await conn.fetchval("SELECT 1;") + assert result == 1 + finally: + await conn.close() + + +def check_redis(env: dict[str, str | None]) -> None: + redis_host = get_env_value(env, "REDIS_LOCAL_HOST", "LOCAL_REDIS_HOST") or "localhost" + redis_port = int(get_env_value(env, "REDIS_LOCAL_PORT", "LOCAL_REDIS_PORT") or "6379") + redis_client = Redis(host=redis_host, port=redis_port, db=0, decode_responses=True) + assert redis_client.ping() is True + + +def check_s3(env: dict[str, str | None]) -> None: + aws_access_key = get_env_value(env, "AWS_ACCESS_KEY", required=True) + aws_secret_key = get_env_value(env, "AWS_SECRET_KEY", required=True) + aws_region = get_env_value(env, "AWS_REGION", required=True) + aws_bucket = get_env_value(env, "AWS_BUCKET", required=True) + + s3 = boto3.client( + "s3", + aws_access_key_id=aws_access_key, + aws_secret_access_key=aws_secret_key, + region_name=aws_region, + ) + s3.head_bucket(Bucket=aws_bucket) + key = f"ci-smoke/{uuid.uuid4().hex}.txt" + body = b"ci-smoke-test" + s3.put_object(Bucket=aws_bucket, Key=key, Body=body, ContentType="text/plain") + obj = s3.get_object(Bucket=aws_bucket, Key=key) + assert obj["Body"].read() == body + s3.delete_object(Bucket=aws_bucket, Key=key) + + +def main() -> int: + env = dotenv_values(".env") + asyncio.run(check_postgres()) + check_redis(env) + check_s3(env) + print("infra smoke OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main())