Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
SHELL := /bin/sh

CARGO ?= cargo
BINARY ?= bssh
BUILD_DIR ?= target/release
BIN := $(BUILD_DIR)/$(BINARY)
PLAYBOOK ?= examples/playbook.yaml
INVENTORY ?= examples/playbook-inventory.yaml
ARGS ?=
RUST_VERSION ?= 1.93
DOCKER_IMAGE ?= rust:$(RUST_VERSION)

.PHONY: help setup bootstrap-rustup toolchain dependencies deps build install check test \
test-playbook fmt fmt-check lint clean playbook playbook-combined dry-run \
dry-run-combined demo playbook-help docker-check docker-test

help: ## Show available commands
@printf '%s\n' \
'Common commands:' \
' make setup Bootstrap rustup, install Rust, and fetch dependencies' \
' make bootstrap-rustup Install rustup when it is missing' \
' make dependencies Download locked Cargo dependencies' \
' make build Build the release binary' \
' make install Install bssh with cargo' \
' make check Check all Rust targets' \
' make test Run the workspace tests' \
' make test-playbook Run only playbook tests' \
' make demo Dry-run the included example' \
' make playbook Run the configured playbook' \
' make dry-run Dry-run the configured playbook' \
'' \
'Playbook overrides:' \
' make playbook PLAYBOOK=deploy.yaml INVENTORY=inventory.yaml' \
' make playbook-combined PLAYBOOK=combined.yaml' \
' make dry-run-combined PLAYBOOK=combined.yaml' \
' make playbook ARGS="--full-output"' \
'' \
'Toolchain-free validation (requires Docker):' \
' make docker-check' \
' make docker-test'

setup: toolchain dependencies ## Bootstrap Rust and fetch dependencies

bootstrap-rustup: ## Install rustup from rustup.rs when it is missing
command -v rustup >/dev/null 2>&1 || [ -x "$(HOME)/.cargo/bin/rustup" ] || { command -v curl >/dev/null 2>&1 || { echo "curl is required to install rustup" >&2; exit 1; }; curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path; }

toolchain: bootstrap-rustup ## Install the supported Rust toolchain, formatter, and linter
PATH="$(HOME)/.cargo/bin:$$PATH" rustup toolchain install $(RUST_VERSION) --profile minimal --component rustfmt,clippy
PATH="$(HOME)/.cargo/bin:$$PATH" rustup default $(RUST_VERSION)

dependencies: ## Download all locked Cargo dependencies
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) fetch --locked

deps: dependencies ## Alias for dependencies

build: ## Build the optimized bssh binary
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) build --release --locked --bin $(BINARY)

install: ## Install bssh from this checkout
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) install --path . --locked

check: ## Type-check all targets
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) check --all-targets --locked

test: ## Run all workspace tests
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) test --workspace --locked

test-playbook: ## Run only playbook tests
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) test --lib playbook --locked

fmt: ## Format Rust sources
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) fmt --all

fmt-check: ## Verify Rust formatting
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) fmt --all -- --check

lint: ## Run Clippy with warnings treated as errors
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) clippy --all-targets --all-features --locked -- -D warnings

clean: ## Remove build artifacts
PATH="$(HOME)/.cargo/bin:$$PATH" $(CARGO) clean

playbook: build ## Run separate playbook and inventory files
"$(BIN)" playbook "$(PLAYBOOK)" --inventory "$(INVENTORY)" $(ARGS)

playbook-combined: build ## Run a legacy combined inventory/playbook file
"$(BIN)" playbook "$(PLAYBOOK)" $(ARGS)

dry-run: build ## Preview separate playbook and inventory files
"$(BIN)" playbook "$(PLAYBOOK)" --inventory "$(INVENTORY)" --dry-run $(ARGS)

dry-run-combined: build ## Preview a legacy combined inventory/playbook file
"$(BIN)" playbook "$(PLAYBOOK)" --dry-run $(ARGS)

demo: dry-run ## Dry-run the included example inventory and playbook

playbook-help: build ## Show playbook CLI help
"$(BIN)" playbook --help

docker-check: ## Run cargo check with Rust $(RUST_VERSION) in Docker
docker run --rm --user "$$(id -u):$$(id -g)" \
-e HOME=/tmp/home -e CARGO_HOME=/tmp/cargo-home \
-v "$(CURDIR):/work" -w /work $(DOCKER_IMAGE) \
cargo check --all-targets --locked

docker-test: ## Run playbook tests with Rust $(RUST_VERSION) in Docker
docker run --rm --user "$$(id -u):$$(id -g)" \
-e HOME=/tmp/home -e CARGO_HOME=/tmp/cargo-home \
-v "$(CURDIR):/work" -w /work $(DOCKER_IMAGE) \
cargo test --lib playbook --locked
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,64 @@ bssh -C all-servers --output-dir ./system-info "uname -a; df -h; free -m"
bssh -C webservers --output-dir ./debug "systemctl status nginx"
```

## sshot-style playbooks

`bssh playbook` runs bssh's intentionally limited sshot-style YAML format. It is
**not an Ansible compatibility mode** and does not accept arbitrary Ansible
modules, expressions, or filters.

```bash
# Separate inventory and playbook files
bssh playbook examples/playbook.yaml \
--inventory examples/playbook-inventory.yaml --dry-run
bssh playbook examples/playbook.yaml -i examples/playbook-inventory.yaml \
--full-output

# Legacy combined inventory/playbook YAML
bssh playbook examples/playbook-combined.yaml --dry-run
```

Inventory YAML supports sshot's `ssh_config` defaults (`user`, `port`,
`key_file`, `use_agent`, and `strict_host_key_check`), host list entries with
`name`/`address`, and group list entries with `order`, `parallel`, and
`depends_on`. When groups exist they are authoritative: direct hosts are
ignored, each ordered group runs the complete task chain, dependencies require
successful groups, and the group's `parallel` flag controls host concurrency.
Named-map inventory forms are accepted as a bssh convenience. Standalone
playbooks use boolean `parallel`; legacy combined files contain
`inventory` and `playbook` mappings. JSON collectors use
`facts.collectors` entries with `name`, `command`, and optional `sudo`.

Tasks support `command`, `shell`, UTF-8 `script`, UTF-8 `copy` with `mode`
(up to 1 KiB inline, with a final 16 KiB SSH-command limit), sshot `wait_for`
strings (`port:`, `service:`, `file:`, and `http:`), and `local_action`, plus
`sudo` (non-interactive `sudo -n`):

- `register` and `{{ variable }}` interpolation (including dotted registered
values such as `result.stdout` and `result.exit_code`)
- `retries` as N retries after the initial attempt, a default five-second
delay when retries are enabled, and cancellable `timeout`
- `allowed_exit_codes` and `ignore_error`
- task `vars`, dependencies, `only_groups`, and `skip_groups`
- `until_success` (an initial attempt plus 60 retries by default)
- `delegate_to` using a named inventory host, or `localhost` for local execution
- `run_once`, which tries eligible hosts in inventory order until one succeeds

Conditions are deliberately strict. The only forms are `name == literal`,
`name != literal`, `name is defined`, and `name is not defined`; unsupported
syntax is an error. Host checking defaults to strict `yes`; plaintext inventory
`password` and `key_password` fields are rejected. A `wait_for` action without
an explicit timeout still stops after 30 checks at two-second intervals. Fact
collector values are commands whose stdout must be
valid JSON. `--dry-run` parses and validates the full playbook, resolves hosts,
delegates, templates available at planning time, and prints actions without
opening SSH connections or executing local commands. Runtime-produced fact or
register values are marked for runtime condition evaluation.

See [`examples/playbook.yaml`](examples/playbook.yaml),
[`examples/playbook-inventory.yaml`](examples/playbook-inventory.yaml), and
[`examples/playbook-combined.yaml`](examples/playbook-combined.yaml).

## Development

Read [ARCHITECTURE](ARCHITECTURE.md) documentation for more information.
Expand Down
33 changes: 33 additions & 0 deletions docs/bssh-playbook.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "bssh limited sshot-style playbook",
"oneOf": [
{ "$ref": "#/$defs/playbook" },
{
"type": "object",
"required": ["inventory", "playbook"],
"properties": {
"inventory": { "type": "object" },
"playbook": { "$ref": "#/$defs/playbook" }
}
}
],
"$defs": {
"playbook": {
"type": "object",
"required": ["name", "tasks"],
"properties": {
"name": { "type": "string" },
"parallel": { "type": ["boolean", "integer"] },
"hosts": { "type": ["string", "array"] },
"vars": { "type": "object" },
"facts": { "type": "object" },
"tasks": {
"type": "array",
"items": { "type": "object" }
}
},
"additionalProperties": true
}
}
}
15 changes: 15 additions & 0 deletions examples/playbook-combined.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Legacy combined sshot-style inventory/playbook YAML
inventory:
ssh_config:
user: deploy
use_agent: true
hosts:
- name: app1
address: app1.example.com
playbook:
name: health
parallel: false
tasks:
- name: health-check
shell: "test -f /etc/hostname"
allowed_exit_codes: [0]
22 changes: 22 additions & 0 deletions examples/playbook-inventory.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# sshot-style inventory for bssh playbooks
ssh_config:
user: deploy
port: 22
key_file: ~/.ssh/id_ed25519
strict_host_key_check: true
hosts:
- name: utility
address: utility.example.com
groups:
- name: web
order: 1
parallel: true
hosts:
- name: web1
address: web1.example.com
vars:
service: api
- name: web2
address: web2.example.com
vars:
service: api
29 changes: 29 additions & 0 deletions examples/playbook.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# yaml-language-server: $schema=../docs/bssh-playbook.schema.json
---
# bssh's limited sshot-style format (not an Ansible playbook)
name: inspect-web
parallel: true
facts:
collectors:
- name: kernel
command: >-
uname -r | python3 -c
'import json,sys; print(json.dumps(sys.stdin.read().strip()))'
tasks:
- name: show-host
command: >-
printf '%s\n' '{{ .hostname }} {{ .service }}'
only_groups: [web]
register: greeting

- name: wait-for-service
wait_for: service:api
only_groups: [web]
timeout: 30
retries: 2
retry_delay: 1

- name: controller-summary
local_action: "printf 'checked %s\n' '{{ .hostname }}'"
when: "{{ .greeting.success }} == true"
run_once: true
10 changes: 8 additions & 2 deletions src/app/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ fn sudo_password_is_applicable(command: &Option<Commands>, command_text: &str) -
| Some(Commands::Download { .. })
| Some(Commands::List)
| Some(Commands::Interactive { .. })
| Some(Commands::CacheStats { .. }) => false,
| Some(Commands::CacheStats { .. })
| Some(Commands::Playbook { .. }) => false,
// `None` is the exec/SSH-compatibility path. A non-empty command can
// use sudo injection; an empty command is an interactive SSH shell and
// has no sudo-injection hook.
Expand All @@ -121,7 +122,7 @@ fn sudo_password_is_applicable(command: &Option<Commands>, command_text: &str) -
fn ssh_password_is_applicable(command: &Option<Commands>) -> bool {
!matches!(
command,
Some(Commands::List) | Some(Commands::CacheStats { .. })
Some(Commands::List) | Some(Commands::CacheStats { .. }) | Some(Commands::Playbook { .. })
)
}

Expand All @@ -134,6 +135,7 @@ fn subcommand_name(command: &Option<Commands>) -> &'static str {
Some(Commands::Download { .. }) => "download",
Some(Commands::Interactive { .. }) => "interactive",
Some(Commands::CacheStats { .. }) => "cache-stats",
Some(Commands::Playbook { .. }) => "playbook",
None => "exec",
}
}
Expand Down Expand Up @@ -329,6 +331,10 @@ pub async fn dispatch_command(cli: &Cli, ctx: &AppContext) -> Result<()> {
// This is handled in main.rs before node resolution
unreachable!("CacheStats should be handled before dispatch")
}
Some(Commands::Playbook { .. }) => {
// This is handled in main.rs before the regular bssh initialization.
unreachable!("Playbook should be handled before dispatch")
}
None => {
// Execute command (auto-exec or interactive shell)
handle_exec_command(cli, ctx, &command, ssh_password.clone()).await
Expand Down
Loading