diff --git a/deploy/deploy.yml b/deploy/deploy.yml new file mode 100644 index 00000000..c269d653 --- /dev/null +++ b/deploy/deploy.yml @@ -0,0 +1,99 @@ +--- +- name: Deploy impress from the current local version + hosts: webserver + become: yes + become_user: impress + vars: + local_app_root: "{{ playbook_dir }}/.." + remote_project_root: "/srv/impress" + # deploy:setup should have added us to the "impress-deployers" group, so we + # should be able to become the "impress" user without a password. + ansible_become_password: "" + tasks: + - name: Generate a version name from the current timestamp + command: date '+%Y-%m-%d-%s' + register: new_app_version + + - name: Print out the new version name + debug: + msg: "Deploying new version: {{ new_app_version.stdout }}" + + - name: Save new remote folder path to a variable + set_fact: + remote_app_root: "{{ remote_project_root }}/versions/{{ new_app_version.stdout }}" + + - name: Create new remote folder for the new version + file: + path: "{{ remote_app_root }}" + state: directory + + - name: Copy local app's source files to new remote folder + ansible.posix.synchronize: + src: "{{ local_app_root }}/" + dest: "{{ remote_app_root }}" + rsync_opts: + - "--exclude=.git" + - "--filter=':- .gitignore'" + + - name: Configure Bundler to run in deployment mode + command: + chdir: "{{ remote_app_root }}" + cmd: /opt/ruby-3.1.4/bin/bundle config set --local deployment true + + # This ensures that, while attempting our current deploy, we don't + # accidentally delete gems out from under the currently-running version. + # NOTE: From reading the docs, I thiink this is the default behavior, but + # I can't be sure? Rather than deep-dive to find out, I'd rather just set + # it, to be clear about the default(?) behavior we're depending on. + - name: Configure Bundler to *not* clean up old gems when installing + command: + chdir: "{{ remote_app_root }}" + cmd: /opt/ruby-3.1.4/bin/bundle config set --local clean false + + # NOTE: Bundler recommends this, and they're pretty smart about it: if the + # Gemfile changes, this shouldn't disrupt the currently-running version, + # because we won't clean up its now-unused gems yet, and if we upgrade a + # gem it'll install *both* versions of the gem until we clean up. + - name: Configure Bundler to use the bundle folder shared by all app versions + command: + chdir: "{{ remote_app_root }}" + cmd: "/opt/ruby-3.1.4/bin/bundle config set --local path {{ remote_project_root}}/shared/bundle" + + - name: Run `bundle install` to install dependencies in remote folder + command: + chdir: "{{ remote_app_root }}" + cmd: /opt/ruby-3.1.4/bin/bundle install + + - name: Update the `current` folder to point to the new version + file: + src: "{{ remote_app_root }}" + dest: /srv/impress/current + state: link + + # NOTE: This uses the passwordless sudo rule we set up in deploy:setup. + # We write it as a command rather than using the built-in `systemd` Ansible + # module, to make sure we're invoking it exactly as we wrote in that rule. + # TODO: I'm not sure why it works to write `sudo` in the command instead of + # `become_user: root`, which complains about the missing sudo password, which + # we already fixed for the rest of the playbook I thought? + - name: Restart the app + become: no + command: sudo systemctl restart impress + + - name: Clean up gems no longer used in the current app version + command: + chdir: "{{ remote_app_root }}" + cmd: /opt/ruby-3.1.4/bin/bundle clean + + - name: Find older app versions to clean up + # Print out all but the 5 last-recently-updated versions. + command: + chdir: "{{ remote_project_root }}/versions" + cmd: bash -c 'ls -t | tail -n +6' + register: versions_to_clean_up + + - name: Clean up older versions + file: + path: "{{ remote_project_root }}/versions/{{ item }}" + state: absent + with_items: "{{ versions_to_clean_up.stdout_lines }}" diff --git a/deploy/setup.yml b/deploy/setup.yml index e70b3278..bd85b4c6 100644 --- a/deploy/setup.yml +++ b/deploy/setup.yml @@ -100,6 +100,42 @@ comment: Impress App create_home: false + - name: Create "impress-deployers" group + group: + name: impress-deployers + + - name: Add the current user to the "impress-deployers" group + user: + name: "{{ lookup('env', 'USER') }}" + groups: + - impress-deployers + append: yes + + # We use this so the deploy playbook doesn't have to prompt for a root + # password: this user just is trusted to act as "impress" in the future. + - name: Enable the "impress-deployers" group to freely act as the "impress" user + community.general.sudoers: + name: impress-deployers-as-impress + group: impress-deployers + runas: impress + commands: ALL + nopassword: yes + + # Similarly, this enables us to manage the impress service in the deploy playbook + # and in live debugging without a password. + # NOTE: In the sudoers file, you need to specify the full path to the + # command, to avoid tricks where you use PATH to get around the intent! + - name: Enable the "impress-deployers" group to freely start and stop the impress service + community.general.sudoers: + name: impress-deployers-systemctl + group: impress-deployers + commands: + - /bin/systemctl status impress + - /bin/systemctl start impress + - /bin/systemctl stop impress + - /bin/systemctl restart impress + nopassword: yes + - name: Install ACL, to enable us to run commands as the "impress" user apt: name: acl @@ -124,6 +160,10 @@ dest: /etc/profile line: 'PATH="/opt/ruby-3.1.4/bin:$PATH" # Added by impress deploy setup script' + - name: Install system dependencies for impress's Ruby gems + apt: + name: libmysqlclient-dev + - name: Create the app folder file: path: /srv/impress @@ -132,12 +172,18 @@ mode: "755" state: directory - - name: Create the app versions folder + - name: Create the app's "versions" folder become_user: impress file: path: /srv/impress/versions state: directory + - name: Create the app's "shared" folder + become_user: impress + file: + path: /srv/impress/shared + state: directory + - name: Check for a current app version stat: path: /srv/impress/current @@ -186,9 +232,12 @@ - name: Upload the production.env file become_user: impress copy: - dest: /srv/impress/production.env + dest: /srv/impress/shared/production.env src: files/production.env mode: "600" + notify: + - Reload systemctl + - Restart impress - name: Create service file for impress copy: @@ -202,7 +251,8 @@ Restart=always WorkingDirectory=/srv/impress/current ExecStart=/opt/ruby-3.1.4/bin/bundle exec puma --port=3000 - EnvironmentFile=/srv/impress/production.env + Environment="RAILS_ENV=production" + EnvironmentFile=/srv/impress/shared/production.env ; Some security directives, adapted from Akkoma's service file, they seem like sensible defaults! ; Use private /tmp and /var/tmp folders inside a new file system namespace, which are discarded after the process stops. diff --git a/package.json b/package.json index 32548e2c..80fa118c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "scripts": { "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --loader:.js=jsx --loader:.png=file --loader:.svg=file --loader:.min.js=text", "build:production": "yarn build --minify", - "deploy:setup": "echo $'Setup requires you to become the root user. You\\'ll need to enter the password for your account on the remote web server below, and you must be part of the `sudo` user group.' && ansible-playbook -K -i deploy/inventory.cfg deploy/setup.yml" + "deploy:setup": "echo $'Setup requires you to become the root user. You\\'ll need to enter the password for your account on the remote web server below, and you must be part of the `sudo` user group.' && ansible-playbook -K -i deploy/inventory.cfg deploy/setup.yml", + "deploy": "ansible-playbook -i deploy/inventory.cfg deploy/deploy.yml" } }