Skip to content

Add CLI Command to Check Deployment Prerequisites #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# macOS system files
.DS_Store
.AppleDouble
.LSOverride

# Thumbnails
._*

# Files that might appear on external disks
.Spotlight-V100
.Trashes

# Linux system files
*~
.nfs*

# Windows system files
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

# Python bytecode
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

# celery beat schedule file
celerybeat-schedule

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyderworkspace

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,63 @@
# co-assistant
Code Ocean Command-Line Assistant
# Code Ocean Support Command-Line

## Installation

### pip
To install or upgrade the co-support, run the following command:
```bash
pip install --upgrade git+https://github.com/codeocean/co-support.git
```

### virtualenv
To install for development purposes using a virtual environment:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```

## Usage
```bash
usage: co-support check-prerequisites [-h] [-s | --silent | --no-silent] [-f {table,yaml}] [-o OUTPUT] [--version VERSION] [--role ROLE] [--domain DOMAIN] [--zone HOSTED_ZONE] [--cert CERT]
[--private-ca | --no-private-ca] [--vpc VPC] [--internet-facing | --no-internet-facing]

options:
-h, --help show this help message and exit
-s, --silent, --no-silent
Run the script in silent mode (default: False)
-f, --format {table,yaml}
Output format: table or yaml (default: table)
-o, --output OUTPUT Path to the directory where the output file will be saved (default: None)
--version VERSION Version of Code Ocean to deploy (e.g., v3.4.1) (default: None)
--role ROLE ARN of the IAM role to deploy the Code Ocean template (e.g., arn:aws:iam::account-id:role/role-name) (default: None)
--domain DOMAIN Domain for the deployment (e.g., codeocean.company.com) (default: None)
--zone HOSTED_ZONE
Hosted zone ID for the deployment (e.g., Z3P5QSUBK4POTI) (default: None)
--cert CERT ARN of the SSL/TLS certificate (e.g., arn:aws:acm:region:account:certificate/certificate-id) (default: None)
--private-ca, --no-private-ca
Indicate if the certificate is signed by a private CA (default: False)
--vpc VPC ID of the existing VPC (e.g., vpc-0bb1c79de3fd22e7d) (default: None)
--internet-facing, --no-internet-facing
Indicate if the deployment is internet-facing (default: True)
```

### Interactive Example
```bash
co-support check-prerequisites
```

### Silent Example
```bash
co-support check-prerequisites -s \
--version v3.4.1 \
--internet-facing \
--domain codeocean.acmecorp.com \
--zone A0B1C2D3E4F5G6H7I8J9 \
--cert arn:aws:acm:us-east-1:000000000000:certificate/01234567-890a-bcde-f012-3456789000 \
--vpc vpc-0123456789abcdeff \
--role arn:aws:iam::000000000000:role/Administrator
--private-ca \
```

## Notes
Currently, this tool only checks prerequisites for Code Ocean deployment.
Empty file added co_support/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions co_support/cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from abc import ABC, abstractmethod
from argparse import ArgumentDefaultsHelpFormatter


class BaseCommand(ABC):
"""
Base class for commands.
"""

def __init__(self, subparsers, name, format_map=None):
self.parser = subparsers.add_parser(
name=name.format_map(format_map),
help=self.__doc__.strip().format_map(format_map),
formatter_class=ArgumentDefaultsHelpFormatter,
)
self.parser.set_defaults(cmd=self.cmd)

@abstractmethod
def cmd(args):
Copy link
Preview

Copilot AI Apr 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The abstract method 'cmd' is missing the 'self' parameter. Change it to 'def cmd(self, args):' to correctly define an instance method.

Suggested change
def cmd(args):
def cmd(self, args):

Copilot is powered by AI, so mistakes are possible. Review output carefully before use.

pass
27 changes: 27 additions & 0 deletions co_support/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env python3

import argparse

from .prerequisites.main import commands as prerequisites_commands


def parse_args():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
prerequisites_commands(subparsers)

return parser.parse_args(), parser


def main():
args, parser = parse_args()
if hasattr(args, 'cmd'):
response = args.cmd(args)
if response:
print(response)
else:
parser.print_help()


if __name__ == '__main__':
main()
Empty file.
Empty file.
122 changes: 122 additions & 0 deletions co_support/prerequisites/checks/access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import boto3
import requests
import yaml

from botocore.exceptions import ClientError
from typing import Dict, Set, Tuple

from ..core.constants import get_account, get_region


def check_linked_roles(params: Dict[str, Set[str]]) -> Tuple[bool, str]:
"""
Verifies the existence of required service-linked roles.
"""
iam_client = boto3.client("iam")
existing_roles: Set[str] = set()
roles_set: Set[str] = set(params.get("roles", set()))

try:
for role in iam_client.list_roles()["Roles"]:
if not role.get("Path", "").startswith("/aws-service-role/") and \
not role.get("AssumeRolePolicyDocument"):
continue
statements = role["AssumeRolePolicyDocument"].get("Statement", [])
for statement in statements:
principal = statement.get("Principal", {})
service = principal.get("Service", "")
existing_roles.add(service)
except ClientError as e:
return False, f"Error fetching roles: {e}"

missing_roles = roles_set - existing_roles
if missing_roles:
return False, (
f"Missing service-linked roles: {', '.join(missing_roles)}."
)

return True, "All required service-linked roles exist."


def check_admin_access(params: Dict[str, str]) -> Tuple[bool, str]:
"""
Determines whether the current user or a given role
has administrator access.
"""
iam_client = boto3.client("iam")
sts_client = boto3.client("sts")

try:
role_arn = params.get("role_arn")
if not role_arn:
role_arn = sts_client.get_caller_identity()["Arn"]

role_name = role_arn.split("/")[-1]
if ":assumed-role/" in role_arn:
role_name = role_arn.split("/")[-2]

if ":user/" in role_arn:
policies = iam_client.list_attached_user_policies(
UserName=role_name
)
else:
policies = iam_client.list_attached_role_policies(
RoleName=role_name
)

if any(
p["PolicyArn"] == "arn:aws:iam::aws:policy/AdministratorAccess"
for p in policies["AttachedPolicies"]
):
return True, (
f"{role_name} has AdministratorAccess policy attached."
)

return False, (
f"{role_name} does not have the AdministratorAccess policy. "
"This is acceptable if a least-privileged role is "
"intentionally being used."
)

except ClientError as e:
return False, f"Error checking admin access: {e}"


def check_shared_ami(params: Dict[str, str]) -> Tuple[bool, str]:
"""
Checks if the AMI is shared with the current account
in the specified region.
"""
yaml_url = (
"https://codeocean-vpc.s3.amazonaws.com/templates/"
f"{params.get('version', '')}/codeocean.template.yaml"
)

try:
yaml_content = yaml.safe_load(requests.get(yaml_url, "").text)
mappings = yaml_content.get("Mappings", {})
ami_id = mappings.get("AMIs", {}).get(get_region(), {}).get("id", "")
if not ami_id:
return False, (
f"The current region {get_region()} "
"is not supported in this version"
)

ec2_client = boto3.client("ec2")
try:
response = ec2_client.describe_images(
ImageIds=[ami_id],
)
if response.get("Images"):
return True, (
f"AMI {ami_id} in region {get_region()} "
f"is shared with account {get_account()}"
)
except ClientError as e:
return False, f"Error checking AMI permissions: {e}"

return False, (
f"AMI {ami_id} is not shared with account {get_account()}"
)
except requests.RequestException as e:
return False, f"Error fetching YAML file: {e}"
Loading