diff --git a/.node-version b/.node-version index a81debae..87834047 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v20.12.2 +20.12.2 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1985157e..d25cbc81 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -108,9 +108,7 @@ def route_component(route) # https://www.leighhalliday.com/ruby-metaprogramming-method-missing sig do - params(method_name: Symbol, - args: T::Array[T.nilable(T.any(T::Hash[T.untyped, T.untyped], - T.proc.returns(T::Hash[T.untyped, T.untyped])))]).void + params(method_name: Symbol, args: T.untyped).void end def method_missing(method_name, *args) mn = method_name.to_s diff --git a/app/models/push_notification_subscription.rb b/app/models/push_notification_subscription.rb index 10d8e215..813126d7 100644 --- a/app/models/push_notification_subscription.rb +++ b/app/models/push_notification_subscription.rb @@ -20,7 +20,7 @@ class PushNotificationSubscription < ApplicationRecord scope :active, -> { where(subscribed: true) } - sig { params(message: String).returns(T.untyped) } + sig { params(message: T::Hash[String, T.untyped]).returns(T.untyped) } def send_web_push_notification(message) WebPush.payload_send( message: JSON.generate(message), diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 72ccec40..911ff47d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -35,4 +35,10 @@ // Make the VAPID public key available to the client as a string window.VAPID_PUBLIC_KEY = "<%= ENV['VAPID_PUBLIC_KEY'].delete('=') %>" + diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index b3908033..8aab80d3 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,10 +1,18 @@ #!/bin/bash -e +./bin/rake sway:volume_setup + # If running the rails server then create or migrate existing database if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then + echo "####################################################" + echo "docker-entrypoint -> Running rails db:migrate" + echo "####################################################" # db:prepare - https://www.bigbinary.com/blog/rails-6-adds-rails-db-prepare-to-migrate-or-setup-a-database - ./bin/rails db:prepare + ./bin/rails db:migrate + echo "####################################################" + echo "docker-entrypoint -> Running rails db:seed" + echo "####################################################" # Ensure seeds are run ./bin/rails db:seed fi diff --git a/config/application.rb b/config/application.rb index 4e33a445..557665d1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -28,6 +28,6 @@ class Application < Rails::Application config.time_zone = 'UTC' # config.eager_load_paths << Rails.root.join("extras") - config.force_ssl = true + config.force_ssl = false end end diff --git a/config/database.yml b/config/database.yml index 1e31447f..9a1baf26 100644 --- a/config/database.yml +++ b/config/database.yml @@ -74,6 +74,7 @@ test: production: <<: *default database: storage/production.sqlite3 + # database: storage/prodswaysqlite/production.sqlite3 # Postgres # production: diff --git a/config/environments/production.rb b/config/environments/production.rb index d9dc3bda..d8e31326 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -2,6 +2,8 @@ require "active_support/core_ext/integer/time" Rails.application.configure do + config.active_record.sqlite3_production_warning = false + # Settings specified here will take precedence over those in config/application.rb. # Code is not reloaded between requests. @@ -50,7 +52,7 @@ # config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + config.force_ssl = false # Log to STDOUT by default config.logger = ActiveSupport::Logger.new(STDOUT) @@ -96,7 +98,10 @@ # https://guides.rubyonrails.org/security.html#dns-rebinding-and-host-header-attacks config.hosts = [ # "example.com", # Allow requests from example.com - /.*\.sway\.vote/ # Allow requests from subdomains like `www.example.com` + /.*\.sway\.vote/, # Allow requests from subdomains like `www.example.com` + /.*\.fly\.dev/, # Allow requests from subdomains like `www.example.com` + 'localhost', + '127.0.0.1' ] # Skip DNS rebinding protection for the default health check endpoint. config.host_authorization = { exclude: ->(request) { request.path == "/up" } } diff --git a/config/puma.rb b/config/puma.rb index ceba4711..269d76ab 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,15 +1,16 @@ # typed: false + # This configuration file will be evaluated by Puma. The top-level methods that # are invoked here are part of Puma"s configuration DSL. For more information # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. -puts "" -puts "##################################################################" -puts "" +puts '' +puts '##################################################################' +puts '' puts "Puma starting with RAILS_ENV = #{ENV.fetch('RAILS_ENV')}" -puts "" -puts "##################################################################" -puts "" +puts '' +puts '##################################################################' +puts '' # Puma can serve each request in a thread from an internal thread pool. # The `threads` method setting takes two numbers: a minimum and maximum. @@ -32,14 +33,16 @@ worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development' # Specifies the `port` that Puma will listen on to receive requests; default is 3000. -# port ENV.fetch("PORT") { 3000 } - -ssl_bind '0.0.0.0', ENV.fetch('PORT', 3000), { - key: 'config/ssl/key.pem', - cert: 'config/ssl/cert.pem', - verify_mode: 'none' -} -# if ENV.fetch('RAILS_ENV', 'development') == 'development' + +if ENV['RAILS_ENV'] == 'production' + port ENV.fetch('PORT') { 3000 } +else + ssl_bind '0.0.0.0', ENV.fetch('PORT', 3000), { + key: 'config/ssl/key.pem', + cert: 'config/ssl/cert.pem', + verify_mode: 'none' + } +end # Specifies the `environment` that Puma will run in. environment ENV.fetch('RAILS_ENV') { 'development' } diff --git a/docker/dockerfiles/production.dockerfile b/docker/dockerfiles/production.dockerfile index bb21fa16..a533c881 100644 --- a/docker/dockerfiles/production.dockerfile +++ b/docker/dockerfiles/production.dockerfile @@ -1,6 +1,8 @@ ARG RUBY_VERSION=3.3.1 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base +LABEL fly_launch_runtime="rails" + # Rails app lives here WORKDIR /rails @@ -74,4 +76,4 @@ ENTRYPOINT ["/rails/bin/docker-entrypoint"] # Start the server by default, this can be overwritten at runtime EXPOSE 3000 -CMD ["./bin/rails", "server", "-u", "puma"] +CMD ["./bin/rails", "server", "-u", "puma", "-b", "0.0.0.0", "-p", "3000", "-e", "production"] diff --git a/docker/dockerfiles/production.dockerfile.dockerignore b/docker/dockerfiles/production.dockerfile.dockerignore index 29b309c3..17b094fd 100644 --- a/docker/dockerfiles/production.dockerfile.dockerignore +++ b/docker/dockerfiles/production.dockerfile.dockerignore @@ -2,6 +2,7 @@ # Ignore git directory. /.git/ +/.github/ # Ignore vscode /.vscode @@ -20,6 +21,7 @@ /config/master.key /config/credentials/*.key /config/keys/ +/config/ssl # Ignore all logfiles and tempfiles. /log/* @@ -41,11 +43,15 @@ # Ignore terraform /tf/ +# Ignore litestream +/litestream/ + # Ignore assets. /node_modules/ /app/assets/builds/ #/app/assets/images/ #/public/* +/public/vite-test/ # Don't need views except layouts /views @@ -74,3 +80,5 @@ # DO ignore geojson files **/**/.*geojson + +fly.toml \ No newline at end of file diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..8b1dd6b0 --- /dev/null +++ b/fly.toml @@ -0,0 +1,58 @@ +# fly.toml app configuration file generated for sway on 2024-06-07T10:24:01-04:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'sway' +primary_region = 'mia' +console_command = '/rails/bin/rails console' + +[build] + image = 'ghcr.io/plebeian-technology/sway:latest' + #dockerfile = "docker/dockerfiles/production.dockerfile" + #ignorefile = "docker/dockerfiles/production.dockerfile.dockerignore" + +[[mounts]] + source = 'prodswaysqlite' + destination = '/storage' + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 1 + max_machines_running = 3 + + [http_service.concurrency] + type = "requests" + soft_limit = 200 + hard_limit = 250 + + [[http_service.checks]] + interval = '30s' + timeout = '5s' + grace_period = '10s' + method = 'GET' + path = '/up' + +[checks] + [checks.status] + port = 3000 + type = 'http' + interval = '10s' + timeout = '2s' + grace_period = '5s' + method = 'GET' + path = '/up' + protocol = 'http' + tls_skip_verify = false + + [checks.status.headers] + X-Forwarded-Proto = 'https' + +[[vm]] + #memory = '256mb' + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/lib/sway_google_cloud_storage.rb b/lib/sway_google_cloud_storage.rb index a89391fd..6fd21361 100644 --- a/lib/sway_google_cloud_storage.rb +++ b/lib/sway_google_cloud_storage.rb @@ -65,9 +65,40 @@ def generate_put_signed_url_v4(bucket_name:, file_name:, content_type:) ) end + def upload_file(bucket_name:, bucket_file_path:, local_file_path:) + return unless bucket_name && bucket_file_path && local_file_path + + bucket = storage.bucket bucket_name, skip_lookup: true + bucket.create_file local_file_path, bucket_file_path + end + + def download_file(bucket_name:, bucket_file_path:, local_file_path:) + return unless bucket_name && bucket_file_path && local_file_path + + bucket = storage.bucket bucket_name, skip_lookup: true + + file = bucket.file bucket_file_path + + FileUtils.mkdir_p(local_file_path.split('/')[0..-2].join('/')) + file.download local_file_path + end + + def download_directory(bucket_name:, bucket_directory_name:, local_directory_name:) + return unless bucket_name && bucket_directory_name && local_directory_name + + bucket = storage.bucket bucket_name, skip_lookup: true + + dir = bucket.files prefix: "#{bucket_directory_name}/" + + dir.all do |f| + FileUtils.mkdir_p("#{local_directory_name}/#{f.name.split('/')[0..-2].join('/')}") + f.download "#{local_directory_name}/#{f.name}" + end + end + def delete_file(bucket_name:, file_name:) return unless bucket_name && file_name - return if file_name.starts_with? "https://" + return if file_name.starts_with? 'https://' bucket = storage.bucket bucket_name, skip_lookup: true file = bucket.file file_name diff --git a/lib/tasks/fly.rake b/lib/tasks/fly.rake new file mode 100644 index 00000000..1e2e74d6 --- /dev/null +++ b/lib/tasks/fly.rake @@ -0,0 +1,25 @@ +# https://fly.io/docs/rails/advanced-guides/sqlite3/ + +# commands used to deploy a Rails application +namespace :fly do + # BUILD step: + # - changes to the filesystem made here DO get deployed + # - NO access to secrets, volumes, databases + # - Failures here prevent deployment + task build: 'assets:precompile' + + # RELEASE step: + # - changes to the filesystem made here are DISCARDED + # - full access to secrets, databases + # - failures here prevent deployment + task :release + + # SERVER step: + # - changes to the filesystem made here are deployed + # - full access to secrets, databases + # - failures here result in VM being stated, shutdown, and rolled back + # to last successful deploy (if any). + task server: 'db:migrate' do + sh 'bin/rails server' + end +end diff --git a/lib/tasks/sway.rake b/lib/tasks/sway.rake new file mode 100644 index 00000000..27fed6f7 --- /dev/null +++ b/lib/tasks/sway.rake @@ -0,0 +1,21 @@ +require_relative '../sway_google_cloud_storage' + +namespace :sway do + include SwayGoogleCloudStorage + + desc 'Sets up a remote volume with files downloaded from a Google Cloud bucket' + task volume_setup: :environment do + download_directory(bucket_name: 'sway-sqlite', bucket_directory_name: 'seeds', local_directory_name: 'storage') + download_directory(bucket_name: 'sway-sqlite', bucket_directory_name: 'geojson', local_directory_name: 'storage') + + if File.exist? 'storage/production.sqlite3' + puts 'Uploading production.db to google storage as backup.' + upload_file(bucket_name: 'sway-sqlite', bucket_file_path: 'production.sqlite3', + local_file_path: 'storage/production.sqlite3') + else + puts 'Getting production.db from google storage backup.' + download_file(bucket_name: 'sway-sqlite', bucket_file_path: 'production.sqlite3', + local_file_path: 'storage/production.sqlite3') + end + end +end diff --git a/scripts/deploy.sh b/scripts/deploy.sh index d73d9bec..44e834e1 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env zsh export $(cat .env.github | xargs) @@ -16,13 +16,26 @@ export $(cat .env.github | xargs) SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:clobber RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:clobber -./litestream/replicate.sh +if [[ "$1" = "google" ]]; then -gcloud storage cp --recursive $(pwd)/storage/geojson gs://sway-sqlite/ + ./litestream/replicate.sh -gcloud storage cp --recursive $(pwd)/storage/seeds/data gs://sway-sqlite/seeds/ + gcloud storage cp --recursive $(pwd)/storage/geojson gs://sway-sqlite/ -# Cloud Run requires AMD64 images -docker buildx build . -f docker/dockerfiles/production.dockerfile --platform linux/amd64 -t us-central1-docker.pkg.dev/sway-421916/sway/sway:latest --push --compress + gcloud storage cp --recursive $(pwd)/storage/seeds/data gs://sway-sqlite/seeds/ -gcloud run deploy sway --project=sway-421916 --region=us-central1 --image=us-central1-docker.pkg.dev/sway-421916/sway/sway:latest --revision-suffix=${1} + # Cloud Run requires AMD64 images + docker buildx build . -f docker/dockerfiles/production.dockerfile --platform linux/amd64 -t us-central1-docker.pkg.dev/sway-421916/sway/sway:latest --push --compress + + gcloud run deploy sway --project=sway-421916 --region=us-central1 --image=us-central1-docker.pkg.dev/sway-421916/sway/sway:latest --revision-suffix=${1} + +elif [[ "$1" = "flyio" ]]; then + + echo $GITHUB_ACCESS_TOKEN | docker login ghcr.io -u dcordz --password-stdin + + docker buildx build . -f docker/dockerfiles/production.dockerfile --platform linux/amd64 -t ghcr.io/plebeian-technology/sway:latest --compress --push + + # docker buildx build . -f docker/dockerfiles/production.dockerfile --platform linux/amd64 -t sway-prod:latest --compress + + fly deploy +fi \ No newline at end of file diff --git a/tf/modules/cloud_run/main.tf b/tf/modules/cloud_run/main.tf index 12d0fe90..756e4634 100644 --- a/tf/modules/cloud_run/main.tf +++ b/tf/modules/cloud_run/main.tf @@ -192,7 +192,8 @@ resource "google_cloud_run_service" "app" { metadata { annotations = { "run.googleapis.com/execution-environment" : "gen2", - "run.googleapis.com/launch-stage" : "BETA" + "run.googleapis.com/launch-stage" : "BETA", + "autoscaling.knative.dev/maxScale": 1 } labels = { @@ -206,11 +207,11 @@ resource "google_cloud_run_service" "app" { latest_revision = true } - lifecycle { - ignore_changes = [ - metadata.0.annotations, - ] - } + # lifecycle { + # ignore_changes = [ + # metadata.0.annotations, + # ] + # } } data "google_iam_policy" "noauth" { @@ -291,7 +292,7 @@ resource "google_cloud_run_v2_job" "backup" { "s3", "cp", "/sway/${var.environment == "prod" ? "production" : var.environment}.sqlite3", - "s3://${local.digitalocean_bucket_name}/production-${timestamp()}.db", + "s3://${local.digitalocean_bucket_name}/production.db", "--endpoint-url", "https://nyc3.digitaloceanspaces.com", "--region", @@ -336,7 +337,7 @@ resource "google_cloud_run_v2_job" "backup" { name = local.google_bucket_name gcs { bucket = local.google_bucket_name - read_only = false + read_only = true } } }