diff --git a/.github/workflows/create-slackbot-docker-image.yml b/.github/workflows/create-slackbot-docker-image.yml new file mode 100644 index 0000000..a20a599 --- /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:latest 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/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/.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..47d55da 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ This is a Slack bot built to help users interact with AWS and OpenStack cloud re ## 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. ## Technologies @@ -18,7 +21,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 +41,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 @@ -74,16 +77,66 @@ python slack_main.py ``` ## Slack Commands -**/create-aws-cluster ** +**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. -**/hello** +**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 + +``` +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 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 +``` + + +**/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. + + +**openstack vm list ** +Lists OpenStack VMs. + +Sample usage: +``` +openstack vm list -status=ACTIVE +openstack vm list -status=ERROR +``` + +**hello** Greets the user with a friendly message. -**/help** +**help** Lists the available commands and how to use them. ## Event Handling @@ -111,10 +164,10 @@ 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 @@ -123,9 +176,26 @@ 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/ocp/__init__.py b/api/app/__init__.py similarity index 100% rename from ocp/__init__.py rename to api/app/__init__.py diff --git a/azure/core.py b/api/app/core/config.py similarity index 100% rename from azure/core.py rename to api/app/core/config.py diff --git a/citools/jenkins.py b/api/app/main.py similarity index 100% rename from citools/jenkins.py rename to api/app/main.py diff --git a/citools/prow.py b/api/app/routes/routes.py similarity index 100% rename from citools/prow.py rename to api/app/routes/routes.py diff --git a/gcp/core.py b/api/pyproject.toml similarity index 100% rename from gcp/core.py rename to api/pyproject.toml diff --git a/jira/core.py b/api/tests/tests.py similarity index 100% rename from jira/core.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..2c214db 100644 --- a/config.py +++ b/config.py @@ -1,51 +1,97 @@ +import logging import os from dotenv import load_dotenv +from dynaconf import Dynaconf +import tempfile +import json +import httpx +import hvac + +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", + "ALLOW_ALL_WORKSPACE_USERS", + "ALLOWED_SLACK_USERS", + "ROTA_SERVICE_ACCOUNT", + "ROTA_ADMINS", + "ROTA_USERS", +] -# Load environment variables from a .env file 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) + +# 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 -class Config: - """ - Config class to load and validate environment variables. - - If any environment variable is missing or empty, a ValueError will be raised. - """ - - 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..c99361a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,10 @@ -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 \ No newline at end of file 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/ostack/__init__.py b/sdk/__init__.py similarity index 100% rename from ostack/__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/sdk/aws/s3.py b/sdk/aws/s3.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/azure/vm.py b/sdk/azure/vm.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/citools/jenkins.py b/sdk/citools/jenkins.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/citools/prow.py b/sdk/citools/prow.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/gcp/compute_engine.py b/sdk/gcp/compute_engine.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/gsheet/gsheet.py b/sdk/gsheet/gsheet.py new file mode 100644 index 0000000..fbca512 --- /dev/null +++ b/sdk/gsheet/gsheet.py @@ -0,0 +1,117 @@ +from 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): + rota_sheet = getattr(config, "ROTA_SHEET", "ROTA") + assignment_wsheet = getattr(config, "ASSIGNMENT_WSHEET", "Assignments") + + account = gspread.service_account_from_dict(token) + self._rota_sheet = account.open(rota_sheet) + self._assignment_wsheet = self._rota_sheet.worksheet(assignment_wsheet) + + def add_release( + self, + rel_ver: str, + s_date: date = None, + e_date: date = None, + pm: str = None, + qe1: str = None, + qe2: 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, qe1, qe2] + + 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_time(self, time_period: str) -> list: + time_period = time_period.title() # Google Sheet has title case + if time_period not in ("This Week", "Next Week"): + logger.error(f"Incorrect time period: {time_period}") + raise ValueError(f"Invalid `time_period`: {time_period}") + + values = self._assignment_wsheet.get_values("A:G") # only get relevant columns + + return [v for v in values if v[6] == time_period] + + 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", "qe1", "qe2"): + logger.error(f"Invalid value for replace column: {column}") + raise ValueError(f"Invalid value for replace column: {column}") + + column_letter_mapping = {"pm": "D", "qe1": "E", "qe2": "F"} + + 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/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..a1b0a57 --- /dev/null +++ b/sdk/tests/conftest.py @@ -0,0 +1,45 @@ +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["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..efa2eb3 --- /dev/null +++ b/sdk/tools/help_system.py @@ -0,0 +1,408 @@ +""" +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 +from functools import wraps +from typing import Dict, List, Optional, Callable, Any +from config import config +import re + +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, +): + """ + 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 + + 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 [] + + # 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_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_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("") + + 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", []) + + # 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..70d2b42 --- /dev/null +++ b/slack_handlers/handlers.py @@ -0,0 +1,1191 @@ +from sdk.aws.ec2 import EC2Helper +from sdk.openstack.core import OpenStackHelper +from config import 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, +) +from sdk.gsheet.gsheet import gsheet +import logging +import traceback +import functools +from datetime import datetime + + +logger = logging.getLogger(__name__) + + +# 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": ["linux"], + }, + "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 + + # Ensure os_name is either 'Linux' or 'linux' + if os_name and os_name.strip().lower() == "linux": + logger.info(f"Operating System selected: {os_name}") + + # Use the hardcoded AMI ID for Amazon Linux + ami_id = ( + "ami-0402e56c0a7afb78f" # Replace with actual AMI ID for your region + ) + logger.info(f"Using AMI ID: {ami_id}") + say( + ":hourglass_flowing_sand: Now processing your request for a Linux 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, # AMI ID for Amazon Linux + instance_type, # Instance type (e.g., t2.micro) + key_to_use["KeyName"], # Key pair (e.g., your_key_pair_name) + ) + + # 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}`. Only `Linux` is supported.") + 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." + ) + + +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 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." + ) + + +# 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", + }, + "qe1": { + "description": "Primary QE engineer username", + "required": False, + "type": "str", + }, + "qe2": { + "description": "Secondary 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 --qe1=jane.smith --qe2=bob.wilson]", + "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")), + qe1=_get_name_from_userid(params_dict.get("qe1")), + qe2=_get_name_from_userid(params_dict.get("qe2")), + ) + 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: + try: + data = gsheet.fetch_data_by_time(time_period) + except ValueError: + say("Time period should either be `This Week` or `Next Week`.") + 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, qe1, qe2, activity = data + + if rel_ver == "N/A": + return "" + + pm = _get_userid_from_name(pm) + qe1 = _get_userid_from_name(qe1) + qe2 = _get_userid_from_name(qe2) + + return ( + f"*Release:* {rel_ver}\n" + f"*Patch Manager:* {pm}\n" + f"*QE:* {qe1}, {qe2}" + ) + + +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..9d5531f 100644 --- a/slack_main.py +++ b/slack_main.py @@ -1,49 +1,111 @@ 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, ) +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), + "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/smartsheet_releases_testing.py b/smartsheet_releases_testing.py new file mode 100755 index 0000000..757db4c --- /dev/null +++ b/smartsheet_releases_testing.py @@ -0,0 +1,188 @@ +import smartsheet +from datetime import datetime, timedelta + +# Configuration +SMARTSHEET_TOKEN='j4fc01RK42GEZuxlRP49zCBYnlwMr3HwjYXsz' +SMARTSHEET_ID='5CRwhFxhPX7pfvcQ6PVRx7VX8ggpW7JPfh4xfHx1' + +def get_week_range(date): + """ + Get the start (Monday) and end (Sunday) of the week for a given date + """ + start = date - timedelta(days=date.weekday()) + end = start + timedelta(days=6) + return start, end + +def validate_credentials(access_token, sheet_id): + """ + Validate Smartsheet credentials before proceeding + """ + try: + print("Validating Smartsheet credentials...") + smartsheet_client = smartsheet.Smartsheet(access_token) + smartsheet_client.errors_as_exceptions(True) + + # Try to get current user info to validate token + current_user = smartsheet_client.Users.get_current_user() + print(f"✓ Access token valid - Authenticated as: {current_user.email}") + + # Try to access the sheet to validate sheet ID + sheet = smartsheet_client.Sheets.get_sheet(sheet_id) + print(f"✓ Sheet ID valid - Sheet name: {sheet.name}") + print(f"✓ Sheet has {sheet.total_row_count} rows and {len(sheet.columns)} columns") + + return True + + except smartsheet.exceptions.ApiError as e: + if e.error.result.status_code == 401: + print("✗ Authentication failed - Invalid access token") + elif e.error.result.status_code == 404: + print("✗ Sheet not found - Invalid sheet ID or no access") + else: + print(f"✗ API Error: {e.error.result.message}") + return False + except Exception as e: + print(f"✗ Unexpected error: {str(e)}") + return False + +def get_smartsheet_releases(access_token, sheet_id): + """ + Fetch release version, start date, and end date from Smartsheet + Filter for current week and next week only + """ + # Initialize Smartsheet client + smartsheet_client = smartsheet.Smartsheet(access_token) + smartsheet_client.errors_as_exceptions(True) + + # Get the sheet + sheet = smartsheet_client.Sheets.get_sheet(sheet_id) + + # Extract column names and find target columns + columns = {col.id: col.title for col in sheet.columns} + column_ids = {title.lower(): col_id for col_id, title in columns.items()} + + # Debug: Print all column names + print("\n📋 Available columns in sheet:") + for col_id, col_title in columns.items(): + print(f" - Column Name : {col_title} ") + print() + + # Get current date and week ranges + today = datetime.now().date() + current_week_start, current_week_end = get_week_range(today) + next_week_start = current_week_start + timedelta(days=7) + next_week_end = current_week_end + timedelta(days=7) + + print(f"Today: {today}") + print(f"Current Week: {current_week_start} to {current_week_end}") + print(f"Next Week: {next_week_start} to {next_week_end}") + print("=" * 60) + + # Extract row data + releases = [] + rows_processed = 0 + + for row in sheet.rows: + # Skip row if all cells are empty + if all( + (getattr(cell, "display_value", None) in [None, ""] and getattr(cell, "value", None) in [None, ""]) + for cell in row.cells + ): + continue + + rows_processed += 1 + row_data = {} + for cell in row.cells: + column_name = columns[cell.column_id].lower() + print(f" {column_name}: {getattr(cell, 'display_value', None)} | {getattr(cell, 'value', None)}") + + # Look for release version, start date, and end date columns + if 'release' in column_name or 'version' in column_name: + row_data['release_version'] = cell.display_value if cell.display_value else cell.value + row_data['release_column'] = columns[cell.column_id] + elif 'start' in column_name and 'date' in column_name: + row_data['start_date'] = cell.value + row_data['start_column'] = columns[cell.column_id] + elif 'end' in column_name and 'date' in column_name: + row_data['end_date'] = cell.value + row_data['end_column'] = columns[cell.column_id] + + # Only process rows that have all required fields + if all(key in row_data for key in ['release_version', 'start_date', 'end_date']): + try: + # Parse dates - handle different date formats + if isinstance(row_data['start_date'], str): + # Try different date formats + for date_format in ['%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y-%m-%dT%H:%M:%S']: + try: + start_date = datetime.strptime(row_data['start_date'], date_format).date() + break + except ValueError: + continue + else: + print(f"⚠️ Could not parse start_date: {row_data['start_date']}") + continue + else: + start_date = row_data['start_date'] + + if isinstance(row_data['end_date'], str): + for date_format in ['%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y-%m-%dT%H:%M:%S']: + try: + end_date = datetime.strptime(row_data['end_date'], date_format).date() + break + except ValueError: + continue + else: + print(f"⚠️ Could not parse end_date: {row_data['end_date']}") + continue + else: + end_date = row_data['end_date'] + + # Check if the release falls in current week or next week + if (current_week_start <= start_date <= next_week_end) or \ + (current_week_start <= end_date <= next_week_end) or \ + (start_date <= current_week_start and end_date >= next_week_end): + releases.append({ + 'release_version': row_data['release_version'], + 'start_date': start_date, + 'end_date': end_date + }) + print(f"✓ Matched release: {row_data['release_version']}") + except (ValueError, TypeError) as e: + # Skip rows with invalid dates + print(f"⚠️ Error processing row: {e}") + continue + + print(f"\n📊 Processed {rows_processed} rows total") + return releases + +def main(): + """ + Main function to fetch and display releases for current and next week + """ + try: + # Validate credentials first + if not validate_credentials(SMARTSHEET_TOKEN, SMARTSHEET_ID): + print("\n❌ Credential validation failed. Please check your access token and sheet ID.") + return + + print("\n" + "=" * 60) + print("Fetching releases from Smartsheet...\n") + releases = get_smartsheet_releases(SMARTSHEET_TOKEN, SMARTSHEET_ID) + + if not releases: + print("No releases found for current week or next week.") + else: + print(f"\nFound {len(releases)} release(s) for current and next week:\n") + for i, release in enumerate(releases, 1): + print(f"{i}. Release Version: {release['release_version']}") + print(f" Start Date: {release['start_date']}") + print(f" End Date: {release['end_date']}") + print("-" * 60) + + except Exception as e: + print(f"Error: {str(e)}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e36e236 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +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" 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..c3588a7 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,40 @@ +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"} +# 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=""