diff --git a/build.tf b/build.tf index d0d1775..1632e62 100644 --- a/build.tf +++ b/build.tf @@ -106,7 +106,7 @@ resource "aws_codebuild_project" "docker" { environment { compute_type = "BUILD_GENERAL1_MEDIUM" - image = "aws/codebuild/standard:6.0" + image = "aws/codebuild/standard:7.0" type = "LINUX_CONTAINER" image_pull_credentials_type = "CODEBUILD" privileged_mode = true @@ -151,7 +151,7 @@ phases: - echo Logging in to Amazon ECR... - aws --version - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AVALON_DOCKER_REPO - - (test -z "$AVALON_COMMIT" && AVALON_COMMIT=`git ls-remote $AVALON_REPO refs/heads/$AVALON_BRANCH | cut -f 1`) || true + - test -z "$AVALON_COMMIT" && AVALON_COMMIT=`git ls-remote $AVALON_REPO refs/heads/$AVALON_BRANCH | cut -f 1` || true - AVALON_DOCKER_CACHE_TAG=$AVALON_COMMIT - docker pull $AVALON_DOCKER_REPO:$AVALON_DOCKER_CACHE_TAG || docker pull $AVALON_DOCKER_REPO:latest || true build: @@ -165,7 +165,7 @@ phases: - echo Pushing the Docker images... - docker push $AVALON_DOCKER_REPO:$AVALON_COMMIT - docker push $AVALON_DOCKER_REPO:latest - - aws ssm send-command --document-name "AWS-RunShellScript" --document-version "1" --targets '[{"Key":"InstanceIds","Values":["${aws_instance.compose.id}"]}]' --parameters '{"commands":["$(aws ecr get-login --region ${local.region} --no-include-email) && docker-compose pull && docker-compose up -d"],"workingDirectory":["/home/ec2-user/avalon-docker-aws_min"],"executionTimeout":["360"]}' --timeout-seconds 600 --max-concurrency "50" --max-errors "0" --cloud-watch-output-config '{"CloudWatchLogGroupName":"avalon-${var.environment}/ssm","CloudWatchOutputEnabled":true}' --region us-east-1 + - aws ssm send-command --document-name "AWS-RunShellScript" --document-version "1" --targets '[{"Key":"InstanceIds","Values":["${aws_instance.compose.id}"]}]' --parameters '{"commands":["$(aws ecr get-login --region ${local.region} --no-include-email) && docker-compose pull && docker-compose up -d"],"workingDirectory":["/home/ec2-user/avalon-docker"],"executionTimeout":["360"]}' --timeout-seconds 600 --max-concurrency "50" --max-errors "0" --cloud-watch-output-config '{"CloudWatchLogGroupName":"avalon-${var.environment}/ssm","CloudWatchOutputEnabled":true}' --region us-east-1 BUILDSPEC } diff --git a/compose.tf b/compose.tf index fc5def8..72954ea 100644 --- a/compose.tf +++ b/compose.tf @@ -36,6 +36,10 @@ data "aws_iam_policy_document" "compose" { } } +locals { + solr_data_device_name = "/dev/sdh" +} + resource "aws_iam_policy" "this_bucket_policy" { name = "${local.namespace}-compose-bucket-access" policy = data.aws_iam_policy_document.this_bucket_access.json @@ -189,45 +193,51 @@ resource "aws_instance" "compose" { } user_data = base64encode(templatefile("scripts/compose-init.sh", { - ec2_public_key = "${var.ec2_public_key}" - solr_backups_efs_id = "${aws_efs_file_system.solr_backups.id}" - solr_backups_efs_dns_name = "${aws_efs_file_system.solr_backups.dns_name}" - db_fcrepo_address = "${module.db_fcrepo.db_instance_address}" - db_fcrepo_username = "${module.db_fcrepo.db_instance_username}" - db_fcrepo_password = "${module.db_fcrepo.db_instance_password}" - db_fcrepo_port = "${module.db_fcrepo.db_instance_port}" - db_avalon_address = "${module.db_avalon.db_instance_address}" - db_avalon_username = "${module.db_avalon.db_instance_username}" - db_avalon_password = "${module.db_avalon.db_instance_password}" - fcrepo_binary_bucket_access_key = "${length(var.fcrepo_binary_bucket_username) > 0 ? var.fcrepo_binary_bucket_access_key : values(aws_iam_access_key.fcrepo_bin_created_access)[0].id }" - fcrepo_binary_bucket_secret_key = "${length(var.fcrepo_binary_bucket_username) > 0 ? var.fcrepo_binary_bucket_secret_key : values(aws_iam_access_key.fcrepo_bin_created_access)[0].secret }" - fcrepo_binary_bucket_id = "${aws_s3_bucket.fcrepo_binary_bucket.id}" - compose_log_group_name = "${aws_cloudwatch_log_group.compose_log_group.name}" - fcrepo_db_ssl = "${var.fcrepo_db_ssl}" - derivatives_bucket_id = "${aws_s3_bucket.this_derivatives.id}" - masterfiles_bucket_id = "${aws_s3_bucket.this_masterfiles.id}" - preservation_bucket_id = "${aws_s3_bucket.this_preservation.id}" - supplemental_files_bucket_id = "${aws_s3_bucket.this_supplemental_files.id}" - avalon_ecr_repository_url = "${aws_ecr_repository.avalon.repository_url}" - avalon_repo = "${var.avalon_repo}" - redis_host_name = "${aws_route53_record.redis.name}" - aws_region = "${var.aws_region}" - avalon_fqdn = "${length(var.alt_hostname) > 0 ? values(var.alt_hostname)[0].hostname : aws_route53_record.alb.fqdn}" - streaming_fqdn = "${aws_route53_record.alb_streaming.fqdn}" - elastictranscoder_pipeline_id = "${aws_elastictranscoder_pipeline.this_pipeline.id}" - email_comments = "${var.email_comments}" - email_notification = "${var.email_notification}" - email_support = "${var.email_support}" - avalon_admin = "${var.avalon_admin}" - bib_retriever_protocol = "${var.bib_retriever_protocol}" - bib_retriever_url = "${var.bib_retriever_url}" - bib_retriever_query = "${var.bib_retriever_query}" - bib_retriever_host = "${var.bib_retriever_host}" - bib_retriever_port = "${var.bib_retriever_port}" - bib_retriever_database = "${var.bib_retriever_database}" - bib_retriever_attribute = "${var.bib_retriever_attribute}" - bib_retriever_class = "${var.bib_retriever_class}" - bib_retriever_class_require = "${var.bib_retriever_class_require}" + ec2_public_key = var.ec2_public_key + ec2_users = var.ec2_users + solr_data_device_name = local.solr_data_device_name + solr_backups_efs_id = aws_efs_file_system.solr_backups.id + solr_backups_efs_dns_name = aws_efs_file_system.solr_backups.dns_name + db_fcrepo_address = module.db_fcrepo.db_instance_address + db_fcrepo_username = module.db_fcrepo.db_instance_username + db_fcrepo_password = module.db_fcrepo.db_instance_password + db_fcrepo_port = module.db_fcrepo.db_instance_port + db_avalon_address = module.db_avalon.db_instance_address + db_avalon_username = module.db_avalon.db_instance_username + db_avalon_password = module.db_avalon.db_instance_password + fcrepo_binary_bucket_access_key = length(var.fcrepo_binary_bucket_username) > 0 ? var.fcrepo_binary_bucket_access_key : values(aws_iam_access_key.fcrepo_bin_created_access)[0].id + fcrepo_binary_bucket_secret_key = length(var.fcrepo_binary_bucket_username) > 0 ? var.fcrepo_binary_bucket_secret_key : values(aws_iam_access_key.fcrepo_bin_created_access)[0].secret + fcrepo_binary_bucket_id = aws_s3_bucket.fcrepo_binary_bucket.id + compose_log_group_name = aws_cloudwatch_log_group.compose_log_group.name + fcrepo_db_ssl = var.fcrepo_db_ssl + derivatives_bucket_id = aws_s3_bucket.this_derivatives.id + masterfiles_bucket_id = aws_s3_bucket.this_masterfiles.id + preservation_bucket_id = aws_s3_bucket.this_preservation.id + supplemental_files_bucket_id = aws_s3_bucket.this_supplemental_files.id + avalon_ecr_repository_url = aws_ecr_repository.avalon.repository_url + avalon_repo = var.avalon_repo + avalon_docker_code_repo = var.avalon_docker_code_repo + avalon_docker_code_branch = var.avalon_docker_code_branch + avalon_docker_code_commit = var.avalon_docker_code_commit + redis_host_name = aws_route53_record.redis.name + aws_region = var.aws_region + avalon_fqdn = length(var.alt_hostname) > 0 ? values(var.alt_hostname)[0].hostname : aws_route53_record.alb.fqdn + streaming_fqdn = aws_route53_record.alb_streaming.fqdn + elastictranscoder_pipeline_id = aws_elastictranscoder_pipeline.this_pipeline.id + email_comments = var.email_comments + email_notification = var.email_notification + email_support = var.email_support + avalon_admin = var.avalon_admin + bib_retriever_protocol = var.bib_retriever_protocol + bib_retriever_url = var.bib_retriever_url + bib_retriever_query = var.bib_retriever_query + bib_retriever_host = var.bib_retriever_host + bib_retriever_port = var.bib_retriever_port + bib_retriever_database = var.bib_retriever_database + bib_retriever_attribute = var.bib_retriever_attribute + bib_retriever_class = var.bib_retriever_class + bib_retriever_class_require = var.bib_retriever_class_require + extra_docker_environment_variables = var.extra_docker_environment_variables })) vpc_security_group_ids = [ @@ -236,16 +246,14 @@ resource "aws_instance" "compose" { aws_security_group.public_ip.id, ] - lifecycle { - ignore_changes = [ami, user_data] - } + user_data_replace_on_change = true } resource "aws_route53_record" "ec2_hostname" { zone_id = module.dns.public_zone_id name = local.ec2_hostname type = "A" - ttl = 300 + ttl = 30 records = [aws_instance.compose.public_ip] } @@ -288,7 +296,7 @@ resource "aws_cloudwatch_log_group" "compose_log_group" { } resource "aws_volume_attachment" "compose_solr" { - device_name = "/dev/sdh" + device_name = local.solr_data_device_name volume_id = aws_ebs_volume.solr_data.id instance_id = aws_instance.compose.id } diff --git a/scripts/compose-init.sh b/scripts/compose-init.sh index f5ae7c7..0fab42d 100644 --- a/scripts/compose-init.sh +++ b/scripts/compose-init.sh @@ -1,37 +1,99 @@ #!/bin/bash +# +# Check the output from this script on the EC2 VM with: +# +# journalctl -u cloud-final +# + +declare -r SOLR_DATA_DEVICE=${solr_data_device_name} + +# +# Add and configure users. +# # Add SSH public key if var was set if [[ -n "${ec2_public_key}" ]]; then - # But first ensure existance and correct permissions - sudo -Hu ec2-user bash <<- EOF - umask 0077 - mkdir -p /home/ec2-user/.ssh - touch /home/ec2-user/.ssh/authorized_keys - EOF - echo "${ec2_public_key}" >> /home/ec2-user/.ssh/authorized_keys + install -d -m 0755 -o ec2-user -g ec2-user ~ec2-user/.ssh + install -m 0644 -o ec2-user -g ec2-user /dev/null ~ec2-user/.ssh/authorized_keys + printf %s\\n "${ec2_public_key}" >>~ec2-user/.ssh/authorized_keys fi -# Create filesystem only if there isn't one -if [[ ! `sudo file -s /dev/xvdh` == *"Linux"* ]]; then - sudo mkfs -t ext4 /dev/xvdh +groupadd --system docker + +# Allow all users in the wheel group to run all commands without a password. +sed -i 's/^# \(%wheel\s\+ALL=(ALL)\s\+NOPASSWD: ALL$\)/\1/' /etc/sudoers + +# The EC2 user's home directory can safely be made world-readable +chmod 0755 ~ec2-user + +%{ for username, user_config in ec2_users ~} +useradd --comment "${user_config.gecos}" --groups adm,wheel,docker "${username}" +install -d -o "${username}" -g "${username}" ~${username}/.ssh +install -m 0644 -o "${username}" -g "${username}" \ + /dev/null ~${username}/.ssh/authorized_keys +%{ for ssh_key in user_config.ssh_keys ~} +printf %s\\n "${ssh_key}" >>~${username}/.ssh/authorized_keys +%{ endfor ~} +%{ for setup_command in user_config.setup_commands ~} +${setup_command} +%{ endfor } +%{ endfor ~} + +# +# Configure filesystems. +# + +# Only format the Solr disk if it's blank. +blkid --probe "$SOLR_DATA_DEVICE" -o export | grep -qE "^PTTYPE=|^TYPE=" || + mkfs -t ext4 "$SOLR_DATA_DEVICE" + +install -d -m 0 /srv/solr_data +echo "$SOLR_DATA_DEVICE /srv/solr_data ext4 defaults 0 2" >>/etc/fstab +# If the mountpoint couldn't be mounted, leave it mode 0 so Solr will fail +# safely. +if mount /srv/solr_data; then + chown -R 8983:8983 /srv/solr_data +else + echo "Error: Could not mount solr_data EBS volume." >&2 fi -sudo mkdir /srv/solr_data -sudo mount /dev/xvdh /srv/solr_data -sudo chown -R 8983:8983 /srv/solr_data -sudo echo /dev/xvdh /srv/solr_data ext4 defaults,nofail 0 2 >> /etc/fstab - -# Setup -echo '${solr_backups_efs_id}:/ /srv/solr_backups nfs nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport,_netdev 0 0' | sudo tee -a /etc/fstab -sudo mkdir -p /srv/solr_backups && sudo mount -t nfs -o nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport,_netdev ${solr_backups_efs_dns_name}:/ /srv/solr_backups -sudo chown 8983:8983 /srv/solr_backups -sudo yum install -y docker && sudo usermod -a -G docker ec2-user && sudo systemctl enable --now docker -sudo curl -L https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose -sudo chmod +x /usr/local/bin/docker-compose - -sudo wget https://github.com/avalonmediasystem/avalon-docker/archive/aws_min.zip -O /home/ec2-user/aws_min.zip && cd /home/ec2-user && unzip aws_min.zip -# Create .env file -sudo cat << EOF > /home/ec2-user/avalon-docker-aws_min/.env +install -d -m 0 /srv/solr_backups +echo '${solr_backups_efs_id}:/ /srv/solr_backups nfs _netdev 0 0' >>/etc/fstab +if mount /srv/solr_backups; then + chown -R 8983:8983 /srv/solr_backups +else + echo "Error: Could not mount solr_backups EFS volume." >&2 +fi + +# +# Install Avalon dependencies. +# + +yum install -y docker +systemctl enable --now docker +usermod -a -G docker ec2-user + +tmp=$(mktemp -d) || exit 1 +curl -L "$(printf %s "https://github.com/docker/compose/releases/latest/" \ + "download/docker-compose-$(uname -s)-$(uname -m)" )" \ + -o "$tmp/docker-compose" && + install -t /usr/local/bin "$tmp/docker-compose" +rm -rf -- "$tmp" +unset tmp + +declare -r AVALON_DOCKER_CHECKOUT_NAME=%{ if avalon_docker_code_commit != "" }${avalon_docker_code_commit}%{ else }${avalon_docker_code_branch}%{ endif } +curl -L ${avalon_docker_code_repo}/archive/$AVALON_DOCKER_CHECKOUT_NAME.zip | + install -m 0644 -o ec2-user -g ec2-user /dev/stdin ~ec2-user/avalon-docker.zip && + setpriv --reuid ec2-user --regid ec2-user --clear-groups -- \ + unzip -d ~ec2-user ~ec2-user/avalon-docker.zip +mv ~ec2-user/avalon-docker-$AVALON_DOCKER_CHECKOUT_NAME ~ec2-user/avalon-docker + +# +# Set up Avalon. +# + +install -m 0600 -o ec2-user -g ec2-user \ + /dev/stdin ~ec2-user/avalon-docker/.env <&- | head -c 64) AVALON_BRANCH=main AWS_REGION=${aws_region} RAILS_LOG_TO_STDOUT=true @@ -81,5 +143,7 @@ SETTINGS__ACTIVE_STORAGE__SERVICE=amazon SETTINGS__ACTIVE_STORAGE__BUCKET=${supplemental_files_bucket_id} CDN_HOST=https://${avalon_fqdn} +%{ for key, value in extra_docker_environment_variables ~} +${key}=${value} +%{ endfor ~} EOF -sudo chown -R ec2-user /home/ec2-user/avalon-docker-aws_min diff --git a/terraform.tfvars.example b/terraform.tfvars.example index e9bb843..2d58be9 100644 --- a/terraform.tfvars.example +++ b/terraform.tfvars.example @@ -1,9 +1,26 @@ environment = "dev" #zone_prefix = "" hosted_zone_name = "mydomain.org" -# At least one of ec2_keyname or ec2_public_key must be set +# At least one of 'ec2_keyname' or 'ec2_public_key', or 'ec2_users' must be set +# for you to have access to your EC2 instance. #ec2_keyname = "my-ec2-key" #ec2_public_key = "" +#ec2_users = [ +# user = { +# ssh_keys = [ +# "ssh-ed25519 ..." +# ] +# } +# another = { +# gecos = "Another User" +# ssh_keys = [ +# "ssh-rsa ..." +# ] +# setup_commands = [ +# "echo 'set editing-mode vi' | install -m 0644 -o another -g another /dev/stdin ~another/.inputrc" +# ] +# } +#] stack_name = "mystack" ssh_cidr_blocks = [] # If the user below is empty, Terraform will attempt to diff --git a/variables.tf b/variables.tf index f0a0089..8c7f054 100644 --- a/variables.tf +++ b/variables.tf @@ -57,6 +57,21 @@ variable "avalon_commit" { default = "" } +variable "avalon_docker_code_repo" { + description = "The avalon-docker repository to pull when running docker-compose" + default = "https://github.com/avalonmediasystem/avalon-docker" +} + +variable "avalon_docker_code_branch" { + description = "The avalon-docker branch to use when running docker-compose" + default = "aws_min" +} + +variable "avalon_docker_code_commit" { + description = "The full avalon-docker commit hash to use when running docker-compose (empty defaults to most recent for the avalon_docker_code_branch)" + default = "" +} + variable "bib_retriever_protocol" { default = "sru" } @@ -117,7 +132,7 @@ variable "db_fcrepo_username" { variable "ec2_keyname" { type = string - default = "" + default = null description = "The name of an AWS EC2 key pair to use for authenticating" } @@ -127,6 +142,15 @@ variable "ec2_public_key" { description = "A SSH public key string to use for authenticating" } +variable "ec2_users" { + type = map(object({ + gecos = optional(string, "") + ssh_keys = optional(list(string), []) + setup_commands = optional(list(string), []) + })) + default = {} +} + variable "email_comments" { type = string } @@ -143,6 +167,12 @@ variable "environment" { type = string } +variable "extra_docker_environment_variables" { + description = "These are passed in to the compose-init.sh script" + type = map(string) + default = {} +} + variable "fcrepo_binary_bucket_username" { type = string default = "" @@ -172,7 +202,7 @@ variable "hosted_zone_name" { } variable "postgres_version" { - default = "14.10" + default = "14.12" } #variable "sms_notification" {