├── LICENSE ├── README.md ├── jenkins-backup ├── jenkins-restore └── jenkins.json /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 The Factory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CloudFormation template for a [Jenkins](https://jenkins-ci.org/) server with automatic backup and recovery. 2 | 3 | Prerequisites: 4 | * Route 53 hosted zone for the desired DNS address (e.g., `mycompany.com` for `jenkins.mycompany.com`) 5 | 6 | ## Overview 7 | 8 | This template bootstraps a Jenkins server. 9 | 10 | The Jenkins server is launched in an auto scaling group using public AMIs running Ubuntu 14.04 LTS and pre-reloaded with Docker and Runit. If you wish to use your own image, simply modify `RegionMap` in the template file. 11 | 12 | The server registers with an Elastic Load Balancer, a CNAME for which is created in Route 53. Use this CNAME as your bookmarked address. 13 | 14 | The auto scaling group is pinned at a capacity of one. If your Jenkins server is terminated, a new one will come back and load the most recent backup. 15 | 16 | The Jenkins daemon is run via a Docker image specified as a Parameter. You can use the default or provide your own. 17 | 18 | Jenkins will be backed up daily to S3. At server boot, Jenkins will be restored from the latest S3 backup (if one exists). 19 | 20 | Note that this template must be used with Amazon VPC. New AWS accounts automatically use VPC, but if you have an old account and are still using EC2-Classic, you'll need to modify this template or make the switch. 21 | 22 | ## Usage 23 | 24 | ### 1. Clone the repository 25 | ```bash 26 | git clone git@github.com:thefactory/cloudformation-jenkins.git 27 | ``` 28 | 29 | ### 2. Create an Admin security group 30 | This is a VPC security group containing access rules for cluster administration, and should be locked down to your IP range, a bastion host, or similar. This security group will be associated with the Jenkins server. 31 | 32 | Inbound rules are at your discretion, but you may want to include access to: 33 | * `22 [tcp]` - SSH port 34 | * `80 [tcp]` - ELB HTTP port 35 | 36 | ### 5. Launch the stack 37 | Launch the stack via the AWS console, a script, or [aws-cli](https://github.com/aws/aws-cli). 38 | 39 | See `jenkins.json` for the full list of parameters, descriptions, and default values. 40 | 41 | Example using `aws-cli`: 42 | ```bash 43 | aws cloudformation create-stack \ 44 | --template-body file://jenkins.json \ 45 | --stack-name \ 46 | --capabilities CAPABILITY_IAM \ 47 | --parameters \ 48 | ParameterKey=KeyName,ParameterValue= \ 49 | ParameterKey=S3Bucket,ParameterValue= \ 50 | ParameterKey=VpcId,ParameterValue= \ 51 | ParameterKey=Subnets,ParameterValue='\,' \ 52 | ParameterKey=AdminSecurityGroup,ParameterValue= \ 53 | ParameterKey=DnsZone,ParameterValue= 54 | ``` 55 | 56 | ### 4. Watch the stack come up 57 | Once the stack has been provisioned, visit `http:///`. You will need to do this from a location granted access by the specified `AdminSecurityGroup`. 58 | 59 | _Note the Docker image for Jenkins may take several minutes to retrieve. This can be improved with the use of a private Docker registry._ 60 | 61 | You should see the Jenkins UI. You can now use it as you would any other Jenkins server. Daily backups will automatically be generated and uploaded to the specified S3 location. -------------------------------------------------------------------------------- /jenkins-backup: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | USAGE="Usage: $0 JENKINS_HOME S3_TARGET\n 4 | \n 5 | Examples:\n 6 | $0 /var/lib/jenkins s3://mybucket/jenkins/jenkins-201405011901.tar.gz" 7 | 8 | JENKINS_HOME=$1 9 | S3_TARGET=$2 10 | if [[ -z "`echo $S3_TARGET|grep '^s3://'`" || ! -d "$JENKINS_HOME" ]]; then 11 | echo -e $USAGE 12 | exit 1 13 | fi 14 | 15 | LOCAL_BACKUP=/tmp/`basename $S3_TARGET` 16 | 17 | tar -C $JENKINS_HOME -zcf $LOCAL_BACKUP .\ 18 | --exclude "config-history/" \ 19 | --exclude "config-history/*" \ 20 | --exclude "jobs/*/workspace*" \ 21 | --exclude "jobs/*/builds/*/archive" \ 22 | --exclude "plugins/*/*" \ 23 | --exclude "plugins/*.bak" \ 24 | --exclude "war" \ 25 | --exclude "cache" 26 | 27 | aws s3 cp $LOCAL_BACKUP $S3_TARGET 28 | rm -f $LOCAL_BACKUP -------------------------------------------------------------------------------- /jenkins-restore: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | USAGE="Usage: $0 S3_TARGET JENKINS_HOME\n 4 | \n 5 | Example:\n 6 | $0 s3://mybucket/jenkins/jenkins-201405011901.tar.gz /var/lib/jenkins\n 7 | \n 8 | If S3_TARGET is a directory, restore from the newest file. Make sure to include the trailing slash:\n 9 | $0 s3://mybucket/jenkins/ /var/lib/jenkins" 10 | 11 | S3_TARGET=$1 12 | JENKINS_HOME=$2 13 | if [[ -z "`echo $S3_TARGET|grep '^s3://'`" ]]; then 14 | echo -e $USAGE 15 | exit 1 16 | fi 17 | 18 | if [[ "$S3_TARGET" == */ ]]; then 19 | S3_TARGET=$S3_TARGET`aws s3 ls $S3_TARGET|tail -1|awk '{print $NF}'` 20 | fi 21 | 22 | LOCAL_BACKUP=/tmp/`basename $S3_TARGET` 23 | aws s3 cp $S3_TARGET $LOCAL_BACKUP 24 | 25 | if [[ -d "$JENKINS_HOME" ]]; then 26 | read -p "Delete existing $JENKINS_HOME? (y/n) " -n 1 -r 27 | echo 28 | if [[ $REPLY =~ ^[Yy]$ ]]; then 29 | rm -rf $JENKINS_HOME 30 | else 31 | echo "Bailing out" 32 | exit 1 33 | fi 34 | fi 35 | 36 | mkdir -p $JENKINS_HOME 37 | tar zxf $LOCAL_BACKUP -C $JENKINS_HOME 38 | rm -f $LOCAL_BACKUP -------------------------------------------------------------------------------- /jenkins.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion" : "2010-09-09", 3 | 4 | "Description" : "Launches a Jenkins server.", 5 | 6 | "Parameters" : { 7 | "InstanceType" : { 8 | "Description" : "EC2 instance type", 9 | "Type" : "String", 10 | "Default" : "m1.small", 11 | "AllowedValues" : [ "t1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m2.xlarge","m2.2xlarge","m2.4xlarge","m3.xlarge","m3.2xlarge","c1.medium","c1.xlarge","cc1.4xlarge","cc2.8xlarge","cg1.4xlarge"], 12 | "ConstraintDescription" : "must be a valid EC2 instance type." 13 | }, 14 | "KeyName" : { 15 | "Description" : "Name of an existing EC2 keypair to enable SSH access to the instances", 16 | "Type" : "AWS::EC2::KeyPair::KeyName" 17 | }, 18 | "DnsPrefix" : { 19 | "Description" : "Prefix for Jenkins' DNS record (.)", 20 | "Type": "String", 21 | "Default": "jenkins" 22 | }, 23 | "DnsZone" : { 24 | "Description" : "Route53-hosted zone to use for the DNS record (.)", 25 | "Type": "String", 26 | "Default": "thefactory.com" 27 | }, 28 | "DockerImage" : { 29 | "Description" : "Path of the Jenkins Docker image (format: '[[:]/]:')", 30 | "Type" : "String", 31 | "Default" : "aespinosa/jenkins" 32 | }, 33 | "S3Bucket" : { 34 | "Description" : "Existing S3 bucket to use for Jenkins backups and restores", 35 | "Type" : "String", 36 | "Default": "thefactory-jenkins" 37 | }, 38 | "S3Prefix" : { 39 | "Description" : "[Optional] Key prefix to use for Jenkins backups", 40 | "Type" : "String", 41 | "Default": "" 42 | }, 43 | "Subnets" : { 44 | "Description" : "List of VPC subnet IDs for the cluster", 45 | "Type" : "List" 46 | }, 47 | "VpcId" : { 48 | "Description" : "VPC associated with the provided subnets", 49 | "Type" : "AWS::EC2::VPC::Id" 50 | }, 51 | "AdminSecurityGroup" : { 52 | "Description" : "Existing security group that should be granted administrative access to Jenkins (e.g., 'sg-123456')", 53 | "Type": "AWS::EC2::SecurityGroup::Id" 54 | } 55 | }, 56 | 57 | "Mappings" : { 58 | "RegionMap" : { 59 | "us-east-1" : { 60 | "AMI" : "ami-f40bbe9c" 61 | }, 62 | "us-west-1" : { 63 | "AMI" : "ami-cfe2ea8a" 64 | }, 65 | "us-west-2" : { 66 | "AMI" : "ami-3fb1f20f" 67 | }, 68 | "eu-west-1" : { 69 | "AMI" : "ami-e0d27397" 70 | }, 71 | "ap-southeast-1" : { 72 | "AMI" : "ami-8a7057d8" 73 | }, 74 | "ap-southeast-2" : { 75 | "AMI" : "ami-d5c1a2ef" 76 | }, 77 | "ap-northeast-1" : { 78 | "AMI" : "ami-a7def7a6" 79 | }, 80 | "sa-east-1" : { 81 | "AMI" : "ami-070fa51a" 82 | } 83 | } 84 | }, 85 | 86 | "Resources" : { 87 | "IAMUser" : { 88 | "Type" : "AWS::IAM::User", 89 | "Properties" : { 90 | "Policies" : [{ 91 | "PolicyName" : "S3Access", 92 | "PolicyDocument" : { 93 | "Statement": [{ 94 | "Effect" : "Allow", 95 | "Action" : "s3:*", 96 | "Resource" : { "Fn::Join" : ["", ["arn:aws:s3:::", {"Ref" : "S3Bucket"} , "/*"]]} 97 | }] 98 | } 99 | }, 100 | { 101 | "PolicyName" : "IAMAccess", 102 | "PolicyDocument" : { 103 | "Statement" : [{ 104 | "Effect" : "Allow", 105 | "NotAction" : "iam:*", 106 | "Resource" : "*" 107 | }] 108 | } 109 | }] 110 | } 111 | }, 112 | 113 | "HostKeys" : { 114 | "Type" : "AWS::IAM::AccessKey", 115 | "Properties" : { 116 | "UserName" : { "Ref" : "IAMUser" } 117 | } 118 | }, 119 | 120 | "ServerGroup" : { 121 | "Type" : "AWS::AutoScaling::AutoScalingGroup", 122 | "Properties" : { 123 | "AvailabilityZones" : { "Fn::GetAZs" : "" }, 124 | "LaunchConfigurationName" : { "Ref" : "LaunchConfig" }, 125 | "MinSize" : "1", 126 | "MaxSize" : "1", 127 | "DesiredCapacity" : "1", 128 | "LoadBalancerNames" : [ { "Ref" : "ElasticLoadBalancer" } ], 129 | "VPCZoneIdentifier" : { "Ref" : "Subnets" } 130 | } 131 | }, 132 | 133 | "LaunchConfig" : { 134 | "Type" : "AWS::AutoScaling::LaunchConfiguration", 135 | "Metadata" : { 136 | "AWS::CloudFormation::Init" : { 137 | "config": { 138 | "packages" : { 139 | "python" : { 140 | "awscli":[] 141 | } 142 | }, 143 | "files" : { 144 | "/etc/aws.conf" : { 145 | "content" : { "Fn::Join" : ["\n", [ 146 | "[default]", 147 | "aws_access_key_id={{access_key}}", 148 | "aws_secret_access_key={{secret_key}}" 149 | ]]}, 150 | "context" : { 151 | "access_key" : { "Ref" : "HostKeys" }, 152 | "secret_key" : { "Fn::GetAtt" : ["HostKeys", "SecretAccessKey"]} 153 | }, 154 | "mode" : "000700", 155 | "owner" : "root", 156 | "group" : "root" 157 | }, 158 | 159 | "/usr/local/bin/jenkins-restore" : { 160 | "content" : { "Fn::Join" : ["\n", [ 161 | "#!/bin/bash -e", 162 | "", 163 | "USAGE=\"Usage: $0 S3_TARGET JENKINS_HOME\\n", 164 | "\\n", 165 | "Example:\\n", 166 | "$0 s3://mybucket/jenkins/jenkins-201405011901.tar.gz /var/lib/jenkins\\n", 167 | "\\n", 168 | "If S3_TARGET is a directory, restore from the newest file. Make sure to include the trailing slash:\\n", 169 | "$0 s3://mybucket/jenkins/ /var/lib/jenkins\"", 170 | "", 171 | "S3_TARGET=$1", 172 | "JENKINS_HOME=$2", 173 | "if [[ -z \"`echo $S3_TARGET|grep '^s3://'`\" ]]; then", 174 | " echo -e $USAGE", 175 | " exit 1", 176 | "fi", 177 | "", 178 | "if [[ \"$S3_TARGET\" == */ ]]; then", 179 | " S3_TARGET=$S3_TARGET`aws s3 ls $S3_TARGET|tail -1|awk '{print $NF}'`", 180 | "fi", 181 | "", 182 | "LOCAL_BACKUP=/tmp/`basename $S3_TARGET`", 183 | "aws s3 cp $S3_TARGET $LOCAL_BACKUP", 184 | "", 185 | "if [[ -d \"$JENKINS_HOME\" ]]; then", 186 | " read -p \"Delete existing $JENKINS_HOME? (y/n) \" -n 1 -r", 187 | " echo", 188 | " if [[ $REPLY =~ ^[Yy]$ ]]; then", 189 | " rm -rf $JENKINS_HOME", 190 | " else", 191 | " echo \"Bailing out\"", 192 | " exit 1", 193 | " fi", 194 | "fi", 195 | "", 196 | "mkdir -p $JENKINS_HOME", 197 | "tar zxf $LOCAL_BACKUP -C $JENKINS_HOME", 198 | "rm -f $LOCAL_BACKUP" 199 | ]]}, 200 | "mode" : "000755", 201 | "owner" : "root", 202 | "group" : "root" 203 | }, 204 | 205 | "/usr/local/bin/jenkins-backup" : { 206 | "content" : { "Fn::Join" : ["\n", [ 207 | "#!/bin/bash -e", 208 | "", 209 | "USAGE=\"Usage: $0 JENKINS_HOME S3_TARGET\\n", 210 | "\\n", 211 | "Examples:\\n", 212 | "$0 /var/lib/jenkins s3://mybucket/jenkins/jenkins-201405011901.tar.gz\"", 213 | "", 214 | "JENKINS_HOME=$1", 215 | "S3_TARGET=$2", 216 | "if [[ -z \"`echo $S3_TARGET|grep '^s3://'`\" || ! -d \"$JENKINS_HOME\" ]]; then", 217 | " echo -e $USAGE", 218 | " exit 1", 219 | "fi", 220 | "", 221 | "LOCAL_BACKUP=/tmp/`basename $S3_TARGET`", 222 | "", 223 | "tar -C $JENKINS_HOME -zcf $LOCAL_BACKUP .\\", 224 | " --exclude \"config-history/\" \\", 225 | " --exclude \"config-history/*\" \\", 226 | " --exclude \"jobs/*/workspace*\" \\", 227 | " --exclude \"jobs/*/builds/*/archive\" \\", 228 | " --exclude \"plugins/*/*\" \\", 229 | " --exclude \"plugins/*.bak\" \\", 230 | " --exclude \"war\" \\", 231 | " --exclude \"cache\"", 232 | "", 233 | "aws s3 cp $LOCAL_BACKUP $S3_TARGET", 234 | "rm -f $LOCAL_BACKUP" 235 | ]]}, 236 | "mode" : "000755", 237 | "owner" : "root", 238 | "group" : "root" 239 | }, 240 | 241 | "/etc/cron.d/jenkins" : { 242 | "content" : { "Fn::Join" : ["\n", [ 243 | "AWS_CONFIG_FILE=/etc/aws.conf", 244 | "PATH=/bin:/usr/bin::/usr/local/bin", 245 | "59 0 * * * root jenkins-backup /var/lib/jenkins s3://{{s3_bucket}}/{{s3_prefix}}jenkins-`date +\\%Y\\%m\\%d\\%H\\%M.tar.gz` >> /var/log/jenkins-backup.log 2>&1\n" 246 | ]]}, 247 | "context" : { 248 | "s3_bucket" : { "Ref" : "S3Bucket"}, 249 | "s3_prefix" : { "Ref" : "S3Prefix"} 250 | }, 251 | "mode" : "000700", 252 | "owner" : "root", 253 | "group" : "root" 254 | } 255 | 256 | } 257 | } 258 | } 259 | }, 260 | "Properties" : { 261 | "KeyName" : { "Ref" : "KeyName" }, 262 | "ImageId" : { "Fn::FindInMap" : [ "RegionMap", { "Ref" : "AWS::Region" }, "AMI"] }, 263 | "SecurityGroups" : [ { "Ref" : "ServerSecurityGroup" }, { "Ref": "AdminSecurityGroup" } ], 264 | "AssociatePublicIpAddress": "true", 265 | "InstanceType" : { "Ref" : "InstanceType" }, 266 | "UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [ 267 | "#!/bin/bash -ex\n", 268 | 269 | "# Helper function\n", 270 | "function error_exit\n", 271 | "{\n", 272 | " cfn-signal -e 1 -r \"$1\" '", { "Ref" : "WaitHandle" }, "'\n", 273 | " exit 1\n", 274 | "}\n", 275 | 276 | "cfn-init -s ", { "Ref" : "AWS::StackName" }, " -r LaunchConfig ", 277 | " --access-key ", { "Ref" : "HostKeys" }, 278 | " --secret-key ", {"Fn::GetAtt": ["HostKeys", "SecretAccessKey"]}, 279 | " --region ", { "Ref" : "AWS::Region" }, " || error_exit 'Failed to run cfn-init'\n", 280 | 281 | "# Post-cfn work\n", 282 | 283 | "# Handle case where cron doesn't detect the new /etc/cron.d file\n", 284 | "service cron restart\n", 285 | 286 | "# Attempt to restore from backup\n", 287 | "export AWS_CONFIG_FILE=/etc/aws.conf\n", 288 | "jenkins-restore s3://",{ "Ref": "S3Bucket" },"/",{ "Ref": "S3Prefix" }," /var/lib/jenkins || true # ignore errors\n", 289 | 290 | "# Start Jenkins\n", 291 | "docker pull ", { "Ref": "DockerImage" }, "\n", 292 | "runit-service create jenkins docker run", 293 | " -p 8080:8080", 294 | " -v /var/lib/jenkins:/jenkins", 295 | " ", { "Ref": "DockerImage" }, "|| error_exit 'Failed to launch Docker container'\n", 296 | "runit-service enable jenkins\n", 297 | 298 | "# All is well, signal success\n", 299 | "cfn-signal -e 0 -r \"Stack setup complete\" '", { "Ref" : "WaitHandle" }, "'\n", 300 | 301 | "#EOF" 302 | ]]}} 303 | } 304 | }, 305 | 306 | "LbSecurityGroup" : { 307 | "Type" : "AWS::EC2::SecurityGroup", 308 | "Properties" : { 309 | "GroupDescription" : "Jenkins LBs", 310 | "VpcId" : { "Ref" : "VpcId" } 311 | } 312 | }, 313 | 314 | "ServerSecurityGroup" : { 315 | "Type" : "AWS::EC2::SecurityGroup", 316 | "Properties" : { 317 | "GroupDescription" : "Jenkins servers", 318 | "VpcId" : { "Ref" : "VpcId" }, 319 | "SecurityGroupIngress" : 320 | [ { "IpProtocol" : "tcp", "FromPort" : "8080", "ToPort" : "8080", "SourceSecurityGroupId" : { "Ref" : "LbSecurityGroup"} }] 321 | } 322 | }, 323 | 324 | "ElasticLoadBalancer" : { 325 | "Type" : "AWS::ElasticLoadBalancing::LoadBalancer", 326 | "Properties" : { 327 | "SecurityGroups": [{ "Ref": "LbSecurityGroup" }, { "Ref": "AdminSecurityGroup" }], 328 | "Subnets": { "Ref": "Subnets" }, 329 | "Listeners" : [ { 330 | "LoadBalancerPort" : "80", 331 | "InstancePort" : "8080", 332 | "Protocol" : "HTTP" 333 | } ], 334 | "HealthCheck" : { 335 | "Target" : "HTTP:8080/", 336 | "HealthyThreshold" : "3", 337 | "UnhealthyThreshold" : "5", 338 | "Interval" : "30", 339 | "Timeout" : "5" 340 | } 341 | } 342 | }, 343 | 344 | "DnsRecord" : { 345 | "Type" : "AWS::Route53::RecordSet", 346 | "Properties" : { 347 | "HostedZoneName" : { "Fn::Join" : [ "", [{"Ref" : "DnsZone"}, "." ]]}, 348 | "Comment" : "Docker Registry", 349 | "Name" : { "Fn::Join" : [ "", [{"Ref" : "DnsPrefix"}, ".", {"Ref" : "DnsZone"}, "."]]}, 350 | "Type" : "CNAME", 351 | "TTL" : "900", 352 | "ResourceRecords" : [ { "Fn::GetAtt" : [ "ElasticLoadBalancer", "DNSName" ] } ] 353 | } 354 | }, 355 | 356 | "WaitHandle" : { 357 | "Type" : "AWS::CloudFormation::WaitConditionHandle" 358 | } 359 | }, 360 | 361 | "Outputs" : { 362 | "DnsAddress" : { 363 | "Description" : "Jenkins URL", 364 | "Value" : { "Fn::Join" : ["", [ 365 | "http://", { "Ref" : "DnsRecord" } 366 | ]]} 367 | } 368 | } 369 | 370 | } 371 | --------------------------------------------------------------------------------