diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..815de96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.next +.swc +node_modules +test-results +playwright-report +coverage +.env +.env.* +!.env.example +*.log + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c6d301..e6ecdf2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,60 +4,234 @@ on: push: branches: - main + - App/aws-replica + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: encodex-production + cancel-in-progress: false + +env: + AWS_REGION: us-east-2 + AWS_PAGER: "" + ECR_REPOSITORY: encodex + ENV_PARAMETER_NAME: /encodex/env jobs: build-and-deploy: name: Build, Push, Deploy runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure AWS credentials with GitHub OIDC + uses: aws-actions/configure-aws-credentials@v6 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-2 + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + role-session-name: encodex-${{ github.run_id }}-${{ github.run_attempt }} + aws-region: ${{ env.AWS_REGION }} + unset-current-credentials: true - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - - name: Build and push image to ECR + - name: Build, migrate, and push image env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} IMAGE_TAG: ${{ github.sha }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + shell: bash run: | - docker build -f docker/Dockerfile \ - --build-arg JWT_SECRET="${{ secrets.JWT_SECRET }}" \ - --build-arg DATABASE_URL="${{ secrets.DATABASE_URL }}" \ - -t $ECR_REGISTRY/encodex:$IMAGE_TAG \ - -t $ECR_REGISTRY/encodex:latest . - docker push $ECR_REGISTRY/encodex:$IMAGE_TAG - docker push $ECR_REGISTRY/encodex:latest - - - name: Deploy to EC2 via SSH - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_SSH_KEY }} - request_pty: true - script: | - echo '${{ secrets.ENV_FILE }}' > /tmp/.env - sudo kubectl create secret generic encodex-secrets \ - --from-env-file=/tmp/.env \ - --dry-run=client -o yaml | sudo kubectl apply -f - - rm /tmp/.env - ECR_TOKEN=$(aws ecr get-login-password --region us-east-2) - sudo kubectl create secret docker-registry ecr-secret \ - --docker-server=509194952795.dkr.ecr.us-east-2.amazonaws.com \ - --docker-username=AWS \ - --docker-password=$ECR_TOKEN \ - --dry-run=client -o yaml | sudo kubectl apply -f - - sudo kubectl apply -f /home/ubuntu/k8s/deployment.yml - sudo kubectl apply -f /home/ubuntu/k8s/service.yml - sudo kubectl set image deployment/encodex-app encodex=${{ steps.login-ecr.outputs.registry }}/encodex:${{ github.sha }} - sudo kubectl rollout status deployment/encodex-app --timeout=120s \ No newline at end of file + set -Eeuo pipefail + + docker build \ + -f docker/Dockerfile \ + --build-arg DATABASE_URL \ + --build-arg JWT_SECRET \ + --tag "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" \ + --tag "$ECR_REGISTRY/$ECR_REPOSITORY:latest" \ + . + + docker build \ + -f docker/Dockerfile \ + --target deps \ + --tag "encodex-migrations:$IMAGE_TAG" \ + . + + docker run --rm \ + --env DATABASE_URL \ + "encodex-migrations:$IMAGE_TAG" \ + npx prisma migrate deploy + + docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" + docker push "$ECR_REGISTRY/$ECR_REPOSITORY:latest" + + - name: Update runtime environment parameter + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + shell: bash + run: | + set -Eeuo pipefail + + env_path="$(mktemp "$RUNNER_TEMP/encodex-env.XXXXXX")" + trap 'rm -f -- "$env_path"' EXIT + chmod 600 "$env_path" + + if [[ -z "$DATABASE_URL" || -z "$JWT_SECRET" ]]; then + echo "::error::DATABASE_URL and JWT_SECRET must both be configured." + exit 1 + fi + + if [[ "$DATABASE_URL" == *$'\n'* || "$DATABASE_URL" == *$'\r'* || + "$JWT_SECRET" == *$'\n'* || "$JWT_SECRET" == *$'\r'* ]]; then + echo "::error::Runtime secret values must each be a single line." + exit 1 + fi + + printf 'DATABASE_URL=%s\nJWT_SECRET=%s\n' \ + "$DATABASE_URL" "$JWT_SECRET" > "$env_path" + + env_bytes="$(wc -c < "$env_path" | tr -d '[:space:]')" + if (( env_bytes == 0 || env_bytes > 4096 )); then + echo "::error::Runtime configuration must contain 1-4096 bytes." + exit 1 + fi + + aws ssm put-parameter \ + --name "$ENV_PARAMETER_NAME" \ + --type SecureString \ + --tier Standard \ + --value "file://$env_path" \ + --overwrite \ + >/dev/null + + - name: Deploy through SSM Run Command + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + shell: bash + run: | + set -Eeuo pipefail + + if [[ ! "$INSTANCE_ID" =~ ^i-[0-9a-f]{8,17}$ ]]; then + echo "::error::EC2_INSTANCE_ID is missing or invalid." + exit 1 + fi + + if [[ ! "$IMAGE_TAG" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Unexpected Git commit SHA." + exit 1 + fi + + image_uri="$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" + deployment_b64="$(base64 -w 0 < k8s/deployment.yml)" + service_b64="$(base64 -w 0 < k8s/service.yml)" + parameters_file="$(mktemp "$RUNNER_TEMP/ssm-parameters.XXXXXX.json")" + trap 'rm -f -- "$parameters_file"' EXIT + + jq -n \ + --arg region "$AWS_REGION" \ + --arg registry "$ECR_REGISTRY" \ + --arg image "$image_uri" \ + --arg parameter "$ENV_PARAMETER_NAME" \ + --arg deployment "$deployment_b64" \ + --arg service "$service_b64" \ + '{ + commands: [ + "exec /usr/bin/env bash <<\u0027ENCODEX_DEPLOY_SCRIPT\u0027", + "set -Eeuo pipefail", + ("AWS_REGION=" + ($region | @sh)), + ("ECR_REGISTRY=" + ($registry | @sh)), + ("IMAGE_URI=" + ($image | @sh)), + ("ENV_PARAMETER_NAME=" + ($parameter | @sh)), + ("DEPLOYMENT_B64=" + ($deployment | @sh)), + ("SERVICE_B64=" + ($service | @sh)), + "umask 077", + "work_dir=$(mktemp -d /tmp/encodex-deploy.XXXXXX)", + "cleanup() { rm -rf -- \"$work_dir\"; }", + "trap cleanup EXIT", + "printf \"%s\" \"$DEPLOYMENT_B64\" | base64 --decode > \"$work_dir/deployment.yml\"", + "printf \"%s\" \"$SERVICE_B64\" | base64 --decode > \"$work_dir/service.yml\"", + "sed -i -E \"0,/^[[:space:]]*image:[[:space:]]*/s#^([[:space:]]*image:[[:space:]]*).*\\$#\\1$IMAGE_URI#\" \"$work_dir/deployment.yml\"", + "aws ssm get-parameter --region \"$AWS_REGION\" --name \"$ENV_PARAMETER_NAME\" --with-decryption --output json | jq -er \".Parameter.Value\" > \"$work_dir/app.env\"", + "sed -i \"s/\\r$//\" \"$work_dir/app.env\"", + "grep -qE \"^DATABASE_URL=.+$\" \"$work_dir/app.env\"", + "grep -qE \"^JWT_SECRET=.+$\" \"$work_dir/app.env\"", + "/usr/local/bin/k3s kubectl create secret generic encodex-secrets --from-env-file=\"$work_dir/app.env\" --dry-run=client -o yaml | /usr/local/bin/k3s kubectl apply -f -", + "ECR_TOKEN=$(aws ecr get-login-password --region \"$AWS_REGION\")", + "/usr/local/bin/k3s kubectl create secret docker-registry ecr-secret --docker-server=\"$ECR_REGISTRY\" --docker-username=AWS --docker-password=\"$ECR_TOKEN\" --dry-run=client -o yaml | /usr/local/bin/k3s kubectl apply -f -", + "unset ECR_TOKEN", + "/usr/local/bin/k3s kubectl apply -f \"$work_dir/service.yml\"", + "/usr/local/bin/k3s kubectl apply -f \"$work_dir/deployment.yml\"", + "/usr/local/bin/k3s kubectl rollout restart deployment/encodex-app", + "/usr/local/bin/k3s kubectl rollout status deployment/encodex-app --timeout=180s", + "for attempt in {1..15}; do if curl -fsS http://127.0.0.1:30180/login >/dev/null; then break; fi; if (( attempt == 15 )); then exit 1; fi; sleep 2; done", + "/usr/local/bin/k3s kubectl get deployment encodex-app -o jsonpath=\"{.spec.template.spec.containers[?(@.name==\\\"encodex\\\")].image}\"", + "printf \"\\n\"", + "ENCODEX_DEPLOY_SCRIPT" + ], + executionTimeout: ["300"] + }' > "$parameters_file" + + command_id="$( + aws ssm send-command \ + --region "$AWS_REGION" \ + --document-name AWS-RunShellScript \ + --instance-ids "$INSTANCE_ID" \ + --comment "Encodex deployment $IMAGE_TAG" \ + --timeout-seconds 60 \ + --parameters "file://$parameters_file" \ + --query Command.CommandId \ + --output text + )" + + echo "SSM command: $command_id" + + status="" + deadline=$((SECONDS + 360)) + while (( SECONDS < deadline )); do + status="$( + aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query Status \ + --output text \ + 2>/dev/null || true + )" + + case "$status" in + Success) + break + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + break + ;; + esac + done + + aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query '{Status:Status,Output:StandardOutputContent,Error:StandardErrorContent}' \ + --output json || true + + if [[ "$status" != "Success" ]]; then + echo "::error::SSM deployment failed with status: ${status:-unknown}" + exit 1 + fi diff --git a/infra/aws/README.md b/infra/aws/README.md new file mode 100644 index 0000000..8efb79d --- /dev/null +++ b/infra/aws/README.md @@ -0,0 +1,92 @@ +# Encodex AWS replica + +This stack reproduces the current Encodex runtime at essentially the same cost: + +- one `t3.small` Ubuntu 22.04 EC2 instance in `us-east-2` +- one 8 GiB root volume +- single-node k3s with the existing Kubernetes manifests +- Caddy in front of the k3s NodePort +- one private ECR repository +- one public IPv4 address + +The replica deliberately replaces long-lived AWS/SSH keys with GitHub OIDC and +AWS Systems Manager. It also closes public SSH and NodePort access, encrypts the +disk, uses `gp3`, and gives the server a stable Elastic IP. These changes do not +materially increase cost or change application behavior. + +## Deploy the infrastructure + +The target account must have a default VPC and public subnet in `us-east-2`. + +```powershell +aws cloudformation deploy ` + --profile encodex-new ` + --region us-east-2 ` + --stack-name encodex ` + --template-file infra/aws/encodex-replica.yml ` + --capabilities CAPABILITY_NAMED_IAM ` + --parameter-overrides ` + VpcId=vpc-06c498b1a5b641dfb ` + PublicSubnetId=subnet-0d53064c3df4bdd1c +``` + +CloudFormation installs k3s, Caddy, AWS CLI, and the SSM agent. The initial +Caddy configuration serves plain HTTP through the Elastic IP so the replica can +be tested before DNS changes. + +## Configure GitHub + +Set these repository variables from the CloudFormation outputs: + +- `AWS_DEPLOY_ROLE_ARN` +- `EC2_INSTANCE_ID` + +Configure these repository secrets: + +- `DATABASE_URL` +- `JWT_SECRET` + +The workflow combines those two secrets into the encrypted runtime parameter; +the legacy `ENV_FILE` secret is no longer used. `DATABASE_URL` must be Railway's +public PostgreSQL TCP-proxy URL, for example: + +```dotenv +DATABASE_URL= +``` + +A `postgres.railway.internal` hostname cannot be reached from AWS. If the +current GitHub secrets still deploy the working demo, reuse them without reading +or replacing their values. + +## Deploy and validate before DNS + +The workflow builds the image, applies the checked-in Prisma migrations, pushes +to the new ECR repository, stores the two runtime secrets as the Standard +SecureString `/encodex/env`, and deploys through SSM. + +Before changing DNS, validate: + +```text +http:///login +``` + +## Namecheap cutover + +After the HTTP replica is healthy: + +1. Change the Namecheap root `A` record (`@`) to the stack's `ElasticIp` output. +2. Wait until `encodexdrive.com` resolves to the new address. +3. Through SSM, switch Caddy to its production configuration: + +```bash +sudo ln -sfn /etc/caddy/Caddyfile.production /etc/caddy/Caddyfile +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +4. Verify `https://encodexdrive.com/login` and an authenticated database-backed + API request. +5. Keep the old EC2 instance running for a rollback window. + +The old AWS credentials and SSH GitHub secrets should only be removed after the +new deployment and DNS cutover have been stable. diff --git a/infra/aws/encodex-replica.yml b/infra/aws/encodex-replica.yml new file mode 100644 index 0000000..5fefe49 --- /dev/null +++ b/infra/aws/encodex-replica.yml @@ -0,0 +1,459 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: >- + Cost-equivalent Encodex replica: one Ubuntu EC2 host running k3s and Caddy, + ECR, GitHub Actions OIDC, SSM deployment, and a stable Elastic IP. + +Parameters: + VpcId: + Type: AWS::EC2::VPC::Id + Description: Default VPC in us-east-2. + + PublicSubnetId: + Type: AWS::EC2::Subnet::Id + Description: Public subnet in us-east-2c. + + AmiId: + Type: AWS::EC2::Image::Id + Default: ami-0503ed50b531cc445 + Description: Canonical Ubuntu 22.04 amd64 AMI used by the current Encodex host. + + InstanceType: + Type: String + Default: t3.small + AllowedValues: + - t3.small + + K3sVersion: + Type: String + Default: v1.36.3+k3s1 + + DomainName: + Type: String + Default: encodexdrive.com + + GitHubOwner: + Type: String + Default: lhd2156 + + GitHubRepository: + Type: String + Default: Encodex + + GitHubBootstrapBranch: + Type: String + Default: App/aws-replica + Description: Temporary branch permitted to run the initial deployment. + +Resources: + EncodexRepository: + Type: AWS::ECR::Repository + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + RepositoryName: encodex + ImageTagMutability: MUTABLE + ImageScanningConfiguration: + ScanOnPush: true + EncryptionConfiguration: + EncryptionType: AES256 + Tags: + - Key: Project + Value: Encodex + - Key: ManagedBy + Value: CloudFormation + + EncodexSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Public HTTP and HTTPS for Encodex; administration uses SSM. + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: 0.0.0.0/0 + Description: HTTP and ACME validation + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: 0.0.0.0/0 + Description: HTTPS + SecurityGroupEgress: + - IpProtocol: "-1" + CidrIp: 0.0.0.0/0 + Description: Package installation, SSM, ECR, and Railway PostgreSQL + Tags: + - Key: Name + Value: encodex-web + - Key: Project + Value: Encodex + - Key: ManagedBy + Value: CloudFormation + + EncodexInstanceRole: + Type: AWS::IAM::Role + Properties: + RoleName: encodex-instance + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore + Policies: + - PolicyName: EncodexRuntimeAccess + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: EcrAuthorization + Effect: Allow + Action: ecr:GetAuthorizationToken + Resource: "*" + - Sid: PullEncodexImage + Effect: Allow + Action: + - ecr:BatchCheckLayerAvailability + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: !GetAtt EncodexRepository.Arn + - Sid: ReadRuntimeEnvironment + Effect: Allow + Action: ssm:GetParameter + Resource: !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/encodex/env + - Sid: SignalBootstrapCompletion + Effect: Allow + Action: cloudformation:SignalResource + Resource: !Sub arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/* + Tags: + - Key: Project + Value: Encodex + - Key: ManagedBy + Value: CloudFormation + + EncodexInstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + InstanceProfileName: encodex-instance + Roles: + - !Ref EncodexInstanceRole + + EncodexInstance: + Type: AWS::EC2::Instance + CreationPolicy: + ResourceSignal: + Count: 1 + Timeout: PT20M + Properties: + ImageId: !Ref AmiId + InstanceType: !Ref InstanceType + IamInstanceProfile: !Ref EncodexInstanceProfile + Monitoring: false + CreditSpecification: + CPUCredits: unlimited + MetadataOptions: + HttpEndpoint: enabled + HttpTokens: required + HttpPutResponseHopLimit: 1 + InstanceMetadataTags: enabled + NetworkInterfaces: + - DeviceIndex: "0" + AssociatePublicIpAddress: true + DeleteOnTermination: true + SubnetId: !Ref PublicSubnetId + GroupSet: + - !Ref EncodexSecurityGroup + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: 8 + VolumeType: gp3 + Encrypted: true + DeleteOnTermination: true + UserData: + Fn::Base64: !Sub | + #!/usr/bin/env bash + set -Eeuo pipefail + umask 027 + + mkdir -p /var/lib/encodex + exec > >(tee -a /var/log/encodex-bootstrap.log | logger -t encodex-bootstrap -s 2>/dev/console) 2>&1 + + on_error() { + rc=$? + printf 'failed_at=%s exit_code=%s line=%s\n' "$(date -u +%FT%TZ)" "$rc" "$LINENO" > /var/lib/encodex/bootstrap.failed + if command -v aws >/dev/null 2>&1; then + aws cloudformation signal-resource \ + --stack-name '${AWS::StackName}' \ + --logical-resource-id EncodexInstance \ + --unique-id "$(hostname)" \ + --status FAILURE \ + --region '${AWS::Region}' || true + fi + exit "$rc" + } + trap on_error ERR + + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y \ + apt-transport-https \ + ca-certificates \ + curl \ + debian-archive-keyring \ + debian-keyring \ + gettext-base \ + gnupg \ + jq \ + snapd \ + unzip + + if ! command -v aws >/dev/null 2>&1; then + curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o /tmp/awscliv2.zip + unzip -q /tmp/awscliv2.zip -d /tmp + /tmp/aws/install --update + rm -rf /tmp/aws /tmp/awscliv2.zip + fi + + if ! snap list amazon-ssm-agent >/dev/null 2>&1; then + snap install amazon-ssm-agent --classic + fi + systemctl enable --now snap.amazon-ssm-agent.amazon-ssm-agent.service + + install -d -m 0755 /etc/rancher/k3s + cat > /etc/rancher/k3s/config.yaml <<'K3S_CONFIG' + write-kubeconfig-mode: "0600" + secrets-encryption: true + disable: + - traefik + - servicelb + K3S_CONFIG + + curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION='${K3sVersion}' sh - + + curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \ + > /etc/apt/sources.list.d/caddy-stable.list + chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg + chmod o+r /etc/apt/sources.list.d/caddy-stable.list + apt-get update + apt-get install -y caddy + + cat > /etc/caddy/Caddyfile.pre-dns <<'CADDY_PRE_DNS' + :80 { + encode zstd gzip + reverse_proxy 127.0.0.1:30180 + } + CADDY_PRE_DNS + + cat > /etc/caddy/Caddyfile.production <<'CADDY_PRODUCTION' + ${DomainName} { + encode zstd gzip + reverse_proxy 127.0.0.1:30180 + } + CADDY_PRODUCTION + + chown root:caddy /etc/caddy/Caddyfile.pre-dns /etc/caddy/Caddyfile.production + chmod 0640 /etc/caddy/Caddyfile.pre-dns /etc/caddy/Caddyfile.production + ln -sfn /etc/caddy/Caddyfile.pre-dns /etc/caddy/Caddyfile + caddy validate --config /etc/caddy/Caddyfile + systemctl enable caddy + systemctl restart caddy + + cat > /usr/local/sbin/refresh-encodex-ecr-secret <<'REFRESH_SCRIPT' + #!/usr/bin/env bash + set -Eeuo pipefail + umask 077 + + region='${AWS::Region}' + account_id=$(aws sts get-caller-identity --query Account --output text) + registry=$account_id.dkr.ecr.$region.amazonaws.com + ecr_password=$(aws ecr get-login-password --region "$region") + + /usr/local/bin/k3s kubectl create secret docker-registry ecr-secret \ + --docker-server="$registry" \ + --docker-username=AWS \ + --docker-password="$ecr_password" \ + --dry-run=client -o yaml \ + | /usr/local/bin/k3s kubectl apply -f - + + unset ecr_password + REFRESH_SCRIPT + chmod 0750 /usr/local/sbin/refresh-encodex-ecr-secret + + cat > /etc/systemd/system/encodex-ecr-refresh.service <<'REFRESH_SERVICE' + [Unit] + Description=Refresh the Encodex ECR Kubernetes pull secret + After=k3s.service network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + ExecStart=/usr/local/sbin/refresh-encodex-ecr-secret + REFRESH_SERVICE + + cat > /etc/systemd/system/encodex-ecr-refresh.timer <<'REFRESH_TIMER' + [Unit] + Description=Refresh the Encodex ECR pull secret every six hours + + [Timer] + OnBootSec=2min + OnUnitActiveSec=6h + RandomizedDelaySec=5min + Persistent=true + + [Install] + WantedBy=timers.target + REFRESH_TIMER + + systemctl daemon-reload + systemctl enable --now encodex-ecr-refresh.timer + + systemctl is-active --quiet k3s + systemctl is-active --quiet caddy + systemctl is-active --quiet snap.amazon-ssm-agent.amazon-ssm-agent.service + rm -f /var/lib/encodex/bootstrap.failed + touch /var/lib/encodex/bootstrap.ready + aws cloudformation signal-resource \ + --stack-name '${AWS::StackName}' \ + --logical-resource-id EncodexInstance \ + --unique-id "$(hostname)" \ + --status SUCCESS \ + --region '${AWS::Region}' + Tags: + - Key: Name + Value: encodex + - Key: Project + Value: Encodex + - Key: ManagedBy + Value: CloudFormation + + EncodexElasticIp: + Type: AWS::EC2::EIP + Properties: + Domain: vpc + Tags: + - Key: Name + Value: encodex + - Key: Project + Value: Encodex + - Key: ManagedBy + Value: CloudFormation + + EncodexElasticIpAssociation: + Type: AWS::EC2::EIPAssociation + Properties: + AllocationId: !GetAtt EncodexElasticIp.AllocationId + InstanceId: !Ref EncodexInstance + + GitHubOidcProvider: + Type: AWS::IAM::OIDCProvider + Properties: + Url: https://token.actions.githubusercontent.com + ClientIdList: + - sts.amazonaws.com + Tags: + - Key: Project + Value: Encodex + - Key: ManagedBy + Value: CloudFormation + + GitHubDeployRole: + Type: AWS::IAM::Role + Properties: + RoleName: encodex-github-deploy + MaxSessionDuration: 3600 + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Federated: !Ref GitHubOidcProvider + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + token.actions.githubusercontent.com:aud: sts.amazonaws.com + token.actions.githubusercontent.com:sub: + - !Sub repo:${GitHubOwner}/${GitHubRepository}:ref:refs/heads/main + - !Sub repo:${GitHubOwner}/${GitHubRepository}:ref:refs/heads/${GitHubBootstrapBranch} + Policies: + - PolicyName: EncodexDeployment + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: EcrAuthorization + Effect: Allow + Action: ecr:GetAuthorizationToken + Resource: "*" + - Sid: PushEncodexImage + Effect: Allow + Action: + - ecr:BatchCheckLayerAvailability + - ecr:BatchGetImage + - ecr:CompleteLayerUpload + - ecr:InitiateLayerUpload + - ecr:PutImage + - ecr:UploadLayerPart + Resource: !GetAtt EncodexRepository.Arn + - Sid: UpdateRuntimeEnvironment + Effect: Allow + Action: ssm:PutParameter + Resource: !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/encodex/env + - Sid: RunDeployment + Effect: Allow + Action: ssm:SendCommand + Resource: + - !Sub arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-RunShellScript + - !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/${EncodexInstance} + - Sid: PollDeployment + Effect: Allow + Action: ssm:GetCommandInvocation + Resource: "*" + Tags: + - Key: Project + Value: Encodex + - Key: ManagedBy + Value: CloudFormation + +Outputs: + AccountId: + Value: !Ref AWS::AccountId + + AwsRegion: + Value: !Ref AWS::Region + + EcrRepositoryName: + Value: !Ref EncodexRepository + + EcrRepositoryUri: + Value: !GetAtt EncodexRepository.RepositoryUri + + InstanceId: + Value: !Ref EncodexInstance + + InstancePrivateIp: + Value: !GetAtt EncodexInstance.PrivateIp + + ElasticIp: + Value: !Ref EncodexElasticIp + + SecurityGroupId: + Value: !Ref EncodexSecurityGroup + + GitHubDeployRoleArn: + Value: !GetAtt GitHubDeployRole.Arn + + RuntimeParameterName: + Value: /encodex/env + + PublicHttpUrl: + Value: !Sub http://${EncodexElasticIp} + + ApplicationUrl: + Value: !Sub https://${DomainName} + + NamecheapRootRecord: + Value: !Sub A @ ${EncodexElasticIp} diff --git a/k8s/deployment.yml b/k8s/deployment.yml index bf0c5e8..d5ede99 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -19,7 +19,9 @@ spec: - name: ecr-secret containers: - name: encodex - image: 509194952795.dkr.ecr.us-east-2.amazonaws.com/encodex:latest + # The deployment workflow replaces this neutral image reference with + # the immutable ECR image URI for the target AWS account. + image: encodex:latest ports: - containerPort: 3000 env: @@ -53,4 +55,4 @@ spec: path: /login port: 3000 initialDelaySeconds: 15 - periodSeconds: 10 \ No newline at end of file + periodSeconds: 10