diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 000000000..2fb07d7d7 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 000000000..75efafc10 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 000000000..1435a677f --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 000000000..f87d81130 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 000000000..18e61d7e5 --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 000000000..1b280c719 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end +end + + +$stdout.sync = true + +puts "Checking build status..." +attempts = 0 +checks = GithubStatusChecks.new + +begin + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 000000000..061f8059e --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets.beta b/.kamal/secrets.beta new file mode 100644 index 000000000..bc94c7d12 --- /dev/null +++ b/.kamal/secrets.beta @@ -0,0 +1,4 @@ +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/REGISTRY_PASSWORD Beta/SECRET_KEY_BASE) + +KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract REGISTRY_PASSWORD $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) diff --git a/.kamal/secrets.production b/.kamal/secrets.production new file mode 100644 index 000000000..457427997 --- /dev/null +++ b/.kamal/secrets.production @@ -0,0 +1,4 @@ +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/REGISTRY_PASSWORD Production/SECRET_KEY_BASE) + +KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract REGISTRY_PASSWORD $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) diff --git a/Gemfile b/Gemfile index fdeb0ea2b..b1ed65062 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,7 @@ gem "hotwire_combobox", github: "josefarias/hotwire_combobox", branch: :main # Deployment and drivers gem "bootsnap", require: false +gem "kamal", require: false gem "puma", ">= 5.0" gem "solid_cable", ">= 3.0" gem "solid_cache", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index cff4ceb78..ce707c3f5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -116,6 +116,7 @@ GEM ast (2.4.2) base64 (0.2.0) bcrypt (3.1.20) + bcrypt_pbkdf (1.1.1) benchmark (0.4.0) bigdecimal (3.1.9) bootsnap (1.18.4) @@ -140,7 +141,9 @@ GEM debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) + dotenv (3.1.7) drb (2.2.1) + ed25519 (1.3.0) erubi (1.13.1) et-orbi (1.2.11) tzinfo @@ -180,6 +183,17 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.9.0) + kamal (2.4.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.2) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.3) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -206,8 +220,13 @@ GEM net-protocol net-protocol (0.2.2) timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.0) net-protocol + net-ssh (7.3.0) nio4r (2.7.4) nokogiri (1.18.1) mini_portile2 (~> 2.8.2) @@ -228,6 +247,7 @@ GEM racc (~> 1.4) nokogiri (1.18.1-x86_64-linux-musl) racc (~> 1.4) + ostruct (0.6.1) parallel (1.26.3) parser (3.3.6.0) ast (~> 2.4.1) @@ -349,6 +369,12 @@ GEM sqlite3 (2.5.0-x86_64-darwin) sqlite3 (2.5.0-x86_64-linux-gnu) sqlite3 (2.5.0-x86_64-linux-musl) + sshkit (1.23.2) + base64 + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.2) @@ -405,6 +431,7 @@ DEPENDENCIES hotwire_combobox! importmap-rails jbuilder + kamal propshaft puma (>= 5.0) rails! diff --git a/README.md b/README.md index 7db80e4ca..4147cfe43 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ -# README +# Fizzy -This README would normally document whatever steps are necessary to get the -application up and running. +## Setting up for development -Things you may want to cover: +First get everything installed and configured with: -* Ruby version + bin/setup -* System dependencies +And then run the development server: -* Configuration + bin/dev -* Database creation +You'll be able to access the app in development at http://localhost:3006. -* Database initialization -* How to run the test suite +## Deploying -* Services (job queues, cache servers, search engines, etc.) +Fizzy is deployed with Kamal. You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. -* Deployment instructions +For beta: -* ... + bin/kamal deploy -d beta + +And for production: + + bin/kamal deploy -d production diff --git a/app/assets/stylesheets/bubbles.css b/app/assets/stylesheets/bubbles.css index 99c98f92a..9d4fb84a8 100644 --- a/app/assets/stylesheets/bubbles.css +++ b/app/assets/stylesheets/bubbles.css @@ -57,7 +57,7 @@ .bubble__perma & { --bubble-border-width: 0.3rem; - --bubble-size: 100% !important; + --bubble-size: 40dvh !important; z-index: 1; } @@ -290,12 +290,6 @@ } } -.bubble__perma { - block-size: 100%; - max-block-size: 40dvh; - max-inline-size: 100%; -} - .bubble__tags { --row-gap: 0; diff --git a/app/assets/stylesheets/flash.css b/app/assets/stylesheets/flash.css index e7523006a..eb2f7be0a 100644 --- a/app/assets/stylesheets/flash.css +++ b/app/assets/stylesheets/flash.css @@ -1,23 +1,19 @@ .flash { display: flex; - inset-block-start: var(--block-space); - inset-inline-start: 50%; + inline-size: 100%; + inset: var(--block-space) auto auto auto; justify-content: center; position: fixed; - transform: translate(-50%); z-index: 6; } .flash__inner { animation: appear-then-fade 3s 300ms both; - aspect-ratio: 1; - background-color: var(--flash-background, var(--color-positive)); - border-radius: 50%; - display: grid; - padding: var(--block-space); - place-items: center; - - img { - grid-area: 1/1; - } + background-color: var(--flash-background, var(--color-ink)); + border-radius: 4em; + color: var(--flash-color, var(--color-ink-reversed)); + display: inline-flex; + font-size: var(--font-size-medium); + margin: 0 auto; + padding: 0.7em 1.4em; } diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index b30ba7172..b2d33064d 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -1,14 +1,13 @@ body { display: grid; - grid-template-rows: auto 1fr auto 10rem; + grid-template-rows: auto 1fr auto 6em; } :where(#main) { - container-type: inline-size; + container-type: normal; inline-size: 100vw; margin-inline: auto; max-inline-size: 100vw; - padding-block: 0 clamp(var(--block-space), 3cqmin, calc(var(--block-space) * 3)); padding-inline: clamp(var(--inline-space), 3cqmin, calc(var(--inline-space) * 3)); text-align: center; diff --git a/app/controllers/bubbles_controller.rb b/app/controllers/bubbles_controller.rb index 8409b53ca..178fd96c5 100644 --- a/app/controllers/bubbles_controller.rb +++ b/app/controllers/bubbles_controller.rb @@ -24,7 +24,7 @@ class BubblesController < ApplicationController def destroy @bubble.destroy! - redirect_to bubbles_path, notice: "Bubble deleted" + redirect_to bubbles_path(bucket_ids: [ @bubble.bucket ]), notice: "Bubble deleted" end def update diff --git a/app/views/bubbles/list/_divider.html.erb b/app/views/bubbles/list/_divider.html.erb index 1e365d554..d60b67de8 100644 --- a/app/views/bubbles/list/_divider.html.erb +++ b/app/views/bubbles/list/_divider.html.erb @@ -2,7 +2,7 @@
Drag up or down

- <%= image_tag "drag.svg", aria: { hidden: true }, size: 24, class: "colorize-black" %> + <%= image_tag "drag.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> 10 Bubbled up
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 3a4b0e53e..45d9b1db3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -27,14 +27,9 @@ <% if notice = flash[:notice] || flash[:alert] %>
-
"> - <% if flash[:alert] %> - <%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> - <% else %> - <%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> - <% end %> +
+ <%= notice %>
- <%= notice %>
<% end %> diff --git a/bin/deploy b/bin/deploy deleted file mode 100755 index edd1ffefa..000000000 --- a/bin/deploy +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env ruby -require "bundler/inline" -require "yaml" - -gemfile do - source "https://rubygems.org" - gem "base64" - gem "bcrypt_pbkdf" - gem "ed25519" - gem "logger" - gem "sshkit" -end - -include SSHKit::DSL - -config_variant = "-#{ARGV[0]}" if ARGV[0] - -begin -config = YAML.load_file("config/deploy#{config_variant}.yml") -rescue Errno::ENOENT - puts "No deploy config found for #{config_variant || "default" }" - exit 1 -end - -HOSTS = [ config["host"] ] -REMOTE_USER = config["ssh_user"] -DOCKER_IMAGE_NAME = config["image"] -ARCH = config["arch"] || `arch`.strip -GIT_SHA = `git rev-parse HEAD`.strip -DOCKER_TAGGED_IMAGE_NAME = [ DOCKER_IMAGE_NAME, GIT_SHA ].join(":") -PORTS = [ "80:80", "443:443" ] -ENVS = { TLS_DOMAIN: config["ssl_domain"] } -ENV_FILE = ".fizzy-env" - -SSHKit::Backend::Netssh.configure do |ssh| - ssh.connection_timeout = 30 - ssh.ssh_options = { user: REMOTE_USER } -end - -def announcing(message) - puts "\n\e[1;36m#{message}\e[0m" - yield -end - -announcing "Building and pushing image..." do - versioning = "--build-arg APP_VERSION=#{GIT_SHA[...6]} --build-arg GIT_REVISION=#{GIT_SHA}" - - on(:local) do - execute :docker, :build, "-t", DOCKER_TAGGED_IMAGE_NAME, versioning, "--platform", ARCH, "." - execute :docker, :push, DOCKER_TAGGED_IMAGE_NAME - end -end - -announcing "Pulling image on remote..." do - on(HOSTS) do - execute :docker, :pull, DOCKER_TAGGED_IMAGE_NAME - end -end - -announcing "Start container on remote..." do - restart_policy = "--restart unless-stopped" - labels = "--label #{DOCKER_IMAGE_NAME}" - ports = PORTS.map { |p| "-p #{p}" }.join(" ") - envs = ENVS.map { |k, v| "-e #{k}=#{v}" }.join(" ") - volume = "--mount 'type=volume,source=fizzy,target=/rails/storage'" - env_file = "--env-file #{ENV_FILE}" - - on(HOSTS) do - execute :docker, :ps, "-q", "-f", "\"label=#{DOCKER_IMAGE_NAME}\"", "|", "xargs", "-r", "docker", "stop" - execute :docker, :run, "-d", labels, ports, envs, env_file, restart_policy, volume, DOCKER_TAGGED_IMAGE_NAME - end -end - -announcing "Remove stopped containers from remote..." do - on(HOSTS) do - execute :docker, :container, :prune, "-f" - execute :docker, :image, :prune, "-af" - end -end diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 000000000..cbe59b95e --- /dev/null +++ b/bin/kamal @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/config/deploy-beta.yml b/config/deploy-beta.yml deleted file mode 100644 index 20d066f16..000000000 --- a/config/deploy-beta.yml +++ /dev/null @@ -1,5 +0,0 @@ -image: registry.kevinmcconnell.dev/fizzy -host: fizzy.37signals.works -ssl_domain: fizzy.37signals.works -ssh_user: root -arch: linux/amd64 diff --git a/config/deploy-production.yml b/config/deploy-production.yml deleted file mode 100644 index e67a7eb24..000000000 --- a/config/deploy-production.yml +++ /dev/null @@ -1,5 +0,0 @@ -image: registry.kevinmcconnell.dev/fizzy -host: once-fizzy-101 -ssl_domain: fizzy.37signals.com -ssh_user: app -arch: linux/amd64 diff --git a/config/deploy.beta.yml b/config/deploy.beta.yml new file mode 100644 index 000000000..0c776c517 --- /dev/null +++ b/config/deploy.beta.yml @@ -0,0 +1,10 @@ +servers: + web: + hosts: + - fizzy.37signals.works + jobs: + hosts: + - fizzy.37signals.works + +proxy: + host: fizzy.37signals.works diff --git a/config/deploy.production.yml b/config/deploy.production.yml new file mode 100644 index 000000000..8c6809306 --- /dev/null +++ b/config/deploy.production.yml @@ -0,0 +1,13 @@ +servers: + web: + hosts: + - once-fizzy-101 + jobs: + hosts: + - once-fizzy-101 + +proxy: + host: fizzy.37signals.com + +ssh: + user: app diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..bde2155d6 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,25 @@ +service: fizzy +image: fizzy + +servers: + jobs: + cmd: bin/jobs + +volumes: + - fizzy:/rails/storage + +proxy: + ssl: true + +registry: + server: registry.kevinmcconnell.dev + username: admin + password: + - KAMAL_REGISTRY_PASSWORD + +builder: + arch: amd64 + +env: + secret: + - SECRET_KEY_BASE diff --git a/config/environments/production.rb b/config/environments/production.rb index 9e78f28c4..7dc9258a5 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -40,7 +40,7 @@ Rails.application.configure do # Assume all access to the app is happening through a SSL-terminating reverse proxy. # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - # config.assume_ssl = true + config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = true