AWS DevOps & Developer Productivity Blog

Integrating JFrog Artifactory with AWS CodePipeline

When I talk with customers and partners, I find that they are in different stages in the adoption of DevOps methodologies. They are automating the creation of application artifacts and the deployment of their applications to different infrastructure environments. In many cases, they are creating and supporting multiple applications using a variety of coding languages and artifacts.

The management of these processes and artifacts can be challenging, but using the right tools and methodologies can simplify the process.

In this post, I will show you how you can automate the creation and storage of application artifacts through the implementation of a pipeline and custom deploy action in AWS CodePipeline. The example includes a Node.js code base stored in an AWS CodeCommit repository. A Node Package Manager (npm) artifact is built from the code base, and the build artifact is published to a JFrog Artifactory npm repository.

I frequently recommend AWS CodePipeline, the AWS continuous integration and continuous delivery tool. You can use it to quickly innovate through integration and deployment of new features and bug fixes by building a workflow that automates the build, test, and deployment of new versions of your application. And, because AWS CodePipeline is extensible, it allows you to create a custom action that performs customized, automated actions on your behalf.

JFrog’s Artifactory is a universal binary repository manager where you can manage multiple applications, their dependencies, and versions in one place. Artifactory also enables you to standardize the way you manage your package types across all applications developed in your company, no matter the code base or artifact type.

If you already have a Node.js CodeCommit repository, a JFrog Artifactory host, and would like to automate the creation of the pipeline, including the custom action and CodeBuild project, you can use this AWS CloudFormation template to create your AWS CloudFormation stack.

The project code can be found in this GitHub repository: https://github.com/aws-samples/aws-codepipeline-custom-job-worker-for-jfrog-artifactory.

 

The AWS CodePipeline workflow

This figure shows the path defined in the pipeline for this project. It starts with a change to Node.js source code committed to a private code repository in AWS CodeCommit. With this change, CodePipeline triggers AWS CodeBuild to create the npm package from the node.js source code. After the build, CodePipeline triggers the custom action job worker to commit the build artifact to the designated artifact repository in Artifactory.

 

This blog post assumes you have already:

·      Created a CodeCommit repository that contains a Node.js project.

·      Configured a two-stage pipeline in AWS CodePipeline.

The Source stage of the pipeline is configured to poll the Node.js CodeCommit repository. The Build stage is configured to use a CodeBuild project to build the npm package using a buildspec.yml file located in the code repository.

If you do not have a Node.js repository, you can create a CodeCommit repository that contains this simple ‘Hello World’ project. This project also includes a buildspec.yml file that is used when you define your CodeBuild project. It defines the steps to be taken by CodeBuild to create the npm artifact.

If you do not already have a pipeline set up in CodePipeline, you can use this template to create a pipeline with a CodeCommit source action and a CodeBuild build action through the AWS Command Line Interface (AWS CLI). If you do not want to install the AWS CLI on your local machine, you can use AWS Cloud9, our managed integrated development environment (IDE), to interact with AWS APIs.

In your development environment, open your favorite editor and fill out the template with values appropriate to your project. For information, see the readme in the GitHub repository.

Use this CLI command to create the pipeline from the template:

aws codepipeline create-pipeline --cli-input-json file://source-build-actions-codepipeline.json --region 'us-west-2'

It creates a pipeline that has a CodeCommit source action and a CodeBuild build action.

Integrating JFrog Artifactory

JFrog Artifactory provides default repositories for your project needs. For my NPM package repository, I am using the default virtual npm repository (named npm) that is available in Artifactory Pro. You might want to consider creating a repository per project but for the example used in this post, using the default lets me get started without having to configure a new repository.

I can use the steps in the Set Me Up -> npm section on the landing page to configure my worker to interact with the default NPM repository.

 

 

 

Custom actions in AWS CodePipeline

A custom action in AWS CodePipeline contains:

·      custom action definition

Describes the required values to run the custom action. I will define my custom action in the ‘Deploy’ category, identify the provider as ‘Artifactory’, of version ‘1’, and specify a variety of configurationProperties whose values will be defined when this stage is added to my pipeline.

·      custom action job worker

Polls CodePipeline for a job, scanning for its action-definition properties. In this blog post, after a job has been found, the job worker does the work required to publish the npm artifact to the Artifactory repository.

 

My custom action definition in JSON:

{
    "category": "Deploy",
    "configurationProperties": [{
        "name": "TypeOfArtifact",
        "required": true,
        "key": true,
        "secret": false,
        "description": "Package type, ex. npm for node packages",
        "type": "String"
    },
    {   "name": "RepoKey",
        "required": true,
        "key": true,
	"secret": false,
	"type": "String",
	"description": "Name of the repository in which this artifact should be stored"
    },
    {   "name": "UserName",
        "required": true,
        "key": true,
	"secret": false,
	"type": "String",
	"description": "Username for authenticating with the repository"
    },
    {   "name": "Password",
        "required": true,
        "key": true,
	"secret": true,
	"type": "String",
	"description": "Password for authenticating with the repository"
    },
    {   "name": "EmailAddress",
        "required": true,
        "key": true,
	"secret": false,
	"type": "String",
	"description": "Email address used to authenticate with the repository"
    },
    {   "name": "ArtifactoryHost",
        "required": true,
        "key": true,
	"secret": false,
	"type": "String",
	"description": "Public address of Artifactory host, ex: https://myexamplehost.com or http://myexamplehost.com:8080"
    }],
    "provider": "Artifactory",
    "version": "1",
    "settings": {
        "entityUrlTemplate": "{Config:ArtifactoryHost}/artifactory/webapp/#/artifacts/browse/tree/General/{Config:RepoKey}"
    },
    "inputArtifactDetails": {
        "maximumCount": 5,
        "minimumCount": 1
    },
    "outputArtifactDetails": {
        "maximumCount": 5,
        "minimumCount": 0
    }
}

There are seven sections to the custom action definition:

  • category: This is the stage in which you will be creating this action. It can be Source, Build, Deploy, Test, Invoke, Approval. Except for source actions, the category section simply allows us to organize our actions. I am setting the category for my action as ‘Deploy’ because I’m using it to publish my node artifact to my Artifactory instance.
  • configurationProperties: These are the parameters or variables required for your project to authenticate and commit your artifact. In the case of my custom worker, I need:

    • TypeOfArtifact: In this case, npm, because it’s for the Node Package Manager.
    • RepoKey: The name of the repository. In this case, it’s the default npm.
    • UserName and Password for the user to authenticate with the Artifactory repository.
    • EmailAddress used to authenticate with the repository.
    • Artifactory host name or IP address.
  • provider: The name you define for your custom action stage. I have named the provider Artifactory.
  • version: Version number for the custom action. Because this is the first version, I set the version number to 1.
  • entityUrlTemplate: This URL is presented to your users for the deploy stage along with the title you define in your provider. The link takes the user to their artifact repository page in the Artifactory host.
  • inputArtifactDetails: The number of artifacts to expect from the previous stage in the pipeline.
  • outputArtifactDetails: The number of artifacts that should be the result from the custom action stage. Later in this blog post, I define 0 for my output artifacts because I am publishing the artifact to the Artifactory repository as the final action.

After I define the custom action in a JSON file, I use the AWS CLI to create the custom action type in CodePipeline:

aws codepipeline create-custom-action-type --cli-input-json file://artifactory_custom_action_deploy_npm.json --region='us-west-2'

After I create the custom action type in the same region as my pipeline, I edit the pipeline to add a Deploy stage and configure it to use the custom action I created for Artifactory:

 

 

 

 

I have created a custom worker for the actions required to commit the npm artifact to the Artifactory repository. The worker is in Python and it runs in a loop on an Amazon EC2 instance. My custom worker polls for a deploy job and publishes the NPM artifact to the Artifactory repository.

 

The EC2 instance is running Amazon Linux and has an IAM instance role attached that gives the worker permission to access CodePipeline. The worker process is as follows:

  1. Take the configuration properties from the custom worker and poll CodePipeline for a custom action job.
  2. After there is a job in the job queue with the appropriate category, provider, and version, acknowledge the job.
  3. Download the zipped artifact created in the previous Build stage from the provided S3 buckets with the provided temporary credentials.
  4. Unzip the artifact into a temporary directory.
  5. A user-defined Artifactory user name and password is used to receive a temporary API key from Artifactory.
  6. To avoid having to write the password to a file, use that temporary API key and user name to authenticate with the NPM repository.
  7. Publish the Node.js package to the specified repository.

Because I am running my custom worker on an Amazon Linux EC2 instance, I installed npm with the following command:

sudo yum install nodejs npm --enablerepo=epel

For my custom worker, I used pip to install the required Python libraries:

pip install boto3 requests

For a full Python package list, see requirements.txt in the GitHub repository.

Let’s take a look at some of the code snippets from the worker.

First, the worker polls for jobs:

def action_type():
    ActionType = {
        'category': 'Deploy',
        'owner': 'Custom',
        'provider': 'Artifactory',
        'version': '1' }
    return(ActionType)

def poll_for_jobs():
    try:
        artifactory_action_type = action_type()
        print(artifactory_action_type)
        jobs = codepipeline.poll_for_jobs(actionTypeId=artifactory_action_type)
        while not jobs['jobs']:
            time.sleep(10)
            jobs = codepipeline.poll_for_jobs(actionTypeId=artifactory_action_type)
            if jobs['jobs']:
                print('Job found')
        return jobs['jobs'][0]
    except ClientError as e:
        print("Received an error: %s" % str(e))
        raise

 

When there is a job in the queue, the poller returns a number of values from the queue such as jobId, the input and output S3 buckets for artifacts, temporary credentials to access the S3 buckets, and other configuration details from the stage in the pipeline.

 

Here is an example of the return response:

{
	'jobs': [
		{
			'nonce': '3',
			'data': {
				'inputArtifacts': [
					{
						'name': 'Output',
						'location': {
							'type': 'S3',
							's3Location': {
								'objectKey': 'ArtifactoryNPMwithCo/Output/Key,
							'bucketName': '123456789012-codepipelineartifact-us-west-2'
							}
						}
					}
				],
				'pipelineContext': {
						'action': {
							'name': 'Deploy'
						},
						'pipelineName': 'ArtifactoryNPMwithCodeDeploy',
						'stage': {
							'name': 'Deploy'
						}
				},
				'actionTypeId': {
					'category': 'Deploy',
					'owner': 'Custom',
					'version': '1',
					'provider': 'Artifactory'
				},
				'outputArtifacts': [
					{
						'name': 'ArtifactoryOut',
						'location': {
							'type': 'S3',
							's3Location': {
								'objectKey': 'ArtifactoryNPMwithCo/Artifactor/Key,
								'bucketName': '123456789012-codepipelineartifact-us-west-2'
							}
						}
					}
				],
				'actionConfiguration': {
					'configuration': {
						'UserName': 'admin',
						'ArtifactoryHost': 'https://artifactory.myexamplehost.com',
						'Password': 'xxx',
						'EmailAddress': 'me@myexamplehost.com',
						'TypeOfArtifact': 'npm',
						'RepoKey': 'npm'
					}
				},
				'artifactCredentials': {
					'secretAccessKey': 'SECRET',
			               'sessionToken':‘FQoDYXdz...XdndMF',
					'accessKeyId': 'ACCESSKEY'
					}
				},
				'id': 'a0eb',
				'accountId': '123456789012
			}
		],
		'ResponseMetadata': {
			'RetryAttempts': 0,
			'HTTPStatusCode': 200,
			'RequestId': a88b-cdbd5d08b9de',
			'HTTPHeaders': {
				'x-amzn-requestid': '77343c2d-eff4’,
				'content-length': '2461',
				'content-type': 'application/x-amz-json-1.1'
			}
		}
}

 

After successfully receiving the job details, the worker sends an acknowledgement to CodePipeline to ensure that the work on the job is not duplicated by other workers watching for the same job:

def job_acknowledge(jobId, nonce):
    try:
        print('Acknowledging job')
        result = codepipeline.acknowledge_job(jobId=jobId, nonce=nonce)
        return result
    except Exception as e:
        print("Received an error when trying to acknowledge the job: %s" % str(e))
        raise

With the job now acknowledged, the worker publishes the source code artifact into the desired repository. The worker gets the value of the artifact S3 bucket and objectKey from the inputArtifacts in the response from the poll_for_jobs API request. Next, the worker creates a new directory in /tmp and downloads the S3 object into this directory:

def get_bucket_location(bucketName, init_client):
    region = init_client.get_bucket_location(Bucket=bucketName)['LocationConstraint']
    if not region:
        region = 'us-east-1'
    return region


def get_s3_artifact(bucketName, objectKey, ak, sk, st):
    init_s3 = boto3.client('s3')
    region = get_bucket_location(bucketName, init_s3)
    session = Session(aws_access_key_id=ak,
                      aws_secret_access_key=sk,
                      aws_session_token=st)

    s3 = session.resource('s3',
                          region_name=region,
                          config=botocore.client.Config(signature_version='s3v4'))
    try:
        tempdirname = tempfile.mkdtemp()
    except OSError as e:
        print('Could not write temp directory %s' % tempdirname)
        raise
    bucket = s3.Bucket(bucketName)
    obj = bucket.Object(objectKey)
    filename = tempdirname + '/' + objectKey
    try:
        if os.path.dirname(objectKey):
            directory = os.path.dirname(filename)
            os.makedirs(directory)
        print('Downloading the %s object and writing it to disk in %s location' % (objectKey, tempdirname))
        with open(filename, 'wb') as data:
            obj.download_fileobj(data)
    except ClientError as e:
        print('Downloading the object and writing the file to disk raised this error: ' + str(e))
        raise
    return(filename, tempdirname)   

 

 

Because the downloaded artifact from S3 is a zip file, the worker must unzip it first. To have a clean area in which to work, I extract the downloaded zip archive into a new directory:

def unzip_codepipeline_artifact(artifact, origtmpdir):
    # create a new temp directory
    # Unzip artifact into new directory
    try:
        newtempdir = tempfile.mkdtemp()
        print('Extracting artifact %s into temporary directory %s' % (artifact, newtempdir))
        zip_ref = zipfile.ZipFile(artifact, 'r')
        zip_ref.extractall(newtempdir)
        zip_ref.close()
        shutil.rmtree(origtmpdir)
        return(os.listdir(newtempdir), newtempdir)
    except OSError as e:
        if e.errno != errno.EEXIST:
            shutil.rmtree(newtempdir)
            raise

The worker now has the npm package that I want to store in my Artifactory NPM repository.

To authenticate with the NPM repository, the worker requests a temporary token from the Artifactory host. After receiving this temporary token, it creates a .npmrc file in the worker user’s home directory that includes a hash of the user name and temporary token. After it has authenticated, the worker runs npm config set registry <URL OF REPOSITORY> to configure the npm registry value to be the Artifactory host.  Next, the worker runs npm publish –registry <URL OF REPOSITORY>, which publishes the node package to the NPM repository in the Artifactory host.

def push_to_npm(configuration, artifact_list, temp_dir, jobId):
    reponame = configuration['RepoKey']
    art_type = configuration['TypeOfArtifact']
    print("Putting artifact into NPM repository " + reponame)
    token, hostname, username = gen_artifactory_auth_token(configuration)
    npmconfigfile = create_npmconfig_file(configuration, username, token)
    url = hostname + '/artifactory/api/' + art_type + '/' + reponame
    print("Changing directory to " + str(temp_dir))
    os.chdir(temp_dir)
    try:
        print("Publishing following files to the repository: %s " % os.listdir(temp_dir))
        print("Sending artifact to Artifactory NPM registry URL: " + url)
        subprocess.call(["npm", "config", "set", "registry", url])
        req = subprocess.call(["npm", "publish", "--registry", url])
        print("Return code from npm publish: " + str(req))
        if req != 0:
            err_msg = "npm ERR! Recieved non OK response while sending response to Artifactory. Return code from npm publish: " + str(req)
            signal_failure(jobId, err_msg)
        else:
            signal_success(jobId)
    except requests.exceptions.RequestException as e:
       print("Received an error when trying to commit artifact %s to repository %s: " % (str(art_type), str(configuration['RepoKey']), str(e)))
       raise
    return(req, npmconfigfile)

 

If the return value from publishing to the repository is not 0, the worker signals a failure to CodePipeline. If the value is 0, the worker signals success to CodePipeline to indicate that the stage of the pipeline has been completed successfully.

For the custom worker code, see npm_job_worker.py in the GitHub repository.

I run my custom worker on an EC2 instance using the command python npm_job_worker.py, with an optional --version flag that can be used to specify worker versions other than 1. Then I trigger a release change in my pipeline:

From my custom worker output logs, I have just committed a package named node_example at version 1.0.3:

On artifact: index.js
Committing to the repo: https://artifactory.myexamplehost.com/artifactory/api/npm/npm
Sending artifact to Artifactory URL: https:// artifactoryhost.myexamplehost.com/artifactory/api/npm/npm
npm config: 0
npm http PUT https://artifactory.myexamplehost.com/artifactory/api/npm/npm/node_example
npm http 201 https://artifactory.myexamplehost.com/artifactory/api/npm/npm/node_example
+ node_example@1.0.3
Return code from npm publish: 0
Signaling success to CodePipeline

After that has been built successfully, I can find my artifact in my Artifactory repository:

To help you automate this process, I have created this AWS CloudFormation template that automates the creation of the CodeBuild project, the custom action, and the CodePipeline pipeline. It also launches the Amazon EC2-based custom job worker in an AWS Auto Scaling group. This template requires you to have a VPC and CodeCommit repository for your Node.js project. If you do not currently have a VPC in which you want to run your custom worker EC2 instances, you can use this AWS QuickStart to create one. If you do not have an existing Node.js project, I’ve provided a sample project in the GitHub repository.

Conclusion

I‘ve shown you the steps to integrate your JFrog Artifactory repository with your CodePipeline workflow. I’ve shown you how to create a custom action in CodePipeline and how to create a custom worker that works in your CI/CD pipeline. To dig deeper into custom actions and see how you can integrate your Artifactory repositories into your AWS CodePipeline projects, check out the full code base on GitHub.

If you have any questions or feedback, feel free to reach out to us through the AWS CodePipeline forum.

Erin McGill is a Solutions Architect in the AWS Partner Program with a focus on DevOps and automation tooling.