mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-11 18:26:36 -05:00
70
.github/workflows/check-config-schema.yml
vendored
Normal file
70
.github/workflows/check-config-schema.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Check config schema
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'stable-*'
|
||||
paths:
|
||||
- 'lib/mastodon/configuration/annotations.yml'
|
||||
- 'lib/mastodon/configuration/schema.rb'
|
||||
- 'lib/mastodon/configuration/env_scanner.rb'
|
||||
- 'lib/mastodon/configuration/docs_generator.rb'
|
||||
- 'lib/tasks/config.rake'
|
||||
- 'mastodon-config.schema.json'
|
||||
- 'config/**/*.rb'
|
||||
- 'config/**/*.yml'
|
||||
- 'lib/mastodon/**/*.rb'
|
||||
- '.github/workflows/check-config-schema.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'lib/mastodon/configuration/annotations.yml'
|
||||
- 'lib/mastodon/configuration/schema.rb'
|
||||
- 'lib/mastodon/configuration/env_scanner.rb'
|
||||
- 'lib/mastodon/configuration/docs_generator.rb'
|
||||
- 'lib/tasks/config.rake'
|
||||
- 'mastodon-config.schema.json'
|
||||
- 'config/**/*.rb'
|
||||
- 'config/**/*.yml'
|
||||
- 'lib/mastodon/**/*.rb'
|
||||
- '.github/workflows/check-config-schema.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-config-schema:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
BUNDLE_ONLY: development
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Check all ENV vars are documented
|
||||
run: bundle exec rails mastodon:config:lint
|
||||
|
||||
- name: Regenerate config schema
|
||||
run: bundle exec rails mastodon:config:schema > mastodon-config.schema.json
|
||||
|
||||
- name: Check schema is up to date
|
||||
run: |
|
||||
if ! git diff --exit-code mastodon-config.schema.json; then
|
||||
echo ""
|
||||
echo "mastodon-config.schema.json is out of date."
|
||||
echo "Run the following command and commit the result:"
|
||||
echo ""
|
||||
echo " bundle exec rails mastodon:config:schema > mastodon-config.schema.json"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify docs generation runs without error
|
||||
run: bundle exec rails mastodon:config:docs > /dev/null
|
||||
193
docs/CONFIG_SCHEMA.md
Normal file
193
docs/CONFIG_SCHEMA.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Configuration schema
|
||||
|
||||
Mastodon ships a machine-readable JSON Schema (draft 2020-12)
|
||||
that describes every environment variable the application reads.
|
||||
`annotations.yml` is the canonical source; both the JSON schema and the
|
||||
admin docs page are generated from it.
|
||||
|
||||
## Generating the schema
|
||||
|
||||
With a working Ruby environment and all gems installed, run:
|
||||
|
||||
```shell
|
||||
bundle exec rails mastodon:config:schema > mastodon-config.schema.json
|
||||
```
|
||||
|
||||
## Generating the admin docs page
|
||||
|
||||
The Hugo-flavored Markdown for `content/en/admin/config.md` in
|
||||
`mastodon/documentation` is generated from the committed JSON schema:
|
||||
|
||||
```shell
|
||||
bundle exec rails mastodon:config:docs > /path/to/documentation/content/en/admin/config.md
|
||||
```
|
||||
|
||||
The task reads `mastodon-config.schema.json` in the project root by default.
|
||||
Pass an explicit path as an argument if needed:
|
||||
|
||||
```shell
|
||||
bundle exec rails 'mastodon:config:docs[/path/to/mastodon-config.schema.json]'
|
||||
```
|
||||
|
||||
The generated Markdown should be committed in the documentation repository.
|
||||
Regenerate it whenever `annotations.yml` changes and a new Mastodon release is
|
||||
cut.
|
||||
|
||||
## Inter-repo workflow
|
||||
|
||||
`mastodon/mastodon` owns `annotations.yml` and `mastodon-config.schema.json`.
|
||||
`mastodon/documentation` consumes the generated Markdown. Two delivery options:
|
||||
|
||||
- **Manual**: a docs maintainer runs `mastodon:config:docs` against a tagged
|
||||
Mastodon release and commits the output.
|
||||
- **Automated**: a docs-repo workflow checks out a Mastodon release, runs the
|
||||
task, and opens a PR.
|
||||
|
||||
## Schema structure
|
||||
|
||||
The top-level object is a JSON Schema `object` whose `properties` are the
|
||||
environment-variable names. Each property carries:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `type` | Semantic type (`string`, `integer`, `boolean`, or `number`). |
|
||||
| `description` | Human-readable explanation of the variable, including its effect and any caveats. |
|
||||
| `default` | The value Mastodon uses when the variable is absent. Omitted when there is no meaningful default. |
|
||||
| `enum` | Allowed values for constrained strings. |
|
||||
| `minimum` / `maximum` | Numeric bounds. |
|
||||
| `format` | JSON Schema semantic format hint (e.g. `uri`, `email`). |
|
||||
| `examples` | Representative values shown in UIs and documentation. |
|
||||
| `x-group` | *(Extension)* Logical grouping name — used by UIs to cluster related settings. |
|
||||
| `x-secret` | *(Extension)* `true` when the value is a cryptographic secret that should never be displayed or logged. |
|
||||
| `x-restart-required` | *(Extension)* `false` when a change can take effect without restarting Mastodon processes (rare). Absent on most properties, meaning a restart is always required. |
|
||||
| `x-status` | *(Extension)* `"deprecated"` or `"removed"`. Absent on active variables. |
|
||||
| `x-version-history` | *(Extension)* Ordered list of `{version, change}` objects describing when the variable was added or changed. |
|
||||
| `x-example-value` | *(Extension)* A single representative value rendered as `Example value: \`…\`` in docs. |
|
||||
| `x-anchor` | *(Extension)* Explicit HTML anchor override; pass `""` to suppress the anchor entirely on a `removed` variable. |
|
||||
| `x-hints` | *(Extension)* List of `{style, body}` Hugo hint shortcode blocks (`style` is `info`, `warning`, or `danger`). Emitted after the description. |
|
||||
| `x-extra` | *(Extension)* Prose paragraph rendered after the hints and before the version-history block. |
|
||||
| `x-trailing` | *(Extension)* Prose paragraph rendered after the example value (the very last body element). |
|
||||
| `x-show-default` | *(Extension)* When `true`, emit a `**Default:** \`…\`` block in the rendered docs (most defaults are described inline in prose). |
|
||||
| `x-suppress-removed-hint` | *(Extension)* When `true`, render a `removed` variable's description as plain prose instead of wrapping it in a danger hint. |
|
||||
|
||||
The schema also carries a top-level `x-docs-layout` object (not a per-property
|
||||
field) that encodes the Hugo frontmatter and section tree used to generate the
|
||||
admin docs page. It is consumed by `mastodon:config:docs` and is not
|
||||
meaningful to standard JSON Schema validators. `x-docs-layout.docs_only_variables`
|
||||
holds annotation entries for variables that should appear in the rendered docs
|
||||
but are not part of the live configuration surface (tombstones for removed
|
||||
variables, Rails-internal vars upstream documents).
|
||||
|
||||
### Subsection fields
|
||||
|
||||
Subsections inside `docs.sections[*].subsections[*]` accept:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `title`, `anchor` | Header label and explicit anchor ID. |
|
||||
| `page_refs` | List of pages rendered as `{{< page-ref page="…" >}}` shortcodes (emitted first). |
|
||||
| `pre_version_history` | Version-history block rendered before any prose (matches upstream's "Fetch All Replies" ordering). |
|
||||
| `pre_hints` | Hint shortcodes rendered before the intro paragraph. |
|
||||
| `intro` | Multi-paragraph Markdown intro. |
|
||||
| `version_history` | Version-history block rendered after the intro. |
|
||||
| `hints` | Hint shortcodes rendered after the intro and version history. |
|
||||
| `variables` | Ordered list of variable names. |
|
||||
| `subsections` | Nested sibling subsections (rendered at the same heading level — upstream uses a flat structure under SMTP). |
|
||||
|
||||
### Groups
|
||||
|
||||
| Group | Variables covered |
|
||||
|-------|------------------|
|
||||
| `federation` | Domain name, federation mode, single-user mode |
|
||||
| `database` | PostgreSQL primary and read-replica connections |
|
||||
| `redis` | Main, Sidekiq, and cache Redis connections including Sentinel |
|
||||
| `email` | SMTP and bulk-mail SMTP settings |
|
||||
| `storage` | S3, OpenStack Swift, Azure Blob Storage, local filesystem |
|
||||
| `search` | Elasticsearch / OpenSearch |
|
||||
| `authentication` | LDAP, PAM, OIDC, SAML, CAS, SSO behaviour |
|
||||
| `web-server` | Puma, Sidekiq, proxy, and CDN settings |
|
||||
| `secrets` | Cryptographic keys and tokens |
|
||||
| `features` | Behavioural feature flags |
|
||||
| `retention` | IP, session, and user-activity retention periods |
|
||||
| `translation` | DeepL and LibreTranslate integration |
|
||||
| `captcha` | hCaptcha |
|
||||
| `cache-buster` | CDN cache purge integration |
|
||||
| `observability` | Prometheus exporter and OpenTelemetry |
|
||||
| `media` | ffmpeg paths and S3 batch-delete tuning |
|
||||
|
||||
## Adding new variables
|
||||
|
||||
Property metadata lives in
|
||||
[`lib/mastodon/configuration/annotations.yml`](../lib/mastodon/configuration/annotations.yml).
|
||||
Add a new top-level key for the variable name. Minimum required fields:
|
||||
|
||||
```yaml
|
||||
MY_NEW_VAR:
|
||||
type: string # string / integer / boolean / number
|
||||
group: features
|
||||
description: What this variable does.
|
||||
default: some-value # omit if there is no meaningful default
|
||||
enum: [a, b, c] # omit if values are unconstrained
|
||||
secret: true # set when the value must not be logged or displayed
|
||||
```
|
||||
|
||||
Optional docs-specific fields:
|
||||
|
||||
```yaml
|
||||
MY_NEW_VAR:
|
||||
# ... required fields above ...
|
||||
description: |
|
||||
Long-form Markdown prose for the docs page. Multi-paragraph, code blocks,
|
||||
and inline links are allowed.
|
||||
version_history:
|
||||
- version: 4.4.0
|
||||
change: Added.
|
||||
example_value: my-value # rendered as `Example value: \`my-value\``
|
||||
status: active # active (default) | deprecated | removed
|
||||
anchor: my-anchor-override # rare
|
||||
show_default: true # emit a "**Default:** \`…\`" block
|
||||
hints:
|
||||
- style: warning # info | warning | danger
|
||||
body: |
|
||||
Markdown body of the Hugo hint shortcode.
|
||||
extra: |
|
||||
Additional prose rendered after the hints (e.g. an inline "Defaults to false." note).
|
||||
trailing: |
|
||||
Additional prose rendered after the example value (e.g. supplementary links).
|
||||
```
|
||||
|
||||
Also add the variable to the appropriate subsection in the `docs.sections`
|
||||
tree at the bottom of `annotations.yml` so it appears in the generated docs
|
||||
page.
|
||||
|
||||
The `EnvScanner` will catch any variable that appears in the source but is
|
||||
absent from `annotations.yml` (see [Lint check](#lint-check) below).
|
||||
|
||||
Run `bundle exec rails mastodon:config:schema > mastodon-config.schema.json`
|
||||
after editing and commit the updated JSON file.
|
||||
|
||||
### Tombstone variables
|
||||
|
||||
Variables that have been removed from the codebase should be kept in
|
||||
`annotations.yml` with `status: removed` (and a `version_history` entry
|
||||
recording when they were removed) so that the generated docs preserves
|
||||
historical anchors and version-history blocks for users upgrading from old
|
||||
installations. They do not need to be moved to `EnvScanner::EXCLUDED_VARS`.
|
||||
|
||||
## Lint check
|
||||
|
||||
`bundle exec rails mastodon:config:lint` statically scans the source tree for
|
||||
literal `ENV.fetch` / `ENV[]` accesses and reports any variable that is absent
|
||||
from `annotations.yml` and not in the explicit exclusion list
|
||||
(`EnvScanner::EXCLUDED_VARS`). CI runs this check automatically on every PR
|
||||
that touches `config/`, `lib/mastodon/`, or the schema files.
|
||||
|
||||
If you add a new env var without updating `annotations.yml`, CI will fail and
|
||||
tell you exactly which file uses the undocumented variable.
|
||||
|
||||
Variables that are intentionally undocumented (deprecated aliases, Rails
|
||||
internals, CI/dev-only vars) belong in `EnvScanner::EXCLUDED_VARS` rather than
|
||||
in `annotations.yml`.
|
||||
|
||||
Regenerate `mastodon-config.schema.json` whenever you upgrade Mastodon to pick
|
||||
up newly added variables.
|
||||
2864
lib/mastodon/configuration/annotations.yml
Normal file
2864
lib/mastodon/configuration/annotations.yml
Normal file
File diff suppressed because it is too large
Load Diff
225
lib/mastodon/configuration/docs_generator.rb
Normal file
225
lib/mastodon/configuration/docs_generator.rb
Normal file
@@ -0,0 +1,225 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'yaml'
|
||||
|
||||
module Mastodon
|
||||
module Configuration
|
||||
# Generates Hugo-flavored Markdown for `content/en/admin/config.md` from
|
||||
# a JSON Schema hash that contains `x-docs-layout` (the section tree) and
|
||||
# per-property `x-*` fields populated from `annotations.yml`.
|
||||
#
|
||||
# Usage:
|
||||
# schema = JSON.parse(File.read('mastodon-config.schema.json'))
|
||||
# puts Mastodon::Configuration::DocsGenerator.render(schema)
|
||||
#
|
||||
# Or via the rake task:
|
||||
# bundle exec rails mastodon:config:docs > /path/to/content/en/admin/config.md
|
||||
module DocsGenerator
|
||||
def self.render(schema)
|
||||
layout = schema['x-docs-layout'] || {}
|
||||
props = schema['properties'] || {}
|
||||
docs_only = layout['docs_only_variables'] || {}
|
||||
all_vars = props.merge(docs_only)
|
||||
|
||||
warn_unlisted(layout, props)
|
||||
|
||||
out = []
|
||||
out << emit_frontmatter(layout['frontmatter'])
|
||||
out << layout['intro'].to_s.strip unless layout['intro'].to_s.strip.empty?
|
||||
out << ''
|
||||
|
||||
(layout['sections'] || []).each do |section|
|
||||
out << emit_section(section, all_vars)
|
||||
end
|
||||
|
||||
out.join("\n").rstrip + "\n"
|
||||
end
|
||||
|
||||
def self.warn_unlisted(layout, props)
|
||||
listed = collect_listed(layout)
|
||||
props.each_key do |var|
|
||||
next if listed.include?(var)
|
||||
group = props[var]['x-group']
|
||||
warn "WARNING: #{var} (group: #{group}) is not listed in any docs section tree subsection"
|
||||
end
|
||||
end
|
||||
private_class_method :warn_unlisted
|
||||
|
||||
def self.collect_listed(layout)
|
||||
listed = []
|
||||
(layout['sections'] || []).each do |section|
|
||||
(section['subsections'] || []).each do |sub|
|
||||
listed.concat(sub['variables'] || [])
|
||||
(sub['subsections'] || []).each do |nested|
|
||||
listed.concat(nested['variables'] || [])
|
||||
end
|
||||
end
|
||||
end
|
||||
listed.to_set
|
||||
end
|
||||
private_class_method :collect_listed
|
||||
|
||||
def self.emit_frontmatter(fm)
|
||||
return '' unless fm
|
||||
"---\n#{fm.to_yaml.sub(/\A---\n/, '')}---\n"
|
||||
end
|
||||
private_class_method :emit_frontmatter
|
||||
|
||||
def self.emit_section(section, props)
|
||||
out = []
|
||||
anchor = section['anchor'] ? " {##{section['anchor']}}" : ''
|
||||
out << "## #{section['title']}#{anchor}"
|
||||
out << ''
|
||||
|
||||
(section['page_refs'] || []).each { |p| out << emit_page_ref(p) }
|
||||
|
||||
if section['intro']
|
||||
out << section['intro'].strip
|
||||
out << ''
|
||||
end
|
||||
|
||||
(section['hints'] || []).each { |h| out << emit_hint(h) }
|
||||
|
||||
(section['subsections'] || []).each do |sub|
|
||||
out << emit_subsection(sub, props)
|
||||
end
|
||||
|
||||
out.join("\n")
|
||||
end
|
||||
private_class_method :emit_section
|
||||
|
||||
def self.emit_subsection(sub, props, level: 3)
|
||||
out = []
|
||||
anchor = sub['anchor'] ? " {##{sub['anchor']}}" : ''
|
||||
out << "#{'#' * level} #{sub['title']}#{anchor}"
|
||||
out << ''
|
||||
|
||||
(sub['page_refs'] || []).each { |p| out << emit_page_ref(p) }
|
||||
|
||||
if sub['pre_version_history']
|
||||
out << version_history_block(sub['pre_version_history'])
|
||||
out << ''
|
||||
end
|
||||
|
||||
(sub['pre_hints'] || []).each { |h| out << emit_hint(h) }
|
||||
|
||||
if sub['intro']
|
||||
out << sub['intro'].strip
|
||||
out << ''
|
||||
end
|
||||
|
||||
if sub['version_history']
|
||||
out << version_history_block(sub['version_history'])
|
||||
out << ''
|
||||
end
|
||||
|
||||
(sub['hints'] || []).each { |h| out << emit_hint(h) }
|
||||
|
||||
(sub['variables'] || []).each do |var|
|
||||
prop = props[var]
|
||||
unless prop
|
||||
warn "WARNING: variable #{var} listed in docs section tree but not found in schema properties or docs_only_variables"
|
||||
next
|
||||
end
|
||||
out << emit_variable(var, prop)
|
||||
end
|
||||
|
||||
# subsections share the parent's heading level (siblings), matching upstream's flat structure.
|
||||
(sub['subsections'] || []).each do |nested|
|
||||
out << emit_subsection(nested, props, level: level)
|
||||
end
|
||||
|
||||
out.join("\n")
|
||||
end
|
||||
private_class_method :emit_subsection
|
||||
|
||||
def self.emit_variable(name, prop)
|
||||
out = []
|
||||
status = prop['x-status'] || 'active'
|
||||
badge = case status
|
||||
when 'removed' then ' {{%removed%}}'
|
||||
when 'deprecated' then ' {{%deprecated%}}'
|
||||
else ''
|
||||
end
|
||||
|
||||
explicit_anchor = prop['x-anchor']
|
||||
anchor_part =
|
||||
if explicit_anchor && !explicit_anchor.empty?
|
||||
" {##{explicit_anchor}}"
|
||||
elsif explicit_anchor == ''
|
||||
''
|
||||
elsif status == 'removed'
|
||||
" {##{name.downcase}}"
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
out << "#### `#{name}`#{badge}#{anchor_part}"
|
||||
out << ''
|
||||
|
||||
body = []
|
||||
|
||||
if status == 'removed' && !prop['x-suppress-removed-hint'] && !prop['description'].to_s.strip.empty?
|
||||
body << emit_hint({ 'style' => 'danger', 'body' => prop['description'].strip }).rstrip
|
||||
body << ''
|
||||
elsif !prop['description'].to_s.strip.empty?
|
||||
body << prop['description'].strip
|
||||
body << ''
|
||||
end
|
||||
|
||||
if prop['x-show-default'] && prop.key?('default') && !prop['default'].nil?
|
||||
body << "**Default:** `#{prop['default']}`"
|
||||
body << ''
|
||||
end
|
||||
|
||||
(prop['x-hints'] || []).each { |h| body << emit_hint(h).rstrip; body << '' }
|
||||
|
||||
if prop['x-extra']
|
||||
body << prop['x-extra'].strip
|
||||
body << ''
|
||||
end
|
||||
|
||||
if prop['x-version-history']&.any?
|
||||
body << version_history_block(prop['x-version-history'])
|
||||
body << ''
|
||||
end
|
||||
|
||||
if prop.key?('x-example-value')
|
||||
body << "Example value: `#{prop['x-example-value']}`"
|
||||
body << ''
|
||||
end
|
||||
|
||||
if prop['x-trailing']
|
||||
body << prop['x-trailing'].strip
|
||||
body << ''
|
||||
end
|
||||
|
||||
out.concat(body)
|
||||
out.join("\n")
|
||||
end
|
||||
private_class_method :emit_variable
|
||||
|
||||
def self.version_history_block(entries)
|
||||
lines = ['**Version history:**\\']
|
||||
entries.each_with_index do |entry, i|
|
||||
suffix = i == entries.length - 1 ? '' : '\\'
|
||||
lines << "#{entry['version']} - #{entry['change']}#{suffix}"
|
||||
end
|
||||
lines.join("\n")
|
||||
end
|
||||
private_class_method :version_history_block
|
||||
|
||||
def self.emit_hint(hint)
|
||||
style = hint['style'] || 'info'
|
||||
body = hint['body'].to_s.strip
|
||||
"{{< hint style=\"#{style}\" >}}\n#{body}\n{{</ hint >}}\n\n"
|
||||
end
|
||||
private_class_method :emit_hint
|
||||
|
||||
def self.emit_page_ref(page)
|
||||
"{{< page-ref page=\"#{page}\" >}}\n\n"
|
||||
end
|
||||
private_class_method :emit_page_ref
|
||||
end
|
||||
end
|
||||
end
|
||||
137
lib/mastodon/configuration/env_scanner.rb
Normal file
137
lib/mastodon/configuration/env_scanner.rb
Normal file
@@ -0,0 +1,137 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'pathname'
|
||||
require 'set'
|
||||
|
||||
module Mastodon
|
||||
module Configuration
|
||||
# Scans the Mastodon source tree for every *literal* environment variable
|
||||
# key that is read via ENV[], ENV.fetch(), ENV.key?(), or ENV.include?().
|
||||
#
|
||||
# Keys with dynamic names (e.g. ENV.fetch("#{prefix}REDIS_URL")) cannot be
|
||||
# statically determined and are therefore omitted; they must be documented
|
||||
# in the schema by the code that generates them.
|
||||
#
|
||||
# Usage:
|
||||
# results = Mastodon::Configuration::EnvScanner.scan # → Hash
|
||||
# results['REDIS_HOST'] # => ["config/initializers/...", ...]
|
||||
module EnvScanner
|
||||
# Directories and individual files to scan, relative to the project root.
|
||||
SCAN_PATHS = %w[
|
||||
config
|
||||
lib/mastodon
|
||||
app/lib
|
||||
app/workers/scheduler
|
||||
].freeze
|
||||
|
||||
# Files/directories inside SCAN_PATHS to skip.
|
||||
EXCLUDE_PATHS = %w[
|
||||
lib/mastodon/configuration
|
||||
spec
|
||||
test
|
||||
].freeze
|
||||
|
||||
# Variables that are deliberately absent from the schema.
|
||||
#
|
||||
# Two categories:
|
||||
# :deprecated – old names superseded by a documented replacement
|
||||
# :internal – framework, tooling, or dev-only vars that are not
|
||||
# meaningful Mastodon configuration knobs
|
||||
EXCLUDED_VARS = {
|
||||
# Deprecated aliases – document the canonical name instead
|
||||
'WHITELIST_MODE' => :deprecated, # → LIMITED_FEDERATION_MODE
|
||||
'EMAIL_DOMAIN_BLACKLIST' => :deprecated, # → EMAIL_DOMAIN_DENYLIST
|
||||
'EMAIL_DOMAIN_WHITELIST' => :deprecated, # → EMAIL_DOMAIN_ALLOWLIST
|
||||
|
||||
# Standard Rails / Rack / system variables
|
||||
'OTHER_DATABASE_URL' => :internal,
|
||||
'RACK_ENV' => :internal,
|
||||
'SECRET_KEY_BASE_DUMMY' => :internal, # used only during asset pre-compilation
|
||||
'USER' => :internal,
|
||||
|
||||
# Third-party gem internals
|
||||
'PGHERO_STATS_DATABASE_URL' => :internal,
|
||||
|
||||
# Development / CI / test variables
|
||||
'CI' => :internal,
|
||||
'GITHUB_ACTIONS' => :internal,
|
||||
'GITHUB_RSPEC' => :internal,
|
||||
'VAGRANT' => :internal,
|
||||
'HEROKU' => :internal,
|
||||
'REMOTE_DEV' => :internal,
|
||||
'COVERAGE' => :internal,
|
||||
'TEST_ENV_NUMBER' => :internal,
|
||||
'VITE_DEV_SERVER_PUBLIC' => :internal,
|
||||
'DISABLE_FORGERY_REQUEST_PROTECTION' => :internal,
|
||||
'ANNOTATERB_SKIP_ON_DB_TASKS' => :internal,
|
||||
'IGNORE_ALREADY_SET_SECRETS' => :internal,
|
||||
'MIGRATION_IGNORE_INVALID_OTP_SECRET' => :internal,
|
||||
'RAILS_LOG_TO_STDOUT' => :internal,
|
||||
}.freeze
|
||||
|
||||
# Matches literal ENV key accesses; does NOT match interpolated keys.
|
||||
# Captures group 1 from ENV['KEY'] / ENV["KEY"],
|
||||
# or group 2 from ENV.fetch('KEY') / ENV.key?('KEY') / etc.
|
||||
LITERAL_KEY_PATTERN = /\bENV(?:\[['"]([A-Z][A-Z0-9_]*)["']\]|\.(?:fetch|key\?|include\?|has_key\?)\(\s*['"]([A-Z][A-Z0-9_]*)["'])/
|
||||
|
||||
# Returns a Hash of { 'VAR_NAME' => ['relative/path', ...] } for every
|
||||
# literal ENV key found in SCAN_PATHS, excluding EXCLUDE_PATHS.
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def self.scan(root = nil)
|
||||
root = resolve_root(root)
|
||||
results = Hash.new { |h, k| h[k] = [] }
|
||||
|
||||
each_file(root) do |abs_path|
|
||||
rel = abs_path.relative_path_from(root).to_s
|
||||
File.read(abs_path).scan(LITERAL_KEY_PATTERN) do |bracket_key, method_key|
|
||||
key = bracket_key || method_key
|
||||
results[key] << rel unless results[key].include?(rel)
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
# Returns the subset of scan results whose keys are absent from the
|
||||
# schema *and* not in EXCLUDED_VARS. These are the undocumented vars
|
||||
# that the lint task should report.
|
||||
def self.undocumented(root = nil)
|
||||
require_relative 'schema'
|
||||
schema_keys = Schema.generate['properties'].keys.to_set
|
||||
scan(root).reject { |key, _| schema_keys.include?(key) || EXCLUDED_VARS.key?(key) }
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def self.resolve_root(root)
|
||||
return Pathname.new(root) if root
|
||||
|
||||
# Walk up from this file to find the Rails root (the directory that
|
||||
# contains Gemfile), so this module works without Rails being loaded.
|
||||
Pathname.new(__dir__).ascend do |dir|
|
||||
return dir if dir.join('Gemfile').exist?
|
||||
end
|
||||
|
||||
raise 'Cannot determine project root: no Gemfile found in parent directories'
|
||||
end
|
||||
private_class_method :resolve_root
|
||||
|
||||
def self.each_file(root)
|
||||
SCAN_PATHS.each do |scan_path|
|
||||
full = root.join(scan_path)
|
||||
next unless full.exist?
|
||||
|
||||
candidates = full.directory? ? full.glob('**/*.{rb,erb,yml}') : [full]
|
||||
candidates.each do |path|
|
||||
next if EXCLUDE_PATHS.any? { |ex| path.to_s.include?(root.join(ex).to_s) }
|
||||
next unless path.file?
|
||||
|
||||
yield path
|
||||
end
|
||||
end
|
||||
end
|
||||
private_class_method :each_file
|
||||
end
|
||||
end
|
||||
end
|
||||
111
lib/mastodon/configuration/schema.rb
Normal file
111
lib/mastodon/configuration/schema.rb
Normal file
@@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'yaml'
|
||||
require_relative 'env_scanner'
|
||||
|
||||
module Mastodon
|
||||
module Configuration
|
||||
# Generates a JSON Schema (draft 2020-12) describing every environment
|
||||
# variable that Mastodon reads at start-up or run-time.
|
||||
#
|
||||
# Property metadata lives in annotations.yml next to this file. The
|
||||
# EnvScanner is the authoritative list of which variables actually exist
|
||||
# in the codebase; annotations provide the human-readable descriptions,
|
||||
# types, groups, and constraints on top.
|
||||
#
|
||||
# To document a new environment variable:
|
||||
# 1. Add it to annotations.yml with at minimum `type`, `group`, and
|
||||
# `description` fields.
|
||||
# 2. Run `bundle exec rails mastodon:config:schema > mastodon-config.schema.json`
|
||||
# to regenerate the committed schema file.
|
||||
#
|
||||
# Custom JSON Schema extensions used here:
|
||||
# x-group – logical grouping name for UI clustering
|
||||
# x-secret – true when the value must never be displayed or logged
|
||||
# x-restart-required – false when a live reload is sufficient (rare)
|
||||
# x-status – "deprecated" or "removed" (absent means active)
|
||||
# x-version-history – ordered list of {version, change} entries
|
||||
# x-example-value – representative value shown in docs
|
||||
# x-anchor – explicit anchor override (rare; "" to suppress)
|
||||
# x-hints – list of {style, body} Hugo hint shortcodes
|
||||
# x-extra – prose paragraph rendered after the hints
|
||||
# x-trailing – prose paragraph rendered after the example value
|
||||
# x-show-default – when true, emit a "**Default:** `…`" line
|
||||
# x-suppress-removed-hint – when true, render "removed" prose plain (no danger hint)
|
||||
# x-docs-layout – top-level docs structure (frontmatter + section tree)
|
||||
#
|
||||
# The `docs_only_variables` key inside the layout holds annotation entries
|
||||
# for variables that should appear in the rendered Markdown but are not
|
||||
# part of the live configuration surface (tombstones for removed vars,
|
||||
# external/Rails-internal vars upstream documents).
|
||||
module Schema
|
||||
ANNOTATIONS_FILE = File.join(__dir__, 'annotations.yml')
|
||||
RESERVED_KEYS = %w(docs docs_only_variables).freeze
|
||||
|
||||
# Returns the full JSON Schema as a Ruby Hash.
|
||||
def self.generate
|
||||
annotations = YAML.load_file(ANNOTATIONS_FILE, aliases: true)
|
||||
docs_layout = annotations['docs']
|
||||
docs_only = annotations['docs_only_variables'] || {}
|
||||
|
||||
schema = {
|
||||
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
|
||||
'$id' => 'https://joinmastodon.org/schemas/environment-config',
|
||||
'title' => 'Mastodon environment configuration',
|
||||
'description' => 'Environment variables recognised by a Mastodon instance.',
|
||||
'type' => 'object',
|
||||
'properties' => build_properties(annotations),
|
||||
}
|
||||
|
||||
if docs_layout
|
||||
layout = docs_layout.dup
|
||||
unless docs_only.empty?
|
||||
layout['docs_only_variables'] = docs_only.transform_values do |meta|
|
||||
annotation_to_property(meta)
|
||||
end
|
||||
end
|
||||
schema['x-docs-layout'] = layout
|
||||
end
|
||||
|
||||
schema
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def self.build_properties(annotations)
|
||||
annotations.each_with_object({}) do |(key, meta), h|
|
||||
next if RESERVED_KEYS.include?(key)
|
||||
h[key] = annotation_to_property(meta)
|
||||
end
|
||||
end
|
||||
private_class_method :build_properties
|
||||
|
||||
def self.annotation_to_property(meta)
|
||||
h = {
|
||||
'type' => meta['type'],
|
||||
'description' => meta['description'],
|
||||
'x-group' => meta['group'],
|
||||
}
|
||||
h['default'] = meta['default'] if meta.key?('default')
|
||||
h['x-secret'] = true if meta['secret']
|
||||
h['x-restart-required'] = false if meta['restart_not_required']
|
||||
h['enum'] = meta['enum'] if meta['enum']
|
||||
h['format'] = meta['format'] if meta['format']
|
||||
h['examples'] = meta['examples'] if meta['examples']
|
||||
h['minimum'] = meta['minimum'] if meta.key?('minimum')
|
||||
h['maximum'] = meta['maximum'] if meta.key?('maximum')
|
||||
h['x-status'] = meta['status'] if meta['status'] && meta['status'] != 'active'
|
||||
h['x-version-history'] = meta['version_history'] if meta['version_history']
|
||||
h['x-example-value'] = meta['example_value'] if meta.key?('example_value')
|
||||
h['x-anchor'] = meta['anchor'] if meta['anchor']
|
||||
h['x-hints'] = meta['hints'] if meta['hints']
|
||||
h['x-show-default'] = true if meta['show_default']
|
||||
h['x-extra'] = meta['extra'] if meta['extra']
|
||||
h['x-trailing'] = meta['trailing'] if meta['trailing']
|
||||
h['x-suppress-removed-hint'] = true if meta['suppress_removed_hint']
|
||||
h
|
||||
end
|
||||
private_class_method :annotation_to_property
|
||||
end
|
||||
end
|
||||
end
|
||||
74
lib/tasks/config.rake
Normal file
74
lib/tasks/config.rake
Normal file
@@ -0,0 +1,74 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../mastodon/configuration/schema'
|
||||
require_relative '../mastodon/configuration/env_scanner'
|
||||
require_relative '../mastodon/configuration/docs_generator'
|
||||
|
||||
namespace :mastodon do
|
||||
namespace :config do
|
||||
desc <<~DESC
|
||||
Print the JSON Schema for Mastodon environment-variable configuration.
|
||||
|
||||
The schema describes every environment variable recognised by this
|
||||
Mastodon instance, including its type, default value, and a human-readable description.
|
||||
Offers a machine-readable description of the configuration surface.
|
||||
|
||||
Usage:
|
||||
bundle exec rails mastodon:config:schema
|
||||
bundle exec rails mastodon:config:schema > mastodon-config.schema.json
|
||||
DESC
|
||||
task :schema do
|
||||
require 'json'
|
||||
puts JSON.pretty_generate(Mastodon::Configuration::Schema.generate)
|
||||
end
|
||||
|
||||
desc <<~DESC
|
||||
Generate Hugo-flavored Markdown for content/en/admin/config.md from the JSON schema.
|
||||
|
||||
Reads `mastodon-config.schema.json` (or the path supplied as an argument)
|
||||
and emits Markdown to stdout. The docs structure is driven by the
|
||||
`x-docs-layout` key, which is populated from the `docs:` block in
|
||||
`lib/mastodon/configuration/annotations.yml`.
|
||||
|
||||
Usage:
|
||||
bundle exec rails mastodon:config:docs
|
||||
bundle exec rails mastodon:config:docs > /path/to/documentation/content/en/admin/config.md
|
||||
DESC
|
||||
task :docs, [:schema_path] do |_t, args|
|
||||
require 'json'
|
||||
path = args[:schema_path] || File.expand_path('../../../mastodon-config.schema.json', __dir__)
|
||||
schema = JSON.parse(File.read(path))
|
||||
puts Mastodon::Configuration::DocsGenerator.render(schema)
|
||||
end
|
||||
|
||||
desc <<~DESC
|
||||
Check that every environment variable used in the source code is
|
||||
documented in the JSON Schema.
|
||||
|
||||
The task statically scans #{Mastodon::Configuration::EnvScanner::SCAN_PATHS.join(', ')} for
|
||||
literal ENV.fetch / ENV[] accesses and reports any key that is absent
|
||||
from the schema and not listed in EnvScanner::EXCLUDED_VARS.
|
||||
|
||||
Exits non-zero if undocumented variables are found.
|
||||
|
||||
Usage:
|
||||
bundle exec rails mastodon:config:lint
|
||||
DESC
|
||||
task :lint do
|
||||
undocumented = Mastodon::Configuration::EnvScanner.undocumented
|
||||
|
||||
if undocumented.empty?
|
||||
puts 'All environment variables are documented in the schema.'
|
||||
else
|
||||
warn "#{undocumented.size} environment variable(s) are used in the source code but not documented in the schema:\n"
|
||||
undocumented.sort.each do |key, files|
|
||||
warn " #{key}"
|
||||
files.each { |f| warn " #{f}" }
|
||||
end
|
||||
warn "\nTo fix, add entries for these variables to lib/mastodon/configuration/annotations.yml"
|
||||
warn 'then regenerate: bundle exec rails mastodon:config:schema > mastodon-config.schema.json'
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
3138
mastodon-config.schema.json
Normal file
3138
mastodon-config.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user