Skip to content

Commit

Permalink
#3: Added release code build (#5)
Browse files Browse the repository at this point in the history
Fixes #3
  • Loading branch information
tomuben authored May 16, 2022
1 parent 1508d1a commit 50ad5e6
Show file tree
Hide file tree
Showing 38 changed files with 1,190 additions and 145 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.idea
.pytest_cache
.gitignore
1 change: 1 addition & 0 deletions doc/changes/changes_0.1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ n/a
## Features / Enhancements

- #1: Implement codebuild deployment and buildspec generation
- #3: Added release code build

## Documentation

Expand Down
7 changes: 6 additions & 1 deletion exasol_script_languages_container_ci_setup/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from exasol_script_languages_container_ci_setup.cli.commands import (
health,
generate_buildspec,
generate_release_buildspec,
deploy_source_credentials,
deploy_ci_build,
deploy_release_build,
validate_ci_build,
validate_source_credentials
validate_release_build,
validate_source_credentials,
start_release_build,
start_test_release_build
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import sys
from typing import Optional

import click

Expand All @@ -19,7 +20,7 @@
@click.option('--project-url', type=str, required=True,
help="""The URL of the project on Github.""")
def deploy_ci_build(
aws_profile: str,
aws_profile: Optional[str],
log_level: str,
project: str,
project_url: str):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import logging
import sys
from typing import Optional

import click

from exasol_script_languages_container_ci_setup.cli.cli import cli
from exasol_script_languages_container_ci_setup.cli.common import add_options
from exasol_script_languages_container_ci_setup.cli.options.logging import logging_options, set_log_level
from exasol_script_languages_container_ci_setup.lib.aws_access import AwsAccess
from exasol_script_languages_container_ci_setup.cli.options.aws_options import aws_options
from exasol_script_languages_container_ci_setup.lib.release_build import run_deploy_release_build


@cli.command()
@add_options(aws_options)
@add_options(logging_options)
@click.option('--project', type=str, required=True,
help="""The project for which the stack will be created.""")
@click.option('--project-url', type=str, required=True,
help="""The URL of the project on Github.""")
def deploy_release_build(
aws_profile: Optional[str],
log_level: str,
project: str,
project_url: str):
set_log_level(log_level)
try:
run_deploy_release_build(AwsAccess(aws_profile), project, project_url)
except Exception:
logging.error("run_deploy_release_build failed.")
sys.exit(1)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import sys
from typing import Optional

import click

Expand All @@ -21,7 +22,7 @@
@click.option('--secret-token-key', required=True, type=str,
help="Token key stored as secret in AWS Secret Manager.")
def deploy_source_credentials(
aws_profile: str,
aws_profile: Optional[str],
log_level: str,
secret_name: str,
secret_user_key: str,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Tuple, Optional

import click

from exasol_script_languages_container_ci_setup.cli.cli import cli
from exasol_script_languages_container_ci_setup.cli.common import add_options
from exasol_script_languages_container_ci_setup.cli.options.logging import logging_options, set_log_level
from exasol_script_languages_container_ci_setup.lib.run_generate_release_buildspec import run_generate_release_buildspec


@cli.command()
@add_options(logging_options)
@click.option('--flavor-root-path', required=True, multiple=True,
type=click.Path(file_okay=False, dir_okay=True, exists=True),
help="Path where script language container flavors are located.")
@click.option('--output-path', type=click.Path(file_okay=False, dir_okay=True, exists=True, writable=True),
default="./aws-code-build/release", show_default=True,
help="Path where buildspec files will be deployed.")
@click.option('--config-file', type=click.Path(file_okay=True, dir_okay=False, exists=True),
help="Configuration file for build (project specific).")
def generate_release_buildspecs(
flavor_root_path: Tuple[str, ...],
log_level: str,
output_path: str,
config_file: Optional[str]
):
"""
This command generates the buildspec file(s) for the AWS CodeBuild Release build based
on the flavors located in path "flavor_root_path".
"""
set_log_level(log_level)
run_generate_release_buildspec(flavor_root_path, output_path, config_file)

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import os
from typing import Optional

import click

from exasol_script_languages_container_ci_setup.cli.cli import cli
from exasol_script_languages_container_ci_setup.cli.common import add_options
from exasol_script_languages_container_ci_setup.cli.options.logging import logging_options, set_log_level
from exasol_script_languages_container_ci_setup.lib.aws_access import AwsAccess
from exasol_script_languages_container_ci_setup.cli.options.aws_options import aws_options
from exasol_script_languages_container_ci_setup.lib.run_start_release_build import run_start_release_build


@cli.command()
@add_options(aws_options)
@add_options(logging_options)
@click.option('--project', type=str, required=True,
help="""The project name. Must be same name as used for the AWS CodeBuild release stack creation.""")
@click.option('--upload-url', type=str, required=False,
help="""The URL of the Github release where artifacts will be stored.""")
@click.option('--branch', type=str, required=True,
help="""The branch of the repository which will be used.""")
def start_release_build(
aws_profile: Optional[str],
log_level: str,
project: str,
upload_url: str,
branch: str):
"""
This command triggers the AWS release Codebuild to upload the
release artifacts onto the given Github release, indicated by parameter 'upload_url'.
"""
set_log_level(log_level)
run_start_release_build(AwsAccess(aws_profile), project, upload_url, branch, os.getenv("GITHUB_TOKEN"))
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os
from typing import Optional

import click

from exasol_script_languages_container_ci_setup.cli.cli import cli
from exasol_script_languages_container_ci_setup.cli.common import add_options
from exasol_script_languages_container_ci_setup.cli.options.logging import logging_options, set_log_level
from exasol_script_languages_container_ci_setup.lib.aws_access import AwsAccess
from exasol_script_languages_container_ci_setup.cli.options.aws_options import aws_options
from exasol_script_languages_container_ci_setup.lib.github_draft_release_creator import GithubDraftReleaseCreator
from exasol_script_languages_container_ci_setup.lib.run_start_release_build import run_start_test_release_build


@cli.command()
@add_options(aws_options)
@add_options(logging_options)
@click.option('--project', type=str, required=True,
help="""The project name. Must be same name as used for the AWS CodeBuild release stack creation.""")
@click.option('--repo-name', type=str, required=True,
help="""The repository for which the test release should be created.
For example 'exasol/script-languages'.""", )
@click.option('--branch', type=str, required=True,
help="""The branch for which the test release should be created.""")
@click.option('--release-title', type=str, required=True,
help="""The title of the Github draft release which will be created.""")
def start_test_release_build(
aws_profile: Optional[str],
log_level: str,
repo_name: str,
project: str,
branch: str,
release_title: str
):
"""
This command creates a release draft on Github and triggers the AWS release Codebuild to upload the
release artifacts onto the new Github release.
"""
set_log_level(log_level)
run_start_test_release_build(AwsAccess(aws_profile), GithubDraftReleaseCreator(),
repo_name, project, branch, release_title, os.getenv("GITHUB_TOKEN"))
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from typing import Optional

import click

Expand All @@ -18,7 +19,7 @@
@click.option('--project-url', type=str, required=True,
help="""The URL of the project on Github.""")
def validate_ci_build(
aws_profile: str,
aws_profile: Optional[str],
log_level: str,
project: str,
project_url: str):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from typing import Optional

import click

from exasol_script_languages_container_ci_setup.cli.cli import cli
from exasol_script_languages_container_ci_setup.cli.common import add_options
from exasol_script_languages_container_ci_setup.cli.options.logging import logging_options, set_log_level
from exasol_script_languages_container_ci_setup.lib.aws_access import AwsAccess
from exasol_script_languages_container_ci_setup.cli.options.aws_options import aws_options
from exasol_script_languages_container_ci_setup.lib.release_build import run_validate_release_build


@cli.command()
@add_options(aws_options)
@add_options(logging_options)
@click.option('--project', type=str, required=True,
help="""The project for which the stack will be created.""")
@click.option('--project-url', type=str, required=True,
help="""The URL of the project on Github.""")
def validate_release_build(
aws_profile: Optional[str],
log_level: str,
project: str,
project_url: str):
set_log_level(log_level)
run_validate_release_build(AwsAccess(aws_profile), project, project_url)
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

import click

from exasol_script_languages_container_ci_setup.cli.cli import cli
Expand All @@ -18,7 +20,7 @@
@click.option('--secret-token-key', required=True, type=str,
help="Github user token key stored as secret in AWS Secret Manager under the respective secret name.")
def validate_source_credentials(
aws_profile: str,
aws_profile: Optional[str],
log_level: str,
secret_name: str,
secret_user_key: str,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import click

aws_options = [
click.option('--aws-profile', required=True, type=str,
click.option('--aws-profile', required=False, type=str,
help="Id of the AWS profile to use."),
]
101 changes: 82 additions & 19 deletions exasol_script_languages_container_ci_setup/lib/aws_access.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from typing import Optional
import time
from typing import Optional, List, Dict, Any, Iterable

import boto3
from botocore.exceptions import ClientError
Expand All @@ -11,6 +12,13 @@ class AwsAccess(object):
def __init__(self, aws_profile: Optional[str]):
self._aws_profile = aws_profile

@property
def aws_profile_for_logging(self) -> str:
if self._aws_profile is not None:
return self._aws_profile
else:
return "{default}"

@property
def aws_profile(self) -> Optional[str]:
return self._aws_profile
Expand All @@ -19,13 +27,8 @@ def upload_cloudformation_stack(self, yml: str, stack_name: str):
"""
Deploy the cloudformation stack.
"""
if self._aws_profile is not None:
logging.debug(f"Running upload_cloudformation_stack for aws profile {self._aws_profile}")
aws_session = boto3.session.Session(profile_name=self._aws_profile)
cloud_client = aws_session.client('cloudformation')
else:
logging.debug(f"Running upload_cloudformation_stack for default aws profile.")
cloud_client = boto3.client('cloudformation')
logging.debug(f"Running upload_cloudformation_stack for aws profile {self.aws_profile_for_logging}")
cloud_client = self._get_aws_client("cloudformation")
try:
cfn_deployer = Deployer(cloudformation_client=cloud_client)
result = cfn_deployer.create_and_wait_for_changeset(stack_name=stack_name, cfn_template=yml,
Expand All @@ -48,9 +51,8 @@ def read_secret_arn(self, secret_name: str):
Uses Boto3 to retrieve the ARN of a secret.
"""
logging.debug(f"Reading secret for getting ARN, secret name = {secret_name}, "
f"for aws profile {self._aws_profile}")
session = boto3.session.Session(profile_name=self._aws_profile)
client = session.client(service_name='secretsmanager')
f"for aws profile {self.aws_profile_for_logging}")
client = self._get_aws_client(service_name='secretsmanager')

try:
get_secret_value_response = client.get_secret_value(SecretId=secret_name)
Expand All @@ -69,12 +71,73 @@ def validate_cloudformation_template(self, cloudformation_yml) -> None:
Pitfall: Boto3 expects the YAML string as parameter, whereas the AWS CLI expects the file URL as parameter.
It requires to have the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY env variables set correctly.
"""
if self._aws_profile is not None:
logging.debug(f"Running validate_cloudformation_template for aws profile {self._aws_profile}")
aws_session = boto3.session.Session(profile_name=self._aws_profile)
cloud_client = aws_session.client('cloudformation')
cloud_client.validate_template(TemplateBody=cloudformation_yml)
logging.debug(f"Running validate_cloudformation_template for aws profile {self.aws_profile_for_logging}")
cloud_client = self._get_aws_client("cloudformation")
cloud_client.validate_template(TemplateBody=cloudformation_yml)

def _get_aws_client(self, service_name: str) -> Any:
if self._aws_profile is None:
return boto3.client(service_name)
aws_session = boto3.session.Session(profile_name=self._aws_profile)
return aws_session.client(service_name)

def get_all_stack_resources(self, stack_name: str) -> List[Dict[str, str]]:
"""
This functions uses Boto3 to get all AWS Cloudformation resources for a specific Cloudformation stack,
identified by parameter `stack_name`.
The AWS API truncates at a size of 1MB, and in order to get all chunks the method must be called
passing the previous retrieved token until no token is returned.
"""
logging.debug(f"Running get_all_codebuild_projects for aws profile {self.aws_profile_for_logging}")
cf_client = self._get_aws_client('cloudformation')
current_result = cf_client.list_stack_resources(StackName=stack_name)
result = current_result["StackResourceSummaries"]

while "nextToken" in current_result:
current_result = cf_client.list_projects(StackName=stack_name, nextToken=current_result["nextToken"])
result.extend(current_result["StackResourceSummaries"])
return result

def start_codebuild(self, project: str, environment_variables_overrides: List[Dict[str, str]], branch: str) -> None:
"""
This functions uses Boto3 to start a batch build.
It forwards all variables from parameter env_variables as environment variables to the CodeBuild project.
If a branch is given, it starts the codebuild for the given branch.
After the build has triggered it waits until the batch build finished
:raises
`RuntimeError` if build fails or AWS Batch build returns unknown status
"""
codebuild_client = self._get_aws_client("codebuild")
logging.info(f"Trigger codebuild for project {project} with branch {branch} "
f"and env_variables ({environment_variables_overrides})")
ret_val = codebuild_client.start_build_batch(projectName=project,
sourceVersion=branch,
environmentVariablesOverride=list(
environment_variables_overrides))

def wait_for(seconds: int, interval: int) -> Iterable[int]:
for _ in range(int(seconds / interval)):
yield interval

build_id = ret_val['buildBatch']['id']
logging.debug(f"Codebuild for project {project} with branch {branch} triggered. Id is {build_id}.")
interval = 30
timeout_time_in_seconds = 60 * 60 * 2 # We wait for maximal 2h + (something)
for seconds_to_wait in wait_for(seconds=timeout_time_in_seconds, interval=interval):
time.sleep(seconds_to_wait)
logging.debug(f"Checking status of codebuild id {build_id}.")
build_response = codebuild_client.batch_get_build_batches(ids=[build_id])
logging.debug(f"Build response of codebuild id {build_id} is {build_response}")
if len(build_response['buildBatches']) != 1:
logging.error(f"Unexpected return value from 'batch_get_build_batches': {build_response}")
build_status = build_response['buildBatches'][0]['buildBatchStatus']
logging.info(f"Build status of codebuild id {build_id} is {build_status}")
if build_status == 'SUCCEEDED':
break
elif build_status in ['FAILED', 'FAULT', 'STOPPED', 'TIMED_OUT']:
raise RuntimeError(f"Build ({build_id}) failed with status: {build_status}")
elif build_status != "IN_PROGRESS":
raise RuntimeError(f"Batch build {build_id} has unknown build status: {build_status}")
# if loop does not break early, build wasn't successful
else:
logging.debug(f"Running validate_cloudformation_template for default aws profile.")
cloud_client = boto3.client('cloudformation')
cloud_client.validate_template(TemplateBody=cloudformation_yml)
raise RuntimeError(f"Batch build {build_id} ran into timeout.")
Loading

0 comments on commit 50ad5e6

Please sign in to comment.