diff --git a/.docker/production/Dockerfile.gha b/.docker/production/Dockerfile.gha new file mode 100644 index 0000000..9558192 --- /dev/null +++ b/.docker/production/Dockerfile.gha @@ -0,0 +1,142 @@ +############################################ +### Base image ### +############################################ + +# Taken from .ruby-version +ARG RUBY_VERSION=2.6.3 +FROM ruby:$RUBY_VERSION-slim-buster AS base +LABEL author="DCHBX" + +ARG DEBIAN_FRONTEND=noninteractive + +# Taken from Gemfile.lock +ARG BUNDLER_VERSION=2.4.22 +ENV BUNDLER_VERSION=$BUNDLER_VERSION + +ARG NODE_MAJOR=20 +ENV NODE_MAJOR=$NODE_MAJOR + +RUN apt-get update -qq && \ + apt-get install -yq --no-install-recommends \ + curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && truncate -s 0 /var/log/*log + +# Add NodeJS to sources list +RUN curl -fsSL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash - + +# Basic packages needed because we're using the slim image +RUN apt-get update \ + && apt-get -yq dist-upgrade \ + && apt-get install -y \ + fontconfig \ + libcurl4-openssl-dev \ + libffi-dev \ + libsodium23 \ + libxext6 \ + libxrender1 \ + libyaml-cpp-dev \ + nodejs \ + default-jre \ + openssl \ + sshpass \ + unzip \ + zip \ + zlib1g \ + libjemalloc2 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && truncate -s 0 /var/log/*log + +# Configure bundler and PATH, install bundler version +ENV GEM_HOME=/usr/local/bundle +ENV BUNDLE_PATH=$GEM_HOME +ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH +ENV BUNDLE_BIN=/usr/local/bin +ENV BUNDLE_JOBS=4 +ENV BUNDLE_RETRY=3 + +ENV LANG=C.UTF-8 + +ENV HOME=/cartafact + +ENV PATH=$HOME/bin:$BUNDLE_BIN:$GEM_HOME/gems/bin:$PATH + +# rubygems-update > 3.4.22 requires ruby >= 3.0.0 +RUN gem update --system 3.4.22\ + && rm -f /usr/local/bin/ruby/gems/*/specifications/default/bundler-*.gemspec \ + && gem install bundler -v $BUNDLER_VERSION + +RUN groupadd --gid 1001 nonroot \ + && useradd --uid 1001 --gid nonroot --shell /bin/bash --create-home nonroot + +RUN mkdir $HOME \ + && chown -R nonroot:nonroot $HOME + +# Configure app home directory +WORKDIR $HOME + + +############################################################################### +### Builder. Adds node and Yarn. Not necessary in production. ### +############################################################################### + +FROM base AS builder + +ARG DEBIAN_FRONTEND=noninteractive + +# Add Yarn to the sources list +RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ + && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list + +# Install Nodejs & Yarn +RUN apt-get update -qq \ + && apt-get install -yq --no-install-recommends \ + build-essential \ + git \ + libpq-dev \ + yarn \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && truncate -s 0 /var/log/*log + + +######################################################## +### Node and Bundle for production ### +######################################################## + +FROM builder AS prod_gems_and_assets + +ENV RAILS_ENV=production + +ARG HOSTNAME=localhost + +COPY --chown=nonroot:nonroot ./Gemfile $HOME/Gemfile +COPY --chown=nonroot:nonroot ./Gemfile.lock $HOME/Gemfile.lock + +RUN bundle config set --local without 'development test' \ + && bundle install + +COPY --chown=nonroot:nonroot . $HOME + +# https://github.com/rubygems/rubygems/issues/3225 +RUN rm -rf $GEM_HOME/bundle/ruby/*/cache + +################################################################ +### Deployable image ### +################################################################ + +FROM base AS deploy + +# Copy prebuilt gems +COPY --chown=nonroot:nonroot --from=prod_gems_and_assets $BUNDLE_PATH $BUNDLE_PATH + +# Copy all app code again (sans gems, node_modules, assets) +COPY --chown=nonroot:nonroot . $HOME + +USER nonroot + +ENV RAILS_ENV=production + +ENTRYPOINT ["bin/docker-entrypoint"] \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..35af776 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,23 @@ +# Release notes template (https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) + +changelog: + categories: + - title: Breaking Changes 🚧 + labels: + # Semver-Major + - breaking change + - title: New Features ✨ + labels: + # Semver-Minor + - enhancement + - title: Bug Fixes 🐛 + labels: + # Semver-Patch + - bugfix + - title: Other Changes 📦 + labels: + - "*" + exclude: + labels: + - chore + - version bump diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml new file mode 100644 index 0000000..c7d62e9 --- /dev/null +++ b/.github/workflows/build_and_deploy.yml @@ -0,0 +1,214 @@ +name: Build Image and Deploy + +on: + workflow_dispatch: + push: + branches: + - "trunk" + - 'upgrade_ruby_and_rails' + - "build_changes" + pull_request: + branches: + - "trunk" + - 'upgrade_ruby_and_rails' + - "build_changes" + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +jobs: + prep: + runs-on: ubuntu-latest + outputs: + taggedImage: ${{ steps.prep.outputs.tagged_image }} + registryGhcr: ${{ steps.prep.outputs.registry_ghcr }} + shortSha: ${{ steps.prep.outputs.short_sha }} + branchName: ${{ steps.prep.outputs.branch_name }} + latestTag: ${{ steps.prep.outputs.latest_tag }} + repositoryName: ${{ steps.prep.outputs.repository_name }} + registryEcr: ${{ steps.prep.outputs.registry_ecr }} + steps: + - name: Prepare info + id: prep + run: | + SHORT_SHA=$(echo $GITHUB_SHA | head -c7) + REPO=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}') + BRANCH_NAME="${{ github.head_ref || github.ref_name }}" + TAG=${BRANCH_NAME}-$(echo $GITHUB_SHA | head -c7) + IMAGE=ideacrew/$REPO + echo "tagged_image=${IMAGE}:${TAG}" >> $GITHUB_OUTPUT + echo "registry_ghcr=ghcr.io" >> $GITHUB_OUTPUT + echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "branch_name=${BRANCH_NAME}" >> $GITHUB_OUTPUT + echo "repository_name=${REPO}" >> $GITHUB_OUTPUT + echo "latest_tag=${IMAGE}:latest" >> $GITHUB_OUTPUT + echo "registry_ecr=public.ecr.aws" >> $GITHUB_OUTPUT + + # Uses buildx to build and push the image + build-and-upload-image: + needs: [prep] + runs-on: ubuntu-latest + services: + mongo: + image: mongo:4.2 + ports: + - 27017:27017 + options: >- + --name "mongo" + --health-cmd mongo + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + # Check out repository + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + with: + install: true + + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + # Key is named differently to avoid collision + key: ${{ runner.os }}-multi-buildx-${{ github.ref }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-multi-buildx-${{ github.ref }}- + ${{ runner.os }}-multi-buildx- + + # Provide credentials for AWS + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + # Must use docker login in order to specify public registry + - name: Login to Public ECR + uses: docker/login-action@v2 + with: + registry: ${{ needs.prep.outputs.registryEcr }} + username: ${{ secrets.AWS_ACCESS_KEY_ID }} + password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ needs.prep.outputs.registryGhcr }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Image + uses: docker/build-push-action@v6 + with: + context: . + builder: ${{ steps.buildx.outputs.name }} + file: .docker/production/Dockerfile.gha + # Set the desired build target here + target: deploy + # needed to access mongo and rabbit on GHA machine + network: host + # send to public registry if not a pull request + push: ${{ github.event_name != 'pull_request' }} + # create local image (for scanning) if it is a pull request + load: ${{ github.event_name == 'pull_request' }} + tags: | + ${{ format('{0}/{1}', needs.prep.outputs.registryGhcr, needs.prep.outputs.taggedImage) }} + ${{ format('{0}/{1}', needs.prep.outputs.registryGhcr, needs.prep.outputs.latestTag) }} + ${{ format('{0}/{1}', needs.prep.outputs.registryEcr, needs.prep.outputs.taggedImage) }} + ${{ format('{0}/{1}', needs.prep.outputs.registryEcr, needs.prep.outputs.latestTag) }} + cache-from: type=local,src=/tmp/.buildx-cache + # Note the mode=max here + # More: https://github.com/moby/buildkit#--export-cache-options + # And: https://github.com/docker/buildx#--cache-tonametypetypekeyvalue + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + build-args: | + HOSTNAME=172.17.0.1 + pull: true + + - name: Scan Docker image + if: github.event_name != 'pull_request' + id: scan + uses: anchore/scan-action@v6 + with: + image: ${{ format('{0}/{1}', needs.prep.outputs.registryGhcr, needs.prep.outputs.taggedImage) }} + acs-report-enable: true + fail-build: false + severity-cutoff: critical + +# - name: upload Anchore scan SARIF report +# if: github.event_name != 'pull_request' +# uses: github/codeql-action/upload-sarif@v2 +# with: +# sarif_file: ${{ steps.scan.outputs.sarif }} + + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + new-image-notification: + if: github.repository_owner == 'ideacrew' && github.event_name != 'pull_request' + needs: [prep, build-and-upload-image] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Git Commit Data w/ Message + uses: jcputney/git-commit-data-action@1.0.2 + - name: Post to a Slack channel + id: ic-slack + uses: slackapi/slack-github-action@v1 + with: + channel-id: 'docker-images-${{ needs.prep.outputs.repositoryName }}' + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "New image built from on `${{ needs.prep.outputs.branchName }}`" + } + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.YELLR_BOT_TOKEN }} + + +# enable slack when image is ready + notify-slack: + if: github.repository_owner == 'ideacrew' && github.event_name != 'pull_request' + needs: [new-image-notification, prep, build-and-upload-image] + runs-on: ubuntu-latest + strategy: + matrix: + registry: ['public.ecr.aws', 'ghcr.io'] + steps: + - name: Post to a Slack channel + id: ic-slack + uses: slackapi/slack-github-action@v1 + with: + channel-id: 'docker-images-${{ needs.prep.outputs.repositoryName }}' + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*${{ matrix.registry }} image*:\n`${{ format('{0}/{1}', matrix.registry, needs.prep.outputs.taggedImage) }}`" + } + }, + { + "type": "divider" + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.YELLR_BOT_TOKEN }} diff --git a/.github/workflows/label-checker.yml b/.github/workflows/label-checker.yml new file mode 100644 index 0000000..27736c9 --- /dev/null +++ b/.github/workflows/label-checker.yml @@ -0,0 +1,21 @@ +name: Label Checker + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + +jobs: + check_labels: + name: Check labels + runs-on: ubuntu-latest + steps: + - uses: docker://agilepathway/pull-request-label-checker:latest + with: + # At least one of the labels listed below must be present on the PR for the check to pass + any_of: breaking change,enhancement,bugfix,version bump,release,chore,documentation,dependencies + repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..e32e495 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,23 @@ +name: Publish Release + +on: + push: + tags: + - v[0-9]+.[0-9]+.[0-9]+ + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --repo="$GITHUB_REPOSITORY" \ + --title="${{ github.ref_name }}" \ + --generate-notes diff --git a/Gemfile.lock b/Gemfile.lock index 66440e5..8cdba41 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -218,7 +218,7 @@ GEM mime-types-data (3.2019.1009) mimemagic (0.3.10) nokogiri (~> 1) - rake (>= 0) + rake mini_mime (1.0.2) mini_portile2 (2.4.0) minitest (5.14.0) @@ -370,6 +370,7 @@ DEPENDENCIES fast_jsonapi jwt (~> 2.2.1) listen (>= 3.0.5, < 3.2) + mimemagic (~> 0.3.10) mongoid (~> 7.0.5) pry-byebug puma (~> 3.11) @@ -391,4 +392,4 @@ RUBY VERSION ruby 2.6.3p62 BUNDLED WITH - 2.0.2 + 2.4.22 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..3c74b3b --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,42 @@ +# Release + +This file describes the process for publishing a new version of the app as a GitHub release. + +Releases are managed through the [GitHub Releases](https://github.com/ideacrew/cartafact/releases) page. + +Release names follow the [Semantic Versioning](https://semver.org/) standard. + +Follow the steps below to package and release a new version. + +## Major/Minor release +### Publishing the Release +1. Checkout the main branch and pull the latest changes. +2. Create and checkout a new release branch, in the pattern of `1.0.x-release`. + - Note: `x` is literal, to aid reuse of same branch for minor bugfixes. +3. `git push` the new branch. +4. Create a new annotated tag with the version number, e.g., `git tag -as v1.0.0 -m "v1.0.0"`. + - IMPORTANT: make sure the tag abides by the format `vX.Y.Z` where `X`, `Y`, and `Z` are integers. It is important that the tag name has a different format than any branch name to avoid confusion with Bundler. +5. Push the tag to the remote repository, e.g., `git push origin refs/tags/v1.0.0`. + - Avoid `git push --tags`, to not accidentally push random assorted local tags. +6. GitHub Actions will automatically create a new release on the [GitHub Releases](https://github.com/ideacrew/cartafact/releases) page with release notes. Confirm that the release was successfully published there and that all intended commits are included in the release. + +## Patch/Bugfix release +### Prepare the release +1. Fix bug in the main branch, via normal PR process. + +For any release that has the bug: +2. Create a temp branch off any live release branch that has the bug. + - Using a branch, which is then PR’d, ensures traceability and inclusion of an item in the generated release notes. +3. Cherry-pick the fix commits to the temp branch. +4. `git push` the temp branch. +5. Issue a PR to merge to the release branch. + +### Publishing the Release +1. Once the pull request is approved and merged, checkout the release branch and pull the latest changes. +2. Create a new annotated tag with the version number, at the point of the release branch with the fix, e.g., `git tag -as v1.0.1 -m "v1.0.1"`. +3. Push the tag to the remote repository, e.g., `git push origin refs/tags/v1.0.1`. + - Again, better to avoid `git push --tags`. +4. Github Actions will create the release and pull in the fix PR's to the changelog. + +## Git Process diagram +![Git Process Diagram - App](docs_assets/release_branching_deployable_app.png) \ No newline at end of file diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100644 index 0000000..f06279e --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,9 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency, especially in production +if [ -z "${LD_PRELOAD+x}" ]; then + LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) + export LD_PRELOAD +fi + +exec "${@}" \ No newline at end of file diff --git a/config/environments/production.rb b/config/environments/production.rb index 2fc664c..5df24c0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -71,7 +71,7 @@ # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') if ENV["RAILS_LOG_TO_STDOUT"].present? - logger = ActiveSupport::Logger.new(STDOUT) + logger = ActiveSupport::Logger.new($stdout) logger.formatter = config.log_formatter config.logger = ActiveSupport::TaggedLogging.new(logger) end diff --git a/config/mongoid.yml b/config/mongoid.yml index 751496e..5b8c20e 100644 --- a/config/mongoid.yml +++ b/config/mongoid.yml @@ -9,24 +9,24 @@ development: # Provides the hosts the default session can connect to. Must be an array # of host:port pairs. (required) hosts: - - <%= ENV['DB_HOST'] %>:<%= ENV['DB_PORT'] %> + - "<%= ENV.fetch('DB_HOST', 'localhost') %>:<%= ENV.fetch('CARTAFACT_DB_PORT', '27017') %>" options: - # Change the default write concern. (default = { w: 1 }) - # write: - # w: 1 + # Change the default write concern. (default = { w: 1 }) + # write: + # w: 1 - # Change the default consistency model to primary, secondary. - # 'secondary' will send reads to secondaries, 'primary' sends everything - # to master. (default: primary) - # read: secondary_preferred + # Change the default consistency model to primary, secondary. + # 'secondary' will send reads to secondaries, 'primary' sends everything + # to master. (default: primary) + # read: secondary_preferred - # How many times Moped should attempt to retry an operation after - # failure. (default: The number of nodes in the cluster) - # max_retries: 20 + # How many times Moped should attempt to retry an operation after + # failure. (default: The number of nodes in the cluster) + # max_retries: 20 - # The time in seconds that Moped should wait before retrying an - # operation on failure. (default: 0.25) - # retry_interval: 0.25 + # The time in seconds that Moped should wait before retrying an + # operation on failure. (default: 0.25) + # retry_interval: 0.25 rocketjob: <<: *default_development @@ -35,38 +35,38 @@ development: # Configure Mongoid specific options. (optional) options: - # Includes the root model name in json serialization. (default: false) - # include_root_in_json: false + # Includes the root model name in json serialization. (default: false) + # include_root_in_json: false - # Include the _type field in serializaion. (default: false) - # include_type_for_serialization: false + # Include the _type field in serializaion. (default: false) + # include_type_for_serialization: false - # Preload all models in development, needed when models use - # inheritance. (default: false) - # preload_models: false + # Preload all models in development, needed when models use + # inheritance. (default: false) + # preload_models: false - # Protect id and type from mass assignment. (default: true) - # protect_sensitive_fields: true + # Protect id and type from mass assignment. (default: true) + # protect_sensitive_fields: true - # Raise an error when performing a #find and the document is not found. - # (default: true) - # raise_not_found_error: true + # Raise an error when performing a #find and the document is not found. + # (default: true) + # raise_not_found_error: true - # Raise an error when defining a scope with the same name as an - # existing method. (default: false) - # scope_overwrite_exception: false + # Raise an error when defining a scope with the same name as an + # existing method. (default: false) + # scope_overwrite_exception: false - # Use Active Support's time zone in conversions. (default: true) - # use_activesupport_time_zone: true + # Use Active Support's time zone in conversions. (default: true) + # use_activesupport_time_zone: true - # Ensure all times are UTC in the app side. (default: false) - # use_utc: false + # Ensure all times are UTC in the app side. (default: false) + # use_utc: false test: clients: default: - database: cartafact_test<%= ENV['TEST_ENV_NUMBER'] %> + database: cartafact_test<%= ENV.fetch('TEST_ENV_NUMBER', '') %> hosts: - - <%= ENV['DB_HOST'] %>:<%= ENV['DB_PORT'] %> + - "<%= ENV.fetch('DB_HOST', 'localhost') %>:<%= ENV.fetch('CARTAFACT_DB_PORT', '27017') %>" options: # In the test environment we lower the retries and retry interval to # low amounts for fast failures. @@ -77,8 +77,19 @@ production: default: database: cartafact_production hosts: - - <%= ENV['DB_HOST'] %>:<%= ENV['DB_PORT'] %> + - <%= ENV['CARTAFACT_DB_HOST'] %>:<%= ENV['CARTAFACT_DB_PORT'] %> options: + <% if ENV['CARTAFACT_DB_AUTH'] == true %> + replica_set: <%= ENV['CARTAFACT_DB_REPLICA_SET_NAME'] %> + + user: <%= ENV['CARTAFACT_DB_USERNAME'] %> + password: <%= ENV['CARTAFACT_DB_PASSWORD'] %> + auth_source: admin + + write: + w: 1 + j: true + <% end %> # In the test environment we lower the retries and retry interval to # low amounts for fast failures. max_retries: 1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d3eb8ec..4cc6160 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,7 +7,7 @@ services: dockerfile: ./.docker/production/Dockerfile args: RUBY_VERSION: '2.6.3' - BUNDLER_VERSION: '2.0.2' + BUNDLER_VERSION: '2.4.22' image: ideacrew/cartafact_app:${IMAGE_TAG:-test_prod} tmpfs: - /tmp