monkrb 0.15.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/CHANGELOG.md +581 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/exe/monk +116 -0
- data/lib/monk/assets.rb +197 -0
- data/lib/monk/auth/errors.rb +13 -0
- data/lib/monk/auth/helpers.rb +76 -0
- data/lib/monk/auth/login_token.rb +11 -0
- data/lib/monk/auth/rate_limiter.rb +46 -0
- data/lib/monk/auth/session.rb +11 -0
- data/lib/monk/auth.rb +301 -0
- data/lib/monk/base.rb +323 -0
- data/lib/monk/context.rb +78 -0
- data/lib/monk/environment.rb +50 -0
- data/lib/monk/errors.rb +37 -0
- data/lib/monk/freeze_hooks.rb +23 -0
- data/lib/monk/live/client/idiomorph.LICENSE +13 -0
- data/lib/monk/live/client/idiomorph.js +4 -0
- data/lib/monk/live/client/monk_live.js +204 -0
- data/lib/monk/live/client/protocol.js +87 -0
- data/lib/monk/live/envelope.rb +51 -0
- data/lib/monk/live/errors.rb +9 -0
- data/lib/monk/live/helpers.rb +22 -0
- data/lib/monk/live/policy.rb +58 -0
- data/lib/monk/live/publisher.rb +91 -0
- data/lib/monk/live/renderer.rb +47 -0
- data/lib/monk/live/session.rb +121 -0
- data/lib/monk/live.rb +96 -0
- data/lib/monk/log.rb +130 -0
- data/lib/monk/persistence/errors.rb +7 -0
- data/lib/monk/persistence/model.rb +41 -0
- data/lib/monk/persistence/pg/errors.rb +4 -0
- data/lib/monk/persistence/pg/migrator.rb +165 -0
- data/lib/monk/persistence/pg/model.rb +233 -0
- data/lib/monk/persistence/pg.rb +34 -0
- data/lib/monk/persistence.rb +113 -0
- data/lib/monk/scaffold.rb +606 -0
- data/lib/monk/settings.rb +151 -0
- data/lib/monk/state_ractor.rb +45 -0
- data/lib/monk/templates/auth/config/auth.rb +28 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
- data/lib/monk/templates/base/.dockerignore +5 -0
- data/lib/monk/templates/base/.gitignore +4 -0
- data/lib/monk/templates/base/.ruby-version +1 -0
- data/lib/monk/templates/base/Dockerfile +28 -0
- data/lib/monk/templates/base/Gemfile +7 -0
- data/lib/monk/templates/base/bin/server +5 -0
- data/lib/monk/templates/base/bin/websocket_server +62 -0
- data/lib/monk/templates/base/config/settings.rb +30 -0
- data/lib/monk/templates/base/config.ru +13 -0
- data/lib/monk/templates/base/public/css/app.css +17 -0
- data/lib/monk/templates/base/public/js/app.js +5 -0
- data/lib/monk/templates/base/views/index.erb +6 -0
- data/lib/monk/templates/base/views/layouts/app.erb +18 -0
- data/lib/monk/templates/live/bin/websocket_server +30 -0
- data/lib/monk/templates/live/config/live.rb +47 -0
- data/lib/monk/templates/live/config.ru +27 -0
- data/lib/monk/templates/live/views/index.erb +18 -0
- data/lib/monk/templates/live/views/live/_hits.erb +1 -0
- data/lib/monk/templates/postgres/Dockerfile +30 -0
- data/lib/monk/templates/postgres/Gemfile.extra +2 -0
- data/lib/monk/templates/postgres/bin/console +7 -0
- data/lib/monk/templates/postgres/bin/migrate +22 -0
- data/lib/monk/templates/postgres/bin/setup_db +9 -0
- data/lib/monk/templates/postgres/config/persistence.rb +10 -0
- data/lib/monk/templates/redis/Gemfile.extra +1 -0
- data/lib/monk/version.rb +9 -0
- data/lib/monk/views.rb +175 -0
- data/lib/monk/websocket/connection.rb +226 -0
- data/lib/monk/websocket/errors.rb +9 -0
- data/lib/monk/websocket/frame.rb +71 -0
- data/lib/monk/websocket/handshake.rb +77 -0
- data/lib/monk/websocket/redis_fanout.rb +103 -0
- data/lib/monk/websocket/registry.rb +92 -0
- data/lib/monk/websocket/server.rb +234 -0
- data/lib/monk/websocket.rb +19 -0
- data/lib/monk.rb +45 -0
- metadata +252 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Monk
|
|
6
|
+
# Writes a new Monk project's skeleton to disk. Templates are static
|
|
7
|
+
# files, copied verbatim -- nothing here needs the project's own name
|
|
8
|
+
# substituted in, so there's no templating engine involved. See
|
|
9
|
+
# docs/history/plan-init.md for the full design.
|
|
10
|
+
class Scaffold
|
|
11
|
+
TEMPLATES_DIR = File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
BASE_FILES = {
|
|
14
|
+
"Gemfile" => "base/Gemfile",
|
|
15
|
+
"config.ru" => "base/config.ru",
|
|
16
|
+
"config/settings.rb" => "base/config/settings.rb",
|
|
17
|
+
".ruby-version" => "base/.ruby-version",
|
|
18
|
+
".gitignore" => "base/.gitignore",
|
|
19
|
+
".dockerignore" => "base/.dockerignore",
|
|
20
|
+
"Dockerfile" => "base/Dockerfile",
|
|
21
|
+
"bin/server" => "base/bin/server",
|
|
22
|
+
"bin/websocket_server" => "base/bin/websocket_server",
|
|
23
|
+
"views/layouts/app.erb" => "base/views/layouts/app.erb",
|
|
24
|
+
"views/index.erb" => "base/views/index.erb",
|
|
25
|
+
"public/css/app.css" => "base/public/css/app.css",
|
|
26
|
+
"public/js/app.js" => "base/public/js/app.js",
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
POSTGRES_FILES = {
|
|
30
|
+
"config/persistence.rb" => "postgres/config/persistence.rb",
|
|
31
|
+
"bin/console" => "postgres/bin/console",
|
|
32
|
+
"bin/setup_db" => "postgres/bin/setup_db",
|
|
33
|
+
"bin/migrate" => "postgres/bin/migrate",
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# The pg gem's native extension needs libpq -- the base Dockerfile has
|
|
37
|
+
# no system packages at all (kino is a precompiled platform gem), so
|
|
38
|
+
# --postgres/--auth swap in a Dockerfile that adds libpq-dev/libpq5
|
|
39
|
+
# instead of patching one image for every combination.
|
|
40
|
+
POSTGRES_OVERRIDES = {
|
|
41
|
+
"Dockerfile" => "postgres/Dockerfile",
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
AUTH_FILES = {
|
|
45
|
+
"config/auth.rb" => "auth/config/auth.rb",
|
|
46
|
+
"db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
|
|
47
|
+
"db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# --live: Monk::Live's demo (a counter whose open tabs update when
|
|
51
|
+
# another request changes it). These replace three base files outright
|
|
52
|
+
# rather than patching them -- a live config.ru and index are different
|
|
53
|
+
# files, not one line of diff -- and add two. config.ru's first line stays
|
|
54
|
+
# the settings require, so --postgres/--auth wiring still finds it.
|
|
55
|
+
LIVE_OVERRIDES = {
|
|
56
|
+
"config.ru" => "live/config.ru",
|
|
57
|
+
"bin/websocket_server" => "live/bin/websocket_server",
|
|
58
|
+
"views/index.erb" => "live/views/index.erb",
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
LIVE_FILES = {
|
|
62
|
+
"config/live.rb" => "live/config/live.rb",
|
|
63
|
+
"views/live/_hits.erb" => "live/views/live/_hits.erb",
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
# Where the client runtime lands in the app's public root (an app serves
|
|
67
|
+
# it like any other static file; its files import each other by relative
|
|
68
|
+
# path, so they stay together in one directory).
|
|
69
|
+
LIVE_CLIENT_DIR = "public/js/monk_live".freeze
|
|
70
|
+
|
|
71
|
+
LIVE_HEAD = <<~HTML.gsub(/^/, " ").freeze
|
|
72
|
+
<meta name="monk-live-url" content="<%= settings[:live_ws_url] %>">
|
|
73
|
+
<script type="module" src="<%= asset_path "/js/monk_live/monk_live.js" %>"></script>
|
|
74
|
+
HTML
|
|
75
|
+
|
|
76
|
+
EXECUTABLE_FILES = %w[bin/server bin/websocket_server bin/console bin/setup_db bin/migrate].freeze
|
|
77
|
+
|
|
78
|
+
# auth: implies postgres -- Monk::Auth is Postgres-only (lib/monk/auth.rb
|
|
79
|
+
# subclasses Monk::Persistence::Pg::Model), so there's no combination
|
|
80
|
+
# where an app gets Auth without also getting the persistence scaffold.
|
|
81
|
+
#
|
|
82
|
+
# redis: is unrelated to both -- it doesn't imply, and isn't implied by,
|
|
83
|
+
# postgres or auth. Unlike them it adds no files of its own, just a
|
|
84
|
+
# Gemfile line: bin/websocket_server (always present, see BASE_FILES
|
|
85
|
+
# above -- WebSocket needs no external service, so unlike Postgres/Redis
|
|
86
|
+
# it isn't gated behind a flag at all) checks ENV["REDIS_URL"] at boot
|
|
87
|
+
# and only then requires "redis", so the gem has to already be in the
|
|
88
|
+
# bundle for that to work.
|
|
89
|
+
#
|
|
90
|
+
# live: implies redis: -- bin/server and bin/websocket_server are separate
|
|
91
|
+
# processes, so an update published by one only reaches a socket held by
|
|
92
|
+
# the other through Redis.
|
|
93
|
+
def initialize(dir, postgres: false, auth: false, redis: false, live: false)
|
|
94
|
+
@dir = dir
|
|
95
|
+
@auth = auth
|
|
96
|
+
@postgres = postgres || auth
|
|
97
|
+
@live = live
|
|
98
|
+
@redis = redis || live
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def write!
|
|
102
|
+
raise Monk::ScaffoldExistsError, "#{@dir} already exists" if File.exist?(@dir)
|
|
103
|
+
|
|
104
|
+
FileUtils.mkdir_p(@dir)
|
|
105
|
+
base_files = BASE_FILES
|
|
106
|
+
base_files = base_files.merge(LIVE_OVERRIDES) if @live
|
|
107
|
+
base_files = base_files.merge(POSTGRES_OVERRIDES) if @postgres
|
|
108
|
+
base_files.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
|
|
109
|
+
write_live! if @live
|
|
110
|
+
|
|
111
|
+
if @postgres
|
|
112
|
+
POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
|
|
113
|
+
FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
|
|
114
|
+
append_gemfile_extra("postgres/Gemfile.extra")
|
|
115
|
+
wire_config_ru!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
append_gemfile_extra("redis/Gemfile.extra") if @redis
|
|
119
|
+
|
|
120
|
+
# --postgres and --redis are the two flags with anything worth
|
|
121
|
+
# putting in an env file (a database, a REDIS_URL) -- the base
|
|
122
|
+
# skeleton alone has nothing to configure this way.
|
|
123
|
+
if @postgres || @redis
|
|
124
|
+
write_env_files!
|
|
125
|
+
uncomment_dotenv!
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
AUTH_FILES.each { |relative, template| write_file(relative, template) } if @auth
|
|
129
|
+
|
|
130
|
+
# Every combination of flags gets a SETUP.md, not just --postgres --
|
|
131
|
+
# even the base skeleton has a dev step (bin/server) and an
|
|
132
|
+
# unscaffolded test framework to set up.
|
|
133
|
+
write_setup_md!
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def write_live!
|
|
139
|
+
LIVE_FILES.each { |relative, template| write_file(relative, template) }
|
|
140
|
+
copy_live_client!
|
|
141
|
+
add_live_to_layout!
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The runtime ships inside the gem (Monk::Live.client_dir); copied, not
|
|
145
|
+
# duplicated under templates/, so there is one source of truth.
|
|
146
|
+
def copy_live_client!
|
|
147
|
+
source = File.join(__dir__, "live/client")
|
|
148
|
+
target = File.join(@dir, LIVE_CLIENT_DIR)
|
|
149
|
+
FileUtils.mkdir_p(target)
|
|
150
|
+
FileUtils.cp(Dir.children(source).map { |name| File.join(source, name) }, target)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def add_live_to_layout!
|
|
154
|
+
path = File.join(@dir, "views/layouts/app.erb")
|
|
155
|
+
content = File.read(path)
|
|
156
|
+
raise "layout wiring failed: </head> not found" unless content.include?(" </head>\n")
|
|
157
|
+
|
|
158
|
+
File.write(path, content.sub(" </head>\n", "#{LIVE_HEAD} </head>\n"))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def append_gemfile_extra(template_path)
|
|
162
|
+
extra = File.read(File.join(TEMPLATES_DIR, template_path))
|
|
163
|
+
File.write(File.join(@dir, "Gemfile"), "\n#{extra}", mode: "a")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# --postgres/--redis write a real .env (see write_env_files!) -- if the
|
|
167
|
+
# dotenv gem stays commented out, as it does in the base skeleton,
|
|
168
|
+
# config/settings.rb's `require "dotenv/load"` never runs, so nothing
|
|
169
|
+
# ever actually loads them: bin/setup_db (or bin/websocket_server's
|
|
170
|
+
# REDIS_URL check) silently falls back to config/persistence.rb's own
|
|
171
|
+
# ENV.fetch defaults, or no REDIS_URL at all, instead of the
|
|
172
|
+
# app-specific values .env was written to provide.
|
|
173
|
+
DOTENV_COMMENTED_LINE = %(# gem "dotenv" # uncomment to load a local .env file (config/settings.rb)\n).freeze
|
|
174
|
+
DOTENV_LINE = %(gem "dotenv" # loads .env/.env.test -- see config/settings.rb\n).freeze
|
|
175
|
+
|
|
176
|
+
def uncomment_dotenv!
|
|
177
|
+
path = File.join(@dir, "Gemfile")
|
|
178
|
+
content = File.read(path)
|
|
179
|
+
raise "Gemfile wiring failed: #{DOTENV_COMMENTED_LINE.inspect} not found" unless content.include?(DOTENV_COMMENTED_LINE)
|
|
180
|
+
|
|
181
|
+
File.write(path, content.sub(DOTENV_COMMENTED_LINE, DOTENV_LINE))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# config.ru ships in BASE_FILES unconditionally (it has to -- it's the
|
|
185
|
+
# only rackup entrypoint), so --postgres/--auth can't gate whether it
|
|
186
|
+
# exists, only what it requires. Unlike BASE_FILES/POSTGRES_FILES/
|
|
187
|
+
# AUTH_FILES, this is a post-write edit rather than a verbatim copy --
|
|
188
|
+
# the alternative (a second, fuller config.ru template per flag
|
|
189
|
+
# combination) would duplicate the whole file for one line of diff.
|
|
190
|
+
def wire_config_ru!
|
|
191
|
+
path = File.join(@dir, "config.ru")
|
|
192
|
+
settings_require = %(require_relative "config/settings"\n)
|
|
193
|
+
target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
|
|
194
|
+
|
|
195
|
+
content = File.read(path)
|
|
196
|
+
raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
|
|
197
|
+
|
|
198
|
+
File.write(path, content.sub(settings_require, "#{settings_require}require_relative \"#{target}\"\n"))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# .env/.env.test/.env.example are the other deliberate exception to
|
|
202
|
+
# "templates are static files, copied verbatim" (see the class comment
|
|
203
|
+
# above): DB_NAME needs this app's own directory name in it -- the one
|
|
204
|
+
# piece of scaffold-wide content that's actually per-project -- so
|
|
205
|
+
# these are composed here instead of read from disk. .env/.env.test are
|
|
206
|
+
# gitignored (see base/.gitignore); .env.example is the tracked
|
|
207
|
+
# placeholder that file explicitly carves out.
|
|
208
|
+
def write_env_files!
|
|
209
|
+
app_name = File.basename(@dir)
|
|
210
|
+
|
|
211
|
+
dev = @postgres ? pg_env_lines("#{app_name}_development") : []
|
|
212
|
+
test = @postgres ? pg_env_lines("#{app_name}_test") : []
|
|
213
|
+
example = @postgres ? pg_env_lines("#{app_name}_development") : []
|
|
214
|
+
|
|
215
|
+
if @auth
|
|
216
|
+
dev << "AUTH_SECRET=change-me-dev-secret"
|
|
217
|
+
test << "AUTH_SECRET=change-me-test-secret"
|
|
218
|
+
example << "AUTH_SECRET=change-me"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# REDIS_URL is deliberately absent from .env.test -- only a test that
|
|
222
|
+
# actually exercises RedisFanout needs it, unlike DB_NAME/AUTH_SECRET
|
|
223
|
+
# which every test touching persistence/auth needs.
|
|
224
|
+
if @redis
|
|
225
|
+
dev << "REDIS_URL=redis://localhost:6379/0"
|
|
226
|
+
example << "REDIS_URL=redis://localhost:6379/0"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
write_lines(".env", dev)
|
|
230
|
+
write_lines(".env.example", example)
|
|
231
|
+
# --redis alone (no --postgres) has nothing to put in .env.test --
|
|
232
|
+
# skip it rather than write an empty, pointless file.
|
|
233
|
+
write_lines(".env.test", test) unless test.empty?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def pg_env_lines(dbname)
|
|
237
|
+
[
|
|
238
|
+
"DB_HOST=127.0.0.1",
|
|
239
|
+
"DB_PORT=5432",
|
|
240
|
+
"DB_USER=postgres",
|
|
241
|
+
"DB_PASSWORD=postgres",
|
|
242
|
+
"DB_NAME=#{dbname}",
|
|
243
|
+
]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def write_lines(relative_path, lines)
|
|
247
|
+
File.write(File.join(@dir, relative_path), "#{lines.join("\n")}\n")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def write_setup_md!
|
|
251
|
+
File.write(File.join(@dir, "SETUP.md"), setup_md_content)
|
|
252
|
+
File.write(File.join(@dir, "SETUP.md"), live_setup_md_content, mode: "a") if @live
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def live_setup_md_content
|
|
256
|
+
<<~MARKDOWN
|
|
257
|
+
|
|
258
|
+
## Live updates (Monk::Live)
|
|
259
|
+
|
|
260
|
+
`monk new --live` wired a demo: a counter whose open tabs update by
|
|
261
|
+
themselves. It needs three things running, each in its own terminal:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
docker run --rm -d -p 6379:6379 --name #{File.basename(@dir)}_redis redis:7 # skip if one is already up
|
|
265
|
+
bin/server # the HTTP app on :9292, which publishes updates
|
|
266
|
+
bin/websocket_server # the sockets on :9293, which deliver them
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Open http://localhost:9292 in two tabs and press the button in one.
|
|
270
|
+
|
|
271
|
+
`bin/server` and `bin/websocket_server` are separate processes, so Redis
|
|
272
|
+
(`REDIS_URL`, already in `.env`) is what carries an update from one to
|
|
273
|
+
the other. Where things are:
|
|
274
|
+
|
|
275
|
+
- `config/live.rb` -- the Redis wiring and the subscribe rules (nothing
|
|
276
|
+
is allowed unless a rule says so).
|
|
277
|
+
- `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
|
|
278
|
+
- `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
|
|
279
|
+
- `views/live/_hits.erb` -- the fragment that gets pushed. Partials
|
|
280
|
+
used this way see only their locals (`locals[:hits]`), never
|
|
281
|
+
`params` or the session.
|
|
282
|
+
- `public/js/monk_live/` -- the browser runtime, copied from the gem.
|
|
283
|
+
The layout points at it and at `LIVE_WS_URL` (default
|
|
284
|
+
`ws://localhost:9293`; use `wss://` in production).
|
|
285
|
+
|
|
286
|
+
`WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
|
|
287
|
+
origin your pages are served from, or the browser's socket is refused.
|
|
288
|
+
MARKDOWN
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def setup_md_content
|
|
292
|
+
@postgres ? postgres_setup_md_content : base_setup_md_content
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# No Postgres, so no database/containers/migrations -- but still a
|
|
296
|
+
# real dev-then-test walkthrough: bin/server needs nothing external,
|
|
297
|
+
# and "no test framework scaffolded" is just as true here as it is
|
|
298
|
+
# with --postgres.
|
|
299
|
+
def base_setup_md_content
|
|
300
|
+
app_name = File.basename(@dir)
|
|
301
|
+
|
|
302
|
+
<<~MARKDOWN
|
|
303
|
+
# Setting up #{app_name} (dev, then test)
|
|
304
|
+
|
|
305
|
+
#{base_setup_md_intro(app_name)}
|
|
306
|
+
|
|
307
|
+
## Dev environment, first run
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
bundle install
|
|
311
|
+
bin/server # HTTP app on :9292 -> http://localhost:9292/hello
|
|
312
|
+
bin/websocket_server # WS chat process on :9293, in another terminal
|
|
313
|
+
```
|
|
314
|
+
#{redis_only_note}
|
|
315
|
+
## Test environment
|
|
316
|
+
|
|
317
|
+
`monk new` scaffolds no test framework at all -- this is the minimum to
|
|
318
|
+
get `bundle exec rake test` working, using Minitest (matches `monk`'s
|
|
319
|
+
own suite):
|
|
320
|
+
|
|
321
|
+
**Gemfile** -- add:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
group :test do
|
|
325
|
+
gem "minitest"
|
|
326
|
+
gem "rake"
|
|
327
|
+
end
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**test/test_helper.rb**:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
$LOAD_PATH.unshift(File.expand_path("..", __dir__))
|
|
334
|
+
|
|
335
|
+
ENV["MONK_ENV"] ||= "test"
|
|
336
|
+
|
|
337
|
+
require "minitest/autorun"
|
|
338
|
+
require_relative "../config/settings"
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Rakefile**:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
require "rake/testtask"
|
|
345
|
+
|
|
346
|
+
Rake::TestTask.new do |t|
|
|
347
|
+
t.libs << "test"
|
|
348
|
+
t.pattern = "test/**/*_test.rb"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
task default: :test
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**test/settings_test.rb** -- `config.ru`'s `class App` lives inline in a
|
|
355
|
+
rackup file, not a plain `.rb` a test could `require_relative`, so this
|
|
356
|
+
starts with what's actually requirable standalone. Extract `App` into
|
|
357
|
+
its own file (`require_relative`d from both `config.ru` and
|
|
358
|
+
`test/test_helper.rb`) once there's real app behavior worth testing
|
|
359
|
+
against requests:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
require_relative "test_helper"
|
|
363
|
+
|
|
364
|
+
class SettingsTest < Minitest::Test
|
|
365
|
+
def test_monk_env_reads_as_test
|
|
366
|
+
assert_equal "test", Monk::Settings[:monk_env]
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Then:
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
bundle install
|
|
375
|
+
bundle exec rake test
|
|
376
|
+
```
|
|
377
|
+
MARKDOWN
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def base_setup_md_intro(app_name)
|
|
381
|
+
unless @redis
|
|
382
|
+
return "No external services are scaffolded here (`monk new #{app_name}`, no " \
|
|
383
|
+
"`--postgres`/`--auth`/`--redis`) -- `bin/server`/`bin/websocket_server` need " \
|
|
384
|
+
"nothing external to run."
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
"No database is scaffolded here (`monk new #{app_name} --redis`, no `--postgres`/" \
|
|
388
|
+
"`--auth`) -- `bin/server` needs nothing external, but `bin/websocket_server`'s " \
|
|
389
|
+
"cross-process fan-out needs a reachable Redis once `REDIS_URL` (already set in " \
|
|
390
|
+
"the generated `.env`) is visible to it."
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def redis_only_note
|
|
394
|
+
return "" unless @redis
|
|
395
|
+
|
|
396
|
+
app_name = File.basename(@dir)
|
|
397
|
+
"\n`.env` already sets `REDIS_URL=redis://localhost:6379/0`, turning on " \
|
|
398
|
+
"`bin/websocket_server`'s cross-process fan-out -- unset (or missing entirely), it " \
|
|
399
|
+
"runs in-process only. Check `docker ps` first in case a Redis container is already " \
|
|
400
|
+
"running from another project; otherwise start one: `docker run --rm -d -p 6379:6379 " \
|
|
401
|
+
"--name #{app_name}_redis redis:7`.\n"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def postgres_setup_md_content
|
|
405
|
+
app_name = File.basename(@dir)
|
|
406
|
+
|
|
407
|
+
<<~MARKDOWN
|
|
408
|
+
# Setting up #{app_name} (dev, then test)
|
|
409
|
+
|
|
410
|
+
`config.ru`, `.env`, and `.env.test` are already wired up by `monk new`
|
|
411
|
+
-- `config.ru` requires `config/#{@auth ? "auth" : "persistence"}` before `class App`, and
|
|
412
|
+
`.env`/`.env.test` are pre-filled with a database name derived from
|
|
413
|
+
this project's directory (`#{app_name}_development` / `#{app_name}_test`), not the
|
|
414
|
+
generic `app_development` fallback baked into `config/persistence.rb`
|
|
415
|
+
itself. Adjust `DB_HOST`/`DB_USER`/`DB_PASSWORD` in both files if your
|
|
416
|
+
local Postgres doesn't use the `postgres`/`postgres` defaults.
|
|
417
|
+
|
|
418
|
+
`MONK_ENV` and the database name are two separate, unlinked knobs --
|
|
419
|
+
setting `MONK_ENV=test` does not by itself change which database you
|
|
420
|
+
connect to. Only `DB_NAME` (here, via `.env`/`.env.test`) does that.
|
|
421
|
+
|
|
422
|
+
## Dev environment, first run
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
bundle install
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### 1. Start Postgres#{@redis ? " and Redis" : ""}
|
|
429
|
+
|
|
430
|
+
Check first whether a Postgres#{@redis ? "/Redis" : ""} container is already running from another
|
|
431
|
+
project -- starting a second one bound to the same host port fails with
|
|
432
|
+
`port is already allocated`:
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
docker ps
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
If something's already listening on 5432#{@redis ? "/6379" : ""}, reuse it instead of starting a new
|
|
439
|
+
one -- point `.env`/`.env.test`'s `DB_HOST`/`DB_PORT`#{@redis ? "/`REDIS_URL`" : ""} at it, and use its
|
|
440
|
+
actual `DB_USER`/`DB_PASSWORD` rather than the generated defaults.
|
|
441
|
+
Otherwise, start #{@redis ? "fresh containers" : "a fresh container"}:
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres --name #{app_name}_pg postgres:16
|
|
445
|
+
#{"docker run --rm -d -p 6379:6379 --name #{app_name}_redis redis:7\n" if @redis}```
|
|
446
|
+
|
|
447
|
+
### 2. Create the dev database
|
|
448
|
+
|
|
449
|
+
Plain `createdb` connects over the local Unix socket by default, which a
|
|
450
|
+
Docker container never provides -- use `-h`/`-p` to force a TCP
|
|
451
|
+
connection, or run `createdb` from inside the container instead to avoid
|
|
452
|
+
passing a password on the command line:
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
# via TCP (adjust -h/-p/-U to match whatever's actually running):
|
|
456
|
+
PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_development
|
|
457
|
+
|
|
458
|
+
# or, from inside an existing Postgres container, as the postgres user:
|
|
459
|
+
docker exec -u postgres <container_name> createdb #{app_name}_development
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### 3. Run migrations
|
|
463
|
+
|
|
464
|
+
```bash
|
|
465
|
+
bin/setup_db
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### 4. Run it
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
bin/server # HTTP app on :9292
|
|
472
|
+
bin/websocket_server # WS chat process on :9293, in another terminal
|
|
473
|
+
```
|
|
474
|
+
#{auth_or_redis_confirmation_note}
|
|
475
|
+
## Test environment
|
|
476
|
+
|
|
477
|
+
### 1. Create the test database
|
|
478
|
+
|
|
479
|
+
```bash
|
|
480
|
+
PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres #{app_name}_test
|
|
481
|
+
|
|
482
|
+
# or, from inside an existing Postgres container, as the postgres user:
|
|
483
|
+
docker exec -u postgres <container_name> createdb #{app_name}_test
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### 2. Migrate the test database
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
DB_NAME=#{app_name}_test bin/setup_db
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### 3. Add a test framework and run it
|
|
493
|
+
|
|
494
|
+
`monk new` scaffolds no test framework at all -- this is the minimum to
|
|
495
|
+
get `bundle exec rake test` working, using Minitest (matches `monk`'s
|
|
496
|
+
own suite):
|
|
497
|
+
|
|
498
|
+
**Gemfile** -- add:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
group :test do
|
|
502
|
+
gem "minitest"
|
|
503
|
+
gem "rake"
|
|
504
|
+
end
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
**test/test_helper.rb** -- loads `.env.test` explicitly (not the default
|
|
508
|
+
dotenv-in-`config/settings.rb` path, which only loads plain `.env`), then
|
|
509
|
+
wires up the app config the same way `config.ru` does:
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
$LOAD_PATH.unshift(File.expand_path("..", __dir__))
|
|
513
|
+
|
|
514
|
+
ENV["MONK_ENV"] ||= "test"
|
|
515
|
+
|
|
516
|
+
require "dotenv"
|
|
517
|
+
Dotenv.load(File.expand_path(".env.test", __dir__ + "/.."))
|
|
518
|
+
|
|
519
|
+
require "minitest/autorun"
|
|
520
|
+
require_relative "../config/settings"
|
|
521
|
+
require_relative "../config/#{@auth ? "auth" : "persistence"}"
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Rakefile**:
|
|
525
|
+
|
|
526
|
+
```ruby
|
|
527
|
+
require "rake/testtask"
|
|
528
|
+
|
|
529
|
+
Rake::TestTask.new do |t|
|
|
530
|
+
t.libs << "test"
|
|
531
|
+
t.pattern = "test/**/*_test.rb"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
task default: :test
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
**test/persistence_test.rb** -- a real smoke test, not just a
|
|
538
|
+
connectivity check:
|
|
539
|
+
|
|
540
|
+
```ruby
|
|
541
|
+
require_relative "test_helper"
|
|
542
|
+
|
|
543
|
+
class PersistenceTest < Minitest::Test
|
|
544
|
+
#{sample_test_body}
|
|
545
|
+
end
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Then:
|
|
549
|
+
|
|
550
|
+
```bash
|
|
551
|
+
bundle install
|
|
552
|
+
bundle exec rake test
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
`test/test_helper.rb` sets `MONK_ENV=test` itself (so `Monk.env.test?`
|
|
556
|
+
reads correctly if the app ever branches on it) and loads `.env.test` for
|
|
557
|
+
the actual connection details -- but per the note at the top, those are
|
|
558
|
+
two separate knobs: `MONK_ENV` doesn't affect `DB_NAME` on its own,
|
|
559
|
+
`.env.test`'s `DB_NAME=#{app_name}_test` is what actually points tests at
|
|
560
|
+
the right database.
|
|
561
|
+
MARKDOWN
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def auth_or_redis_confirmation_note
|
|
565
|
+
return "" unless @auth || @redis
|
|
566
|
+
|
|
567
|
+
flags = [("authenticate: true" if @auth), ("redis fan-out: on" if @redis)].compact.join(", ")
|
|
568
|
+
visible = if @auth && @redis
|
|
569
|
+
"both `AUTH_SECRET` and `REDIS_URL` are"
|
|
570
|
+
else
|
|
571
|
+
@auth ? "`AUTH_SECRET` is" : "`REDIS_URL` is"
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
"\n`bin/websocket_server`'s startup line should print `#{flags}` once " \
|
|
575
|
+
"#{visible} visible to it -- that confirms everything's actually wired up.\n"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def sample_test_body
|
|
579
|
+
if @auth
|
|
580
|
+
<<~RUBY.chomp.gsub(/^/, " ")
|
|
581
|
+
def test_connects_to_the_test_database_and_sees_the_auth_tables
|
|
582
|
+
Monk::Persistence::Pg.checkout(:primary) do |conn|
|
|
583
|
+
result = conn.exec("SELECT to_regclass('login_tokens') IS NOT NULL AS present")
|
|
584
|
+
assert_equal true, result[0]["present"]
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
RUBY
|
|
588
|
+
else
|
|
589
|
+
<<~RUBY.chomp.gsub(/^/, " ")
|
|
590
|
+
def test_connects_to_the_test_database
|
|
591
|
+
Monk::Persistence::Pg.checkout(:primary) do |conn|
|
|
592
|
+
assert_equal "1", conn.exec("SELECT 1").getvalue(0, 0)
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
RUBY
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def write_file(relative_path, template_path, executable: false)
|
|
600
|
+
destination = File.join(@dir, relative_path)
|
|
601
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
602
|
+
FileUtils.cp(File.join(TEMPLATES_DIR, template_path), destination)
|
|
603
|
+
File.chmod(0o755, destination) if executable
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|