Module: Terret::Doctor
- Defined in:
- lib/terret/doctor.rb
Overview
trt doctor resolves a profile and validates every row's config against its
plugin's Hames::Schema, without booting anything (docs/composition.md §9).
It requires the composition's code and materializes each row (so !env, !setting and !ruby resolve to concrete values), then maps each row's plugin to its class and checks the materialized config against the class's schema. Nothing is mounted: doctor reports on a composition it never boots.
Two semantics keep the exit status trustworthy, and both err the same way:
a service with no schema is reported unschema'd, not failed, and an extra
key WARNS rather than fails. Environment probes — does OPENROUTER_API_KEY
resolve — print as informational lines and never as failures: doctor
validates config, not the world. Exit status is 1 only when an enabled row's
config is actually wrong.
Class Method Summary collapse
-
.check(row, settings, allow_config_ruby) ⇒ Object
One row's verdict.
- .collect_env(value, names) ⇒ Object
- .constantize(name) ⇒ Object
- .env_markers(rows) ⇒ Object
-
.info_lines(resolved, load_failures) ⇒ Object
Informational, never a verdict: which !env markers this composition reads and whether each resolves, plus any code that would not load.
-
.plugin_class?(klass) ⇒ Boolean
The plugin contract boot itself enforces (boot.rb): a class whose instances respond to #apply.
-
.render(resolved, results, load_failures, settings_error, out) ⇒ Object
-- output ----------------------------------------------------------------.
-
.require_code(resolved, allow_config_ruby) ⇒ Object
Requiring the composition's code, best-effort: a require that fails does not abort doctor.
-
.run(resolved, allow_config_ruby: false, out:) ⇒ Object
Returns a process exit status: 1 when an enabled row's config is wrong, 0 otherwise.
- .status_text(result) ⇒ Object
Class Method Details
.check(row, settings, allow_config_ruby) ⇒ Object
One row's verdict. The row id names the row in the table's first column,
so validate is called WITHOUT a subject — the schema can name the row (its
unit tests prove it), but here the column already does, and the doc's
error: sink must be a String reads better without the id repeated.
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/terret/doctor.rb', line 74 def self.check(row, settings, allow_config_ruby) base = { id: row.id, plugin: row.plugin, disabled: row.disabled } config = Composition.materialize(row.config, settings: settings, allow_config_ruby: allow_config_ruby, where: "row #{row.id.inspect}") klass = constantize(row.plugin) return base.merge(status: :error, detail: "#{row.plugin} does not resolve to a plugin class") unless klass unless plugin_class?(klass) # A name that resolves to a live constant that is not a plugin (String, a # module, a typo hitting something real) is not "a plugin with no schema" # — it is a wrong plugin:, and reporting it unschema'd would hide that. return base.merge(status: :error, detail: "#{row.plugin} is not a plugin " \ "(its instances do not respond to #apply)") end schema = klass.respond_to?(:config_schema) ? klass.config_schema : nil return base.merge(status: :unschema, detail: nil) unless schema # redact: a value here is a materialized !env/!setting/!ruby result and # may be a secret; the detail names its type only, never its content. result = schema.validate(config, redact: true) status = if result.errors.any? then :error elsif result.warnings.any? then :warn else :ok end base.merge(status: status, detail: (result.errors + result.warnings).join("; ")) rescue Composition::Error => e # A row whose !setting or !ruby cannot resolve is a config fault doctor # owns, reported against the row rather than crashing every other row. base.merge(status: :error, detail: e.) end |
.collect_env(value, names) ⇒ Object
175 176 177 178 179 180 181 |
# File 'lib/terret/doctor.rb', line 175 def self.collect_env(value, names) case value when Composition::Tagged then names << value.argument if value.tag == "env" when Hash then value.each_value { |v| collect_env(v, names) } when Array then value.each { |v| collect_env(v, names) } end end |
.constantize(name) ⇒ Object
105 106 107 108 109 |
# File 'lib/terret/doctor.rb', line 105 def self.constantize(name) Object.const_get(name) rescue NameError nil end |
.env_markers(rows) ⇒ Object
169 170 171 172 173 |
# File 'lib/terret/doctor.rb', line 169 def self.env_markers(rows) names = [] rows.each { |row| collect_env(row.config, names) } names.uniq end |
.info_lines(resolved, load_failures) ⇒ Object
Informational, never a verdict: which !env markers this composition reads and whether each resolves, plus any code that would not load. The resolved value of a marker never appears — a doctor that printed a secret would be one nobody could run in front of other people.
163 164 165 166 167 |
# File 'lib/terret/doctor.rb', line 163 def self.info_lines(resolved, load_failures) lines = env_markers(resolved.rows).map { |name| "#{name}: #{ENV.key?(name) ? 'set' : 'unset'}" } load_failures.each { |file, | lines << "could not require #{file.inspect}: #{message}" } lines end |
.plugin_class?(klass) ⇒ Boolean
The plugin contract boot itself enforces (boot.rb): a class whose instances respond to #apply. Using the same test keeps doctor from red-flagging a composition boot would accept — including a third-party functional plugin that is not a Hames::Service.
115 116 117 |
# File 'lib/terret/doctor.rb', line 115 def self.plugin_class?(klass) klass.is_a?(Class) && klass.method_defined?(:apply) end |
.render(resolved, results, load_failures, settings_error, out) ⇒ Object
-- output ----------------------------------------------------------------
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/terret/doctor.rb', line 121 def self.render(resolved, results, load_failures, settings_error, out) out.puts "# doctor: profile #{resolved.profile.inspect}" out.puts if settings_error out.puts "error #{Composition.one_line(settings_error)}" out.puts end # Row ids are validated at resolution, but plugin names, error details, # env markers and file paths are not — a newline in any of them could # forge a table row, so every printed value goes through one_line. Widths # are computed on the neutralized plugin so the table stays aligned. plugins = results.to_h { |r| [r[:id], Composition.one_line(r[:plugin])] } row_w = [results.map { |r| r[:id].length }.max || 3, 3].max plugin_w = [plugins.values.map(&:length).max || 6, 6].max out.puts "#{'row'.ljust(row_w)} #{'plugin'.ljust(plugin_w)} status" results.each do |r| out.puts "#{r[:id].ljust(row_w)} #{plugins[r[:id]].ljust(plugin_w)} #{status_text(r)}" end info = info_lines(resolved, load_failures) return if info.empty? out.puts info.each { |line| out.puts "info #{Composition.one_line(line)}" } end |
.require_code(resolved, allow_config_ruby) ⇒ Object
Requiring the composition's code, best-effort: a require that fails does not abort doctor. It surfaces as an info line for the file, and as the per-row "does not resolve" error for every class that file would define — which is more useful than a single aborted run.
doctor is the SAFE preview — "validates config, not the world" — so a
path-shaped require in a profile's plugins: (portable config from anywhere)
is NOT executed to do that job: it surfaces as a load failure the same way
a missing feature does, keeping trt doctor <untrusted profile> from being
arbitrary code execution. A bundle's requires: ship inside an installed gem
and are trusted (they may name a path to their own lib);
--allow-config-ruby is the operator's consent to load a profile path too,
exactly as at boot.
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/terret/doctor.rb', line 54 def self.require_code(resolved, allow_config_ruby) refused, permitted = resolved.plugins.partition do |file| !allow_config_ruby && !Composition.load_path_feature?(file) end failures = (resolved.requires + permitted).filter_map do |file| require file nil rescue LoadError => e [file, e.] end failures + refused.map do |file| [file, "refused: a filesystem path, not a load-path feature name; doctor does " \ "not execute untrusted requires — pass --allow-config-ruby to load it"] end end |
.run(resolved, allow_config_ruby: false, out:) ⇒ Object
Returns a process exit status: 1 when an enabled row's config is wrong, 0 otherwise.
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
# File 'lib/terret/doctor.rb', line 23 def self.run(resolved, allow_config_ruby: false, out:) load_failures = require_code(resolved, allow_config_ruby) # Settings resolve once, but a bad !setting/!ruby in settings: must not # abort the whole run and hide the row table. It becomes its own error # line; rows then materialize against empty settings, so any !setting in a # row surfaces as that row's own error rather than a swallowed one. settings, settings_error = begin [Composition.materialize_settings(resolved.settings, allow_config_ruby: allow_config_ruby), nil] rescue Composition::Error => e [{}, e.] end results = resolved.rows.map { |row| check(row, settings, allow_config_ruby) } render(resolved, results, load_failures, settings_error, out) bad = settings_error || results.any? { |r| !r[:disabled] && r[:status] == :error } bad ? 1 : 0 end |
.status_text(result) ⇒ Object
148 149 150 151 152 153 154 155 156 157 |
# File 'lib/terret/doctor.rb', line 148 def self.status_text(result) detail = Composition.one_line(result[:detail].to_s) text = case result[:status] when :ok then "ok" when :unschema then "unschema'd" when :warn then "warn: #{detail}" when :error then "error: #{detail}" end result[:disabled] ? "#{text} (disabled)" : text end |