├── .config ├── README.md.template └── templating.sh ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .hermit └── bin │ ├── .deno-2.1.1.pkg │ ├── .hyperfine@latest.pkg │ ├── .just@latest.pkg │ ├── .pre-commit@latest.pkg │ ├── .uv-0.5.21.pkg │ ├── README.hermit.md │ ├── activate-hermit │ ├── deno │ ├── hermit │ ├── hermit.hcl │ ├── hyperfine │ ├── just │ ├── pre-commit │ ├── publish-docs │ ├── runbook │ ├── uv │ └── uvx ├── .pre-commit-config.yaml ├── .release-it.json ├── CHANGELOG.md ├── Justfile ├── README.md ├── TESTING.md ├── deno.json ├── deno.lock ├── docs ├── NOTES.md ├── PERFORMANCE.md ├── README.md ├── _static │ └── .gitkeep ├── _templates │ └── .gitkeep ├── cli.rst ├── conf.py └── index.rst ├── ext └── deno │ └── runbook │ ├── deno.json │ ├── deno.lock │ ├── log.ts │ ├── mod.ts │ ├── secrets.ts │ ├── secrets │ ├── aws_parameter_store.ts │ └── one_password_cli.ts │ ├── shell.d.ts │ ├── shell.ts │ └── shell_test.ts ├── package.json ├── pyproject.toml ├── runbook ├── __init__.py ├── cli │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── check.py │ │ ├── convert.py │ │ ├── create.py │ │ ├── diff.py │ │ ├── edit.py │ │ ├── init.py │ │ ├── list.py │ │ ├── plan.py │ │ ├── review.py │ │ ├── run.py │ │ ├── show.py │ │ └── version.py │ ├── completions.py │ ├── lib.py │ └── validators.py ├── constants.py ├── data │ ├── _template-deno.ipynb │ └── _template-python.ipynb ├── nbconvert │ └── templates │ │ └── mdaudit │ │ ├── conf.json │ │ └── index.md.j2 ├── notifications.py ├── shell.py └── template.py ├── tests ├── __init__.py ├── __snapshots__ │ └── cli_test.ts.snap ├── cli_test.py ├── cli_test.ts ├── fixtures │ └── prompters │ │ ├── echoer │ │ └── interactive-prompter.ts └── manual │ ├── 01_edit.sh │ └── 02_run.sh └── uv.lock /.config/README.md.template: -------------------------------------------------------------------------------- 1 | # Runbook 2 | 3 | ## Summary 4 | 5 | Runbook is a powerful CLI tool that transforms your operational procedures into interactive, executable notebooks. It combines the best of documentation and automation by letting you create dynamic runbooks using Markdown, Deno, or Python. 6 | 7 | Think of it as "infrastructure as code" meets "documentation as code" - perfect for DevOps teams who want both flexibility and reliability. 8 | 9 | **At work, it empowered us to move 300 Mysql Clusters to TiDB with a small team [recording](https://www.youtube.com/watch?app=desktop&v=-_JoqZthrI8) over the course of 18 months.** 10 | 11 | # Quick Start 12 | 13 | ```sh 14 | uv tool install git+https://github.com/zph/runbook.git@$RUNBOOK_VERSION 15 | 16 | # Initialize a new runbook project in a repo of your choosing 17 | runbook init 18 | 19 | # Create a new runbook 20 | runbook create -l deno runbook-name.ipynb 21 | 22 | # Edit the runbook 23 | runbook edit runbook-name.ipynb 24 | 25 | # Plan the runbook 26 | runbook plan runbook-name.ipynb --embed file.json --parameters '{"arg": 1, "foo": "baz"}' 27 | 28 | # Run the runbook 29 | runbook run runbook-name.ipynb 30 | ``` 31 | 32 | # Background 33 | 34 | ## What is a Runbook? 35 | A runbook is an executable document that combines: 36 | - Clear markdown documentation 37 | - Runnable code blocks 38 | - Parameterized inputs for reusability 39 | - Built-in safety checks 40 | 41 | It's ideal for operations like encoding your Disaster Recovery Operations, spinning up a new cluster, or restoring from snapshots. 42 | 43 | ## When Should You Use This? 44 | - ✅ When you need **semi-automated tools** with audit trails and safety checks 45 | - ✅ When you want **rapid iteration** on operational procedures with built-in rollback steps 46 | - ✅ When you need something more powerful than shell scripts but don't want to build a full application 47 | - ✅ When you want to make complex operations both **safe and repeatable** 48 | 49 | ## Runbook Best Practices 50 | 1. Structure your runbooks with: 51 | - Clear purpose and summary 52 | - Step-by-step descriptions 53 | - Warning signs and precautions 54 | - Verification steps 55 | - Execution steps in logical order 56 | - Rollback and cleanup steps 57 | 2. Keep read-only operations flexible 58 | 3. Require explicit confirmation for destructive actions using the `confirm` flag 59 | 4. Include pre-flight checks before any system modifications 60 | 5. For critical operations, use pair execution: 61 | - One person to run the procedure 62 | - Another to verify and validate safety checks 63 | 64 | ## Workflow 65 | 66 | 1. Initialize a new folder project with `runbook init...` 67 | 1. Create a new runbook with `runbook create -l deno runbook-name.ipynb` 68 | 1. Edit the runbook with `runbook edit runbook-name.ipynb` (or using editor of choice) and add your title, description, steps 69 | 1. For complex runbooks, offload the coding details into an SDK that you build beside the runbooks that can be reused across multiple runbooks 70 | 1. Plan that runbook for a specific run `runbook plan runbook-name.ipynb --embed file.json --parameters '{"arg": 1, "foo": "baz"}' 71 | 1. Run the instance of a runbook with either `runbook run runbook-name.ipynb` or use VSCode to run it `code runbooks/runs/runbook-name.ipynb` 72 | 1. Depending on auditing needs, you can either commit the "runs" folder to your repo or only keep the "binder" folder committed. 73 | 1. In case of strict auditing needs, we recommend you add auditing of commands in the local SDK as well as in your cloud provider 74 | 75 | # Installation 76 | 77 | We recommend using [uv](https://docs.astral.sh/uv/) for installing runbook as a cli tool. If you already use pipx, you can use that instead. 78 | 79 | ```sh 80 | uv tool install git+https://github.com/zph/runbook.git 81 | ``` 82 | 83 | Or pin to a version 84 | 85 | ```sh 86 | uv tool install git+https://github.com/zph/runbook.git@$RUNBOOK_VERSION 87 | ``` 88 | 89 | # CLI 90 | 91 | ```sh 92 | $RUNBOOK_HELP 93 | ``` 94 | 95 | Shell completion is included via click library and enabled as follows [link](https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion) 96 | 97 | ``` 98 | # Bash 99 | # Add this to ~/.bashrc: 100 | eval "$(_RUNBOOK_COMPLETE=bash_source runbook)" 101 | 102 | # Zsh 103 | # Add this to ~/.zshrc: 104 | eval "$(_RUNBOOK_COMPLETE=zsh_source runbook)" 105 | 106 | # Fish 107 | # Add this to ~/.config/fish/completions/foo-bar.fish: 108 | _RUNBOOK_COMPLETE=fish_source runbook | source 109 | ``` 110 | 111 | For advanced completion setup see [docs](https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion) 112 | 113 | # Principles 114 | 115 | - Prefer deno for better package management and developer ergonomics with typing 116 | - But allow for other kernels (python) as secondary option, via compatible libraries 117 | - Make `runbook` batteries included for interfacing with shell commands and common runbook 118 | operations 119 | 120 | # Caveats 121 | 122 | 1. Running notebook in VScode does not set the timings necessary in notebook for being auditable and exported later 123 | 1. Recommendation: if auditable runs are needed, use jupyter via browser `runbook run TITLE` 124 | 1. Notebooks have different structured ids per cell depending on run environment 125 | 1. Recommendation: if requiring consistency, write your own pre-processor to standardize on an id format 126 | 1. Built-in shell package requires a shell environment and is only expected to run on Linux or Mac not Windows. 127 | 1. Recommendation: Windows support is out of scope for now but we'll review PRs 128 | 129 | ## Deno / Typescript 130 | 1. Parameter cells must use `let` declarations to allow for param overriding 131 | - `var` or `let` work in Deno notebooks but only `let` works if using `runbook convert a.ipynb a.ts` and running the ts version 132 | 133 | # Developing runbook cli 134 | 135 | For development we use the following tools: 136 | - [hermit](https://hermit.dev/) to manage developement tool dependencies (see .hermit/bin) 137 | - [uv](https://docs.astral.sh/uv/) python package manager and cli runner (see pyproject.toml) 138 | 139 | Necessary deps can be seen in pyproject.toml and .hermit/bin 140 | 141 | Use .hermit/bin/activate-hermit to activate the environment. 142 | 143 | # Readme Changes 144 | 145 | README.md is generated from .config/README.md.template and should be updated there. 146 | -------------------------------------------------------------------------------- /.config/templating.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eou pipefail 4 | 5 | export RUNBOOK_HELP="$(runbook --help)" 6 | export RUNBOOK_VERSION="$(runbook version | grep -i version | awk '{print $3}')" 7 | 8 | envsubst < ".config/README.md.template" > README.md 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: runbook tests and stuff 2 | on: [push] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Check out repository code 8 | uses: actions/checkout@v4 9 | # Export env vars for later steps, normal export doesn't work 10 | - run: echo PATH=./.hermit/bin:$PATH >> $GITHUB_ENV 11 | # Setup the working dir to run runbook commands 12 | - run: echo WORKING_DIR="$(mktemp -d -t runbook-XXXX)" >> $GITHUB_ENV 13 | - name: Setup Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: '3.11' 17 | 18 | - name: initialize folders 19 | # Runbook command comes from .hermit/bin/runbook 20 | run: runbook init --skip-confirmation=true 21 | - name: Ruff formatting 22 | run: pre-commit run ruff-format 23 | - name: Ruff linting 24 | run: pre-commit run ruff 25 | - run: just test 26 | - run: echo "🍏 This job's status is ${{ job.status }}." 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | docs/README.md 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | runbooks 164 | .hermit/.hermit 165 | 166 | docs/README.md 167 | -------------------------------------------------------------------------------- /.hermit/bin/.deno-2.1.1.pkg: -------------------------------------------------------------------------------- 1 | hermit -------------------------------------------------------------------------------- /.hermit/bin/.hyperfine@latest.pkg: -------------------------------------------------------------------------------- 1 | hermit -------------------------------------------------------------------------------- /.hermit/bin/.just@latest.pkg: -------------------------------------------------------------------------------- 1 | hermit -------------------------------------------------------------------------------- /.hermit/bin/.pre-commit@latest.pkg: -------------------------------------------------------------------------------- 1 | hermit -------------------------------------------------------------------------------- /.hermit/bin/.uv-0.5.21.pkg: -------------------------------------------------------------------------------- 1 | hermit -------------------------------------------------------------------------------- /.hermit/bin/README.hermit.md: -------------------------------------------------------------------------------- 1 | # Hermit environment 2 | 3 | This is a [Hermit](https://github.com/cashapp/hermit) bin directory. 4 | 5 | The symlinks in this directory are managed by Hermit and will automatically 6 | download and install Hermit itself as well as packages. These packages are 7 | local to this environment. 8 | -------------------------------------------------------------------------------- /.hermit/bin/activate-hermit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This file must be used with "source bin/activate-hermit" from bash or zsh. 3 | # You cannot run it directly 4 | # 5 | # THIS FILE IS GENERATED; DO NOT MODIFY 6 | 7 | if [ "${BASH_SOURCE-}" = "$0" ]; then 8 | echo "You must source this script: \$ source $0" >&2 9 | exit 33 10 | fi 11 | 12 | BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" 13 | if "${BIN_DIR}/hermit" noop > /dev/null; then 14 | eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" 15 | 16 | if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then 17 | hash -r 2>/dev/null 18 | fi 19 | 20 | echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" 21 | fi 22 | -------------------------------------------------------------------------------- /.hermit/bin/deno: -------------------------------------------------------------------------------- 1 | .deno-2.1.1.pkg -------------------------------------------------------------------------------- /.hermit/bin/hermit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # THIS FILE IS GENERATED; DO NOT MODIFY 4 | 5 | set -eo pipefail 6 | 7 | export HERMIT_USER_HOME=~ 8 | 9 | if [ -z "${HERMIT_STATE_DIR}" ]; then 10 | case "$(uname -s)" in 11 | Darwin) 12 | export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" 13 | ;; 14 | Linux) 15 | export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" 16 | ;; 17 | esac 18 | fi 19 | 20 | export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" 21 | HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" 22 | export HERMIT_CHANNEL 23 | export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} 24 | 25 | if [ ! -x "${HERMIT_EXE}" ]; then 26 | echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 27 | INSTALL_SCRIPT="$(mktemp)" 28 | # This value must match that of the install script 29 | INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" 30 | if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then 31 | curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" 32 | else 33 | # Install script is versioned by its sha256sum value 34 | curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" 35 | # Verify install script's sha256sum 36 | openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ 37 | awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ 38 | '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' 39 | fi 40 | /bin/bash "${INSTALL_SCRIPT}" 1>&2 41 | fi 42 | 43 | exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" 44 | -------------------------------------------------------------------------------- /.hermit/bin/hermit.hcl: -------------------------------------------------------------------------------- 1 | sources = ["https://github.com/cashapp/hermit-packages.git", "https://github.com/zph/hermit-packages.git"] 2 | manage-git = false 3 | -------------------------------------------------------------------------------- /.hermit/bin/hyperfine: -------------------------------------------------------------------------------- 1 | .hyperfine@latest.pkg -------------------------------------------------------------------------------- /.hermit/bin/just: -------------------------------------------------------------------------------- 1 | .just@latest.pkg -------------------------------------------------------------------------------- /.hermit/bin/pre-commit: -------------------------------------------------------------------------------- 1 | .pre-commit@latest.pkg -------------------------------------------------------------------------------- /.hermit/bin/publish-docs: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Stop on errors 4 | set -eou pipefail 5 | set -x 6 | 7 | # Variables 8 | DOCS_DIR="site" 9 | BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) 10 | TEMP_DIR=$(mktemp -d) 11 | TEMP_GIT_DIR=$(mktemp -d) 12 | ORIGINAL_GIT_DIR=$(git rev-parse --show-toplevel) 13 | 14 | # Check if git working directory is clean 15 | # if [ -n "$(git status --porcelain)" ]; then 16 | # echo "Error: Git working directory is not clean. Please commit or stash changes first." 17 | # exit 1 18 | # fi 19 | 20 | just docs 21 | # Copy the docs to the temporary directory 22 | cp -r "$DOCS_DIR"/* "$TEMP_DIR/" 23 | 24 | ( 25 | cd "$TEMP_GIT_DIR" 26 | git clone "$ORIGINAL_GIT_DIR" . 27 | git checkout gh-pages 28 | 29 | rm -rf ./* 30 | cp -r "$TEMP_DIR"/* . 31 | git add -A 32 | git commit -m "Publish documentation [$(date)]" 33 | # Push to local original repo 34 | git push origin gh-pages 35 | ) 36 | 37 | # Push to remote 38 | git push origin gh-pages 39 | 40 | echo "Documentation published to gh-pages branch." 41 | -------------------------------------------------------------------------------- /.hermit/bin/runbook: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | uv run runbook "$@" 4 | -------------------------------------------------------------------------------- /.hermit/bin/uv: -------------------------------------------------------------------------------- 1 | .uv-0.5.21.pkg -------------------------------------------------------------------------------- /.hermit/bin/uvx: -------------------------------------------------------------------------------- 1 | .uv-0.5.21.pkg -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: "d7f0995" # 2025-02-07 4 | hooks: 5 | - id: ruff 6 | args: [--fix] 7 | exclude: runbooks/binder/_template-deno.ipynb 8 | - id: ruff-format 9 | exclude: runbooks/binder/_template-deno.ipynb 10 | 11 | - repo: local 12 | hooks: 13 | - id: tests 14 | name: Runs tests 15 | entry: just test 16 | language: system 17 | files: '.*$' 18 | pass_filenames: false 19 | # - id: update-readme 20 | # name: Runs readme templating 21 | # entry: just readme 22 | # language: system 23 | # files: '.*$' 24 | # pass_filenames: false 25 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "@release-it/bumper": { 4 | "in": "runbook/__init__.py", 5 | "out": "runbook/__init__.py" 6 | } 7 | }, 8 | "hooks": { 9 | "after:bump": "npx auto-changelog -p && git add CHANGELOG.md", 10 | "before:init": "just clean", 11 | "after:github:release": "just docs-release && just clean", 12 | "before:release": "just build" 13 | }, 14 | "npm": { 15 | "publish": false 16 | }, 17 | "github": { 18 | "release": true, 19 | "assets": [ 20 | "dist/*.gz", 21 | "dist/*.whl" 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### [1.0.0-rc2.3](https://github.com/zph/runbook/compare/1.0.0-rc2.2...1.0.0-rc2.3) 8 | 9 | #### [1.0.0-rc2.2](https://github.com/zph/runbook/compare/1.0.0-rc2.1...1.0.0-rc2.2) 10 | 11 | > 1 February 2025 12 | 13 | - Update shell implementation for deno [`8926272`](https://github.com/zph/runbook/commit/8926272d66f40605e9e80aa10ae11b7f255cb0a1) 14 | - Release 1.0.0-rc2.2 [`88603bb`](https://github.com/zph/runbook/commit/88603bb5bc43007c65bddeea2923aab4683ff590) 15 | 16 | #### [1.0.0-rc2.1](https://github.com/zph/runbook/compare/1.0.0-rc2.0...1.0.0-rc2.1) 17 | 18 | > 20 January 2025 19 | 20 | - Improve discoverability [`d68237e`](https://github.com/zph/runbook/commit/d68237ebb39701e7be1eb7aac5e405c9d6486c5d) 21 | - Release 1.0.0-rc2.1 [`0005afd`](https://github.com/zph/runbook/commit/0005afd36fb204201085f3b22c26fe04d87fff41) 22 | - Update template [`bc40326`](https://github.com/zph/runbook/commit/bc40326112ea1fa57e0f14dd18a3b640c50ef123) 23 | 24 | #### [1.0.0-rc2.0](https://github.com/zph/runbook/compare/1.0.0-rc2...1.0.0-rc2.0) 25 | 26 | > 20 January 2025 27 | 28 | - Add e2e testing and restructure the README in preparations for v1 release [`#23`](https://github.com/zph/runbook/pull/23) 29 | - Replace npm with deno for release-it [`d0ebbfc`](https://github.com/zph/runbook/commit/d0ebbfcdcd0bd29c752cd06ba36fec6449bc25c9) 30 | - Expand e2e test cases [`83c1a63`](https://github.com/zph/runbook/commit/83c1a635bd62e053fea98f88d306bba06ac69df5) 31 | - Simplify test setup [`70b5617`](https://github.com/zph/runbook/commit/70b5617e1f3edcff2dacf095f402365493940fa0) 32 | 33 | #### [1.0.0-rc2](https://github.com/zph/runbook/compare/1.0.0-rc1...1.0.0-rc2) 34 | 35 | > 19 January 2025 36 | 37 | - Update readme and convert to using uv [`fd7377c`](https://github.com/zph/runbook/commit/fd7377ccad8191b02c9bed2db8ac426c7c49baa1) 38 | - Tidy up testing file [`cad571f`](https://github.com/zph/runbook/commit/cad571ffc45a99a7d5e4b75d3b5341a105fd2898) 39 | - Add show command [`f37cb96`](https://github.com/zph/runbook/commit/f37cb96f6b3188ddb1a60e0ae923bc709eb226cc) 40 | 41 | #### [1.0.0-rc1](https://github.com/zph/runbook/compare/0.3.5...1.0.0-rc1) 42 | 43 | > 18 January 2025 44 | 45 | - Add auto prompting during plan if no args supplied [`#19`](https://github.com/zph/runbook/pull/19) 46 | - Initial implementation of auto-prompting [`c8cb128`](https://github.com/zph/runbook/commit/c8cb12823e9c8bafe21c44e441bb57afcfbe7eab) 47 | - Fix python auto-planning [`fbd0259`](https://github.com/zph/runbook/commit/fbd0259ca481e849e84bc71b3f525c84210af8aa) 48 | - Update readme for install requirements [`03b8434`](https://github.com/zph/runbook/commit/03b84346abdc17290bf961ace3060e5d195f1ce6) 49 | 50 | #### [0.3.5](https://github.com/zph/runbook/compare/0.3.4...0.3.5) 51 | 52 | > 14 June 2024 53 | 54 | - Update npm packages related to release-it [`9c48365`](https://github.com/zph/runbook/commit/9c48365399140118aadaeccfbcfabc32e35c484d) 55 | - Release 0.3.5 [`b874eb5`](https://github.com/zph/runbook/commit/b874eb5b98e29eee20072f6fa543106d1c0e2c0d) 56 | - Add identifier to output folder as well as file [`00ea82d`](https://github.com/zph/runbook/commit/00ea82d250fe66d1698a9cfcd7ab1714b00705c9) 57 | 58 | #### [0.3.4](https://github.com/zph/runbook/compare/0.3.3...0.3.4) 59 | 60 | > 13 June 2024 61 | 62 | - Fix runbook run validations of filename for better DEVEX [`#15`](https://github.com/zph/runbook/issues/15) [`#13`](https://github.com/zph/runbook/issues/13) 63 | - Remove trailing hyphen when no identifier used [`#14`](https://github.com/zph/runbook/issues/14) 64 | - Release 0.3.4 [`9310b62`](https://github.com/zph/runbook/commit/9310b62bb067baed7f3a81115e5e23a0df2979dc) 65 | 66 | #### [0.3.3](https://github.com/zph/runbook/compare/0.3.2...0.3.3) 67 | 68 | > 15 April 2024 69 | 70 | - Update readme with new commands [`8957d87`](https://github.com/zph/runbook/commit/8957d87687bde487228b990d8e7100d8780a399a) 71 | - Add readme updates to pre-commit [`709880f`](https://github.com/zph/runbook/commit/709880f32e69c525a863647e4f46d834f9fec611) 72 | - Release 0.3.3 [`772b7bd`](https://github.com/zph/runbook/commit/772b7bd580272f83dd1c5c9acc2cacab2be9a48d) 73 | 74 | #### [0.3.2](https://github.com/zph/runbook/compare/0.3.1...0.3.2) 75 | 76 | > 13 April 2024 77 | 78 | - Auto format all the things [`9f4440d`](https://github.com/zph/runbook/commit/9f4440dba6e0f0aa0a1220e8b26b0c16f3479136) 79 | - Add diff function for runbooks [`61588cf`](https://github.com/zph/runbook/commit/61588cfbdf50872435c0e83cd0429bc7123e6406) 80 | - Release 0.3.2 [`7afc778`](https://github.com/zph/runbook/commit/7afc778b26d0664a54f5bd9bc2e593173b130c53) 81 | 82 | #### [0.3.1](https://github.com/zph/runbook/compare/0.3.0...0.3.1) 83 | 84 | > 12 April 2024 85 | 86 | - Issue #5: Allow for custom suffix on planned runbook name with --iden… [`#9`](https://github.com/zph/runbook/pull/9) 87 | - Update papermill to use let not var for Typescript notebooks [`#8`](https://github.com/zph/runbook/pull/8) 88 | - Add 'check' command for deno and for python [`84e3f5e`](https://github.com/zph/runbook/commit/84e3f5ed56f65b8b498dfb8a2af77845657d4cab) 89 | - Release 0.3.1 [`1e12521`](https://github.com/zph/runbook/commit/1e125210d712ee117303a30dd350a09903b0a693) 90 | - Issue #5: Allow for custom suffix on planned runbook name with --identifier [`5dd230f`](https://github.com/zph/runbook/commit/5dd230f6bb2b5bae6896ac69296155428d30e3ae) 91 | 92 | #### [0.3.0](https://github.com/zph/runbook/compare/0.2.2...0.3.0) 93 | 94 | > 31 March 2024 95 | 96 | - Maintain an exported interface for runbooks [`4a5467a`](https://github.com/zph/runbook/commit/4a5467a4d1c1e307e9ce8dcc75ff9afde7df3206) 97 | - Expose jupyter's binary for pipx installations [`5860a0d`](https://github.com/zph/runbook/commit/5860a0dfea4898a023a0dbc96061d58a540a4a67) 98 | - Add wrapper for sh in deno [`b430a81`](https://github.com/zph/runbook/commit/b430a8139f6548f5a50467cfdab65787fd200998) 99 | 100 | #### [0.2.2](https://github.com/zph/runbook/compare/0.2.1...0.2.2) 101 | 102 | > 11 February 2024 103 | 104 | - Add changelog [`59142d6`](https://github.com/zph/runbook/commit/59142d68b6cf620896fe96ad668d1409022e1d19) 105 | - Add version command [`234fa16`](https://github.com/zph/runbook/commit/234fa166c29c983d61298e7911c25721cfed8024) 106 | - Release 0.2.2 [`7bc61b8`](https://github.com/zph/runbook/commit/7bc61b8a115e3f0bb8b7f5ba9c9410bbd01b241c) 107 | 108 | #### [0.2.1](https://github.com/zph/runbook/compare/0.2.0...0.2.1) 109 | 110 | > 11 February 2024 111 | 112 | - Enable typescript based deno notebooks [`4c5af1a`](https://github.com/zph/runbook/commit/4c5af1ada9284a6f196ca59a3b124862505bdff2) 113 | - Avoid using ClearMetadataPreprocessor to to surprising behavior [`34c2a2d`](https://github.com/zph/runbook/commit/34c2a2d2a00127052238043c1151cff80a8f4f35) 114 | - Add release-it to help with version management [`e415abf`](https://github.com/zph/runbook/commit/e415abf124564b1eb29335ee18ca7f99364be111) 115 | 116 | #### [0.2.0](https://github.com/zph/runbook/compare/0.1.1-1...0.2.0) 117 | 118 | > 28 January 2024 119 | 120 | - Release 0.2.0 [`29b176f`](https://github.com/zph/runbook/commit/29b176f116e0832c73c539d505e118d69ddcfca8) 121 | 122 | #### [0.1.1-1](https://github.com/zph/runbook/compare/0.1.1-0...0.1.1-1) 123 | 124 | > 28 January 2024 125 | 126 | - Bump release-it deps [`eb1e595`](https://github.com/zph/runbook/commit/eb1e5959bead18ccefb261e0e04662724907639e) 127 | - Release 0.1.1-1 [`55481c2`](https://github.com/zph/runbook/commit/55481c2b0a651d24f4605fdbacbb6ed156c5a8c7) 128 | - Coordinate python with json [`a35c0b8`](https://github.com/zph/runbook/commit/a35c0b89a5fe924f7ba3149673966923ba6c0a56) 129 | 130 | #### [0.1.1-0](https://github.com/zph/runbook/compare/0.1.0...0.1.1-0) 131 | 132 | > 28 January 2024 133 | 134 | - Release 0.1.1-0 [`95c054d`](https://github.com/zph/runbook/commit/95c054deab52d7496be280be5237b46323bd8af7) 135 | 136 | #### 0.1.0 137 | 138 | > 28 January 2024 139 | 140 | - Add release-it to help with version management [`4e7633f`](https://github.com/zph/runbook/commit/4e7633fd3bc0d3344b938a748bd707c89878bc6f) 141 | - Initial commit with functioning lib tools [`8bd36f7`](https://github.com/zph/runbook/commit/8bd36f72db2af1ed5ee9fc9dcdb9b88e52ad9f30) 142 | - Working primary commands including embedded jupyter and nbconvert [`22e088b`](https://github.com/zph/runbook/commit/22e088b78d27fdb31e3accf12124333d695352f6) 143 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | test: 2 | uv run pytest --disable-warnings -s 3 | deno test -A --reload https://raw.githubusercontent.com/zph/runbook/main/ext/deno/runbook/mod.ts --parallel tests/cli_test.ts 4 | 5 | test-watch: 6 | watchexec -- just test 7 | 8 | open NOTEBOOK: 9 | uv run runbook edit {{NOTEBOOK}} 10 | 11 | clear-binder-output: 12 | jupyter nbconvert --clear-output --inplace ./runbook/data/*.ipynb 13 | 14 | clear-output *FILES: 15 | jupyter nbconvert --clear-output --inplace {{FILES}} 16 | 17 | lint: 18 | pre-commit run 19 | 20 | lint-all: 21 | pre-commit run --all-files 22 | 23 | profile: 24 | uv run python3 -m cProfile runbook/cli/__init__.py 25 | 26 | release: 27 | deno run -A npm:release-it 28 | 29 | clean: 30 | rm -rf ./dist 31 | 32 | build: 33 | uv build 34 | 35 | benchmark: 36 | hyperfine --export-markdown=docs/PERFORMANCE.md -- runbook 37 | 38 | readme: 39 | .config/templating.sh 40 | 41 | docs: 42 | cp -f README.md docs/ 43 | uvx --with sphinx-click --with myst_parser --with . --from sphinx sphinx-build -b html docs/ site 44 | 45 | docs-release: 46 | bash .hermit/bin/publish-docs 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Runbook 2 | 3 | ## Summary 4 | 5 | Runbook is a powerful CLI tool that transforms your operational procedures into interactive, executable notebooks. It combines the best of documentation and automation by letting you create dynamic runbooks using Markdown, Deno, or Python. 6 | 7 | Think of it as "infrastructure as code" meets "documentation as code" - perfect for DevOps teams who want both flexibility and reliability. 8 | 9 | **At work, it empowered us to move 300 Mysql Clusters to TiDB with a small team [recording](https://www.youtube.com/watch?app=desktop&v=-_JoqZthrI8) over the course of 18 months.** 10 | 11 | # Quick Start 12 | 13 | ```sh 14 | uv tool install git+https://github.com/zph/runbook.git@1.0.0-rc2 15 | 16 | # Initialize a new runbook project in a repo of your choosing 17 | runbook init 18 | 19 | # Create a new runbook 20 | runbook create -l deno runbook-name.ipynb 21 | 22 | # Edit the runbook 23 | runbook edit runbook-name.ipynb 24 | 25 | # Plan the runbook 26 | runbook plan runbook-name.ipynb --embed file.json --parameters '{"arg": 1, "foo": "baz"}' 27 | 28 | # Run the runbook 29 | runbook run runbook-name.ipynb 30 | ``` 31 | 32 | # Background 33 | 34 | ## What is a Runbook? 35 | A runbook is an executable document that combines: 36 | - Clear markdown documentation 37 | - Runnable code blocks 38 | - Parameterized inputs for reusability 39 | - Built-in safety checks 40 | 41 | It's ideal for operations like encoding your Disaster Recovery Operations, spinning up a new cluster, or restoring from snapshots. 42 | 43 | ## When Should You Use This? 44 | - ✅ When you need **semi-automated tools** with audit trails and safety checks 45 | - ✅ When you want **rapid iteration** on operational procedures with built-in rollback steps 46 | - ✅ When you need something more powerful than shell scripts but don't want to build a full application 47 | - ✅ When you want to make complex operations both **safe and repeatable** 48 | 49 | ## Runbook Best Practices 50 | 1. Structure your runbooks with: 51 | - Clear purpose and summary 52 | - Step-by-step descriptions 53 | - Warning signs and precautions 54 | - Verification steps 55 | - Execution steps in logical order 56 | - Rollback and cleanup steps 57 | 2. Keep read-only operations flexible 58 | 3. Require explicit confirmation for destructive actions using the `confirm` flag 59 | 4. Include pre-flight checks before any system modifications 60 | 5. For critical operations, use pair execution: 61 | - One person to run the procedure 62 | - Another to verify and validate safety checks 63 | 64 | ## Workflow 65 | 66 | 1. Initialize a new folder project with `runbook init...` 67 | 1. Create a new runbook with `runbook create -l deno runbook-name.ipynb` 68 | 1. Edit the runbook with `runbook edit runbook-name.ipynb` (or using editor of choice) and add your title, description, steps 69 | 1. For complex runbooks, offload the coding details into an SDK that you build beside the runbooks that can be reused across multiple runbooks 70 | 1. Plan that runbook for a specific run `runbook plan runbook-name.ipynb --embed file.json --parameters '{"arg": 1, "foo": "baz"}' 71 | 1. Run the instance of a runbook with either `runbook run runbook-name.ipynb` or use VSCode to run it `code runbooks/runs/runbook-name.ipynb` 72 | 1. Depending on auditing needs, you can either commit the "runs" folder to your repo or only keep the "binder" folder committed. 73 | 1. In case of strict auditing needs, we recommend you add auditing of commands in the local SDK as well as in your cloud provider 74 | 75 | # Installation 76 | 77 | We recommend using [uv](https://docs.astral.sh/uv/) for installing runbook as a cli tool. If you already use pipx, you can use that instead. 78 | 79 | ```sh 80 | uv tool install git+https://github.com/zph/runbook.git 81 | ``` 82 | 83 | Or pin to a version 84 | 85 | ```sh 86 | uv tool install git+https://github.com/zph/runbook.git@1.0.0-rc2 87 | ``` 88 | 89 | # CLI 90 | 91 | ```sh 92 | Usage: runbook [OPTIONS] COMMAND [ARGS]... 93 | 94 | Options: 95 | --cwd PATH Directory for operations (normally at root above runbooks, ie 96 | ../.runbook.yaml) and can be set with RUNBOOK_WORKING_DIR or 97 | WORKING_DIR environment variables 98 | --help Show this message and exit. 99 | 100 | Commands: 101 | check Check the language validity and formatting of a runbook. 102 | convert Convert a runbook between different formats 103 | create Create a new runbook from a template 104 | diff Compare two runbooks and show their differences 105 | edit Edit an existing runbook 106 | init Initialize a folder as a runbook repository 107 | list List runbooks 108 | plan Prepares the runbook for execution by injecting parameters. 109 | review [Unimplemented] Entrypoint for reviewing runbook 110 | run Run a runbook 111 | show Show runbook parameters and metadata 112 | version Display version information about runbook 113 | ``` 114 | 115 | Shell completion is included via click library and enabled as follows [link](https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion) 116 | 117 | ``` 118 | # Bash 119 | # Add this to ~/.bashrc: 120 | eval "$(_RUNBOOK_COMPLETE=bash_source runbook)" 121 | 122 | # Zsh 123 | # Add this to ~/.zshrc: 124 | eval "$(_RUNBOOK_COMPLETE=zsh_source runbook)" 125 | 126 | # Fish 127 | # Add this to ~/.config/fish/completions/foo-bar.fish: 128 | _RUNBOOK_COMPLETE=fish_source runbook | source 129 | ``` 130 | 131 | For advanced completion setup see [docs](https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion) 132 | 133 | # Principles 134 | 135 | - Prefer deno for better package management and developer ergonomics with typing 136 | - But allow for other kernels (python) as secondary option, via compatible libraries 137 | - Make `runbook` batteries included for interfacing with shell commands and common runbook 138 | operations 139 | 140 | # Caveats 141 | 142 | 1. Running notebook in VScode does not set the timings necessary in notebook for being auditable and exported later 143 | 1. Recommendation: if auditable runs are needed, use jupyter via browser `runbook run TITLE` 144 | 1. Notebooks have different structured ids per cell depending on run environment 145 | 1. Recommendation: if requiring consistency, write your own pre-processor to standardize on an id format 146 | 1. Built-in shell package requires a shell environment and is only expected to run on Linux or Mac not Windows. 147 | 1. Recommendation: Windows support is out of scope for now but we'll review PRs 148 | 149 | ## Deno / Typescript 150 | 1. Parameter cells must use `let` declarations to allow for param overriding 151 | - `var` or `let` work in Deno notebooks but only `let` works if using `runbook convert a.ipynb a.ts` and running the ts version 152 | 153 | # Developing runbook cli 154 | 155 | For development we use the following tools: 156 | - [hermit](https://hermit.dev/) to manage developement tool dependencies (see .hermit/bin) 157 | - [uv](https://docs.astral.sh/uv/) python package manager and cli runner (see pyproject.toml) 158 | 159 | Necessary deps can be seen in pyproject.toml and .hermit/bin 160 | 161 | Use .hermit/bin/activate-hermit to activate the environment. 162 | 163 | # Readme Changes 164 | 165 | README.md is generated from .config/README.md.template and should be updated there. 166 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Coverage 4 | 5 | | Command | Description | Unit/Integration Tests | Manual Testing Required | 6 | | ------- | ---------------------------------------------------------- | ---------------------- | ----------------------- | 7 | | check | Check language validity and formatting of a notebook | ✅ | ❌ | 8 | | convert | Convert an existing runbook to different format | ✅ | ❌ | 9 | | create | Create a new runbook from [template] | ✅ | ❌ | 10 | | diff | Diff two notebooks | ✅ | ❌ | 11 | | edit | Edit an existing runbook | ❌ | ✅️ | 12 | | init | Initialize a folder as a runbook repository | ✅ | ❌ | 13 | | list | List runbooks | ✅ | ❌ | 14 | | plan | Prepares the runbook for execution by injecting parameters | ✅ | ❌️ | 15 | | review | [Unimplemented] Entrypoint for reviewing runbook | ❌ | N/A | 16 | | run | Run a notebook | ❌ | ✅️ | 17 | | show | Show runbook parameters and metadata | ✅ | ❌ | 18 | | version | Display version information about runbook | ✅ | ❌ | 19 | 20 | Commands marked with "Manual Testing Required: Yes" should be tested manually as 21 | they cannot be easily automation tested. 22 | 23 | ## Manual Testing 24 | 25 | See `tests/manual` for manual testing. 26 | 27 | It involves running the shell scripts in `tests/manual` and auditing behavior. 28 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@david/dax": "jsr:@david/dax@^0.42.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/NOTES.md: -------------------------------------------------------------------------------- 1 | # Roadmap and Notes 2 | 3 | ## Triage 4 | - [x] Use better way to embed files in the runbook package (ie templates) 5 | - [x] Read `runbook plan ...` support reading params from file 6 | - [x] If no argument included for RUNBOOK TITLE in edit/plan/run then prompt with options 7 | - [x] Include field 'embeds' in the metadata to help with referencing them 8 | - [ ] --- Add helper for referencing the embeds? 9 | - [ ] Use execute and upload output to S3 for non-interactive: https://github.com/nteract/papermill/tree/main?tab=readme-ov-file#execute-via-cli 10 | 11 | ## P0 12 | - [ ] Add linter for runbooks (using `runbook lint`) that checks for presence of title, desc, rollback, cleanup, etc 13 | - [ ] Add `runbook init` suggestion or automation to add `export RUNBOOK_WORKING_DIR=...` to shell initializations 14 | - [ ] Install pre-commit.yml or git integration during `init` for secure linting and talisman 15 | - [x] Setup versioning and bumper (using versioner from npm ecosystem @release-it and @release-it/bumper) 16 | - [x] Fix tag key getting stripped out of planned runbooks b/c it breaks papermill 17 | - [x] Setup tagging in the notebooks to auto-set those values 18 | - [ ] Security: Setup git to autoclear cell outputs for a given folder's notebooks (ie templates) https://stackoverflow.com/a/58004619 19 | - [x] Assess if we need shell completions 20 | - [x] Yes, very nice and use https://click.palletsprojects.com/en/8.1.x/shell-completion/#custom-type-completion to define for custom types, ie only find ipynb files for edit, create, etc 21 | - [ ] Integrate mechanisms to read Grafana or other data sources and package "pre/post check" helpers 22 | - [ ] ie poll for values over N period and if exceeding Y or Z then throw error and suggest rollbacks 23 | 24 | ## P1 25 | - [ ] Include safe way to retry when APIs fail but with confirmation 26 | - for handling the case when infrastructure API calls fail when made in large async batch 27 | - [ ] Immutably store everything with bookstore https://github.com/nteract/bookstore 28 | - [ ] Setup a watcher for auto-exporting html or other formats 29 | - [x] Use slack notify for posting execution steps: https://github.com/keitakurita/jupyter-slack-notify 30 | 31 | ## P2 32 | - [x] Setup different folder from runbooks for execution files? 33 | - [x] Only allow for executing runbooks that are pre-processed 34 | - [x] Should I follow the tf convention of `plan | apply` 35 | - [x] Setup decorator to embed dry_run into shell command 36 | - [ ] (won't do yet) Allow for executing cell by cell from commandline in a repl? 37 | - [ ] Running cell by cell: https://github.com/odewahn/ipynb-examples/blob/master/Importing%20Notebooks.ipynb 38 | - [ ] Build auditability through a custom runner interface 39 | -- Or a custom kernel wrapper? 40 | -- https://ipython.readthedocs.io/en/stable/config/options/kernel.html 41 | - [x] Jupytext runner that watches fs changes and exports to scripts (can be done via jupytext 42 | config) 43 | - [ ] After execution in web browser jupyter notebook or in vscode dynamic environment, execute as HTML 44 | for archival. It looks great. 45 | 46 | - Use custom format for nbconvert exports: https://nbconvert.readthedocs.io/en/latest/customizing.html 47 | 48 | > jupyter nbconvert --TemplateExporter.extra_template_basedirs=nbconvert/templates --to markdown --template=mdaudit exec 49 | > uted-Untitled4.ipynb 50 | 51 | - [x] (NA it's in modern jupyter) Bundle in the timing nbextension jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/execute_time/readme.html 52 | - [x] (won't do) Freeze dangerous cells as a safegurad? https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/freeze/readme.html 53 | - [x] (won't do) Cell filtering https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/cell_filter/README.html 54 | 55 | ## How To 56 | How to connect to existing running notebook from different interface: 57 | - https://stackoverflow.com/questions/31382405/ipython-notebook-how-to-connect-to-existing-kernel 58 | 1. %connect_info in the notebook and note the json file it references 59 | 2. jupyter console --existing JSONFILE 60 | 61 | ## Extensibility 62 | 63 | - Runners 64 | - Run from local 65 | - Run against remote deployed server behind auth? can be done via Spawners 66 | - Store files on S3 (ie repo published to S3 for centralized repo?) https://github.com/danielfrg/s3contents 67 | 68 | - Get extension for skip working: https://raw.githubusercontent.com/RobbeSneyders/Jupyter-skip-extension/master/skip_kernel_extension.py 69 | -------------------------------------------------------------------------------- /docs/PERFORMANCE.md: -------------------------------------------------------------------------------- 1 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | 2 | |:---|---:|---:|---:|---:| 3 | | `runbook` | 400.9 ± 21.6 | 379.0 | 457.5 | 1.00 | 4 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Runbook 2 | 3 | ## Summary 4 | 5 | Runbook is a powerful CLI tool that transforms your operational procedures into interactive, executable notebooks. It combines the best of documentation and automation by letting you create dynamic runbooks using Markdown, Deno, or Python. 6 | 7 | Think of it as "infrastructure as code" meets "documentation as code" - perfect for DevOps teams who want both flexibility and reliability. 8 | 9 | **At work, it empowered us to move 300 Mysql Clusters to TiDB with a small team [recording](https://www.youtube.com/watch?app=desktop&v=-_JoqZthrI8) over the course of 18 months.** 10 | 11 | # Quick Start 12 | 13 | ```sh 14 | uv tool install git+https://github.com/zph/runbook.git@1.0.0-rc2 15 | 16 | # Initialize a new runbook project in a repo of your choosing 17 | runbook init 18 | 19 | # Create a new runbook 20 | runbook create -l deno runbook-name.ipynb 21 | 22 | # Edit the runbook 23 | runbook edit runbook-name.ipynb 24 | 25 | # Plan the runbook 26 | runbook plan runbook-name.ipynb --embed file.json --parameters '{"arg": 1, "foo": "baz"}' 27 | 28 | # Run the runbook 29 | runbook run runbook-name.ipynb 30 | ``` 31 | 32 | # Background 33 | 34 | ## What is a Runbook? 35 | A runbook is an executable document that combines: 36 | - Clear markdown documentation 37 | - Runnable code blocks 38 | - Parameterized inputs for reusability 39 | - Built-in safety checks 40 | 41 | It's ideal for operations like encoding your Disaster Recovery Operations, spinning up a new cluster, or restoring from snapshots. 42 | 43 | ## When Should You Use This? 44 | - ✅ When you need **semi-automated tools** with audit trails and safety checks 45 | - ✅ When you want **rapid iteration** on operational procedures with built-in rollback steps 46 | - ✅ When you need something more powerful than shell scripts but don't want to build a full application 47 | - ✅ When you want to make complex operations both **safe and repeatable** 48 | 49 | ## Runbook Best Practices 50 | 1. Structure your runbooks with: 51 | - Clear purpose and summary 52 | - Step-by-step descriptions 53 | - Warning signs and precautions 54 | - Verification steps 55 | - Execution steps in logical order 56 | - Rollback and cleanup steps 57 | 2. Keep read-only operations flexible 58 | 3. Require explicit confirmation for destructive actions using the `confirm` flag 59 | 4. Include pre-flight checks before any system modifications 60 | 5. For critical operations, use pair execution: 61 | - One person to run the procedure 62 | - Another to verify and validate safety checks 63 | 64 | ## Workflow 65 | 66 | 1. Initialize a new folder project with `runbook init...` 67 | 1. Create a new runbook with `runbook create -l deno runbook-name.ipynb` 68 | 1. Edit the runbook with `runbook edit runbook-name.ipynb` (or using editor of choice) and add your title, description, steps 69 | 1. For complex runbooks, offload the coding details into an SDK that you build beside the runbooks that can be reused across multiple runbooks 70 | 1. Plan that runbook for a specific run `runbook plan runbook-name.ipynb --embed file.json --parameters '{"arg": 1, "foo": "baz"}' 71 | 1. Run the instance of a runbook with either `runbook run runbook-name.ipynb` or use VSCode to run it `code runbooks/runs/runbook-name.ipynb` 72 | 1. Depending on auditing needs, you can either commit the "runs" folder to your repo or only keep the "binder" folder committed. 73 | 1. In case of strict auditing needs, we recommend you add auditing of commands in the local SDK as well as in your cloud provider 74 | 75 | # Installation 76 | 77 | We recommend using [uv](https://docs.astral.sh/uv/) for installing runbook as a cli tool. If you already use pipx, you can use that instead. 78 | 79 | ```sh 80 | uv tool install git+https://github.com/zph/runbook.git 81 | ``` 82 | 83 | Or pin to a version 84 | 85 | ```sh 86 | uv tool install git+https://github.com/zph/runbook.git@1.0.0-rc2 87 | ``` 88 | 89 | # CLI 90 | 91 | ```sh 92 | Usage: runbook [OPTIONS] COMMAND [ARGS]... 93 | 94 | Options: 95 | --cwd PATH Directory for operations (normally at root above runbooks, ie 96 | ../.runbook.yaml) and can be set with RUNBOOK_WORKING_DIR or 97 | WORKING_DIR environment variables 98 | --help Show this message and exit. 99 | 100 | Commands: 101 | check Check the language validity and formatting of a runbook. 102 | convert Convert a runbook between different formats 103 | create Create a new runbook from a template 104 | diff Compare two runbooks and show their differences 105 | edit Edit an existing runbook 106 | init Initialize a folder as a runbook repository 107 | list List runbooks 108 | plan Prepares the runbook for execution by injecting parameters. 109 | review [Unimplemented] Entrypoint for reviewing runbook 110 | run Run a runbook 111 | show Show runbook parameters and metadata 112 | version Display version information about runbook 113 | ``` 114 | 115 | Shell completion is included via click library and enabled as follows [link](https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion) 116 | 117 | ``` 118 | # Bash 119 | # Add this to ~/.bashrc: 120 | eval "$(_RUNBOOK_COMPLETE=bash_source runbook)" 121 | 122 | # Zsh 123 | # Add this to ~/.zshrc: 124 | eval "$(_RUNBOOK_COMPLETE=zsh_source runbook)" 125 | 126 | # Fish 127 | # Add this to ~/.config/fish/completions/foo-bar.fish: 128 | _RUNBOOK_COMPLETE=fish_source runbook | source 129 | ``` 130 | 131 | For advanced completion setup see [docs](https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion) 132 | 133 | # Principles 134 | 135 | - Prefer deno for better package management and developer ergonomics with typing 136 | - But allow for other kernels (python) as secondary option, via compatible libraries 137 | - Make `runbook` batteries included for interfacing with shell commands and common runbook 138 | operations 139 | 140 | # Caveats 141 | 142 | 1. Running notebook in VScode does not set the timings necessary in notebook for being auditable and exported later 143 | 1. Recommendation: if auditable runs are needed, use jupyter via browser `runbook run TITLE` 144 | 1. Notebooks have different structured ids per cell depending on run environment 145 | 1. Recommendation: if requiring consistency, write your own pre-processor to standardize on an id format 146 | 1. Built-in shell package requires a shell environment and is only expected to run on Linux or Mac not Windows. 147 | 1. Recommendation: Windows support is out of scope for now but we'll review PRs 148 | 149 | ## Deno / Typescript 150 | 1. Parameter cells must use `let` declarations to allow for param overriding 151 | - `var` or `let` work in Deno notebooks but only `let` works if using `runbook convert a.ipynb a.ts` and running the ts version 152 | 153 | # Developing runbook cli 154 | 155 | For development we use the following tools: 156 | - [hermit](https://hermit.dev/) to manage developement tool dependencies (see .hermit/bin) 157 | - [uv](https://docs.astral.sh/uv/) python package manager and cli runner (see pyproject.toml) 158 | 159 | Necessary deps can be seen in pyproject.toml and .hermit/bin 160 | 161 | Use .hermit/bin/activate-hermit to activate the environment. 162 | 163 | # Readme Changes 164 | 165 | README.md is generated from .config/README.md.template and should be updated there. 166 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zph/runbook/bc18fe0592ec10a376e0fbcedba5d98625566222/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/_templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zph/runbook/bc18fe0592ec10a376e0fbcedba5d98625566222/docs/_templates/.gitkeep -------------------------------------------------------------------------------- /docs/cli.rst: -------------------------------------------------------------------------------- 1 | .. Runbook documentation master file, created by 2 | sphinx-quickstart on Mon Jan 20 11:01:47 2025. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Runbook CLI 7 | ===================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | .. click:: runbook.cli:cli 14 | :prog: runbook 15 | :show-nested: 16 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | # + 10 | project = "Runbook" 11 | copyright = "2025, Zander Hill" 12 | author = "Zander Hill" 13 | import runbook 14 | 15 | release = runbook.__version__ 16 | # - 17 | 18 | # -- General configuration --------------------------------------------------- 19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | 22 | extensions = [ 23 | "sphinx.ext.autodoc", 24 | "sphinx.ext.napoleon", 25 | "sphinx_click", 26 | "myst_parser", 27 | ] 28 | 29 | templates_path = ["_templates"] 30 | exclude_patterns = [] 31 | 32 | 33 | # -- Options for HTML output ------------------------------------------------- 34 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 35 | 36 | html_theme = "alabaster" 37 | html_static_path = ["_static"] 38 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Runbook documentation master file, created by 2 | sphinx-quickstart on Mon Jan 20 11:01:47 2025. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Runbook documentation 7 | ===================== 8 | 9 | CLI for dynamic runbooks: a structured and auditable approach 10 | to creating and executing operational procedures, bridging the 11 | gap between simple shell scripts and more complex tooling. 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | :caption: Contents: 16 | 17 | README 18 | CLI Documentation 19 | Roadmap and Notes 20 | Performance 21 | -------------------------------------------------------------------------------- /ext/deno/runbook/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "dax": "jsr:@david/dax@^0.42.0", 4 | "@std/assert": "jsr:@std/assert@1" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ext/deno/runbook/log.ts: -------------------------------------------------------------------------------- 1 | import * as log from "jsr:@std/log"; 2 | 3 | export const timestampISOLocal = (date: Date) => { 4 | // subtract the offset from t 5 | const offset = date.getTime() - date.getTimezoneOffset() * 60 * 1000; 6 | // create shifted Date object 7 | const local = new Date(offset); 8 | // convert to ISO format string 9 | return local.toISOString(); 10 | }; 11 | 12 | // Using .cache to avoid spreading temporary files across the repo 13 | // and to be clear that they're transient 14 | export const fileHandler = new log.FileHandler("INFO", { 15 | filename: [ 16 | // TODO: decide how to populate this 17 | Deno.env.get("RUNBOOK_DIR"), 18 | "runbooks", 19 | ".cache", 20 | "logs", 21 | "audit", 22 | "output.log", 23 | ].join("/"), 24 | formatter: (record) => 25 | JSON.stringify({ 26 | level: record.levelName, 27 | message: record.msg, 28 | ts: timestampISOLocal(record.datetime), 29 | name: record.loggerName, 30 | // Note this requires we use logs with only a type signature of `log.info("message", {})` 31 | // not variadric usage 32 | args: record.args[0], 33 | }), 34 | }); 35 | 36 | log.setup({ 37 | handlers: { 38 | // TODO: decide on re-enabling fileHandler 39 | // file: fileHandler, 40 | console: new log.ConsoleHandler("INFO"), 41 | }, 42 | 43 | loggers: { 44 | default: { 45 | level: "INFO", 46 | handlers: ["console"], 47 | }, 48 | }, 49 | }); 50 | 51 | export { log }; 52 | -------------------------------------------------------------------------------- /ext/deno/runbook/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./shell.ts"; 2 | 3 | // Mod.ts for runbook-deno helpers 4 | // Default Setup 5 | // See deno dax module for capabilities, including $.request, retry, etc 6 | import {CommandBuilder, build$} from "dax"; 7 | 8 | const commandBuilder = new CommandBuilder() 9 | .stdout("inheritPiped") 10 | .stderr("inheritPiped") 11 | .printCommand(true); 12 | 13 | const $ = build$({ commandBuilder }); 14 | 15 | const sh = async (...args) => { 16 | const start = (new Date()).toISOString() 17 | performance.mark('command_start'); 18 | const r = await $.raw`${args.join(" ")}` 19 | performance.mark('command_end') 20 | const end = (new Date()).toISOString() 21 | const measure = performance.measure('command', 'command_start', 'command_end') 22 | const timing = {duration_ms: measure.duration, start, end} 23 | const {stdout, stderr, code} = r 24 | await Deno.jupyter.broadcast("display_data", { 25 | data: { "application/json": {command: args.join(" "), exit_code: code, timing, stdout, stderr}}, 26 | metadata: {}, 27 | transient: { display_id: "progress" } 28 | }); 29 | return r 30 | } 31 | 32 | export { sh, $ } 33 | -------------------------------------------------------------------------------- /ext/deno/runbook/secrets.ts: -------------------------------------------------------------------------------- 1 | export interface Secrets { 2 | [key: string]: () => Promise; 3 | } 4 | 5 | export class SecureEnvironment { 6 | #secrets: Secrets; 7 | constructor(secrets: Secrets) { 8 | this.#secrets = secrets; 9 | } 10 | 11 | async load(): Promise<{ [key: string]: string }> { 12 | const acc: { [key: string]: string } = {}; 13 | for await (const [k, v] of Object.entries(this.#secrets)) { 14 | const resolved = await v(); 15 | acc[k] = resolved; 16 | } 17 | 18 | // Ensure that we don't have overlapping secret keys 19 | // because they will have undefined behavior during 20 | // replacements 21 | this.ensureNonOverlappingKeys(acc); 22 | return acc; 23 | } 24 | 25 | ensureNonOverlappingKeys(kvs: { [key: string]: string }) { 26 | const keys = Object.keys(kvs); 27 | keys.forEach((key, _i) => { 28 | keys.forEach((kx, _ix) => { 29 | if (kx.includes(key) && key !== kx) { 30 | throw new Error(`Secrets cannot overlap: ${key} and ${kx}`); 31 | } 32 | }); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ext/deno/runbook/secrets/aws_parameter_store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetParameterCommand, 3 | SSMClient, 4 | } from "npm:@aws-sdk/client-ssm@^3.315.0"; 5 | 6 | const REGION = 'us-east-1' 7 | 8 | export const getParameterByNameBuilder = async (region: string): Promise<(name: string) => Promise> => { 9 | return async (name: string) => { 10 | const ssmClient = new SSMClient({ region: REGION }); 11 | try { 12 | const { Parameter } = await ssmClient.send( 13 | new GetParameterCommand({ 14 | Name: name, 15 | WithDecryption: true, 16 | }), 17 | ); 18 | 19 | if (Parameter?.Value == null) { 20 | throw new Error( 21 | `No parameters found for ${name}.`, 22 | ); 23 | } 24 | 25 | return Parameter.Value; 26 | } finally { 27 | ssmClient.destroy(); 28 | } 29 | 30 | } 31 | }; 32 | 33 | export const getParameterByName = getParameterByNameBuilder(REGION) 34 | -------------------------------------------------------------------------------- /ext/deno/runbook/secrets/one_password_cli.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "jsr:@david/dax"; 2 | 3 | export async function getTokenFromVaultByPath(path: string) { 4 | const hasOp = await $.which("op"); 5 | if (hasOp) { 6 | const result = await $ 7 | .raw`op read "${path}"` 8 | .text(); 9 | return result.trim(); 10 | } else { 11 | throw new Error( 12 | "No way to get slack token from vault. Appears that 1password cli is failing. See 1password cli setup instructions and Try again.", 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ext/deno/runbook/shell.d.ts: -------------------------------------------------------------------------------- 1 | export interface SecureCommandResultParams { 2 | timing: { 3 | duration_ms: number; 4 | start: string; 5 | end: string; 6 | }; 7 | ts: string; 8 | code: number; 9 | command: string; 10 | stdout: string; 11 | stderr: string; 12 | } 13 | 14 | export interface SecureCommandResultRaw { 15 | // stdout is the output of the command with trimEnd() applied 16 | stdout: string; 17 | stderr: string; 18 | code: number; 19 | command: string; 20 | } 21 | 22 | export type RunOptions = { 23 | stdin?: string; 24 | verbose?: boolean; 25 | // TODO(zph): thread this through 26 | //env?: SecureEnvironment; 27 | noThrow?: boolean; 28 | dryRun?: boolean; 29 | }; 30 | 31 | export interface CommandResultStub { 32 | stdout: string; 33 | stderr: string; 34 | code: number; 35 | } 36 | -------------------------------------------------------------------------------- /ext/deno/runbook/shell.ts: -------------------------------------------------------------------------------- 1 | import { 2 | build$, 3 | CommandBuilder, 4 | CommandContext, 5 | CommandResult, 6 | } from "dax"; 7 | import { log, fileHandler, timestampISOLocal } from "./log.ts"; 8 | import { ulid } from "jsr:@std/ulid@1"; 9 | import { CommandResultStub, SecureCommandResultParams } from "./shell.d.ts"; 10 | import { SecureEnvironment } from "./secrets.ts"; 11 | import { RunOptions } from "./shell.d.ts"; 12 | 13 | export const RunOptionsDefaults = { 14 | stdin: "", 15 | verbose: false, 16 | noThrow: false, 17 | dryRun: false, 18 | isSensitive: true, 19 | }; 20 | 21 | export class SecureCommandResult { 22 | id: string; 23 | timing: { 24 | duration_ms: number; 25 | start: string; 26 | end: string; 27 | }; 28 | ts: string; 29 | code: number; 30 | command: string; 31 | #stdout: string; 32 | #stderr: string; 33 | constructor( 34 | { timing, ts, code, command, stdout, stderr }: SecureCommandResultParams, 35 | ) { 36 | this.id = ulid(); 37 | this.timing = timing; 38 | this.ts = ts; 39 | this.code = code; 40 | this.command = command; 41 | this.#stdout = stdout; 42 | this.#stderr = stderr; 43 | } 44 | 45 | stdout(): string { 46 | return this.#stdout; 47 | } 48 | 49 | // deno-lint-ignore no-explicit-any 50 | json(): { [key: string]: any } | [any] { 51 | return JSON.parse(this.#stdout); 52 | } 53 | 54 | stderr(): string { 55 | return this.#stderr; 56 | } 57 | } 58 | 59 | const htmlWarning = ( 60 | message: string, 61 | command: string, 62 | stderr: string, 63 | ) => { 64 | return ` 65 | 66 | 67 | 75 | 76 | 77 | 78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 |
Message: ${message}Command: ${command}Stderr: ${stderr}
86 |
87 | 88 | 89 | `; 90 | }; 91 | 92 | /* 93 | * Safe helper to ensure that this function succeeds 94 | * even when Deno.jupyter is unavailable such as inside 95 | * the `deno repl` 96 | await jupyterBroadcast("display_data", { 97 | data: { "application/json": to_broadcast}, 98 | metadata: {}, 99 | transient: { display_id: "progress" } 100 | }) 101 | */ 102 | 103 | // deno-lint-ignore no-explicit-any 104 | const jupyterBroadcast = async (msg: string, options: any) => { 105 | if (Deno.jupyter != undefined) { 106 | await Deno.jupyter.broadcast(msg, options); 107 | } 108 | }; 109 | 110 | export default class Shell { 111 | #secureEnvironment: SecureEnvironment; 112 | #secrets: { [key: string]: string }; 113 | 114 | constructor(secrets: SecureEnvironment) { 115 | this.#secureEnvironment = secrets; 116 | this.#secrets = {}; 117 | } 118 | 119 | async load(): Promise { 120 | const secrets = await this.#secureEnvironment.load(); 121 | this.#secrets = secrets; 122 | return this; 123 | } 124 | 125 | async execution( 126 | unsafeCommand: string, 127 | { stdin, noThrow }: RunOptions = { 128 | stdin: "", 129 | verbose: false, 130 | noThrow: false, 131 | }, 132 | ): Promise { 133 | const commandBuilder = new CommandBuilder() 134 | .stdout("inheritPiped") 135 | .stderr("inheritPiped") 136 | .env(this.#secrets); 137 | 138 | if (stdin == null) { 139 | stdin = ""; 140 | } 141 | 142 | const $$ = build$({ commandBuilder }); 143 | 144 | try { 145 | const safeCommand = this.replaceSecrets(unsafeCommand); 146 | // Intentionally print to console for the sake of jupyter display 147 | console.log(`> ${safeCommand}`); 148 | const executor = $$.raw`${unsafeCommand}`.stdinText(stdin).noThrow( 149 | noThrow, 150 | ); 151 | const rawResponse = await executor; 152 | return this.redactor(unsafeCommand, rawResponse); 153 | } catch (e) { 154 | log.error("SECURE_EXECUTION", e); 155 | const a: CommandResultStub = { 156 | stdout: "", 157 | stderr: e.message, 158 | code: 1, 159 | }; 160 | return this.redactor(unsafeCommand, a); 161 | } 162 | } 163 | 164 | // Pass in per-run env values that are merged over #secrets 165 | // Replaces plaintext secrets with their secret names 166 | replaceSecrets(text: string): string { 167 | for (const [k, v] of Object.entries(this.#secrets)) { 168 | text = text.replaceAll(v, `$${k}`); 169 | } 170 | return text; 171 | } 172 | 173 | redactor( 174 | args: string, 175 | result: CommandResult | CommandResultStub, 176 | ): SecureCommandResult { 177 | const { stdout, stderr, code } = result; 178 | 179 | return new SecureCommandResult({ 180 | timing: { 181 | duration_ms: 0, 182 | start: "", 183 | end: "", 184 | }, 185 | ts: "", 186 | code, 187 | command: this.replaceSecrets(args), 188 | stdout: this.replaceSecrets(stdout).trimEnd(), 189 | stderr: this.replaceSecrets(stderr), 190 | }); 191 | } 192 | 193 | async run( 194 | cmd: string, 195 | options: RunOptions = RunOptionsDefaults, 196 | ): Promise { 197 | const start = (new Date()).toISOString(); 198 | performance.mark("command_start"); 199 | const r: SecureCommandResult = await this.execution(cmd, options); 200 | // Unset cmd and options to avoid logging the command in the output due to containing 201 | // secrets 202 | cmd = ""; 203 | 204 | // Variables from here forward must be sanitized to avoid secrets appearing in logs 205 | performance.mark("command_end"); 206 | const end = (new Date()).toISOString(); 207 | const measure = performance.measure( 208 | "command", 209 | "command_start", 210 | "command_end", 211 | ); 212 | const timing = { 213 | duration_ms: Math.round(measure.duration * 1000) / 1000, 214 | start, 215 | end, 216 | }; 217 | r.timing = timing; 218 | r.ts = timestampISOLocal(new Date()); 219 | 220 | if (options.verbose) { 221 | await jupyterBroadcast("display_data", { 222 | data: { "application/json": r }, 223 | metadata: {}, 224 | transient: { display_id: "progress" }, 225 | }); 226 | } 227 | log.info("SHELL", r); 228 | 229 | // Flush the file handler to ensure that the logs are written to disk 230 | fileHandler.flush(); 231 | if (r.code != 0) { 232 | log.error("EXIT DUE TO NON_ZERO EXIT CODE", { id: r.id }); 233 | fileHandler.flush(); 234 | await jupyterBroadcast("display_data", { 235 | data: { 236 | "text/html": htmlWarning( 237 | `Exiting due to nonzero exit code`, 238 | r.command, 239 | r.stderr(), 240 | ), 241 | }, 242 | metadata: {}, 243 | transient: { display_id: "progress" }, 244 | }); 245 | if (!options.noThrow) { 246 | throw new Error("Exiting due to non-zero exit code"); 247 | } 248 | } 249 | 250 | // Return broadcast including stdout / stderr to operator 251 | return r; 252 | } 253 | 254 | /* 255 | * bastion(command, {stdin: "textual representation of a config file \n more lines"}) 256 | * provides a helper mechanism for two scenarios 257 | * - using command on bastion normally via ssh/ssm/teleport 258 | * - using shell on bastion in cases where you need to have a config file on bastion but don't want it on disk because of sensitive data 259 | * this helper avoids the complexity and risk of encrypting and passing around keys because the config file can exist exclusively in memory 260 | * bastion fn will pipe the text of the config file in as stdin to ssh which will be accessible as /dev/stdin file descriptor 261 | 262 | > await bastion(`uname -a`) 263 | > await bastion(`head -2 /dev/stdin`, {stdin: "textual representation of a config file \n more lines"}) 264 | > await bastion(`a-command -config /dev/stdin`, {stdin: "textual representation of a config file \n more lines"}) 265 | */ 266 | bastion = async ( 267 | cmd: string, 268 | options: RunOptions = RunOptionsDefaults, 269 | ): Promise => { 270 | throw Error(`Not implemented, extend Shell class with a custom implementation. this.run is available if the command starts as a local shell`) 271 | 272 | // Example but realize you'll need to handle quoting levels 273 | cmd = `ssh bastion "${cmd}"` 274 | 275 | // Note that if we need to pass binary values it should be as Uint8Array and with `stdin(array)` not `stdinText(text)` 276 | return await this.run(cmd, options); 277 | }; 278 | } 279 | -------------------------------------------------------------------------------- /ext/deno/runbook/shell_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { SecureEnvironment } from "./secrets.ts"; 3 | import Shell from "./shell.ts"; 4 | 5 | Deno.test("redaction test", async () => { 6 | const se = new SecureEnvironment({ 7 | "SERVICE_PASSWORD": async () => "AAAAAAAAAAAA", 8 | "SERVICE_USER": async () => "username", 9 | }); 10 | 11 | const sh = await (new Shell(se)).load(); 12 | assertEquals( 13 | sh.replaceSecrets("AAAAAAAAAAAA username"), 14 | `$SERVICE_PASSWORD $SERVICE_USER`, 15 | ); 16 | }); 17 | 18 | Deno.test("validation sanitization", async function (t): Promise { 19 | const se = new SecureEnvironment({ 20 | "SERVICE_PASSWORD": async () => "AAAAAAAAAAAA", 21 | "SERVICE_USER": async () => "username", 22 | }); 23 | 24 | const sh = await (new Shell(se)).load(); 25 | const result = await sh.run("echo $SERVICE_PASSWORD username"); 26 | const expected = 27 | `$SERVICE_PASSWORD $SERVICE_USER`; 28 | await assertEquals(expected, result.stdout()); 29 | }); 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc2.3", 3 | "devDependencies": { 4 | "@release-it/bumper": "^6.0.1", 5 | "release-it": "^17.0.3" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "runbook" 3 | # Managed via runbook.__version__ and https://pypi.org/project/poetry-version-plugin/ 4 | version = "0" 5 | description = "Runbook lib and cli" 6 | requires-python = ">=3.11" 7 | authors = [ 8 | { name = "Zander Hill", email = "zander@xargs.io" }, 9 | ] 10 | license = "MIT" 11 | readme = "README.md" 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.11" 15 | shx = "^0.4.2" 16 | rich = "^13.9.4" 17 | # papermill = "^2.5.0" 18 | # Used until upstreaming typescript into papermill 19 | papermill = { git = "https://github.com/zph/papermill", branch = "main" } 20 | jupyter = "^1.0.0" 21 | click = "^8.1.7" 22 | bash-kernel = "^0.9.3" 23 | jupyterlab-execute-time = "^3.1.0" 24 | jupytext = "^1.16.1" 25 | nbformat = "^5.9.2" 26 | nbconvert = "^7.14.1" 27 | pyyaml = "^6.0.1" 28 | traitlets = "^5.14.1" 29 | ipywidgets = "^8.1.1" 30 | pre-commit = "^3.6.0" 31 | python-ulid = "^2.2.0" 32 | slack-sdk = "^3.26.2" 33 | jupyterlab = "^4.1.5" 34 | nbdime = "^4.0.1" 35 | 36 | 37 | [tool.poetry.group.dev.dependencies] 38 | pandas = "^2.1.4" 39 | pyarrow = "^14.0.2" 40 | matplotlib = "^3.8.2" 41 | pytest = "^7.4.4" 42 | 43 | [build-system] 44 | requires = ["poetry-core"] 45 | build-backend = "poetry.core.masonry.api" 46 | 47 | [dependency-groups] 48 | dev = [ 49 | "pytest>=8.3.4", 50 | "ruff>=0.9.5", 51 | ] 52 | 53 | [tool.poetry.scripts] 54 | runbook = "runbook.cli:cli" 55 | # For the sake of installation accessibility for deno notebooks 56 | jupyter = "jupyter_core.command:main" 57 | 58 | [tool.poetry-version-plugin] 59 | source = "init" 60 | 61 | # https://github.com/python-poetry/poetry/issues/927 62 | # [tool.poetry.plugins."papermill.translators"] 63 | # "typescript" = "translators:runbook.translators.TypescriptTranslator" 64 | 65 | [tool.ruff] 66 | line-length = 88 # Same default as Black (adjust if needed) 67 | 68 | [tool.ruff.format] 69 | quote-style = "double" # Choose "single" or "double" 70 | indent-style = "space" 71 | line-ending = "lf" 72 | 73 | [tool.ruff.lint] 74 | select = ["I"] 75 | -------------------------------------------------------------------------------- /runbook/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.0-rc2.3" 2 | -------------------------------------------------------------------------------- /runbook/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | 5 | from runbook.cli.commands import ( 6 | check, 7 | convert, 8 | create, 9 | diff, 10 | edit, 11 | init, 12 | list, 13 | plan, 14 | review, 15 | run, 16 | show, 17 | version, 18 | ) 19 | 20 | CONTEXT_SETTINGS = dict(auto_envvar_prefix="RUNBOOK") 21 | 22 | 23 | @click.group() 24 | @click.option( 25 | "--cwd", 26 | envvar=["RUNBOOK_WORKING_DIR", "WORKING_DIR"], 27 | default=os.curdir, 28 | type=click.Path(exists=True, resolve_path=True, dir_okay=True), 29 | help="Directory for operations (normally at root above runbooks, ie ../.runbook.yaml) and can be set with RUNBOOK_WORKING_DIR or WORKING_DIR environment variables", 30 | ) 31 | @click.pass_context 32 | def cli(ctx, cwd): 33 | os.chdir(cwd) 34 | ctx.ensure_object(dict) 35 | ctx.obj["WORKING_DIR"] = cwd 36 | 37 | 38 | cli.add_command(init) 39 | cli.add_command(plan) 40 | cli.add_command(edit) 41 | cli.add_command(create) 42 | cli.add_command(convert) 43 | cli.add_command(check) 44 | cli.add_command(diff) 45 | cli.add_command(run) 46 | cli.add_command(review) 47 | cli.add_command(version) 48 | cli.add_command(list) 49 | cli.add_command(show) 50 | cli.name = "runbook" 51 | cli 52 | 53 | 54 | def main(**args): 55 | cli(**args, **CONTEXT_SETTINGS) 56 | 57 | 58 | if __name__ == "main": 59 | import sys 60 | 61 | main(sys.arv) 62 | -------------------------------------------------------------------------------- /runbook/cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from runbook.cli.commands.check import check 2 | from runbook.cli.commands.convert import convert 3 | from runbook.cli.commands.create import create 4 | from runbook.cli.commands.diff import diff 5 | from runbook.cli.commands.edit import edit 6 | from runbook.cli.commands.init import init 7 | from runbook.cli.commands.list import list 8 | from runbook.cli.commands.plan import plan 9 | from runbook.cli.commands.review import review 10 | from runbook.cli.commands.run import run 11 | from runbook.cli.commands.show import show 12 | from runbook.cli.commands.version import version 13 | -------------------------------------------------------------------------------- /runbook/cli/commands/check.py: -------------------------------------------------------------------------------- 1 | # metadata.kernelspec.name = deno || python3 2 | 3 | import io 4 | import json 5 | import sys 6 | from contextlib import redirect_stderr, redirect_stdout 7 | from os import path 8 | 9 | import click 10 | 11 | from runbook.cli.completions import EditableNotebook 12 | from runbook.cli.validators import validate_runbook_file_path 13 | 14 | 15 | @click.command() 16 | @click.argument( 17 | "filename", 18 | type=EditableNotebook(file_okay=True), 19 | callback=validate_runbook_file_path, 20 | ) 21 | @click.option( 22 | "-c", 23 | "--command", 24 | default=None, 25 | help="Run a notebook through this custom command, ie 'deno check {}' where the {} is the temporary filename.", 26 | ) 27 | @click.pass_context 28 | def check(ctx, filename, command): 29 | """Check the language validity and formatting of a runbook. 30 | 31 | This command validates the syntax and formatting of runbook cells based on the kernel 32 | specified in the notebook's metadata. By default: 33 | - For Python kernels: uses 'black' to check code formatting 34 | - For Deno kernels: uses 'deno check' to validate TypeScript/JavaScript 35 | 36 | FILENAME: Path to the runbook file to check. 37 | 38 | Options: 39 | --command, -c: Specify a custom validation command. Use {} as a placeholder for the 40 | filename (e.g., 'mycheck {} --strict'). This overrides the default 41 | checker for the kernel. 42 | 43 | Exit codes: 44 | 0: Check passed successfully 45 | Non-zero: Check failed, see error output for details 46 | """ 47 | full_path = path.abspath(filename) 48 | content = None 49 | with open(full_path, "r") as f: 50 | txt = f.read() 51 | content = json.loads(txt) 52 | 53 | # Default 54 | language_specific_tool = "black --diff {}" 55 | if command: 56 | language_specific_tool = command 57 | else: 58 | kernel_name = content["metadata"]["kernelspec"]["name"] 59 | if kernel_name == "deno": 60 | language_specific_tool = "deno check {}" 61 | 62 | argv = ["--check", language_specific_tool, full_path] 63 | # Lazily loaded for performance 64 | from jupytext.cli import jupytext as jupytext_main 65 | 66 | stdout = io.StringIO() 67 | stderr = io.StringIO() 68 | 69 | result = None 70 | with redirect_stdout(stdout), redirect_stderr(stderr): 71 | result = jupytext_main(args=argv) 72 | 73 | stdout_content = stdout.getvalue() 74 | stderr_content = stderr.getvalue() 75 | 76 | click.echo(f"Checked {full_path}") 77 | if result is not None and result != 0: 78 | if stderr_content: 79 | click.echo(f"Error: {stderr_content}", err=True) 80 | if stdout_content: 81 | click.echo(f"Output: {stdout_content}") 82 | sys.exit(result) 83 | -------------------------------------------------------------------------------- /runbook/cli/commands/convert.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | import click 4 | 5 | from runbook.cli.completions import EditableNotebook 6 | from runbook.cli.validators import validate_runbook_file_path 7 | 8 | 9 | @click.command() 10 | @click.argument( 11 | "filename", 12 | type=EditableNotebook(file_okay=True), 13 | callback=validate_runbook_file_path, 14 | ) 15 | @click.argument( 16 | "output", 17 | type=click.Path(exists=False, file_okay=True, writable=True), 18 | ) 19 | @click.pass_context 20 | def convert(ctx, filename, output): 21 | """Convert a runbook between different formats 22 | 23 | This command converts notebooks between various formats using jupytext. 24 | Supported conversions include: 25 | - .ipynb to .py (Python script) 26 | - .ipynb to .ts (Deno notebook) 27 | - .ipynb to .md (Markdown) 28 | - And other formats supported by jupytext 29 | 30 | FILENAME: Path to the source notebook file to convert. 31 | OUTPUT: Destination path for the converted file. The format is determined 32 | by the file extension. 33 | 34 | Examples: 35 | runbook convert notebook.ipynb script.py # Convert to Python script 36 | runbook convert notebook.ipynb notebook.ts # Convert to Deno notebook 37 | runbook convert notebook.ipynb notebook.md # Convert to Markdown 38 | 39 | The conversion preserves cell metadata, notebook metadata, and execution outputs 40 | where applicable. 41 | """ 42 | # Must override argv because it's used in launch instance and there isn't a way 43 | # to pass via argument in ExtensionApp.lauch_instance 44 | # TODO: 45 | argv = [path.abspath(filename), "--output", output] 46 | # Lazily loaded for performance 47 | from jupytext.cli import jupytext as jupytext_main 48 | 49 | jupytext_main(args=argv) 50 | # TODO: insert post run hooks here 51 | -------------------------------------------------------------------------------- /runbook/cli/commands/create.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | import click 4 | 5 | from runbook.cli.lib import nbconvert_launch_instance 6 | from runbook.cli.validators import ( 7 | validate_create_language, 8 | validate_has_notebook_extension, 9 | validate_template, 10 | ) 11 | 12 | 13 | @click.command() 14 | @click.argument( 15 | "filename", 16 | type=click.Path(exists=False, file_okay=True), 17 | callback=validate_has_notebook_extension, 18 | ) 19 | @click.option( 20 | "-t", 21 | "--template", 22 | envvar="TEMPLATE", 23 | default="./runbooks/binder/_template-deno.ipynb", 24 | type=click.Path(exists=True, file_okay=True), 25 | callback=validate_template, 26 | help="Path to the template file to use", 27 | ) 28 | # TODO: switch to language and template defaulting to Deno 29 | # based on operational experience at work 30 | @click.option( 31 | "-l", 32 | "--language", 33 | envvar="LANGUAGE", 34 | default="deno", 35 | callback=validate_create_language, 36 | help="Language to use for the runbook", 37 | ) 38 | @click.pass_context 39 | def create(ctx, filename, template, language): 40 | """Create a new runbook from a template 41 | 42 | This command creates a new runbook using a specified template 43 | or language preset. The new runbook will be created in the runbooks/binder directory. 44 | 45 | FILENAME: Name for the new runbook file (e.g., 'maintenance-task.ipynb'). 46 | Should be a basename only, without directory path. 47 | 48 | Options: 49 | --template, -t: Path to a custom template runbook to use as a base. 50 | Can be set via TEMPLATE environment variable. 51 | Default: ./runbooks/binder/_template-deno.ipynb 52 | 53 | --language, -l: Shortcut to use a predefined language template. 54 | Can be set via LANGUAGE environment variable. 55 | Default: deno 56 | 57 | Examples: 58 | runbook create maintenance-task.ipynb # Creates using default Deno template 59 | runbook create task.ipynb -l python # Creates using Python template 60 | runbook create task.ipynb -t custom-template.ipynb # Creates from custom template 61 | 62 | The command will create the notebook and display the edit command to open it. 63 | """ 64 | 65 | if language: 66 | template = language 67 | if path.basename(filename) != filename: 68 | raise click.UsageError( 69 | "Supplied filename included more than a basename, should look like 'maintenance-operation.ipynb'" 70 | ) 71 | # TODO: remove hardcoding of folder outer name and rely on config file 72 | path.join("runbooks", "binder", filename) 73 | argv = [ 74 | template, 75 | "--to", 76 | "notebook", 77 | "--output", 78 | filename, 79 | "--output-dir", 80 | path.join("runbooks", "binder"), 81 | ] 82 | 83 | nbconvert_launch_instance(argv, clear_output=True) 84 | 85 | click.echo( 86 | click.style( 87 | f"$> runbook edit ./runbooks/binder/{filename}", fg="green", bold=True 88 | ) 89 | ) 90 | -------------------------------------------------------------------------------- /runbook/cli/commands/diff.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | import click 4 | 5 | from runbook.cli.completions import EditableNotebook 6 | from runbook.cli.validators import validate_runbook_file_path 7 | 8 | 9 | @click.command() 10 | @click.argument( 11 | "notebook_1", 12 | type=EditableNotebook(file_okay=True), 13 | callback=validate_runbook_file_path, 14 | ) 15 | @click.argument( 16 | "notebook_2", 17 | type=EditableNotebook(file_okay=True), 18 | callback=validate_runbook_file_path, 19 | ) 20 | @click.pass_context 21 | def diff(ctx, notebook_1, notebook_2): 22 | """Compare two runbooks and show their differences 23 | 24 | This command uses nbdime to display a detailed comparison between two runbooks. 25 | 26 | Arguments: 27 | NOTEBOOK_1: Path to the first runbook for comparison 28 | NOTEBOOK_2: Path to the second runbook for comparison 29 | 30 | Examples: 31 | runbook diff notebook1.ipynb notebook2.ipynb # Compare two notebooks 32 | runbook diff original.ipynb modified.ipynb # Show changes between versions 33 | 34 | The diff output will be displayed in a terminal-friendly format, with: 35 | - Added content in green 36 | - Removed content in red 37 | - Modified content showing both versions 38 | """ 39 | argv = [path.abspath(notebook_1), path.abspath(notebook_2)] 40 | from nbdime import nbdiffapp 41 | 42 | nbdiffapp.main(args=argv) 43 | -------------------------------------------------------------------------------- /runbook/cli/commands/edit.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | import click 4 | 5 | from runbook.cli.completions import EditableNotebook 6 | from runbook.cli.validators import validate_runbook_file_path 7 | 8 | 9 | @click.command() 10 | @click.argument( 11 | "filename", 12 | type=EditableNotebook(file_okay=True), 13 | callback=validate_runbook_file_path, 14 | ) 15 | @click.pass_context 16 | def edit(ctx, filename): 17 | """Edit an existing runbook""" 18 | # Must override argv because it's used in launch instance and there isn't a way 19 | # to pass via argument in ExtensionApp.lauch_instance 20 | # TODO: 21 | argv = [path.abspath(filename)] 22 | # Lazily loaded for performance 23 | from notebook.app import JupyterNotebookApp 24 | 25 | JupyterNotebookApp.launch_instance(argv=argv) 26 | # TODO: insert post run hooks here 27 | -------------------------------------------------------------------------------- /runbook/cli/commands/init.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import click 5 | 6 | from runbook import __version__ as VERSION 7 | from runbook.template import TEMPLATES 8 | 9 | RUNBOOK_CONFIG = {"version": 1, "library_version": VERSION, "directory": None} 10 | 11 | 12 | @click.command() 13 | @click.option( 14 | "-d", 15 | "--directory", 16 | envvar="DIRECTORY", 17 | default="runbooks", 18 | type=click.Path(exists=False, dir_okay=True), 19 | help="Path to the runbook directory", 20 | ) 21 | @click.option( 22 | "-s", 23 | "--skip-confirmation", 24 | envvar="SKIP_CONFIRMATION", 25 | default=False, 26 | type=click.BOOL, 27 | help="Skip confirmation prompt", 28 | ) 29 | @click.pass_context 30 | def init(ctx, directory, skip_confirmation): 31 | """Initialize a folder as a runbook repository""" 32 | 33 | click.echo( 34 | "Command creates ./runbooks folder with the structure needed for runbook" 35 | ) 36 | click.echo("In pseudo code it does the following") 37 | click.echo(f"""in {ctx.obj["WORKING_DIR"]}""") 38 | click.echo( 39 | f""" 40 | mkdir ./{directory} 41 | mkdir ./{directory}/binder 42 | mkdir ./{directory}/runs 43 | touch ./{directory}/.runbook.json 44 | touch ./{directory}/_template-python.ipynb 45 | touch ./{directory}/_template-deno.ipynb 46 | """ 47 | ) 48 | 49 | if not skip_confirmation: 50 | click.confirm(click.style("Proceed?", fg="red", bold=True)) 51 | Path(f"./{directory}/binder").mkdir(parents=True, exist_ok=True) 52 | Path(f"./{directory}/runs").mkdir(parents=True, exist_ok=True) 53 | cfg = {**RUNBOOK_CONFIG, **{"directory": directory}} 54 | with open(f"./{directory}/.runbook.json", "w") as f: 55 | f.write(json.dumps(cfg)) 56 | with open(f"./{directory}/binder/_template-python.ipynb", "w") as f: 57 | f.write(TEMPLATES["python"]) 58 | 59 | with open(f"./{directory}/binder/_template-deno.ipynb", "w") as f: 60 | f.write(TEMPLATES["deno"]) 61 | -------------------------------------------------------------------------------- /runbook/cli/commands/list.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from runbook.cli.commands.show import get_notebook_header 4 | 5 | 6 | def get_notebook_title(notebook_path): 7 | header = get_notebook_header(notebook_path) 8 | if header: 9 | maybe_title = [line for line in header.split("\n") if line.startswith("# ")] 10 | if maybe_title: 11 | return maybe_title[0][2:].strip() 12 | return None 13 | 14 | 15 | @click.command() 16 | @click.pass_context 17 | def list(ctx): 18 | """List runbooks""" 19 | import glob 20 | 21 | from rich import print as rprint 22 | 23 | # Find all .ipynb files in current directory 24 | runbooks = glob.glob("**/*.ipynb", recursive=True) 25 | templates = [] 26 | runs = [] 27 | for runbook in runbooks: 28 | if "binder" in runbook: 29 | templates.append(runbook) 30 | else: 31 | runs.append(runbook) 32 | 33 | if not templates and not runs: 34 | rprint("[yellow]No runbooks found in current directory[/yellow]") 35 | return 36 | 37 | # Print found runbooks in a nice format 38 | rprint("\n[bold blue]Templates:[/bold blue]") 39 | for runbook in sorted(templates): 40 | title = get_notebook_title(runbook) 41 | rprint(f"📔 {title} - {runbook}") 42 | 43 | rprint("\n[bold blue]Runs:[/bold blue]") 44 | for runbook in sorted(runs): 45 | title = get_notebook_title(runbook) 46 | rprint(f"📔 {title} - {runbook}") 47 | -------------------------------------------------------------------------------- /runbook/cli/commands/plan.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import json 3 | import os 4 | import subprocess 5 | from datetime import datetime 6 | from os import path 7 | from pathlib import Path 8 | 9 | import click 10 | import nbformat 11 | import papermill as pm 12 | 13 | from runbook.cli.lib import nbconvert_launch_instance 14 | from runbook.cli.validators import validate_plan_params, validate_runbook_file_path 15 | from runbook.constants import RUNBOOK_METADATA 16 | 17 | 18 | def get_notebook_language(notebook_path: str) -> str: 19 | """ 20 | Determine the language of the notebook by checking the first code cell's metadata. 21 | Returns 'python', 'typescript', or 'unknown' 22 | """ 23 | nb = nbformat.read(notebook_path, as_version=4) 24 | for cell in nb.cells: 25 | if cell.cell_type == "code": 26 | # Check kernel info 27 | if "kernelspec" in nb.metadata: 28 | kernel_name = nb.metadata.kernelspec.name.lower() 29 | if "python" in kernel_name: 30 | return "python" 31 | elif "typescript" in kernel_name or "ts" in kernel_name: 32 | return "typescript" 33 | # Check language info 34 | if "language_info" in nb.metadata: 35 | language = nb.metadata.language_info.name.lower() 36 | if "python" in language: 37 | return "python" 38 | elif "typescript" in language or "ts" in language: 39 | return "typescript" 40 | return "unknown" 41 | 42 | 43 | def get_parser_by_language(language: str): 44 | if language == "typescript": 45 | return json.loads 46 | elif language == "python": 47 | return ast.literal_eval 48 | else: 49 | # Default to json.loads for unknown languages 50 | return json.loads 51 | 52 | 53 | @click.command() 54 | @click.argument( 55 | "input", 56 | type=click.Path(file_okay=True), 57 | callback=validate_runbook_file_path, 58 | ) 59 | # TODO allow for specifying output filename to allow for easier naming 60 | @click.option( 61 | "-e", 62 | "--embed", 63 | type=click.Path(exists=True), 64 | multiple=True, 65 | help="Path to file(s) to embed in the runbook output directory", 66 | ) 67 | @click.option( 68 | "-p", 69 | "--params", 70 | default={}, 71 | type=click.UNPROCESSED, 72 | callback=validate_plan_params, 73 | help="Parameters to inject into the runbook in json object format where the key is the parameter name and the value is the parameter value", 74 | ) 75 | @click.option( 76 | "-i", 77 | "--identifier", 78 | default="", 79 | type=click.STRING, 80 | help="Optional identifier to append to the output filename", 81 | ) 82 | @click.option( 83 | "-p", 84 | "--prompter", 85 | default="", 86 | type=click.Path(file_okay=True), 87 | help="[Experimental] Path to a prompter script that will be used to gather parameters from the user", 88 | ) 89 | @click.pass_context 90 | def plan(ctx, input, embed, identifier="", params={}, prompter=""): 91 | """Prepares the runbook for execution by injecting parameters. Doesn't run runbook.""" 92 | import shutil 93 | 94 | date = datetime.now().date() 95 | basename = path.basename(input) 96 | basename_without_ext = basename[0:-6] 97 | output_basename_without_ext = basename_without_ext 98 | if len(identifier) > 0: 99 | output_basename_without_ext = "-".join( 100 | [output_basename_without_ext, identifier] 101 | ) 102 | output = "-".join([str(date), output_basename_without_ext]) 103 | output_folder = f"./runbooks/runs/{output}" 104 | full_output = f"{output_folder}/{output_basename_without_ext}.ipynb" 105 | 106 | runbook_param_injection = { 107 | RUNBOOK_METADATA: { 108 | "RUNBOOK_FOLDER": output_folder, 109 | "RUNBOOK_FILE": full_output, 110 | "RUNBOOK_SOURCE": input, 111 | "CREATED_AT": str(datetime.utcnow()), 112 | "CREATED_BY": os.environ["USER"], 113 | } 114 | } 115 | 116 | # TODO: add test cases for auto-planning 117 | # As of 2025 Jan it's manual regression testing 118 | if len(params) == 0 or prompter: 119 | inferred_params = pm.inspect_notebook(input) 120 | notebook_language = get_notebook_language(input) 121 | value_parser = get_parser_by_language(notebook_language) 122 | # Inferred_type_name is language specific 123 | formatted_params = {} 124 | for key, value in inferred_params.items(): 125 | if key != RUNBOOK_METADATA: 126 | default = value["default"].rstrip(";") 127 | typing = value["inferred_type_name"] or "" 128 | help = value["help"] or "" 129 | formatted_params[key] = { 130 | "default": default, 131 | "typing": typing, 132 | "help": help, 133 | } 134 | 135 | if prompter: 136 | # Input format: params: {'server': {'default': '"main.xargs.io"', 'typing': 'string', 'help': ''}, 'arg': {'default': '1', 'typing': 'number', 'help': ''}, 'anArray': {'default': '["a", "b"]', 'typing': 'string[]', 'help': 'normally a / b'}, '__RUNBOOK_METADATA__': {'default': '{}', 'typing': 'None', 'help': ''}} 137 | # Run prompter with inferred params passed via stdin 138 | result = subprocess.run( 139 | [prompter], 140 | input=json.dumps(formatted_params), 141 | capture_output=True, 142 | text=True, 143 | ) 144 | # Response format: params: {'server': '"main.xargs.io"', 'arg': '1', 'anArray': '["a", "b"]'} 145 | params = json.loads(result.stdout.strip()) 146 | else: 147 | for key, value in formatted_params.items(): 148 | parsed_value = click.prompt( 149 | f"""Enter value for {key} {value["typing"]} {value["help"]}""", 150 | default=value["default"], 151 | value_proc=value_parser, 152 | ) 153 | params[key] = parsed_value 154 | 155 | injection_params = {**runbook_param_injection, **params} 156 | 157 | if not Path(output_folder).exists(): 158 | os.makedirs(output_folder, exist_ok=True) 159 | 160 | pm.execute_notebook( 161 | input_path=input, 162 | output_path=full_output, 163 | parameters=injection_params, 164 | prepare_only=True, 165 | ) 166 | 167 | argv = [ 168 | "--inplace", 169 | full_output, 170 | ] 171 | 172 | nbconvert_launch_instance(argv, clear_output=True) 173 | 174 | for f in embed: 175 | shutil.copyfile(src=f, dst=f"{output_folder}/{path.basename(f)}") 176 | 177 | cmd = click.style(f"$> runbook run {full_output}", fg="green", bold=True) 178 | click.echo(click.style(f"Run your new runbook instance with:\n\t{cmd}")) 179 | -------------------------------------------------------------------------------- /runbook/cli/commands/review.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.command() 5 | @click.pass_context 6 | def review(ctx): 7 | """[Unimplemented] Entrypoint for reviewing runbook""" 8 | raise RuntimeError("Not Implemented") 9 | -------------------------------------------------------------------------------- /runbook/cli/commands/run.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from runbook.cli.validators import validate_planned_runbook_file_path 4 | 5 | 6 | @click.command() 7 | @click.argument( 8 | "filename", 9 | type=click.Path(file_okay=True), 10 | callback=validate_planned_runbook_file_path, 11 | ) 12 | @click.option( 13 | "--output", 14 | type=click.Path(file_okay=True), 15 | default=None, 16 | help="Path to the output file", 17 | ) 18 | @click.option( 19 | "--interactive/--no-interactive", 20 | default=True, 21 | help="Run the notebook in interactive mode or EXPERIMENTAL non-interactive mode", 22 | ) 23 | @click.pass_context 24 | def run(ctx, filename, output, interactive): 25 | """Run a runbook""" 26 | if interactive: 27 | argv = [filename] 28 | 29 | # Lazily loaded for performance 30 | from notebook.app import JupyterNotebookApp 31 | 32 | JupyterNotebookApp.launch_instance(argv=argv) 33 | else: 34 | if not output: 35 | raise click.BadOptionUsage( 36 | "--output", "--output is required when --interactive is false" 37 | ) 38 | import papermill as pm 39 | 40 | pm.execute_notebook( 41 | input_path=filename, 42 | output_path=output, 43 | ) 44 | print(f"Output written to {output}") 45 | -------------------------------------------------------------------------------- /runbook/cli/commands/show.py: -------------------------------------------------------------------------------- 1 | import click 2 | import nbformat 3 | import papermill as pm 4 | from rich.console import Console 5 | from rich.table import Table 6 | 7 | from runbook.cli.commands.plan import get_notebook_language 8 | from runbook.cli.validators import validate_runbook_file_path 9 | from runbook.constants import RUNBOOK_METADATA 10 | 11 | 12 | def get_notebook_header(notebook_path): 13 | """Get the title (H1) and description (H2) from the notebook's markdown cells. 14 | 15 | Args: 16 | notebook_path (str): Path to the notebook file 17 | 18 | Returns: 19 | str: The header of the first markdown cell, or None if not found 20 | """ 21 | nb = nbformat.read(notebook_path, as_version=4) 22 | header = None 23 | 24 | for cell in nb.cells: 25 | if cell.cell_type == "markdown": 26 | header = cell.source 27 | break 28 | 29 | return header 30 | 31 | 32 | @click.command() 33 | @click.argument( 34 | "runbook", type=click.Path(file_okay=True), callback=validate_runbook_file_path 35 | ) 36 | @click.pass_context 37 | def show(ctx, runbook): 38 | """Show runbook parameters and metadata""" 39 | console = Console() 40 | inferred_params = pm.inspect_notebook(runbook) 41 | notebook_language = get_notebook_language(runbook) 42 | 43 | # Print runbook info with styling 44 | console.print(f"\n[bold blue]Runbook:[/] {runbook}") 45 | console.print(f"[bold blue]Language:[/] {notebook_language}\n") 46 | 47 | # Get and print title and description if available 48 | header = get_notebook_header(runbook) 49 | if header: 50 | console.print(f"[bold blue]Header:\n[/]{header}\n") 51 | 52 | # Create and populate parameters table 53 | table = Table(show_header=True, header_style="bold blue") 54 | table.add_column("Parameter") 55 | table.add_column("Default Value") 56 | table.add_column("Type") 57 | table.add_column("Help") 58 | 59 | for param, value in inferred_params.items(): 60 | default = value["default"].rstrip(";") 61 | typing = value["inferred_type_name"] or "" 62 | help_text = value["help"] or "" 63 | if param != RUNBOOK_METADATA: 64 | table.add_row(param, default, typing, help_text) 65 | 66 | console.print(table) 67 | -------------------------------------------------------------------------------- /runbook/cli/commands/version.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from runbook import __version__ as VERSION 4 | 5 | 6 | @click.command() 7 | @click.pass_context 8 | def version(ctx): 9 | """Display version information about runbook""" 10 | 11 | click.echo(f"Runbook version: {VERSION}") 12 | -------------------------------------------------------------------------------- /runbook/cli/completions.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | 3 | import click 4 | from click.shell_completion import CompletionItem 5 | 6 | 7 | def runnable_shell_complete(self, ctx, param, incomplete): 8 | pattern = f"**/{incomplete}*.ipynb" 9 | if incomplete == "": 10 | pattern = "**/*.ipynb" 11 | 12 | return [ 13 | CompletionItem(name) for name in glob(pattern, recursive=True) if "runs" in name 14 | ] 15 | 16 | 17 | RunnableNotebook = click.Path 18 | RunnableNotebook.shell_complete = runnable_shell_complete 19 | 20 | 21 | def editable_shell_complete(self, ctx, param, incomplete): 22 | pattern = f"**/{incomplete}*.ipynb" 23 | if incomplete == "": 24 | pattern = "**/*.ipynb" 25 | 26 | return [CompletionItem(name) for name in glob(pattern, recursive=True)] 27 | 28 | 29 | EditableNotebook = click.Path 30 | EditableNotebook.shell_complete = editable_shell_complete 31 | -------------------------------------------------------------------------------- /runbook/cli/lib.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import sys 3 | from io import StringIO 4 | 5 | from ulid import ULID 6 | 7 | 8 | def sha256sum(filename): 9 | with open(filename, "rb", buffering=0) as f: 10 | return hashlib.file_digest(f, "sha256").hexdigest() 11 | 12 | 13 | # 10 is a pure timestamp to millisecond precision 14 | # 11 allows or a few generations per ms without collisions 15 | # if needing collision resistance greater than casual and low generation rate 16 | # use ULID() directly 17 | def ts_id(length=10): 18 | return str(ULID())[0:length] 19 | 20 | 21 | # Suppresses nbconvert output 22 | def nbconvert_launch_instance(argv, clear_output=True): 23 | # Imported here to avoid performance hit of importing it 24 | from nbconvert.nbconvertapp import NbConvertApp 25 | 26 | if clear_output: 27 | argv.insert(0, "--ClearOutputPreprocessor.enabled=True") 28 | stdout = sys.stdout 29 | stderr = sys.stderr 30 | sys.stdout = StringIO() 31 | sys.stderr = StringIO() 32 | try: 33 | NbConvertApp().launch_instance(argv=argv) 34 | except Exception as e: 35 | print(sys.stdout.getvalue()) 36 | print(sys.stderr.getvalue()) 37 | raise e 38 | finally: 39 | # Restore stdout/stderr 40 | sys.stdout = stdout 41 | sys.stderr = stderr 42 | -------------------------------------------------------------------------------- /runbook/cli/validators.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import json 3 | from os import path 4 | from pathlib import Path 5 | 6 | import click 7 | 8 | 9 | # TODO: ensure no duplicate extension here 10 | def validate_template(ctx, param, value): 11 | try: 12 | ext = path.splitext(value)[-1].lower() 13 | if ext == ".ipynb": 14 | return value 15 | else: 16 | raise click.BadOptionUsage( 17 | param, "format for param is not an notebook ending with ipynb" 18 | ) 19 | except ValueError: 20 | raise click.BadOptionUsage( 21 | param, "format for param is not an notebook ending with ipynb" 22 | ) 23 | 24 | 25 | def validate_create_language(ctx, param, value): 26 | if value == "./runbooks/binder/_template-python.ipynb": 27 | return value 28 | if value == "./runbooks/binder/_template-deno.ipynb": 29 | return value 30 | if value in ["python", "deno"]: 31 | return f"./runbooks/binder/_template-{value}.ipynb" 32 | else: 33 | raise click.BadOptionUsage("--language", "options are python or deno") 34 | 35 | 36 | def validate_plan_params(ctx, param, value): 37 | if isinstance(value, dict): 38 | return value 39 | 40 | # We allow this for the auto-plan case 41 | if value == "" or value == None: 42 | return {} 43 | 44 | try: 45 | v = json.loads(value) 46 | if isinstance(v, dict): 47 | return v 48 | else: 49 | raise click.BadOptionUsage("--param", "format for param is not an object") 50 | except ValueError: 51 | raise click.BadOptionUsage( 52 | "--param", "format must be json with an outermost structure of an object" 53 | ) 54 | 55 | 56 | def validate_runbook_file_path(ctx, param, value): 57 | try: 58 | if Path(value).exists(): 59 | return value 60 | elif Path(f"./runbooks/binder/{value}").exists(): 61 | return f"./runbooks/binder/{value}" 62 | elif Path(f"./runbooks/runs/{value}").exists(): 63 | return f"./runbooks/runs/{value}" 64 | else: 65 | raise click.BadOptionUsage("FILENAME", "unable to find file") 66 | except ValueError: 67 | raise click.BadOptionUsage("FILENAME", "unable to find file") 68 | 69 | 70 | def process_glob_matches(options): 71 | options = sorted(options) 72 | options.reverse() 73 | if len(options) == 1: 74 | return options[0] 75 | elif len(options) > 1: 76 | return click.prompt("Which file?", type=click.Choice(options)) 77 | else: 78 | raise click.BadOptionUsage("FILENAME", f"unable to find {value} file") 79 | 80 | 81 | def validate_has_notebook_extension(ctx, param, value): 82 | if not value.lower().endswith(".ipynb"): 83 | value = value + ".ipynb" 84 | return value 85 | 86 | 87 | def validate_planned_runbook_file_path(ctx, param, value): 88 | value = validate_has_notebook_extension(ctx, param, value) 89 | base_name = path.basename(value) 90 | try: 91 | if Path(value).exists(): 92 | return value 93 | elif glob.glob(f"./runbooks/runs/**/{value}", recursive=True): 94 | return process_glob_matches( 95 | glob.glob(f"./runbooks/runs/**/{value}", recursive=True) 96 | ) 97 | elif glob.glob(f"./runbooks/runs/**/{base_name}", recursive=True): 98 | return process_glob_matches( 99 | glob.glob(f"./runbooks/runs/**/{base_name}", recursive=True) 100 | ) 101 | else: 102 | raise click.BadOptionUsage( 103 | "FILENAME", f"unable to find {value} file", value 104 | ) 105 | except ValueError: 106 | raise click.BadOptionUsage("FILENAME", f"unable to find {value} file") 107 | -------------------------------------------------------------------------------- /runbook/constants.py: -------------------------------------------------------------------------------- 1 | RUNBOOK_METADATA = "__RUNBOOK_METADATA__" 2 | -------------------------------------------------------------------------------- /runbook/data/_template-deno.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "6ff80734-8906-4f85-9aab-6ceace7ba425", 6 | "metadata": { 7 | "editable": true, 8 | "slideshow": { 9 | "slide_type": "" 10 | }, 11 | "tags": [] 12 | }, 13 | "source": [ 14 | "# TITLE\n", 15 | "\n", 16 | "## DESCRIPTION\n", 17 | "\n", 18 | "Description continues in narrative form" 19 | ] 20 | }, 21 | { 22 | "cell_type": "markdown", 23 | "id": "05b3e880-8601-4107-9895-d792ba1dcd30", 24 | "metadata": { 25 | "editable": true, 26 | "slideshow": { 27 | "slide_type": "" 28 | }, 29 | "tags": [] 30 | }, 31 | "source": [ 32 | "## Setup" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": null, 38 | "id": "117307e4-e66f-4044-b7e7-914dc0292821", 39 | "metadata": { 40 | "editable": true, 41 | "slideshow": { 42 | "slide_type": "" 43 | }, 44 | "tags": [ 45 | "parameters" 46 | ] 47 | }, 48 | "outputs": [], 49 | "source": [ 50 | "// Default Params\n", 51 | "let server: string = \"main.xargs.io\";\n", 52 | "let arg: number = 1;\n", 53 | "let anArray: string[] = [\"a\", \"b\"]; // normally a / b\n", 54 | "let __RUNBOOK_METADATA__ = {};" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "id": "4b3058da", 61 | "metadata": { 62 | "editable": true, 63 | "slideshow": { 64 | "slide_type": "" 65 | }, 66 | "tags": [] 67 | }, 68 | "outputs": [], 69 | "source": [ 70 | "import { $, sh } from \"https://raw.githubusercontent.com/zph/runbook/main/ext/deno/runbook/mod.ts\";" 71 | ] 72 | }, 73 | { 74 | "cell_type": "code", 75 | "execution_count": null, 76 | "id": "b27def56-0219-4389-82c7-44c77bab6b19", 77 | "metadata": { 78 | "editable": true, 79 | "slideshow": { 80 | "slide_type": "" 81 | }, 82 | "tags": [] 83 | }, 84 | "outputs": [], 85 | "source": [ 86 | "await sh(\"git log\");" 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": null, 92 | "id": "9ae59b65", 93 | "metadata": { 94 | "execution": { 95 | "iopub.execute_input": "2024-07-04T22:11:06.713960Z", 96 | "iopub.status.busy": "2024-07-04T22:11:06.713812Z", 97 | "iopub.status.idle": "2024-07-04T22:11:09.442208Z", 98 | "shell.execute_reply": "2024-07-04T22:11:09.435918Z" 99 | } 100 | }, 101 | "outputs": [], 102 | "source": [ 103 | "prompt(\"AAAAAAAAAAAA\")" 104 | ] 105 | }, 106 | { 107 | "cell_type": "code", 108 | "execution_count": null, 109 | "id": "02a9b324", 110 | "metadata": { 111 | "execution": { 112 | "iopub.execute_input": "2024-07-04T22:11:12.305431Z", 113 | "iopub.status.busy": "2024-07-04T22:11:12.305244Z", 114 | "iopub.status.idle": "2024-07-04T22:11:16.543787Z", 115 | "shell.execute_reply": "2024-07-04T22:11:16.536815Z" 116 | } 117 | }, 118 | "outputs": [], 119 | "source": [ 120 | "console.log(\"AAAAAAAAAA\")\n", 121 | "\n", 122 | "console.log(\"BBBBBBBBBBBB\")" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "id": "b7f15098-d540-4c24-83bc-73f8b81688c7", 128 | "metadata": { 129 | "editable": true, 130 | "slideshow": { 131 | "slide_type": "" 132 | }, 133 | "tags": [] 134 | }, 135 | "source": [ 136 | "## Operation" 137 | ] 138 | }, 139 | { 140 | "cell_type": "markdown", 141 | "id": "a5f85ddd-65e8-4d04-9692-e50724f3a697", 142 | "metadata": { 143 | "editable": true, 144 | "slideshow": { 145 | "slide_type": "" 146 | }, 147 | "tags": [] 148 | }, 149 | "source": [ 150 | "### Step 1. Echo JSON" 151 | ] 152 | }, 153 | { 154 | "cell_type": "code", 155 | "execution_count": null, 156 | "id": "e774e7ca", 157 | "metadata": { 158 | "editable": true, 159 | "slideshow": { 160 | "slide_type": "" 161 | }, 162 | "tags": [] 163 | }, 164 | "outputs": [], 165 | "source": [ 166 | "await $`echo '{\"a\": 1}'`.json();" 167 | ] 168 | }, 169 | { 170 | "cell_type": "markdown", 171 | "id": "c97a321e-dbe7-4eda-929b-87fc6aa29052", 172 | "metadata": { 173 | "editable": true, 174 | "slideshow": { 175 | "slide_type": "" 176 | }, 177 | "tags": [] 178 | }, 179 | "source": [ 180 | "### Step 2 Get Git Log" 181 | ] 182 | }, 183 | { 184 | "cell_type": "code", 185 | "execution_count": null, 186 | "id": "6eed6e46", 187 | "metadata": { 188 | "editable": true, 189 | "slideshow": { 190 | "slide_type": "" 191 | }, 192 | "tags": [] 193 | }, 194 | "outputs": [], 195 | "source": [ 196 | "await $`git log`.text();" 197 | ] 198 | }, 199 | { 200 | "cell_type": "markdown", 201 | "id": "eca30de0-ba65-43c6-b220-cab0dae363ff", 202 | "metadata": { 203 | "editable": true, 204 | "slideshow": { 205 | "slide_type": "" 206 | }, 207 | "tags": [] 208 | }, 209 | "source": [ 210 | "## Rollback" 211 | ] 212 | }, 213 | { 214 | "cell_type": "markdown", 215 | "id": "fce08404-4eb1-45d3-8f9c-2e5601662962", 216 | "metadata": { 217 | "editable": true, 218 | "slideshow": { 219 | "slide_type": "" 220 | }, 221 | "tags": [] 222 | }, 223 | "source": [ 224 | "## Cleanup" 225 | ] 226 | }, 227 | { 228 | "cell_type": "markdown", 229 | "id": "36cfe462-e424-459b-957a-0e06d231c928", 230 | "metadata": { 231 | "editable": true, 232 | "slideshow": { 233 | "slide_type": "" 234 | }, 235 | "tags": [] 236 | }, 237 | "source": [ 238 | "## Action Items / Learnings" 239 | ] 240 | } 241 | ], 242 | "metadata": { 243 | "kernelspec": { 244 | "display_name": "Deno", 245 | "language": "typescript", 246 | "name": "deno" 247 | }, 248 | "language_info": { 249 | "file_extension": ".ts", 250 | "mimetype": "text/x.typescript", 251 | "name": "typescript", 252 | "nb_converter": "script", 253 | "pygments_lexer": "typescript", 254 | "version": "5.4.5" 255 | }, 256 | "widgets": { 257 | "application/vnd.jupyter.widget-state+json": { 258 | "state": {}, 259 | "version_major": 2, 260 | "version_minor": 0 261 | } 262 | } 263 | }, 264 | "nbformat": 4, 265 | "nbformat_minor": 5 266 | } 267 | -------------------------------------------------------------------------------- /runbook/data/_template-python.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "e368013b", 6 | "metadata": { 7 | "editable": true, 8 | "slideshow": { 9 | "slide_type": "" 10 | }, 11 | "tags": [] 12 | }, 13 | "source": [ 14 | "# TITLE\n", 15 | "\n", 16 | "## Description" 17 | ] 18 | }, 19 | { 20 | "cell_type": "markdown", 21 | "id": "30d9dcb9", 22 | "metadata": { 23 | "editable": true, 24 | "slideshow": { 25 | "slide_type": "" 26 | }, 27 | "tags": [] 28 | }, 29 | "source": [ 30 | "## Setup" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": null, 36 | "id": "19ef6903-2fcd-4bdf-935e-92ce2940b277", 37 | "metadata": { 38 | "tags": [ 39 | "parameters" 40 | ] 41 | }, 42 | "outputs": [], 43 | "source": [ 44 | "# PARAMETERS DEFAULTS\n", 45 | "dry_run = True # controls sh behavior for safety\n", 46 | "an_array: List[str] = [\"a\", \"b\"] # array of strings" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": null, 52 | "id": "a1db85d5-89a4-4721-837c-2f17f55581eb", 53 | "metadata": { 54 | "editable": true, 55 | "slideshow": { 56 | "slide_type": "" 57 | }, 58 | "tags": [] 59 | }, 60 | "outputs": [], 61 | "source": [ 62 | "# SHELL HELPERS\n", 63 | "from runbook.shell import confirm, gather, shell_builder, style\n", 64 | "\n", 65 | "sh = shell_builder(\n", 66 | " dry_run, tags_default={\"environment\": \"testing\"}, confirm_default=True\n", 67 | ")" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": null, 73 | "id": "b60b6e71-13da-40d3-9a74-016f75fbaf46", 74 | "metadata": { 75 | "editable": true, 76 | "slideshow": { 77 | "slide_type": "" 78 | }, 79 | "tags": [] 80 | }, 81 | "outputs": [], 82 | "source": [ 83 | "# Widgets\n", 84 | "import ipywidgets as widgets\n", 85 | "from IPython.display import display\n", 86 | "\n", 87 | "w = widgets.Dropdown(\n", 88 | " options=[(\"Production\", 1), (\"Staging\", 2), (\"Development\", 3)],\n", 89 | " value=3,\n", 90 | " description=\"Environment:\",\n", 91 | ")\n", 92 | "\n", 93 | "v = widgets.Dropdown(\n", 94 | " options=[\"Mysql\", \"Redis\", \"Memcached\"],\n", 95 | " value=\"Redis\",\n", 96 | " description=\"Database:\",\n", 97 | ")\n", 98 | "display(w)\n", 99 | "display(v)" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "id": "79b18076-340f-42d3-a021-96a661de7e3a", 106 | "metadata": {}, 107 | "outputs": [], 108 | "source": [ 109 | "print(f\"Value of widget: {w.value} {v.value}\")" 110 | ] 111 | }, 112 | { 113 | "cell_type": "markdown", 114 | "id": "f7610298", 115 | "metadata": { 116 | "editable": true, 117 | "slideshow": { 118 | "slide_type": "" 119 | }, 120 | "tags": [] 121 | }, 122 | "source": [ 123 | "## Operation" 124 | ] 125 | }, 126 | { 127 | "cell_type": "markdown", 128 | "id": "adf2dc3c", 129 | "metadata": {}, 130 | "source": [ 131 | "### Step 1 - Get git info" 132 | ] 133 | }, 134 | { 135 | "cell_type": "code", 136 | "execution_count": null, 137 | "id": "6ee12661-62b8-43b7-b557-2acc9a346d84", 138 | "metadata": {}, 139 | "outputs": [], 140 | "source": [ 141 | "output = await sh(\"git --version && echo 333 >&2\", confirm=True)" 142 | ] 143 | }, 144 | { 145 | "cell_type": "markdown", 146 | "id": "82c90614", 147 | "metadata": { 148 | "editable": true, 149 | "slideshow": { 150 | "slide_type": "" 151 | }, 152 | "tags": [] 153 | }, 154 | "source": [ 155 | "### Step 2 - Print" 156 | ] 157 | }, 158 | { 159 | "cell_type": "code", 160 | "execution_count": null, 161 | "id": "5c94548f-2197-4de7-b72c-b1feb29bc18c", 162 | "metadata": {}, 163 | "outputs": [], 164 | "source": [ 165 | "print(output)" 166 | ] 167 | }, 168 | { 169 | "cell_type": "markdown", 170 | "id": "f0e9eade", 171 | "metadata": {}, 172 | "source": [ 173 | "DANGER DANGER DANGER The following code is going to be risky. Be prepared with rollback procedures." 174 | ] 175 | }, 176 | { 177 | "cell_type": "code", 178 | "execution_count": null, 179 | "id": "0add2816-a9cb-410c-83f3-a4aad6aaff5f", 180 | "metadata": {}, 181 | "outputs": [], 182 | "source": [ 183 | "confirm(style(\"Danger, ready to proceed?\", fg=\"red\", bold=True), abort=True)\n", 184 | "cmds = [sh(\"sleep 1; echo 1\"), sh(\"sleep 2; echo 2\"), sh(\"sleep 3; echo 3\")]" 185 | ] 186 | }, 187 | { 188 | "cell_type": "code", 189 | "execution_count": null, 190 | "id": "ef525b6d-2059-412e-a0cb-c1e908ca54c7", 191 | "metadata": {}, 192 | "outputs": [], 193 | "source": [ 194 | "result = await gather(*cmds)" 195 | ] 196 | }, 197 | { 198 | "cell_type": "markdown", 199 | "id": "74d941f2", 200 | "metadata": {}, 201 | "source": [ 202 | "## Cleanup" 203 | ] 204 | }, 205 | { 206 | "cell_type": "markdown", 207 | "id": "d5b16dbe", 208 | "metadata": { 209 | "editable": true, 210 | "slideshow": { 211 | "slide_type": "" 212 | }, 213 | "tags": [] 214 | }, 215 | "source": [ 216 | "## Rollback" 217 | ] 218 | }, 219 | { 220 | "cell_type": "markdown", 221 | "id": "3e3784fc-e073-49a3-a17d-c5f8273394b0", 222 | "metadata": { 223 | "editable": true, 224 | "slideshow": { 225 | "slide_type": "" 226 | }, 227 | "tags": [] 228 | }, 229 | "source": [ 230 | "## Action Items / Learnings" 231 | ] 232 | }, 233 | { 234 | "cell_type": "code", 235 | "execution_count": null, 236 | "id": "ad7ed51f-7e0f-4c7d-95b3-c73ecdeaa3a8", 237 | "metadata": { 238 | "editable": true, 239 | "slideshow": { 240 | "slide_type": "" 241 | }, 242 | "tags": [] 243 | }, 244 | "outputs": [], 245 | "source": [] 246 | } 247 | ], 248 | "metadata": { 249 | "kernelspec": { 250 | "display_name": "Python 3 (ipykernel)", 251 | "language": "python", 252 | "name": "python3" 253 | }, 254 | "language_info": { 255 | "codemirror_mode": { 256 | "name": "ipython", 257 | "version": 3 258 | }, 259 | "file_extension": ".py", 260 | "mimetype": "text/x-python", 261 | "name": "python", 262 | "nbconvert_exporter": "python", 263 | "pygments_lexer": "ipython3", 264 | "version": "3.11.7" 265 | }, 266 | "widgets": { 267 | "application/vnd.jupyter.widget-state+json": { 268 | "state": {}, 269 | "version_major": 2, 270 | "version_minor": 0 271 | } 272 | } 273 | }, 274 | "nbformat": 4, 275 | "nbformat_minor": 5 276 | } 277 | -------------------------------------------------------------------------------- /runbook/nbconvert/templates/mdaudit/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "mimetypes": { 3 | "text/markdown": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /runbook/nbconvert/templates/mdaudit/index.md.j2: -------------------------------------------------------------------------------- 1 | {% extends 'markdown/index.md.j2' %} 2 | 3 | {% block input %} 4 | Timestamps 5 | ```yaml 6 | --- 7 | start: {{ cell.metadata.execution['iopub.execute_input'] }} 8 | end: {{ cell.metadata.execution['shell.execute_reply'] }} 9 | ``` 10 | 11 | ``` 12 | {%- if 'magics_language' in cell.metadata -%} 13 | {{ cell.metadata.magics_language}} 14 | {%- elif 'name' in nb.metadata.get('language_info', {}) -%} 15 | {{ nb.metadata.language_info.name }} 16 | {%- endif %} 17 | {{ cell.source}} 18 | 19 | ``` 20 | {% endblock input %} 21 | -------------------------------------------------------------------------------- /runbook/notifications.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.DEBUG) 4 | 5 | import os 6 | from functools import partial 7 | 8 | from slack_sdk import WebClient 9 | from slack_sdk.errors import SlackApiError 10 | 11 | 12 | # Usage 13 | # r = thread_post_message = start_thread('CCCCC', 'Topic For Discussion') 14 | # r2 = r.thread_post_message('A threaded message about gritty') 15 | # r2.react_to_message(emoji='gritty') 16 | def start_thread(channel, message): 17 | return post_message(channel, "🧵 " + message + " 🧵") 18 | 19 | 20 | def thread_fn(channel, thread): 21 | return partial(post_message, channel=channel, thread=thread) 22 | 23 | 24 | def react_to_message(channel, emoji, timestamp): 25 | slack_token = os.environ["SLACK_BOT_TOKEN"] 26 | client = WebClient(token=slack_token) 27 | 28 | arg = dict( 29 | channel=channel, 30 | timestamp=timestamp, 31 | name=emoji, 32 | ) 33 | 34 | try: 35 | r = client.reactions_add(**arg) 36 | # On first post treat response as thread id 37 | ts = r["ts"] 38 | if not thread: 39 | thread = ts 40 | 41 | return dict( 42 | ts=ts, 43 | thread=thread, 44 | thread_post_message=partial(post_message, channel=channel, thread=thread), 45 | react_to_message=partial(react_to_message, channel=channel, timestamp=ts), 46 | ) 47 | 48 | except SlackApiError as e: 49 | # You will get a SlackApiError if "ok" is False 50 | assert e.response["error"] 51 | 52 | 53 | def post_message(channel, message, thread=None): 54 | slack_token = os.environ["SLACK_BOT_TOKEN"] 55 | client = WebClient(token=slack_token) 56 | 57 | arg = dict( 58 | channel=channel, 59 | thread_ts=thread, 60 | blocks=[{"type": "section", "fields": [{"type": "mrkdwn", "text": message}]}], 61 | ) 62 | if not thread: 63 | del arg["thread_ts"] 64 | 65 | try: 66 | # TODO: return fns to keep interacting instead of data? 67 | return client.chat_postMessage(**arg) 68 | 69 | except SlackApiError as e: 70 | # You will get a SlackApiError if "ok" is False 71 | assert e.response["error"] 72 | -------------------------------------------------------------------------------- /runbook/shell.py: -------------------------------------------------------------------------------- 1 | from asyncio.subprocess import Process 2 | from datetime import datetime 3 | from shlex import quote 4 | 5 | import click 6 | 7 | # Confirm is kept for usage as an interface for notebooks 8 | from click import confirm, echo, style 9 | from shx.shx import _CVAR as cv 10 | from shx.shx import SHX, gather 11 | 12 | 13 | def __retain_imports_via_noop(): 14 | gather 15 | confirm 16 | style 17 | echo 18 | style 19 | quote 20 | 21 | 22 | class ShellProcess(Process): 23 | def __init__( 24 | self, process, command, dry_run=False, tags={}, start_time=None, end_time=None 25 | ): 26 | self.stdout = str(process.stdout).rstrip("\n") 27 | self.stdout_raw = process.stdout 28 | self.stderr = process.stderr 29 | self.stdin = process.stdin 30 | self.command = command 31 | self.pid = process.pid 32 | self.dry_run = dry_run 33 | self.tags = tags 34 | self.start_time = start_time 35 | self.end_time = end_time 36 | 37 | self.duration = end_time - start_time 38 | 39 | def to_dict(self): 40 | return { 41 | "command": self.command, 42 | "timestamps": { 43 | "start": datetime.isoformat(self.start_time), 44 | "end": datetime.isoformat(self.end_time), 45 | "duration": str(self.duration), 46 | }, 47 | "stdout": self.stdout, 48 | "stderr": self.stderr, 49 | "stdin": self.stdin, 50 | "pid": self.pid, 51 | "dry_run": self.dry_run, 52 | "tags": self.tags, 53 | } 54 | 55 | def __str__(self): 56 | return f"ShellProcess({self.to_dict()})" 57 | 58 | def __repr__(self): 59 | return f"ShellProcess({self.to_dict()})" 60 | 61 | 62 | def shell_builder(dry_run, tags_default, confirm_default): 63 | from functools import partial 64 | 65 | return partial( 66 | shell, 67 | dry_run=dry_run, 68 | tags_default=tags_default, 69 | confirm_default=confirm_default, 70 | ) 71 | 72 | 73 | async def shell(cmd: str, dry_run, tags={}, confirm=False, **kwargs): 74 | tags_default = kwargs.pop("tags_default", None) 75 | confirm_default = kwargs.pop("confirm_default", False) 76 | trace = cv["trace"].get() 77 | prefix = cv["prefix"].get() 78 | capture = True 79 | if tags_default: 80 | tags = {**tags_default, **tags} 81 | if confirm_default: 82 | confirm = confirm or confirm_default 83 | 84 | if dry_run: 85 | cmd_mod = f"echo {quote(cmd)}" 86 | else: 87 | cmd_mod = cmd 88 | 89 | if confirm: 90 | echo(f"Prepared to execute command: {cmd_mod}") 91 | click.confirm(style("Proceed?", fg="red", bold=True), abort=True) 92 | start_time = datetime.now() 93 | result = await SHX(cmd_mod, prefix, trace, capture, **kwargs) 94 | end_time = datetime.now() 95 | 96 | if dry_run: 97 | result.command = cmd 98 | result.stdout = "" 99 | return ShellProcess(result, cmd, dry_run, tags, start_time, end_time) 100 | -------------------------------------------------------------------------------- /runbook/template.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info >= (3, 7): 4 | from importlib import resources as importlib_resources 5 | else: 6 | import pkgutil 7 | 8 | 9 | def read_embedded_file(package: str, filename: str) -> str: 10 | """ 11 | Reads an embedded file from a given package using the appropriate method 12 | based on the Python version. 13 | 14 | :param package: The package name where the file is located (e.g., "mypackage.data"). 15 | :param filename: The name of the file inside the package (e.g., "myfile.txt"). 16 | :return: The contents of the file as a string. 17 | """ 18 | if sys.version_info >= (3, 7): 19 | # Use importlib.resources (Python 3.7+) 20 | with ( 21 | importlib_resources.files(package) 22 | .joinpath(filename) 23 | .open("r", encoding="utf-8") as f 24 | ): 25 | return f.read() 26 | else: 27 | # Use pkgutil.get_data() for older versions 28 | data = pkgutil.get_data(package, filename) 29 | return data.decode("utf-8") if data else None 30 | 31 | 32 | TEMPLATES = { 33 | "python": read_embedded_file("runbook.data", "_template-python.ipynb").strip(), 34 | "deno": read_embedded_file("runbook.data", "_template-deno.ipynb").strip(), 35 | } 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zph/runbook/bc18fe0592ec10a376e0fbcedba5d98625566222/tests/__init__.py -------------------------------------------------------------------------------- /tests/__snapshots__/cli_test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`convert 1`] = ` 4 | { 5 | exitCode: 0, 6 | stderr: "", 7 | } 8 | `; 9 | 10 | snapshot[`convert 2`] = ` 11 | "// --- 12 | // jupyter: 13 | // jupytext: 14 | // text_representation: 15 | // extension: .ts 16 | // format_name: light 17 | // format_version: '1.5' 18 | // jupytext_version: 1.16.6 19 | // kernelspec: 20 | // display_name: Deno 21 | // language: typescript 22 | // name: deno 23 | // --- 24 | 25 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 26 | // # TITLE 27 | // 28 | // ## DESCRIPTION 29 | // 30 | // Description continues in narrative form 31 | 32 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 33 | // ## Setup 34 | 35 | // + editable=true slideshow={\\"slide_type\\": \\"\\"} tags=[\\"parameters\\"] 36 | // Default Params 37 | let server: string = \\"main.xargs.io\\"; 38 | let arg: number = 1; 39 | let anArray: string[] = [\\"a\\", \\"b\\"]; // normally a / b 40 | let __RUNBOOK_METADATA__ = {}; 41 | 42 | // + editable=true slideshow={\\"slide_type\\": \\"\\"} 43 | import { \$, sh } from \\"https://raw.githubusercontent.com/zph/runbook/main/ext/deno/runbook/mod.ts\\"; 44 | 45 | // + editable=true slideshow={\\"slide_type\\": \\"\\"} 46 | await sh(\\"git log\\"); 47 | // - 48 | 49 | prompt(\\"AAAAAAAAAAAA\\") 50 | 51 | // + 52 | console.log(\\"AAAAAAAAAA\\") 53 | 54 | console.log(\\"BBBBBBBBBBBB\\") 55 | 56 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 57 | // ## Operation 58 | 59 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 60 | // ### Step 1. Echo JSON 61 | 62 | // + editable=true slideshow={\\"slide_type\\": \\"\\"} 63 | await \$\`echo '{\\"a\\": 1}'\`.json(); 64 | 65 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 66 | // ### Step 2 Get Git Log 67 | 68 | // + editable=true slideshow={\\"slide_type\\": \\"\\"} 69 | await \$\`git log\`.text(); 70 | 71 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 72 | // ## Rollback 73 | 74 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 75 | // ## Cleanup 76 | 77 | // + [markdown] editable=true slideshow={\\"slide_type\\": \\"\\"} 78 | // ## Action Items / Learnings 79 | " 80 | `; 81 | 82 | snapshot[`create 1`] = ` 83 | { 84 | exitCode: 0, 85 | stderr: "", 86 | } 87 | `; 88 | 89 | snapshot[`list 1`] = ` 90 | { 91 | exitCode: 0, 92 | stderr: "", 93 | stdout: " 94 | Templates: 95 | 📔 TITLE - runbooks/binder/_template-deno.ipynb 96 | 📔 TITLE - runbooks/binder/_template-python.ipynb 97 | 98 | Runs: 99 | ", 100 | } 101 | `; 102 | 103 | snapshot[`show 1`] = ` 104 | { 105 | exitCode: 0, 106 | stderr: "", 107 | stdout: ' 108 | Runbook: runbooks/binder/_template-deno.ipynb 109 | Language: typescript 110 | 111 | Header: 112 | # TITLE 113 | 114 | ## DESCRIPTION 115 | 116 | Description continues in narrative form 117 | 118 | ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ 119 | ┃ Parameter ┃ Default Value ┃ Type ┃ Help ┃ 120 | ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ 121 | │ server │ "main.xargs.io" │ string │ │ 122 | │ arg │ 1 │ number │ │ 123 | │ anArray │ ["a", "b"] │ string[] │ normally a / b │ 124 | └───────────┴─────────────────┴──────────┴────────────────┘ 125 | ', 126 | } 127 | `; 128 | 129 | snapshot[`version 1`] = ` 130 | { 131 | exitCode: 0, 132 | stderr: "", 133 | stdout: "Runbook version", 134 | } 135 | `; 136 | -------------------------------------------------------------------------------- /tests/cli_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import nbformat 5 | from click.testing import CliRunner 6 | 7 | from runbook import cli 8 | 9 | python_template = "./runbooks/binder/_template-python.ipynb" 10 | deno_template = "./runbooks/binder/_template-deno.ipynb" 11 | new_template = "./runbooks/binder/new-template.ipynb" 12 | 13 | base_paths = [ 14 | "./runbooks", 15 | "./runbooks/binder", 16 | "./runbooks/runs", 17 | "./runbooks/.runbook.json", 18 | python_template, 19 | deno_template, 20 | ] 21 | 22 | 23 | def invoker(runner, argv, working_dir, prog_name="runbook"): 24 | return runner.invoke( 25 | cli, 26 | argv, 27 | env={ 28 | "RUNBOOK_WORKING_DIR": working_dir, 29 | }, 30 | prog_name=prog_name, 31 | ) 32 | 33 | 34 | def test_cli_help(): 35 | runner = CliRunner() 36 | with runner.isolated_filesystem() as dir: 37 | result = invoker(runner, ["--help"], dir) 38 | assert result.exit_code == 0 39 | output = """\ 40 | Usage: runbook [OPTIONS] COMMAND [ARGS]... 41 | 42 | Options: 43 | --cwd PATH Directory for operations (normally at root above runbooks, ie 44 | ../.runbook.yaml) and can be set with RUNBOOK_WORKING_DIR or 45 | WORKING_DIR environment variables 46 | --help Show this message and exit. 47 | 48 | Commands: 49 | check Check the language validity and formatting of a runbook. 50 | convert Convert a runbook between different formats 51 | create Create a new runbook from a template 52 | diff Compare two runbooks and show their differences 53 | edit Edit an existing runbook 54 | init Initialize a folder as a runbook repository 55 | list List runbooks 56 | plan Prepares the runbook for execution by injecting parameters. 57 | review [Unimplemented] Entrypoint for reviewing runbook 58 | run Run a runbook 59 | show Show runbook parameters and metadata 60 | version Display version information about runbook 61 | """ 62 | assert result.output == output 63 | 64 | 65 | def test_cli_init(): 66 | runner = CliRunner() 67 | with runner.isolated_filesystem() as dir: 68 | result = invoker(runner, ["init"], dir) 69 | assert result.exit_code == 0 70 | for p in base_paths: 71 | assert Path(p).exists() 72 | 73 | 74 | def test_cli_create(): 75 | runner = CliRunner() 76 | with runner.isolated_filesystem() as dir: 77 | result = invoker(runner, ["init"], dir) 78 | result = invoker(runner, ["create", "new-template"], dir) 79 | assert result.exit_code == 0 80 | paths = [*base_paths, new_template] 81 | for p in paths: 82 | assert Path(p).exists() 83 | 84 | with open(python_template, encoding="utf8") as f: 85 | nb = nbformat.read(f, 4) 86 | c = nb.cells[2] 87 | assert "parameters" in c.metadata.tags 88 | 89 | with open(deno_template, encoding="utf8") as f: 90 | nb = nbformat.read(f, 4) 91 | c = nb.cells[2] 92 | assert "parameters" in c.metadata.tags 93 | 94 | with open(new_template, encoding="utf8") as f: 95 | nb = nbformat.read(f, 4) 96 | c = nb.cells[2] 97 | assert "parameters" in c.metadata.tags 98 | 99 | 100 | def test_cli_lifecycle_to_plan(): 101 | runner = CliRunner() 102 | with runner.isolated_filesystem() as dir: 103 | result = invoker(runner, ["init"], dir) 104 | assert result.exit_code == 0 105 | result = invoker(runner, ["create", "new-template"], dir) 106 | assert result.exit_code == 0 107 | paths = [*base_paths, new_template] 108 | for p in paths: 109 | assert Path(p).exists() 110 | 111 | with open(python_template, encoding="utf8") as f: 112 | nb = nbformat.read(f, 4) 113 | c = nb.cells[2] 114 | assert "parameters" in c.metadata.tags 115 | 116 | with open(deno_template, encoding="utf8") as f: 117 | nb = nbformat.read(f, 4) 118 | c = nb.cells[2] 119 | assert "parameters" in c.metadata.tags 120 | 121 | with open(new_template, encoding="utf8") as f: 122 | nb = nbformat.read(f, 4) 123 | c = nb.cells[2] 124 | assert "parameters" in c.metadata.tags 125 | 126 | # result = invoker(runner, ["edit", "new-template.ipynb"], dir) 127 | 128 | json_params = json.dumps(dict(dry_run=False)) 129 | result = invoker( 130 | runner, 131 | ["plan", "new-template.ipynb", "--params", f"""{json_params}"""], 132 | dir, 133 | ) 134 | assert result.exit_code == 0 135 | 136 | # result = invoker(runner, ["run", "new-template.ipynb"], dir) 137 | # assert result.exit_code == 0 138 | 139 | 140 | def test_cli_lifecycle_to_run(): 141 | runner = CliRunner() 142 | with runner.isolated_filesystem() as dir: 143 | result = invoker(runner, ["init"], dir) 144 | assert result.exit_code == 0 145 | result = invoker(runner, ["create", "new-template"], dir) 146 | assert result.exit_code == 0 147 | paths = [*base_paths, new_template] 148 | for p in paths: 149 | assert Path(p).exists() 150 | 151 | with open(python_template, encoding="utf8") as f: 152 | nb = nbformat.read(f, 4) 153 | c = nb.cells[2] 154 | assert "parameters" in c.metadata.tags 155 | 156 | with open(deno_template, encoding="utf8") as f: 157 | nb = nbformat.read(f, 4) 158 | c = nb.cells[2] 159 | assert "parameters" in c.metadata.tags 160 | 161 | # TODO: fix multiple singletons of ServerApp 162 | # result = invoker(runner, ["run", deno_template], dir) 163 | # assert result.exit_code == 0 164 | -------------------------------------------------------------------------------- /tests/cli_test.ts: -------------------------------------------------------------------------------- 1 | // edit 2 | // plan 3 | // review 4 | // run 5 | 6 | import { assertEquals, assertArrayIncludes, assertMatch } from "jsr:@std/assert"; 7 | import { assertSnapshot } from "jsr:@std/testing/snapshot"; 8 | import { $ } from "jsr:@david/dax" 9 | 10 | const runbook = async (args: string[], config: { cwd: string }) => { 11 | const env = { 12 | WORKING_DIR: config.cwd, 13 | }; 14 | const cmd = await $`runbook ${args}`.env(env).stdout("piped").stderr("piped").noThrow(); 15 | return cmd; 16 | }; 17 | 18 | const init = async (dir: string) => { 19 | const _cmd = await runbook(["init", "--skip-confirmation=true"], { cwd: dir }); 20 | }; 21 | 22 | const setup = async () => { 23 | const dir = await Deno.makeTempDir(); 24 | await init(dir); 25 | return {runbook: (args: string[]) => runbook(args, { cwd: dir }), dir}; 26 | } 27 | 28 | Deno.test.ignore("check", async (t) => { 29 | const {runbook } = await setup(); 30 | const cmd = await runbook(["check", "runbooks/binder/_template-deno.ipynb"]); 31 | assertMatch(cmd.stdout.trim(), /Checked .*\/runbooks\/binder\/_template-deno\.ipynb/); 32 | assertMatch(cmd.stderr.trim(), /.*Check.*runbooks\/binder\/_template-deno-.*\.ts/); 33 | assertEquals(cmd.code, 0); 34 | }); 35 | 36 | Deno.test("convert", async (t) => { 37 | const {runbook, dir} = await setup(); 38 | const cmd = await runbook(["convert", "runbooks/binder/_template-deno.ipynb", "_template-deno.ts"]); 39 | assertSnapshot(t, { stderr: cmd.stderr, exitCode: cmd.code }); 40 | const txt = await Deno.readTextFile([dir, "_template-deno.ts"].join("/")); 41 | assertSnapshot(t, txt); 42 | }); 43 | 44 | // create 45 | Deno.test("create", async (t) => { 46 | const {runbook, dir} = await setup(); 47 | const cmd = await runbook(["create", "create-test.ipynb"]); 48 | assertSnapshot(t, { stderr: cmd.stderr, exitCode: cmd.code }); 49 | const exists = await Deno.stat([dir, "runbooks/binder/create-test.ipynb"].join("/")).then(() => true).catch(() => false); 50 | assertEquals(exists, true); 51 | }); 52 | 53 | // diff 54 | Deno.test.ignore("diff", async (t) => { 55 | const {runbook, dir} = await setup(); 56 | 57 | // Create two notebooks with different content 58 | await Deno.writeTextFile( 59 | [dir, "notebook1.ipynb"].join("/"), 60 | JSON.stringify({ 61 | cells: [{ 62 | cell_type: "code", 63 | source: "print('hello')", 64 | outputs: [] 65 | }] 66 | }) 67 | ); 68 | 69 | await Deno.writeTextFile( 70 | [dir, "notebook2.ipynb"].join("/"), 71 | JSON.stringify({ 72 | cells: [{ 73 | cell_type: "code", 74 | source: "print('world')", 75 | outputs: [] 76 | }] 77 | }) 78 | ); 79 | 80 | const cmd = await runbook(["diff", "notebook1.ipynb", "notebook2.ipynb"]); 81 | assertSnapshot(t, { stdout: cmd.stdout, stderr: cmd.stderr, exitCode: cmd.code }); 82 | }); 83 | 84 | Deno.test("init", async (t) => { 85 | const {dir} = await setup(); 86 | const files = (await getAllFiles(dir)).map(f => f.replace(dir, "")).sort(); 87 | assertEquals(files, [ 88 | "/runbooks/binder/_template-deno.ipynb", 89 | "/runbooks/binder/_template-python.ipynb", 90 | "/runbooks/.runbook.json", 91 | ].sort()); 92 | }); 93 | 94 | Deno.test("list", async (t) => { 95 | const {runbook } = await setup(); 96 | const cmd = await runbook(["list"]); 97 | assertSnapshot(t, { stdout: cmd.stdout, stderr: cmd.stderr, exitCode: cmd.code }); 98 | }); 99 | 100 | // show 101 | Deno.test("show", async (t) => { 102 | const {runbook } = await setup(); 103 | const cmd = await runbook(["show", "runbooks/binder/_template-deno.ipynb"]); 104 | assertSnapshot(t, { stdout: cmd.stdout, stderr: cmd.stderr, exitCode: cmd.code }); 105 | }); 106 | 107 | // plan 108 | Deno.test("plan: prompter interface", async (t) => { 109 | const cwd = Deno.cwd(); 110 | const {runbook, dir } = await setup(); 111 | const cmd = await runbook(["plan", "runbooks/binder/_template-deno.ipynb", "--prompter", [cwd, "tests/fixtures/prompters/echoer"].join("/")]); 112 | assertEquals(cmd.code, 0); 113 | 114 | const files = await getAllFiles($.path(dir).join("runbooks/runs").toString()); 115 | const planFile = files.find(f => f.endsWith("_template-deno/_template-deno.ipynb")); 116 | if(!planFile) { 117 | throw new Error("Plan file not found"); 118 | } 119 | const json = await Deno.readTextFile(planFile); 120 | const plan = JSON.parse(json); 121 | const maybeParamCells = plan.cells.filter((c: any) => c.cell_type === "code" && c.metadata?.tags?.includes("injected-parameters")); 122 | assertEquals(maybeParamCells.length, 1); 123 | const paramCell = maybeParamCells[0]; 124 | assertArrayIncludes(paramCell.source, [`server = "main.xargs.io";\n`, `arg = 1;\n`, `anArray = ["a", "b"];\n`]); 125 | // assertSnapshot(t, { stdout: cmd.stdout, stderr: cmd.stderr, exitCode: cmd.code }); 126 | }); 127 | 128 | // run 129 | /* failing on nested dax commands 130 | Exception encountered at "In [3]": 131 | Stack trace: 132 | Error: Exited with code: 128 133 | at CommandChild.pipedStdoutBuffer (https://deno.land/x/dax@0.39.2/src/command.ts:758:19) 134 | at eventLoopTick (ext:core/01_core.js:175:7) 135 | */ 136 | Deno.test.ignore("run", async (t) => { 137 | const {runbook, dir} = await setup(); 138 | const cmd = await runbook(["run", "--no-interactive", "runbooks/binder/_template-deno.ipynb", "--output", "output.ipynb"]); 139 | assertSnapshot(t, { stdout: cmd.stdout, stderr: cmd.stderr, exitCode: cmd.code }); 140 | const output = await Deno.readTextFile([dir, "output.ipynb"].join("/")); 141 | assertSnapshot(t, output); 142 | }); 143 | 144 | // version 145 | Deno.test("version", async (t) => { 146 | const {runbook } = await setup(); 147 | const cmd = await runbook(["version"]); 148 | assertSnapshot(t, { stdout: cmd.stdout.split(":")[0], stderr: cmd.stderr, exitCode: cmd.code }); 149 | }); 150 | 151 | async function* walkFiles(dir: string): AsyncGenerator { 152 | for await (const entry of Deno.readDir(dir)) { 153 | const path = `${dir}/${entry.name}`; 154 | if (entry.isDirectory) { 155 | yield* walkFiles(path); 156 | } else { 157 | yield path; 158 | } 159 | } 160 | } 161 | 162 | async function getAllFiles(dir: string): Promise { 163 | const files = []; 164 | for await (const file of walkFiles(dir)) { 165 | files.push(file); 166 | } 167 | return files; 168 | } 169 | 170 | -------------------------------------------------------------------------------- /tests/fixtures/prompters/echoer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo '{"server": "main.xargs.io", "arg": 1, "anArray": ["a", "b"]}' 4 | # cat /dev/stdin 5 | -------------------------------------------------------------------------------- /tests/fixtures/prompters/interactive-prompter.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run -A 2 | 3 | const response = prompt("What is your name?", "Doe") 4 | 5 | console.error("debug", Deno.env.get("DEBUG")) 6 | console.log(JSON.stringify({server: response})); 7 | -------------------------------------------------------------------------------- /tests/manual/01_edit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | set -x 4 | 5 | echo "Testing 'edit' command..." 6 | TEMP_DIR=$(mktemp -d) 7 | 8 | export RUNBOOK_WORKING_DIR="$TEMP_DIR" 9 | 10 | runbook init --skip-confirmation=true 11 | runbook create test-notebook.ipynb 12 | runbook edit runbooks/binder/test-notebook.ipynb 13 | -------------------------------------------------------------------------------- /tests/manual/02_run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | echo "Testing 'run' command..." 5 | TEMP_DIR=$(mktemp -d) 6 | 7 | export RUNBOOK_WORKING_DIR="$TEMP_DIR" 8 | 9 | runbook init --skip-confirmation=true 10 | 11 | # Test non-interactive run 12 | echo "Testing non-interactive run..." 13 | runbook run --no-interactive runbooks/binder/_template-deno.ipynb --output output.ipynb 14 | 15 | # Verify output file exists 16 | if [ -f "output.ipynb" ]; then 17 | echo "✅ Non-interactive run successful" 18 | else 19 | echo "❌ Non-interactive run failed - output file not created" 20 | exit 1 21 | fi 22 | 23 | # Test interactive run 24 | echo "Testing interactive run..." 25 | runbook run runbooks/binder/_template-deno.ipynb 26 | --------------------------------------------------------------------------------