fizzy-saas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.md +10 -0
- data/README.md +57 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/fizzy/saas/application.css +15 -0
- data/app/models/subscription.rb +13 -0
- data/app/views/layouts/fizzy/saas/application.html.erb +17 -0
- data/app/views/signup/completions/new.html.erb +30 -0
- data/app/views/signup/new.html.erb +28 -0
- data/config/database.yml +103 -0
- data/config/deploy.beta.yml +60 -0
- data/config/deploy.production.yml +80 -0
- data/config/deploy.staging.yml +77 -0
- data/config/deploy.yml +37 -0
- data/config/environments/beta.rb +8 -0
- data/config/environments/development.rb +10 -0
- data/config/environments/production.rb +8 -0
- data/config/environments/staging.rb +8 -0
- data/config/routes.rb +3 -0
- data/config/storage.yml +33 -0
- data/lib/fizzy/saas/engine.rb +98 -0
- data/lib/fizzy/saas/metrics.rb +13 -0
- data/lib/fizzy/saas/signup.rb +43 -0
- data/lib/fizzy/saas/testing.rb +9 -0
- data/lib/fizzy/saas/transaction_pinning.rb +65 -0
- data/lib/fizzy/saas/version.rb +5 -0
- data/lib/fizzy/saas.rb +12 -0
- data/lib/tasks/fizzy/saas_tasks.rake +18 -0
- data/lib/yabeda/solid_queue.rb +27 -0
- data/test/models/signup_test.rb +47 -0
- metadata +268 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1f850d45a7504d2e96057396d2d9ca8a8a4167fda02a3a51d3c797e346ff70c3
|
|
4
|
+
data.tar.gz: c241ef1add2ed5d6081d45fe14dc082479ab47133e3e7243abcae7379218c5f7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a04af9b6d8e8e00a9af2ac4cfacc11ef7a13ffbb0319279378367494e28caa61307dcab3b58fc46dd2a8c565bf474e4501ec6ae1418d92b3857dc5660742d485
|
|
7
|
+
data.tar.gz: c7cd9f827b9c6fb9533eca3eead283de8655ba14f8e5ecd76fbd78a6cbfd07cc8ba8987b06f074f134d38fb485c6a2848bf9b11424576b164e4879c5c6c96f5e
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# O'Saasy License Agreement
|
|
2
|
+
|
|
3
|
+
Copyright © 2025, 37signals LLC.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
This is a Rails engine that [37signals](https://37signals.com/) bundles with [Fizzy](https://github.com/basecamp/fizzy) to offer the hosted version at https://fizzy.do.
|
|
2
|
+
|
|
3
|
+
## Development
|
|
4
|
+
|
|
5
|
+
To make Fizzy run in SaaS mode, run this in the terminal:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
bin/rails saas:enable
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
To can go back to open source mode:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
bin/rails saas:disable
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then you can work do [Fizzy development as usual](https://github.com/basecamp/fizzy).
|
|
18
|
+
|
|
19
|
+
## How to update Fizzy
|
|
20
|
+
|
|
21
|
+
After making changes to this gem, you need to update Fizzy to pick up the changes:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
BUNDLE_GEMFILE=Gemfile.saas bundle update --conservative fizzy-saas
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Environments
|
|
28
|
+
|
|
29
|
+
Fizzy is deployed with [Kamal](https://kamal-deploy.org/). 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.
|
|
30
|
+
|
|
31
|
+
## Handbook
|
|
32
|
+
|
|
33
|
+
See the [Fizzy handbook](https://handbooks.37signals.works/18/fizzy) for runbooks and more.
|
|
34
|
+
|
|
35
|
+
### Production
|
|
36
|
+
|
|
37
|
+
- https://app.fizzy.do/
|
|
38
|
+
|
|
39
|
+
This environment uses a FlashBlade bucket for blob storage.
|
|
40
|
+
|
|
41
|
+
### Beta
|
|
42
|
+
|
|
43
|
+
Beta is primarily intended for testing product features. It uses the same production database and Active Storage configuration.
|
|
44
|
+
|
|
45
|
+
Beta tenant is:
|
|
46
|
+
|
|
47
|
+
- https://fizzy-beta.37signals.com
|
|
48
|
+
|
|
49
|
+
### Staging
|
|
50
|
+
|
|
51
|
+
Staging is primarily intended for testing infrastructure changes. It uses production-like but separate database and Active Storage configurations.
|
|
52
|
+
|
|
53
|
+
- https://fizzy.37signals-staging.com/
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
fizzy-saas is released under the [O'Saasy License](LICENSE.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class Subscription < Queenbee::Subscription
|
|
2
|
+
SHORT_NAMES = %w[ FreeV1 ]
|
|
3
|
+
|
|
4
|
+
def self.short_name
|
|
5
|
+
name.demodulize
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class FreeV1 < Subscription
|
|
9
|
+
property :proper_name, "Free Subscription"
|
|
10
|
+
property :price, 0
|
|
11
|
+
property :frequency, "yearly"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Fizzy saas</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "fizzy/saas/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<% @page_title = "Complete your sign-up" %>
|
|
2
|
+
|
|
3
|
+
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
|
|
4
|
+
<h1 class="txt-x-large font-weight-black margin-block-end"><%= @page_title %></h1>
|
|
5
|
+
|
|
6
|
+
<%= form_with model: @signup, url: saas.signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %>
|
|
7
|
+
<%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true %>
|
|
8
|
+
|
|
9
|
+
<p>You’re one step away. Just enter your name to get your own Fizzy account.</p>
|
|
10
|
+
|
|
11
|
+
<% if @signup.errors.any? %>
|
|
12
|
+
<div class="margin-block-half">
|
|
13
|
+
<ul class="margin-block-none txt-negative txt-small">
|
|
14
|
+
<% @signup.errors.full_messages.each do |message| %>
|
|
15
|
+
<li><%= message %></li>
|
|
16
|
+
<% end %>
|
|
17
|
+
</ul>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<button type="submit" class="btn btn--link center" data-form-target="submit">
|
|
22
|
+
<span>Continue</span>
|
|
23
|
+
<%= icon_tag "arrow-right" %>
|
|
24
|
+
</button>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<% content_for :footer do %>
|
|
29
|
+
<%= render "sessions/footer" %>
|
|
30
|
+
<% end %>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<% @page_title = "Sign up for Fizzy" %>
|
|
2
|
+
|
|
3
|
+
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
|
|
4
|
+
<h1 class="txt-xx-large margin-block-end-double">Sign up</h1>
|
|
5
|
+
|
|
6
|
+
<%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %>
|
|
7
|
+
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %>
|
|
8
|
+
|
|
9
|
+
<% if @signup.errors.any? %>
|
|
10
|
+
<div class="margin-block-half">
|
|
11
|
+
<ul class="margin-block-none txt-negative txt-small">
|
|
12
|
+
<% @signup.errors.full_messages.each do |message| %>
|
|
13
|
+
<li><%= message %></li>
|
|
14
|
+
<% end %>
|
|
15
|
+
</ul>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<button type="submit" class="btn btn--link center" data-form-target="submit">
|
|
20
|
+
<span>Continue</span>
|
|
21
|
+
<%= icon_tag "arrow-right" %>
|
|
22
|
+
</button>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<% content_for :footer do %>
|
|
27
|
+
<%= render "sessions/footer" %>
|
|
28
|
+
<% end %>
|
data/config/database.yml
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<%
|
|
2
|
+
if ENV["MIGRATE"].present?
|
|
3
|
+
mysql_app_user_key = "MYSQL_ALTER_USER"
|
|
4
|
+
mysql_app_password_key = "MYSQL_ALTER_PASSWORD"
|
|
5
|
+
max_execution_time_ms = 0 # No limit
|
|
6
|
+
else
|
|
7
|
+
mysql_app_user_key = "MYSQL_APP_USER"
|
|
8
|
+
mysql_app_password_key = "MYSQL_APP_PASSWORD"
|
|
9
|
+
max_execution_time_ms = 5_000
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
mysql_app_user = ENV[mysql_app_user_key]
|
|
13
|
+
mysql_app_password = ENV[mysql_app_password_key]
|
|
14
|
+
%>
|
|
15
|
+
|
|
16
|
+
default: &default
|
|
17
|
+
adapter: trilogy
|
|
18
|
+
host: <%= ENV.fetch "FIZZY_DB_HOST", "127.0.0.1" %>
|
|
19
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 3306 %>
|
|
20
|
+
pool: 50
|
|
21
|
+
timeout: 5000
|
|
22
|
+
variables:
|
|
23
|
+
transaction_isolation: READ-COMMITTED
|
|
24
|
+
max_execution_time: <%= max_execution_time_ms %>
|
|
25
|
+
|
|
26
|
+
development:
|
|
27
|
+
primary:
|
|
28
|
+
<<: *default
|
|
29
|
+
database: fizzy_development
|
|
30
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
|
|
31
|
+
replica:
|
|
32
|
+
<<: *default
|
|
33
|
+
database: fizzy_development
|
|
34
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
|
|
35
|
+
replica: true
|
|
36
|
+
cable:
|
|
37
|
+
<<: *default
|
|
38
|
+
database: development_cable
|
|
39
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
|
|
40
|
+
migrations_paths: db/cable_migrate
|
|
41
|
+
cache:
|
|
42
|
+
<<: *default
|
|
43
|
+
database: development_cache
|
|
44
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
|
|
45
|
+
migrations_paths: db/cache_migrate
|
|
46
|
+
queue:
|
|
47
|
+
<<: *default
|
|
48
|
+
database: development_queue
|
|
49
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
|
|
50
|
+
migrations_paths: db/queue_migrate
|
|
51
|
+
|
|
52
|
+
# Warning: The database defined as "test" will be erased and
|
|
53
|
+
# re-generated from your development database when you run "rake".
|
|
54
|
+
# Do not set this db to the same as development or production.
|
|
55
|
+
test:
|
|
56
|
+
primary:
|
|
57
|
+
<<: *default
|
|
58
|
+
database: fizzy_test
|
|
59
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
|
|
60
|
+
replica:
|
|
61
|
+
<<: *default
|
|
62
|
+
database: fizzy_test
|
|
63
|
+
port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %>
|
|
64
|
+
replica: true
|
|
65
|
+
|
|
66
|
+
production: &production
|
|
67
|
+
primary:
|
|
68
|
+
<<: *default
|
|
69
|
+
database: fizzy_production
|
|
70
|
+
host: <%= ENV["MYSQL_DATABASE_HOST"] %>
|
|
71
|
+
username: <%= mysql_app_user %>
|
|
72
|
+
password: <%= mysql_app_password %>
|
|
73
|
+
replica:
|
|
74
|
+
<<: *default
|
|
75
|
+
database: fizzy_production
|
|
76
|
+
host: <%= ENV["MYSQL_DATABASE_REPLICA_HOST"] %>
|
|
77
|
+
username: <%= ENV["MYSQL_READONLY_USER"] %>
|
|
78
|
+
password: <%= ENV["MYSQL_READONLY_PASSWORD"] %>
|
|
79
|
+
replica: true
|
|
80
|
+
cable:
|
|
81
|
+
<<: *default
|
|
82
|
+
database: fizzy_solidcable_production
|
|
83
|
+
host: <%= ENV["MYSQL_SOLID_CABLE_HOST"] %>
|
|
84
|
+
username: <%= mysql_app_user %>
|
|
85
|
+
password: <%= mysql_app_password %>
|
|
86
|
+
migrations_paths: db/cable_migrate
|
|
87
|
+
queue:
|
|
88
|
+
<<: *default
|
|
89
|
+
database: fizzy_solidqueue_production
|
|
90
|
+
host: <%= ENV["MYSQL_SOLID_QUEUE_HOST"] %>
|
|
91
|
+
username: <%= mysql_app_user %>
|
|
92
|
+
password: <%= mysql_app_password %>
|
|
93
|
+
migrations_paths: db/queue_migrate
|
|
94
|
+
cache:
|
|
95
|
+
<<: *default
|
|
96
|
+
database: fizzy_solidcache_production
|
|
97
|
+
host: <%= ENV["MYSQL_SOLID_CACHE_HOST"] %>
|
|
98
|
+
username: <%= mysql_app_user %>
|
|
99
|
+
password: <%= mysql_app_password %>
|
|
100
|
+
migrations_paths: db/cache_migrate
|
|
101
|
+
|
|
102
|
+
beta: *production
|
|
103
|
+
staging: *production
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
servers:
|
|
2
|
+
web:
|
|
3
|
+
hosts:
|
|
4
|
+
- fizzy-beta-app-01.sc-chi-int.37signals.com: sc_chi
|
|
5
|
+
- fizzy-beta-app-101.df-iad-int.37signals.com: df_iad
|
|
6
|
+
labels:
|
|
7
|
+
otel_scrape_enabled: true
|
|
8
|
+
|
|
9
|
+
# we don't run the jobs role in beta
|
|
10
|
+
allow_empty_roles: true
|
|
11
|
+
|
|
12
|
+
proxy:
|
|
13
|
+
ssl: false
|
|
14
|
+
|
|
15
|
+
ssh:
|
|
16
|
+
user: app
|
|
17
|
+
|
|
18
|
+
env:
|
|
19
|
+
clear:
|
|
20
|
+
RAILS_ENV: beta
|
|
21
|
+
MYSQL_DATABASE_HOST: fizzy-mysql-primary
|
|
22
|
+
MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica
|
|
23
|
+
MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary
|
|
24
|
+
MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary
|
|
25
|
+
MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101
|
|
26
|
+
secret:
|
|
27
|
+
- RAILS_MASTER_KEY
|
|
28
|
+
- MYSQL_ALTER_PASSWORD
|
|
29
|
+
- MYSQL_ALTER_USER
|
|
30
|
+
- MYSQL_APP_PASSWORD
|
|
31
|
+
- MYSQL_APP_USER
|
|
32
|
+
- MYSQL_READONLY_PASSWORD
|
|
33
|
+
- MYSQL_READONLY_USER
|
|
34
|
+
- SECRET_KEY_BASE
|
|
35
|
+
- VAPID_PUBLIC_KEY
|
|
36
|
+
- VAPID_PRIVATE_KEY
|
|
37
|
+
- ACTIVE_STORAGE_ACCESS_KEY_ID
|
|
38
|
+
- ACTIVE_STORAGE_SECRET_ACCESS_KEY
|
|
39
|
+
- QUEENBEE_API_TOKEN
|
|
40
|
+
- SIGNAL_ID_SECRET
|
|
41
|
+
- SENTRY_DSN
|
|
42
|
+
tags:
|
|
43
|
+
sc_chi: {}
|
|
44
|
+
df_iad:
|
|
45
|
+
PRIMARY_DATACENTER: true
|
|
46
|
+
|
|
47
|
+
accessories:
|
|
48
|
+
load-balancer:
|
|
49
|
+
image: basecamp/kamal-proxy:lb
|
|
50
|
+
host: fizzy-beta-lb-01.sc-chi-int.37signals.com
|
|
51
|
+
labels:
|
|
52
|
+
otel_role: load-balancer
|
|
53
|
+
otel_service: fizzy-load-balancer
|
|
54
|
+
otel_scrape_enabled: true
|
|
55
|
+
options:
|
|
56
|
+
publish:
|
|
57
|
+
- 80:80
|
|
58
|
+
- 443:443
|
|
59
|
+
volumes:
|
|
60
|
+
- load-balancer:/home/kamal-proxy/.config/kamal-proxy
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
servers:
|
|
2
|
+
web:
|
|
3
|
+
hosts:
|
|
4
|
+
- fizzy-app-01.sc-chi-int.37signals.com: sc_chi
|
|
5
|
+
- fizzy-app-02.sc-chi-int.37signals.com: sc_chi
|
|
6
|
+
- fizzy-app-101.df-iad-int.37signals.com: df_iad
|
|
7
|
+
- fizzy-app-102.df-iad-int.37signals.com: df_iad
|
|
8
|
+
- fizzy-app-401.df-ams-int.37signals.com: df_ams
|
|
9
|
+
- fizzy-app-402.df-ams-int.37signals.com: df_ams
|
|
10
|
+
labels:
|
|
11
|
+
otel_scrape_enabled: true
|
|
12
|
+
jobs:
|
|
13
|
+
hosts:
|
|
14
|
+
- fizzy-jobs-101.df-iad-int.37signals.com: df_iad
|
|
15
|
+
- fizzy-jobs-102.df-iad-int.37signals.com: df_iad
|
|
16
|
+
labels:
|
|
17
|
+
otel_scrape_enabled: true
|
|
18
|
+
|
|
19
|
+
proxy:
|
|
20
|
+
ssl: false
|
|
21
|
+
|
|
22
|
+
ssh:
|
|
23
|
+
user: app
|
|
24
|
+
|
|
25
|
+
env:
|
|
26
|
+
clear:
|
|
27
|
+
RAILS_ENV: production
|
|
28
|
+
MYSQL_DATABASE_HOST: fizzy-mysql-primary
|
|
29
|
+
MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica
|
|
30
|
+
MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary
|
|
31
|
+
MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary
|
|
32
|
+
secret:
|
|
33
|
+
- RAILS_MASTER_KEY
|
|
34
|
+
- MYSQL_ALTER_PASSWORD
|
|
35
|
+
- MYSQL_ALTER_USER
|
|
36
|
+
- MYSQL_APP_PASSWORD
|
|
37
|
+
- MYSQL_APP_USER
|
|
38
|
+
- MYSQL_READONLY_PASSWORD
|
|
39
|
+
- MYSQL_READONLY_USER
|
|
40
|
+
- SECRET_KEY_BASE
|
|
41
|
+
- VAPID_PUBLIC_KEY
|
|
42
|
+
- VAPID_PRIVATE_KEY
|
|
43
|
+
- ACTIVE_STORAGE_ACCESS_KEY_ID
|
|
44
|
+
- ACTIVE_STORAGE_SECRET_ACCESS_KEY
|
|
45
|
+
- QUEENBEE_API_TOKEN
|
|
46
|
+
- SIGNAL_ID_SECRET
|
|
47
|
+
- SENTRY_DSN
|
|
48
|
+
tags:
|
|
49
|
+
sc_chi:
|
|
50
|
+
MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com
|
|
51
|
+
df_iad:
|
|
52
|
+
MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-101.df-iad-int.37signals.com
|
|
53
|
+
PRIMARY_DATACENTER: true
|
|
54
|
+
df_ams:
|
|
55
|
+
MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-401.df-ams-int.37signals.com
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
accessories:
|
|
59
|
+
load-balancer:
|
|
60
|
+
image: basecamp/kamal-proxy:lb
|
|
61
|
+
hosts:
|
|
62
|
+
- fizzy-lb-101.df-iad-int.37signals.com
|
|
63
|
+
- fizzy-lb-102.df-iad-int.37signals.com
|
|
64
|
+
- fizzy-lb-01.sc-chi-int.37signals.com
|
|
65
|
+
- fizzy-lb-02.sc-chi-int.37signals.com
|
|
66
|
+
- fizzy-lb-401.df-ams-int.37signals.com
|
|
67
|
+
- fizzy-lb-402.df-ams-int.37signals.com
|
|
68
|
+
labels:
|
|
69
|
+
otel_role: load-balancer
|
|
70
|
+
otel_service: fizzy-load-balancer
|
|
71
|
+
otel_scrape_enabled: true
|
|
72
|
+
options:
|
|
73
|
+
publish:
|
|
74
|
+
- 80:80
|
|
75
|
+
- 443:443
|
|
76
|
+
# NFS mount for certificates
|
|
77
|
+
# See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061
|
|
78
|
+
mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-production-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2"
|
|
79
|
+
volumes:
|
|
80
|
+
- load-balancer:/home/kamal-proxy/.config/kamal-proxy
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
servers:
|
|
2
|
+
web:
|
|
3
|
+
hosts:
|
|
4
|
+
- fizzy-staging-app-101.df-iad-int.37signals.com: df_iad
|
|
5
|
+
- fizzy-staging-app-102.df-iad-int.37signals.com: df_iad
|
|
6
|
+
- fizzy-staging-app-01.sc-chi-int.37signals.com: sc_chi
|
|
7
|
+
- fizzy-staging-app-02.sc-chi-int.37signals.com: sc_chi
|
|
8
|
+
- fizzy-staging-app-401.df-ams-int.37signals.com: df_ams
|
|
9
|
+
- fizzy-staging-app-402.df-ams-int.37signals.com: df_ams
|
|
10
|
+
labels:
|
|
11
|
+
otel_scrape_enabled: true
|
|
12
|
+
jobs:
|
|
13
|
+
hosts:
|
|
14
|
+
- fizzy-staging-jobs-101.df-iad-int.37signals.com: df_iad
|
|
15
|
+
- fizzy-staging-jobs-102.df-iad-int.37signals.com: df_iad
|
|
16
|
+
labels:
|
|
17
|
+
otel_scrape_enabled: true
|
|
18
|
+
|
|
19
|
+
proxy:
|
|
20
|
+
ssl: false
|
|
21
|
+
|
|
22
|
+
ssh:
|
|
23
|
+
user: app
|
|
24
|
+
|
|
25
|
+
env:
|
|
26
|
+
clear:
|
|
27
|
+
RAILS_ENV: staging
|
|
28
|
+
MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary
|
|
29
|
+
MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica
|
|
30
|
+
MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary
|
|
31
|
+
MYSQL_SOLID_QUEUE_HOST: fizzy-staging-mysql-primary
|
|
32
|
+
secret:
|
|
33
|
+
- RAILS_MASTER_KEY
|
|
34
|
+
- MYSQL_ALTER_PASSWORD
|
|
35
|
+
- MYSQL_ALTER_USER
|
|
36
|
+
- MYSQL_APP_PASSWORD
|
|
37
|
+
- MYSQL_APP_USER
|
|
38
|
+
- MYSQL_READONLY_PASSWORD
|
|
39
|
+
- MYSQL_READONLY_USER
|
|
40
|
+
- SECRET_KEY_BASE
|
|
41
|
+
- VAPID_PUBLIC_KEY
|
|
42
|
+
- VAPID_PRIVATE_KEY
|
|
43
|
+
- ACTIVE_STORAGE_ACCESS_KEY_ID
|
|
44
|
+
- ACTIVE_STORAGE_SECRET_ACCESS_KEY
|
|
45
|
+
- QUEENBEE_API_TOKEN
|
|
46
|
+
- SIGNAL_ID_SECRET
|
|
47
|
+
- SENTRY_DSN
|
|
48
|
+
tags:
|
|
49
|
+
sc_chi:
|
|
50
|
+
MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com
|
|
51
|
+
df_iad:
|
|
52
|
+
MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-101.df-iad-int.37signals.com
|
|
53
|
+
PRIMARY_DATACENTER: true
|
|
54
|
+
df_ams:
|
|
55
|
+
MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-401.df-ams-int.37signals.com
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
accessories:
|
|
59
|
+
load-balancer:
|
|
60
|
+
image: basecamp/kamal-proxy:lb
|
|
61
|
+
hosts:
|
|
62
|
+
- fizzy-staging-lb-01.sc-chi-int.37signals.com
|
|
63
|
+
- fizzy-staging-lb-101.df-iad-int.37signals.com
|
|
64
|
+
- fizzy-staging-lb-401.df-ams-int.37signals.com
|
|
65
|
+
labels:
|
|
66
|
+
otel_role: load-balancer
|
|
67
|
+
otel_service: fizzy-load-balancer
|
|
68
|
+
otel_scrape_enabled: true
|
|
69
|
+
options:
|
|
70
|
+
publish:
|
|
71
|
+
- 80:80
|
|
72
|
+
- 443:443
|
|
73
|
+
# NFS mount for certificates
|
|
74
|
+
# See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061
|
|
75
|
+
mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-staging-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2"
|
|
76
|
+
volumes:
|
|
77
|
+
- load-balancer:/home/kamal-proxy/.config/kamal-proxy
|
data/config/deploy.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
service: fizzy
|
|
2
|
+
image: basecamp/fizzy
|
|
3
|
+
asset_path: /rails/public/assets
|
|
4
|
+
hooks_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal", "hooks") %>
|
|
5
|
+
secrets_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal/secrets") %>
|
|
6
|
+
|
|
7
|
+
servers:
|
|
8
|
+
jobs:
|
|
9
|
+
cmd: bin/jobs
|
|
10
|
+
|
|
11
|
+
volumes:
|
|
12
|
+
- fizzy:/rails/storage
|
|
13
|
+
|
|
14
|
+
proxy:
|
|
15
|
+
ssl: true
|
|
16
|
+
|
|
17
|
+
registry:
|
|
18
|
+
server: registry.37signals.com
|
|
19
|
+
username: robot$harbor-bot
|
|
20
|
+
password:
|
|
21
|
+
- BASECAMP_REGISTRY_PASSWORD
|
|
22
|
+
|
|
23
|
+
builder:
|
|
24
|
+
arch: amd64
|
|
25
|
+
dockerfile: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "Dockerfile") %>
|
|
26
|
+
secrets:
|
|
27
|
+
- GITHUB_TOKEN
|
|
28
|
+
remote: ssh://app@docker-builder-102
|
|
29
|
+
local: <%= ENV.fetch("KAMAL_BUILDER_LOCAL", "true") %>
|
|
30
|
+
|
|
31
|
+
env:
|
|
32
|
+
secret:
|
|
33
|
+
- RAILS_MASTER_KEY
|
|
34
|
+
|
|
35
|
+
aliases:
|
|
36
|
+
console: app exec -i --reuse "bin/rails console"
|
|
37
|
+
ssh: app exec -i --reuse /bin/bash
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
require_relative "production"
|
|
2
|
+
|
|
3
|
+
Rails.application.configure do
|
|
4
|
+
config.action_mailer.smtp_settings[:domain] = "fizzy-beta.37signals.com"
|
|
5
|
+
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
|
|
6
|
+
config.action_mailer.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
|
|
7
|
+
config.action_controller.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
|
|
8
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Rails.application.configure do
|
|
2
|
+
if Rails.root.join("tmp/structured-logging.txt").exist?
|
|
3
|
+
config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log")
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
if Rails.root.join("tmp/solid-queue.txt").exist?
|
|
7
|
+
config.active_job.queue_adapter = :solid_queue
|
|
8
|
+
config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Rails.application.configure do
|
|
2
|
+
config.active_storage.service = :purestorage
|
|
3
|
+
config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT)
|
|
4
|
+
|
|
5
|
+
config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" }
|
|
6
|
+
config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" }
|
|
7
|
+
config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false }
|
|
8
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
require_relative "production"
|
|
2
|
+
|
|
3
|
+
Rails.application.configure do
|
|
4
|
+
config.action_mailer.smtp_settings[:domain] = "fizzy.37signals-staging.com"
|
|
5
|
+
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
|
|
6
|
+
config.action_mailer.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" }
|
|
7
|
+
config.action_controller.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" }
|
|
8
|
+
end
|
data/config/routes.rb
ADDED
data/config/storage.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
test:
|
|
2
|
+
service: Disk
|
|
3
|
+
root: <%= Rails.root.join("tmp/storage/files") %>
|
|
4
|
+
|
|
5
|
+
local:
|
|
6
|
+
service: Disk
|
|
7
|
+
root: <%= Rails.root.join("storage", Rails.env, "files") %>
|
|
8
|
+
|
|
9
|
+
devminio:
|
|
10
|
+
service: S3
|
|
11
|
+
bucket: fizzy-dev-activestorage
|
|
12
|
+
endpoint: "http://minio.localhost:39000"
|
|
13
|
+
force_path_style: true
|
|
14
|
+
request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support
|
|
15
|
+
response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support
|
|
16
|
+
region: us-east-1 # default region required for signer
|
|
17
|
+
access_key_id: minioadmin
|
|
18
|
+
secret_access_key: minioadmin
|
|
19
|
+
|
|
20
|
+
# We have "development", "staging", and "production" buckets configured. Note that we don't have a
|
|
21
|
+
# "beta" bucket. (As of 2025-06-01.)
|
|
22
|
+
<% pure_env = Rails.env.beta? ? "production" : Rails.env %>
|
|
23
|
+
purestorage:
|
|
24
|
+
service: S3
|
|
25
|
+
bucket: fizzy-<%= pure_env %>-activestorage
|
|
26
|
+
endpoint: "https://storage.basecamp.com"
|
|
27
|
+
ssl_verify_peer: false # FIXME: using self-signed cert internally
|
|
28
|
+
force_path_style: true
|
|
29
|
+
request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support
|
|
30
|
+
response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support
|
|
31
|
+
region: us-east-1 # default region required for signer
|
|
32
|
+
access_key_id: <%= ENV["ACTIVE_STORAGE_ACCESS_KEY_ID"] %>
|
|
33
|
+
secret_access_key: <%= ENV["ACTIVE_STORAGE_SECRET_ACCESS_KEY"] %>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require_relative "transaction_pinning"
|
|
2
|
+
require_relative "signup"
|
|
3
|
+
|
|
4
|
+
module Fizzy
|
|
5
|
+
module Saas
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
# moved from config/initializers/queenbee.rb
|
|
8
|
+
Queenbee.host_app = Fizzy
|
|
9
|
+
|
|
10
|
+
initializer "fizzy.saas.mount" do |app|
|
|
11
|
+
app.routes.append do
|
|
12
|
+
mount Fizzy::Saas::Engine => "/", as: "saas"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "fizzy_saas.transaction_pinning" do |app|
|
|
17
|
+
app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer "fizzy_saas.solid_queue" do
|
|
21
|
+
SolidQueue.on_start do
|
|
22
|
+
Process.warmup
|
|
23
|
+
Yabeda::Prometheus::Exporter.start_metrics_server!
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
initializer "fizzy_saas.logging.session" do |app|
|
|
28
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
29
|
+
before_action do
|
|
30
|
+
if Current.identity.present?
|
|
31
|
+
logger.struct(authentication: { identity: { id: Current.identity.id } })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if Current.account.present?
|
|
35
|
+
logger.struct(account: { queenbee_id: Current.account.external_account_id })
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Load test mocks automatically in test environment
|
|
42
|
+
initializer "fizzy_saas.test_mocks", after: :load_config_initializers do
|
|
43
|
+
if Rails.env.test?
|
|
44
|
+
require "fizzy/saas/testing"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
initializer "fizzy_saas.sentry" do
|
|
49
|
+
if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank?
|
|
50
|
+
Sentry.init do |config|
|
|
51
|
+
config.dsn = ENV["SENTRY_DSN"]
|
|
52
|
+
config.breadcrumbs_logger = %i[ active_support_logger http_logger ]
|
|
53
|
+
config.send_default_pii = false
|
|
54
|
+
config.release = ENV["GIT_REVISION"]
|
|
55
|
+
config.excluded_exceptions += [ "ActiveRecord::ConcurrentMigrationError" ]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
initializer "fizzy_saas.yabeda" do
|
|
61
|
+
require "prometheus/client/support/puma"
|
|
62
|
+
|
|
63
|
+
Prometheus::Client.configuration.logger = Rails.logger
|
|
64
|
+
Prometheus::Client.configuration.pid_provider = Prometheus::Client::Support::Puma.method(:worker_pid_provider)
|
|
65
|
+
Yabeda::Rails.config.controller_name_case = :camel
|
|
66
|
+
Yabeda::Rails.config.ignore_actions = %w[
|
|
67
|
+
Rails::HealthController#show
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
Yabeda::ActiveJob.install!
|
|
71
|
+
|
|
72
|
+
require "yabeda/solid_queue"
|
|
73
|
+
Yabeda::SolidQueue.install!
|
|
74
|
+
|
|
75
|
+
Yabeda::ActionCable.configure do |config|
|
|
76
|
+
config.channel_class_name = "ActionCable::Channel::Base"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
require_relative "metrics"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
config.to_prepare do
|
|
83
|
+
::Signup.prepend(Fizzy::Saas::Signup)
|
|
84
|
+
|
|
85
|
+
Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
|
|
86
|
+
|
|
87
|
+
# Default to local dev QB token if not set
|
|
88
|
+
Queenbee::ApiToken.token = ENV.fetch("QUEENBEE_API_TOKEN") { "69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1" }
|
|
89
|
+
|
|
90
|
+
Subscription::SHORT_NAMES.each do |short_name|
|
|
91
|
+
const_name = "#{short_name}Subscription"
|
|
92
|
+
::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name)
|
|
93
|
+
::Object.const_set const_name, Subscription.const_get(short_name, false)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Yabeda.configure do
|
|
2
|
+
SHORT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5 ]
|
|
3
|
+
|
|
4
|
+
group :fizzy do
|
|
5
|
+
counter :replica_stale,
|
|
6
|
+
comment: "Number of requests served from a stale replica"
|
|
7
|
+
|
|
8
|
+
histogram :replica_wait,
|
|
9
|
+
unit: :seconds,
|
|
10
|
+
comment: "Time spent waiting for replica to catch up with transaction",
|
|
11
|
+
buckets: SHORT_HISTOGRAM_BUCKETS
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Fizzy
|
|
2
|
+
module Saas
|
|
3
|
+
module Signup
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
attr_reader :queenbee_account
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
def create_tenant
|
|
12
|
+
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
|
|
13
|
+
@queenbee_account.id.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def handle_account_creation_error(error)
|
|
17
|
+
@queenbee_account&.cancel
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def queenbee_account_attributes
|
|
21
|
+
{}.tap do |attributes|
|
|
22
|
+
attributes[:product_name] = "fizzy"
|
|
23
|
+
attributes[:name] = generate_account_name
|
|
24
|
+
attributes[:owner_name] = full_name
|
|
25
|
+
attributes[:owner_email] = email_address
|
|
26
|
+
|
|
27
|
+
attributes[:trial] = true
|
|
28
|
+
attributes[:subscription] = subscription_attributes
|
|
29
|
+
attributes[:remote_request] = request_attributes
|
|
30
|
+
|
|
31
|
+
# # TODO: Terms of Service
|
|
32
|
+
# attributes[:terms_of_service] = true
|
|
33
|
+
|
|
34
|
+
# We've confirmed the email
|
|
35
|
+
attributes[:auto_allow] = true
|
|
36
|
+
|
|
37
|
+
# Tell Queenbee to skip the request to create a local account. We've created it ourselves.
|
|
38
|
+
attributes[:skip_remote] = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require "queenbee/testing/mocks"
|
|
2
|
+
|
|
3
|
+
Queenbee::Remote::Account.class_eval do
|
|
4
|
+
# because we use the account ID as the tenant name, we need it to be unique in each test to avoid
|
|
5
|
+
# parallelized tests clobbering each other.
|
|
6
|
+
def next_id
|
|
7
|
+
super + Random.rand(1000000)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module TransactionPinning
|
|
2
|
+
class Middleware
|
|
3
|
+
SESSION_KEY = :last_txn
|
|
4
|
+
DEFAULT_MAX_WAIT = 0.25
|
|
5
|
+
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
@timeout = Rails.application.config.x.transaction_pinning&.timeout&.to_f || DEFAULT_MAX_WAIT
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(env)
|
|
12
|
+
request = ActionDispatch::Request.new(env)
|
|
13
|
+
replica_metrics = {}
|
|
14
|
+
|
|
15
|
+
if ApplicationRecord.current_role == :reading
|
|
16
|
+
wait_for_replica_catchup(request, replica_metrics)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
status, headers, body = @app.call(env)
|
|
20
|
+
headers.merge!(replica_metrics.transform_values(&:to_s))
|
|
21
|
+
|
|
22
|
+
if ApplicationRecord.current_role == :writing
|
|
23
|
+
capture_transaction_id(request)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
[ status, headers, body ]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
def wait_for_replica_catchup(request, replica_metrics)
|
|
31
|
+
if last_txn = request.session[SESSION_KEY].presence
|
|
32
|
+
has_transaction = tracking_replica_wait_time(replica_metrics) do
|
|
33
|
+
replica_has_transaction(last_txn)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless has_transaction
|
|
37
|
+
Yabeda.fizzy.replica_stale.increment
|
|
38
|
+
replica_metrics["X-Replica-Stale"] = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def capture_transaction_id(request)
|
|
44
|
+
request.session[SESSION_KEY] = ApplicationRecord.connection.show_variable("global.gtid_executed")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def replica_has_transaction(txn)
|
|
48
|
+
sql = ApplicationRecord.sanitize_sql_array([ "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)", txn, @timeout ])
|
|
49
|
+
ApplicationRecord.connection.select_value(sql) == 0
|
|
50
|
+
rescue => e
|
|
51
|
+
Sentry.capture_exception(e, extra: { gtid: txn })
|
|
52
|
+
true # Treat as if we're up to date, since we don't know
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tracking_replica_wait_time(replica_metrics)
|
|
56
|
+
started_at = Time.current
|
|
57
|
+
|
|
58
|
+
Yabeda.fizzy.replica_wait.measure do
|
|
59
|
+
yield
|
|
60
|
+
end.tap do
|
|
61
|
+
replica_metrics["X-Replica-Wait"] = Time.current - started_at
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/fizzy/saas.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require "fizzy/saas/version"
|
|
2
|
+
require "fizzy/saas/engine"
|
|
3
|
+
|
|
4
|
+
module Fizzy
|
|
5
|
+
module Saas
|
|
6
|
+
def self.append_test_paths
|
|
7
|
+
engine_test_path = Engine.root.join("test")
|
|
8
|
+
ENV["DEFAULT_TEST"] = "{#{engine_test_path},test}/**/*_test.rb"
|
|
9
|
+
ENV["DEFAULT_TEST_EXCLUDE"] = "{#{engine_test_path},test}/{system,dummy,fixtures}/**/*_test.rb"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "rake/testtask"
|
|
2
|
+
|
|
3
|
+
namespace :test do
|
|
4
|
+
# task :prepare_saas => :environment do
|
|
5
|
+
# require "rails/test_help"
|
|
6
|
+
#
|
|
7
|
+
# $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s
|
|
8
|
+
# require Fizzy::Saas::Engine.root.join("test/test_helper")
|
|
9
|
+
# end
|
|
10
|
+
|
|
11
|
+
desc "Run tests for fizzy-saas gem"
|
|
12
|
+
Rake::TestTask.new(:saas => :environment) do |t|
|
|
13
|
+
t.libs << "test"
|
|
14
|
+
t.test_files = FileList[Fizzy::Saas::Engine.root.join("test/**/*_test.rb")]
|
|
15
|
+
t.warning = false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Yabeda
|
|
2
|
+
module SolidQueue
|
|
3
|
+
def self.install!
|
|
4
|
+
Yabeda.configure do
|
|
5
|
+
group :solid_queue
|
|
6
|
+
|
|
7
|
+
gauge :jobs_failed_count, comment: "Number of failed jobs"
|
|
8
|
+
gauge :jobs_unreleased_count, comment: "Number of claimed jobs that don't belong to any process"
|
|
9
|
+
gauge :jobs_scheduled_and_delayed_count, comment: "Number of scheduled jobs that have over 5 minutes delay"
|
|
10
|
+
gauge :recurring_tasks_count, comment: "Number of recurring jobs scheduled"
|
|
11
|
+
gauge :recurring_tasks_delayed_count, comment: "Number of recurring jobs that haven't been enqueued within their schedule"
|
|
12
|
+
|
|
13
|
+
collect do
|
|
14
|
+
if ::SolidQueue.supervisor?
|
|
15
|
+
solid_queue.jobs_failed_count.set({}, ::SolidQueue::FailedExecution.count)
|
|
16
|
+
solid_queue.jobs_unreleased_count.set({}, ::SolidQueue::ClaimedExecution.where(process: nil).count)
|
|
17
|
+
solid_queue.jobs_scheduled_and_delayed_count.set({}, ::SolidQueue::ScheduledExecution.where(scheduled_at: ..5.minutes.ago).count)
|
|
18
|
+
solid_queue.recurring_tasks_count.set({}, ::SolidQueue::RecurringTask.count)
|
|
19
|
+
solid_queue.recurring_tasks_delayed_count.set({}, ::SolidQueue::RecurringTask.count do |task|
|
|
20
|
+
task.last_enqueued_time.present? && (task.previous_time - task.last_enqueued_time) > 5.minutes
|
|
21
|
+
end)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
|
|
3
|
+
class Fizzy::Saas::SignupTest < ActiveSupport::TestCase
|
|
4
|
+
test "#complete creates queenbee account and uses its id as tenant" do
|
|
5
|
+
queenbee_account = mock("queenbee_account")
|
|
6
|
+
queenbee_account.stubs(:id).returns(123456)
|
|
7
|
+
|
|
8
|
+
Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account)
|
|
9
|
+
Account.any_instance.expects(:setup_customer_template).once
|
|
10
|
+
|
|
11
|
+
Current.without_account do
|
|
12
|
+
assert_changes -> { Account.count }, +1 do
|
|
13
|
+
sequence_value_before = Account::ExternalIdSequence.value
|
|
14
|
+
|
|
15
|
+
signup = Signup.new(
|
|
16
|
+
full_name: "Kevin",
|
|
17
|
+
identity: identities(:kevin)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
assert signup.complete
|
|
21
|
+
|
|
22
|
+
assert signup.account
|
|
23
|
+
assert_equal 123456, signup.account.external_account_id
|
|
24
|
+
assert_equal sequence_value_before, Account::ExternalIdSequence.value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
test "#complete calls cancel on queenbee account when account creation fails" do
|
|
30
|
+
queenbee_account = mock("queenbee_account")
|
|
31
|
+
queenbee_account.stubs(:id).returns(789012)
|
|
32
|
+
queenbee_account.expects(:cancel).once
|
|
33
|
+
|
|
34
|
+
Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account)
|
|
35
|
+
Account.any_instance.stubs(:setup_customer_template).raises(StandardError.new("Account setup failed"))
|
|
36
|
+
|
|
37
|
+
Current.without_account do
|
|
38
|
+
signup = Signup.new(
|
|
39
|
+
full_name: "Kevin",
|
|
40
|
+
identity: identities(:kevin)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert_not signup.complete
|
|
44
|
+
assert_includes signup.errors[:base], "Something went wrong, and we couldn't create your account. Please give it another try."
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fizzy-saas
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mike Dalessio
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 8.1.0.beta1
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 8.1.0.beta1
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: queenbee
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rails_structured_logging
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: sentry-ruby
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: sentry-rails
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: yabeda
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: yabeda-actioncable
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
type: :runtime
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: yabeda-activejob
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: yabeda-gc
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: yabeda-http_requests
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0'
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - ">="
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0'
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: yabeda-prometheus-mmap
|
|
154
|
+
requirement: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - ">="
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '0'
|
|
159
|
+
type: :runtime
|
|
160
|
+
prerelease: false
|
|
161
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
+
requirements:
|
|
163
|
+
- - ">="
|
|
164
|
+
- !ruby/object:Gem::Version
|
|
165
|
+
version: '0'
|
|
166
|
+
- !ruby/object:Gem::Dependency
|
|
167
|
+
name: yabeda-puma-plugin
|
|
168
|
+
requirement: !ruby/object:Gem::Requirement
|
|
169
|
+
requirements:
|
|
170
|
+
- - ">="
|
|
171
|
+
- !ruby/object:Gem::Version
|
|
172
|
+
version: '0'
|
|
173
|
+
type: :runtime
|
|
174
|
+
prerelease: false
|
|
175
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
176
|
+
requirements:
|
|
177
|
+
- - ">="
|
|
178
|
+
- !ruby/object:Gem::Version
|
|
179
|
+
version: '0'
|
|
180
|
+
- !ruby/object:Gem::Dependency
|
|
181
|
+
name: yabeda-rails
|
|
182
|
+
requirement: !ruby/object:Gem::Requirement
|
|
183
|
+
requirements:
|
|
184
|
+
- - ">="
|
|
185
|
+
- !ruby/object:Gem::Version
|
|
186
|
+
version: '0.10'
|
|
187
|
+
type: :runtime
|
|
188
|
+
prerelease: false
|
|
189
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
+
requirements:
|
|
191
|
+
- - ">="
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: '0.10'
|
|
194
|
+
- !ruby/object:Gem::Dependency
|
|
195
|
+
name: prometheus-client-mmap
|
|
196
|
+
requirement: !ruby/object:Gem::Requirement
|
|
197
|
+
requirements:
|
|
198
|
+
- - ">="
|
|
199
|
+
- !ruby/object:Gem::Version
|
|
200
|
+
version: '0'
|
|
201
|
+
type: :runtime
|
|
202
|
+
prerelease: false
|
|
203
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
204
|
+
requirements:
|
|
205
|
+
- - ">="
|
|
206
|
+
- !ruby/object:Gem::Version
|
|
207
|
+
version: '0'
|
|
208
|
+
description: Rails engine that bundles with Fizzy to offer the hosted version at https://app.fizzy.do
|
|
209
|
+
email:
|
|
210
|
+
- mike@37signals.com
|
|
211
|
+
executables: []
|
|
212
|
+
extensions: []
|
|
213
|
+
extra_rdoc_files: []
|
|
214
|
+
files:
|
|
215
|
+
- LICENSE.md
|
|
216
|
+
- README.md
|
|
217
|
+
- Rakefile
|
|
218
|
+
- app/assets/stylesheets/fizzy/saas/application.css
|
|
219
|
+
- app/models/subscription.rb
|
|
220
|
+
- app/views/layouts/fizzy/saas/application.html.erb
|
|
221
|
+
- app/views/signup/completions/new.html.erb
|
|
222
|
+
- app/views/signup/new.html.erb
|
|
223
|
+
- config/database.yml
|
|
224
|
+
- config/deploy.beta.yml
|
|
225
|
+
- config/deploy.production.yml
|
|
226
|
+
- config/deploy.staging.yml
|
|
227
|
+
- config/deploy.yml
|
|
228
|
+
- config/environments/beta.rb
|
|
229
|
+
- config/environments/development.rb
|
|
230
|
+
- config/environments/production.rb
|
|
231
|
+
- config/environments/staging.rb
|
|
232
|
+
- config/routes.rb
|
|
233
|
+
- config/storage.yml
|
|
234
|
+
- lib/fizzy/saas.rb
|
|
235
|
+
- lib/fizzy/saas/engine.rb
|
|
236
|
+
- lib/fizzy/saas/metrics.rb
|
|
237
|
+
- lib/fizzy/saas/signup.rb
|
|
238
|
+
- lib/fizzy/saas/testing.rb
|
|
239
|
+
- lib/fizzy/saas/transaction_pinning.rb
|
|
240
|
+
- lib/fizzy/saas/version.rb
|
|
241
|
+
- lib/tasks/fizzy/saas_tasks.rake
|
|
242
|
+
- lib/yabeda/solid_queue.rb
|
|
243
|
+
- test/models/signup_test.rb
|
|
244
|
+
homepage: https://github.com/basecamp/fizzy-saas
|
|
245
|
+
licenses:
|
|
246
|
+
- O'Saasy
|
|
247
|
+
metadata:
|
|
248
|
+
allowed_push_host: https://rubygems.org
|
|
249
|
+
homepage_uri: https://github.com/basecamp/fizzy-saas
|
|
250
|
+
source_code_uri: https://github.com/basecamp/fizzy-saas
|
|
251
|
+
rdoc_options: []
|
|
252
|
+
require_paths:
|
|
253
|
+
- lib
|
|
254
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
255
|
+
requirements:
|
|
256
|
+
- - ">="
|
|
257
|
+
- !ruby/object:Gem::Version
|
|
258
|
+
version: '0'
|
|
259
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
260
|
+
requirements:
|
|
261
|
+
- - ">="
|
|
262
|
+
- !ruby/object:Gem::Version
|
|
263
|
+
version: '0'
|
|
264
|
+
requirements: []
|
|
265
|
+
rubygems_version: 3.7.2
|
|
266
|
+
specification_version: 4
|
|
267
|
+
summary: 37signals SaaS companion for Fizzy
|
|
268
|
+
test_files: []
|