diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2074b4d --- /dev/null +++ b/.env.example @@ -0,0 +1,87 @@ +# Slack Worker Environment Configuration +# Copy this file to .env and fill in with your actual values + +# ============================================================================ +# SLACK CONFIGURATION +# ============================================================================ +# Slack bot token for sending messages and DMs +SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN-HERE + +# Slack channel ID for group reminders (e.g., #rota-notifications) +ROTA_GROUP_CHANNEL=C086RHFK7ED + +# Slack user ID mapping (JSON format, without outer quotes in .env) +# Maps display names to Slack user IDs for @mentions +ROTA_USERS={"user1": "U00000000001", "user2": "U00000000002", "user3": "U00000000003"} + +# ============================================================================ +# GOOGLE SHEETS CONFIGURATION +# ============================================================================ +# Google Sheets spreadsheet ID (ROTA sheet) +SPREADSHEET_ID=YOUR-SPREADSHEET-ID-HERE + +# Google service account credentials (JSON) +# Remove quotes from the JSON if pasted from .json file +ROTA_SERVICE_ACCOUNT={"type": "service_account", "project_id": "your-project-id", "private_key": "-----BEGIN PRIVATE KEY-----\nYOUR-PRIVATE-KEY-HERE\n-----END PRIVATE KEY-----", "client_email": "your-service-account@project.iam.gserviceaccount.com"} + +# ============================================================================ +# SMARTSHEET CONFIGURATION +# ============================================================================ +# Smartsheet API access token +SMARTSHEET_ACCESS_TOKEN=YOUR-SMARTSHEET-TOKEN-HERE + +# Smartsheet sheet IDs for each OCP version (Z-Stream releases) +# Get these from Smartsheet URL: https://app.smartsheet.com/sheets/{SHEET_ID} +# +# IMPORTANT: The sync job dynamically discovers versions from SMARTSHEET_SHEET_*_ID env vars. +# To add/remove versions: +# 1. Uncomment the SMARTSHEET_SHEET_X_XX_ID line +# 2. Add the actual sheet ID value +# 3. Job automatically discovers and syncs it +# +# Active versions (currently synced) +SMARTSHEET_SHEET_4_12_ID=7020066991564676 +SMARTSHEET_SHEET_4_13_ID=3756051883052932 +SMARTSHEET_SHEET_4_14_ID=868851797413764 +SMARTSHEET_SHEET_4_15_ID=6000327677398916 +SMARTSHEET_SHEET_4_16_ID=2840413782101892 + +# Future versions - uncomment to be automatically included in sync when needed! +# SMARTSHEET_SHEET_4_17_ID=7572774457397124 +# SMARTSHEET_SHEET_4_18_ID=7994157331074948 +# SMARTSHEET_SHEET_4_19_ID=8025934201311108 +# SMARTSHEET_SHEET_4_20_ID=4695628593450884 +# SMARTSHEET_SHEET_4_21_ID=YOUR-SHEET-ID-HERE +# SMARTSHEET_SHEET_4_22_ID=YOUR-SHEET-ID-HERE + +# Release filtering configuration +# Number of months ahead from current month to include in release filter +# Default: 1 (current month + 1 month ahead) +# Examples: +# 1 = Feb 1 - Mar 31 (if today is Feb 26) +# 2 = Feb 1 - Apr 30 +# 3 = Feb 1 - May 31 +RELEASE_FILTER_MONTHS_AHEAD=1 +# SMARTSHEET_SHEET_4_18_ID=7994157331074948 +# SMARTSHEET_SHEET_4_19_ID=8025934201311108 +# SMARTSHEET_SHEET_4_20_ID=4695628593450884 + +# ============================================================================ +# SCHEDULER CONFIGURATION +# ============================================================================ +# Cron expressions for scheduled jobs +# Format: minute hour day month day_of_week + +# ROTA Jobs +SCHEDULE_ROTA_SHEET_SYNC=0 8 * * MON,THU +SCHEDULE_ROTA_NOTIFICATIONS=0 9 * * MON,THU + +# To disable a job, set its schedule to empty: +# SCHEDULE_ROTA_SHEET_SYNC= +# SCHEDULE_ROTA_NOTIFICATIONS= + +# ============================================================================ +# LOGGING CONFIGURATION +# ============================================================================ +# Log level (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO diff --git a/.github/workflows/create-slackbot-docker-image.yml b/.github/workflows/create-slackbot-docker-image.yml new file mode 100644 index 0000000..22f12f0 --- /dev/null +++ b/.github/workflows/create-slackbot-docker-image.yml @@ -0,0 +1,135 @@ +name: Create Docker Image for Slackbot +on: + workflow_dispatch: + inputs: + quay_registry_username: + description: 'Username' + required: true + quay_registry_password: + description: 'Password' + required: true + tag_major_version: + description: 'Major Version' + required: true + default: "1" + tag_minor_version: + description: 'Minor Version' + required: true + default: "1" + push_when_critical_errors_in_scan: + description: 'Push when Critical Errors in Scan' + required: true + default: "false" + type: choice + options: + - "true" + - "false" +permissions: + contents: read + security-events: write +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + IMAGE_NAME: "quay.io/ocp_sustaining_engineering/slack_backend" + TAG_PATCH_VERSION: 0 + SLACKBOT_IMAGE_REPO_URL: "https://quay.io/api/v1/repository/ocp_sustaining_engineering/slack_backend/tag/" + steps: + - name: Validate inputs + run: | + if ! [[ "${{ inputs.tag_major_version }}" =~ ^[0-9]+$ ]]; then + echo "Major version must be numeric" + exit 1 + fi + if ! [[ "${{ inputs.tag_minor_version }}" =~ ^[0-9]+$ ]]; then + echo "Minor version must be numeric" + exit 1 + fi + - name: Checkout code + uses: actions/checkout@v4 + - name: Get Next Tag Version + uses: nick-fields/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_on: error + command: | + PAGE=1 + LIMIT=50 + HAS_MORE=true + ALL_TAGS='[]' + FILTER_PARAMS="&onlyActiveTags=1&filter_tag_name=like:${{ inputs.tag_major_version }}.${{ inputs.tag_minor_version }}." + while [ "$HAS_MORE" = true ]; do + echo "Fetching page $PAGE..." + PAGE_AND_LIMIT_PARAMS="?limit=$LIMIT&page=$PAGE" + if ! JSON_RESPONSE=$(curl -s -f --max-time 300 "$SLACKBOT_IMAGE_REPO_URL$PAGE_AND_LIMIT_PARAMS$FILTER_PARAMS"); then + echo "Failed to fetch tags from API" + exit 1 + fi + TAGS=$(echo "$JSON_RESPONSE" | jq '.tags') + ALL_TAGS=$(jq -s 'add' <(echo "$ALL_TAGS") <(echo "$TAGS")) + HAS_MORE=$(echo "$JSON_RESPONSE" | jq '.has_additional') + PAGE=$((PAGE + 1)) + done + COUNT_EXISTING=$(echo "$ALL_TAGS" | jq '. | length') + if [ "$COUNT_EXISTING" -eq 0 ]; then + NEXT_TAG_VERSION="${{ inputs.tag_major_version }}.${{ inputs.tag_minor_version }}.${{ env.TAG_PATCH_VERSION }}" + else + MAX_VER=$(echo "$ALL_TAGS" | jq -r '.[].name'| sort -V | tail -n1) + IFS='.' read -r MAJOR MINOR PATCH <<< "$MAX_VER" + NEW_PATCH=$((PATCH + 1)) + NEXT_TAG_VERSION="${{ inputs.tag_major_version }}.${{ inputs.tag_minor_version }}.${NEW_PATCH}" + fi + echo "NEXT_TAG_VERSION=$NEXT_TAG_VERSION" >> $GITHUB_ENV + echo "Computed image version: $NEXT_TAG_VERSION" + - name: Login to Quay.io + id: login + run: | + set -e + QUAY_PASSWORD=$(jq -r '.inputs.quay_registry_password' $GITHUB_EVENT_PATH) + echo ::add-mask::$QUAY_PASSWORD + QUAY_USERNAME=$(jq -r '.inputs.quay_registry_username' $GITHUB_EVENT_PATH) + echo ::add-mask::$QUAY_USERNAME + echo ":closed_lock_with_key: Logging in to quay.io..." + echo "$QUAY_PASSWORD" | docker login quay.io -u "$QUAY_USERNAME" --password-stdin + - name: Build Docker image locally + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: ${{ env.IMAGE_NAME }}:${{ env.NEXT_TAG_VERSION }} + load: true + timeout-minutes: 30 + - name: Trivy scan gating + run: | + if [ "$PUSH_ON_CRITICAL" == "true" ]; then + EXIT_CODE=0 + else + EXIT_CODE=1 + fi + + echo "Using Trivy exit code: $EXIT_CODE" + + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy:0.69.3 image \ + --severity CRITICAL \ + --format table \ + --exit-code $EXIT_CODE \ + ${{ env.IMAGE_NAME }}:${{ env.NEXT_TAG_VERSION }} + env: + PUSH_ON_CRITICAL: ${{ inputs.push_when_critical_errors_in_scan }} + + - name: Push Docker image to Quay + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ env.IMAGE_NAME }}:${{ env.NEXT_TAG_VERSION }} + + - name: Clean up + if: ${{ always() }} + run: | + echo ":wastebasket: Removing temporary file etc" + rm -f /home/runner/.docker/config.json + docker logout quay.io || true + diff --git a/.github/workflows/create-slackworker-docker-image.yml b/.github/workflows/create-slackworker-docker-image.yml new file mode 100644 index 0000000..717bde0 --- /dev/null +++ b/.github/workflows/create-slackworker-docker-image.yml @@ -0,0 +1,134 @@ +name: Create Docker Image for Slackworker +on: + workflow_dispatch: + inputs: + quay_registry_username: + description: 'Username' + required: true + quay_registry_password: + description: 'Password' + required: true + tag_major_version: + description: 'Major Version' + required: true + default: "1" + tag_minor_version: + description: 'Minor Version' + required: true + default: "1" + push_when_critical_errors_in_scan: + description: 'Push when Critical Errors in Scan' + required: true + default: "false" + type: choice + options: + - "true" + - "false" +permissions: + contents: read + security-events: write +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + IMAGE_NAME: "quay.io/ocp_sustaining_engineering/slack_worker" + TAG_PATCH_VERSION: 0 + SLACKWORKER_IMAGE_REPO_URL: "https://quay.io/api/v1/repository/ocp_sustaining_engineering/slack_worker/tag/" + steps: + - name: Validate inputs + run: | + if ! [[ "${{ inputs.tag_major_version }}" =~ ^[0-9]+$ ]]; then + echo "Major version must be numeric" + exit 1 + fi + if ! [[ "${{ inputs.tag_minor_version }}" =~ ^[0-9]+$ ]]; then + echo "Minor version must be numeric" + exit 1 + fi + - name: Checkout code + uses: actions/checkout@v4 + - name: Get Next Tag Version + uses: nick-fields/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_on: error + command: | + PAGE=1 + LIMIT=50 + HAS_MORE=true + ALL_TAGS='[]' + FILTER_PARAMS="&onlyActiveTags=1&filter_tag_name=like:${{ inputs.tag_major_version }}.${{ inputs.tag_minor_version }}." + while [ "$HAS_MORE" = true ]; do + echo "Fetching page $PAGE..." + PAGE_AND_LIMIT_PARAMS="?limit=$LIMIT&page=$PAGE" + if ! JSON_RESPONSE=$(curl -s -f --max-time 300 "$SLACKWORKER_IMAGE_REPO_URL$PAGE_AND_LIMIT_PARAMS$FILTER_PARAMS"); then + echo "Failed to fetch tags from API" + exit 1 + fi + TAGS=$(echo "$JSON_RESPONSE" | jq '.tags') + ALL_TAGS=$(jq -s 'add' <(echo "$ALL_TAGS") <(echo "$TAGS")) + HAS_MORE=$(echo "$JSON_RESPONSE" | jq '.has_additional') + PAGE=$((PAGE + 1)) + done + COUNT_EXISTING=$(echo "$ALL_TAGS" | jq '. | length') + if [ "$COUNT_EXISTING" -eq 0 ]; then + NEXT_TAG_VERSION="${{ inputs.tag_major_version }}.${{ inputs.tag_minor_version }}.${{ env.TAG_PATCH_VERSION }}" + else + MAX_VER=$(echo "$ALL_TAGS" | jq -r '.[].name'| sort -V | tail -n1) + IFS='.' read -r MAJOR MINOR PATCH <<< "$MAX_VER" + NEW_PATCH=$((PATCH + 1)) + NEXT_TAG_VERSION="${{ inputs.tag_major_version }}.${{ inputs.tag_minor_version }}.${NEW_PATCH}" + fi + echo "NEXT_TAG_VERSION=$NEXT_TAG_VERSION" >> $GITHUB_ENV + echo "Computed image version: $NEXT_TAG_VERSION" + - name: Login to Quay.io + id: login + run: | + set -e + QUAY_PASSWORD=$(jq -r '.inputs.quay_registry_password' $GITHUB_EVENT_PATH) + echo ::add-mask::$QUAY_PASSWORD + QUAY_USERNAME=$(jq -r '.inputs.quay_registry_username' $GITHUB_EVENT_PATH) + echo ::add-mask::$QUAY_USERNAME + echo ":closed_lock_with_key: Logging in to quay.io..." + echo "$QUAY_PASSWORD" | docker login quay.io -u "$QUAY_USERNAME" --password-stdin + - name: Build Docker image locally + uses: docker/build-push-action@v5 + with: + context: slack_worker + push: false + tags: ${{ env.IMAGE_NAME }}:${{ env.NEXT_TAG_VERSION }} + load: true + timeout-minutes: 30 + - name: Trivy scan gating + run: | + if [ "$PUSH_ON_CRITICAL" == "true" ]; then + EXIT_CODE=0 + else + EXIT_CODE=1 + fi + + echo "Using Trivy exit code: $EXIT_CODE" + + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy:0.69.3 image \ + --severity CRITICAL \ + --format table \ + --exit-code $EXIT_CODE \ + ${{ env.IMAGE_NAME }}:${{ env.NEXT_TAG_VERSION }} + env: + PUSH_ON_CRITICAL: ${{ inputs.push_when_critical_errors_in_scan }} + + - name: Push Docker image to Quay + uses: docker/build-push-action@v5 + with: + context: slack_worker + push: true + tags: ${{ env.IMAGE_NAME }}:${{ env.NEXT_TAG_VERSION }} + + - name: Clean up + if: ${{ always() }} + run: | + echo ":wastebasket: Removing temporary file etc" + rm -f /home/runner/.docker/config.json + docker logout quay.io || true diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 29ac1b2..df1d339 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -41,26 +41,47 @@ jobs: echo "✅ No .env files detected." fi - # run-entrypoint: - # name: Test Python Entrypoint - # runs-on: ubuntu-latest + tests: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed + run: | + echo "CHANGED=$(git diff --name-only origin/${{ github.base_ref }} | tr '\n' ' ')" >> $GITHUB_ENV + + - name: Setup python requirements + if: contains(env.CHANGED, 'sdk/') || contains(env.CHANGED, 'tests/') || contains(env.CHANGED, 'slack_handlers/') || contains(env.CHANGED, 'api/') || contains(env.CHANGED, '/') + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt - # steps: - # - name: Checkout Code - # uses: actions/checkout@v3 + - name: Test SDK AWS + if: contains(env.CHANGED, 'sdk/aws/') || contains(env.CHANGED, 'sdk/tests/') + run: | + python -m pytest sdk/tests/test_runner.py::TestRunner::test_aws - # - name: Set up Python - # uses: actions/setup-python@v4 - # with: - # python-version: 3.12 + - name: Test SDK OpenStack + if: contains(env.CHANGED, 'sdk/openstack/') || contains(env.CHANGED, 'sdk/tests/') + run: | + python -m pytest sdk/tests/test_runner.py::TestRunner::test_openstack - # - name: Install Dependencies - # run: | - # python -m pip install --upgrade pip - # # Install project dependencies - # pip install -r requirements.txt + - name: Test SDK Tools + if: contains(env.CHANGED, 'sdk/tools/') || contains(env.CHANGED, 'sdk/tests/') + run: | + python -m pytest sdk/tests/test_runner.py::TestRunner::test_tools - # - name: Run Entrypoint - # run: | - # export $(grep -v '^#' vars | xargs) - # python -c "import slack_main" || exit 1 + - name: Test Slack Handlers + if: (contains(env.CHANGED, 'tests/') && !contains(env.CHANGED, 'sdk/')) || contains(env.CHANGED, 'slack_handlers/') + run: | + python -m pytest tests/test_runner.py::TestRunner::test_handlers + + - name: Test Slack Commands + run: | + python -m pytest tests/test_runner.py::TestRunner::test_slack_commands diff --git a/.github/workflows/slackworker-checks.yaml b/.github/workflows/slackworker-checks.yaml new file mode 100644 index 0000000..0054d5e --- /dev/null +++ b/.github/workflows/slackworker-checks.yaml @@ -0,0 +1,65 @@ +name: SlackWorker Checks + +on: + pull_request: + branches: + - main + paths: + - 'slack_worker/**' + - '.github/workflows/slackworker-checks.yaml' + +jobs: + lint: + name: code linting + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff Linter on slack_worker + run: | + ruff check slack_worker/ --output-format=github + ruff format --check slack_worker/ + + check-env-files: + name: Check for .env and Secrets + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Search for .env Files + run: | + if find slack_worker/ -type f -name "*.env" | grep .env; then + echo "❌ .env file detected in slack_worker. Failing the build." + exit 1 + else + echo "✅ No .env files detected in slack_worker." + fi + + tests: + name: SlackWorker Tests + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r slack_worker/requirements.txt + + - name: Run SlackWorker Tests + run: | + python -m pytest slack_worker/tests/ -v --tb=short diff --git a/.gitignore b/.gitignore index a1d668d..363b1a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,21 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ + +# Unit test / coverage reports +.pytest_cache/ + +# Environments .env +.venv +env/ +venv/ + +# IDE settings +.idea/ +.vscode/ + +# Ruff stuff: .ruff_cache/ -__pycache__/ \ No newline at end of file + +# Mac OS +.DS_Store diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..3c5b0fb --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @prabhapa diff --git a/Dockerfile b/Dockerfile index 6775604..0c4837f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,24 @@ - -FROM python:3.9-slim +FROM python:3.12-alpine WORKDIR /app -COPY . /app +# Copy files and directories separately to ensure proper structure +COPY requirements.txt config.py slack_main.py /app/ +COPY sdk /app/sdk/ +COPY slack_handlers /app/slack_handlers/ + +# Install build dependencies, upgrade security-critical packages, then install Python packages +RUN apk add --no-cache --virtual .build-deps gcc musl-dev linux-headers python3-dev \ + && apk upgrade libcrypto3 libssl3 sqlite-libs \ + && pip install --no-cache-dir -r requirements.txt \ + && apk del .build-deps + +# Verify sqlite-libs version (for security audit) +RUN apk info sqlite-libs && echo "✅ sqlite-libs version verified" -RUN pip install --no-cache-dir -r requirements.txt +# Remove sqlite binary tools (sqlite-libs must remain as Python dependency) +# Note: sqlite-libs cannot be removed from Alpine Python - it's a core Python dependency +RUN apk del sqlite || true -CMD ["python", "slack_main.py"] \ No newline at end of file +# Run the app +CMD ["python", "slack_main.py"] diff --git a/README.md b/README.md index b45161f..404f7a6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # OCP Infra Slack Bot -This is a Slack bot built to help users interact with AWS and OpenStack cloud resources through simple commands. The bot can create OpenShift clusters in AWS and virtual machines in OpenStack etc etc. It can connect to JIRA and Jenkins to perform some tasks.It also provides helpful information when mentioned or through direct messages. +This is a Slack bot built to help users interact with AWS and OpenStack cloud resources through simple commands. The bot can create OpenShift clusters in AWS and virtual machines in OpenStack etc etc. It can connect to JIRA and Jenkins to perform some tasks. It also provides helpful information when mentioned or through direct messages. ## Features -- **Create AWS OpenShift Clusters**: Easily create an OpenShift cluster in AWS using the `create-aws-cluster` command. -- **Create OpenStack Virtual Machines**: Create a VM on OpenStack using the `create-openstack-vm` command. +- **Infrastructure commands**: Handles the infrastructure create tasks with different cloud providers : AWS, AZURE, GCP etc. +- **JIRA & other tool commands**: Handle JIRA commands and other jenkins/ci job trigger commands. - **Help Command**: The bot responds with a list of available commands when mentioned with the word `help`. +- **Other commands**: Commands to display important team links, trigger a tool/job etc. - **Message Handling**: The bot can respond to messages and mentions, providing helpful information or performing actions based on user input. +- **Common slack SDK python module** : we are building a common slack backend python module which is opensource and can be installable and reusable . Planning to push to pypi public python registry available to all. +- **API interface using above module** : we are also building an api wrapper around the SDK . This can be deployed an independet app and provides same integrations that we are using in slackbot . This will be helpful to have same functionality with other apps such as google chat etc. +- **ROTA Notifications**: Automated release schedule reminders - group notifications, individual DMs, and Smartsheet to Google Sheets sync. ## Technologies @@ -18,7 +22,7 @@ This is a Slack bot built to help users interact with AWS and OpenStack cloud re ## Requirements -- Python 3.8 or higher +- Python 3.12 - Slack App with `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` - AWS account with `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` - OpenStack credentials (`OS_AUTH_URL`, `OS_PROJECT_NAME`, `OS_INTERFACE`, `OS_ID_API_VERSION`,`OS_REGION_NAME`,`OS_APP_CRED_ID`,`OS_APP_CRED_SECRET`,`OS_AUTH_TYPE`) @@ -38,11 +42,11 @@ cd ocp-sustaining-bot ``` ### 2. Create a Virtual Environment -It’s recommended to use a virtual environment to manage dependencies: +It's recommended to use a virtual environment to manage dependencies: ```bash -python3 -m venv venv -source venv/bin/activate +python3 -m venv .venv +source .venv/bin/activate ``` ### 3. Install Dependencies @@ -72,18 +76,90 @@ OS_PROJECT_NAME=your-openstack-project-name ```bash python slack_main.py ``` + ## Slack Commands -**/create-aws-cluster ** +### AWS + +**create-aws-cluster ** Creates an AWS OpenShift cluster using the provided cluster_name. -**/create-openstack-vm ** -Creates an OpenStack VM with the specified name, image, flavor, and network. +**aws vm create** +Creates an AWS EC2 instance. + +Sample usage: +``` +aws vm create --os_name=linux --instance_type=t2.micro --key_pair=new +aws vm create --os_name=linux --instance_type=t2.micro --key_pair=existing +``` + +**aws vm list** +Lists AWS EC2 instances + +Sample usage: +``` + +aws vm list --state=pending,running,shutting-down,terminated,stopping,stopped +aws vm list --type=t3.micro,t2.micro +aws vm list --type=t3.micro,t2.micro --state=pending,stopped +aws vm list --instance-ids=i-123456,i-987654 + +``` +**/aws vm modify --stop --vm-id=** +Stops a specific AWS EC2 instance by its instance ID. The instance can be restarted later. + +**/aws vm modify --delete --vm-id=** +Deletes a specific AWS EC2 instance by its instance ID. + + +Note 1: +The list of parameters that can be passed using the --type subcommand is extremely large. +See [https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/describe_instances.html](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/describe_instances.html) for the complete list. +Search for t2.micro + +### Openstack +**openstack vm create ** +Creates an OpenStack VM with the specified name, os type, flavor, network and key name. + +Sample usage: +``` +openstack vm create --name=PAYMENTGATEWAY1 --os_name=fedora --flavor=ci.cpu.small --network=provider_net_ocp_dev --key_name=sustaining-bot-key +``` + +**openstack vm list ** +Lists OpenStack VMs. + +Sample usage: +``` +openstack vm list -status=ACTIVE +openstack vm list -status=ERROR +``` +### GCP +**gcp vm create** +Creates a Google Cloud VM with the given name and OS. **Instance type** is optional: use ``--instance-type=`` or ``--instance_type=``; the value must be in ``GCP_POPULAR_INSTANCE_TYPES`` (see ``gcp_popular_instance_types`` / env). If omitted, **``GCP_DEFAULT_INSTANCE_TYPE``** is used (default **e2-medium** when allowed, else the first popular type). + +Boot disk size (GB) defaults to ``GCP_BOOT_DISK_SIZE_GB`` (allowed **10, 20, 50**; see ``_GCP_DEFAULT_DISK_SIZES``). Optional ``--disk-size-gb=`` overrides for this create. + +Popular types list: set ``GCP_POPULAR_INSTANCE_TYPES`` in ``.env`` as JSON (e.g. ``["e2-medium","n2-standard-4"]``). + +gcp vm create name= --os_name=debian-12 + +gcp vm create name= --os_name=debian-12 --instance-type=n2-standard-4 + +gcp vm create name= --os_name=debian-12 --instance_type=e2-medium --disk-size-gb=20 -**/hello** +**/gcp vm modify --stop --vm-name=** +Stops a specific GCP instance by its instance name. The instance can be restarted later. + + +**/gcp vm modify --delete --vm-name=** +Deletes a specific GCP instance by its instance name. + +### Other +**hello** Greets the user with a friendly message. -**/help** +**help** Lists the available commands and how to use them. ## Event Handling @@ -93,6 +169,31 @@ The bot responds to the following events: Direct Messages: Responds to DMs with helpful information. Mentions: If the bot is mentioned in a message, it can respond with a message or trigger an action. +## ROTA Notifications System + +Automated release schedule management with three main components: + +### 1. Smartsheet to Google Sheets Sync +- **Schedule**: Daily at 8 AM (UTC) +- **Function**: Fetches OCP release data from Smartsheet (versions 4.12-4.16) +- **Filtering**: Z-stream format only, dev flag enabled, current month + 1 month ahead +- **Output**: Writes to Google Sheets (ROTA → Assignments worksheet) +- **Status**: Tracks "Is Active?" for each release + +### 2. Group Reminder Notification +- **Schedule**: Monday & Thursday at 9 AM (UTC) +- **Function**: Posts release schedule to Slack channel +- **Content**: Current week and next week releases +- **Audience**: Entire team in configured Slack channel + +### 3. Individual DM Reminders +- **Schedule**: Friday at 9 AM, Monday at 9 AM (UTC) +- **Function**: Sends direct messages to team members +- **Content**: Personalized release reminders +- **Audience**: Individual Slack users in ROTA rotation + +### Configuration +See [SLACK_WORKER_IMPLEMENTATION.md](./SLACK_WORKER_IMPLEMENTATION.md) for detailed setup and [CONTAINERIZATION_GUIDE.md](./CONTAINERIZATION_GUIDE.md) for Docker deployment. ## Contributions @@ -111,21 +212,39 @@ Feel free to fork the repository and submit pull requests. Contributions are wel 6. Create a new Pull Request ## Testing -1. There is a slack bot namely : ocp-sustaining-bot is already created and whose credentials will be shared with you along with other cloud credentials. -2. There is a testing slack workspace created : slackbot-template.slack.com . Please get added your user/mail to this by admin. +1. There is a dev slack bot namely : ocp-sustaining-bot is already created . Please get those credentials along with other cloud credentials of the slack bot. +2. There is a testing slack workspace created : slackbot-template.slack.com . Please get your user/mail added to this by the admin. 3. Make sure you add your local .env file with all secrets to the repo root. -4. Once you have your code changes ready the run the code locally using `python slack_main.py` +4. Once you have your code changes ready then run the code locally using `python slack_main.py` 5. Then from the slackbot template workspace run your command by mention or direct message to test. ## Draft requirements Please refer to the below google docs + https://docs.google.com/document/d/1D_efhIfCjikWhoY43WqJOOe25DEKsWeXIbmEpqkFJfo/edit?tab=t.0 I will have those tasks added under our sustaining jira project soon. -## TBD -1. unit tests to be added -2. We need to raise a slack addon enablement service now ticket (https://redhat.service-now.com/help?id=sc_cat_item&sys_id=35bfc06313b82a00dce03ff18144b0d2 ) for this bot to be added to our workspace. -3. Once we are ready with basic commands we can deploy this to our server / or any redhat platform where we run production workloads. Then ppl can start using this on redhat workspace. -4. We need to limit this bot our user group which is not done yet. -5. We need to create a build ,test , deploy pipeline to prod for automating new change deployment. Will use github actions. +## Backend Deployment + +1. A build pipeline called "Create Docker Image for Slackbot" will create the docker image and push it to the registry `quay.io/ocp_sustaining_engineering/slack_backend` which is also open source. +2. It runs as a docker container inside `project-tools` VM in our openstack cluster. + +3. **Troubleshooting tips** : + Get the key of that server from admin to login . Then use below or similar commands to explore. + + **Docker container commands** : + To check if container is running or killed : + `docker ps -a` + + **Docker run command** : + + `docker run -d --name slack_backend --env-file /root/sec/.env --restart unless-stopped quay.io/ocp_sustaining_engineering/slack_backend:1.0.1` + + **To trace logs** : + + `docker logs -f slack_backend` + + **Increase the log**: + + update root/sec/.env and set LOG_LEVEL=DEBUG. Then stop the container and restart it with above mentioned run command. diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 8fa8448..0000000 Binary files a/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/aws/__init__.py b/api/Dockerfile similarity index 100% rename from aws/__init__.py rename to api/Dockerfile diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..28b07ef --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/api/aws/aws_service.py b/api/aws/aws_service.py new file mode 100644 index 0000000..8cf5c64 --- /dev/null +++ b/api/aws/aws_service.py @@ -0,0 +1,17 @@ +from api.cloud_services import CloudService +from sdk.aws.ec2 import EC2Helper + + +def aws_get_service(service: str, type: str, state: str): + query_dict = {} + query_dict["state"] = state + query_dict["type"] = type + aws_helper = EC2Helper() + if service == CloudService.vms: + instances = aws_helper.list_instances(query_dict) + return { + "instances": instances, + "service": service, + "type": type, + "state": state, + } diff --git a/api/cloud_services.py b/api/cloud_services.py new file mode 100644 index 0000000..f46e5d4 --- /dev/null +++ b/api/cloud_services.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class CloudService(str, Enum): + vms = "vms" diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..28fb7c2 --- /dev/null +++ b/api/main.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI +from api.router.aws_router import router as aws_router + + +def create_api() -> FastAPI: + app = FastAPI(title="ocp-sustaining-bot API", version="1.0.0") + app.include_router(router=aws_router, prefix="/aws") + return app + + +app = create_api() diff --git a/azure/core.py b/api/pyproject.toml similarity index 100% rename from azure/core.py rename to api/pyproject.toml diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..60fdca1 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1 @@ +fastapi==0.116.1 \ No newline at end of file diff --git a/api/router/aws_router.py b/api/router/aws_router.py new file mode 100644 index 0000000..cde1ca4 --- /dev/null +++ b/api/router/aws_router.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from api.aws.aws_service import aws_get_service + +router = APIRouter() + + +@router.get("/{service}") +def aws_router(service: str, type: str, state: str): + if service == "vms": + return aws_get_service(service=service, type=type, state=state) diff --git a/citools/jenkins.py b/api/tests/tests.py similarity index 100% rename from citools/jenkins.py rename to api/tests/tests.py diff --git a/aws/__pycache__/__init__.cpython-312.pyc b/aws/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e22a09b..0000000 Binary files a/aws/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/aws/__pycache__/core.cpython-312.pyc b/aws/__pycache__/core.cpython-312.pyc deleted file mode 100644 index a114ff3..0000000 Binary files a/aws/__pycache__/core.cpython-312.pyc and /dev/null differ diff --git a/aws/__pycache__/rosa.cpython-312.pyc b/aws/__pycache__/rosa.cpython-312.pyc deleted file mode 100644 index 1c2dc02..0000000 Binary files a/aws/__pycache__/rosa.cpython-312.pyc and /dev/null differ diff --git a/aws/ec2_helper.py b/aws/ec2_helper.py deleted file mode 100644 index b62c881..0000000 --- a/aws/ec2_helper.py +++ /dev/null @@ -1,45 +0,0 @@ -import boto3 -from config import config - - -class EC2Helper: - def __init__(self, region=None): - self.region = region or config.AWS_DEFAULT_REGION - self.session = boto3.Session( - aws_access_key_id=config.AWS_ACCESS_KEY_ID, - aws_secret_access_key=config.AWS_SECRET_ACCESS_KEY, - region_name=self.region, - ) - - def list_instances(self): - """ - List all EC2 instances in the specified region. - """ - ec2 = self.session.client("ec2") - response = ec2.describe_instances() - return response["Reservations"] - - def create_instance( - self, image_id, instance_type, key_name, security_group_id, subnet_id - ): - """ - Create an EC2 instance with the given parameters. - """ - ec2 = self.session.resource("ec2") - try: - instance_params = { - "ImageId": image_id, - "InstanceType": instance_type, - "KeyName": key_name, - "SecurityGroupIds": [security_group_id], - "MinCount": 1, - "MaxCount": 1, - } - if subnet_id: - instance_params["SubnetId"] = subnet_id - - instances = ec2.create_instances(**instance_params) - return instances[0] - except Exception as e: - print(f"An error occurred: {e}") - return None diff --git a/aws/rosa_helper.py b/aws/rosa_helper.py deleted file mode 100644 index 13345a6..0000000 --- a/aws/rosa_helper.py +++ /dev/null @@ -1,57 +0,0 @@ -import subprocess - - -class ROSAHelper: - def create_rosa_cluster(self, cluster_name, say): - """ - Create a ROSA cluster using the ROSA CLI. - If `say` is provided, it will send messages back to Slack. - """ - if not cluster_name: - if say: - say( - "Please provide a cluster name. Usage: `create-aws-cluster `" - ) - return - - if say: - say( - f"Creating AWS OpenShift cluster: {cluster_name} in region {self.region}..." - ) - - try: - command = [ - "rosa", - "create", - "cluster", - "--cluster-name", - cluster_name, - "--region", - self.region, - ] - subprocess.run(command, check=True) - if say: - say(f"Cluster {cluster_name} created successfully in AWS!") - except subprocess.CalledProcessError as e: - if say: - say(f"Error creating AWS cluster: {str(e)}") - raise e - - def list_rosa_clusters(self, say=None): - """ - List all ROSA clusters using the ROSA CLI. - If `say` is provided, it will send the list to Slack. - """ - if say: - say("Fetching ROSA clusters...") - - try: - command = ["rosa", "list", "clusters"] - result = subprocess.run(command, capture_output=True, text=True, check=True) - if say: - say(f"ROSA Clusters:\n{result.stdout}") - return result.stdout - except subprocess.CalledProcessError as e: - if say: - say(f"Error fetching ROSA clusters: {str(e)}") - raise e diff --git a/config.py b/config.py index 1ff0adc..5a4a8fe 100644 --- a/config.py +++ b/config.py @@ -1,51 +1,247 @@ +import logging import os +import re from dotenv import load_dotenv +from dynaconf import Dynaconf +import tempfile +import json +import httpx +import hvac + +required_keys = [ + "GOOGLE_CLOUD_CREDS", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_DEFAULT_REGION", + "OS_AUTH_URL", + "OS_PROJECT_ID", + "OS_INTERFACE", + "OS_ID_API_VERSION", + "OS_REGION_NAME", + "OS_APP_CRED_ID", + "OS_APP_CRED_SECRET", + "OS_AUTH_TYPE", + "ALLOW_ALL_WORKSPACE_USERS", + "ALLOWED_SLACK_USERS", + "ROTA_SERVICE_ACCOUNT", + "ROTA_ADMINS", + "ROTA_USERS", +] -# Load environment variables from a .env file load_dotenv() +# Default GCP machine types for help / command choices if ``GCP_POPULAR_INSTANCE_TYPES`` is unset +# or invalid in ``.env`` (JSON array of strings). +gcp_popular_instance_types = [ + "e2-micro", + "e2-small", + "e2-medium", + "e2-standard-2", + "e2-standard-4", + "e2-standard-8", + "n1-standard-1", + "n1-standard-2", + "n1-standard-4", + "n2-standard-2", + "n2-standard-4", + "n2-standard-8", + "n2-standard-16", + "n2-highmem-4", + "n2-highmem-8", + "n2d-standard-2", + "n2d-standard-4", + "n2d-standard-8", + "c2d-standard-4", + "c2d-standard-8", +] + +_GCP_MACHINE_TYPE_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + +# GCP boot disk size (GB) must be one of these; env ``GCP_BOOT_DISK_SIZE_GB`` picks which to use. +_GCP_DEFAULT_DISK_SIZES = (10, 20, 50) + + +def _resolve_gcp_boot_disk_size_gb(): + """ + Read ``GCP_BOOT_DISK_SIZE_GB`` from config (``.env`` / Vault). If missing or invalid, + use ``_GCP_DEFAULT_DISK_SIZES[0]``. If set but not in ``_GCP_DEFAULT_DISK_SIZES``, + log a warning and use ``_GCP_DEFAULT_DISK_SIZES[0]``. + """ + raw = getattr(config, "GCP_BOOT_DISK_SIZE_GB", None) + if raw is None or (isinstance(raw, str) and not raw.strip()): + return _GCP_DEFAULT_DISK_SIZES[0] + try: + n = int(raw) + except (TypeError, ValueError): + logging.warning( + "GCP_BOOT_DISK_SIZE_GB=%r is not an integer; using %s", + raw, + _GCP_DEFAULT_DISK_SIZES[0], + ) + return _GCP_DEFAULT_DISK_SIZES[0] + if n not in _GCP_DEFAULT_DISK_SIZES: + logging.warning( + "GCP_BOOT_DISK_SIZE_GB=%s is not in %s; using %s", + n, + _GCP_DEFAULT_DISK_SIZES, + _GCP_DEFAULT_DISK_SIZES[0], + ) + return _GCP_DEFAULT_DISK_SIZES[0] + return n + + +def _normalize_gcp_instance_types_list(value): + """Return sorted unique machine type strings, or None if value is missing/invalid.""" + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None + try: + value = json.loads(stripped) + except json.JSONDecodeError: + return None + if not isinstance(value, (list, tuple)): + return None + out = [] + for x in value: + s = str(x).strip().lower() + if s and len(s) <= 63 and _GCP_MACHINE_TYPE_NAME_RE.match(s): + out.append(s) + return sorted(set(out)) if out else None + + +req_env_vars = { + "RH_CA_BUNDLE_TEXT", + "VAULT_ENABLED_FOR_DYNACONF", + "VAULT_URL_FOR_DYNACONF", + "VAULT_SECRET_ID_FOR_DYNACONF", + "VAULT_ROLE_ID_FOR_DYNACONF", + "VAULT_MOUNT_POINT_FOR_DYNACONF", + "VAULT_PATH_FOR_DYNACONF", + "VAULT_KV_VERSION_FOR_DYNACONF", +} + +# DON'T MOVE: `basicConfig` gets called only once. Dynaconf sets it to `WARNING` so our setting should be above that +log_level = os.getenv("LOG_LEVEL", "INFO") +log_level = log_level.upper() +log_level_int = getattr(logging, log_level, 20) +log_format = "[%(asctime)s %(levelname)s %(name)s] %(message)s" +logging.basicConfig(level=log_level_int, format=log_format) + +vault_enabled = req_env_vars <= set(os.environ.keys()) # subset of os.environ -class Config: +# Load CA Cert to avoid SSL errors +ca_bundle_file = tempfile.NamedTemporaryFile() +cert_txt = os.getenv("RH_CA_BUNDLE_TEXT", "") +cert_text_final = cert_txt.replace("\\n", "\n") +with open(ca_bundle_file.name, "w") as f: + f.write(cert_text_final) + +# print("CA bundle file:", ca_bundle_file.name) +# os.system(f"cat {ca_bundle_file.name}") + + +try: + config = Dynaconf( + load_dotenv=True, # This will load config from `.env` + environment=False, # This will disable layered env + vault_enabled=vault_enabled, + vault={ + "url": os.getenv("VAULT_URL_FOR_DYNACONF", ""), + "verify": ca_bundle_file.name, + }, + envvar_prefix=False, # This will make it so that ALL the variables from `.env` are loaded + ) +except (httpx.ConnectError, ConnectionError): + logging.warn("Vault connection failed") +except hvac.exceptions.InvalidRequest: + logging.warn("Authentication error with Vault") + +for key in dir(config): + try: + value = getattr(config, key) + if isinstance(value, str): + val = json.loads(value) + # NOTE: This will create a `Dynabox` object which is basically a wrapper around dicts which allows . access to keys + # So you can access `config.key.val` instead of `config.key['val']` but it can be used like a dictionary as well. + config.set(key, val) + except json.decoder.JSONDecodeError: + logging.warn(f"{key} is not a valid JSON string .") + pass + except AttributeError: + logging.warn(f"Attribute {key} not found.") # Should usually be harmless + +# ``GCP_POPULAR_INSTANCE_TYPES`` in ``.env``: JSON array of machine types, e.g. +# GCP_POPULAR_INSTANCE_TYPES=["e2-medium","n2-standard-4"] +# If missing or invalid, ``config.GCP_POPULAR_INSTANCE_TYPES`` is set to ``gcp_popular_instance_types``. +_raw_gcp_instance_types = getattr(config, "GCP_POPULAR_INSTANCE_TYPES", None) +_gcp_types = _normalize_gcp_instance_types_list(_raw_gcp_instance_types) +if _gcp_types is None: + if _raw_gcp_instance_types not in (None, ""): + logging.warning( + "GCP_POPULAR_INSTANCE_TYPES is missing or invalid; using gcp_popular_instance_types" + ) + _gcp_types = list(gcp_popular_instance_types) +else: + logging.info( + "GCP_POPULAR_INSTANCE_TYPES loaded from environment (%d types)", + len(_gcp_types), + ) +config.set("GCP_POPULAR_INSTANCE_TYPES", _gcp_types) + + +def _resolve_gcp_default_instance_type(): """ - Config class to load and validate environment variables. + Default machine type when ``gcp vm create`` omits ``--instance-type`` / ``--instance_type``. - If any environment variable is missing or empty, a ValueError will be raised. + Read ``GCP_DEFAULT_INSTANCE_TYPE`` from config. It must appear in + ``GCP_POPULAR_INSTANCE_TYPES``. If missing or invalid, use ``e2-medium`` when allowed, + otherwise the first entry in ``GCP_POPULAR_INSTANCE_TYPES``. """ + allowed = list(config.GCP_POPULAR_INSTANCE_TYPES) + allowed_set = set(allowed) + raw = getattr(config, "GCP_DEFAULT_INSTANCE_TYPE", None) + fallback = "e2-medium" if "e2-medium" in allowed_set else allowed[0] + + if raw is None or (isinstance(raw, str) and not raw.strip()): + return fallback + + s = str(raw).strip().lower() + if not _GCP_MACHINE_TYPE_NAME_RE.match(s): + logging.warning( + "GCP_DEFAULT_INSTANCE_TYPE=%r is not a valid machine type name; using %s", + raw, + fallback, + ) + return fallback + if s not in allowed_set: + logging.warning( + "GCP_DEFAULT_INSTANCE_TYPE=%s is not in GCP_POPULAR_INSTANCE_TYPES; using %s", + s, + fallback, + ) + return fallback + return s + + +config.set( + "GCP_DEFAULT_INSTANCE_TYPE", + _resolve_gcp_default_instance_type(), +) + +# ``GCP_DEFAULT_INSTANCE_TYPE`` in ``.env``: machine type used when ``gcp vm create`` omits +# ``--instance-type`` / ``--instance_type``. Must be in ``GCP_POPULAR_INSTANCE_TYPES``. + +# Boot disk size (GB) for `gcp vm create`: ``GCP_BOOT_DISK_SIZE_GB`` in ``.env`` (must be one of +# ``_GCP_DEFAULT_DISK_SIZES``). Resolved and stored as ``config.GCP_BOOT_DISK_SIZE_GB``. +config.set("GCP_BOOT_DISK_SIZE_GB", _resolve_gcp_boot_disk_size_gb()) - def __init__(self): - required_keys = [ - "SLACK_BOT_TOKEN", - "SLACK_APP_TOKEN", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_DEFAULT_REGION", - "OS_AUTH_URL", - "OS_PROJECT_ID", - "OS_INTERFACE", - "OS_ID_API_VERSION", - "OS_REGION_NAME", - "OS_APP_CRED_ID", - "OS_APP_CRED_SECRET", - "OS_AUTH_TYPE", - ] - for key in required_keys: - value = os.getenv(key) - try: - if not value: - # Raise an error if the environment variable is missing or empty - raise ValueError( - f"Missing or empty value for environment variable: {key}" - ) - - setattr(self, key, value) - - except ValueError as e: - print(f"Error: {e}") - raise - - except Exception as e: - print(f"Unexpected error with environment variable {key}: {e}") - raise - - -config = Config() +# Verify that all keys were loaded correctly +for k in required_keys: + if not hasattr(config, k): + logging.error(f"Could not read key: {k} from Vault.") + raise AttributeError(f"Could not read key: {k} from Vault.") diff --git a/ostack/__pycache__/__init__.cpython-312.pyc b/ostack/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index dfc0ab6..0000000 Binary files a/ostack/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ostack/__pycache__/core.cpython-312.pyc b/ostack/__pycache__/core.cpython-312.pyc deleted file mode 100644 index 6592a57..0000000 Binary files a/ostack/__pycache__/core.cpython-312.pyc and /dev/null differ diff --git a/ostack/core.py b/ostack/core.py deleted file mode 100644 index 0a00661..0000000 --- a/ostack/core.py +++ /dev/null @@ -1,59 +0,0 @@ -from openstack import connection -from config import config - - -class OpenStackHelper: - def __init__( - self, - auth_url=None, - project_name=None, - application_credential_id=None, - application_credential_secret=None, - ): - self.conn = connection.Connection( - auth_url=config.OS_AUTH_URL, - project_id=config.OS_PROJECT_ID, - application_credential_id=config.OS_APP_CRED_ID, - application_credential_secret=config.OS_APP_CRED_SECRET, - region_name=config.OS_REGION_NAME, - interface=config.OS_INTERFACE, - identity_api_version=config.OS_ID_API_VERSION, - auth_type=config.OS_AUTH_TYPE, - ) - - def list_servers(self): - """ - List all servers in OpenStack. - """ - return [server.name for server in self.conn.compute.servers()] - - def create_vm(self, args, say): - """ - Create an OpenStack VM with the specified parameters provided as a list of arguments. - If `say` is provided, it will send messages back to Slack. - - :param args: List of arguments: [name, image, flavor, network] - :param say: (optional) Slack messaging function for feedback - :return: The created server object - """ - if len(args) != 4: - if say: - say("Usage: `create-openstack-vm `") - return - - name, image, flavor, network = args - - if say: - say(f"Creating OpenStack VM: {name}...") - - try: - server = self.conn.compute.create_server( - name=name, image=image, flavor=flavor, networks=[{"uuid": network}] - ) - if say: - say(f"VM {server.name} created successfully in OpenStack!") - return server - except Exception as e: - if say: - say(f"Error creating OpenStack VM: {str(e)}") - raise e diff --git a/requirements.txt b/requirements.txt index 46ca424..7930af0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,19 @@ -boto3 -openshift -openstacksdk -requests -python-dotenv -slack-bolt -aiohttp +# HTTP client with minimal dependencies - compression disabled by default +boto3==1.38.18 +openshift==0.13.2 +openstacksdk==4.5.0 +httpx==0.28.1 +python-dotenv==1.1.0 +slack_bolt==1.23.0 +pytest==8.3.5 +dynaconf[vault]==3.2.11 +gspread==6.2.1 +requests>=2.28.0 +packaging>=21.0 +python-dateutil>=2.8.2 + +# Slack SDK - REQUIRED for ROTA notifications (WebClient, SlackApiError) +# Used by slack_client.py for: chat_postMessage (channels), conversations_open (DMs) +# Without this, group reminders and DM notifications will fail +slack-sdk==3.35.0 +google-cloud-compute==1.14.0 diff --git a/scripts/aws/main.tf b/scripts/aws/main.tf new file mode 100644 index 0000000..addce70 --- /dev/null +++ b/scripts/aws/main.tf @@ -0,0 +1,130 @@ +provider "aws" { + region = var.aws_region +} + +# VPC +resource "aws_vpc" "custom_vpc" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = var.vpc_name + Owner = var.vpc_owner + } +} + +# Internet Gateway +resource "aws_internet_gateway" "igw" { + vpc_id = aws_vpc.custom_vpc.id + + tags = { + Name = "${var.vpc_name}-igw" + Owner = var.vpc_owner + } +} + +# Public Subnet 1 +resource "aws_subnet" "public_subnet_1" { + vpc_id = aws_vpc.custom_vpc.id + cidr_block = var.public_subnet_cidr_1 + availability_zone = var.public_subnet_az_1 + map_public_ip_on_launch = true + + tags = { + Name = "${var.vpc_name}-public-subnet-1" + Owner = var.vpc_owner + } +} + +# Public Subnet 2 +resource "aws_subnet" "public_subnet_2" { + vpc_id = aws_vpc.custom_vpc.id + cidr_block = var.public_subnet_cidr_2 + availability_zone = var.public_subnet_az_2 + map_public_ip_on_launch = true + + tags = { + Name = "${var.vpc_name}-public-subnet-2" + Owner = var.vpc_owner + } +} + +# Public Route Table for Routing to Internet Gateway +resource "aws_route_table" "public_rt" { + vpc_id = aws_vpc.custom_vpc.id + + tags = { + Name = "${var.vpc_name}-public-rt" + Owner = var.vpc_owner + } +} + +# Route for Internet Access +resource "aws_route" "public_internet_access" { + route_table_id = aws_route_table.public_rt.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.igw.id + depends_on = [aws_internet_gateway.igw] +} + +# Associate Route Table with Public Subnets +resource "aws_route_table_association" "public_assoc_1" { + subnet_id = aws_subnet.public_subnet_1.id + route_table_id = aws_route_table.public_rt.id +} + +resource "aws_route_table_association" "public_assoc_2" { + subnet_id = aws_subnet.public_subnet_2.id + route_table_id = aws_route_table.public_rt.id +} + +# Security Group for SSH Access +resource "aws_security_group" "allow_ssh" { + name = "allow_ssh" + description = "Allow SSH access from anywhere" + vpc_id = aws_vpc.custom_vpc.id + + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] # Allow SSH from anywhere + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" # Allow all outbound traffic + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "Allow SSH" + Owner = var.vpc_owner + } +} + +# Output the VPC ID +output "vpc_id" { + description = "The ID of the custom VPC" + value = aws_vpc.custom_vpc.id +} + +# Output the Public Subnets IDs +output "public_subnet_id_1" { + description = "The ID of the first public subnet" + value = aws_subnet.public_subnet_1.id +} + +output "public_subnet_id_2" { + description = "The ID of the second public subnet" + value = aws_subnet.public_subnet_2.id +} + +# Output the Security Group ID +output "security_group_id" { + description = "The ID of the security group" + value = aws_security_group.allow_ssh.id +} + diff --git a/scripts/aws/vars.tf b/scripts/aws/vars.tf new file mode 100644 index 0000000..65c9020 --- /dev/null +++ b/scripts/aws/vars.tf @@ -0,0 +1,48 @@ +variable "aws_region" { + description = "The AWS region to deploy the VPC" + type = string + default = "ap-south-1" +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "vpc_name" { + description = "Name tag for the VPC" + type = string + default = "openshift-sustaining-vpc" +} + +variable "vpc_owner" { + description = "Resource Owner" + type = string + default = "openshift sustaining slack bot" +} + +variable "public_subnet_cidr_1" { + description = "CIDR block for the first public subnet" + type = string + default = "10.0.1.0/24" +} + +variable "public_subnet_cidr_2" { + description = "CIDR block for the second public subnet" + type = string + default = "10.0.2.0/24" +} + +variable "public_subnet_az_1" { + description = "Availability Zone for the first public subnet" + type = string + default = "ap-south-1a" +} + +variable "public_subnet_az_2" { + description = "Availability Zone for the second public subnet" + type = string + default = "ap-south-1b" +} + diff --git a/ocp/__init__.py b/sdk/__init__.py similarity index 100% rename from ocp/__init__.py rename to sdk/__init__.py diff --git a/sdk/aws/ec2.py b/sdk/aws/ec2.py new file mode 100644 index 0000000..e7f8160 --- /dev/null +++ b/sdk/aws/ec2.py @@ -0,0 +1,435 @@ +import boto3 +from config import config +from sdk.tools.helpers import get_list_of_values_for_key_in_dict_of_parameters +import logging +import random +import string +import traceback +import botocore + +logger = logging.getLogger(__name__) + + +class EC2Helper: + def __init__(self, region=None): + self.region = region or config.AWS_DEFAULT_REGION + self.session = boto3.Session( + aws_access_key_id=config.AWS_ACCESS_KEY_ID, + aws_secret_access_key=config.AWS_SECRET_ACCESS_KEY, + region_name=self.region, + ) + logger.info(f"Region set for session: {self.region}") + + def list_instances(self, params_dict=None): + """ + get all EC2 instances in the specified region. + returns a dictionary with information on server instances + """ + if params_dict is None: + params_dict = {} + try: + ec2 = self.session.client("ec2") + + # instance ids to retrieve + instance_ids = get_list_of_values_for_key_in_dict_of_parameters( + "instance-ids", params_dict + ) + + filters = [] + state_filters = get_list_of_values_for_key_in_dict_of_parameters( + "state", params_dict + ) + if state_filters: + filters.append({"Name": "instance-state-name", "Values": state_filters}) + + instance_type_filters = get_list_of_values_for_key_in_dict_of_parameters( + "type", params_dict + ) + if instance_type_filters: + filters.append( + {"Name": "instance-type", "Values": instance_type_filters} + ) + + response = ec2.describe_instances(InstanceIds=instance_ids, Filters=filters) + except Exception as e: + logger.error(f"Unable to get instances description from AWS: {e}") + raise e + + instances_info = [] + + if response: + reservations = response.get("Reservations", []) + for reservation in reservations: + for instance in reservation.get("Instances", []): + instance_state_name = instance.get("State", {}).get("Name", "") + + # Tags is a list and each element in the list is a dictionary + ec2_instance_name = "" + ec2_architecture = "" + for tag in instance.get("Tags", []): + key = tag.get("Key", "") + value = tag.get("Value", "") + if key == "Name": + ec2_instance_name = value + elif key == "architecture": + ec2_architecture = value + + # Create a formatted string with instance details + instance_info = { + "name": ec2_instance_name, + "architecture": ec2_architecture, + "instance_id": instance.get("InstanceId", ""), + "image_id": instance.get("ImageId", ""), + "instance_type": instance.get("InstanceType", ""), + "key_name": instance.get("KeyName", ""), + "vpc_id": instance.get("VpcId", ""), + "public_ip": instance.get("PublicIpAddress", "N/A"), + "private_ip": instance.get("PrivateIpAddress", "N/A"), + "state": instance_state_name, + } + instances_info.append(instance_info) + + # return a dictionary that contains the instances_info array and the count of server instances + return {"count": len(instances_info), "instances": instances_info} + + def _get_custom_vpc_id(self, vpc_name="openshift-sustaining-vpc"): + """ + Get the custom VPC ID based on the VPC Name or Tag. + """ + ec2_client = self.session.client("ec2") + + try: + # Describe VPCs and filter by Name tag (or any other criteria) + response = ec2_client.describe_vpcs( + Filters=[{"Name": "tag:Name", "Values": [vpc_name]}] + ) + vpcs = response.get("Vpcs", []) + if not vpcs: + raise Exception(f"No VPC found with name/tag '{vpc_name}'.") + + return vpcs[0]["VpcId"] + except Exception as e: + logger.error(f"Error fetching custom VPC ID: {e}") + raise + + def _get_subnet_ids(self, vpc_id): + """ + Get the subnet IDs associated with the given VPC. + """ + ec2_client = self.session.client("ec2") + + try: + # Describe subnets in the VPC + response = ec2_client.describe_subnets( + Filters=[{"Name": "vpc-id", "Values": [vpc_id]}] + ) + if not response["Subnets"]: + raise Exception(f"No subnets found for VPC '{vpc_id}'.") + + # Extract subnet IDs from the response + subnet_ids = [subnet["SubnetId"] for subnet in response["Subnets"]] + logger.info(f"Found subnets: {subnet_ids}") # Log the subnets found + return subnet_ids + + except Exception as e: + logger.error(f"Error fetching subnet IDs: {e}") + raise + + def _get_security_group_id(self, sec_group_name): + """ + Get the security group ID for the VPC. + """ + ec2_client = self.session.client("ec2") + + try: + # Describe security groups in the VPC + response = ec2_client.describe_security_groups( + Filters=[{"Name": "tag:Name", "Values": [sec_group_name]}] + ) + security_groups = response["SecurityGroups"] + + if not security_groups: + raise Exception( + f"No security group found with name '{sec_group_name}'." + ) + + sg_id = security_groups[0]["GroupId"] + logger.info( + f"Found Security Group: {sg_id}" + ) # Log the security group found + return sg_id + + except Exception as e: + logger.error(f"Error fetching security group ID: {e}") + raise # This ensures the error is raised and traceback is preserved + + def create_instance(self, image_id, instance_type, key_name): + """ + Create an EC2 instance with the given parameters. + """ + try: + # Get the username for tagging + sts = self.session.client("sts") + identity = sts.get_caller_identity() + arn = identity["Arn"] + username = ( + arn.split("/")[-1] + + "-" + + "".join(random.choices(string.ascii_lowercase + string.digits, k=5)) + ) + + ec2_resource = self.session.resource("ec2") + + # Dynamically fetch the custom VPC ID for the region + vpc_id = self._get_custom_vpc_id() + if not vpc_id: + logger.error("No VPC ID found.") + return {"count": 0, "instances": [], "error": "No VPC ID found."} + + # Fetch subnet + subnet_ids = self._get_subnet_ids(vpc_id) + if not subnet_ids: + logger.warning("No subnets found in the specified VPC") + return {"count": 0, "instances": [], "error": "No subnets found."} + subnet_id = random.choice(subnet_ids) + + # Fetch security group + security_group_id = self._get_security_group_id(sec_group_name="Allow SSH") + if not security_group_id: + logger.error("No Security Groups found.") + return { + "count": 0, + "instances": [], + "error": "No Security Group found with name Allow SSH.", + } + + # Define instance parameters + instance_params = { + "ImageId": image_id, + "InstanceType": instance_type, + "KeyName": key_name, + "SecurityGroupIds": [security_group_id], + "SubnetId": subnet_id, + "TagSpecifications": [ + { + "ResourceType": "instance", + "Tags": [{"Key": "Name", "Value": f"{username}"}], + } + ], + "MinCount": 1, + "MaxCount": 1, + } + + # Now create the EC2 instance using the defined parameters + instances = ec2_resource.create_instances(**instance_params) + + if not instances: + logger.warning(f"Unable to create EC2 instance: {instance_params}") + return { + "count": 0, + "instances": [], + "error": "EC2 instance creation returned no instances. This could be due to invalid parameters, lack of capacity, or AWS service issues.", + } + + instance = instances[0] + instance.wait_until_running() + instance.reload() + + logger.info( + f"Instance {instance.id} created successfully with name '{username}'" + ) + + instance_info = { + "name": username, + "instance_id": instance.id, + "key_name": key_name, + "instance_type": instance_type, + "public_ip": instance.public_ip_address, + } + + return { + "count": 1, + "instances": [instance_info], + } + + except Exception as e: + logger.error(f"An error occurred creating the EC2 instance: {e}") + logger.debug(traceback.format_exc()) + return { + "count": 0, + "instances": [], + "error": str(e), + } + + def create_keypair(self, key_name: str): + """ + Function to create a keypair on aws and return the private key. + It will default to RSA algorithm instead of ED25519 because Windows only supports ED25519 + """ + client = self.session.client("ec2") + new_key = client.create_key_pair( + KeyName=key_name, + # default to RSA because ED25519 is not supported on Windows + KeyType="rsa", + KeyFormat="pem", # pem is globally supported, ppk is PuTTY only + DryRun=False, + ) + + return new_key + + def describe_keypair(self, key_name: str = None): + """ + Function to return a list of all the keypairs or it will return the specific keypair if `key_name` is specified + Returns a dictionary with `KeyName` and `KeyFingerprint` keys + """ + client = self.session.client("ec2") + try: + if key_name: + # Ideally single key should be passed + if not isinstance(key_name, list): + key_name = [key_name] + return client.describe_key_pairs(KeyNames=key_name).get( + "KeyPairs", None + )[0] # 1 key per user + else: + return client.describe_key_pairs().get("KeyPairs", None) + except botocore.exceptions.ClientError: + # Will throw exception if there are no keys + return None + except TypeError: + logger.error("No key found but `ClientError` not thrown.") + return None + + def delete_keypair(self, key_name: str): + """ + Function to delete the keypair specified + """ + client = self.session.client("ec2") + result = client.delete_key_pair(KeyName=key_name, DryRun=False) + if result.get("Return", None): + logger.debug(f"Delete keypair result: {result}") + return True + else: + logger.error( + f"Couldn't delete keypair with name: {key_name}. Error:\n{result}" + ) + return False + + def stop_instance(self, instance_id: str): + """ + Stop a specific EC2 instance by ID. + + :param instance_id: The ID of the instance to stop + :return: Dictionary with operation status and details + """ + try: + ec2_client = self.session.client("ec2") + + response = ec2_client.describe_instances(InstanceIds=[instance_id]) + + if not response["Reservations"]: + return {"success": False, "error": f"Instance {instance_id} not found"} + + instance = response["Reservations"][0]["Instances"][0] + current_state = instance["State"]["Name"] + + if current_state == "stopped": + return { + "success": False, + "error": f"Instance {instance_id} is already stopped", + } + + if current_state not in ["running", "pending"]: + return { + "success": False, + "error": f"Instance {instance_id} is in state '{current_state}' and cannot be stopped", + } + + response = ec2_client.stop_instances(InstanceIds=[instance_id]) + + logger.info(f"Successfully initiated stop for instance {instance_id}") + + return { + "success": True, + "instance_id": instance_id, + "previous_state": current_state, + "current_state": response["StoppingInstances"][0]["CurrentState"][ + "Name" + ], + } + + except botocore.exceptions.ClientError as e: + error_code = e.response["Error"]["Code"] + error_message = e.response["Error"]["Message"] + logger.error( + f"AWS API error stopping instance {instance_id}: {error_code} - {error_message}" + ) + return {"success": False, "error": f"AWS API error: {error_message}"} + except Exception as e: + logger.error(f"Unexpected error stopping instance {instance_id}: {str(e)}") + return {"success": False, "error": f"Unexpected error: {str(e)}"} + + def terminate_instance(self, instance_id: str): + """ + Terminate (delete) a specific EC2 instance by ID. + + :param instance_id: The ID of the instance to terminate + :return: Dictionary with operation status and details + """ + try: + ec2_client = self.session.client("ec2") + + response = ec2_client.describe_instances(InstanceIds=[instance_id]) + + if not response["Reservations"]: + return {"success": False, "error": f"Instance {instance_id} not found"} + + instance = response["Reservations"][0]["Instances"][0] + current_state = instance["State"]["Name"] + + if current_state == "terminated": + return { + "success": False, + "error": f"Instance {instance_id} is already terminated", + } + + if current_state == "terminating": + return { + "success": False, + "error": f"Instance {instance_id} is already being terminated", + } + + instance_name = "" + for tag in instance.get("Tags", []): + if tag.get("Key") == "Name": + instance_name = tag.get("Value") + break + + response = ec2_client.terminate_instances(InstanceIds=[instance_id]) + + logger.info( + f"Successfully initiated termination for instance {instance_id} (name: {instance_name})" + ) + + return { + "success": True, + "instance_id": instance_id, + "instance_name": instance_name, + "previous_state": current_state, + "current_state": response["TerminatingInstances"][0]["CurrentState"][ + "Name" + ], + } + + except botocore.exceptions.ClientError as e: + error_code = e.response["Error"]["Code"] + error_message = e.response["Error"]["Message"] + logger.error( + f"AWS API error terminating instance {instance_id}: {error_code} - {error_message}" + ) + return {"success": False, "error": f"AWS API error: {error_message}"} + except Exception as e: + logger.error( + f"Unexpected error terminating instance {instance_id}: {str(e)}" + ) + return {"success": False, "error": f"Unexpected error: {str(e)}"} diff --git a/citools/prow.py b/sdk/aws/s3.py similarity index 100% rename from citools/prow.py rename to sdk/aws/s3.py diff --git a/gcp/core.py b/sdk/azure/vm.py similarity index 100% rename from gcp/core.py rename to sdk/azure/vm.py diff --git a/jira/core.py b/sdk/citools/jenkins.py similarity index 100% rename from jira/core.py rename to sdk/citools/jenkins.py diff --git a/ostack/__init__.py b/sdk/citools/prow.py similarity index 100% rename from ostack/__init__.py rename to sdk/citools/prow.py diff --git a/sdk/gcp/compute_engine.py b/sdk/gcp/compute_engine.py new file mode 100644 index 0000000..b489e7b --- /dev/null +++ b/sdk/gcp/compute_engine.py @@ -0,0 +1,402 @@ +import logging +import re +import traceback + +from config import _GCP_DEFAULT_DISK_SIZES, config +from google.api_core import exceptions as google_exceptions +from google.cloud import compute_v1 +from google.oauth2 import service_account +from sdk.tools.helpers import get_list_of_values_for_key_in_dict_of_parameters + +logger = logging.getLogger(__name__) + + +class GCPHelper: + def __init__(self): + self.region = getattr(config, "GCP_DEFAULT_REGION", "asia-south1") + _info = dict(config["GOOGLE_CLOUD_CREDS"]) + self._credentials = service_account.Credentials.from_service_account_info( + _info, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + self.project_id = _info.get("project_id", "") + network = getattr(config, "GCP_NETWORK", None) or "default" + # network = "slackbot-vpc" + if not network.startswith(("projects/", "global/")): + network = f"global/networks/{network}" + self.network = network + # Subnetwork required for custom VPC; use same region as helper + subnetwork = getattr(config, "GCP_SUBNETWORK", None) + # subnetwork = "slackbot-vpc-net1" + if subnetwork and not subnetwork.startswith(("projects/", "regions/")): + subnetwork = f"regions/{self.region}/subnetworks/{subnetwork}" + self.subnetwork = subnetwork + logger.info(f"Region set for session: {self.region}") + + def _instance_to_info(self, instance, zone_key): + """Map a GCP Instance to the EC2-style instance info dict.""" + # Zone key is like "zones/us-central1-a"; strip to get zone name + zone_name = zone_key.split("/")[-1] if zone_key else "" + + state = (instance.status or "").lower() if instance.status else "" + + # machine_type is a URL; use last segment (e.g. n1-standard-4) + machine_type = "N/A" + if instance.machine_type: + machine_type = instance.machine_type.split("/")[-1] + + # Boot disk: first boot disk's source (image URL contains /images/) + image_id = "N/A" + if instance.disks: + for d in instance.disks: + if getattr(d, "boot", False): + src = getattr(d, "source", "") or "" + if "/images/" in src: + image_id = src.split("/")[-1] + break + + # Labels (GCP) -> name, architecture (like EC2 tags) + name = instance.name or "" + architecture = "" + if instance.labels: + architecture = instance.labels.get("architecture", "") + + # Network: first interface (proto uses network_i_p, access_configs) + private_ip = "N/A" + public_ip = "N/A" + vpc_id = "N/A" + if instance.network_interfaces: + ni = instance.network_interfaces[0] + private_ip = getattr(ni, "network_i_p", None) or "N/A" + network_url = getattr(ni, "network", None) or "" + vpc_id = network_url.split("/")[-1] if network_url else "N/A" + for ac in getattr(ni, "access_configs", []) or []: + if getattr(ac, "type", None) == "ONE_TO_ONE_NAT": + public_ip = getattr(ac, "nat_i_p", None) or "N/A" + break + + return { + "name": name, + "architecture": architecture, + "instance_id": str(instance.id) if instance.id else instance.name or "", + "image_id": image_id, + "instance_type": machine_type, + "key_name": "", # GCP uses metadata/OS Login or project-level keys + "vpc_id": vpc_id, + "public_ip": public_ip, + "private_ip": private_ip, + "state": state, + "zone": zone_name, + } + + def _get_zone_by_instance_name(self, instance_name): + """ + Resolve the zone of an instance by name (search in self.region). + Returns (zone_name, None) if found, or (None, error_message) if not. + """ + if not instance_name or not instance_name.strip(): + return None, "Instance name is required" + instance_name = instance_name.strip() + try: + client = compute_v1.InstancesClient(credentials=self._credentials) + request = compute_v1.AggregatedListInstancesRequest() + request.project = self.project_id + request.max_results = 500 + zone_prefix = f"zones/{self.region}" + for zone_key, response in client.aggregated_list(request=request): + if not response.instances or not zone_key.startswith(zone_prefix): + continue + for instance in response.instances: + if instance.name == instance_name: + zone_name = zone_key.split("/")[-1] + return zone_name, None + return None, f"Instance '{instance_name}' not found in region {self.region}" + except Exception as e: + logger.error(f"Error resolving instance zone: {e}") + return None, str(e) + + def stop_instance(self, instance_name): + """ + Stop a GCP VM instance by name. + + :param instance_name: The name of the instance to stop. + :return: Dict with "success" (bool) and optionally "error" (str). + """ + zone, err = self._get_zone_by_instance_name(instance_name) + if err: + return {"success": False, "error": err} + try: + client = compute_v1.InstancesClient(credentials=self._credentials) + operation = client.stop( + project=self.project_id, + zone=zone, + instance=instance_name, + ) + operation.result(timeout=120) + logger.info(f"Successfully stopped instance {instance_name} in zone {zone}") + return {"success": True, "instance_name": instance_name, "zone": zone} + except google_exceptions.NotFound: + return {"success": False, "error": f"Instance '{instance_name}' not found"} + except Exception as e: + logger.error(f"Error stopping instance {instance_name}: {e}") + logger.debug(traceback.format_exc()) + return {"success": False, "error": str(e)} + + def delete_instance(self, instance_name): + """ + Delete a GCP VM instance by name. + + :param instance_name: The name of the instance to delete. + :return: Dict with "success" (bool) and optionally "error" (str). + """ + zone, err = self._get_zone_by_instance_name(instance_name) + if err: + return {"success": False, "error": err} + try: + client = compute_v1.InstancesClient(credentials=self._credentials) + operation = client.delete( + project=self.project_id, + zone=zone, + instance=instance_name, + ) + operation.result(timeout=120) + logger.info(f"Successfully deleted instance {instance_name} in zone {zone}") + return {"success": True, "instance_name": instance_name, "zone": zone} + except google_exceptions.NotFound: + return {"success": False, "error": f"Instance '{instance_name}' not found"} + except Exception as e: + logger.error(f"Error deleting instance {instance_name}: {e}") + logger.debug(traceback.format_exc()) + return {"success": False, "error": str(e)} + + def list_instances(self, params_dict=None): + """ + get all GCP instances in the specified region. + returns a dictionary with information on server instances (EC2-style shape). + """ + if params_dict is None: + params_dict = {} + instance_ids = get_list_of_values_for_key_in_dict_of_parameters( + "instance-ids", params_dict + ) + state_filters = get_list_of_values_for_key_in_dict_of_parameters( + "state", params_dict + ) + instance_type_filters = get_list_of_values_for_key_in_dict_of_parameters( + "type", params_dict + ) + if state_filters: + state_filters = {s.lower() for s in state_filters} + if instance_type_filters: + instance_type_filters = {t.lower() for t in instance_type_filters} + + try: + client = compute_v1.InstancesClient(credentials=self._credentials) + request = compute_v1.AggregatedListInstancesRequest() + request.project = self.project_id + request.max_results = 500 + agg_list = client.aggregated_list(request=request) + + instances_info = [] + # Restrict to zones in self.region (e.g. us-central1-a, us-central1-b) + zone_prefix = f"zones/{self.region}" + for zone_key, response in agg_list: + if not response.instances: + continue + if not zone_key.startswith(zone_prefix): + continue + for instance in response.instances: + state = (instance.status or "").lower() + if state_filters and state not in state_filters: + continue + machine_type = "" + if instance.machine_type: + machine_type = instance.machine_type.split("/")[-1].lower() + if ( + instance_type_filters + and machine_type not in instance_type_filters + ): + continue + if instance_ids: + name_ok = instance.name in instance_ids + id_ok = str(instance.id) in instance_ids + if not (name_ok or id_ok): + continue + info = self._instance_to_info(instance, zone_key) + instances_info.append(info) + + return {"count": len(instances_info), "instances": instances_info} + except google_exceptions.Forbidden as e: + logger.error(f"GCP Compute API Forbidden (403): {e}") + raise PermissionError( + "Access to Compute Engine API was denied. For project " + f"'{self.project_id}': (1) Enable the 'Compute Engine API' in Google " + "Cloud Console (APIs & Services → Library → Compute Engine API). " + "(2) Grant the service account (e.g. Compute Viewer, " + "roles/compute.viewer) on the project. If you just enabled the API, " + "wait a few minutes and retry." + ) from e + except Exception as e: + logger.error(f"Unable to get instances from GCP: {e}") + raise + + def create_instance( + self, + image_id, + instance_type, + instance_name, + disk_gb_override=None, + zone=None, + network=None, + ): + """ + Create a GCP VM instance with the given parameters + + Args: + image_id: Source image for the boot disk. Use a full path + (e.g. projects/debian-cloud/global/images/family/debian-12) + or an image family name (e.g. debian-12) which will be resolved + as projects/debian-cloud/global/images/family/. + instance_type: Machine type (e.g. n1-standard-1, e2-medium). + instance_name: Name for the VM (1–63 chars, lowercase, digits, hyphens; + must match [a-z]([-a-z0-9]*[a-z0-9])?). + disk_gb_override: Optional boot disk size in GB; must be in + ``_GCP_DEFAULT_DISK_SIZES``. If None, uses ``config.GCP_BOOT_DISK_SIZE_GB``. + zone: Optional zone (e.g. asia-south1-a). Defaults to -a. + network: Optional network name or URL (e.g. default, or + projects/PROJECT/global/networks/VPC). Uses config.GCP_NETWORK + if not set, then global/networks/default. + + Returns: + {"count": 1, "instances": [{"name", "instance_id", "instance_type", "zone", + "disk_gb", "public_ip"}]} + or on error {"count": 0, "instances": [], "error": "..."}. + """ + zone = zone or f"{self.region}-a" + instance_name = (instance_name or "").strip().lower() + if not instance_name: + return { + "count": 0, + "instances": [], + "error": "instance_name is required", + } + if len(instance_name) > 63: + return { + "count": 0, + "instances": [], + "error": "instance_name must be at most 63 characters", + } + if not re.match(r"^[a-z]([-a-z0-9]*[a-z0-9])?$", instance_name): + return { + "count": 0, + "instances": [], + "error": "instance_name must start with a letter, use only lowercase letters, digits, and hyphens", + } + + if disk_gb_override is not None: + try: + disk_gb = int(disk_gb_override) + except (TypeError, ValueError): + return { + "count": 0, + "instances": [], + "error": "disk_gb_override must be an integer", + } + if disk_gb not in _GCP_DEFAULT_DISK_SIZES: + return { + "count": 0, + "instances": [], + "error": ( + f"disk size must be one of {_GCP_DEFAULT_DISK_SIZES} GB " + "(use --disk-size-gb or GCP_BOOT_DISK_SIZE_GB)" + ), + } + else: + disk_gb = int(config.GCP_BOOT_DISK_SIZE_GB) + + try: + client = compute_v1.InstancesClient(credentials=self._credentials) + + # Resolve image: if not a full path, treat as image family + if image_id.startswith("projects/"): + source_image = image_id + else: + source_image = f"projects/debian-cloud/global/images/family/{image_id}" + + attached_disk = compute_v1.AttachedDisk() + init_params = compute_v1.AttachedDiskInitializeParams() + init_params.source_image = source_image + init_params.disk_size_gb = disk_gb + attached_disk.initialize_params = init_params + attached_disk.auto_delete = True + attached_disk.boot = True + + # Network: param overrides instance default (self.network from config) + network_ref = network if network is not None else self.network + if not network_ref.startswith(("projects/", "global/")): + network_ref = f"global/networks/{network_ref}" + + network_interface = compute_v1.NetworkInterface() + network_interface.network = network_ref + if self.subnetwork: + network_interface.subnetwork = self.subnetwork + access = compute_v1.AccessConfig() + access.type_ = "ONE_TO_ONE_NAT" + access.name = "External NAT" + access.network_tier = "PREMIUM" + network_interface.access_configs = [access] + + instance_resource = compute_v1.Instance() + instance_resource.name = instance_name + instance_resource.machine_type = ( + f"zones/{zone}/machineTypes/{instance_type}" + ) + instance_resource.disks = [attached_disk] + instance_resource.network_interfaces = [network_interface] + + request = compute_v1.InsertInstanceRequest() + request.project = self.project_id + request.zone = zone + request.instance_resource = instance_resource + + operation = client.insert(request=request) + operation.result(timeout=300) + + created = client.get( + project=self.project_id, + zone=zone, + instance=instance_name, + ) + public_ip = "N/A" + if created.network_interfaces: + ni = created.network_interfaces[0] + for ac in getattr(ni, "access_configs", []) or []: + if getattr(ac, "nat_i_p", None): + public_ip = ac.nat_i_p + break + + instance_info = { + "name": instance_name, + "instance_id": str(created.id) if created.id else instance_name, + "instance_type": instance_type, + "zone": zone, + "disk_gb": disk_gb, + "public_ip": public_ip, + } + logger.info(f"Instance {instance_name} created successfully in {zone}") + return {"count": 1, "instances": [instance_info]} + except google_exceptions.Forbidden as e: + logger.error(f"GCP Compute API Forbidden (403): {e}") + return { + "count": 0, + "instances": [], + "error": ( + "Access denied to Compute Engine API. Enable the API and " + "grant the service account roles/compute.instanceAdmin.v1 " + "(or similar) on the project." + ), + } + except Exception as e: + logger.error(f"An error occurred creating the GCP instance: {e}") + logger.debug(traceback.format_exc()) + return {"count": 0, "instances": [], "error": str(e)} diff --git a/sdk/gsheet/gsheet.py b/sdk/gsheet/gsheet.py new file mode 100644 index 0000000..d5a8f0f --- /dev/null +++ b/sdk/gsheet/gsheet.py @@ -0,0 +1,123 @@ +try: + from config import config +except ModuleNotFoundError: + from slack_worker.config import config + +import gspread +import re +import logging +from datetime import date + +logger = logging.getLogger(__name__) + + +class GSheet: + def __init__(self, token: dict = config.ROTA_SERVICE_ACCOUNT): + account = gspread.service_account_from_dict(token) + self._rota_sheet = account.open(config.ROTA_SHEET) + self._assignment_wsheet = self._rota_sheet.worksheet(config.ASSIGNMENT_WSHEET) + + def add_release( + self, + rel_ver: str, + s_date: date = None, + e_date: date = None, + pm: str = None, + qe: str = None, + notify_date: str = None, + ) -> None: + passed_args = {**locals()} + release_regex = re.compile(r"^\d\.\d{1,3}\.\d{1,3}$") + if not release_regex.match(rel_ver): + logger.debug(f"{rel_ver} seems to be wrongly formatted") + raise ValueError( + f"{rel_ver} does not seem to match the expected format `\\d\\.\\d{1, 3}\\.\\d{1, 3}`" + ) + + row_to_append = [rel_ver, s_date, e_date, pm, qe, notify_date] + + if not row_to_append: + logging.error(f"No row to be updated: {passed_args}") + raise ValueError("No arguments to be added.") + + self._assignment_wsheet.append_row( + row_to_append, value_input_option="USER_ENTERED" + ) # use `input_option` so that Appscript gets triggered + + def fetch_data_by_release(self, rel_ver: str) -> list: + release_regex = re.compile(r"^\d\.\d{1,3}\.\d{1,3}$") + if not release_regex.match(rel_ver): + logger.debug(f"{rel_ver} seems to be wrongly formatted") + raise ValueError( + f"{rel_ver} does not seem to match the expected format `\\d\\.\\d{1, 3}\\.\\d{1, 3}`" + ) + + values = self._assignment_wsheet.get_values("A:G") # only get relevant columns + + return_val = None + for v in values: + if v[0] == rel_ver: + return_val = v + break + + return return_val + + def fetch_data_by_weekId(self, target_monday: date) -> list: + """Get releases for a specific Monday date + + Args: + target_monday: The Monday date to filter by (from column F - ERR) + + Returns: + List of release rows matching the target Monday + """ + values = self._assignment_wsheet.get_values("A:G") # only get relevant columns + target_monday_str = str(target_monday) + + # Filter releases where column F (ERR) matches the target Monday date + return [v for v in values if len(v) > 5 and v[5] == target_monday_str] + + def replace_user_for_release( + self, rel_ver: str, column: str, user: str = None + ) -> None: + column = column.lower() + release_regex = re.compile(r"^\d\.\d{1,3}\.\d{1,3}$") + if not release_regex.match(rel_ver): + logger.debug(f"{rel_ver} seems to be wrongly formatted") + raise ValueError( + f"{rel_ver} does not seem to match the expected format `\\d\\.\\d{1, 3}\\.\\d{1, 3}`" + ) + + if column not in ("pm", "qe"): + logger.error(f"Invalid value for replace column: {column}") + raise ValueError(f"Invalid value for replace column: {column}") + + column_letter_mapping = {"pm": "D", "qe": "E"} + + column_a1 = column_letter_mapping[column] + + values = self._assignment_wsheet.get_values("A:G") # only get relevant columns + + row_idx = -1 + for idx, v in enumerate(values): + if v[0] == rel_ver: + row_idx = idx + break + + if row_idx == -1: + logging.debug(f"Release {rel_ver} not found") + raise ValueError(f"Release {rel_ver} not found") + + cell_a1 = f"{column_a1}{row_idx + 1}" + if not user: + logger.debug(f"Cleared cell: {cell_a1}") + self._assignment_wsheet.batch_clear([cell_a1]) + else: + self._assignment_wsheet.update_acell(cell_a1, user) + + +try: + gsheet = GSheet() +except Exception as ex: + gsheet = None + logging.info(f"Error in call to Gsheet : {repr(ex)}") diff --git a/sdk/jira/jira.py b/sdk/jira/jira.py new file mode 100644 index 0000000..e69de29 diff --git a/ocp/core.py b/sdk/ocp/core.py similarity index 100% rename from ocp/core.py rename to sdk/ocp/core.py diff --git a/sdk/openstack/core.py b/sdk/openstack/core.py new file mode 100644 index 0000000..59c9e61 --- /dev/null +++ b/sdk/openstack/core.py @@ -0,0 +1,370 @@ +from openstack import connection +from openstack.exceptions import ResourceFailure, ConflictException +from config import config +from sdk.tools.helpers import get_list_of_values_for_key_in_dict_of_parameters +import logging +import traceback + +logger = logging.getLogger(__name__) + + +class OpenStackHelper: + def __init__( + self, + auth_url=None, + project_name=None, + application_credential_id=None, + application_credential_secret=None, + ): + self.conn = connection.Connection( + auth_url=config.OS_AUTH_URL, + application_credential_id=config.OS_APP_CRED_ID, + application_credential_secret=config.OS_APP_CRED_SECRET, + region_name=config.OS_REGION_NAME, + interface=config.OS_INTERFACE, + identity_api_version=config.OS_ID_API_VERSION, + auth_type=config.OS_AUTH_TYPE, + ) + + def list_servers(self, params_dict=None): + """ + List all OpenStack VMs, optionally filtered by status (e.g., 'ACTIVE', 'SHUTOFF'). + Returns a list of dictionaries with basic VM info. + """ + if params_dict is None: + params_dict = {} + + try: + # Extract status filters as a list + status_filter = get_list_of_values_for_key_in_dict_of_parameters( + "status", params_dict + ) + + # Default to ACTIVE if no status filter provided + status_filter = status_filter[0].upper() if status_filter else "ACTIVE" + + servers_info = [] + # Iterate through all servers + for server in self.conn.compute.servers(status=status_filter): + # Initialize IP-related fields + networks = server.addresses or {} + ip_addr, net_name = None, None + + # Prioritize floating IP, fallback to fixed if not available + for net, ips in networks.items(): + for ip_info in ips: + if ip_info.get("OS-EXT-IPS:type") == "floating": + ip_addr = ip_info.get("addr") + net_name = net + break + elif not ip_addr and ip_info.get("OS-EXT-IPS:type") == "fixed": + ip_addr = ip_info.get("addr") + net_name = net + + # Collect server details + servers_info.append( + { + "name": server.name, + "server_id": server.id, + "flavor": server.flavor.get("original_name") + or server.flavor.get("id"), + "network": net_name, + "private_ip": ip_addr, + "key_name": getattr(server, "key_name", "N/A"), + "status": server.status, + } + ) + + # Log the number of servers retrieved + logger.info( + f"Retrieved {len(servers_info)} servers with status filter '{status_filter}'." + ) + return {"count": len(servers_info), "instances": servers_info} + + except Exception as e: + # Log the exception that occurred during the listing process + logger.exception( + f"Error listing servers with status filter '{status_filter}': {e}" + ) + raise e + + def create_servers(self, name, image_id, flavor, key_name, network=None): + """ + Create an OpenStack VM with the specified parameters provided as a dictionary. + :param name: Name of the VM. + :param image_id: ID of the image to use. + :param flavor: Flavor name (size) of the VM. + :param key_name: Name of the SSH keypair to associate. + :param network: (Optional) Network UUID to attach the instance to. + :return: dictionary containing instance details. + """ + + logger.info( + f"Creating OpenStack VM: {name} with image {image_id}, flavor {flavor}, " + f"network {network}, key_name {key_name}" + ) + + networks_param = [{"uuid": network}] if network else [] + + # Validate the provided key_name + available_keys = [kp.name for kp in self.conn.compute.keypairs()] + if key_name not in available_keys: + logger.error( + f"Invalid key_name '{key_name}' provided. Available keypairs: {available_keys}" + ) + raise ValueError( + f"Invalid key_name '{key_name}' provided. Available keypairs: {available_keys}" + ) + + try: + # Resolve flavor by name + flavor = self.conn.compute.find_flavor(flavor, ignore_missing=False) + if not flavor: + raise ValueError(f"Flavor '{flavor}' not found in OpenStack.") + + # Optionally validate image exists + image = self.conn.compute.find_image(image_id, ignore_missing=False) + if not image: + raise ValueError(f"Image '{image_id}' not found in OpenStack.") + + server = self.conn.compute.create_server( + name=name, + image_id=image.id, + flavor_id=flavor.id, + networks=networks_param, + key_name=key_name, + ) + + # Wait for VM to become ACTIVE + server = self.conn.compute.wait_for_server(server) + + logger.info(f"VM {server.name} created successfully in OpenStack!") + + # Extract the first fixed (private) IP address + private_ip = None + for addr_list in server.addresses.values(): + for addr in addr_list: + if addr.get("OS-EXT-IPS:type") == "fixed": + private_ip = addr.get("addr") + break + if private_ip: + break + + logger.info(f"VM {server.name} is ACTIVE with private IP: {private_ip}") + + # Construct response dictionary + server_info = { + "name": server.name, + "server_id": server.id, + "status": server.status, + "flavor": flavor.name, + "network": network or "Default Network", + "key_name": key_name, + "private_ip": private_ip or "N/A", + } + + return { + "count": 1, + "instances": [server_info], + } + + except ResourceFailure as rf: + logger.error(f"OpenStack VM transitioned to ERROR state: {str(rf)}") + raise RuntimeError( + "OpenStack VM provisioning failed. Please verify image/flavor/network configuration." + ) from rf + + except Exception as e: + logger.error(f"Error creating OpenStack VM: {str(e)}") + logger.error(traceback.format_exc()) + raise e + + def create_keypair(self, key_name: str): + """ + Function to create keypair on Openstack and return the private key. + It will default to RSA to maintain consistency with AWS + """ + new_key = {} + try: + key = self.conn.create_keypair(key_name) + + new_key["KeyName"] = key_name + new_key["KeyFingerprint"] = key["fingerprint"] + new_key["KeyMaterial"] = key["private_key"] + + logger.debug( + f"Created keypair {key_name} with fingerprint: {key['fingerprint']}" + ) + + except ConflictException as e: + logger.error(f"Key already existed for this user: {e}") + + return new_key + + def delete_keypair(self, key_name: str): + """ + Function to delete keypair on Openstack. + Returns a boolean. + """ + result = self.conn.delete_keypair(key_name) + if not result: + logger.error(f"Couldn't delete key: {key_name}") + + return result + + def describe_keypair(self, key_name: str = None): + """ + Function to fetch keys from Openstack + """ + key = {} + try: + if key_name: + ret_keys = self.conn.list_keypairs({"name": key_name}) + if not ret_keys or not isinstance(ret_keys, list): + return None + + key["KeyName"] = ret_keys[0].name + key["KeyFingerprint"] = ret_keys[0].fingerprint + else: + ret_keys = self.conn.list_keypairs() + if not ret_keys or not isinstance(ret_keys, list): + return None + + key = ret_keys + except Exception as e: + logger.exception(f"Unexpected exception occurred while fetching keys: {e}") + # Return empty key + + return key + + def stop_server(self, server_id: str): + """ + Stop a specific OpenStack server by ID. + + :param server_id: The ID of the server to stop + :return: Dictionary with operation status and details + """ + try: + # Get server details first to validate it exists + server = self.conn.compute.find_server(server_id, ignore_missing=False) + if not server: + return {"success": False, "error": f"Server {server_id} not found"} + + current_status = server.status + + if current_status == "SHUTOFF": + return { + "success": False, + "error": f"Server {server_id} is already stopped (SHUTOFF)", + } + + if current_status not in ["ACTIVE", "SUSPENDED"]: + return { + "success": False, + "error": f"Server {server_id} is in status '{current_status}' and cannot be stopped", + } + + # Stop the server + self.conn.compute.stop_server(server) + + logger.info(f"Successfully initiated stop for server {server_id}") + + return { + "success": True, + "server_id": server_id, + "server_name": server.name, + "previous_status": current_status, + "current_status": "stopping", + } + + except Exception as e: + logger.error(f"Error stopping server {server_id}: {str(e)}") + logger.error(traceback.format_exc()) + return {"success": False, "error": f"Failed to stop server: {str(e)}"} + + def start_server(self, server_id: str): + """ + Start a specific OpenStack server by ID. + + :param server_id: The ID of the server to start + :return: Dictionary with operation status and details + """ + try: + # Get server details first to validate it exists + server = self.conn.compute.find_server(server_id, ignore_missing=False) + if not server: + return {"success": False, "error": f"Server {server_id} not found"} + + current_status = server.status + + if current_status == "ACTIVE": + return { + "success": False, + "error": f"Server {server_id} is already running (ACTIVE)", + } + + if current_status not in ["SHUTOFF", "SUSPENDED"]: + return { + "success": False, + "error": f"Server {server_id} is in status '{current_status}' and cannot be started", + } + + # Start the server + self.conn.compute.start_server(server) + + logger.info(f"Successfully initiated start for server {server_id}") + + return { + "success": True, + "server_id": server_id, + "server_name": server.name, + "previous_status": current_status, + "current_status": "starting", + } + + except Exception as e: + logger.error(f"Error starting server {server_id}: {str(e)}") + logger.error(traceback.format_exc()) + return {"success": False, "error": f"Failed to start server: {str(e)}"} + + def delete_server(self, server_id: str): + """ + Delete (terminate) a specific OpenStack server by ID. + + :param server_id: The ID of the server to delete + :return: Dictionary with operation status and details + """ + try: + # Get server details first to validate it exists + server = self.conn.compute.find_server(server_id, ignore_missing=False) + if not server: + return {"success": False, "error": f"Server {server_id} not found"} + + current_status = server.status + server_name = server.name + + if current_status in ["DELETED", "ERROR"]: + return { + "success": False, + "error": f"Server {server_id} is already in status '{current_status}'", + } + + # Delete the server + self.conn.compute.delete_server(server) + + logger.info( + f"Successfully initiated deletion for server {server_id} (name: {server_name})" + ) + + return { + "success": True, + "server_id": server_id, + "server_name": server_name, + "previous_status": current_status, + "current_status": "deleting", + } + + except Exception as e: + logger.error(f"Error deleting server {server_id}: {str(e)}") + logger.error(traceback.format_exc()) + return {"success": False, "error": f"Failed to delete server: {str(e)}"} diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml new file mode 100644 index 0000000..e69de29 diff --git a/sdk/smartsheet/__init__.py b/sdk/smartsheet/__init__.py new file mode 100644 index 0000000..b185df1 --- /dev/null +++ b/sdk/smartsheet/__init__.py @@ -0,0 +1,17 @@ +""" +Smartsheet SDK for fetching and syncing OCP release data +""" + +from .fetch_parse_write import ( + fetch_sheet_by_id, + parse_sheet_releases, + filter_releases, + write_to_gsheet, +) + +__all__ = [ + "fetch_sheet_by_id", + "parse_sheet_releases", + "filter_releases", + "write_to_gsheet", +] diff --git a/sdk/smartsheet/fetch_parse_write.py b/sdk/smartsheet/fetch_parse_write.py new file mode 100644 index 0000000..df2911a --- /dev/null +++ b/sdk/smartsheet/fetch_parse_write.py @@ -0,0 +1,312 @@ +""" +Smartsheet sync functions - fetch releases and write to Google Sheets +Fetches from Smartsheet → Parses → Filters → Writes to Google Sheets (daily 8 AM) +""" + +import json +import os +import re +from datetime import datetime, timedelta + +import requests +import gspread +from dateutil.relativedelta import relativedelta +from slack_worker.config import config + + +def fetch_sheet_by_id(sheet_id, access_token): + """Fetch sheet data by ID from Smartsheet API""" + url = f"https://api.smartsheet.com/2.0/sheets/{sheet_id}?includeAll=true" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + +def extract_version(version_str): + """Extract version from string like '4.12.30 in Fast Channel'""" + version_pattern = r"\b(\d+\.\d+(?:\.\d+)?)\b" + matches = re.findall(version_pattern, version_str) + return matches[0] if matches else None + + +def get_previous_weekId(date): + """Get the previous Monday of a given date. If date is already Monday, return it unchanged.""" + weekday = date.weekday() # 0=Monday, 1=Tuesday, ..., 6=Sunday + + if weekday == 0: # Already Monday + return date + else: + # Go back to the previous Monday + days_back = weekday + return date - timedelta(days=days_back) + + +def get_release_filter_date_range(): + """Get date range for filtering releases. + + Calculates: current month start to (current month + N months end) + where N is configurable via RELEASE_FILTER_MONTHS_AHEAD env var. + + Default: 1 month ahead from current month start + Example: If today is Feb 26, 2026: + - month_start: Feb 1, 2026 + - month_end: Mar 31, 2026 (1 month ahead) + + To modify range without code changes: + RELEASE_FILTER_MONTHS_AHEAD=2 (extends to 2 months ahead) + RELEASE_FILTER_MONTHS_AHEAD=3 (extends to 3 months ahead) + + Returns: + tuple: (month_start, month_end) as date objects + """ + current_date = datetime.now().date() + months_ahead = int(os.getenv("RELEASE_FILTER_MONTHS_AHEAD", "1")) + + month_start = current_date.replace(day=1) + # Add months_ahead + 1 to get end of the last month in range + next_month = month_start + relativedelta(months=months_ahead + 1) + month_end = next_month - timedelta(days=1) + + return month_start, month_end + + +def parse_sheet_releases(sheet, source_version): + """Parse releases from a Smartsheet sheet""" + releases = [] + rows = sheet.get("rows", []) + + for row in rows: + cells = row.get("cells", []) + + if len(cells) >= 3: + release_name = cells[0].get("displayValue") or cells[0].get("value", "") + version_cell = cells[1].get("displayValue") or cells[1].get("value", "") + date_cell = cells[2].get("value", "") + flags = ( + cells[3].get("displayValue") or cells[3].get("value", "") + if len(cells) > 3 + else "" + ) + + if not release_name or not date_cell: + continue + + try: + # Parse date + date_str = date_cell.split("T")[0] + finish_date = datetime.strptime(date_str, "%Y-%m-%d").date() + + # Extract version from cell combined with release name + version_str = extract_version( + str(version_cell) + " " + str(release_name) + ) + + if not version_str: + version_str = source_version + + releases.append( + { + "version": version_str, + "release_name": str(release_name), + "finish_date": finish_date, + "flag": str(flags), + } + ) + except Exception: + continue + + return releases + + +def filter_releases(all_releases): + """Filter releases by z-stream format, dev flag, and date range. + + Note: Releases are already fetched from specific version Smartsheet IDs, + so version range filtering is not needed here. + + Date range is configurable via RELEASE_FILTER_MONTHS_AHEAD env var (default: 1 month ahead). + + Only includes: + - z-stream versions (x.y.z format) like 4.14.61, not just 4.14 + - releases with 'dev' flag + - releases within configured date range + """ + + month_start, month_end = get_release_filter_date_range() + + filtered = [] + + for release in all_releases: + version_str = release["version"] + finish_date = release["finish_date"] + flag = release.get("flag", "") + + # Filter by z-stream format only (x.y.z, not x.y) + # A z-stream version should have 3 parts when split by dot + version_parts = version_str.split(".") + if len(version_parts) < 3: + continue + + # Filter by flag - only include "dev" flag + if "dev" not in flag.lower(): + continue + + # Filter by date range + if month_start <= finish_date <= month_end: + filtered.append(release) + + # Sort by finish_date + filtered.sort(key=lambda x: x["finish_date"]) + + return filtered + + +def write_to_gsheet(filtered_releases, gsheet_creds): + """Write filtered releases to Google Sheets ROTA -> Assignments + + Intelligently compares fetched releases with existing data: + - Updates dates (B, C) for existing releases (preserves PM/QE/NOTIFY_DATE in D-F) + - Adds new rows for new releases + - Marks rows for deleted releases (optional) + + This prevents mismatching PM/QE assignments to wrong releases. + """ + + try: + # Parse credentials: handle string JSON, dict, or DynaBox object + if isinstance(gsheet_creds, str): + creds_dict = json.loads(gsheet_creds) + else: + creds_dict = ( + dict(gsheet_creds) + if not isinstance(gsheet_creds, dict) + else gsheet_creds + ) + + client = gspread.service_account_from_dict(creds_dict) + + spreadsheet = client.open(config.ROTA_SHEET) + + try: + worksheet = spreadsheet.worksheet(config.ASSIGNMENT_WSHEET) + except gspread.exceptions.WorksheetNotFound: + available = [ws.title for ws in spreadsheet.worksheets()] + raise Exception(f"Assignments worksheet not found. Available: {available}") + + except (Exception, PermissionError) as e: + error_msg = str(e) + if "404" in error_msg or "not found" in error_msg.lower(): + raise Exception("ROTA spreadsheet not found") + elif "403" in error_msg or "permission" in error_msg.lower(): + raise Exception("Permission denied for ROTA spreadsheet") + else: + raise + + # Get existing data from worksheet + all_values = worksheet.get_all_values() + + if not all_values or len(all_values) == 0: + # Worksheet is empty, write headers and all new releases + worksheet.update( + values=[["Release", "Start Date", "End Date"]], range_name="A1:C1" + ) + all_values = [["Release", "Start Date", "End Date"]] + + # Build map of existing releases: {version: row_index} + existing_releases = {} + for idx in range(1, len(all_values)): # Skip header (row 0) + row = all_values[idx] + if row and len(row) > 0 and row[0]: # Has version in column A + version = row[0] + existing_releases[version] = idx + + # Build map of fetched releases with calculated dates + fetched_releases = {} + for rel in sorted(filtered_releases, key=lambda x: x["finish_date"]): + finish_date = rel["finish_date"] + start_date = finish_date # Use fetched date directly + end_date = start_date + timedelta(days=8) # Add 8 days to start_date + notify_date = get_previous_weekId( + start_date + ) # Get previous Monday, or keep if already Monday + + fetched_releases[rel["version"]] = { + "start_date": str(start_date), + "end_date": str(end_date), + "notify_date": str(notify_date), + } + + # Track updates and inserts + updates = [] # List of [range, values] + new_releases = [] # List of [version, start_date, end_date] only + new_releases_notify = {} # Map of version to notify_date for later update + rows_modified = 0 + + # Update existing releases with new dates (preserve D-F) + for version, dates in fetched_releases.items(): + if version in existing_releases: + row_idx = existing_releases[version] + row_num = row_idx + 1 # Convert to 1-based sheet row number + + # Update columns B, C, and F (start_date, end_date, and ERR) + updates.append( + (f"B{row_num}:C{row_num}", [[dates["start_date"], dates["end_date"]]]) + ) + # Update column F with notify_date (previous Monday or same if already Monday) + updates.append((f"F{row_num}", [[dates["notify_date"]]])) + rows_modified += 1 + else: + # New release - only append A, B, C (don't write to D-G yet) + new_releases.append([version, dates["start_date"], dates["end_date"]]) + new_releases_notify[version] = dates["notify_date"] + + print( + f"[DEBUG] {len([u for u in updates if 'F' in u[0]])} existing release ERR updates queued", + file=__import__("sys").stderr, + ) + print( + f"[DEBUG] {len(new_releases)} new releases to add + update ERR column", + file=__import__("sys").stderr, + ) + + # Apply date updates for existing releases + if updates: + for range_name, values in updates: + try: + worksheet.update(values=values, range_name=range_name) + except Exception as e: + print(f"Error updating {range_name}: {e}") + raise + + # Add new releases to the end (only columns A-C) + if new_releases: + worksheet.append_rows(new_releases) + rows_modified += len(new_releases) + + # Now update column F with notify_date for newly added rows + # Get the starting row number for the new releases + all_values_after = worksheet.get_all_values() + start_row = ( + len(all_values_after) - len(new_releases) + 1 + ) # +1 for 1-based indexing + + notify_updates = [] + for idx, (version, notify_date) in enumerate(new_releases_notify.items()): + row_num = start_row + idx + notify_updates.append((f"F{row_num}", [[notify_date]])) + + # Apply F column updates for new releases + if notify_updates: + for range_name, values in notify_updates: + try: + worksheet.update(values=values, range_name=range_name) + except Exception as e: + print(f"Error updating NOTIFY date for {range_name}: {e}") + raise + + return rows_modified diff --git a/sdk/tests/__init__.py b/sdk/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/tests/conftest.py b/sdk/tests/conftest.py new file mode 100644 index 0000000..22a1d9d --- /dev/null +++ b/sdk/tests/conftest.py @@ -0,0 +1,46 @@ +import os + +import pytest + +pytest_plugins = [ + "pytester", +] + + +@pytest.fixture(scope="session", autouse=True) +def setup_environment_variables(): + os.environ["SLACK_BOT_TOKEN"] = "SLACK_BOT_TOKEN" + os.environ["SLACK_APP_TOKEN"] = "SLACK_APP_TOKEN" + os.environ["AWS_ACCESS_KEY_ID"] = "AWS_ACCESS_KEY_ID" + os.environ["AWS_SECRET_ACCESS_KEY"] = "AWS_SECRET_ACCESS_KEY" + os.environ["AWS_DEFAULT_REGION"] = "AWS_DEFAULT_REGION" + os.environ["OS_AUTH_URL"] = "OS_AUTH_URL" + os.environ["OS_PROJECT_ID"] = "OS_PROJECT_ID" + os.environ["OS_INTERFACE"] = "OS_INTERFACE" + os.environ["OS_ID_API_VERSION"] = "OS_ID_API_VERSION" + os.environ["OS_REGION_NAME"] = "OS_REGION_NAME" + os.environ["OS_APP_CRED_ID"] = "OS_APP_CRED_ID" + os.environ["OS_APP_CRED_SECRET"] = "OS_APP_CRED_SECRET" + os.environ["OS_AUTH_TYPE"] = "v3applicationcredential" + os.environ["ALLOW_ALL_WORKSPACE_USERS"] = "ALLOW_ALL_WORKSPACE_USERS" + os.environ["ALLOWED_SLACK_USERS"] = "ALLOWED_SLACK_USERS" + os.environ["OS_IMAGE_MAP"] = '{"root": "dummy-image-id"}' + os.environ["AWS_AMI_MAP"] = '{"linux": "ami-dummy"}' + os.environ["OS_NETWORK_MAP"] = '{"network1": "dummy-network-id"}' + os.environ["OS_DEFAULT_NETWORK"] = "network1" + os.environ["OS_DEFAULT_SSH_USER"] = "root" + os.environ["RH_CA_BUNDLE_TEXT"] = "RH_CA_BUNDLE_TEXT" + os.environ["VAULT_ENABLED_FOR_DYNACONF"] = "VAULT_ENABLED_FOR_DYNACONF" + os.environ["VAULT_URL_FOR_DYNACONF"] = "VAULT_URL_FOR_DYNACONF" + os.environ["VAULT_SECRET_ID_FOR_DYNACONF"] = "VAULT_SECRET_ID_FOR_DYNACONF" + os.environ["VAULT_ROLE_ID_FOR_DYNACONF"] = "VAULT_ROLE_ID_FOR_DYNACONF" + os.environ["VAULT_MOUNT_POINT_FOR_DYNACONF"] = "VAULT_MOUNT_POINT_FOR_DYNACONF" + os.environ["VAULT_PATH_FOR_DYNACONF"] = "VAULT_PATH_FOR_DYNACONF" + os.environ["VAULT_KV_VERSION_FOR_DYNACONF"] = "VAULT_KV_VERSION_FOR_DYNACONF" + + +@pytest.fixture(autouse=True) +def populate_command_registry(): + """Ensure COMMAND_REGISTRY is populated for tests.""" + import slack_handlers.handlers # noqa: F401 + from sdk.tools.help_system import COMMAND_REGISTRY # noqa: F401 diff --git a/sdk/tests/test_aws.py b/sdk/tests/test_aws.py new file mode 100644 index 0000000..5b6e33f --- /dev/null +++ b/sdk/tests/test_aws.py @@ -0,0 +1,414 @@ +import unittest.mock as mock +import botocore.exceptions +from unittest.mock import Mock + +from sdk.aws.ec2 import EC2Helper + + +@mock.patch("boto3.Session") +def test_list_instances_empty(mock_boto3_session): + """Test listing instances when there are no instances.""" + mock_client = mock.MagicMock() + mock_client.describe_instances.return_value = {"Reservations": []} + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="eu-west-2") + result = ec2_helper.list_instances() + assert result == {"count": 0, "instances": []} + + mock_boto3_session.assert_called_once() + mock_boto3_session.return_value.client.assert_called_once_with("ec2") + mock_client.describe_instances.assert_called_once() + + +@mock.patch("boto3.Session") +def test_list_instances(mock_boto3_session): + """Test listing EC2 instances.""" + mock_client = mock.MagicMock() + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + { + "InstanceId": "i-3651695", + "InstanceType": "t2.micro", + "State": {"Name": "running"}, + }, + { + "InstanceId": "i-78435671", + "InstanceType": "t2.small", + "State": {"Name": "stopped"}, + }, + ] + } + ] + } + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="ap-southeast-1") + result = ec2_helper.list_instances() + + assert result["count"] == 2 + + instances = result["instances"] + assert len(instances) == 2 + assert instances[0]["instance_id"] == "i-3651695" + assert instances[0]["instance_type"] == "t2.micro" + assert instances[0]["state"] == "running" + + assert instances[1]["instance_id"] == "i-78435671" + assert instances[1]["instance_type"] == "t2.small" + assert instances[1]["state"] == "stopped" + + mock_boto3_session.assert_called_once() + mock_boto3_session.return_value.client.assert_called_once_with("ec2") + mock_client.describe_instances.assert_called_once() + + +@mock.patch("boto3.client") +@mock.patch("boto3.Session") +def test_create_instance_success(mock_boto3_session, mock_boto3_client): + """Test successful creation of an EC2 instance.""" + mock_resource = mock.MagicMock() + mock_resource.create_instances.return_value = [ + Mock(id="i-3651695", instance_type="t2.micro", state={"Name": "running"}), + ] + mock_boto3_session.return_value.resource.return_value = mock_resource + + mock_client = mock.MagicMock() + mock_client.describe_subnets.return_value = { + "Subnets": [{"SubnetId": "subnet-on-vpc"}] + } + mock_client.describe_vpcs.return_value = {"Vpcs": [{"VpcId": "vpc-for-testing"}]} + mock_client.describe_security_groups.return_value = { + "SecurityGroups": [{"GroupId": "sg-12345"}] + } + + mock_boto3_session.return_value.client.return_value = mock_client + + mock_boto3_client.return_value.get_caller_identity.return_value = { + "Arn": "test-arn/redhat" + } + + region = "ca-central-1" + ec2_helper = EC2Helper(region=region) + image_id = "rhel-10" + instance_type = "t2.nano" + key_name = "test-key-pair" + security_group_id = "sg-12345" + subnet_id = "subnet-on-vpc" + + instance_create = ec2_helper.create_instance( + image_id=image_id, + instance_type=instance_type, + key_name=key_name, + ) + + assert instance_create["count"] == 1 + assert len(instance_create["instances"]) == 1 + assert instance_create["instances"][0]["instance_id"] == "i-3651695" + assert instance_create["instances"][0]["key_name"] == key_name + assert instance_create["instances"][0]["instance_type"] == instance_type + + username = instance_create["instances"][0]["name"] + assert username.startswith("redhat-") + + mock_boto3_session.assert_called_once() + mock_boto3_session.return_value.resource.assert_called_once_with("ec2") + mock_resource.create_instances.assert_called_once_with( + ImageId=image_id, + InstanceType=instance_type, + KeyName=key_name, + SecurityGroupIds=[security_group_id], + SubnetId=subnet_id, + TagSpecifications=[ + { + "ResourceType": "instance", + "Tags": [{"Key": "Name", "Value": f"{username}"}], + } + ], + MinCount=1, + MaxCount=1, + ) + + +@mock.patch("boto3.Session") +def test_create_instance_unable_create(mock_boto3_session): + """Test unable to create an EC2 instance.""" + mock_client = mock.MagicMock() + mock_client.create_instances.return_value = [] + mock_boto3_session.return_value.resource.return_value = mock_client + + mock_client = mock.MagicMock() + mock_client.describe_subnets.return_value = { + "Subnets": [{"SubnetId": "subnet-on-vpc"}] + } + mock_boto3_session.return_value.client.return_value = mock_client + + region = "ca-central-1" + ec2_helper = EC2Helper(region=region) + image_id = "rhel-10" + instance_type = "t2.nano" + key_name = "test-key-pair" + + instance_create = ec2_helper.create_instance( + image_id=image_id, + instance_type=instance_type, + key_name=key_name, + ) + + assert instance_create["count"] == 0 + assert len(instance_create["instances"]) == 0 + + +@mock.patch("boto3.Session") +def test_stop_instance_success(mock_boto3_session): + """Test successful stopping of an EC2 instance.""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + {"InstanceId": "i-1234567890", "State": {"Name": "running"}} + ] + } + ] + } + + mock_client.stop_instances.return_value = { + "StoppingInstances": [ + { + "InstanceId": "i-1234567890", + "CurrentState": {"Name": "stopping"}, + "PreviousState": {"Name": "running"}, + } + ] + } + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.stop_instance("i-1234567890") + + assert result["success"] is True + assert result["instance_id"] == "i-1234567890" + assert result["previous_state"] == "running" + assert result["current_state"] == "stopping" + + mock_client.describe_instances.assert_called_once_with(InstanceIds=["i-1234567890"]) + mock_client.stop_instances.assert_called_once_with(InstanceIds=["i-1234567890"]) + + +@mock.patch("boto3.Session") +def test_stop_instance_already_stopped(mock_boto3_session): + """Test stopping an instance that is already stopped.""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + {"InstanceId": "i-1234567890", "State": {"Name": "stopped"}} + ] + } + ] + } + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.stop_instance("i-1234567890") + + assert result["success"] is False + assert "already stopped" in result["error"] + + mock_client.describe_instances.assert_called_once_with(InstanceIds=["i-1234567890"]) + mock_client.stop_instances.assert_not_called() + + +@mock.patch("boto3.Session") +def test_stop_instance_not_found(mock_boto3_session): + """Test stopping an instance that doesn't exist.""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = {"Reservations": []} + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.stop_instance("i-nonexistent") + + assert result["success"] is False + assert "not found" in result["error"] + + mock_client.describe_instances.assert_called_once_with( + InstanceIds=["i-nonexistent"] + ) + mock_client.stop_instances.assert_not_called() + + +@mock.patch("boto3.Session") +def test_stop_instance_invalid_state(mock_boto3_session): + """Test stopping an instance in an invalid state (e.g., terminating).""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + {"InstanceId": "i-1234567890", "State": {"Name": "terminating"}} + ] + } + ] + } + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.stop_instance("i-1234567890") + + assert result["success"] is False + assert "cannot be stopped" in result["error"] + assert "terminating" in result["error"] + + mock_client.describe_instances.assert_called_once_with(InstanceIds=["i-1234567890"]) + mock_client.stop_instances.assert_not_called() + + +@mock.patch("boto3.Session") +def test_terminate_instance_success(mock_boto3_session): + """Test successful termination of an EC2 instance.""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + { + "InstanceId": "i-1234567890", + "State": {"Name": "running"}, + "Tags": [ + {"Key": "Name", "Value": "test-instance"}, + {"Key": "Owner", "Value": "test-user"}, + ], + } + ] + } + ] + } + + mock_client.terminate_instances.return_value = { + "TerminatingInstances": [ + { + "InstanceId": "i-1234567890", + "CurrentState": {"Name": "shutting-down"}, + "PreviousState": {"Name": "running"}, + } + ] + } + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.terminate_instance("i-1234567890") + + assert result["success"] is True + assert result["instance_id"] == "i-1234567890" + assert result["instance_name"] == "test-instance" + assert result["previous_state"] == "running" + assert result["current_state"] == "shutting-down" + + mock_client.describe_instances.assert_called_once_with(InstanceIds=["i-1234567890"]) + mock_client.terminate_instances.assert_called_once_with( + InstanceIds=["i-1234567890"] + ) + + +@mock.patch("boto3.Session") +def test_terminate_instance_already_terminated(mock_boto3_session): + """Test terminating an instance that is already terminated.""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + {"InstanceId": "i-1234567890", "State": {"Name": "terminated"}} + ] + } + ] + } + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.terminate_instance("i-1234567890") + + assert result["success"] is False + assert "already terminated" in result["error"] + + mock_client.describe_instances.assert_called_once_with(InstanceIds=["i-1234567890"]) + mock_client.terminate_instances.assert_not_called() + + +@mock.patch("boto3.Session") +def test_terminate_instance_terminating(mock_boto3_session): + """Test terminating an instance that is already being terminated.""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + {"InstanceId": "i-1234567890", "State": {"Name": "terminating"}} + ] + } + ] + } + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.terminate_instance("i-1234567890") + + assert result["success"] is False + assert "already being terminated" in result["error"] + + mock_client.describe_instances.assert_called_once_with(InstanceIds=["i-1234567890"]) + mock_client.terminate_instances.assert_not_called() + + +@mock.patch("boto3.Session") +def test_stop_instance_api_error(mock_boto3_session): + """Test handling of AWS API errors when stopping an instance.""" + mock_client = mock.MagicMock() + + mock_client.describe_instances.return_value = { + "Reservations": [ + { + "Instances": [ + {"InstanceId": "i-1234567890", "State": {"Name": "running"}} + ] + } + ] + } + + error_response = { + "Error": { + "Code": "UnauthorizedOperation", + "Message": "You are not authorized to perform this operation.", + } + } + mock_client.stop_instances.side_effect = botocore.exceptions.ClientError( + error_response, "StopInstances" + ) + + mock_boto3_session.return_value.client.return_value = mock_client + + ec2_helper = EC2Helper(region="us-east-1") + result = ec2_helper.stop_instance("i-1234567890") + + assert result["success"] is False + assert "AWS API error" in result["error"] + assert "not authorized" in result["error"] diff --git a/sdk/tests/test_openstack.py b/sdk/tests/test_openstack.py new file mode 100644 index 0000000..e0a3012 --- /dev/null +++ b/sdk/tests/test_openstack.py @@ -0,0 +1,737 @@ +import unittest.mock as mock +from unittest.mock import Mock, PropertyMock + +import pytest +from openstack.exceptions import ResourceFailure + +from sdk.openstack.core import OpenStackHelper + + +@mock.patch("openstack.connection.Connection") +def test_list_vms_empty(mock_openstack): + """Test listing VMs when there are no VMs.""" + mock_compute = mock.MagicMock() + mock_compute.servers.return_value = [] + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.list_servers() + + assert result["count"] == 0 + + instances = result["instances"] + assert len(instances) == 0 + + mock_openstack.assert_called_once() + mock_compute.servers.assert_called_once() + + +@mock.patch("openstack.connection.Connection") +def test_list_vms(mock_openstack): + """Test listing VMs.""" + mock_compute = mock.MagicMock() + server = Mock( + id="42", + flavor={"original_name": "rhel-10"}, + availability_zone="us", + addresses={ + "private": [ + {"OS-EXT-IPS:type": "fixed", "addr": "10.123.50.11", "version": "ipv4"} + ] + }, + key_name="my_key", + status="ACTIVE", + ) + type(server).name = PropertyMock(return_value="server") + + test_server = Mock( + name="test_server", + id="35", + flavor={"original_name": "rhel-11"}, + availability_zone="us", + addresses={ + "test_network": [ + {"OS-EXT-IPS:type": "fixed", "addr": "10.70.33.2", "version": "ipv4"} + ] + }, + key_name="test_key", + status="ACTIVE", + ) + type(test_server).name = PropertyMock(return_value="test_server") + mock_compute.servers.return_value = [ + server, + test_server, + ] + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.list_servers() + + assert result["count"] == 2 + + instances = result["instances"] + assert len(instances) == 2 + + assert instances[0].get("name") == "server" + assert instances[0].get("flavor") == "rhel-10" + assert instances[0].get("server_id") == "42" + assert instances[0].get("network") == "private" + assert instances[0].get("status") == "ACTIVE" + + assert instances[1].get("name") == "test_server" + assert instances[1].get("flavor") == "rhel-11" + assert instances[1].get("server_id") == "35" + assert instances[1].get("network") == "test_network" + assert instances[1].get("status") == "ACTIVE" + + mock_openstack.assert_called_once() + mock_compute.servers.assert_called_once() + + +@mock.patch("openstack.connection.Connection") +def test_list_vms_with_status_filter(mock_openstack): + """Test listing VMs with status filter.""" + mock_compute = mock.MagicMock() + server = Mock( + id="42", + flavor={"original_name": "rhel-10"}, + addresses={ + "private": [ + {"OS-EXT-IPS:type": "fixed", "addr": "10.123.50.11", "version": "ipv4"} + ] + }, + key_name="my_key", + status="SHUTOFF", + ) + type(server).name = PropertyMock(return_value="server") + + mock_compute.servers.return_value = [server] + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.list_servers({"status": "SHUTOFF"}) + + assert result["count"] == 1 + instances = result["instances"] + assert len(instances) == 1 + assert instances[0].get("status") == "SHUTOFF" + + mock_openstack.assert_called_once() + mock_compute.servers.assert_called_once_with(status="SHUTOFF") + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_success(mock_openstack): + """Test successful creation of a VM.""" + mock_compute = mock.MagicMock() + mock_flavor = Mock(id="flavor-id-123") + mock_flavor.name = "rhel-10" + mock_image = Mock(id="image-id-456") + + mock_server = Mock( + id="server-id-789", + status="ACTIVE", + addresses={ + "private": [ + {"OS-EXT-IPS:type": "fixed", "addr": "10.123.50.11", "version": "ipv4"} + ] + }, + ) + mock_server.name = "test-server" + + mock_keypair = Mock() + mock_keypair.name = "test-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_compute.find_flavor.return_value = mock_flavor + mock_compute.find_image.return_value = mock_image + + mock_compute.create_server.return_value = mock_server + mock_compute.wait_for_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.create_servers( + name="test-server", + image_id="image-id-456", + flavor="rhel-10", + key_name="test-key", + network="network-id-123", + ) + + assert result["count"] == 1 + instances = result["instances"] + assert len(instances) == 1 + + instance = instances[0] + assert instance["name"] == "test-server" + assert instance["server_id"] == "server-id-789" + assert instance["status"] == "ACTIVE" + assert instance["flavor"] == "rhel-10" + assert instance["key_name"] == "test-key" + assert instance["private_ip"] == "10.123.50.11" + + mock_compute.create_server.assert_called_once_with( + name="test-server", + image_id="image-id-456", + flavor_id="flavor-id-123", + networks=[{"uuid": "network-id-123"}], + key_name="test-key", + ) + mock_compute.wait_for_server.assert_called_once() + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_invalid_key_name(mock_openstack): + """Test creation of VM with invalid key name.""" + mock_compute = mock.MagicMock() + mock_keypair = Mock() + mock_keypair.name = "valid-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + + with pytest.raises(ValueError) as exc_info: + openstack_helper.create_servers( + name="test-server", + image_id="image-id-456", + flavor="rhel-10", + key_name="invalid-key", + network="network-id-123", + ) + + assert "Invalid key_name 'invalid-key' provided" in str(exc_info.value) + assert "Available keypairs: ['valid-key']" in str(exc_info.value) + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_invalid_flavor(mock_openstack): + """Test creation of VM with invalid flavor.""" + mock_compute = mock.MagicMock() + mock_keypair = Mock() + mock_keypair.name = "test-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_compute.find_flavor.return_value = None + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + + with pytest.raises(ValueError) as exc_info: + openstack_helper.create_servers( + name="test-server", + image_id="image-id-456", + flavor="invalid-flavor", + key_name="test-key", + network="network-id-123", + ) + + assert "Flavor 'None' not found in OpenStack" in str(exc_info.value) + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_invalid_image(mock_openstack): + """Test creation of VM with invalid image.""" + mock_compute = mock.MagicMock() + mock_keypair = Mock() + mock_keypair.name = "test-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_flavor = Mock(id="flavor-id-123", name="rhel-10") + mock_compute.find_flavor.return_value = mock_flavor + + mock_compute.find_image.return_value = None + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + + with pytest.raises(ValueError) as exc_info: + openstack_helper.create_servers( + name="test-server", + image_id="invalid-image", + flavor="rhel-10", + key_name="test-key", + network="network-id-123", + ) + + assert "Image 'invalid-image' not found in OpenStack" in str(exc_info.value) + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_resource_failure(mock_openstack): + """Test VM creation with ResourceFailure exception.""" + mock_compute = mock.MagicMock() + mock_keypair = Mock() + mock_keypair.name = "test-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_flavor = Mock(id="flavor-id-123", name="rhel-10") + mock_image = Mock(id="image-id-456") + mock_compute.find_flavor.return_value = mock_flavor + mock_compute.find_image.return_value = mock_image + + mock_compute.create_server.side_effect = ResourceFailure("VM creation failed") + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + + with pytest.raises(RuntimeError) as exc_info: + openstack_helper.create_servers( + name="test-server", + image_id="image-id-456", + flavor="rhel-10", + key_name="test-key", + network="network-id-123", + ) + + assert "OpenStack VM provisioning failed" in str(exc_info.value) + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_general_exception(mock_openstack): + """Test VM creation with general exception.""" + mock_compute = mock.MagicMock() + mock_keypair = Mock() + mock_keypair.name = "test-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_flavor = Mock(id="flavor-id-123", name="rhel-10") + mock_image = Mock(id="image-id-456") + mock_compute.find_flavor.return_value = mock_flavor + mock_compute.find_image.return_value = mock_image + + mock_compute.create_server.side_effect = Exception("General error") + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + + with pytest.raises(Exception) as exc_info: + openstack_helper.create_servers( + name="test-server", + image_id="image-id-456", + flavor="rhel-10", + key_name="test-key", + network="network-id-123", + ) + + assert "General error" in str(exc_info.value) + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_no_network(mock_openstack): + """Test VM creation without network parameter.""" + mock_compute = mock.MagicMock() + mock_keypair = Mock() + mock_keypair.name = "test-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_flavor = Mock(id="flavor-id-123", name="rhel-10") + mock_image = Mock(id="image-id-456") + + mock_server = Mock( + id="server-id-789", + name="test-server", + status="ACTIVE", + addresses={ + "default": [ + {"OS-EXT-IPS:type": "fixed", "addr": "10.123.50.11", "version": "ipv4"} + ] + }, + ) + + mock_compute.find_flavor.return_value = mock_flavor + mock_compute.find_image.return_value = mock_image + mock_compute.create_server.return_value = mock_server + mock_compute.wait_for_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.create_servers( + name="test-server", + image_id="image-id-456", + flavor="rhel-10", + key_name="test-key", + network=None, + ) + + assert result["count"] == 1 + instances = result["instances"] + assert len(instances) == 1 + + instance = instances[0] + assert instance["network"] == "Default Network" + + mock_compute.create_server.assert_called_once_with( + name="test-server", + image_id="image-id-456", + flavor_id="flavor-id-123", + networks=[], + key_name="test-key", + ) + + +@mock.patch("openstack.connection.Connection") +def test_create_servers_floating_ip_priority(mock_openstack): + """Test that floating IP is prioritized over fixed IP.""" + mock_compute = mock.MagicMock() + mock_keypair = Mock() + mock_keypair.name = "test-key" + mock_compute.keypairs.return_value = [mock_keypair] + + mock_flavor = Mock(id="flavor-id-123", name="rhel-10") + mock_image = Mock(id="image-id-456") + + mock_server = Mock( + id="server-id-789", + name="test-server", + status="ACTIVE", + addresses={ + "private": [ + {"OS-EXT-IPS:type": "fixed", "addr": "10.123.50.11", "version": "ipv4"}, + { + "OS-EXT-IPS:type": "floating", + "addr": "192.168.1.100", + "version": "ipv4", + }, + ] + }, + ) + + mock_compute.find_flavor.return_value = mock_flavor + mock_compute.find_image.return_value = mock_image + mock_compute.create_server.return_value = mock_server + mock_compute.wait_for_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.create_servers( + name="test-server", + image_id="image-id-456", + flavor="rhel-10", + key_name="test-key", + network="network-id-123", + ) + + assert result["count"] == 1 + instances = result["instances"] + assert len(instances) == 1 + + instance = instances[0] + assert instance["private_ip"] == "10.123.50.11" + + +# Tests for OpenStack VM lifecycle management +@mock.patch("openstack.connection.Connection") +def test_stop_server_success(mock_openstack): + """Test successful server stop operation.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.name = "test-server" + mock_server.status = "ACTIVE" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + mock_compute.stop_server.return_value = None + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.stop_server("test-server-id") + + assert result["success"] is True + assert result["server_id"] == "test-server-id" + assert result["server_name"] == "test-server" + assert result["previous_status"] == "ACTIVE" + assert result["current_status"] == "stopping" + + mock_compute.find_server.assert_called_once_with( + "test-server-id", ignore_missing=False + ) + mock_compute.stop_server.assert_called_once_with(mock_server) + + +@mock.patch("openstack.connection.Connection") +def test_stop_server_already_stopped(mock_openstack): + """Test stopping a server that is already stopped.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.status = "SHUTOFF" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.stop_server("test-server-id") + + assert result["success"] is False + assert "already stopped" in result["error"] + + mock_compute.stop_server.assert_not_called() + + +@mock.patch("openstack.connection.Connection") +def test_stop_server_invalid_status(mock_openstack): + """Test stopping a server with invalid status.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.status = "ERROR" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.stop_server("test-server-id") + + assert result["success"] is False + assert "cannot be stopped" in result["error"] + + mock_compute.stop_server.assert_not_called() + + +@mock.patch("openstack.connection.Connection") +def test_start_server_success(mock_openstack): + """Test successful server start operation.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.name = "test-server" + mock_server.status = "SHUTOFF" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + mock_compute.start_server.return_value = None + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.start_server("test-server-id") + + assert result["success"] is True + assert result["server_id"] == "test-server-id" + assert result["server_name"] == "test-server" + assert result["previous_status"] == "SHUTOFF" + assert result["current_status"] == "starting" + + mock_compute.find_server.assert_called_once_with( + "test-server-id", ignore_missing=False + ) + mock_compute.start_server.assert_called_once_with(mock_server) + + +@mock.patch("openstack.connection.Connection") +def test_start_server_already_running(mock_openstack): + """Test starting a server that is already running.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.status = "ACTIVE" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.start_server("test-server-id") + + assert result["success"] is False + assert "already running" in result["error"] + + mock_compute.start_server.assert_not_called() + + +@mock.patch("openstack.connection.Connection") +def test_start_server_from_suspended(mock_openstack): + """Test starting a server from suspended state.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.name = "test-server" + mock_server.status = "SUSPENDED" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + mock_compute.start_server.return_value = None + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.start_server("test-server-id") + + assert result["success"] is True + assert result["previous_status"] == "SUSPENDED" + + mock_compute.start_server.assert_called_once_with(mock_server) + + +@mock.patch("openstack.connection.Connection") +def test_delete_server_success(mock_openstack): + """Test successful server deletion.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.name = "test-server" + mock_server.status = "ACTIVE" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + mock_compute.delete_server.return_value = None + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.delete_server("test-server-id") + + assert result["success"] is True + assert result["server_id"] == "test-server-id" + assert result["server_name"] == "test-server" + assert result["previous_status"] == "ACTIVE" + assert result["current_status"] == "deleting" + + mock_compute.find_server.assert_called_once_with( + "test-server-id", ignore_missing=False + ) + mock_compute.delete_server.assert_called_once_with(mock_server) + + +@mock.patch("openstack.connection.Connection") +def test_delete_server_already_deleted(mock_openstack): + """Test deleting a server that is already deleted.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.status = "DELETED" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.delete_server("test-server-id") + + assert result["success"] is False + assert "already in status" in result["error"] + + mock_compute.delete_server.assert_not_called() + + +@mock.patch("openstack.connection.Connection") +def test_delete_server_error_status(mock_openstack): + """Test deleting a server with ERROR status.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.status = "ERROR" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.delete_server("test-server-id") + + assert result["success"] is False + assert "already in status 'ERROR'" in result["error"] + + mock_compute.delete_server.assert_not_called() + + +@mock.patch("openstack.connection.Connection") +def test_lifecycle_server_not_found(mock_openstack): + """Test lifecycle operations on non-existent server.""" + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = None + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + + # Test stop + result = openstack_helper.stop_server("non-existent-id") + assert result["success"] is False + assert "not found" in result["error"] + + # Test start + result = openstack_helper.start_server("non-existent-id") + assert result["success"] is False + assert "not found" in result["error"] + + # Test delete + result = openstack_helper.delete_server("non-existent-id") + assert result["success"] is False + assert "not found" in result["error"] + + +@mock.patch("openstack.connection.Connection") +def test_lifecycle_operations_with_exceptions(mock_openstack): + """Test lifecycle operations when OpenStack API throws exceptions.""" + mock_compute = mock.MagicMock() + mock_compute.find_server.side_effect = Exception("API Error") + + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + + # Test stop with exception + result = openstack_helper.stop_server("test-server-id") + assert result["success"] is False + assert "Failed to stop server" in result["error"] + + # Test start with exception + result = openstack_helper.start_server("test-server-id") + assert result["success"] is False + assert "Failed to start server" in result["error"] + + # Test delete with exception + result = openstack_helper.delete_server("test-server-id") + assert result["success"] is False + assert "Failed to delete server" in result["error"] + + +@mock.patch("openstack.connection.Connection") +def test_lifecycle_operations_with_api_call_exceptions(mock_openstack): + """Test lifecycle operations when individual API calls throw exceptions.""" + mock_server = mock.MagicMock() + mock_server.id = "test-server-id" + mock_server.name = "test-server" + mock_server.status = "ACTIVE" + + mock_compute = mock.MagicMock() + mock_compute.find_server.return_value = mock_server + + # Test stop server API call exception + mock_compute.stop_server.side_effect = Exception("Stop API Error") + mock_openstack.return_value.compute = mock_compute + + openstack_helper = OpenStackHelper() + result = openstack_helper.stop_server("test-server-id") + + assert result["success"] is False + assert "Failed to stop server" in result["error"] + + # Test start server API call exception + mock_server.status = "SHUTOFF" + mock_compute.start_server.side_effect = Exception("Start API Error") + + result = openstack_helper.start_server("test-server-id") + + assert result["success"] is False + assert "Failed to start server" in result["error"] + + # Test delete server API call exception + mock_server.status = "ACTIVE" + mock_compute.delete_server.side_effect = Exception("Delete API Error") + + result = openstack_helper.delete_server("test-server-id") + + assert result["success"] is False + assert "Failed to delete server" in result["error"] diff --git a/sdk/tests/test_runner.py b/sdk/tests/test_runner.py new file mode 100644 index 0000000..11d75a0 --- /dev/null +++ b/sdk/tests/test_runner.py @@ -0,0 +1,44 @@ +from pytest import Pytester +from unittest.mock import Mock +import sys + +# Mock config module so that no connections are made at import time leading to exceptions +sys.modules["config"] = Mock() + + +class TestRunner(object): + def test_aws(self, pytester: Pytester) -> None: + pytester.copy_example("tests/test_aws.py") + result = pytester.runpytest() + outcomes = result.parseoutcomes() + assert "failed" not in outcomes.keys(), ( + f"{outcomes['failed']} unit tests failed." + ) + assert "errors" not in outcomes.keys(), ( + f"{outcomes['errors']} unit tests have errors." + ) + assert "passed" in outcomes.keys(), "No tests passed." + + def test_openstack(self, pytester: Pytester) -> None: + pytester.copy_example("tests/test_openstack.py") + result = pytester.runpytest() + outcomes = result.parseoutcomes() + assert "failed" not in outcomes.keys(), ( + f"{outcomes['failed']} unit tests failed." + ) + assert "errors" not in outcomes.keys(), ( + f"{outcomes['errors']} unit tests have errors." + ) + assert "passed" in outcomes.keys(), "No tests passed." + + def test_tools(self, pytester: Pytester) -> None: + pytester.copy_example("tests/test_tools.py") + result = pytester.runpytest() + outcomes = result.parseoutcomes() + assert "failed" not in outcomes.keys(), ( + f"{outcomes['failed']} unit tests failed." + ) + assert "errors" not in outcomes.keys(), ( + f"{outcomes['errors']} unit tests have errors." + ) + assert "passed" in outcomes.keys(), "No tests passed." diff --git a/sdk/tests/test_tools.py b/sdk/tests/test_tools.py new file mode 100644 index 0000000..c8b3d7b --- /dev/null +++ b/sdk/tests/test_tools.py @@ -0,0 +1,196 @@ +from sdk.tools.helpers import ( + get_named_and_positional_params, + get_list_of_values_for_key_in_dict_of_parameters, +) + + +def test_get_named_and_positional_params_when_no_params(): + named_params, positional_params = get_named_and_positional_params("aws vm list") + assert len(named_params) == 0 + assert len(positional_params) == 0 + + +def test_get_named_and_positional_params_when_no_key_value_params(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list something else" + ) + assert len(named_params) == 0 + assert "something" == positional_params[0] + assert "else" == positional_params[1] + + +def test_get_named_and_positional_params_when_mixture_param_types(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list paramA paramB --type=t3.micro,t2.micro --state=pending,stopped spaces spaces2" + ) + assert "t3.micro,t2.micro" == named_params.get("type") + assert "pending,stopped spaces spaces2" == named_params.get("state") + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_named_and_positional_params_when_using_positional_params(): + named_params, positional_params = get_named_and_positional_params( + "aws vm create linux 1.2.3" + ) + assert len(named_params) == 0 + assert "linux" == positional_params[0] + assert "1.2.3" == positional_params[1] + + +def test_get_named_and_positional_params_when_single_value_params_with_equals_separator(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list --type=t3.micro --state=pending" + ) + assert "t3.micro" == named_params.get("type") + assert "pending" == named_params.get("state") + assert not positional_params + + +def test_get_named_and_positional_params_when_single_value_params_with_no_equals_separator(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list --type t3.micro --state pending" + ) + assert "t3.micro" == named_params.get("type") + assert "pending" == named_params.get("state") + assert len(positional_params) == 0 + + +def test_get_named_and_positional_params_when_no_equals_separator(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list --type t3.micro, t2.micro, t1.micro --state pending" + ) + assert named_params.get("type") == "t3.micro,t2.micro,t1.micro" + assert named_params.get("state") == "pending" + assert not positional_params + + +def test_get_named_and_positional_params_when_good_params_with_no_equals_separator(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list paramA paramB --type t3.micro,t2.micro --state pending,stopped" + ) + assert "t3.micro,t2.micro" == named_params.get("type") + assert "pending,stopped" == named_params.get("state") + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_named_and_positional_params_when_mixture_good_params(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list paramA paramB --type t3.micro,t2.micro --state=pending,stopped" + ) + assert "t3.micro,t2.micro" == named_params.get("type") + assert "pending,stopped" == named_params.get("state") + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_named_and_positional_params_when_value_has_spaces(): + named_params, positional_params = get_named_and_positional_params( + "aws vm list paramA paramB --desc some value with space --state pending" + ) + assert "some value with space" == named_params.get("desc") + assert "pending" == named_params.get("state") + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_dict_when_command_has_extra_spaces(): + named_params, positional_params = get_named_and_positional_params( + "aws vm create paramA paramB --os_name=linux --instance_type=t2.micro --key_pair=my-key" + ) + assert "linux" == named_params.get("os_name") + assert "t2.micro" == named_params.get("instance_type") + assert "my-key" == named_params.get("key_pair") + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_dict_when_spaces_between_params(): + named_params, positional_params = get_named_and_positional_params( + "aws vm create paramA paramB --state=pending, stopped , running" + ) + assert "pending,stopped,running" == named_params.get("state") + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_dict_when_trailing_commas_between_params(): + named_params, positional_params = get_named_and_positional_params( + "aws vm create --state=pending, stopped, " + ) + assert "pending,stopped" == named_params.get("state") + named_params, positional_params = get_named_and_positional_params( + "aws vm create paramA paramB --state=pending,,, stopped,,,, " + ) + assert "pending,stopped" == named_params.get("state") + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_dict_when_multi_words_in_command_line(): + named_params, positional_params = get_named_and_positional_params( + "aws vm create paramA paramB --vm-id=i-123456 --multi=these are values --state=pending, stopped" + ) + assert named_params.get("state") == "pending,stopped" + assert named_params.get("vm-id") == "i-123456" + assert named_params.get("multi") == "these are values" + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_dict_with_flag_parameters(): + named_params, positional_params = get_named_and_positional_params( + "aws vm modify paramA paramB --stop --vm-id=i-123456" + ) + assert named_params.get("stop") is True + assert named_params.get("vm-id") == "i-123456" + assert "paramA" == positional_params[0] + assert "paramB" == positional_params[1] + + +def test_get_dict_with_multiple_flag_parameters(): + named_params, positional_params = get_named_and_positional_params( + "aws vm modify paramA --flag1 --flag2 --value=test" + ) + assert named_params.get("flag1") is True + assert named_params.get("flag2") is True + assert named_params.get("value") == "test" + assert "paramA" == positional_params[0] + + +def test_get_dict_with_only_flag_parameters(): + named_params, positional_params = get_named_and_positional_params( + "aws vm modify --stop --delete" + ) + assert named_params.get("stop") is True + assert named_params.get("delete") is True + assert len(named_params) == 2 + assert len(positional_params) == 0 + + +def test_get_list_of_values_for_key_in_dict_of_parameters_when_absent_key(): + named_params, positional_params = get_named_and_positional_params( + "aws vm modify --stop --delete" + ) + result = named_params.get("absent_key") + assert result is None + assert len(positional_params) == 0 + + +def test_get_list_of_values_for_key_in_dict_of_parameters_when_present_in_dict_params(): + param_dict = {"state": "pending,stopped", "type": "t2.micro,t3.micro"} + result = get_list_of_values_for_key_in_dict_of_parameters("type", param_dict) + assert result == ["t2.micro", "t3.micro"] + + +def test_get_list_of_values_for_key_in_dict_of_parameters_when_absent_in_dict_params(): + param_dict = {"state": "pending,stopped", "type": "t2.micro,t3.micro"} + result = get_list_of_values_for_key_in_dict_of_parameters("missing_key", param_dict) + assert len(result) == 0 + + +def test_get_list_of_values_for_key_in_dict_of_parameters_when_spaces_in_dict_params(): + param_dict = {"state": "pending, stopped, running", "type": "t2.micro,t3.micro"} + result = get_list_of_values_for_key_in_dict_of_parameters("state", param_dict) + assert result == ["pending", "stopped", "running"] diff --git a/sdk/tools/__init__.py b/sdk/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/tools/help_system.py b/sdk/tools/help_system.py new file mode 100644 index 0000000..912e61b --- /dev/null +++ b/sdk/tools/help_system.py @@ -0,0 +1,477 @@ +""" +Help system for the OCP Sustaining Bot. + +This module provides a decorator-based help metadata system that allows +command handlers to store their help information alongside their implementation. +""" + +import logging +import re +from functools import wraps +from typing import Dict, List, Optional, Callable, Any + +from config import config + +logger = logging.getLogger(__name__) + +# Global command registry: command name -> function +COMMAND_REGISTRY: Dict[str, Callable] = {} + +# Cached help text for performance +_CACHED_HELP_TEXT: Optional[str] = None + + +def command_meta( + name: str, + description: str, + arguments: Optional[Dict[str, Dict[str, Any]]] = None, + examples: Optional[List[str]] = None, + aliases: Optional[List[str]] = None, + extra_help: Optional[str] = None, +): + """ + Decorator to attach help metadata to command functions. + + Args: + name: The command name (e.g., 'openstack vm create') + description: Brief description of what the command does + arguments: Dictionary with argument names as keys and argument info as values + examples: List of example usage strings + aliases: List of alternative command names + extra_help: Optional Markdown-ish text appended for `help ` (detailed only) + + Returns: + Decorated function with help metadata attached + """ + + def attach_metadata(func): + # Store metadata on the function as attributes + func._command_description = description + func._command_arguments = arguments or {} + func._command_examples = examples or [] + func._command_aliases = aliases or [] + func._command_extra_help = extra_help or "" + + # Register the command + if name != "help": + COMMAND_REGISTRY[name] = func + + # Register aliases + if aliases: + for alias in aliases: + COMMAND_REGISTRY[alias] = func + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + return attach_metadata + + +def get_dynamic_value(value_or_callable): + """ + Get a value that might be static or callable. + + Args: + value_or_callable: Either a static value or a callable that returns a value + + Returns: + The resolved value + """ + if callable(value_or_callable): + try: + return value_or_callable() + except Exception as e: + logger.error(f"Error getting dynamic value: {e}") + return "" + return value_or_callable + + +def get_openstack_os_names(): + """Get available OpenStack OS names from config.""" + try: + return list(config.OS_IMAGE_MAP.keys()) + except Exception as e: + logger.error(f"Error getting OpenStack OS names: {e}") + return [""] + + +def get_aws_os_ami_names(): + """Get available AWS OS names from config.""" + try: + aws_ami_map = getattr(config, "AWS_AMI_MAP", {"linux": "ami-0402e56c0a7afb78f"}) + return list(aws_ami_map.keys()) + except Exception as e: + logger.error(f"Error getting AWS OS names: {e}") + return [""] + + +def get_openstack_statuses(): + """Get valid OpenStack VM statuses.""" + return ["ACTIVE", "SHUTOFF", "ERROR"] + + +def get_openstack_flavors(): + """Get common OpenStack flavors.""" + return [ + # Standard m1.* flavors + "m1.tiny", + "m1.small", + "m1.medium", + "m1.large", + "m1.xlarge", + # CI flavors + "ci.cpu.small", + "ci.cpu.medium", + "ci.cpu.large", + # General purpose flavors + "t2.micro", + "t2.small", + "t2.medium", + "t3.micro", + "t3.small", + "t3.medium", + # Compute optimized flavors + "c5.large", + "c5.xlarge", + "c5.2xlarge", + # Memory optimized flavors + "r5.large", + "r5.xlarge", + # Storage optimized flavors + "i3.large", + "i3.xlarge", + ] + + +def get_gcp_os_names(): + """Get available GCP OS/image names from config.""" + try: + gcp_image_map = getattr( + config, + "GCP_IMAGE_MAP", + { + "debian-12": "projects/debian-cloud/global/images/family/debian-12", + "linux": "projects/debian-cloud/global/images/family/debian-12", + }, + ) + return list(gcp_image_map.keys()) + except Exception as e: + logger.error(f"Error getting GCP OS names: {e}") + return [""] + + +def get_gcp_boot_disk_size_choices_gb(): + """Values allowed for optional ``--disk-size-gb`` (see ``_GCP_DEFAULT_DISK_SIZES`` in config).""" + from config import _GCP_DEFAULT_DISK_SIZES + + return [str(n) for n in _GCP_DEFAULT_DISK_SIZES] + + +def get_gcp_instance_states(): + """Get valid GCP Compute Engine instance status values (lowercase, for filtering).""" + return [ + "provisioning", # Allocating resources + "staging", # Preparing for first boot + "running", # Booting or actively running + "stopping", # Shutting down + "suspending", # Being suspended + "suspended", # Suspended + "repairing", # Under repair + "terminated", # Stopped or deleted + ] + + +def get_gcp_instance_types(): + """ + GCP machine types for help text and command choices. + + Values come from ``config.GCP_POPULAR_INSTANCE_TYPES`` (see ``.env`` key + ``GCP_POPULAR_INSTANCE_TYPES``); set in ``config.py`` with fallback to + ``gcp_popular_instance_types``. + """ + return list(config.GCP_POPULAR_INSTANCE_TYPES) + + +def get_aws_instance_states(): + """Get valid AWS instance states.""" + return ["pending", "running", "shutting-down", "terminated", "stopping", "stopped"] + + +def get_aws_instance_types(): + """Get common AWS instance types.""" + # TODO: Confirm with team before expanding to include m5 instances + return [ + "t2.micro", + "t2.small", + "t2.medium", + "t3.micro", + "t3.small", + "t3.medium", + ] + + +def format_command_help(command_name: str, detailed: bool = False) -> str: + """ + Format help text for a specific command. + + Args: + command_name: Name of the command + detailed: If True, include detailed argument descriptions + + Returns: + Formatted help text + """ + if command_name not in COMMAND_REGISTRY: + return f"Command '{command_name}' not found." + + func = COMMAND_REGISTRY[command_name] + + # Basic format for command list + if not detailed: + description = getattr(func, "_command_description", "No description available") + return f"`{command_name}` - {description}" + + # Detailed format for specific command help + description = getattr(func, "_command_description", "No description available") + arguments = getattr(func, "_command_arguments", {}) + examples = getattr(func, "_command_examples", []) + aliases = getattr(func, "_command_aliases", []) + + help_text = [f"*{command_name}*", f"_{description}_", ""] + + # Add usage information + if arguments: + usage_parts = [command_name] + for arg_name, arg_info in arguments.items(): + if arg_info.get("required", False): + usage_parts.append(f"--{arg_name}=<{arg_name}>") + else: + usage_parts.append(f"[--{arg_name}=<{arg_name}>]") + + help_text.append(f"*Usage:* `{' '.join(usage_parts)}`") + help_text.append("") + + # Add arguments section + if arguments: + help_text.append("*Arguments:*") + for arg_name, arg_info in arguments.items(): + arg_line = f" `--{arg_name}`" + if arg_info.get("required", False): + arg_line += " *(required)*" + arg_line += f" - {arg_info.get('description', 'No description')}" + + # Add choices if available + if "choices" in arg_info: + choices = get_dynamic_value(arg_info["choices"]) + if choices and isinstance(choices, (list, tuple)): + if len(choices) > 10: + arg_line += f" (Options: {', '.join(str(c) for c in choices[:10])}, ...)" + else: + arg_line += f" (Options: {', '.join(str(c) for c in choices)})" + + # Add default value + if "default" in arg_info: + default_val = get_dynamic_value(arg_info["default"]) + arg_line += f" (Default: {default_val})" + + help_text.append(arg_line) + help_text.append("") + + # Add examples + if examples: + help_text.append("*Examples:*") + for example in examples: + help_text.append(f" `{example}`") + help_text.append("") + + # Add aliases + if aliases: + help_text.append(f"*Aliases:* {', '.join(aliases)}") + help_text.append("") + + extra_help_text = getattr(func, "_command_extra_help", "") or "" + if detailed and extra_help_text.strip(): + help_text.append(extra_help_text.strip()) + help_text.append("") + + return "\n".join(help_text).strip() + + +def _build_general_help_text() -> str: + """ + Build the general help text with all commands. + This is cached for performance since commands don't change after startup. + """ + help_lines = ["*Available Commands:*"] + + # Get unique commands (excluding aliases) + unique_commands = {} + for cmd_name, func in COMMAND_REGISTRY.items(): + if cmd_name not in unique_commands: + unique_commands[cmd_name] = func + + # Sort commands alphabetically + sorted_commands = sorted(unique_commands.items()) + + for cmd_name, func in sorted_commands: + # Format command with arguments for display + arguments = getattr(func, "_command_arguments", {}) + description = getattr(func, "_command_description", "No description available") + + # Build command usage string + usage_parts = [cmd_name] + for arg_name, arg_info in arguments.items(): + if arg_info.get("required", False): + usage_parts.append(f"<{arg_name}>") + else: + usage_parts.append(f"[{arg_name}]") + + command_usage = " ".join(usage_parts) + help_lines.append(f"`{command_usage}` - {description}") + + help_lines.extend( + [ + "", + "For detailed help on any command, use: `help ` or ` --help`", + "", + "Example: `help openstack vm create` or `openstack vm create --help`", + ] + ) + + return "\n".join(help_lines) + + +def get_cached_general_help() -> str: + """ + Get cached general help text, building it if not already cached. + """ + global _CACHED_HELP_TEXT + if _CACHED_HELP_TEXT is None: + _CACHED_HELP_TEXT = _build_general_help_text() + return _CACHED_HELP_TEXT + + +def remove_help_from_command(command_name) -> str: + """ + Remove help from command. + """ + if command_name and check_help_flag(command_name): + return command_name.replace("help ", "").replace(" help", "").replace(" h", "") + + return command_name + + +def handle_help_command( + say, user: Optional[str] = None, command_name: Optional[str] = None +): + """ + Handle help command requests. + + Args: + say: Slack say function + user: User requesting help + command_name: Optional specific command to get help for + """ + try: + if command_name: + command_name = remove_help_from_command(command_name) + + # Show help for specific command + if command_name in COMMAND_REGISTRY: + help_text = format_command_help(command_name, detailed=True) + greeting = f"Hello <@{user}>! " if user else "Hello! " + say(f"{greeting}Here's help for `{command_name}`:\n\n{help_text}") + else: + # Suggest similar commands + available_commands = list(COMMAND_REGISTRY.keys()) + suggestions = [ + cmd + for cmd in available_commands + if command_name.lower() in cmd.lower() + ] + + greeting = f"Hello <@{user}>! " if user else "Hello! " + if suggestions: + say( + f"{greeting}Command `{command_name}` not found. Did you mean: {', '.join(suggestions[:5])}?" + ) + else: + say( + f"{greeting}Command `{command_name}` not found. Use `help` to see all available commands." + ) + else: + # Show all commands using cached help text + greeting = f"Hello <@{user}>! " if user else "Hello! " + cached_help = get_cached_general_help() + say(f"{greeting}Here's what I can help you with:\n\n{cached_help}") + + except Exception as e: + logger.error(f"Error in handle_help_command: {e}") + greeting = f"Sorry <@{user}>, " if user else "Sorry, " + say(f"{greeting}I encountered an error while generating help information.") + + +def register_command(name: str, handler: Callable, meta: Dict[str, Any]): + """ + Manually register a command (for commands that can't use the decorator). + + Args: + name: Command name + handler: Handler function + meta: Metadata dictionary + """ + # Set attributes on the handler function + handler._command_description = meta.get("description", "") + handler._command_arguments = meta.get("arguments", {}) + handler._command_examples = meta.get("examples", []) + handler._command_aliases = meta.get("aliases", []) + handler._command_extra_help = meta.get("extra_help") or "" + + # Register the command + COMMAND_REGISTRY[name] = handler + + # Register aliases + for alias in meta.get("aliases", []): + COMMAND_REGISTRY[alias] = handler + + +def get_command_handler(command_name: str) -> Optional[Callable]: + """ + Get the handler function for a command. + + Args: + command_name: Name of the command + + Returns: + Handler function or None if not found + """ + if command_name in COMMAND_REGISTRY: + return COMMAND_REGISTRY[command_name] + return None + + +def list_commands() -> List[str]: + """ + Get list of all registered command names. + + Returns: + List of command names + """ + return list(COMMAND_REGISTRY.keys()) + + +def check_help_flag(command_line) -> bool: + """ + Check if the help flag is present in command line. + + Args: + command_line: string + + Returns: + True if help flag is present, False otherwise + """ + help_pattern = re.compile(r"^help\b\s.+|.*\S.*\s(-{0,2}h(elp)?)$") + return bool(help_pattern.match(command_line)) diff --git a/sdk/tools/helpers.py b/sdk/tools/helpers.py new file mode 100644 index 0000000..e09e0ce --- /dev/null +++ b/sdk/tools/helpers.py @@ -0,0 +1,295 @@ +import shlex +import logging +import re + +from typing_extensions import Match + +from sdk.tools.help_system import COMMAND_REGISTRY + +logger = logging.getLogger(__name__) + + +def get_named_and_positional_params(command_line: str) -> tuple[dict, list]: + """ + Parse command line arguments into a dictionary of parameters and their values and/or a list of parameters + + This function extracts command-line parameters from a string and converts them into + a dictionary and list format. It handles both valued parameters (--key=value or --key value) + and flag parameters (--flag). The function supports various parameter formats including + comma-separated values and multi-token values. + + NB: if using a mixture of parameters and key/values, place the parameters before the key/values + + Args: + command_line (str): The command line string to parse. Can include the command name + followed by parameters in various formats. + + Returns: + tuple: + dict: A dictionary containing parsed parameters where: + - Keys are parameter names (without leading dashes) + - Values are either: + * String values for parameters with values (comma-separated values are cleaned) + * Boolean True for flag parameters without values + - Returns empty dict {} if no parameters found or on parsing errors + list: all parameters that don't start with -- or - will get added to the list + + Supported Parameter Formats: + - --param=value : {'param': 'value'} + - --param value : {'param': 'value'} + - -param value : {'param': 'value'} + - --flag : {'flag': True} + - --list=val1,val2 : {'list': 'val1,val2'} + - --multi word value : {'multi': 'word value'} + - --quoted="spaced value": {'quoted': 'spaced value'} + arg1 arg2 + + Examples: + >>> get_named_and_positional_params("aws vm list --type=t3.micro,t2.micro --state=pending,stopped --stop") + {'type': 't3.micro,t2.micro', 'state': 'pending,stopped', 'stop': True} + [""] + + >>> get_named_and_positional_params("command --region us-east-1 --verbose") + {'region': 'us-east-1', 'verbose': True} + [""] + + >>> get_named_and_positional_params("command --name 'my server' --count 5") + {'name': 'my server', 'count': '5'} + [""] + + >>> get_named_and_positional_params("command --list item1, item2 , item3") + {'list': 'item1,item2,item3'} + [""] + + >>> get_named_and_positional_params("") + {} + [] + + >>> get_named_and_positional_params("command-with-no-params") + {} + [""] + + >>> get_named_and_positional_params("aws vm list param1 param2 --type=t3.micro,t2.micro --state=pending,stopped --stop", "aws vm list") + {'type': 't3.micro,t2.micro', 'state': 'pending,stopped', 'stop': True} + ['param1','param2'] + + Notes: + - Parameter names have leading dashes (-/--) stripped from keys + - Comma-separated values are cleaned (extra whitespace removed) + - Multi-token values are joined with spaces + - Uses shlex.split for proper handling of quoted arguments + - Gracefully handles malformed input by returning empty dict + - Logs errors when parsing fails + - Call get_list_of_values_for_key_in_dict_of_parameters to get a list of values for a key in the returned dictionary + """ + if not isinstance(command_line, str): + logger.error(f"command_line must be a string. command_line: {command_line}") + return {}, [] + + command_line = command_line.strip() + if not command_line: + return {}, [] + + named_params = {} + positional_params = [] + + parameters_line = get_parameters_line(command_line) + + if not parameters_line: + return {}, [] + + try: + # Use shlex.split to properly handle quoted arguments and spaces + args = shlex.split(parameters_line) + + i = 0 + while i < len(args): + token = args[i] + + # Handle both --param and -param formats + if token.startswith("--") or (token.startswith("-") and len(token) > 1): + # Extract the parameter name (remove leading dashes) + key = token.lstrip("-") + value = None + + if "=" in key: + # Handle --key=value format + key, value = key.split("=", 1) + + # Check if this value continues across multiple tokens (comma-separated values) + value_parts = [value] + next_idx = i + 1 + + # Collect subsequent tokens until we hit another parameter or end of args + while ( + next_idx < len(args) + and not args[next_idx].startswith("-") + and not args[next_idx].startswith("--") + ): + value_parts.append(args[next_idx]) + next_idx += 1 + + # Join all value parts + value = " ".join(value_parts) + # Update index to skip the consumed tokens + i = next_idx - 1 + + elif i + 1 < len(args) and not args[i + 1].startswith("-"): + # Handle --key value format + value_parts = [args[i + 1]] + next_idx = i + 2 + + # Collect subsequent tokens until we hit another parameter or end of args + while ( + next_idx < len(args) + and not args[next_idx].startswith("-") + and not args[next_idx].startswith("--") + ): + value_parts.append(args[next_idx]) + next_idx += 1 + + # Join all value parts + value = " ".join(value_parts) + # Update index to skip the consumed tokens + i = next_idx - 1 + + # Clean up key name + key = key.strip() + + # Only add valid parameter names + if key: + if value is not None and value != "": + # Clean up comma-separated values in the value + cleaned_value = _clean_comma_separated_value(value) + named_params[key] = cleaned_value + else: + # Flag parameter without value (e.g., --stop, --delete) + named_params[key] = True + # handle parameters that are not key/values + elif len(token) > 1: + positional_params.append(token.strip()) + i += 1 + + except Exception as e: + logger.error(f"Error parsing command line '{parameters_line}': {e}") + + return named_params, positional_params + + +def get_list_of_values_for_key_in_dict_of_parameters( + key_name: str, dict_of_parameters: dict +) -> list[str]: + """ + Given a dictionary of parameters and a key name, return a list of values for that key. + + Args: + key_name: The parameter name to look for + dict_of_parameters: Dictionary containing parameters and their values + + Returns: + List of string values split by comma if the key exists and has a string value, + empty list otherwise. + + Examples: + >>> get_list_of_values_for_key_in_dict_of_parameters("state", {"state": "pending,stopped"}) + ['pending', 'stopped'] + >>> get_list_of_values_for_key_in_dict_of_parameters("missing", {"state": "pending"}) + [] + >>> get_list_of_values_for_key_in_dict_of_parameters("flag", {"flag": True}) + [] + """ + # Input validation + if not key_name or not dict_of_parameters: + return [] + + if not isinstance(key_name, str) or not isinstance(dict_of_parameters, dict): + return [] + + # Get the value for the key + value = dict_of_parameters.get(key_name) + + # Only process string values (skip booleans, None, etc.) + if not isinstance(value, str) or not value.strip(): + return [] + + # Use the existing comma-separated value cleaning function + cleaned_value = _clean_comma_separated_value(value) + + # Split by comma and return the list + return cleaned_value.split(",") if cleaned_value else [] + + +def _clean_comma_separated_value(value: str) -> str: + """ + Clean up comma-separated values by removing extra whitespace and empty values. + E.g., "pending, stopped , running" -> "pending,stopped,running" + """ + if not value or not isinstance(value, str): + return value + + # Split by comma, strip each part, and filter out empty strings + parts = [part.strip() for part in value.split(",") if part.strip()] + + # Join back with commas + return ",".join(parts) + + +def _get_match_command_line(command_line: str) -> Match[str] | None: + commands_pattern = "|".join(map(re.escape, COMMAND_REGISTRY)) + pattern = re.compile( + r"^(?Phelp$|" + r"(?:(help\s)?" + r"(" + commands_pattern + r")" + r"(\s(?:help|h))?)" + r")\b" + r"(?P.*)" + ) + + return pattern.match(command_line) + + +def get_base_command(command_line: str) -> str | None: + """Extracts the base command.""" + match_command = _get_match_command_line(command_line) + + if not match_command: + return None + + groups = match_command.groupdict() + base_command = groups.get("base_command", "") or "" + + return f"{base_command}".strip() + + +def get_parameters_line(command_line: str) -> str | None: + """Extracts the parameters line.""" + match_command = _get_match_command_line(command_line) + + if not match_command: + return None + + groups = match_command.groupdict() + params_line = groups.get("params", "") or "" + + return f"{params_line}".strip() + + +def remove_bot_username(command_line: str) -> str: + """Remove the bot username from the command line.""" + cmd_strings = [x for x in command_line.split(" ") if x.strip() != ""] + + if len(cmd_strings) > 1 and ( + cmd_strings[0].startswith("<@") or cmd_strings[0].startswith("@") + ): + return " ".join(cmd_strings[1:]) + + return " ".join(cmd_strings) + + +def validate_command(command_line: str) -> bool: + """ + Validates the command line, returning True if the command is valid, False otherwise. + """ + command_pattern = r"^\s*(\S.*?)(?:\s+--|$)" + + return bool(re.match(command_pattern, command_line)) diff --git a/slack_handlers/handlers.py b/slack_handlers/handlers.py new file mode 100644 index 0000000..d18d7da --- /dev/null +++ b/slack_handlers/handlers.py @@ -0,0 +1,1567 @@ +from sdk.aws.ec2 import EC2Helper +from sdk.gcp.compute_engine import GCPHelper +from sdk.openstack.core import OpenStackHelper +from config import _GCP_DEFAULT_DISK_SIZES, config +from sdk.tools.help_system import ( + command_meta, + handle_help_command, + get_openstack_os_names, + get_openstack_statuses, + get_openstack_flavors, + get_aws_instance_states, + get_aws_instance_types, + get_aws_os_ami_names, + get_gcp_boot_disk_size_choices_gb, + get_gcp_instance_states, + get_gcp_instance_types, + get_gcp_os_names, +) +from sdk.gsheet.gsheet import gsheet +import logging +import traceback +import functools +from datetime import datetime + + +logger = logging.getLogger(__name__) + +# Shown in `help gcp vm create` and after successful VM creation (Google OS Login). +GCP_VM_OS_LOGIN_HELP = ( + "*SSH (Google OS Login):*\n" + "SSH is tied to your Google identity when OS Login is enabled on the project.\n\n" + "*Project (admins):* enable OS Login, e.g.\n" + "`gcloud compute project-info add-metadata --metadata enable-oslogin=TRUE`\n\n" + "*Your workstation — add your public key once:*\n" + "`gcloud compute os-login ssh-keys add --key-file=~/.ssh/id_rsa.pub`\n\n" + "*Connect with plain SSH:*\n" + "`ssh -i ~/.ssh/id_rsa @`\n" + "Your OS Login POSIX username: `gcloud compute os-login describe-profile`\n\n" + "*Or use gcloud (recommended):*\n" + "`gcloud compute ssh --zone=`" +) + + +# Helper function to handle the "help" command +@command_meta( + name="help", + description="Show help information for commands", + arguments={ + "command": { + "description": "Specific command to get help for", + "required": False, + "type": "str", + } + }, + examples=["help", "help openstack vm create"], +) +def handle_help(say, user, command_name=None): + """Handle help command using the new help system.""" + handle_help_command(say, user, command_name) + + +# Helper function to handle creating an OpenStack VM +@command_meta( + name="openstack vm create", + description="Create an OpenStack VM with specified configuration", + arguments={ + "name": {"description": "Name for the VM", "required": True, "type": "str"}, + "os_name": { + "description": "Operating system name", + "required": True, + "type": "str", + "choices": get_openstack_os_names, + }, + "flavor": { + "description": "VM flavor/size (e.g., ci.cpu.small)", + "required": True, + "type": "str", + "choices": get_openstack_flavors, + }, + "key_pair": { + "description": "Whether to use new or existing keypair", + "required": True, + "type": "str", + "choices": ["new", "existing"], + }, + }, + examples=[ + "openstack vm create --name=myvm --os_name=fedora --flavor=ci.cpu.small --key_pair=new" + ], +) +def handle_create_openstack_vm(say, user, app, params_dict): + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_create_openstack_vm" + ) + + # Extract key params from command + name = params_dict.get("name") + os_name = params_dict.get("os_name") + flavor = params_dict.get("flavor") + key_pair = params_dict.get("key_pair") # `new` or `existing` + + logger.info( + f"Parsed Parameters: name={name}, os_name={os_name}, flavor={flavor}, key_pair={key_pair}" + ) + + # Validate required fields + required_params = ["name", "os_name", "flavor", "key_pair"] + missing_params = [ + param for param in required_params if not params_dict.get(param) + ] + + if missing_params: + say( + f":warning: Missing required parameters: {', '.join(missing_params)}. " + f"Usage: openstack vm create --name= --os_name= --flavor= --key_pair=[new|existing]\n" + f"Supported OS names: {', '.join(config.OS_IMAGE_MAP.keys())}" + ) + return + + # Normalize OS name and retrieve corresponding image ID + os_name_lower = os_name.strip().lower() if os_name else "" + image_id = config.OS_IMAGE_MAP.get(os_name_lower) + + if not image_id: + say( + f":x: Unsupported OS name: `{os_name}`. " + f"Supported OS names: {', '.join(config.OS_IMAGE_MAP.keys())}" + ) + return + + # Resolve network ID using default network name + network_id = config.OS_NETWORK_MAP.get(config.OS_DEFAULT_NETWORK) + if not network_id: + say( + ":x: No valid network ID found for the default network. Please check configuration." + ) + logger.error( + f"Missing network ID for default network: {config.OS_DEFAULT_NETWORK}" + ) + return + + logger.info(f"Using Image ID: {image_id} and Network ID: {network_id}") + + if key_pair not in ("new", "existing"): + say("`key_pair` should either be `new` or `existing`.") + logger.debug(f"invalid `key_pair` value: {key_pair}") + return + + say( + ":hourglass_flowing_sand: Now processing your request for an OpenStack VM... Please wait." + ) + openstack_helper = OpenStackHelper() + + key_pair = _helper_select_keypair( + key_pair, user, app, "OpenStack", image_id, flavor, say, openstack_helper + ) + + if not key_pair: + logging.error( + f"Fetching/creating Openstack keypair failed for user {user} Aborting." + ) + say("Some problem occurred during keypair selection. Aborting VM creation") + return + + response = openstack_helper.create_servers( + name, image_id, flavor, key_pair["KeyName"], network_id + ) + + # Extract result from response + instances = response.get("instances", []) + if instances: + instance_info = instances[0] + + say(":white_check_mark: *Successfully created OpenStack VM!*\n") + + # Format and display instance metadata as table + print_keys = [ + "name", + "server_id", + "key_name", + "flavor", + "network", + "status", + "private_ip", + ] + block_message = "Here are the details of your new OpenStack VM:" + helper_display_dict_output_as_table( + {"instances": [instance_info]}, print_keys, say, block_message + ) + + # Provide SSH access instructions for user + say( + f"\n\n" + ":key: *Access Instructions (Linux/Unix):*\n" + "Use the following command to SSH into your instance:\n" + f"`ssh -i {config.OS_DEFAULT_SSH_USER}@{instance_info.get('private_ip', '')}`\n" + "Make sure your key file has the correct permissions: `chmod 400 `\n" + "\n\n" + ":warning: *Key Pair Access:*\n" + f"To access this instance via SSH, you should have the private key with fingerprint `{key_pair['KeyFingerprint']}`.\n" + "If you don't have it, please contact the admin for access." + ) + + else: + say(":x: VM creation failed. No instance details returned.") + logger.error(f"OpenStack VM creation failed: {response}") + + except Exception as e: + logger.error(f"Error during OpenStack VM creation: {e}") + logger.error(traceback.format_exc()) + say( + ":x: An internal error occurred while creating the OpenStack VM. Please contact the administrator." + ) + + +# Helper function to list OpenStack VMs with error handling +@command_meta( + name="openstack vm list", + description="List OpenStack VMs with optional status filtering", + arguments={ + "status": { + "description": "Filter VMs by status", + "required": False, + "type": "str", + "choices": get_openstack_statuses, + "default": "ACTIVE", + } + }, + examples=[ + "openstack vm list", + "openstack vm list --status=ACTIVE", + "openstack vm list --status=SHUTOFF", + ], +) +def handle_list_openstack_vms(say, params_dict): + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_list_openstack_vms" + ) + + # Define valid status filters + VALID_STATUSES = {"ACTIVE", "SHUTOFF", "ERROR"} + # Default to ACTIVE if no status filter provided + status_filter = params_dict.get("status", "ACTIVE").upper() + + if status_filter not in VALID_STATUSES: + logger.error(f"Received unsupported status filter: {status_filter}.") + say( + f":warning: Invalid status filter *{status_filter}*. Supported values are: {', '.join(sorted(VALID_STATUSES))}" + ) + return + + # Log the status filter being used + logger.info(f"Filtering OpenStack VMs with status filter: {status_filter}.") + + helper = OpenStackHelper() + servers = helper.list_servers(params_dict) + + # Check for error returned from main function + if "error" in servers: + say(f":warning: {servers['error']}") + return + + if servers["count"] == 0: + say( + f":no_entry_sign: There are currently no VMs in the *{status_filter}* state in OpenStack." + ) + return + else: + # Define the keys to display in the table output + print_keys = [ + "server_id", + "name", + "flavor", + "network", + "private_ip", + "key_name", + "status", + ] + # Display the data as a Slack table + helper_display_dict_output_as_table( + servers, + print_keys, + say, + block_message=" Here are the requested VM instances:", + ) + + except Exception as e: + # Log the error for debugging purposes + logger.error(f"Failed to list OpenStack VMs: {e}") + say(":x: An error occurred while fetching the list of VMs.") + + +# Helper function to handle greeting +@command_meta(name="hello", description="Greet the bot", examples=["hello"]) +def handle_hello(say, user): + logger.info(f"Saying hello back to user {user}") + say(f"Hello <@{user}>! How can I assist you today?") + + +@command_meta( + name="aws vm create", + description="Create an AWS EC2 instance", + arguments={ + "os_name": { + "description": "Operating system name", + "required": True, + "type": "str", + "choices": get_aws_os_ami_names, + }, + "instance_type": { + "description": "EC2 instance type", + "required": True, + "type": "str", + "choices": get_aws_instance_types, + }, + "key_pair": { + "description": "Key pair option", + "required": True, + "type": "str", + "choices": ["new", "existing"], + }, + }, + examples=[ + "aws vm create --os_name=linux --instance_type=t2.micro --key_pair=new", + "aws vm create --os_name=linux --instance_type=t3.small --key_pair=existing", + ], +) +def handle_create_aws_vm(say, user, region, app, params_dict): + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_create_aws_vm" + ) + + os_name = params_dict.get("os_name") + instance_type = params_dict.get("instance_type") + key_pair = params_dict.get("key_pair") + + # Log the parsed parameters for debugging purposes + logger.info( + f"Parsed Parameters: os_name={os_name}, instance_type={instance_type}, key_pair={key_pair}" + ) + + # Check for missing parameters and inform the user which ones are missing + if not all([os_name, instance_type, key_pair]): + missing_params = [] + if not os_name: + missing_params.append("os_name") + if not instance_type: + missing_params.append("instance_type") + if not key_pair: + missing_params.append("key_pair") + + say( + f":warning: Missing required parameters: {', '.join(missing_params)}. Usage: aws vm create --os_name= --instance_type= --key_pair=" + ) + return + + # Key pair should be either 'new' or 'existing' + key_pair = key_pair.strip().lower() if key_pair else "" + if key_pair not in {"new", "existing"}: + say(":warning: `key_pair` should be either `new` or `existing`") + return + + os_name_lower = os_name.strip().lower() if os_name else "" + aws_ami_map = getattr(config, "AWS_AMI_MAP", {"linux": "ami-0402e56c0a7afb78f"}) + ami_id = aws_ami_map.get(os_name_lower) + + if ami_id: + logger.info(f"Operating System selected: {os_name}") + logger.info(f"Using AMI ID: {ami_id}") + say( + f":hourglass_flowing_sand: Now processing your request for a {os_name} Instance... Please wait." + ) + + # Create EC2 instance using the helper + ec2_helper = EC2Helper(region=region) + + # Select key to use + key_to_use = _helper_select_keypair( + key_pair, user, app, "AWS", os_name, instance_type, say, ec2_helper + ) + + if not key_to_use: + logger.error("Aborting VM creation because returned keypair was empty.") + return + + server_status_dict = ec2_helper.create_instance( + ami_id, + instance_type, + key_to_use["KeyName"], + ) + + # Log the server creation response for debugging + logger.debug(f"Server creation response: {server_status_dict}") + + # Handle known error if server creation fails + if "error" in server_status_dict: + error_msg = server_status_dict["error"] + logger.error(f"EC2 instance creation failed: {error_msg}") + say(":x: *EC2 instance creation failed.*") + return + + # Check for successful instance creation and provide details + servers_created = server_status_dict.get("instances", []) + if servers_created: + instance = servers_created[0] + + instance_dict = { + "instances": [ + { + "name": instance.get("name", "unknown"), + "instance_id": instance.get("instance_id", "unknown"), + "key_name": instance.get("key_name", "unknown"), + "instance_type": instance.get("instance_type", "unknown"), + "public_ip": instance.get("public_ip", "unknown"), + } + ] + } + + # Define the keys to display in the table + print_keys = [ + "name", + "instance_id", + "key_name", + "instance_type", + "public_ip", + ] + + say(":white_check_mark: *Successfully created EC2 instance!*\n\n") + + # Use the helper function to display the instance details as a table + helper_display_dict_output_as_table( + instance_dict, + print_keys, + say, + block_message=" Here are the requested VM instances:", + ) + + say( + "\n\n" + ":key: *Access Instructions (Linux/Unix):*\n" + "Use the following command to SSH into your instance:\n" + "`ssh -i ec2-user@`\n" + "Make sure your key file has the correct permissions: `chmod 400 `\n" + "\n\n" + ":warning: *Key Pair Access:*\n" + f"To access this instance via SSH, you should have the private key with fingerprint: `{key_to_use['KeyFingerprint']}`.\n" + "If you don't have it, please contact the admin for access." + ) + else: + say(":x: *EC2 instance creation failed.* No instance returned.") + logger.error("EC2 creation failed: No instance returned in response.") + else: + say( + f":x: Unsupported OS name: `{os_name}`. " + f"Supported OS names: {', '.join(aws_ami_map.keys())}" + ) + return + + except Exception as e: + # Log the error and provide a user-friendly message + logger.error("An error occurred while creating the EC2 instance") + logger.error(f"Error Details: {e}") + logger.error(traceback.format_exc()) + say( + ":x: An internal error occurred while creating the EC2 instance. Please contact the administrator." + ) + + +@command_meta( + name="gcp vm create", + description="Create a GCP Compute Engine VM instance", + arguments={ + "name": {"description": "Name for the VM", "required": True, "type": "str"}, + "os_name": { + "description": "Operating system / image name", + "required": True, + "type": "str", + "choices": get_gcp_os_names, + }, + "instance_type": { + "description": ( + "GCP machine type (optional; default from GCP_DEFAULT_INSTANCE_TYPE / .env). " + "Must be one of GCP_POPULAR_INSTANCE_TYPES. " + "Use ``--instance_type`` or ``--instance-type``." + ), + "required": False, + "type": "str", + "choices": get_gcp_instance_types, + }, + "disk-size-gb": { + "description": "Boot disk size in GB (optional; overrides GCP_BOOT_DISK_SIZE_GB)", + "required": False, + "type": "str", + "choices": get_gcp_boot_disk_size_choices_gb, + }, + }, + examples=[ + "gcp vm create name=vm-test-123 --os_name=debian-12", + "gcp vm create name=vm-test-123 --os_name=debian-12 --instance-type=n2-standard-4", + "gcp vm create name=vm-test-123 --os_name=debian-12 --instance_type=e2-medium --disk-size-gb=20", + "gcp vm create name=vm-test-123 --os_name=linux --instance_type=n1-standard-1", + ], + extra_help=GCP_VM_OS_LOGIN_HELP, +) +def handle_create_gcp_vm(say, user, params_dict): + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_create_gcp_vm" + ) + + os_name = params_dict.get("os_name") + instance_type_param = params_dict.get("instance-type") or params_dict.get( + "instance_type" + ) + name = params_dict.get("name") + # ``--disk-size-gb=`` / ``--disk_size_gb=`` (parser keeps hyphens / underscores) + disk_size_gb_param = params_dict.get("disk-size-gb") or params_dict.get( + "disk_size_gb" + ) + + disk_gb_override = None + if disk_size_gb_param not in (None, "", False): + try: + disk_gb_override = int(str(disk_size_gb_param).strip()) + except ValueError: + say( + ":warning: `--disk-size-gb` must be an integer (GB).\n" + f"Allowed values: {', '.join(str(x) for x in _GCP_DEFAULT_DISK_SIZES)}." + ) + return + if disk_gb_override not in _GCP_DEFAULT_DISK_SIZES: + say( + f":warning: `--disk-size-gb` must be one of {_GCP_DEFAULT_DISK_SIZES} GB." + ) + return + + _allowed_types = list(config.GCP_POPULAR_INSTANCE_TYPES) + if instance_type_param in (None, "", False): + instance_type = config.GCP_DEFAULT_INSTANCE_TYPE + else: + instance_type = str(instance_type_param).strip().lower() + if instance_type not in _allowed_types: + say( + ":warning: Instance type must be one of the configured popular types.\n" + f"Allowed: {', '.join(_allowed_types)}.\n" + f"Omit ``--instance-type`` / ``--instance_type`` to use default " + f"`{config.GCP_DEFAULT_INSTANCE_TYPE}`." + ) + return + + logger.info( + f"Parsed Parameters: name={name}, os_name={os_name}, " + f"instance_type={instance_type}, disk_gb_override={disk_gb_override}" + ) + + if not all([os_name, name]): + missing_params = [] + if not name: + missing_params.append("name") + if not os_name: + missing_params.append("os_name") + say( + f":warning: Missing required parameters: {', '.join(missing_params)}. " + "Usage: gcp vm create name= --os_name= " + "[--instance-type=]" + ) + return + + os_name_lower = os_name.strip().lower() if os_name else "" + gcp_image_map = getattr( + config, + "GCP_IMAGE_MAP", + { + "debian-12": "projects/debian-cloud/global/images/family/debian-12", + "linux": "projects/debian-cloud/global/images/family/debian-12", + }, + ) + image_id = gcp_image_map.get(os_name_lower) + + if image_id: + logger.info( + f"User: {user}, Operating System selected: {os_name}, image: {image_id}" + ) + say( + ":hourglass_flowing_sand: Now processing your request for a GCP VM... Please wait." + ) + + gcp_helper = GCPHelper() + server_status_dict = gcp_helper.create_instance( + image_id, + instance_type, + name, + disk_gb_override=disk_gb_override, + ) + + logger.debug(f"Server creation response: {server_status_dict}") + + if "error" in server_status_dict: + error_msg = server_status_dict["error"] + logger.error(f"GCP instance creation failed: {error_msg}") + say(f":x: *GCP instance creation failed.*\n```{error_msg}```") + return + + servers_created = server_status_dict.get("instances", []) + if servers_created: + instance = servers_created[0] + instance_dict = { + "instances": [ + { + "name": instance.get("name", "unknown"), + "instance_id": instance.get("instance_id", "unknown"), + "instance_type": instance.get("instance_type", "unknown"), + "zone": instance.get("zone", "unknown"), + "disk_gb": instance.get("disk_gb", "unknown"), + "public_ip": instance.get("public_ip", "unknown"), + } + ] + } + print_keys = [ + "name", + "instance_id", + "instance_type", + "zone", + "disk_gb", + "public_ip", + ] + say(":white_check_mark: *Successfully created GCP VM instance!*\n\n") + helper_display_dict_output_as_table( + instance_dict, + print_keys, + say, + block_message=" Here are the requested VM instances:", + ) + say( + "\n\n:key: *Access Instructions (OS Login):*\n" + f"{GCP_VM_OS_LOGIN_HELP}\n" + ) + else: + say(":x: *GCP instance creation failed.* No instance returned.") + logger.error("GCP creation failed: No instance returned in response.") + else: + say( + f":x: Unsupported OS name: `{os_name}`. " + f"Supported OS names: {', '.join(gcp_image_map.keys())}" + ) + return + + except Exception as e: + logger.error("An error occurred while creating the GCP instance") + logger.error(f"Error Details: {e}") + logger.error(traceback.format_exc()) + say( + ":x: An internal error occurred while creating the GCP instance. Please contact the administrator." + ) + + +def helper_create_table(data_rows, table_column_names, max_column_widths): + """ + Given + 1. data_rows - a list of row data to display + 2. column_names - the column names in the table to display + 3. max_column_widths - a dictionary with the max column width for each column of values + Create a table of data with spaces as padding and using a monospaced font (inside triple backticks) + """ + + # Format table header (first row) and the divider (2nd row) + header = " | ".join( + f"{column_name:<{max_column_widths[column_name]}}" + for column_name in table_column_names + ) + divider = "-+-".join( + "-" * max_column_widths[column_name] for column_name in table_column_names + ) + + # Format the data rows, left aligning with spaces + rows = [] + + for row in data_rows: + row_values = [] + for index, val in enumerate(row): + # left-align the text with spaces + row_values.append( + f"{str(val):<{max_column_widths[table_column_names[index]]}}" + ) + rows.append(" | ".join(row_values)) + + table = "\n".join([header, divider] + rows) + return f"```\n{table}\n```" + + +def helper_setup_slack_header_line(header_text, emoji_name="ledger"): + """ + sets up a slack block consisting of an emoji and bold text. This is typically used for a header line + """ + return [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "emoji", "name": f"{emoji_name}"}, + { + "type": "text", + "text": f"{header_text}", + "style": {"bold": True}, + }, + ], + } + ], + } + ] + + +def helper_display_dict_output_as_table(instances_dict, print_keys, say, block_message): + """ + given a dictionary containing instance information for servers, set up a header line and then display the data in + a "table" + """ + if instances_dict and isinstance(instances_dict, dict) and len(instances_dict) > 0: + say( + text=".", + blocks=helper_setup_slack_header_line(block_message), + ) + max_column_widths = {} + rows = [] + + # for each column of data (including the column header name), calculate the max width of each columns data + # storing it in a dictionary + + # initially set the max length for each column to the column header name + for data_key_name in print_keys: + max_column_widths[data_key_name] = len(data_key_name) + + for instance_info in instances_dict.get("instances", []): + row = [] + for data_key_name in print_keys: + column_value_raw = instance_info.get(data_key_name, "unknown") + column_value = str(column_value_raw) + current_max_len = max_column_widths.get(data_key_name, 0) + if len(column_value) > current_max_len: + max_column_widths[data_key_name] = len(column_value) + row.append(column_value) + rows.append(row) + say(helper_create_table(rows, print_keys, max_column_widths)) + + +# Helper function to list GCP VM instances +@command_meta( + name="gcp vm list", + description="List GCP VM instances with optional filtering", + arguments={ + "state": { + "description": "Filter instances by state", + "required": False, + "type": "str", + "choices": get_gcp_instance_states, + }, + "type": { + "description": "Filter instances by type", + "required": False, + "type": "str", + "choices": get_gcp_instance_types, + }, + "instance-ids": { + "description": "Comma-separated list of instance IDs", + "required": False, + "type": "str", + }, + }, + examples=[ + "gcp vm list", + "gcp vm list --state=running,stopped", + "gcp vm list --type=t2.micro,t3.small", + "gcp vm list --instance-ids=i-123456,i-789012", + ], +) +def handle_list_gcp_vms(say, user, params_dict): + try: + logger.info("User {user} has requested a list of GCP vms") + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_list_gcp_vms" + ) + + gcp_helper = GCPHelper() # Set your region + + instances_dict = gcp_helper.list_instances(params_dict) + count_servers = instances_dict.get("count", 0) + if count_servers == 0: + msg = ( + "There are currently no GCP instances available that match the specified criteria" + if len(params_dict) > 0 + else "There are currently no GCP instances to retrieve" + ) + say(msg) + else: + print_keys = [ + "instance_id", + "name", + "instance_type", + "state", + "public_ip", + "private_ip", + ] + helper_display_dict_output_as_table( + instances_dict, + print_keys, + say, + block_message=" Here are the requested VM instances:", + ) + except Exception as e: + logger.error(f"An error occurred listing the GCP instances: {e}") + say("An internal error occurred, please contact administrator.") + + +# Helper function to list AWS EC2 instances +@command_meta( + name="aws vm list", + description="List AWS EC2 instances with optional filtering", + arguments={ + "state": { + "description": "Filter instances by state", + "required": False, + "type": "str", + "choices": get_aws_instance_states, + }, + "type": { + "description": "Filter instances by type", + "required": False, + "type": "str", + "choices": get_aws_instance_types, + }, + "instance-ids": { + "description": "Comma-separated list of instance IDs", + "required": False, + "type": "str", + }, + }, + examples=[ + "aws vm list", + "aws vm list --state=running,stopped", + "aws vm list --type=t2.micro,t3.small", + "aws vm list --instance-ids=i-123456,i-789012", + ], +) +def handle_list_aws_vms(say, region, user, params_dict): + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_list_aws_vms" + ) + + ec2_helper = EC2Helper(region=region) # Set your region + instances_dict = ec2_helper.list_instances(params_dict) + count_servers = instances_dict.get("count", 0) + if count_servers == 0: + msg = ( + "There are currently no EC2 instances available that match the specified criteria" + if len(params_dict) > 0 + else "There are currently no EC2 instances to retrieve" + ) + say(msg) + else: + print_keys = [ + "instance_id", + "name", + "instance_type", + "state", + "public_ip", + "private_ip", + ] + helper_display_dict_output_as_table( + instances_dict, + print_keys, + say, + block_message=" Here are the requested VM instances:", + ) + except Exception as e: + logger.error(f"An error occurred listing the EC2 instances: {e}") + say("An internal error occurred, please contact administrator.") + + +@command_meta( + name="aws vm modify", + description="Stop or delete AWS EC2 instances", + arguments={ + "vm-id": { + "description": "Instance ID to modify", + "required": True, + "type": "str", + }, + "stop": {"description": "Stop the instance", "required": False, "type": "bool"}, + "delete": { + "description": "Delete the instance", + "required": False, + "type": "bool", + }, + }, + examples=[ + "aws vm modify --stop --vm-id=i-1234567890abcdef0", + "aws vm modify --delete --vm-id=i-1234567890abcdef0", + ], +) +def handle_aws_modify_vm(say, region, user, params_dict): + """ + Helper function to modify AWS EC2 instances (stop/delete) + """ + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_aws_modify_vm" + ) + + stop_action = params_dict.get("stop", False) + delete_action = params_dict.get("delete", False) + vm_id = params_dict.get("vm-id") + + if not vm_id: + say( + ":warning: Missing required parameter `--vm-id`. Usage: `aws vm modify --stop --vm-id=` or `aws vm modify --delete --vm-id=`" + ) + return + + if not stop_action and not delete_action: + say(":warning: You must specify either `--stop` or `--delete` action.") + return + + if stop_action and delete_action: + say( + ":warning: Please specify only one action: either `--stop` or `--delete`, not both." + ) + return + + ec2_helper = EC2Helper(region=region) + + if stop_action: + logger.info(f"User {user} requested to stop instance {vm_id}") + say(f":hourglass_flowing_sand: Attempting to stop instance `{vm_id}`...") + + result = ec2_helper.stop_instance(vm_id) + + if result["success"]: + say( + f":white_check_mark: *Successfully initiated stop for instance `{vm_id}`*\n" + f"• Previous state: `{result['previous_state']}`\n" + f"• Current state: `{result['current_state']}`\n" + f"\n:information_source: The instance will take a moment to fully stop." + ) + else: + logger.error( + f"Failed to stop instance `{vm_id}`, error: {result['error']}" + ) + say(f":x: *Failed to stop instance `{vm_id}`*") + + elif delete_action: + logger.info(f"User {user} requested to terminate instance {vm_id}") + + say( + f":warning: *Termination Warning*\n" + f"You are about to permanently terminate instance `{vm_id}`. This action cannot be undone.\n" + f":hourglass_flowing_sand: Proceeding with termination..." + ) + + result = ec2_helper.terminate_instance(vm_id) + + if result["success"]: + instance_name = result.get("instance_name", "N/A") + say( + f":white_check_mark: *Successfully initiated termination for instance `{vm_id}`*\n" + f"• Instance name: `{instance_name}`\n" + f"• Previous state: `{result['previous_state']}`\n" + f"• Current state: `{result['current_state']}`\n" + f"\n:information_source: The instance is being terminated and will be permanently deleted." + ) + else: + logger.error( + f"Failed to terminate instance `{vm_id}`, error: {result['error']}" + ) + say(f":x: *Failed to terminate instance `{vm_id}`*") + + except Exception as e: + logger.error(f"An error occurred while modifying EC2 instance: {e}") + logger.error(traceback.format_exc()) + say( + ":x: An internal error occurred while modifying the EC2 instance. Please contact the administrator." + ) + + +@command_meta( + name="gcp vm modify", + description="Stop or delete GCP VM instances (by instance name)", + arguments={ + "vm-name": { + "description": "Instance name to modify (e.g. vm-abc12345)", + "required": True, + "type": "str", + }, + "stop": {"description": "Stop the instance", "required": False, "type": "bool"}, + "delete": { + "description": "Delete the instance", + "required": False, + "type": "bool", + }, + }, + examples=[ + "gcp vm modify --stop --vm-name=vm-abc12345", + "gcp vm modify --delete --vm-name=vm-abc12345", + ], +) +def handle_gcp_modify_vm(say, user, params_dict): + """ + Helper function to modify GCP VM instances (stop/delete) by instance name. + """ + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_gcp_modify_vm" + ) + + stop_action = params_dict.get("stop", False) + delete_action = params_dict.get("delete", False) + vm_name = params_dict.get("vm-name") + + if not vm_name or not str(vm_name).strip(): + say( + ":warning: Missing required parameter `--vm-name`. Usage: `gcp vm modify --stop --vm-name=` or `gcp vm modify --delete --vm-name=`" + ) + return + + vm_name = str(vm_name).strip() + + if not stop_action and not delete_action: + say(":warning: You must specify either `--stop` or `--delete` action.") + return + + if stop_action and delete_action: + say( + ":warning: Please specify only one action: either `--stop` or `--delete`, not both." + ) + return + + gcp_helper = GCPHelper() + + if stop_action: + logger.info(f"User {user} requested to stop GCP instance {vm_name}") + say(f":hourglass_flowing_sand: Attempting to stop instance `{vm_name}`...") + + result = gcp_helper.stop_instance(vm_name) + + if result["success"]: + zone = result.get("zone", "N/A") + say( + f":white_check_mark: *Successfully stopped instance `{vm_name}`*\n" + f"• Zone: `{zone}`\n" + f"\n:information_source: The instance has been stopped." + ) + else: + logger.error( + f"Failed to stop instance `{vm_name}`, error: {result['error']}" + ) + say(f":x: *Failed to stop instance `{vm_name}`*\n{result['error']}") + + elif delete_action: + logger.info(f"User {user} requested to delete GCP instance {vm_name}") + say( + f":warning: *Deletion Warning*\n" + f"You are about to permanently delete instance `{vm_name}`. This action cannot be undone.\n" + f":hourglass_flowing_sand: Proceeding with deletion..." + ) + + result = gcp_helper.delete_instance(vm_name) + + if result["success"]: + zone = result.get("zone", "N/A") + say( + f":white_check_mark: *Successfully deleted instance `{vm_name}`*\n" + f"• Zone: `{zone}`\n" + f"\n:information_source: The instance has been permanently deleted." + ) + else: + logger.error( + f"Failed to delete instance `{vm_name}`, error: {result['error']}" + ) + say(f":x: *Failed to delete instance `{vm_name}`*\n{result['error']}") + + except Exception as e: + logger.error(f"An error occurred while modifying GCP instance: {e}") + logger.error(traceback.format_exc()) + say( + ":x: An internal error occurred while modifying the GCP instance. Please contact the administrator." + ) + + +# Helper function to list important team links +@command_meta( + name="project links list", + description="Display important team links", + examples=["project links list"], +) +def handle_list_team_links(say, user): + if not hasattr(config, "LIST_OF_ALL_TEAM_LINKS"): + logger.error("LIST_OF_ALL_TEAM_LINKS are not set properly") + say("There are no links available.") + return + say( + text=".", + blocks=helper_setup_slack_header_line(" Here are the important team links:"), + ) + + link_lines = "\n".join( + [ + f":small_orange_diamond: *{title}:* <{url}|Link>" + for title, url in config.LIST_OF_ALL_TEAM_LINKS.items() + ] + ) + + say( + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": link_lines, + }, + }, + ], + ) + + +# Helper function to handle ROTA operations +@command_meta( + name="rota", + description="Manage release rotation assignments in Google Sheets", + arguments={ + "action": { + "description": "Action to perform", + "required": True, + "type": "str", + "choices": ["add", "check", "replace"], + }, + "release": { + "description": "Release version (e.g., 4.15.1)", + "required": False, + "type": "str", + }, + "start": { + "description": "Start date in YYYY-MM-DD format (must be a Monday)", + "required": False, + "type": "str", + }, + "end": { + "description": "End date in YYYY-MM-DD format (must be a Friday)", + "required": False, + "type": "str", + }, + "pm": { + "description": "Project Manager username", + "required": False, + "type": "str", + }, + "qe": { + "description": "QE engineer username", + "required": False, + "type": "str", + }, + }, + examples=[ + "rota --add --release=4.15.1 [--start=2024-01-08 --end=2024-01-12 --pm=john.doe --qe=jane.smith --notify_date=2024-01-08]", + "rota --check --time='This Week'", + "rota --check --release=4.15.1" + "rota --replace --release=4.15.1 --column=new_pm [--user=new_person]", + ], +) +def handle_rota(say, user, params_dict): + """ + Function to interface with ROTA sheet. + `add` will add a new release + `check` will return the details of a release either by version or by time period (`This Week` or `Next Week`) + `replace` will replace a user with someone else + """ + if [ + params_dict.get("add"), + params_dict.get("check"), + params_dict.get("replace"), + ].count(True) > 1: + say("You can use only 1 of `add`, `check` and `replace`") + return + + # Add + if params_dict.get("add"): + if user not in config.ROTA_ADMINS.values(): + say("Sorry. Only admins can add releases.") + return + + rel_ver = params_dict.get("release") + if not rel_ver: + say("Please provide a release.") + return + + try: + start = params_dict.get("start") + end = params_dict.get("end") + + error = ( + _helper_date_validation(start, 0) + + "\n" + + _helper_date_validation(end, 4) + + "\n" + + _helper_date_cmp(start, end) + ) + error = error.strip() + if error: + say(error) + return + + gsheet.add_release( + rel_ver, + s_date=start, + e_date=end, + pm=_get_name_from_userid(params_dict.get("pm")), + qe=_get_name_from_userid(params_dict.get("qe")), + notify_date=params_dict.get("notify_date"), + ) + except ValueError as e: + say(str(e)) + return + + say("Success!") + return + + elif params_dict.get("check"): + rel_ver = params_dict.get("release") + time_period = params_dict.get("time") + + if rel_ver and time_period: + say("Only provide one of `release` and `time`.") + return + + elif rel_ver: + try: + data = gsheet.fetch_data_by_release(rel_ver) + except ValueError: + say("Please provide a correctly formatted release version.") + return + + elif time_period: + say( + "Time-based queries will be supported in a future update. Please use `release` parameter for now." + ) + return + + else: + say("Please provide either `release` or `time`.") + return + + if not data: + say("Sorry, could not find the requested data.") + return + + logger.debug(f"Received data from sheet: {data}") + + if isinstance(data[0], list): + formatted_str = "\n\n".join(_helper_format_rota_output(d) for d in data) + else: + formatted_str = _helper_format_rota_output(data) + + formatted_str = ( + formatted_str.strip() or "Sorry, could not find the requested data." + ) + + say(formatted_str) + return + + elif params_dict.get("replace"): + if user not in config.ROTA_USERS.values(): + say("You are not authorized to use `replace`.") + return + + rel_ver = params_dict.get("release") + column = params_dict.get("column") + user = _get_name_from_userid(params_dict.get("user")) + + if not all([rel_ver, column]): + say("Please provide `release` and `column`.") + return + + try: + gsheet.replace_user_for_release(rel_ver, column, user) + except ValueError as e: + say(e) + + say("Success!") + return + + else: + say("You need one of `add`, `check` or `replace`.") + return + + +def _helper_format_rota_output(data: list) -> str: + if not data or len(data) != 7: + logger.error(f"Cannot format ROTA data: {data}") + return "Some error occurred parsing the data." + + rel_ver, s_date, e_date, pm, qe, notify_date, activity = data + + if rel_ver == "N/A": + return "" + + pm = _get_userid_from_name(pm) + qe = _get_userid_from_name(qe) + + return f"*Release:* {rel_ver}\n" + f"*Patch Manager:* {pm}\n" + f"*QE:* {qe}" + + +def _get_userid_from_name(name: str) -> str: + return f"<@{config.ROTA_USERS.get(name, name)}>" + + +def _get_name_from_userid(userid: str) -> str: + if not userid: + return + + if not userid.startswith("<@") or not userid.endswith(">"): + return userid + + userid = userid[2:-1] + + @functools.cache + def reverse_dict(): + return {v: k for k, v in config.ROTA_USERS.items()} + + rev_dict = reverse_dict() + return rev_dict.get(userid, userid) + + +def _helper_date_validation(date: str, day: int) -> str: + # Return empty string for correct date + if not date: + return "" + try: + d = datetime.strptime(date, "%Y-%m-%d") + except ValueError: + return "Please format the date in the format YYYY-MM-DD." + + if not d: + return "Something went wrong while parsing date." + + if d.weekday() != day: + if day == 0: + return "Start date should be a Monday." + elif day == 4: + return "End date should be a Friday." + else: + return "Day of the week is incorrect" + + return "" + + +def _helper_date_cmp(start: str, end: str) -> str: + # Validate that start date is before end date + try: + s_date = datetime.strptime(start, "%Y-%m-%d") + e_date = datetime.strptime(end, "%Y-%m-%d") + + if s_date >= e_date: + return "End date should be after start date." + else: + return "" + except ValueError: + return "" + + +@command_meta( + name="openstack vm modify", + description="Stop, start, or delete OpenStack VMs", + arguments={ + "vm-id": { + "description": "Server ID to modify", + "required": True, + "type": "str", + }, + "stop": {"description": "Stop the server", "required": False, "type": "bool"}, + "start": {"description": "Start the server", "required": False, "type": "bool"}, + "delete": { + "description": "Delete the server", + "required": False, + "type": "bool", + }, + }, + examples=[ + "openstack vm modify --stop --vm-id=abc123-def456-ghi789", + "openstack vm modify --start --vm-id=abc123-def456-ghi789", + "openstack vm modify --delete --vm-id=abc123-def456-ghi789", + ], +) +def handle_openstack_modify_vm(say, user, params_dict): + """ + Helper function to modify OpenStack servers (stop/start/reboot/delete) + """ + try: + if not isinstance(params_dict, dict): + raise ValueError( + "Invalid parameter params_dict passed to handle_openstack_modify_vm" + ) + + stop_action = params_dict.get("stop", False) + start_action = params_dict.get("start", False) + delete_action = params_dict.get("delete", False) + vm_id = params_dict.get("vm-id") + + if not vm_id: + say( + ":warning: Missing required parameter `--vm-id`. " + "Usage: `openstack vm modify -- --vm-id=`\n" + "Available actions: --stop, --start, --delete" + ) + return + + # Count the number of actions specified + actions = [stop_action, start_action, delete_action] + action_count = sum(bool(action) for action in actions) + + if action_count == 0: + say( + ":warning: You must specify one action: `--stop`, `--start`, or `--delete`." + ) + return + + if action_count > 1: + say( + ":warning: Please specify only one action at a time: either `--stop`, `--start`, or `--delete`." + ) + return + + openstack_helper = OpenStackHelper() + + if stop_action: + logger.info(f"User {user} requested to stop server {vm_id}") + say(f":hourglass_flowing_sand: Attempting to stop server `{vm_id}`...") + + result = openstack_helper.stop_server(vm_id) + + if result["success"]: + say( + f":white_check_mark: *Successfully initiated stop for server `{vm_id}`*\n" + f"• Server name: `{result['server_name']}`\n" + f"• Previous status: `{result['previous_status']}`\n" + f"• Current status: `{result['current_status']}`\n" + f"\n:information_source: The server will take a moment to fully stop." + ) + else: + logger.error( + f"Failed to stop server `{vm_id}`, error: {result['error']}" + ) + say(f":x: *Failed to stop server `{vm_id}`*\n{result['error']}") + + elif start_action: + logger.info(f"User {user} requested to start server {vm_id}") + say(f":hourglass_flowing_sand: Attempting to start server `{vm_id}`...") + + result = openstack_helper.start_server(vm_id) + + if result["success"]: + say( + f":white_check_mark: *Successfully initiated start for server `{vm_id}`*\n" + f"• Server name: `{result['server_name']}`\n" + f"• Previous status: `{result['previous_status']}`\n" + f"• Current status: `{result['current_status']}`\n" + f"\n:information_source: The server will take a moment to fully start." + ) + else: + logger.error( + f"Failed to start server `{vm_id}`, error: {result['error']}" + ) + say(f":x: *Failed to start server `{vm_id}`*\n{result['error']}") + + elif delete_action: + logger.info(f"User {user} requested to delete server {vm_id}") + + say( + f":warning: *Deletion Warning*\n" + f"You are about to permanently delete server `{vm_id}`. This action cannot be undone.\n" + f":hourglass_flowing_sand: Proceeding with deletion..." + ) + + result = openstack_helper.delete_server(vm_id) + + if result["success"]: + say( + f":white_check_mark: *Successfully initiated deletion for server `{vm_id}`*\n" + f"• Server name: `{result['server_name']}`\n" + f"• Previous status: `{result['previous_status']}`\n" + f"• Current status: `{result['current_status']}`\n" + f"\n:information_source: The server is being deleted and will be permanently removed." + ) + else: + logger.error( + f"Failed to delete server `{vm_id}`, error: {result['error']}" + ) + say(f":x: *Failed to delete server `{vm_id}`*\n{result['error']}") + + except Exception as e: + logger.error(f"An error occurred while modifying OpenStack server: {e}") + logger.error(traceback.format_exc()) + say( + ":x: An internal error occurred while modifying the OpenStack server. Please contact the administrator." + ) + + +def _helper_select_keypair( + key_option, user, app, cloud_type, os_name, instance_type, say, cloud_sdk_obj +): + key_to_use = {} + + existing_key = cloud_sdk_obj.describe_keypair(key_name=user) + if existing_key: + logger.debug(f"Found existing key: {existing_key['KeyFingerprint']}") + else: + logger.debug("No existing keys found.") + + if key_option == "new": + # Delete old key since we want to maintain only one key per user (per cloud) + if existing_key: + success = cloud_sdk_obj.delete_keypair(key_name=user) + if not success: + say("Some error occurred while deleting old key.") + return + logger.debug("Deleted old key.") + + new_key = cloud_sdk_obj.create_keypair(key_name=user) + logger.debug(f"Created new key: {new_key['KeyFingerprint']}") + + # DM user with private key + app.client.chat_postMessage( + channel=user, + text=f"""New key created:\n```{new_key["KeyMaterial"]}``` + Cloud: {cloud_type}, OS: {os_name}, Instance: {instance_type}""", + ) + logger.debug("Sent private key in user DM.") + say("Please check DM for the newly generated private key.") + + key_to_use = { + "KeyName": new_key["KeyName"], + "KeyFingerprint": new_key["KeyFingerprint"], + } + + return key_to_use + + else: + if not existing_key: + logger.debug("Existing key not found") + say(":warning: You do not have any existing keys.") + return + + key_to_use = existing_key + logger.debug(f"Using existing key: {key_to_use['KeyFingerprint']}") + + return key_to_use diff --git a/slack_helpers/helper_functions.py b/slack_helpers/helper_functions.py deleted file mode 100644 index 4c2a4a6..0000000 --- a/slack_helpers/helper_functions.py +++ /dev/null @@ -1,45 +0,0 @@ -from aws.ec2_helper import EC2Helper -from aws.rosa_helper import ROSAHelper -from ostack.core import OpenStackHelper - - -# Helper function to handle the "help" command -def handle_help(say, user): - say( - f"Hello <@{user}>! I'm here to help. You can use the following commands:\n" - "`create-rosa-cluster `: Create an AWS OpenShift cluster.\n" - "`create-openstack-vm `: Create an OpenStack VM.\n" - "`hello`: Greet the bot." - ) - - -# Helper function to handle creating a ROSA cluster -def handle_create_rosa_cluster(say, user, text): - cluster_name = text.replace("create-rosa-cluster", "").strip() - rosa_helper = ROSAHelper(region="") # Set your region - rosa_helper.create_rosa_cluster(cluster_name) - - -# Helper function to handle creating an OpenStack VM -def handle_create_openstack_vm(say, user, text): - args = text.replace("create-openstack-vm", "").strip().split() - os_helper = OpenStackHelper() - os_helper.create_vm(args, say) - - -# Helper function to handle greeting -def handle_hello(say, user): - say(f"Hello <@{user}>! How can I assist you today?") - - -# Helper function to handle creating an AWS EC2 instances -def handle_create_aws_vm(say, user): - ec2_helper = EC2Helper(region="") # Set your region - instance = ec2_helper.create_instance( - "", # Replace with a valid AMI ID - "", # Replace with a valid instance type - "", # Replace with your key name - "", # Replace with your security group ID - "", # Replace with your subnet ID - ) - say(f"Successfully created EC2 instance: {instance.id}") diff --git a/slack_main.py b/slack_main.py index 78cddd3..fdd35ee 100644 --- a/slack_main.py +++ b/slack_main.py @@ -1,49 +1,117 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from config import config -import re +from sdk.tools.helpers import ( + get_named_and_positional_params, + validate_command, + remove_bot_username, + get_base_command, +) +from sdk.tools.help_system import handle_help_command, check_help_flag +import logging -from slack_helpers.helper_functions import ( - handle_help, - handle_create_rosa_cluster, +from slack_handlers.handlers import ( handle_create_openstack_vm, + handle_list_openstack_vms, handle_hello, handle_create_aws_vm, + handle_list_aws_vms, + handle_list_team_links, + handle_aws_modify_vm, + handle_rota, + handle_openstack_modify_vm, + handle_list_gcp_vms, + handle_create_gcp_vm, + handle_gcp_modify_vm, ) +logger = logging.getLogger(__name__) + app = App(token=config.SLACK_BOT_TOKEN) +def is_user_allowed(user_id: str) -> bool: + return user_id in config.ALLOWED_SLACK_USERS.values() + + # Define the main event handler function @app.event("app_mention") @app.event("message") def mention_handler(body, say): user = body.get("event", {}).get("user") - text = body.get("event", {}).get("text", "").strip() - # Create a command mapping + # Authorization check + if config.ALLOW_ALL_WORKSPACE_USERS: + if not is_user_allowed(user): + say( + f"Sorry <@{user}>, you're not authorized to use this bot.Contact ocp-sustaining-admin@redhat.com for assistance." + ) + return + + command_line = body.get("event", {}).get("text", "").strip() + region = config.AWS_DEFAULT_REGION + + if not validate_command(command_line): + say( + f"Hello <@{user}>! I couldn't understand your request. Please try again or type 'help' for assistance." + ) + return + + command_line = remove_bot_username(command_line) + + base_command = get_base_command(command_line) + + # Extract parameters using the utility function + named_params, positional_params = get_named_and_positional_params(command_line) + + # Check if this is a help request for a specific command + if check_help_flag(command_line): + handle_help_command(say, user, base_command) + return + commands = { - r"\bhelp\b": lambda: handle_help(say, user), - r"^create-rosa-cluster": lambda: handle_create_rosa_cluster(say, user, text), - r"^create-openstack-vm": lambda: handle_create_openstack_vm(say, user, text), - r"\bhello\b": lambda: handle_hello(say, user), - r"\bcreate_aws_vm\b": lambda: handle_create_aws_vm(say, user), + "openstack vm create": lambda: handle_create_openstack_vm( + say, user, app, named_params + ), + "openstack vm list": lambda: handle_list_openstack_vms(say, named_params), + "openstack vm modify": lambda: handle_openstack_modify_vm( + say, user, named_params + ), + "hello": lambda: handle_hello(say, user), + "aws vm create": lambda: handle_create_aws_vm( + say, + user, + region, + app, # pass `app` so that bot can send DM to users + named_params, + ), + "aws vm modify": lambda: handle_aws_modify_vm(say, region, user, named_params), + "aws vm list": lambda: handle_list_aws_vms(say, region, user, named_params), + "gcp vm list": lambda: handle_list_gcp_vms(say, user, named_params), + "gcp vm create": lambda: handle_create_gcp_vm(say, user, named_params), + "gcp vm modify": lambda: handle_gcp_modify_vm(say, user, named_params), + "project links list": lambda: handle_list_team_links(say, user), + "help": lambda: handle_help_command(say, user), + "rota": lambda: handle_rota(say, user, named_params), } - # Check for command matches and execute the appropriate handler - for pattern, handler in commands.items(): - if re.search(pattern, text, re.IGNORECASE): - handler() # Execute the handler - return + command_function = commands.get(base_command) + + if not command_function: + say( + f"Hello <@{user}>! I couldn't understand your request. Please try again or type 'help' for assistance." + ) + return - # If no match is found, provide a default message - say( - f"Hello <@{user}>! I couldn't understand your request. Please try again or type 'help' for assistance." - ) + try: + command_function() + except Exception as e: + logger.error(f"An error occurred and it was caught at the mention_handler: {e}") + say("An internal error occurred, please contact administrator.") # Main Entry Point if __name__ == "__main__": - print("Starting Slack bot...") + logger.info("Starting Slack bot...") handler = SocketModeHandler(app, config.SLACK_APP_TOKEN) handler.start() diff --git a/slack_worker/Dockerfile b/slack_worker/Dockerfile new file mode 100644 index 0000000..e77e730 --- /dev/null +++ b/slack_worker/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.12-alpine + +WORKDIR /app + +# Install system dependencies +RUN apk add --no-cache --virtual .build-deps \ + gcc \ + musl-dev \ + linux-headers \ + python3-dev + +# Copy slack_worker requirements +COPY requirements.txt /app/slack_worker/requirements.txt + +# Install Python dependencies +RUN pip install --no-cache-dir -r /app/slack_worker/requirements.txt + +# Remove build dependencies +RUN apk del .build-deps + +# Copy necessary files from parent directory +COPY ../sdk /app/sdk/ + +# Copy slack_worker service +COPY ./ /app/slack_worker/ + +# Create lock directory for file-based locking +RUN mkdir -p /tmp/slack_worker_locks + +# Set Python path +ENV PYTHONPATH=/app + +# Run the worker service +CMD ["python", "-m", "slack_worker.main"] diff --git a/slack_worker/README.md b/slack_worker/README.md new file mode 100644 index 0000000..4412e39 --- /dev/null +++ b/slack_worker/README.md @@ -0,0 +1,92 @@ +# Slack Worker Service + +Scheduled job service for ROTA (Release Operations Tracking and Automation) notifications and release synchronization. + +## Overview + +The Slack Worker runs two main jobs on a schedule: + +1. **ROTA Notifications** (`jobs/rota_notifications.py`) + - Sends release reminders to Slack + - Schedule: Monday 9 AM (group + DM), Thursday 9 AM (group), Friday 5 PM (DM) + - Reads from Google Sheets and posts to Slack channels and user DMs + +2. **Release Sync** (`jobs/sync_releases.py`) + - Syncs OCP release data from Smartsheet to Google Sheets + - Schedule: Daily 8 AM UTC + - Uses SDK functions for fetch, parse, and write operations + +## Architecture + +``` +Smartsheet (API) + ↓ [sync_releases.py - 8 AM] +Google Sheets (ROTA - Assignments worksheet) + ↓ [rota_notifications.py - 9 AM / 5 PM] +Slack (channels + DMs) +``` + +## Key Components + +- **`config.py`** - Configuration management (env variables, scheduling) +- **`slack_client.py`** - Slack API wrapper (messages, DMs) +- **`scheduler.py`** - APScheduler job scheduler +- **`main.py`** - Entry point that initializes and starts the scheduler +- **`jobs/`** - Scheduled job implementations + - `rota_notifications.py` - ROTA notification logic + - `sync_releases.py` - Release sync wrapper + +## Dependencies + +- `slack-sdk>=3.35.0` - Slack API client (WebClient, error handling) +- `gspread==6.2.1` - Google Sheets API +- `apscheduler==3.10.4` - Job scheduling +- `python-dotenv==1.1.0` - Environment variables + +## Job Schedules (Cron format) + +```bash +SCHEDULE_ROTA_NOTIFICATIONS=0 9 * * MON,THU +# Monday & Thursday 9 AM +SCHEDULE_ROTA_SHEET_SYNC=0 8 * * MON,THU +# Monday & Thursday 8 AM +``` + +### Job Control + +Enable/disable jobs by setting schedule (empty string = disabled): + +```bash +SCHEDULE_ROTA_NOTIFICATIONS=0 9 * * MON # Enabled +SCHEDULE_ROTA_NOTIFICATIONS= # Disabled +``` + +## Environment Variables + +- `SLACK_BOT_TOKEN` - Slack bot API token +- `ROTA_GROUP_CHANNEL` - Slack channel for group reminders +- `ROTA_USERS` - JSON mapping of user names to Slack user IDs +- `ROTA_SERVICE_ACCOUNT` - Google service account JSON (with quotes stripped) +- `SMARTSHEET_ACCESS_TOKEN` - Smartsheet API token +- `SMARTSHEET_SHEET_*_ID` - Smartsheet IDs for OCP versions +- `SCHEDULE_ROTA_SHEET_SYNC` - Cron expression for sync job (e.g., `0 8 * * *`) + +## Running + +```bash +# With Docker +docker run --env-file .env slack-worker-scheduler:latest + +# Locally (requires Python 3.12+) +python slack_worker/main.py +``` + +## Testing + +```bash +# Run tests +pytest tests/ + +# Test individual components +python -m pytest tests/test_handlers.py -v +``` diff --git a/slack_worker/config.py b/slack_worker/config.py new file mode 100644 index 0000000..654ddaf --- /dev/null +++ b/slack_worker/config.py @@ -0,0 +1,124 @@ +""" +Configuration for Slack Worker Service +Uses Dynaconf + Vault pattern (same as root config.py) but only with worker-needed env vars +""" + +import json +import logging +import os +import tempfile + +import httpx +import hvac +import requests.exceptions +from dotenv import load_dotenv +from dynaconf import Dynaconf + +required_keys = [ + "SLACK_BOT_TOKEN", + "ROTA_SERVICE_ACCOUNT", + "ROTA_USERS", + "ROTA_ADMINS", + "LOCK_DIR", + "LOCK_TIMEOUT", + "TIMEZONE", +] + +gcp_popular_instance_types = [ + "e2-micro", + "e2-small", + "e2-medium", + "e2-standard-2", + "e2-standard-4", + "e2-standard-8", + "n1-standard-1", + "n1-standard-2", + "n1-standard-4", + "n2-standard-2", + "n2-standard-4", + "n2-standard-8", + "n2-standard-16", + "n2-highmem-4", + "n2-highmem-8", + "n2d-standard-2", + "n2d-standard-4", + "n2d-standard-8", + "c2d-standard-4", + "c2d-standard-8", +] + +load_dotenv() +req_env_vars = { + "RH_CA_BUNDLE_TEXT", + "VAULT_ENABLED_FOR_DYNACONF", + "VAULT_URL_FOR_DYNACONF", + "VAULT_SECRET_ID_FOR_DYNACONF", + "VAULT_ROLE_ID_FOR_DYNACONF", + "VAULT_MOUNT_POINT_FOR_DYNACONF", + "VAULT_PATH_FOR_DYNACONF", + "VAULT_KV_VERSION_FOR_DYNACONF", +} + +# DON'T MOVE: `basicConfig` gets called only once. Dynaconf sets it to `WARNING` so our setting should be above that +log_level = os.getenv("LOG_LEVEL", "INFO") +log_level = log_level.upper() +log_level_int = getattr(logging, log_level, 20) +log_format = "[%(asctime)s %(levelname)s %(name)s] %(message)s" +logging.basicConfig(level=log_level_int, format=log_format) + +vault_enabled = req_env_vars <= set(os.environ.keys()) # subset of os.environ + +# Load CA Cert to avoid SSL errors +ca_bundle_file = tempfile.NamedTemporaryFile() +cert_txt = os.getenv("RH_CA_BUNDLE_TEXT", "") +cert_text_final = cert_txt.replace("\\n", "\n") +with open(ca_bundle_file.name, "w") as f: + f.write(cert_text_final) + +config = Dynaconf( + load_dotenv=True, + environment=False, + vault_enabled=vault_enabled, + vault={ + "url": os.getenv("VAULT_URL_FOR_DYNACONF", ""), + "verify": ca_bundle_file.name, + }, + envvar_prefix=False, +) + +# Dynaconf is lazy -- vault connection happens on first access (dir/getattr), +# not at construction time. Catch vault errors here where they actually occur. +try: + for key in dir(config): + try: + value = getattr(config, key) + if isinstance(value, str): + val = json.loads(value) + config.set(key, val) + except json.decoder.JSONDecodeError: + logging.debug(f"{key} is not a valid JSON string.") + except AttributeError: + logging.debug(f"Attribute {key} not found.") +except ( + httpx.ConnectError, + ConnectionError, + requests.exceptions.SSLError, + requests.exceptions.ConnectionError, +): + logging.warning("Vault connection failed — continuing with env/dotenv config only") + config = Dynaconf( + load_dotenv=True, environment=False, vault_enabled=False, envvar_prefix=False + ) +except hvac.exceptions.InvalidRequest: + logging.warning( + "Vault authentication error — continuing with env/dotenv config only" + ) + config = Dynaconf( + load_dotenv=True, environment=False, vault_enabled=False, envvar_prefix=False + ) + +# Verify that all keys were loaded correctly +for k in required_keys: + if not hasattr(config, k): + logging.error(f"Could not read key: {k} from Vault.") + raise AttributeError(f"Could not read key: {k} from Vault.") diff --git a/slack_worker/jobs/__init__.py b/slack_worker/jobs/__init__.py new file mode 100644 index 0000000..88a682d --- /dev/null +++ b/slack_worker/jobs/__init__.py @@ -0,0 +1,17 @@ +""" +Scheduled job implementations +""" + +from .rota_notifications import ( + send_dm_reminders, + send_group_reminder, + send_rota_notifications, +) +from .sync_releases import sync_releases_to_gsheet + +__all__ = [ + "send_dm_reminders", + "send_group_reminder", + "send_rota_notifications", + "sync_releases_to_gsheet", +] diff --git a/slack_worker/jobs/rota_notifications.py b/slack_worker/jobs/rota_notifications.py new file mode 100644 index 0000000..6a9c086 --- /dev/null +++ b/slack_worker/jobs/rota_notifications.py @@ -0,0 +1,451 @@ +""" +ROTA notification job - sends both group reminders and DM reminders +Runs: Monday 9 AM (group + DM), Thursday 9 AM (group), Friday 5 PM (DM) +Writes notifications to Slack channel and DMs +""" + +import logging +from datetime import datetime, date, timedelta +from typing import Dict, List + +from slack_worker.config import config +from slack_worker.slack_client import slack_client +from sdk.gsheet.gsheet import GSheet + +logger = logging.getLogger(__name__) + + +def get_this_week_monday() -> date: + """Get this week's Monday date""" + today = datetime.now().date() + weekday = today.weekday() # 0=Monday, 1=Tuesday, ..., 6=Sunday + + if weekday == 0: # Already Monday + return today + else: + # Go back to this week's Monday + return today - timedelta(days=weekday) + + +def get_next_available_monday(assignment_wsheet) -> date: + """Get the next Monday that has releases in the sheet (could be 1+ weeks away) + + Args: + assignment_wsheet: The worksheet to search for available Mondays + + Returns: + The earliest Monday date after this week that has at least one release, + or next week's Monday if no releases are found + """ + this_week_monday = get_this_week_monday() + values = assignment_wsheet.get_values( + "F:F" + ) # Get all column F values (notify_date) + + if not values: + return this_week_monday + timedelta(days=7) + + # Collect all Monday dates in column F, filter for dates after this week + future_mondays = set() + for row in values[1:]: # Skip header + if row and len(row) > 0 and row[0]: + try: + monday_date = datetime.strptime(row[0], "%Y-%m-%d").date() + if monday_date > this_week_monday: + future_mondays.add(monday_date) + except (ValueError, IndexError): + continue + + # Return the earliest future Monday, or default to next week + return ( + min(future_mondays) + if future_mondays + else (this_week_monday + timedelta(days=7)) + ) + + +def _parse_releases_from_rows(data: List[List]) -> List[Dict]: + """ + Parse raw Google Sheets rows into release dictionaries + + Args: + data: List of rows from Google Sheets (each row is a list) + + Returns: + List of release dictionaries with version, dates, and team info + """ + releases = [] + for row in data: + if len(row) >= 6: + release = { + "version": row[0], + "start_date": row[1], + "end_date": row[2], + "pm": row[3], + "qe": row[4], + "notify_date": row[5], + } + releases.append(release) + logger.debug(f" • Parsed release: {row[0]}") + + return releases + + +def get_current_week_releases() -> List[Dict]: + """ + Get releases for the current week from Google Sheets + (Releases with ERR date = this week's Monday) + + Returns: + List of release data dictionaries + """ + logger.debug("Step 1: Fetching current week releases from Google Sheets") + try: + gsheet = GSheet(token=config.ROTA_SERVICE_ACCOUNT) + logger.debug(" - Google Sheets client initialized") + + this_week_monday = get_this_week_monday() + logger.debug(f" - This week's Monday: {this_week_monday}") + + data = gsheet.fetch_data_by_weekId(this_week_monday) + logger.debug(f" - Fetched raw data, rows count: {len(data) if data else 0}") + + if not data: + logger.info(" ✗ No releases found for current week") + return [] + + releases = _parse_releases_from_rows(data) + logger.info(f" ✓ Found {len(releases)} release(s) for current week") + return releases + + except Exception as e: + logger.error(f" ✗ Error fetching current week releases: {e}", exc_info=True) + return [] + + +def get_next_releases() -> List[Dict]: + """ + Get releases for the next week from Google Sheets + (Releases with ERR date = next available Monday with releases) + + Returns: + List of release data dictionaries + """ + logger.debug("Step 2: Fetching next week releases from Google Sheets") + try: + gsheet = GSheet(token=config.ROTA_SERVICE_ACCOUNT) + logger.debug(" - Google Sheets client initialized") + + next_monday = get_next_available_monday(gsheet._assignment_wsheet) + logger.debug(f" - Next available Monday: {next_monday}") + + data = gsheet.fetch_data_by_weekId(next_monday) + logger.debug(f" - Fetched raw data, rows count: {len(data) if data else 0}") + + if not data: + logger.info(" ✗ No releases found for next week") + return [] + + releases = _parse_releases_from_rows(data) + logger.info(f" ✓ Found {len(releases)} release(s) for next week") + return releases + + except Exception as e: + logger.error(f" ✗ Error fetching next week releases: {e}", exc_info=True) + return [] + + +def get_user_mention(username: str) -> str: + """ + Convert username to Slack mention format + + Args: + username: Username or display name + + Returns: + Slack mention string + """ + if not username or username == "TBD": + return username + + user_id = config.ROTA_USERS.get(username) + if user_id: + return f"<@{user_id}>" + + return username + + +def format_release_message(releases: List[Dict], week_label: str = "This Week") -> str: + """ + Format release information into a readable message + + Args: + releases: List of release dictionaries + week_label: Label for the week (e.g., "This Week", "Next Week") + + Returns: + Formatted message string + """ + logger.debug(f"Step 3: Formatting {len(releases)} releases for '{week_label}'") + if not releases: + logger.debug(" - No releases to format") + return f"No releases scheduled for {week_label.lower()}." + + message_parts = [] + + for release in releases: + pm = release.get("pm", "TBD") + qe = release.get("qe", "TBD") + logger.debug(f" • Formatting {release['version']}: PM={pm}, QE={qe}") + + pm_mention = get_user_mention(pm) + qe_mention = get_user_mention(qe) + + message_text = ( + f"*Release:* `{release['version']}`\n" + f":calendar: *Development Cut-off:* {release['start_date']}\n" + f":calendar: *Fast-Channel:* {release['end_date']}\n" + f"*Patch Manager:* {pm_mention}\n" + f"*QE:* {qe_mention}\n" + ) + + if "This Week" in week_label: + message_text += "*Status: :green-circle-small: Active*\n" + + message_parts.append(message_text) + + logger.debug(f" ✓ Formatted message with {len(message_parts)} release(s)") + return "\n".join(message_parts) + + +def send_group_reminder(): + """ + Send group reminder about the week's releases + Scheduled via SCHEDULE_ROTA_NOTIFICATIONS_GROUP_CHANNEL env var + """ + logger.info("Step 4a: Sending group reminder to Slack channel") + + try: + today = datetime.now().date() + day_of_week = today.weekday() # 0 = Monday, 3 = Thursday + logger.debug(f" - Current day: {today} (day_of_week={day_of_week})") + + # Determine if it's Monday or Thursday and fetch appropriate releases + if day_of_week == 0: # Monday + logger.debug( + " - Monday detected: fetching current week and next week releases" + ) + current_releases = get_current_week_releases() + next_releases = get_next_releases() + + message_parts = [":robot_face: *ROTA Release Reminder*\n"] + + if current_releases: + logger.debug( + f" - Adding current week section ({len(current_releases)} releases)" + ) + message_parts.append(":threadparrot: *This week release*\n") + message_parts.append( + format_release_message(current_releases, "This Week") + ) + + if next_releases: + logger.debug( + f" - Adding next week section ({len(next_releases)} releases)" + ) + message_parts.append("\n:threadparrot: *Next release*\n") + message_parts.append(format_release_message(next_releases, "Next Week")) + + if not current_releases and not next_releases: + logger.debug(" - No releases found, adding empty state message") + message_parts.append( + "No releases scheduled for this week or next week." + ) + + message = "\n".join(message_parts) + + else: # Thursday (or other configured days) + logger.debug( + " - Thursday detected: fetching current week releases (reminder)" + ) + current_releases = get_current_week_releases() + + message_parts = [":robot_face: *ROTA Release Reminder (Mid-Week)*\n"] + + if current_releases: + logger.debug( + f" - Adding current week section ({len(current_releases)} releases)" + ) + message_parts.append( + format_release_message(current_releases, "This Week") + ) + else: + logger.debug(" - No releases found, adding empty state message") + message_parts.append("No releases scheduled for this week.") + + message = "\n".join(message_parts) + + if config.ROTA_GROUP_CHANNEL: + logger.debug(f" - Sending message to channel: {config.ROTA_GROUP_CHANNEL}") + success = slack_client.send_message( + channel=config.ROTA_GROUP_CHANNEL, text=message + ) + + if success: + logger.info(" ✓ Group reminder sent successfully") + else: + logger.error(" ✗ Failed to send group reminder") + else: + logger.warning( + " ✗ ROTA_GROUP_CHANNEL not configured, skipping group reminder" + ) + + except Exception as e: + logger.error(f" ✗ Error in group reminder: {e}", exc_info=True) + raise + + +def send_dm_reminders(): + """ + Send DM reminders to individuals about their releases + Scheduled via SCHEDULE_ROTA_NOTIFICATIONS_DMS env var + """ + logger.info("Step 4b: Sending DM reminders to individuals") + + try: + today = datetime.now().date() + day_of_week = today.weekday() # 0 = Monday, 4 = Friday + logger.debug(f" - Current day: {today} (day_of_week={day_of_week})") + + if day_of_week == 0: # Monday + logger.debug(" - Monday detected: fetching current week releases for DM") + releases = get_current_week_releases() + week_label = "this week" + else: # Friday (or other scheduled days) + logger.debug(" - Friday detected: fetching NEXT week releases for DM") + releases = get_next_releases() + week_label = "next week" + + if not releases: + logger.info(f" ✗ No releases for {week_label}, no DMs to send") + return + + logger.debug(f" - Building notification list for {len(releases)} release(s)") + people_to_notify = {} + + for release in releases: + pm = release.get("pm") + if pm and pm != "TBD": + user_id = config.ROTA_USERS.get(pm) + if user_id: + if user_id not in people_to_notify: + people_to_notify[user_id] = {"name": pm, "assignments": []} + people_to_notify[user_id]["assignments"].append( + { + "role": "Patch Manager", + "version": release.get("version"), + "release": release, + } + ) + logger.debug(f" • Added {pm} (PM) for {release.get('version')}") + + qe = release.get("qe") + if qe and qe != "TBD": + user_id = config.ROTA_USERS.get(qe) + if user_id: + if user_id not in people_to_notify: + people_to_notify[user_id] = {"name": qe, "assignments": []} + people_to_notify[user_id]["assignments"].append( + { + "role": "QE", + "version": release.get("version"), + "release": release, + } + ) + logger.debug(f" • Added {qe} (QE) for {release.get('version')}") + + logger.debug(f" - Sending DMs to {len(people_to_notify)} people") + for user_id, user_info in people_to_notify.items(): + assignments = user_info.get("assignments", []) + name = user_info.get("name", "Sustain-er") + + message_parts = [f":robot_face: Hey {name}!\n"] + message_parts.append( + "You're on ROTA this week! Here's what you're sustaining:\n" + ) + + for assignment in assignments: + release = assignment["release"] + role = assignment["role"] + message_parts.append(f"Release *{release['version']}* - {role}\n") + + message_parts.append("Keep the builds running smoothly! :rocket:") + message_parts.append("You've got this! :mechanical_arm:") + + message = "\n".join(message_parts) + + logger.debug( + f" - Sending DM to {name} ({user_id}) with {len(assignments)} assignment(s)" + ) + success = slack_client.send_dm(user_id=user_id, text=message) + + if success: + logger.info(f" ✓ Sent DM reminder to {name} ({user_id})") + else: + logger.error(f" ✗ Failed to send DM reminder to {name} ({user_id})") + + logger.info(f" ✓ Completed DM reminders for {len(people_to_notify)} people") + except Exception as e: + logger.error(f" ✗ Error in DM reminder: {e}", exc_info=True) + raise + + +def send_rota_notifications(): + """ + Main ROTA notification job - combines group reminders and DM reminders + + Schedule: + - Monday 9 AM: Group reminder (current week + next week if found) + DM reminders (current week only) + - Thursday 9 AM: Group reminder only (same current week, mid-week recap) + - Friday 9 AM: DM reminders only (next week releases if they exist) + """ + logger.info("=" * 60) + logger.info("STARTING ROTA NOTIFICATION JOB") + logger.info("=" * 60) + + try: + today = datetime.now().date() + day_of_week = today.weekday() + logger.info(f"Job started at {datetime.now()}") + logger.info(f"Current date: {today}") + + if day_of_week == 0: # Monday + logger.info( + "Monday 9 AM: Sending group reminder (current + next week if found) and DM reminders (current week)" + ) + send_group_reminder() + send_dm_reminders() + + elif day_of_week == 3: # Thursday + logger.info("Thursday 9 AM: Sending group reminder (current week) only") + send_group_reminder() + + elif day_of_week == 4: # Friday + logger.info( + "Friday 9 AM: Sending DM reminders (next week) only - skips if no next week releases" + ) + send_dm_reminders() + + else: + logger.info(f"Day {day_of_week}: No notifications scheduled") + + logger.info("=" * 60) + logger.info("ROTA NOTIFICATION JOB COMPLETED SUCCESSFULLY") + logger.info("=" * 60) + + except Exception as e: + logger.error("=" * 60) + logger.error("ROTA NOTIFICATION JOB FAILED") + logger.error(f"Error: {e}", exc_info=True) + logger.error("=" * 60) + raise diff --git a/slack_worker/jobs/sync_releases.py b/slack_worker/jobs/sync_releases.py new file mode 100644 index 0000000..6d73732 --- /dev/null +++ b/slack_worker/jobs/sync_releases.py @@ -0,0 +1,155 @@ +""" +Smartsheet sync job - fetches releases from Smartsheet and writes to Google Sheets +Runs daily at 8 AM +""" + +import logging +import os +import re + +from slack_worker.config import config +from sdk.smartsheet import ( + fetch_sheet_by_id, + parse_sheet_releases, + filter_releases, + write_to_gsheet, +) + +logger = logging.getLogger(__name__) + + +def _load_sheet_ids(): + """Dynamically load OCP version sheet IDs from environment variables. + + Scans all environment variables for SMARTSHEET_SHEET_*_ID pattern. + Only includes versions that have actual IDs configured (ignores empty/commented vars). + + Pattern: SMARTSHEET_SHEET___ID + Examples: + SMARTSHEET_SHEET_4_12_ID=7020066991564676 → "4.12": "7020066991564676" + SMARTSHEET_SHEET_4_17_ID=... → "4.17": "..." + # SMARTSHEET_SHEET_4_21_ID=... → (skipped - commented) + + To add a new OCP version: + 1. Uncomment SMARTSHEET_SHEET_4_XX_ID in .env + 2. Add the sheet ID + 3. Job automatically discovers and syncs it + + Returns: + dict: Mapping of version strings to sheet IDs + Example: {"4.12": "7020066991564676", "4.13": "3756051883052932"} + """ + sheet_ids = {} + # Pattern: SMARTSHEET_SHEET_4_XX_ID + pattern = r"SMARTSHEET_SHEET_(\d+)_(\d+)_ID" + + for key, value in os.environ.items(): + match = re.match(pattern, key) + if match and value: + major = match.group(1) + minor = match.group(2) + version = f"{major}.{minor}" + sheet_ids[version] = value + + return sheet_ids + + +# Load sheet IDs dynamically from .env - add/remove versions without code changes +SHEET_IDS = _load_sheet_ids() + + +def sync_releases_to_gsheet(): + """ + Main sync job - fetches from Smartsheet and writes to Google Sheets + Runs daily at 8 AM UTC + """ + logger.info("=" * 60) + logger.info("STARTING RELEASES SYNC JOB") + logger.info("=" * 60) + + try: + from datetime import datetime + + logger.info(f"Job started at {datetime.now()}") + + smartsheet_token = config.SMARTSHEET_ACCESS_TOKEN + gsheet_creds = config.ROTA_SERVICE_ACCOUNT + logger.debug("Checking required environment variables") + + if not smartsheet_token: + logger.error(" ✗ SMARTSHEET_ACCESS_TOKEN not found in environment") + raise ValueError("SMARTSHEET_ACCESS_TOKEN not configured") + logger.debug(" ✓ SMARTSHEET_ACCESS_TOKEN found") + + if not gsheet_creds: + logger.error(" ✗ ROTA_SERVICE_ACCOUNT not found in environment") + raise ValueError("ROTA_SERVICE_ACCOUNT not configured") + logger.debug(" ✓ ROTA_SERVICE_ACCOUNT found") + + # Step 1: Fetch from Smartsheet + logger.info("Step 1: Fetching data from OCP 4.12-4.16 Smartsheet sheets") + all_releases = [] + fetch_count = 0 + + for short_version, sheet_id in SHEET_IDS.items(): + if not sheet_id: + logger.warning( + f" ⊘ No Smartsheet ID configured for OCP {short_version}, skipping" + ) + continue + + try: + logger.debug( + f" - Fetching OCP {short_version} (Sheet ID: {sheet_id[:8]}...)" + ) + sheet_data = fetch_sheet_by_id(sheet_id, smartsheet_token) + releases = parse_sheet_releases(sheet_data, short_version) + all_releases.extend(releases) + logger.info( + f" ✓ OCP {short_version}: {len(releases)} releases fetched" + ) + fetch_count += 1 + except Exception as e: + logger.error( + f" ✗ Error fetching OCP {short_version}: {e}", exc_info=True + ) + + logger.info( + f" ✓ Total releases fetched: {len(all_releases)} from {fetch_count} sheets" + ) + + # Step 2: Filter + logger.info( + "Step 2: Filtering releases by version, z-stream format, dev flag, and date range" + ) + logger.debug(f" - Input: {len(all_releases)} releases") + filtered = filter_releases(all_releases) + logger.info( + f" ✓ Filtered to {len(filtered)} releases (removed {len(all_releases) - len(filtered)})" + ) + + if filtered: + logger.debug(" - Filtered releases:") + for rel in filtered[:5]: # Log first 5 for debugging + logger.debug(f" • {rel['version']} - {rel['finish_date']}") + if len(filtered) > 5: + logger.debug(f" ... and {len(filtered) - 5} more") + + # Step 3: Write to Google Sheets + logger.info( + "Step 3: Writing releases to Google Sheets (ROTA -> Assignments worksheet)" + ) + logger.debug(f" - Preparing to write {len(filtered)} rows") + rows_written = write_to_gsheet(filtered, gsheet_creds) + logger.info(f" ✓ Successfully wrote {rows_written} releases to Google Sheets") + + logger.info("=" * 60) + logger.info("RELEASES SYNC JOB COMPLETED SUCCESSFULLY") + logger.info("=" * 60) + + except Exception as e: + logger.error("=" * 60) + logger.error("RELEASES SYNC JOB FAILED") + logger.error(f"Error: {e}", exc_info=True) + logger.error("=" * 60) + raise diff --git a/slack_worker/main.py b/slack_worker/main.py new file mode 100644 index 0000000..4035a77 --- /dev/null +++ b/slack_worker/main.py @@ -0,0 +1,118 @@ +""" +Main entry point for Slack Worker Service +Initializes and starts the job scheduler with configured jobs +""" + +import logging +import sys +from pathlib import Path + +# Add parent directory to Python path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from slack_worker.config import config +from slack_worker.jobs import ( + send_group_reminder, + send_dm_reminders, + sync_releases_to_gsheet, +) +from slack_worker.scheduler import JobScheduler + +logger = logging.getLogger(__name__) + + +def setup_jobs(scheduler: JobScheduler): + """ + Set up all scheduled jobs + + Args: + scheduler: JobScheduler instance + """ + logger.info("Setting up scheduled jobs...") + + # 1. Sync releases job (runs first - before notifications) + if config.SCHEDULE_ROTA_SHEET_SYNC: + scheduler.add_cron_job( + func=sync_releases_to_gsheet, + job_id="sync_releases_to_gsheet", + cron_expression=config.SCHEDULE_ROTA_SHEET_SYNC, + use_lock=True, + ) + logger.info(f"Enabled: Sync releases job ({config.SCHEDULE_ROTA_SHEET_SYNC})") + else: + logger.info("Disabled: Sync releases job (empty schedule)") + + # 2. Group channel notifications job + if config.SCHEDULE_ROTA_NOTIFICATIONS_GROUP_CHANNEL: + scheduler.add_cron_job( + func=send_group_reminder, + job_id="send_rota_notifications_group_channel", + cron_expression=config.SCHEDULE_ROTA_NOTIFICATIONS_GROUP_CHANNEL, + use_lock=True, + ) + logger.info( + f"Enabled: ROTA group channel notifications job ({config.SCHEDULE_ROTA_NOTIFICATIONS_GROUP_CHANNEL})" + ) + else: + logger.info("Disabled: ROTA group channel notifications job (empty schedule)") + + # 3. DM notifications job + if config.SCHEDULE_ROTA_NOTIFICATIONS_DMS: + scheduler.add_cron_job( + func=send_dm_reminders, + job_id="send_rota_notifications_dms", + cron_expression=config.SCHEDULE_ROTA_NOTIFICATIONS_DMS, + use_lock=True, + ) + logger.info( + f"Enabled: ROTA DM notifications job ({config.SCHEDULE_ROTA_NOTIFICATIONS_DMS})" + ) + else: + logger.info("Disabled: ROTA DM notifications job (empty schedule)") + + logger.info( + f"Job setup complete. Total jobs scheduled: {len(scheduler.scheduler.get_jobs())}" + ) + + +def main(): + """Main entry point""" + logger.info("=" * 60) + logger.info("Starting Slack Worker Service") + logger.info("=" * 60) + + try: + # Create lock directory if it doesn't exist + lock_dir = Path(config.LOCK_DIR) + lock_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Lock directory: {lock_dir}") + + # Initialize scheduler + logger.info(f"Initializing scheduler (timezone: {config.TIMEZONE})...") + scheduler = JobScheduler(timezone=config.TIMEZONE) + + # Set up jobs + setup_jobs(scheduler) + + # List all scheduled jobs + logger.info("Scheduled jobs:") + scheduler.list_jobs() + + # Start scheduler (blocking) + logger.info("=" * 60) + logger.info("Slack Worker Service is running") + logger.info("Press Ctrl+C to stop") + logger.info("=" * 60) + scheduler.start() + + except KeyboardInterrupt: + logger.info("\nReceived shutdown signal") + except Exception as e: + logger.error(f"Fatal error in main: {e}", exc_info=True) + sys.exit(1) + finally: + logger.info("Slack Worker Service stopped") + + +if __name__ == "__main__": + main() diff --git a/slack_worker/requirements.txt b/slack_worker/requirements.txt new file mode 100644 index 0000000..16e12d5 --- /dev/null +++ b/slack_worker/requirements.txt @@ -0,0 +1,27 @@ +# Slack Worker Service Requirements + +# Base dependencies (needed at runtime, originally from root requirements.txt) +httpx==0.28.1 +python-dotenv==1.1.0 +dynaconf[vault]==3.2.11 +gspread==6.2.1 +slack-sdk==3.35.0 +requests>=2.28.0 +python-dateutil>=2.8.2 + +# Service-specific packages + +# APScheduler for job scheduling +APScheduler==3.10.4 + +# Smartsheet SDK for reading data +smartsheet-python-sdk==3.0.3 + +# Timezone support +pytz==2024.1 + +# Testing extensions (pytest is in root) +pytest-cov==6.0.0 +pytest-mock==3.14.0 + + diff --git a/slack_worker/scheduler.py b/slack_worker/scheduler.py new file mode 100644 index 0000000..8e316fe --- /dev/null +++ b/slack_worker/scheduler.py @@ -0,0 +1,302 @@ +""" +Job Scheduler with APScheduler and file-based locking for horizontal scaling +""" + +import fcntl +import logging +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Callable + +import pytz +from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.cron import CronTrigger + +from .config import config + +logger = logging.getLogger(__name__) + + +class FileLock: + """ + File-based lock for preventing duplicate job execution in horizontally scaled environments. + Uses flock for advisory locking across processes/containers via shared PVC. + """ + + def __init__(self, lock_name: str, timeout: int = None): + """ + Initialize file lock + + Args: + lock_name: Name of the lock (used for lock file name) + timeout: Lock timeout in seconds + """ + self.lock_name = lock_name + self.timeout = timeout or config.LOCK_TIMEOUT + self.lock_dir = Path(config.LOCK_DIR) + self.lock_file_path = self.lock_dir / f"{lock_name}.lock" + self.lock_file = None + + # Create lock directory if it doesn't exist + self.lock_dir.mkdir(parents=True, exist_ok=True) + + def __enter__(self): + """Acquire lock""" + try: + self.lock_file = open(self.lock_file_path, "w") + + # Try to acquire lock with timeout + start_time = time.time() + while True: + try: + # Non-blocking exclusive lock + fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + # Write PID and timestamp to lock file + self.lock_file.write(f"PID: {os.getpid()}\n") + self.lock_file.write(f"Acquired: {datetime.now().isoformat()}\n") + self.lock_file.flush() + + logger.debug(f"Acquired lock: {self.lock_name}") + return self + + except BlockingIOError: + # Lock is held by another process + if time.time() - start_time > self.timeout: + raise TimeoutError( + f"Could not acquire lock {self.lock_name} within {self.timeout} seconds" + ) + time.sleep(0.1) # Wait a bit before retrying + + except Exception as e: + if self.lock_file: + self.lock_file.close() + raise e + + def __exit__(self, exc_type, exc_val, exc_tb): + """Release lock""" + try: + if self.lock_file: + fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN) + self.lock_file.close() + logger.debug(f"Released lock: {self.lock_name}") + except Exception as e: + logger.error(f"Error releasing lock {self.lock_name}: {e}") + + return False + + +def with_lock(lock_name: str): + """ + Decorator to wrap a job function with file locking + + Args: + lock_name: Name of the lock + """ + + def decorator(func: Callable): + def wrapper(*args, **kwargs): + try: + with FileLock(lock_name): + logger.info(f"Executing job: {func.__name__}") + result = func(*args, **kwargs) + logger.info(f"Completed job: {func.__name__}") + return result + except TimeoutError as e: + logger.warning( + f"Job {func.__name__} skipped - another instance is running: {e}" + ) + return None + except Exception as e: + logger.error(f"Error in job {func.__name__}: {e}", exc_info=True) + raise + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper + + return decorator + + +class JobScheduler: + """ + Job scheduler with APScheduler and file-based locking support + """ + + def __init__(self, timezone: str = None): + """ + Initialize job scheduler + + Args: + timezone: Timezone for scheduling (default from config) + """ + self.timezone = timezone or config.TIMEZONE + self.scheduler = BlockingScheduler(timezone=self.timezone) + + # Add event listeners + self.scheduler.add_listener(self._job_executed, EVENT_JOB_EXECUTED) + self.scheduler.add_listener(self._job_error, EVENT_JOB_ERROR) + + logger.info(f"Initialized job scheduler with timezone: {self.timezone}") + + def _job_executed(self, event): + """Event listener for successful job execution""" + logger.info(f"Job {event.job_id} executed successfully") + + def _job_error(self, event): + """Event listener for job errors""" + logger.error(f"Job {event.job_id} raised an exception: {event.exception}") + + def add_cron_job( + self, + func: Callable, + job_id: str, + cron_expression: str, + use_lock: bool = True, + **kwargs, + ): + """ + Add a cron job to the scheduler + + Args: + func: Function to execute + job_id: Unique job identifier + cron_expression: Cron expression (e.g., '0 9 * * MON,THU') + use_lock: Whether to use file locking (for horizontal scaling) + **kwargs: Additional arguments to pass to the job + """ + # Wrap function with lock if needed + if use_lock: + func = with_lock(f"job_{job_id}")(func) + + # Parse cron expression + # Format: minute hour day month day_of_week + parts = cron_expression.split() + if len(parts) != 5: + raise ValueError(f"Invalid cron expression: {cron_expression}") + + minute, hour, day, month, day_of_week = parts + + trigger = CronTrigger( + minute=minute, + hour=hour, + day=day, + month=month, + day_of_week=day_of_week, + timezone=self.timezone, + ) + + self.scheduler.add_job( + func, + trigger=trigger, + id=job_id, + name=func.__name__, + kwargs=kwargs, + replace_existing=True, + max_instances=1, # Prevent concurrent execution of same job + ) + + logger.info( + f"Added cron job: {job_id} ({func.__name__}) " + f"with schedule: {cron_expression} (lock: {use_lock})" + ) + + def add_interval_job( + self, + func: Callable, + job_id: str, + seconds: int = None, + minutes: int = None, + hours: int = None, + use_lock: bool = True, + **kwargs, + ): + """ + Add an interval-based job to the scheduler + + Args: + func: Function to execute + job_id: Unique job identifier + seconds: Interval in seconds + minutes: Interval in minutes + hours: Interval in hours + use_lock: Whether to use file locking + **kwargs: Additional arguments to pass to the job + """ + if use_lock: + func = with_lock(f"job_{job_id}")(func) + + # Build interval kwargs, excluding None values + interval_kwargs = {} + if seconds is not None: + interval_kwargs["seconds"] = seconds + if minutes is not None: + interval_kwargs["minutes"] = minutes + if hours is not None: + interval_kwargs["hours"] = hours + + self.scheduler.add_job( + func, + "interval", + **interval_kwargs, + id=job_id, + name=func.__name__, + kwargs=kwargs, + replace_existing=True, + max_instances=1, + ) + + interval_str = ( + f"{seconds}s" if seconds else f"{minutes}m" if minutes else f"{hours}h" + ) + logger.info( + f"Added interval job: {job_id} ({func.__name__}) " + f"with interval: {interval_str} (lock: {use_lock})" + ) + + def start(self): + """Start the scheduler""" + logger.info("Starting job scheduler...") + logger.info(f"Scheduled jobs: {len(self.scheduler.get_jobs())}") + + for job in self.scheduler.get_jobs(): + # Get next run time from trigger if available + next_run = getattr( + job, "next_run_time", None + ) or job.trigger.get_next_fire_time(None, datetime.now(pytz.UTC)) + logger.info(f" - {job.id}: {job.name} (next run: {next_run})") + + try: + self.scheduler.start() + except (KeyboardInterrupt, SystemExit): + logger.info("Scheduler stopped by user") + self.shutdown() + + def shutdown(self): + """Shutdown the scheduler""" + logger.info("Shutting down job scheduler...") + self.scheduler.shutdown(wait=True) + logger.info("Job scheduler stopped") + + def list_jobs(self): + """List all scheduled jobs""" + jobs = self.scheduler.get_jobs() + if not jobs: + logger.info("No jobs scheduled") + return [] + + job_list = [] + for job in jobs: + job_info = { + "id": job.id, + "name": job.name, + "next_run": getattr(job, "next_run_time", None), + "trigger": str(job.trigger), + } + job_list.append(job_info) + logger.info(f"Job: {job_info}") + + return job_list diff --git a/slack_worker/slack_client.py b/slack_worker/slack_client.py new file mode 100644 index 0000000..5a57700 --- /dev/null +++ b/slack_worker/slack_client.py @@ -0,0 +1,125 @@ +""" +Slack client for sending messages +""" + +import logging + +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from .config import config + +logger = logging.getLogger(__name__) + + +class SlackClient: + """Wrapper for Slack Web API client""" + + def __init__(self, token: str = None): + """ + Initialize Slack client + + Args: + token: Slack bot token (defaults to config) + """ + self.token = token or config.SLACK_BOT_TOKEN + self.client = WebClient(token=self.token) + + def send_message(self, channel: str, text: str = None, blocks: list = None) -> bool: + """ + Send a message to a channel or user + + Args: + channel: Channel ID or user ID + text: Plain text message + blocks: Slack blocks for rich formatting + + Returns: + bool: True if successful, False otherwise + """ + try: + response = self.client.chat_postMessage( + channel=channel, text=text, blocks=blocks + ) + + if response["ok"]: + logger.info(f"Message sent successfully to {channel}") + return True + else: + logger.error(f"Failed to send message to {channel}: {response}") + return False + + except SlackApiError as e: + logger.error( + f"Slack API error sending message to {channel}: {e.response['error']}" + ) + return False + except Exception as e: + logger.error(f"Unexpected error sending message to {channel}: {e}") + return False + + def send_dm(self, user_id: str, text: str = None, blocks: list = None) -> bool: + """ + Send a direct message to a user + + Args: + user_id: Slack user ID + text: Plain text message + blocks: Slack blocks for rich formatting + + Returns: + bool: True if successful, False otherwise + """ + try: + # Open a DM channel with the user + response = self.client.conversations_open(users=[user_id]) + + if not response["ok"]: + logger.error(f"Failed to open DM channel with {user_id}") + return False + + channel_id = response["channel"]["id"] + + # Send message to the DM channel + return self.send_message(channel_id, text=text, blocks=blocks) + + except SlackApiError as e: + logger.error( + f"Slack API error sending DM to {user_id}: {e.response['error']}" + ) + return False + except Exception as e: + logger.error(f"Unexpected error sending DM to {user_id}: {e}") + return False + + def get_user_info(self, user_id: str) -> dict: + """ + Get user information + + Args: + user_id: Slack user ID + + Returns: + dict: User information + """ + try: + response = self.client.users_info(user=user_id) + + if response["ok"]: + return response["user"] + else: + logger.error(f"Failed to get user info for {user_id}") + return {} + + except SlackApiError as e: + logger.error( + f"Slack API error getting user info for {user_id}: {e.response['error']}" + ) + return {} + except Exception as e: + logger.error(f"Unexpected error getting user info for {user_id}: {e}") + return {} + + +# Global slack client instance +slack_client = SlackClient() diff --git a/slack_worker/tests/__init__.py b/slack_worker/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/slack_worker/tests/conftest.py b/slack_worker/tests/conftest.py new file mode 100644 index 0000000..51bad9b --- /dev/null +++ b/slack_worker/tests/conftest.py @@ -0,0 +1,46 @@ +import json +import os + +# Set env vars at module level so they're available during collection, +# before test files import slack_worker.config (which validates at import time). + +# slack_worker/config.py required keys +os.environ["SLACK_BOT_TOKEN"] = "xoxb-test-token-12345" +os.environ["ROTA_USERS"] = "user1,user2,user3" +os.environ["ROTA_ADMINS"] = "admin1,admin2" +os.environ["ROTA_SERVICE_ACCOUNT"] = json.dumps( + { + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS8JJcds3xfn/J7WdhPsHmN4i6QQZH7dKZGhYgCRKt/+p\nWVPPsLdVKXvSahdvE4kh7vbKgX5vTi9EF2n5qQIDAQABAoIBAEgxBXBkIVa0Hx3Z\nI7GWA7AqD8v3CyKBvVgGlTGQ5OJaQlvB5vKDl9qYsKBvR7mXAhDl8R6bEkS1K3fS\n-----END RSA PRIVATE KEY-----", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } +) +os.environ["LOCK_DIR"] = "/tmp/test_locks" +os.environ["LOCK_TIMEOUT"] = "10" +os.environ["TIMEZONE"] = "UTC" + +# Root config.py required keys (needed because slack_worker.jobs transitively +# imports sdk.gsheet.gsheet which imports the root config module). +os.environ["GOOGLE_CLOUD_CREDS"] = json.dumps( + {"type": "service_account", "project_id": "test"} +) +os.environ["SLACK_APP_TOKEN"] = "xapp-test-token" +os.environ["AWS_ACCESS_KEY_ID"] = "REDACTED" +os.environ["AWS_SECRET_ACCESS_KEY"] = "REDACTED" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" +os.environ["OS_AUTH_URL"] = "https://auth.example.com/v3" +os.environ["OS_PROJECT_ID"] = "test-project-id" +os.environ["OS_INTERFACE"] = "public" +os.environ["OS_ID_API_VERSION"] = "3" +os.environ["OS_REGION_NAME"] = "regionOne" +os.environ["OS_APP_CRED_ID"] = "test-cred-id" +os.environ["OS_APP_CRED_SECRET"] = "test-cred-secret" +os.environ["OS_AUTH_TYPE"] = "v3applicationcredential" +os.environ["ALLOW_ALL_WORKSPACE_USERS"] = "false" +os.environ["ALLOWED_SLACK_USERS"] = "false" +os.environ["AWS_AMI_MAP"] = '{"linux": "ami-dummy"}' diff --git a/slack_worker/tests/test_slack_worker_core.py b/slack_worker/tests/test_slack_worker_core.py new file mode 100644 index 0000000..f3a8781 --- /dev/null +++ b/slack_worker/tests/test_slack_worker_core.py @@ -0,0 +1,95 @@ +""" +Core tests for slack_worker - Validates health_check.py checks +""" + +import tempfile +from pathlib import Path + +from slack_worker.config import config +from slack_worker.scheduler import JobScheduler + + +# ============================================================================ +# 1. CONFIG VALIDATION TESTS +# ============================================================================ + + +class TestConfigValidation: + """Test config loads and has all required keys (from check_config)""" + + def test_config_loads_successfully(self): + """Config module loads without errors""" + assert config is not None + + def test_all_required_env_vars_present(self): + """All 7 required environment variables are set""" + required_keys = [ + "SLACK_BOT_TOKEN", + "ROTA_SERVICE_ACCOUNT", + "ROTA_USERS", + "ROTA_ADMINS", + "LOCK_DIR", + "LOCK_TIMEOUT", + "TIMEZONE", + ] + + for key in required_keys: + assert hasattr(config, key), f"Missing config key: {key}" + value = getattr(config, key) + assert value is not None, f"Config key {key} is None" + + +# ============================================================================ +# 2. LOCK DIRECTORY TESTS +# ============================================================================ + + +class TestLockDirectory: + """Test lock directory can be created (from check_lock_dir)""" + + def test_lock_dir_can_be_created_and_accessed(self): + """Lock directory can be created at configured path""" + with tempfile.TemporaryDirectory() as tmpdir: + lock_dir = Path(tmpdir) / "locks" + lock_dir.mkdir(parents=True, exist_ok=True) + assert lock_dir.exists(), "Lock directory was not created" + + +# ============================================================================ +# 3. JOB IMPORTS TESTS +# ============================================================================ + + +class TestJobImports: + """Test all required job imports (from check_imports)""" + + def test_required_job_functions_importable(self): + """All required job functions can be imported""" + from slack_worker.jobs import ( + send_group_reminder, + send_dm_reminders, + sync_releases_to_gsheet, + ) + + assert callable(send_group_reminder) + assert callable(send_dm_reminders) + assert callable(sync_releases_to_gsheet) + + +# ============================================================================ +# 4. SCHEDULER INITIALIZATION TESTS +# ============================================================================ + + +class TestSchedulerInitialization: + """Test JobScheduler can be initialized (from check_scheduler)""" + + def test_scheduler_initializes_with_timezone(self): + """JobScheduler initializes successfully with timezone""" + scheduler = JobScheduler(timezone=config.TIMEZONE) + assert scheduler is not None + + def test_scheduler_timezone_configuration(self): + """Scheduler accepts UTC timezone configuration""" + scheduler = JobScheduler(timezone="UTC") + assert scheduler is not None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f8ebfa6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +import os + +import pytest + +pytest_plugins = [ + "pytester", +] + + +@pytest.fixture(scope="session", autouse=True) +def setup_environment_variables(): + os.environ["SLACK_BOT_TOKEN"] = "SLACK_BOT_TOKEN" + os.environ["SLACK_APP_TOKEN"] = "SLACK_APP_TOKEN" + os.environ["AWS_ACCESS_KEY_ID"] = "AWS_ACCESS_KEY_ID" + os.environ["AWS_SECRET_ACCESS_KEY"] = "AWS_SECRET_ACCESS_KEY" + os.environ["AWS_DEFAULT_REGION"] = "AWS_DEFAULT_REGION" + os.environ["OS_AUTH_URL"] = "OS_AUTH_URL" + os.environ["OS_PROJECT_ID"] = "OS_PROJECT_ID" + os.environ["OS_INTERFACE"] = "OS_INTERFACE" + os.environ["OS_ID_API_VERSION"] = "OS_ID_API_VERSION" + os.environ["OS_REGION_NAME"] = "OS_REGION_NAME" + os.environ["OS_APP_CRED_ID"] = "OS_APP_CRED_ID" + os.environ["OS_APP_CRED_SECRET"] = "OS_APP_CRED_SECRET" + os.environ["OS_AUTH_TYPE"] = "v3applicationcredential" + os.environ["ALLOW_ALL_WORKSPACE_USERS"] = "false" + os.environ["ALLOWED_SLACK_USERS"] = "false" + os.environ["AWS_AMI_MAP"] = '{"linux": "ami-dummy"}' diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..1809c14 --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,486 @@ +import unittest.mock as mock +from unittest.mock import MagicMock + +from slack_handlers.handlers import handle_aws_modify_vm, handle_openstack_modify_vm + + +@mock.patch("slack_handlers.handlers.EC2Helper") +def test_handle_aws_modify_vm_stop_success(mock_ec2_helper): + """Test successful stop command.""" + mock_ec2 = MagicMock() + mock_ec2_helper.return_value = mock_ec2 + + mock_ec2.stop_instance.return_value = { + "success": True, + "instance_id": "i-1234567890", + "previous_state": "running", + "current_state": "stopping", + } + + mock_say = MagicMock() + + handle_aws_modify_vm( + say=mock_say, + region="us-east-1", + user="test-user", + params_dict={"stop": True, "vm-id": "i-1234567890"}, + ) + + mock_ec2_helper.assert_called_once_with(region="us-east-1") + + mock_ec2.stop_instance.assert_called_once_with("i-1234567890") + + calls = mock_say.call_args_list + assert len(calls) == 2 + assert "Attempting to stop" in calls[0][0][0] + assert "Successfully initiated stop" in calls[1][0][0] + assert "i-1234567890" in calls[1][0][0] + + +@mock.patch("slack_handlers.handlers.EC2Helper") +def test_handle_aws_modify_vm_delete_success(mock_ec2_helper): + """Test successful delete command.""" + mock_ec2 = MagicMock() + mock_ec2_helper.return_value = mock_ec2 + + mock_ec2.terminate_instance.return_value = { + "success": True, + "instance_id": "i-1234567890", + "instance_name": "test-instance", + "previous_state": "running", + "current_state": "shutting-down", + } + + mock_say = MagicMock() + + handle_aws_modify_vm( + say=mock_say, + region="us-east-1", + user="test-user", + params_dict={"delete": True, "vm-id": "i-1234567890"}, + ) + + mock_ec2_helper.assert_called_once_with(region="us-east-1") + + mock_ec2.terminate_instance.assert_called_once_with("i-1234567890") + + calls = mock_say.call_args_list + assert len(calls) == 2 + assert "Termination Warning" in calls[0][0][0] + assert "Successfully initiated termination" in calls[1][0][0] + assert "test-instance" in calls[1][0][0] + + +@mock.patch("slack_handlers.handlers.EC2Helper") +def test_handle_aws_modify_vm_missing_vm_id(mock_ec2_helper): + """Test handle_aws_modify_vm with missing vm-id parameter.""" + mock_say = MagicMock() + + handle_aws_modify_vm( + say=mock_say, + region="us-east-1", + user="test-user", + params_dict={"stop": True}, + ) + + mock_ec2_helper.assert_not_called() + + mock_say.assert_called_once() + assert "Missing required parameter" in mock_say.call_args[0][0] + assert "--vm-id" in mock_say.call_args[0][0] + + +@mock.patch("slack_handlers.handlers.EC2Helper") +def test_handle_aws_modify_vm_missing_action(mock_ec2_helper): + """Test handle_aws_modify_vm with missing action.""" + mock_say = MagicMock() + + handle_aws_modify_vm( + say=mock_say, + region="us-east-1", + user="test-user", + params_dict={"vm-id": "i-1234567890"}, + ) + + mock_ec2_helper.assert_not_called() + + mock_say.assert_called_once() + assert "must specify either" in mock_say.call_args[0][0] + assert "--stop" in mock_say.call_args[0][0] + assert "--delete" in mock_say.call_args[0][0] + + +@mock.patch("slack_handlers.handlers.EC2Helper") +def test_handle_aws_modify_vm_both_actions(mock_ec2_helper): + """Test handle_aws_modify_vm with both stop and delete actions specified.""" + mock_say = MagicMock() + + handle_aws_modify_vm( + say=mock_say, + region="us-east-1", + user="test-user", + params_dict={"stop": True, "delete": True, "vm-id": "i-1234567890"}, + ) + + mock_ec2_helper.assert_not_called() + + mock_say.assert_called_once() + assert "only one action" in mock_say.call_args[0][0] + assert "not both" in mock_say.call_args[0][0] + + +@mock.patch("slack_handlers.handlers.EC2Helper") +def test_handle_aws_modify_vm_stop_failure(mock_ec2_helper): + """Test handle_aws_modify_vm when stop operation fails.""" + mock_ec2 = MagicMock() + mock_ec2_helper.return_value = mock_ec2 + + mock_ec2.stop_instance.return_value = { + "success": False, + "error": "Instance i-1234567890 is already stopped", + } + + mock_say = MagicMock() + + handle_aws_modify_vm( + say=mock_say, + region="us-east-1", + user="test-user", + params_dict={"stop": True, "vm-id": "i-1234567890"}, + ) + + calls = mock_say.call_args_list + assert len(calls) == 2 + assert "Failed to stop instance" in calls[1][0][0] + + +@mock.patch("slack_handlers.handlers.EC2Helper") +@mock.patch("slack_handlers.handlers.logger") +def test_handle_aws_modify_vm_exception(mock_logger, mock_ec2_helper): + """Test handle_aws_modify_vm when an exception occurs.""" + mock_ec2_helper.side_effect = Exception("Connection error") + + mock_say = MagicMock() + + handle_aws_modify_vm( + say=mock_say, + region="us-east-1", + user="test-user", + params_dict={"stop": True, "vm-id": "i-1234567890"}, + ) + + mock_logger.error.assert_called() + + mock_say.assert_called_once() + assert "internal error occurred" in mock_say.call_args[0][0] + assert "contact the administrator" in mock_say.call_args[0][0] + + +# Tests for OpenStack VM lifecycle management handlers + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_stop_success(mock_openstack_helper): + """Test successful stop operation via handler.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + mock_helper_instance.stop_server.return_value = { + "success": True, + "server_id": "test-server-id", + "server_name": "test-server", + "previous_status": "ACTIVE", + "current_status": "stopping", + } + + params_dict = {"vm-id": "test-server-id", "stop": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_helper_instance.stop_server.assert_called_once_with("test-server-id") + assert mock_say.call_count == 2 + # Check progress message + progress_call = mock_say.call_args_list[0][0][0] + assert ":hourglass_flowing_sand:" in progress_call + assert "Attempting to stop server" in progress_call + # Check success message + result_call = mock_say.call_args_list[1][0][0] + assert ":white_check_mark:" in result_call + assert "Successfully initiated stop" in result_call + assert "test-server" in result_call + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_start_success(mock_openstack_helper): + """Test successful start operation via handler.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + mock_helper_instance.start_server.return_value = { + "success": True, + "server_id": "test-server-id", + "server_name": "test-server", + "previous_status": "SHUTOFF", + "current_status": "starting", + } + + params_dict = {"vm-id": "test-server-id", "start": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_helper_instance.start_server.assert_called_once_with("test-server-id") + assert mock_say.call_count == 2 + # Check progress message + progress_call = mock_say.call_args_list[0][0][0] + assert ":hourglass_flowing_sand:" in progress_call + assert "Attempting to start server" in progress_call + # Check success message + result_call = mock_say.call_args_list[1][0][0] + assert ":white_check_mark:" in result_call + assert "Successfully initiated start" in result_call + assert "test-server" in result_call + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_delete_success(mock_openstack_helper): + """Test successful delete operation via handler.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + mock_helper_instance.delete_server.return_value = { + "success": True, + "server_id": "test-server-id", + "server_name": "test-server", + "previous_status": "ACTIVE", + "current_status": "deleting", + } + + params_dict = {"vm-id": "test-server-id", "delete": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_helper_instance.delete_server.assert_called_once_with("test-server-id") + assert mock_say.call_count == 2 + # Check warning/progress message + warning_call = mock_say.call_args_list[0][0][0] + assert ":warning:" in warning_call + assert "Deletion Warning" in warning_call + assert "Proceeding with deletion" in warning_call + # Check success message + result_call = mock_say.call_args_list[1][0][0] + assert ":white_check_mark:" in result_call + assert "Successfully initiated deletion" in result_call + assert "test-server" in result_call + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_stop_failure(mock_openstack_helper): + """Test failed stop operation via handler.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + mock_helper_instance.stop_server.return_value = { + "success": False, + "error": "Server is already stopped (status: SHUTOFF)", + } + + params_dict = {"vm-id": "test-server-id", "stop": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_helper_instance.stop_server.assert_called_once_with("test-server-id") + assert mock_say.call_count == 2 + # Check progress message + progress_call = mock_say.call_args_list[0][0][0] + assert ":hourglass_flowing_sand:" in progress_call + assert "Attempting to stop server" in progress_call + # Check error message + error_call = mock_say.call_args_list[1][0][0] + assert ":x:" in error_call + assert "Failed to stop server" in error_call + assert "already stopped" in error_call + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_start_failure(mock_openstack_helper): + """Test failed start operation via handler.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + mock_helper_instance.start_server.return_value = { + "success": False, + "error": "Server is already running (status: ACTIVE)", + } + + params_dict = {"vm-id": "test-server-id", "start": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_helper_instance.start_server.assert_called_once_with("test-server-id") + assert mock_say.call_count == 2 + # Check progress message + progress_call = mock_say.call_args_list[0][0][0] + assert ":hourglass_flowing_sand:" in progress_call + assert "Attempting to start server" in progress_call + # Check error message + error_call = mock_say.call_args_list[1][0][0] + assert ":x:" in error_call + assert "Failed to start server" in error_call + assert "already running" in error_call + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_delete_failure(mock_openstack_helper): + """Test failed delete operation via handler.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + mock_helper_instance.delete_server.return_value = { + "success": False, + "error": "Server with ID 'test-server-id' not found", + } + + params_dict = {"vm-id": "test-server-id", "delete": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_helper_instance.delete_server.assert_called_once_with("test-server-id") + assert mock_say.call_count == 2 + # Check warning/progress message + warning_call = mock_say.call_args_list[0][0][0] + assert ":warning:" in warning_call + assert "Deletion Warning" in warning_call + # Check error message + error_call = mock_say.call_args_list[1][0][0] + assert ":x:" in error_call + assert "Failed to delete server" in error_call + assert "not found" in error_call + + +def test_handle_openstack_modify_vm_missing_vm_id(): + """Test handler with missing vm-id parameter.""" + mock_say = mock.MagicMock() + + params_dict = {"stop": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_say.assert_called_once() + call_args = mock_say.call_args[0][0] + assert ":warning:" in call_args + assert "Missing required parameter" in call_args + assert "--vm-id" in call_args + + +def test_handle_openstack_modify_vm_no_action(): + """Test handler with no action specified.""" + mock_say = mock.MagicMock() + + params_dict = {"vm-id": "test-server-id"} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_say.assert_called_once() + call_args = mock_say.call_args[0][0] + assert ":warning:" in call_args + assert "You must specify one action" in call_args + assert "--stop" in call_args and "--start" in call_args and "--delete" in call_args + + +def test_handle_openstack_modify_vm_multiple_actions(): + """Test handler with multiple actions specified.""" + mock_say = mock.MagicMock() + + params_dict = {"vm-id": "test-server-id", "stop": True, "start": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_say.assert_called_once() + call_args = mock_say.call_args[0][0] + assert ":warning:" in call_args + assert "Please specify only one action at a time" in call_args + assert "--stop" in call_args and "--start" in call_args and "--delete" in call_args + + +def test_handle_openstack_modify_vm_all_three_actions(): + """Test handler with all three actions specified.""" + mock_say = mock.MagicMock() + + params_dict = { + "vm-id": "test-server-id", + "stop": True, + "start": True, + "delete": True, + } + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + mock_say.assert_called_once() + call_args = mock_say.call_args[0][0] + assert ":warning:" in call_args + assert "Please specify only one action at a time" in call_args + assert "--stop" in call_args and "--start" in call_args and "--delete" in call_args + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_exception_handling(mock_openstack_helper): + """Test handler with unexpected exception.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + # Simulate an exception during the operation + mock_helper_instance.stop_server.side_effect = Exception("Unexpected error") + + params_dict = {"vm-id": "test-server-id", "stop": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + assert mock_say.call_count == 2 + # Check progress message + progress_call = mock_say.call_args_list[0][0][0] + assert ":hourglass_flowing_sand:" in progress_call + assert "Attempting to stop server" in progress_call + # Check error message + error_call = mock_say.call_args_list[1][0][0] + assert ":x:" in error_call + assert "An internal error occurred" in error_call + + +@mock.patch("slack_handlers.handlers.OpenStackHelper") +def test_handle_openstack_modify_vm_with_detailed_response(mock_openstack_helper): + """Test handler response formatting with detailed server information.""" + mock_say = mock.MagicMock() + mock_helper_instance = mock.MagicMock() + mock_openstack_helper.return_value = mock_helper_instance + + mock_helper_instance.stop_server.return_value = { + "success": True, + "server_id": "abc123-def456-ghi789", + "server_name": "production-web-server-01", + "previous_status": "ACTIVE", + "current_status": "stopping", + } + + params_dict = {"vm-id": "abc123-def456-ghi789", "stop": True} + + handle_openstack_modify_vm(mock_say, "test_user", params_dict) + + assert mock_say.call_count == 2 + # Check progress message + progress_call = mock_say.call_args_list[0][0][0] + assert ":hourglass_flowing_sand:" in progress_call + assert "abc123-def456-ghi789" in progress_call + # Check success message with detailed information + result_call = mock_say.call_args_list[1][0][0] + assert ":white_check_mark:" in result_call + assert "production-web-server-01" in result_call + assert "abc123-def456-ghi789" in result_call + assert "ACTIVE" in result_call + assert "stopping" in result_call diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..746c14d --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,47 @@ +from pytest import Pytester +from unittest.mock import Mock +import sys + +# Mock config module so that no connections are made at import time leading to exceptions +sys.modules["config"] = Mock() +sys.modules["gspread"] = Mock() + +# fmt: off +from config import config # noqa +config.ALLOWED_SLACK_USERS = {"test_user": "U123456"} +config.GCP_POPULAR_INSTANCE_TYPES = [ + "e2-micro", + "e2-medium", + "n2-standard-4", +] +config.GCP_BOOT_DISK_SIZE_GB = 10 +config.GCP_DEFAULT_INSTANCE_TYPE = "e2-medium" +# fmt: on + + +class TestRunner(object): + def test_handlers(self, pytester: Pytester) -> None: + """Test the slack handlers.""" + pytester.copy_example("tests/test_handlers.py") + result = pytester.runpytest() + outcomes = result.parseoutcomes() + assert "failed" not in outcomes.keys(), ( + f"{outcomes['failed']} unit tests failed." + ) + assert "errors" not in outcomes.keys(), ( + f"{outcomes['errors']} unit tests have errors." + ) + assert "passed" in outcomes.keys(), "No tests passed." + + def test_slack_commands(self, pytester: Pytester) -> None: + """Test the slack commands.""" + pytester.copy_example("tests/test_slack_commands.py") + result = pytester.runpytest() + outcomes = result.parseoutcomes() + assert "failed" not in outcomes.keys(), ( + f"{outcomes['failed']} unit tests failed." + ) + assert "errors" not in outcomes.keys(), ( + f"{outcomes['errors']} unit tests have errors." + ) + assert "passed" in outcomes.keys(), "No tests passed." diff --git a/tests/test_slack_commands.py b/tests/test_slack_commands.py new file mode 100644 index 0000000..e3cef91 --- /dev/null +++ b/tests/test_slack_commands.py @@ -0,0 +1,161 @@ +import os +from unittest.mock import MagicMock, patch + +os.environ["SLACK_BOT_TOKEN"] = "fake-token-for-testing" +os.environ["SLACK_APP_TOKEN"] = "fake-token-for-testing" + +import sys + +with patch("slack_sdk.web.client.WebClient.auth_test", return_value={"ok": True}): + if "slack_main" in sys.modules: + del sys.modules["slack_main"] + from slack_main import mention_handler + + +class MockCommand: + """Helper class to create reusable mock setups for commands""" + + @staticmethod + def setup_mocks(): + """Set up all necessary mocks for mention_handler testing - only command handlers""" + return { + "handle_create_openstack_vm": patch( + "slack_main.handle_create_openstack_vm" + ), + "handle_list_openstack_vms": patch("slack_main.handle_list_openstack_vms"), + "handle_hello": patch("slack_main.handle_hello"), + "handle_create_aws_vm": patch("slack_main.handle_create_aws_vm"), + "handle_list_aws_vms": patch("slack_main.handle_list_aws_vms"), + "handle_aws_modify_vm": patch("slack_main.handle_aws_modify_vm"), + "handle_list_team_links": patch("slack_main.handle_list_team_links"), + "handle_help_command": patch("slack_main.handle_help_command"), + } + + @staticmethod + def create_mock_body(text="hello", user="U123456"): + """Create a mock Slack body event""" + return {"event": {"user": user, "text": text}} + + +class TestSlackCommands: + """Test class for the slack commands""" + + def setup_method(self): + self.mock_say = MagicMock() + self.mocks = MockCommand.setup_mocks() + + for mock_name, mock_patch in self.mocks.items(): + setattr(self, f"mock_{mock_name}", mock_patch.start()) + + def teardown_method(self): + for mock_patch in self.mocks.values(): + mock_patch.stop() + + def call_mention_handler(self, command_text, user="U123456"): + """Helper method to create body and call mention_handler""" + body = MockCommand.create_mock_body(command_text, user) + mention_handler(body, self.mock_say) + + def test_hello_command_success(self): + """Test successful hello command execution""" + self.call_mention_handler("hello") + + self.mock_handle_hello.assert_called_once() + + def test_aws_vm_create_command_success(self): + """Test successful AWS VM create command execution""" + self.call_mention_handler( + "@bot aws vm create --os_name=linux --instance_type=t2.micro --key_pair=new" + ) + + self.mock_handle_create_aws_vm.assert_called_once() + + def test_openstack_vm_create_command_success(self): + """Test successful OpenStack VM create command execution""" + self.call_mention_handler( + "@bot openstack vm create --name=test --os_name=fedora --flavor=ci.cpu.small --key_pair=new" + ) + + self.mock_handle_create_openstack_vm.assert_called_once() + + def test_invalid_command(self): + """Test invalid command input""" + self.call_mention_handler("") + + self.mock_say.assert_called_once() + call_args = self.mock_say.call_args[0][0] + assert "couldn't understand" in call_args + assert "help" in call_args + + def test_unknown_command(self): + """Test unknown command handling""" + self.call_mention_handler("@bot unknown param") + + self.mock_say.assert_called_once() + call_args = self.mock_say.call_args[0][0] + assert "couldn't understand" in call_args + + def test_help_flag_command(self): + """Test help flag handling""" + self.call_mention_handler("@bot aws vm create --help") + + self.mock_handle_help_command.assert_called_once() + + def test_aws_vm_modify_with_multiple_parameters(self): + """Test AWS VM modify command with multiple parameters""" + self.call_mention_handler("@bot aws vm modify --stop --vm-id=i-123456") + + self.mock_handle_aws_modify_vm.assert_called_once() + + def test_aws_vm_list_with_filters(self): + """Test AWS VM list command with filter parameters""" + self.call_mention_handler( + "@bot aws vm list --state=running,stopped --type=t2.micro" + ) + + self.mock_handle_list_aws_vms.assert_called_once() + + def test_openstack_vm_list_with_status(self): + """Test OpenStack VM list command with status filter""" + self.call_mention_handler("@bot openstack vm list --status=ACTIVE") + + self.mock_handle_list_openstack_vms.assert_called_once() + + def test_project_links_list_command(self): + """Test project links list command""" + self.call_mention_handler("@bot project links list") + + self.mock_handle_list_team_links.assert_called_once_with( + self.mock_say, "U123456" + ) + + def test_command_with_nonexistent_parameters(self): + """Test command with non-existent parameters""" + self.call_mention_handler( + "@bot aws vm create --invalid_param=value --os_name=linux" + ) + + self.mock_handle_create_aws_vm.assert_called_once() + + def test_command_with_typo(self): + """Test command with a typo""" + self.call_mention_handler( + "aws vm creaate --invalid_param=value --os_name=linux" + ) + + self.mock_handle_create_aws_vm.assert_not_called() + self.mock_say.assert_called_once() + call_args = self.mock_say.call_args[0][0] + assert "couldn't understand" in call_args + + def test_empty_body_event(self): + """Test handling of empty or malformed body event""" + body = {} + + mention_handler(body, self.mock_say) + + def test_missing_event_data(self): + """Test handling of missing event data""" + body = {"event": {}} + + mention_handler(body, self.mock_say) diff --git a/vars b/vars deleted file mode 100644 index 6cbcaec..0000000 --- a/vars +++ /dev/null @@ -1,20 +0,0 @@ -# AWS Credentials -AWS_ACCESS_KEY_ID="" -AWS_SECRET_ACCESS_KEY="" -AWS_DEFAULT_REGION="" - -# OpenStack Credentials -OS_AUTH_URL="" -OS_PROJECT_ID="" -OS_INTERFACE="" -OS_ID_API_VERSION="" -OS_REGION_NAME="" -OS_APP_CRED_ID="" -OS_APP_CRED_SECRET="" -OS_AUTH_TYPE="" - - - -# Slack credentials -SLACK_BOT_TOKEN="" -SLACK_APP_TOKEN=""