command_tower 0.16.0 → 0.18.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 +4 -4
- data/README.md +8 -8
- data/app/controllers/command_tower/application_controller.rb +1 -0
- data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
- data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
- data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
- data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
- data/app/models/command_tower/messaging/communication.rb +31 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +45 -1
- data/app/services/command_tower/email_theme/resolver.rb +45 -0
- data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
- data/app/services/command_tower/messaging/accept/persister.rb +13 -0
- data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
- data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
- data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
- data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
- data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
- data/app/services/command_tower/messaging/inbox.rb +4 -0
- data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
- data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +224 -0
- data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
- data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
- data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
- data/app/services/command_tower/messaging.rb +5 -1
- data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
- data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
- data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
- data/app/services/command_tower/services/messaging/inbox.rb +55 -2
- data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
- data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
- data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
- data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
- data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
- data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb +5 -1
- data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
- data/config/routes.rb +1 -0
- data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
- data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
- data/docs/api_reference.md +13 -2
- data/docs/authorization.md +1 -1
- data/docs/bootstrap/00-ownership.md +77 -0
- data/docs/bootstrap/01-docker-make-compose.md +267 -0
- data/docs/bootstrap/02-create-the-rails-app.md +75 -0
- data/docs/bootstrap/03-pin-the-gem.md +53 -0
- data/docs/bootstrap/04-secrets-and-env.md +59 -0
- data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
- data/docs/bootstrap/06-mount-and-health.md +53 -0
- data/docs/bootstrap/07-execution-bases.md +38 -0
- data/docs/bootstrap/08-initializer.md +90 -0
- data/docs/bootstrap/09-rbac.md +83 -0
- data/docs/bootstrap/10-roles-and-gates.md +62 -0
- data/docs/bootstrap/11-auth-client-path.md +57 -0
- data/docs/bootstrap/12-smoke-check.md +81 -0
- data/docs/bootstrap/13-optional.md +50 -0
- data/docs/bootstrap/14-sanity-checks.md +44 -0
- data/docs/bootstrap/README.md +90 -0
- data/docs/cookie_authentication_guide.md +11 -0
- data/docs/extending.md +5 -4
- data/docs/host_integration_guide.md +3 -246
- data/docs/initializing.md +37 -28
- data/docs/messaging_integration_guide.md +39 -1
- data/docs/principal_capabilities.md +1 -1
- data/docs/upgrades/0.10.0.md +5 -5
- data/docs/upgrades/0.11.0.md +5 -5
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/0.18.0.md +35 -0
- data/docs/upgrades/README.md +3 -1
- data/lib/command_tower/authorization/default.yml +1 -0
- data/lib/command_tower/client_compatibility/version.rb +45 -0
- data/lib/command_tower/client_compatibility.rb +23 -0
- data/lib/command_tower/configuration/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -0
- data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
- data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
- data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
- data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
- data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
- data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
- data/lib/command_tower/configuration/registry/config.rb +14 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
- data/lib/command_tower/current.rb +1 -0
- data/lib/command_tower/engine.rb +4 -0
- data/lib/command_tower/inbox_presentations.rb +19 -0
- data/lib/command_tower/install/baseline.rb +2 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +47 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module ClientCompatibility
|
|
6
|
+
# Orchestrates one HTTP request's client-version-compatibility check.
|
|
7
|
+
# Maps the pure `Services::ClientCompatibility::Evaluate` projection to
|
|
8
|
+
# a `WorkflowResult`, and is the only layer (along with the boundary)
|
|
9
|
+
# allowed to place recommendation metadata onto `CommandTower::Current`
|
|
10
|
+
# — `Evaluate` itself never touches `Current` (authority §10, §13).
|
|
11
|
+
class EvaluateWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
12
|
+
retry_strategy :none
|
|
13
|
+
|
|
14
|
+
APP_VERSION_HEADER = "X-App-Version"
|
|
15
|
+
PLATFORM_HEADER = "X-Client-Platform"
|
|
16
|
+
|
|
17
|
+
def call(request:, controller_class:, action_name:)
|
|
18
|
+
decision = CommandTower::Services::ClientCompatibility::Evaluate.call(
|
|
19
|
+
app_version_header: request.headers[APP_VERSION_HEADER],
|
|
20
|
+
platform_header: request.headers[PLATFORM_HEADER],
|
|
21
|
+
controller_class: controller_class,
|
|
22
|
+
action_name: action_name
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
mode = CommandTower.config.registry.client_compatibility.mode
|
|
26
|
+
enforced = mode == :enforce
|
|
27
|
+
blocked = enforced && decision.incompatible?
|
|
28
|
+
|
|
29
|
+
log_decision(decision, mode:, enforced:, blocked:)
|
|
30
|
+
stash_recommendation!(decision)
|
|
31
|
+
|
|
32
|
+
if blocked
|
|
33
|
+
return failure(
|
|
34
|
+
errors: [CommandTower::Errors::ClientUpdateRequiredError.new(details: details_for(decision))],
|
|
35
|
+
http_status: :upgrade_required
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
success(payload: { decision: decision })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def stash_recommendation!(decision)
|
|
45
|
+
return unless decision.recommendation_projection
|
|
46
|
+
|
|
47
|
+
CommandTower::Current.client_compatibility_recommendation = decision.recommendation_projection
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def details_for(decision)
|
|
51
|
+
{
|
|
52
|
+
platform: decision.platform&.to_s,
|
|
53
|
+
scope: decision.scope&.to_s,
|
|
54
|
+
currentVersion: decision.current_version,
|
|
55
|
+
minimumVersion: decision.effective_minimum,
|
|
56
|
+
recovery: decision.recovery&.to_s,
|
|
57
|
+
updateUrl: decision.update_url
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Structured (non-audit) decision log via the semantic `command_tower.log.*`
|
|
62
|
+
# event contract (authority §20). Workflows must not write lifecycle
|
|
63
|
+
# observation directly to `Rails.logger` — `publish_event` routes
|
|
64
|
+
# through `CommandTower::Logging::Subscriber` like every other
|
|
65
|
+
# semantic log line.
|
|
66
|
+
def log_decision(decision, mode:, enforced:, blocked:)
|
|
67
|
+
payload = {
|
|
68
|
+
message: "client_compatibility.evaluated",
|
|
69
|
+
mode: mode,
|
|
70
|
+
enforced: enforced,
|
|
71
|
+
platform: decision.platform,
|
|
72
|
+
app_version: decision.current_version,
|
|
73
|
+
matched_entities: decision.matched_entity_names,
|
|
74
|
+
decision: decision.decision,
|
|
75
|
+
http_status: blocked ? 426 : nil
|
|
76
|
+
}.compact
|
|
77
|
+
|
|
78
|
+
publish_event(category: :log, name: blocked ? :warn : :info, payload:)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module ClientCompatibility
|
|
6
|
+
# Shared sequence fragment: reads the recommendation projection that
|
|
7
|
+
# `EvaluateWorkflow` stashed on `Current` (never written by `Evaluate`
|
|
8
|
+
# itself) and shapes it into `WorkflowResult.meta`. Included only by
|
|
9
|
+
# Login and Session::Show — recommended-update guidance is deliberately
|
|
10
|
+
# not attached to every success envelope (authority §13).
|
|
11
|
+
module RecommendationMeta
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def client_compatibility_meta
|
|
17
|
+
recommendation = CommandTower::Current.client_compatibility_recommendation
|
|
18
|
+
return {} if recommendation.blank?
|
|
19
|
+
|
|
20
|
+
{ clientCompatibility: recommendation }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb
CHANGED
|
@@ -15,7 +15,9 @@ module CommandTower
|
|
|
15
15
|
title:,
|
|
16
16
|
body:,
|
|
17
17
|
platform_enabled_channels:,
|
|
18
|
-
metadata: nil
|
|
18
|
+
metadata: nil,
|
|
19
|
+
conversation_key: nil,
|
|
20
|
+
conversation_title: nil
|
|
19
21
|
)
|
|
20
22
|
user = User.find_by(id: user_id)
|
|
21
23
|
unless user
|
|
@@ -34,6 +36,8 @@ module CommandTower
|
|
|
34
36
|
body:,
|
|
35
37
|
metadata:,
|
|
36
38
|
platform_enabled_channels:,
|
|
39
|
+
conversation_key:,
|
|
40
|
+
conversation_title:,
|
|
37
41
|
)
|
|
38
42
|
|
|
39
43
|
unless result.success?
|
|
@@ -26,12 +26,26 @@ module CommandTower
|
|
|
26
26
|
result = CommandTower::Services::Messaging::Inbox::List.call(user:, limit:, offset:, scope:)
|
|
27
27
|
return result_or_failure(result) unless result.success?
|
|
28
28
|
|
|
29
|
-
payload = result.data[:items].map { |item| CommandTower::Serializers::Messaging::Inbox::
|
|
29
|
+
payload = result.data[:items].map { |item| CommandTower::Serializers::Messaging::Inbox::EntrySerializer.serialize(item) }
|
|
30
30
|
meta = CommandTower::Serializers::Messaging::Inbox::PaginationMetaSerializer.serialize(result.data[:pagination])
|
|
31
31
|
success(payload:, meta:, http_status: :ok)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
class ConversationWorkflow < BaseWorkflow
|
|
36
|
+
def call(user:, key:, scope:, around: nil, before_id: nil, after_id: nil, limit: nil)
|
|
37
|
+
result = CommandTower::Services::Messaging::Inbox::Conversation.call(
|
|
38
|
+
user:, key:, scope:, around:, before_id:, after_id:, limit:,
|
|
39
|
+
)
|
|
40
|
+
return result_or_failure(result) unless result.success?
|
|
41
|
+
|
|
42
|
+
success(
|
|
43
|
+
payload: CommandTower::Serializers::Messaging::Inbox::ConversationSerializer.serialize(result.data[:conversation]),
|
|
44
|
+
http_status: :ok,
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
35
49
|
class ShowWorkflow < BaseWorkflow
|
|
36
50
|
def call(user:, inbox_item_id:)
|
|
37
51
|
result = CommandTower::Services::Messaging::Inbox::Show.call(user:, inbox_item_id:)
|
data/config/routes.rb
CHANGED
|
@@ -64,6 +64,7 @@ CommandTower::Engine.routes.draw do
|
|
|
64
64
|
|
|
65
65
|
resources :inbox, only: [:index, :show, :destroy], controller: "inbox" do
|
|
66
66
|
collection do
|
|
67
|
+
get :conversation
|
|
67
68
|
get :unread_count, path: "unread-count"
|
|
68
69
|
post "bulk/read", action: :bulk_read
|
|
69
70
|
post "bulk/unread", action: :bulk_unread
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddConversationIdentityToMessagingCommunications < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
add_column :messaging_communications, :conversation_key, :string, limit: 128
|
|
6
|
+
add_column :messaging_communications, :conversation_title, :string
|
|
7
|
+
add_index :messaging_communications, %i[user_id conversation_key],
|
|
8
|
+
name: "index_messaging_communications_on_user_and_conversation_key"
|
|
9
|
+
end
|
|
10
|
+
end
|
data/docs/api_reference.md
CHANGED
|
@@ -338,8 +338,8 @@ Pagination for list: query `limit` (default **50**, max **100**), `offset` (defa
|
|
|
338
338
|
| Method | Path | Notes |
|
|
339
339
|
|--------|------|--------|
|
|
340
340
|
| `GET` | `/me/inbox` | List — `data` array of items; pagination meta |
|
|
341
|
-
| `GET` | `/me/inbox/:id` | Detail (+ `body`, `metadata`, `notificationTypeKey`) |
|
|
342
|
-
| `POST` | `/me/inbox/:id/open` | Detail |
|
|
341
|
+
| `GET` | `/me/inbox/:id` | Detail (+ `body`, `metadata`, `notificationTypeKey`, `content`) |
|
|
342
|
+
| `POST` | `/me/inbox/:id/open` | Detail (+ `content`) |
|
|
343
343
|
| `PATCH` | `/me/inbox/:id/archive` | Item |
|
|
344
344
|
| `DELETE` | `/me/inbox/:id` | `data: null` |
|
|
345
345
|
| `GET` | `/me/inbox/unread-count` | `{ count }` |
|
|
@@ -349,6 +349,17 @@ Pagination for list: query `limit` (default **50**, max **100**), `offset` (defa
|
|
|
349
349
|
| `POST` | `/me/inbox/bulk/restore` | same |
|
|
350
350
|
| `POST` | `/me/inbox/bulk/delete` | same |
|
|
351
351
|
|
|
352
|
+
**`content` (detail only, response-only — `inbox_document_v1`):** rendered at read from the item's `Communication`, never persisted; absent from list items. Shape: `{ schema: "inbox_document_v1", blocks: [...] }`. Allowlisted block types:
|
|
353
|
+
|
|
354
|
+
| Block | Fields | Notes |
|
|
355
|
+
|-------|--------|-------|
|
|
356
|
+
| `paragraph` | `text` (string) | |
|
|
357
|
+
| `cta` | `label` (string), `href` (string) | `href` must be `http(s)` or a custom scheme (e.g. `pickem://...`); `javascript:`/`data:`/`vbscript:`, schemeless, blank, and unparsable hrefs are rejected — the `cta` block is simply omitted, never an error |
|
|
358
|
+
|
|
359
|
+
Generic (default) rendering: one `paragraph` block from `communication.body` (omitted if blank — `blocks` can legitimately be `[]`), plus one `cta` block if `metadata.deep_link` is a safe href (label from `metadata.cta_label`, default `"Open"`).
|
|
360
|
+
|
|
361
|
+
Hosts may override the document per `notificationTypeKey` with an `inbox_document.json.erb` view at `app/views/command_tower/messaging/rendering/<notification_type_key>/inbox_document.json.erb` (same lookup convention as [`messaging_integration_guide.md`](messaging_integration_guide.md#rendering-template-overrides)). Any failure resolving or rendering that template (missing file, malformed JSON, wrong `schema`/`blocks` shape, or a raising template) fails open to the generic document — the Inbox read path never 500s on a bad type template. A valid envelope with one invalid/unknown block strips only that block; if stripping empties `blocks`, the generic document is used instead.
|
|
362
|
+
|
|
352
363
|
**List item fields:** `id`, `title`, `status`, `read`, `viewedAt`, `createdAt`, `updatedAt`.
|
|
353
364
|
|
|
354
365
|
**Errors:** `401` / `403` / `422`; show/open may return `404` `not_found`.
|
data/docs/authorization.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Authorization establishes **permission** after authentication. Failed authorization returns `403`.
|
|
4
4
|
|
|
5
|
-
Host `rbac_groups.yml` is a **required integration step**
|
|
5
|
+
Host `rbac_groups.yml` is a **required integration step** — see [Bootstrap — RBAC](bootstrap/09-rbac.md). CommandTower ships CT-owned entity definitions. Hosts grant those names to product roles; they must not copy CT controller mappings. Without a host role that grants Me/Auth entities, authenticated calls return **403**.
|
|
6
6
|
|
|
7
7
|
## Quick usage (host provisional)
|
|
8
8
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# 00 — Ownership
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Know what CommandTower owns vs what the host owns **before** you generate a Rails app. Wrong ownership here produces forked platform code and duplicate schema.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
Read [`README.md`](README.md). This step is conceptual; no files yet.
|
|
10
|
+
|
|
11
|
+
## Engine vs host
|
|
12
|
+
|
|
13
|
+
CommandTower is a **mountable Rails engine**. The host is a Rails API application that:
|
|
14
|
+
|
|
15
|
+
- Depends on the `command_tower` gem
|
|
16
|
+
- Runs install/migrate/doctor **inside Compose**
|
|
17
|
+
- Mounts the engine
|
|
18
|
+
- Configures secrets, RBAC composition, and feature gates
|
|
19
|
+
- Adds **product** routes, workflows, and schema
|
|
20
|
+
|
|
21
|
+
The engine owns:
|
|
22
|
+
|
|
23
|
+
- CommandTower-owned schema (users, sessions, JWT-related tables, Me/Auth, messaging platform tables, and the rest of the engine `db/migrate`)
|
|
24
|
+
- Platform HTTP under the mount
|
|
25
|
+
- Workflow / service / serializer framework
|
|
26
|
+
- Default entity catalog (`lib/command_tower/authorization/default.yml`)
|
|
27
|
+
- Shared FactoryBot definitions
|
|
28
|
+
|
|
29
|
+
The host owns:
|
|
30
|
+
|
|
31
|
+
- `Dockerfile`, `docker-compose.yml`, `Makefile`
|
|
32
|
+
- Product schema, product routes, product workflows
|
|
33
|
+
- `config/initializers/command_tower.rb` after generate
|
|
34
|
+
- `config/rbac_groups.yml` (grants of **CT entity names** plus host-owned entities)
|
|
35
|
+
- Secrets in Compose / env
|
|
36
|
+
- Feature-gate choices
|
|
37
|
+
- Messaging catalogs and adapter credentials (optional)
|
|
38
|
+
|
|
39
|
+
## Schema (AD-SCH-01)
|
|
40
|
+
|
|
41
|
+
CommandTower is the **sole authoring authority** for CommandTower-owned schema. Hosts must **not** manually write, edit, or copy-paste CommandTower schema migrations.
|
|
42
|
+
|
|
43
|
+
Hosts install copies via `command_tower:install` / `command_tower:install:migrations`, then `db:migrate`. Installed `*.command_tower.rb` files are an **execution context**, not a second authoring home.
|
|
44
|
+
|
|
45
|
+
Contract: [`../initializing.md`](../initializing.md).
|
|
46
|
+
|
|
47
|
+
## Workflow layer
|
|
48
|
+
|
|
49
|
+
Meaningful business behavior enters through a **workflow**. Controllers, jobs, and scheduled tasks do not call services as a full business action. Workflows do not call other workflows (extract a shared sequence). See [`../architecture.md`](../architecture.md).
|
|
50
|
+
|
|
51
|
+
Do not invent a parallel “manager” layer.
|
|
52
|
+
|
|
53
|
+
## Dummy `rails_app` is not a product template
|
|
54
|
+
|
|
55
|
+
[`../../rails_app/`](../../rails_app/) is the **in-engine dummy host** used to develop and test the gem. Later steps cite its initializer and `rbac_groups.yml` for **shape**.
|
|
56
|
+
|
|
57
|
+
Do not copy `rails_app/` as a new product. Do not copy this gem’s [`docker-compose.yaml`](../../docker-compose.yaml) (`mysql:latest`, engine ports) — that file is for **engine development**, not a product host.
|
|
58
|
+
|
|
59
|
+
This tree writes **generic** Makefile / Compose templates in [`01-docker-make-compose.md`](01-docker-make-compose.md).
|
|
60
|
+
|
|
61
|
+
## Docker-only
|
|
62
|
+
|
|
63
|
+
No Ruby or Rails on the host OS. See the README **Docker-only** section. Step 01 exists so `rails new` never runs on the Mac.
|
|
64
|
+
|
|
65
|
+
## What a platform-proof host is
|
|
66
|
+
|
|
67
|
+
Enough to:
|
|
68
|
+
|
|
69
|
+
1. Boot in Compose
|
|
70
|
+
2. Pass `make doctor`
|
|
71
|
+
3. Register, log in, and receive **200** from `GET /me` as a `member`
|
|
72
|
+
|
|
73
|
+
Product domain, Solid Queue workers, SMS/Pushover adapters, and admin RBAC bundles are **not** required to finish this manual.
|
|
74
|
+
|
|
75
|
+
## Stop
|
|
76
|
+
|
|
77
|
+
You can explain engine vs host, AD-SCH-01, and why Docker exists before `rails new`. Next: [`01-docker-make-compose.md`](01-docker-make-compose.md).
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# 01 — Docker, Make, Compose
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Author the local toolchain **before** any Rails app exists. After this step you can `make help` and `make build` on an empty repository. Ruby is only inside the image.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- Docker Desktop (or equivalent) with the Compose plugin
|
|
10
|
+
- Make
|
|
11
|
+
- Git
|
|
12
|
+
- Empty (or nearly empty) host repository
|
|
13
|
+
|
|
14
|
+
Do **not** install Ruby, Bundler, or Rails on the Mac.
|
|
15
|
+
|
|
16
|
+
## Files the host creates
|
|
17
|
+
|
|
18
|
+
| File | Role |
|
|
19
|
+
|------|------|
|
|
20
|
+
| `Dockerfile` | Ruby image: Bundler, MariaDB client, Node (asset/JS tooling if needed) |
|
|
21
|
+
| `docker-compose.yml` | `api`, MariaDB **10.11+**, Redis |
|
|
22
|
+
| `Makefile` | Operator surface: `build`, `rails-new`, `rails`, `migrate`, `doctor`, … |
|
|
23
|
+
| `.dockerignore` | Keep the build context small |
|
|
24
|
+
| `.env` (gitignored) | Local secrets later ([`04-secrets-and-env.md`](04-secrets-and-env.md)); create a stub if Compose interpolates vars |
|
|
25
|
+
|
|
26
|
+
The database in **this recipe** is MariaDB 10.11+ with `utf8mb4` / `utf8mb4_unicode_ci`. That is a host choice documented here. Do not copy the gem’s engine-dev compose (`mysql:latest`).
|
|
27
|
+
|
|
28
|
+
Redis is required for CommandTower cache/features that expect it. Optional `worker` is only if the host uses Solid Queue (add after the app exists).
|
|
29
|
+
|
|
30
|
+
## Procedure
|
|
31
|
+
|
|
32
|
+
### 1. `.dockerignore`
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
.git
|
|
36
|
+
log
|
|
37
|
+
tmp
|
|
38
|
+
.env
|
|
39
|
+
coverage
|
|
40
|
+
vendor/bundle
|
|
41
|
+
node_modules
|
|
42
|
+
.DS_Store
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. `Dockerfile`
|
|
46
|
+
|
|
47
|
+
Use an MRI image CommandTower’s doctor accepts (Rails **>= 7 and < 9**). A current floor that matches engine CI is Ruby **4.0.x** and Rails **8.1**. Do not freeze an older patch than the gem you will pin in step 03.
|
|
48
|
+
|
|
49
|
+
The image must **bind Puma on `0.0.0.0`**. Binding `127.0.0.1` inside the container is not reachable from the Mac.
|
|
50
|
+
|
|
51
|
+
```dockerfile
|
|
52
|
+
FROM ruby:4.0.1
|
|
53
|
+
|
|
54
|
+
ENV RAILS_LOG_TO_STDOUT=1
|
|
55
|
+
ENV BUNDLE_PATH=/usr/local/bundle
|
|
56
|
+
ENV BUNDLE_JOBS=4
|
|
57
|
+
|
|
58
|
+
RUN apt-get update -qq && apt-get install --no-install-recommends -y \
|
|
59
|
+
build-essential \
|
|
60
|
+
git \
|
|
61
|
+
default-libmysqlclient-dev \
|
|
62
|
+
default-mysql-client \
|
|
63
|
+
libyaml-dev \
|
|
64
|
+
pkg-config \
|
|
65
|
+
redis-tools \
|
|
66
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
67
|
+
|
|
68
|
+
WORKDIR /app
|
|
69
|
+
|
|
70
|
+
# Gemfile does not exist until `make rails-new`. Copy the context; install gems when present.
|
|
71
|
+
COPY . .
|
|
72
|
+
RUN if [ -f Gemfile ]; then bundle install; fi
|
|
73
|
+
|
|
74
|
+
EXPOSE 3000
|
|
75
|
+
CMD ["bundle", "exec", "bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Until [`02-create-the-rails-app.md`](02-create-the-rails-app.md) runs, there is **no** `Gemfile`. That is expected. `make build` still produces an image with Ruby, Bundler, and the MariaDB client. `make rails-new` uses that image.
|
|
79
|
+
|
|
80
|
+
After `rails new` you will have a Gemfile; rebuild so `bundle install` during image build caches gems. Day-to-day `make bundle` still refreshes gems in the running volume.
|
|
81
|
+
|
|
82
|
+
### 3. `docker-compose.yml`
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
services:
|
|
86
|
+
api:
|
|
87
|
+
build: .
|
|
88
|
+
working_dir: /app
|
|
89
|
+
command: bundle exec bin/rails server -b 0.0.0.0 -p 3000
|
|
90
|
+
volumes:
|
|
91
|
+
- .:/app
|
|
92
|
+
- bundle_cache:/usr/local/bundle
|
|
93
|
+
ports:
|
|
94
|
+
- "3000:3000"
|
|
95
|
+
environment:
|
|
96
|
+
RAILS_ENV: development
|
|
97
|
+
DATABASE_URL: mysql2://app:app@db:3306/app_development
|
|
98
|
+
REDIS_URL: redis://redis:6379/0
|
|
99
|
+
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev-secret-key-base-change-me}
|
|
100
|
+
SIGNUP_SESSION_JWT_SECRET: ${SIGNUP_SESSION_JWT_SECRET:-dev-signup-session-change-me}
|
|
101
|
+
PASSWORD_RECOVERY_SESSION_JWT_SECRET: ${PASSWORD_RECOVERY_SESSION_JWT_SECRET:-dev-password-recovery-change-me}
|
|
102
|
+
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8081,http://localhost:8082,http://localhost:19006}
|
|
103
|
+
depends_on:
|
|
104
|
+
db:
|
|
105
|
+
condition: service_healthy
|
|
106
|
+
redis:
|
|
107
|
+
condition: service_started
|
|
108
|
+
stdin_open: true
|
|
109
|
+
tty: true
|
|
110
|
+
|
|
111
|
+
db:
|
|
112
|
+
image: mariadb:10.11
|
|
113
|
+
environment:
|
|
114
|
+
MARIADB_ROOT_PASSWORD: root
|
|
115
|
+
MARIADB_DATABASE: app_development
|
|
116
|
+
MARIADB_USER: app
|
|
117
|
+
MARIADB_PASSWORD: app
|
|
118
|
+
ports:
|
|
119
|
+
- "3306:3306"
|
|
120
|
+
volumes:
|
|
121
|
+
- mariadb_data:/var/lib/mysql
|
|
122
|
+
command:
|
|
123
|
+
- --character-set-server=utf8mb4
|
|
124
|
+
- --collation-server=utf8mb4_unicode_ci
|
|
125
|
+
healthcheck:
|
|
126
|
+
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
|
127
|
+
interval: 5s
|
|
128
|
+
timeout: 5s
|
|
129
|
+
retries: 20
|
|
130
|
+
|
|
131
|
+
redis:
|
|
132
|
+
image: redis:7-alpine
|
|
133
|
+
ports:
|
|
134
|
+
- "6379:6379"
|
|
135
|
+
|
|
136
|
+
volumes:
|
|
137
|
+
bundle_cache:
|
|
138
|
+
mariadb_data:
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Rename `app_development` / user / password to product names when you have them. Keep utf8mb4.
|
|
142
|
+
|
|
143
|
+
`command:` (and the Dockerfile `CMD`) must use `-b 0.0.0.0`. Without it, `make server` is not reachable from the Mac or from an Expo app on the host OS.
|
|
144
|
+
|
|
145
|
+
Published port **3000** is this recipe’s default for a single local API. The frontend `HostConfig` API origin must use the **same** host-published port. A second Command Tower API on this machine publishes a different host port. Engine-dev compose in this gem uses different ports; ignore it.
|
|
146
|
+
|
|
147
|
+
`CORS_ALLOWED_ORIGINS` in the sample (`8081`, `8082`, `19006`) is one web product’s origins. A second local web app uses its own Expo port. Change that origin, this API’s published port, and `config.application.url` (step 08) together. The list is consumed in [`11-auth-client-path.md`](11-auth-client-path.md). `curl` on the Mac does not need CORS.
|
|
148
|
+
|
|
149
|
+
### 4. `Makefile`
|
|
150
|
+
|
|
151
|
+
Generic operator surface. `COMPOSE` is the only Compose invocation.
|
|
152
|
+
|
|
153
|
+
```makefile
|
|
154
|
+
.PHONY: help build bundle setup fresh rebuild rails-new rails db-prepare migrate doctor \
|
|
155
|
+
bash console runner server s halt down down-v rspec test spec rubocop
|
|
156
|
+
|
|
157
|
+
COMPOSE := docker compose
|
|
158
|
+
API := $(COMPOSE) run --rm api
|
|
159
|
+
API_T := $(COMPOSE) run --rm -T api
|
|
160
|
+
|
|
161
|
+
help:
|
|
162
|
+
@echo "build Build the api image"
|
|
163
|
+
@echo "bundle bundle install in the container"
|
|
164
|
+
@echo "setup build + db-prepare"
|
|
165
|
+
@echo "fresh down-v + setup"
|
|
166
|
+
@echo "rebuild build --no-cache + bundle"
|
|
167
|
+
@echo "rails-new rails new (API) inside the container"
|
|
168
|
+
@echo "rails Forward to bin/rails (ARGS=...)"
|
|
169
|
+
@echo "db-prepare db:prepare"
|
|
170
|
+
@echo "migrate db:migrate"
|
|
171
|
+
@echo "doctor command_tower:doctor"
|
|
172
|
+
@echo "bash Shell in api"
|
|
173
|
+
@echo "console rails console"
|
|
174
|
+
@echo "runner rails runner CMD='...'"
|
|
175
|
+
@echo "server / s Foreground api server"
|
|
176
|
+
@echo "halt Stop compose"
|
|
177
|
+
@echo "down compose down"
|
|
178
|
+
@echo "down-v compose down -v (destroys DB volume)"
|
|
179
|
+
@echo "rspec/test rspec"
|
|
180
|
+
@echo "spec rspec SPEC=path"
|
|
181
|
+
@echo "rubocop rubocop"
|
|
182
|
+
|
|
183
|
+
build:
|
|
184
|
+
$(COMPOSE) build api
|
|
185
|
+
|
|
186
|
+
bundle:
|
|
187
|
+
$(API) bundle install
|
|
188
|
+
|
|
189
|
+
setup: build db-prepare
|
|
190
|
+
|
|
191
|
+
fresh: down-v setup
|
|
192
|
+
|
|
193
|
+
rebuild:
|
|
194
|
+
$(COMPOSE) build --no-cache api
|
|
195
|
+
$(MAKE) bundle
|
|
196
|
+
|
|
197
|
+
# Chicken-and-egg: image has Ruby; Gemfile may not exist yet.
|
|
198
|
+
# Doctor accepts Rails >= 7 and < 9. Prefer 8.1 to match current engine CI.
|
|
199
|
+
RAILS_NEW_VERSION ?= ~> 8.1.0
|
|
200
|
+
rails-new:
|
|
201
|
+
$(API) bash -lc 'gem install rails -v "$(RAILS_NEW_VERSION)" --no-document && rails new . --api --database=mysql --skip-git --force'
|
|
202
|
+
|
|
203
|
+
rails:
|
|
204
|
+
$(COMPOSE) run --rm -e SKIP_MOUNT -e SKIP_CONFIGURE -e FORCE api bundle exec bin/rails $(ARGS)
|
|
205
|
+
|
|
206
|
+
db-prepare:
|
|
207
|
+
$(API) bundle exec bin/rails db:prepare
|
|
208
|
+
|
|
209
|
+
migrate:
|
|
210
|
+
$(API) bundle exec bin/rails db:migrate
|
|
211
|
+
|
|
212
|
+
doctor:
|
|
213
|
+
$(API) bundle exec bin/rails command_tower:doctor
|
|
214
|
+
|
|
215
|
+
bash:
|
|
216
|
+
$(COMPOSE) run --rm api bash
|
|
217
|
+
|
|
218
|
+
console:
|
|
219
|
+
$(API) bundle exec bin/rails console
|
|
220
|
+
|
|
221
|
+
runner:
|
|
222
|
+
$(API) bundle exec bin/rails runner "$(CMD)"
|
|
223
|
+
|
|
224
|
+
server s:
|
|
225
|
+
$(COMPOSE) up api
|
|
226
|
+
|
|
227
|
+
halt:
|
|
228
|
+
$(COMPOSE) stop
|
|
229
|
+
|
|
230
|
+
down:
|
|
231
|
+
$(COMPOSE) down
|
|
232
|
+
|
|
233
|
+
down-v:
|
|
234
|
+
$(COMPOSE) down -v
|
|
235
|
+
|
|
236
|
+
rspec test:
|
|
237
|
+
$(API_T) bundle exec rspec
|
|
238
|
+
|
|
239
|
+
spec:
|
|
240
|
+
$(API_T) bundle exec rspec $(SPEC)
|
|
241
|
+
|
|
242
|
+
rubocop:
|
|
243
|
+
$(API_T) bundle exec rubocop
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`rails-new` installs the Rails **gem inside the ephemeral container** and writes the app onto the mounted `/app`. That is still Docker-only: the Mac never runs `gem` or `rails`.
|
|
247
|
+
|
|
248
|
+
After the app exists, prefer `make rails ARGS='…'` over re-running `rails-new`.
|
|
249
|
+
|
|
250
|
+
Flags such as `SKIP_MOUNT=1` belong on the Make line. The `rails` recipe forwards `SKIP_MOUNT`, `SKIP_CONFIGURE`, and `FORCE` into the container:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
SKIP_MOUNT=1 make rails ARGS='command_tower:install'
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### 5. Build
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
make help
|
|
260
|
+
make build
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`make build` may warn that `Gemfile` is missing. That is expected until step 02.
|
|
264
|
+
|
|
265
|
+
## Stop
|
|
266
|
+
|
|
267
|
+
`make help` prints the operator surface. `make build` produces an `api` image with Ruby. There is still no Rails application. Next: [`02-create-the-rails-app.md`](02-create-the-rails-app.md).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 02 — Create the Rails app
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Generate a Rails **8.1 API** application **inside the Compose image**. The host OS never receives a `rails` binary.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- [`01-docker-make-compose.md`](01-docker-make-compose.md) complete: `make build` works
|
|
10
|
+
- Working directory is the host repository (already contains Dockerfile / Compose / Makefile)
|
|
11
|
+
|
|
12
|
+
## Files this step creates (inside the container, onto the mount)
|
|
13
|
+
|
|
14
|
+
Rails’ `rails new .` writes `Gemfile`, `config/`, `app/`, `bin/`, and the rest of a standard API app into the current directory.
|
|
15
|
+
|
|
16
|
+
`--force` is required because Dockerfile, Compose, and Makefile already exist.
|
|
17
|
+
|
|
18
|
+
## Procedure
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
make rails-new
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That target (from step 01) runs in the `api` service:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
gem install rails -v "~> 8.1.0" --no-document
|
|
28
|
+
rails new . --api --database=mysql --skip-git --force
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Both commands run **in the container**. `--skip-git` avoids nesting a repo; you already have Git on the host. `--database=mysql` matches MariaDB via the `mysql2` adapter.
|
|
32
|
+
|
|
33
|
+
If `rails new` overwrites `Dockerfile`, `docker-compose.yml`, or `Makefile`, **restore the files from step 01**. Rails generators are not the source of the host toolchain. Confirm `command:` / `CMD` still bind `-b 0.0.0.0`.
|
|
34
|
+
|
|
35
|
+
Then add host gems CommandTower does not replace. In `Gemfile` (keep `mysql2` and `puma` from `rails new`):
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem "rack-cors"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`command_tower` (step 03) already depends on `redis`. You do not need a second Redis gem unless the host uses Redis for its own cache.
|
|
42
|
+
|
|
43
|
+
Set `config/database.yml` so development points at the `db` service, not `localhost` on the Mac:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
development:
|
|
47
|
+
adapter: mysql2
|
|
48
|
+
encoding: utf8mb4
|
|
49
|
+
collation: utf8mb4_unicode_ci
|
|
50
|
+
url: <%= ENV.fetch("DATABASE_URL") %>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Leave test until you add a test database; `db:prepare` can create it later.
|
|
54
|
+
|
|
55
|
+
Local mail for verify/reset codes (Compose-mounted `tmp/` is visible on the Mac):
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# config/environments/development.rb
|
|
59
|
+
config.action_mailer.perform_deliveries = true
|
|
60
|
+
config.action_mailer.delivery_method = :file
|
|
61
|
+
config.action_mailer.file_settings = { location: Rails.root.join("tmp/mails") }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
make build
|
|
68
|
+
make bundle
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Do not run `rails new` on the Mac. Do not `brew install ruby` to make this step “easier.”
|
|
72
|
+
|
|
73
|
+
## Stop
|
|
74
|
+
|
|
75
|
+
`Gemfile` exists, `bin/rails` exists **in the repo** (executed only via `make rails`). Dockerfile/Compose from step 01 are still the operator toolchain. `make bundle` succeeds. Next: [`03-pin-the-gem.md`](03-pin-the-gem.md).
|