Ruby API¶
The gem can be used without the web page: to validate runbooks in CI, to run a runbook from a script, or to read run records. Everything below is covered by the test suite.
require "runsheets" loads the model. Runsheets::Web, Pages, Assets
and CLI are autoloaded on first use so a script that only reads records
never loads Sinatra.
Loading a runbook¶
runbook = Runsheets::Runbook.load("ops/runbooks/staging-teardown") # a directory
runbook = Runsheets::Runbook.load("ops/runbooks/db-maintenance.md") # or a single file
runbook.single_file? # true for the second form
runbook.main_path # runbook.md, or the single file
runbook.slug # => "staging-teardown"
runbook.title # => "Staging Infrastructure Teardown and Rebuild"
runbook.when_to_use # String or nil
runbook.prerequisites # Array of String
runbook.blast_radius # String or nil
runbook.escalation # String or nil
runbook.tags # Array of String
runbook.destructive? # true if a blast radius is declared or any step is destructive
runbook.inputs # Array of Runbook::Input (name, prompt, default, secret?)
runbook.interpreters # { "bash" => ["bash"], "sh" => ["sh"], "zsh" => ["zsh"], "ruby" => ["ruby"] } plus the front matter's entries
runbook.warnings # Array of String, empty for a clean runbook
runbook.steps # numbered steps, in order
runbook.verify # Step or nil
runbook.rollback # Step or nil
runbook.landing # the runbook.md preamble as a Step with slug "runbook"
runbook.documents # every document keyed by slug
runbook.step("020-stop-the-pipeline")
runbook.find_block("020-stop-the-pipeline-1") # => [step, block] or nil
runbook.neighbors(step) # => [previous, next]
Runbook.load raises Runsheets::RunbookError when the path does not
exist, a directory has no runbook.md, or front matter is invalid or has
no title.
Authoring problems that do not prevent loading are collected in
warnings. Runsheets::SingleFile.split(markdown) is the parser behind
the single-file shape, returning the preamble and the sections with their
attribute comments decoded.
Steps¶
step = runbook.steps.first
step.slug # "010-confirm-nothing-to-preserve"
step.position # 1
step.number # "010"
step.title
step.kind # "automated", "manual" or "verify"
step.destructive?
step.timeout # seconds, Integer
step.cwd # String or nil
step.data # the raw front matter Hash
step.body # markdown after the front matter
step.html # rendered body
step.blocks # Array of Block
step.executable_blocks
step.block("010-confirm-nothing-to-preserve-1")
step.warnings
Blocks¶
block = step.blocks.first
block.id # "010-confirm-nothing-to-preserve-1"
block.lang # "bash"
block.flags # ["run"]
block.kind # :display, :run, :destructive, :terminal, :expect, :background
block.executable? # true for :run, :destructive and :background in a language the runbook's interpreters map
block.interpreters # that map: the defaults plus the front matter's entries
block.destructive?
block.background?
block.terminal?
block.acknowledgeable? # true for :terminal
block.expect?
block.expect_for # for an :expect block, the id of the executable block above it, or nil
block.code # the fence contents, dedented
block.line # line number of the opening fence
block.warnings
block.referenced_variables # ["AWS_PROFILE", "CLUSTER"]
block.to_h
Verification and reloading¶
runbook.verify_documents # verify-kind steps, then verify.md
runbook.verify_blocks # their executable blocks
runbook.stale? # a source file changed since load
Validating in CI¶
#!/usr/bin/env ruby
require "runsheets"
failed = false
Dir.glob("ops/runbooks/*/runbook.md").each do |path|
runbook = Runsheets::Runbook.load(File.dirname(path))
runbook.warnings.each { |w| warn "#{runbook.slug}: #{w}"; failed = true }
rescue Runsheets::RunbookError => e
warn "#{path}: #{e.message}"
failed = true
end
exit(failed ? 1 : 0)
Or simply runsheets --check DIR per runbook.
Running a runbook from a script¶
Session is the same object the web page drives. A session needs an
engineer and a reason, like the start page; it creates its directory and
log under runs_root as soon as it is built.
runbook = Runsheets::Runbook.load("examples/directory_based_runbooks/hello")
session = Runsheets::Session.new(engineer: "ci", why: "nightly check of the hello runbook",
runs_root: "/tmp/runs", runbook:)
session.open(runbook, inputs: { "NAME" => "script" })
runbook.steps.first(2).each do |step|
step.executable_blocks.each do |block|
execution = session.execute(block.id).wait
puts "#{block.id}: #{execution.state} #{execution.exit_status}"
puts execution.output
abort "stopping at #{block.id}" unless execution.success?
end
session.mark_step(step.slug, status: "done", note: "run by script")
end
session.note!("first two steps pass")
session.end!("script finished")
puts session.run.dir # /tmp/runs/hello/20261010T012715
puts session.run.status # "partial": two of six steps marked
Session.new takes engineer: and why: (both required; a blank one
raises Runsheets::RunError), runs_root: (default Runsheets.runs_dir),
library: or runbook: (the one runbook served, put on screen),
token:, executor: (default Executor.new), log_level: (default
"info"), echo: (an IO the log is echoed to, or nil for none) and
now:. Building it also closes any earlier session in runs_root left
running by a process that is gone.
open(runbook, inputs:) puts the runbook on screen and establishes its
run, or returns the run it already has (the inputs are then ignored). The
methods that act on "this runbook" (execute, acknowledge,
mark_step, change_inputs, run) go to the run of the runbook on
screen. execute returns immediately with a running Execution; wait
blocks until it is reaped. It raises Runsheets::RunError when the
runbook on screen has no run, the block is unknown or not executable, or
a referenced declared input is blank.
end!(how) closes every run with its derived status (completed,
partial or opened), stopping anything still running first, writes the
records, and logs how as the reason. Ending twice does nothing.
Other session methods:
session.id # "20261010T012715", the directory name
session.dir # <runs_root>/sessions/<id>
session.engineer
session.why # the first note's text
session.notes # [{ at:, text: }, ...]
session.host
session.started_at
session.ended_at # nil until end!
session.elapsed
session.status # "running" or "ended"
session.ended?
session.log # the SessionLog
session.token # the token the web layer requires
session.show(runbook) # put a runbook on screen without opening a run
session.runbook # the runbook on screen
session.runs # every Run, in the order their runbooks were selected
session.run_for(slug) # the Run of a runbook, or nil
session.current # the Run of the runbook on screen, or nil
session.run # current's RunRecord, or nil
session.active? # the runbook on screen has an open run
session.carried_inputs # non-secret inputs given earlier in the session, by name
session.resolve_inputs(given) # what the inputs would be for the runbook on screen
session.change_inputs(given) # change the current run's inputs; a blank secret keeps its value
session.acknowledge(block_id, note: "...") # record that a terminal block was run by hand
session.ack(block_id) # the latest acknowledgement Hash, or nil
session.step_status(slug) # "done", "skipped" or nil
session.secret_inputs_set # names of secret inputs that have a value
session.history # previous RunRecords of the runbook on screen, newest first
session.refresh_runbook! # reload the runbook on screen if a source file changed
session.execution(id) # a live Execution by id, in any run
session.executions # every Execution in the session
session.running_executions # those whose process is still alive
session.running # [[run, execution], ...] for each one still running
session.stop(execution_id) # TERM then KILL its process group, whichever runbook started it
session.write! # rewrite session.json
session.to_h # the session.json data
Runsheets::Session.close_interrupted(runs_root) # close sessions left running by a dead process; returns their ids
Executing a destructive block without the right confirm raises
Runsheets::Run::ConfirmationRequired (also reachable as
Runsheets::Session::ConfirmationRequired), a RunError whose
challenge is the code to pass back:
begin
session.execute("040-exercise-failure-1")
rescue Runsheets::Run::ConfirmationRequired => e
session.execute("040-exercise-failure-1", confirm: e.challenge)
end
Runs¶
Runsheets::Run is one runbook's part of a session. Session#open
builds them; scripts usually reach them through session.current or
session.run_for(slug).
run = session.current
run.runbook
run.slug
run.record # its RunRecord
run.inputs # the inputs in force, secrets included (memory only)
run.open? # until the session ends
run.execute(block_id, confirm: nil)
run.challenge_for(block_id) # the code a destructive block currently expects
run.stop(execution_id)
run.acknowledge(block_id, note: nil)
run.mark_step(step_slug, status: "done", note: nil)
run.change_inputs(given) # recorded as an inputs event and logged
run.executions
run.running_executions
run.close! # stop what is running, close the record; returns the status
Runsheets::Run.resolve_inputs(runbook, given, carried: {})
# for each input: given, else carried (never a secret), else ENV, else the default
The session log¶
log = Runsheets::SessionLog.new("/tmp/session.log", level: "info", echo: $stdout, echo_level: "info")
log.info("deployed", tags: ["release", "step-3"])
# 2026-10-10 14:02:41.200 INFO [release step-3] deployed
log.debug(text, tags: []) # also warn, error, fatal
log.add("warn", text, tags: [])
log.output_stream(["#1a2b"]) # a sink for output chunks: each complete line becomes "> line"
log.path
log.level
log.close
Multi-line text becomes one log line per line, each with the same prefix.
An unknown level raises Runsheets::ConfigError.
Executions¶
execution.id
execution.block_id
execution.step_slug
execution.command # ["bash"]
execution.background? # true for a background block (no timeout)
execution.state # :pending, :running, :finished, :timed_out, :stopped, :failed
execution.running?
execution.finished? # true for finished, timed_out, stopped and failed
execution.success? # finished with exit status 0
execution.failure? # finished non-zero, timed_out or failed; never stopped
execution.stopped?
execution.timed_out?
execution.pid
execution.exit_status
execution.signal
execution.error # why it could not start
execution.started_at
execution.finished_at
execution.duration
execution.output # the whole .out file, UTF-8, scrubbed
execution.output(tail: 4096)
execution.output_size
execution.cmd_path
execution.log_path
execution.wait(limit = nil)
execution.to_h
Running one block by hand¶
Executor is independent of runbooks and records.
Dir.mktmpdir do |dir|
execution = Runsheets::Execution.new(
id: "x", block_id: "b", step_slug: "s", command: %w[bash],
cmd_path: File.join(dir, "b.cmd"), log_path: File.join(dir, "b.out"),
timeout: 10
)
Runsheets::Executor.new.run(execution, code: "echo $GREETING\n", env: { "GREETING" => "hi" }, cwd: dir)
execution.output # => "hi\n"
end
run is start followed by wait. start raises nothing; a spawn
failure leaves the execution in state :failed with error set.
executor.stop(execution) asks the reaper to end the process group.
start takes a redactor: that every chunk of output passes through
before it is written:
redactor = Runsheets::Redactor.new("DB_PASSWORD" => "hunter2")
redactor.redact("password=hunter2") # => "password=[redacted DB_PASSWORD]"
Runsheets::Executor.new.run(execution, code: "echo $DB_PASSWORD\n", env: { "DB_PASSWORD" => "hunter2" }, redactor:)
execution.output # => "[redacted DB_PASSWORD]\n"
Redactor.for(inputs, runbook) builds the one a run uses: every
secret input that has a value. feed(chunk) and flush are the
streaming form, which holds back a tail that could be the start of a
secret until the next chunk settles it.
Reading run records¶
runs = Runsheets::RunRecord.list(Runsheets.runs_dir, "staging-teardown")
run = runs.first
run.id
run.dir
run.status # "running", "completed", "partial", "opened", "interrupted" ("abandoned" in old records)
run.session_id # the session it belongs to, or nil in old records
run.active?
run.started_at
run.finished_at
run.duration
run.inputs # non-secret inputs
run.events # Array of Hash with symbol keys
run.executions # Array of Hash with symbol keys
run.step_status # { "010-..." => "done" }
run.acks # { "020-...-3" => { at:, step:, note: } } terminal confirmations
run.kind # "run", or "verify" in records from before sessions
run.verify?
run.closing_status(step_slugs) # the status it would close with now
run.latest_executions # block id => its most recent execution Hash
run.unresolved_failures # latest executions that are failures
run.last_step # the step of the latest event, or nil
run.drift(runbook) # [{ block_id:, status: :changed, diff: [Diff::Line...] }, { status: :missing, ... }]
run.steps_done
run.failed_executions
run.summary # { id:, kind:, started_at:, finished_at:, status:, duration:, executions:, failures:, unresolved:, steps_done:, last_step: }
run.transcript # the run.md text
run.to_h # the run.json data
RunRecord.load(dir) reads one directory. Records that cannot be parsed are
skipped by list. RunRecord.start(runs_root, runbook, session_id:, inputs: {})
creates a run directory named by the session id and writes the first
version; Run calls it. change_inputs(inputs, runbook) records an
inputs event, and finish!(status:) closes the record with one of the
final statuses.
Libraries¶
library = Runsheets::Library.load("ops/runbooks") # RunbookError if no runbooks are found
Runsheets::Library.library?("ops/runbooks") # a directory without its own runbook.md
library.dir
library.entries # every runbook at any depth, by slug; Library::Entry
library.folders # every folder below the root; Library::Folder
library.root # the root Folder (slug "")
library.find("database/maintenance") # an Entry, or nil
library.folder("database") # a Folder, or nil
library.node(slug) # an Entry, a Folder, or nil
library.runbook("database/maintenance") # the Runbook, loaded afresh with its library slug
library.entry_at(path) # the Entry whose files include path, or nil
library.refresh! # rescan if anything changed
An Entry has slug, name, path, runbook (nil when it does not
load), error, ok?, title and warnings. A Folder has slug,
name, folders, entries, readme_html and runbooks (everything
beneath it).
Searching¶
library = Runsheets::Library.load("ops/runbooks")
runbooks = library.entries.select(&:ok?).map { [it.slug, it.runbook] }
results = Runsheets::Search.run(runbooks, 'vacuum "worst table"')
results.each do |result|
result.slug # "database/maintenance"
result.score # higher is better; results come best first
result.hits.each { puts "#{it.title}: #{it.snippet}" }
end
Runsheets::Search.terms('Stop "the pipeline"') # => ["stop", "the pipeline"]
Every term must appear somewhere in a runbook for it to match.
Search::Query.parse(string) gives the parsed query, whose count,
snippet and highlight can be called on their own; terms, text_of
and plain are module functions.
Rendering markdown¶
result = Runsheets::Renderer.render(markdown, id_prefix: "doc")
result.html # with executable blocks wrapped
result.blocks # Array of Block
Runsheets::Renderer.rewrite_relative_urls(html, base_dir) points relative
src and href values at /files/, as the step pages do.
Comparing output with an expect block¶
result = Runsheets::Expectation.compare("hello {{any}}\n", "hello world\n")
result.match? # => true
result.rows # Expectation::Row (tag :same, :changed, :expected or :actual), line by line
result.to_h # { match:, rows: [...] }, as /executions/:id sends it
step.expected_output(block_id) # the text of the expect block for that block, or nil
Lines are compared in order with trailing whitespace ignored; {{any}}
matches any characters within one line.
Serving¶
require "runsheets"
runbook = Runsheets::Runbook.load("examples/directory_based_runbooks/hello")
options = { runs_root: Runsheets.runs_dir, log_level: "info", echo: $stdout }
# With the engineer and the reason known, start the session now:
session = Runsheets::Session.new(engineer: "Pat", why: "monthly checks", runbook:, **options)
Runsheets::Web.configure_for(session, bind: "127.0.0.1", port: 4567, runbook:)
# Or leave it to the start page:
Runsheets::Web.configure_for(nil, bind: "127.0.0.1", port: 4567, runbook:)
.prepare_start(session_options: options, engineer: "Pat")
Runsheets::Web.run!
Runsheets::Web.session&.end!("server stopped")
configure_for(session, bind:, port:, library:, runbook:) wires in a
library of runbooks or one runbook, and the session if it has started, and
restricts permitted hosts to loopback plus the bind address.
prepare_start(session_options:, engineer:) says what a session started
from the start page is built with and who the page suggests.
Web.start_session(engineer:, why:) is what the start page calls;
Web.session is the session once it has started. This is what runsheets
does, ending the session when the server stops.
Configuration¶
Runsheets.config # Runsheets::Config: the layered settings (see the CLI page)
Runsheets.config.port # 4567 unless the config file, RUNSHEETS_PORT or the CLI say otherwise
Runsheets.config.files # the config files that exist and were read
Runsheets.config.to_config_yaml # the settings in force as config-file text (what --dump prints)
Runsheets.configure({ port: 4580 }, path: "/etc/runsheets.yml") # install your own
Runsheets.runs_dir # Runsheets.config.runs_dir unless assigned
Runsheets.runs_dir = "/srv/runs"
Runsheets.reset_config! # rebuild from the layers on next use
Runsheets::VERSION
Runsheets::Config is a myway_config
class. Its layers, lowest to highest: lib/runsheets/config/defaults.yml in the gem,
~/.config/runsheets/runsheets.yml, the project config (./config/runsheets.yml,
or the file path: or RUNSHEETS_CONFIG names), RUNSHEETS_*
variables, then the overrides hash. A Runsheets::ConfigError is raised
for a port that is not a whole number from 1 to 65535, an unknown log level, or a named
config file that is missing. engineer, why, log_level and quiet
are settings like the rest.
Errors¶
| Class | Raised when |
|---|---|
Runsheets::Error |
Base class. |
Runsheets::RunbookError |
A runbook cannot be loaded. |
Runsheets::RunError |
A session or run operation is not allowed in the current state, or a session is started without an engineer or a reason. |
Runsheets::Run::ConfirmationRequired |
A destructive block was executed without its code; a RunError carrying challenge. |
Runsheets::ConfigError |
A setting is invalid: a port, a log level, a missing config file. Also raised by SessionLog for an unknown level. |