- Python 82.4%
- Shell 6.7%
- Makefile 4.3%
- Jinja 3.9%
- Dockerfile 2.7%
Demo repository showing Semaphore UI workflows against a disposable fleet: - 8 hardened, non-root Alpine SSH targets (compose) in three environments - File inventories per environment with group_vars/host_vars, playbooks (ping, site, build, deploy, maintenance, chaos) and five roles - Survey answers delivered as env vars and validated, so they cannot inject Jinja into the Semaphore host - stdlib-only bootstrap script creating the full Semaphore project via the API (keys, repo, inventories, environments, views, templates with surveys, build/deploy pipeline, schedule), with rollback on failure - Unit tests, Makefile, lint configs and docs Verified end to end against Semaphore v2.19.16. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| inventories | ||
| lab | ||
| playbooks | ||
| roles | ||
| scripts | ||
| tests | ||
| .ansible-lint | ||
| .editorconfig | ||
| .flake8 | ||
| .gitignore | ||
| .hadolint.yaml | ||
| .yamllint.yml | ||
| ansible.cfg | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| compose.yaml | ||
| Makefile | ||
| pyproject.toml | ||
| README.md | ||
| requirements-dev.txt | ||
sema-demo: a hands-on showcase for Semaphore UI
A self-contained demo that shows what Semaphore UI
adds on top of plain ansible-playbook. It runs tasks live against several
inventories, with surveys, a build → deploy pipeline with versions, schedules,
secrets, and failures you can inspect. The playbooks only pretend to manage
nginx, PostgreSQL and HAProxy. The goal is to see how the workflows look and
feel, not to manage real services.
The repository contains:
- A disposable fleet: 8 hardened, non-root SSH containers that act as servers in three environments.
- An Ansible repository in the layout Semaphore expects: file inventories
per environment with
group_vars/host_vars, playbooks, roles and layered variables. - A bootstrap script that creates the complete Semaphore project (keys, repository, inventories, environments, views, templates, schedule) through the API in one command.
Tested with Semaphore v2.19.16, which ships ansible-core 2.20. Local
tooling pins ansible-core 2.21 in requirements-dev.txt.
┌──────────────── Docker host ─────────────────┐
┌────────────┐ SSH │ production prod-lb-01 :2201 │
│ Semaphore │──────────►│ prod-web-01..03 :2202-2204 │
│ (yours) │ key auth │ prod-db-01 :2205 │
└────────────┘ user │ staging stg-web-01 :2211 │
│ ansible │ stg-db-01 :2212 │
│ git clone │ development dev-box-01 :2221 (all roles) │
▼ └──────────── network "sema-demo" ─────────────┘
this repository
Quick start
Prerequisites: Docker with the Compose plugin, Python 3.10+, ssh-keygen,
a Semaphore server, and a git server Semaphore can clone from.
-
Decide how Semaphore reaches the fleet:
Semaphore runs… Do this Bootstrap flag in Docker on the same host after step 2: docker network connect sema-demo <semaphore-container>none anywhere else put LAB_BIND_ADDR=0.0.0.0(or one interface IP) in.envbefore step 2LAB_HOST=<docker-host-ip>By default the SSH ports are bound to
127.0.0.1only. Each container listens on the same port inside and outside, so both ways use the inventories unchanged.playbooks/group_vars/all.ymlexplains the mechanism.Docker's published ports bypass host firewalls such as ufw and firewalld. Only open them on a network you trust. On the same host without joining the network, the host firewall may instead block container→host traffic; joining the network avoids that.
-
Start the fleet on any Docker host:
make up # generates keys/sema-demo_ed25519 + .env on first run make status # all 8 containers should be "healthy"make downremoves thesema-demonetwork, so aftermake downandmake uprun thedocker network connectagain. -
Push this repository to a git server that your Semaphore can reach. The lab key in
keys/and.envare git-ignored. -
Create the Semaphore project. Run this from the machine holding
keys/, because the script uploads the lab private key:read -rs SEMAPHORE_API_TOKEN && export SEMAPHORE_API_TOKEN # or SEMAPHORE_USER + SEMAPHORE_PASSWORD make bootstrap SEMAPHORE_URL=https://semaphore.example.net \ REPO_URL=https://git.example.net/me/sema-demo.git \ LAB_HOST=10.0.0.5 # only for the "anywhere else" case- Other options:
REPO_BRANCH=(defaultmain). Callscripts/bootstrap_semaphore.py --helpfor the rest, such as--repo-ssh-key <deploy-key>for a private repo over SSH,--project-name, and--enable-schedule. - Plain HTTP: plain
http://to anything but the local machine is refused, because the token and the private key would travel unencrypted. Use https, or opt in withALLOW_INSECURE_HTTP=1. - Safe to re-run: the script refuses to run if the project already exists, and it never modifies anything it didn't create. If it fails half-way, it deletes the half-built project again, so you can simply re-run it.
If you have no API token yet, create one with the session login:
export SEMAPHORE_URL=https://semaphore.example.net jar="$(mktemp)" curl -c "$jar" -H 'Content-Type: application/json' \ -d '{"auth":"admin","password":"..."}' "$SEMAPHORE_URL/api/auth/login" curl -b "$jar" -X POST "$SEMAPHORE_URL/api/user/tokens" # -> {"id": "<token>"} rm -f "$jar" - Other options:
Demo walkthrough
The templates are grouped into three tabs (views). A good order for showing them to someone:
| # | Template (tab) | What to point out |
|---|---|---|
| 1 | Ping & facts (Operations) | Live output, per-host results, the recap. Run it again and pick the Staging inventory in the run dialog: same playbook, different fleet. |
| 2 | Converge (dry run) | --check --diff stored in the template. Shows what would change on production. |
| 3 | Converge | Defaults to Development. Pick Production, then use the limit (prod-web-03) and tags (webserver) prompts. Compare prod-web-03's 8 workers (host_vars) with the 4 of its siblings (group_vars). |
| 4 | Build app (Deploy) | A Build template. Every run gets the next version (1.0.0, 1.0.1, …). |
| 5 | Deploy to Staging | Starts automatically after each successful build and deploys exactly that version. |
| 6 | Deploy to Production | Pick a build version, then fill the survey: change ticket (required for production, e.g. CHG-1234), batch size and notes. It uses the slow motion environment, so you can watch the rolling deploy: drain from LB → deploy → health check → back into LB, batch by batch. |
| 7 | Maintenance (Operations) | A required secret survey field (any value with 8+ characters), masked in the UI and the log. It only demonstrates masking; it is not an auth check. |
| 8 | Chaos monkey (Chaos) | Retries (FAILED - RETRYING), rescued failures, random host failures and an unreachable host. Each one looks different in the task log and recap. |
| 9 | Schedules | "Fleet health check" runs Ping & facts every 15 min. It is created inactive; toggle it on (or pass --enable-schedule). |
Also worth showing:
- History: the task history with who ran what and when, and re-run from history.
- Debug toggle:
-vvvv, available on the Operations and Chaos templates. - Validation: a survey answer that fails it stops the run before
anything changes. Try change ticket
abc, or a maintenance token shorter than 8 characters. - Secret: the Environment's hidden secret
demo_api_token. It is written on the hosts withno_log, so it never shows up, not even in--diff.
make reset restarts all containers. Their home directories are tmpfs, so
this gives you a clean fleet for the next demo.
How the repository maps to Semaphore
| Semaphore concept | In this repo | Notes |
|---|---|---|
| Repository | the repo root | ansible.cfg here is picked up automatically (roles_path, output format). |
| Inventory (type File) | inventories/<env>/hosts.yml |
The path is relative to the repo. group_vars/ and host_vars/ next to it load automatically. |
| Key Store | keys/sema-demo_ed25519 |
Uploaded as "Lab SSH key (ansible)" and attached to every inventory. |
| Environment (variable group) | created by the bootstrap | "Lab" and "Lab - slow motion": extra vars, an env var (DEMO_OPERATOR_TEAM) and a secret (demo_api_token). |
| Task template | playbooks/*.yml |
See the walkthrough. Survey answers arrive as environment variables (see below). |
| Build / Deploy templates | playbooks/build.yml, playbooks/deploy.yml |
Versions arrive as semaphore_vars.task_details.target_version / incoming_version. |
├── Makefile, compose.yaml # fleet + tooling entry points
├── lab/ # target image: Dockerfile, .dockerignore, sshd_config, entrypoint.sh
├── ansible.cfg
├── inventories/
│ ├── production/ hosts.yml, group_vars/{all,webservers,dbservers,loadbalancers}.yml, host_vars/
│ ├── staging/ (empty loadbalancers group, so LB steps are skipped)
│ └── development/ (one box in every group)
├── playbooks/
│ ├── group_vars/all.yml # shared by all inventories (connection, demo_root, pacing)
│ ├── vars/release.yml # deploy inputs: version resolution + survey answers + validation
│ └── ping, site, build, deploy, maintenance, chaos .yml
├── roles/{common,webserver,database,loadbalancer,app_deploy}/
├── scripts/{lab-keys.sh,bootstrap_semaphore.py}
├── tests/ # unit tests for the bootstrap script
└── pyproject.toml, .flake8, .yamllint.yml, .ansible-lint, .hadolint.yaml, requirements-dev.txt
# generated + git-ignored: keys/ (lab key pair), .env (public key, LAB_BIND_ADDR)
Survey answers are environment variables, not extra vars
Ansible evaluates Jinja inside extra vars. A survey answer such as
{{ lookup('pipe', 'id') }} passed as an extra var would therefore run on
the Semaphore server. Every survey in this demo uses Semaphore's
environment variable target instead (DEPLOY_BATCH_SIZE, RELEASE_MESSAGE,
CHANGE_TICKET, RETAIN_RELEASES, MAINTENANCE_TOKEN, CHAOS_SCENARIO,
CHAOS_FAILURE_PERCENT). The playbooks read them with lookup('env'), whose
results are never templated, and validate each value before anything
changes. From the CLI, export the same variables, e.g.
DEPLOY_BATCH_SIZE=2 CHANGE_TICKET=CHG-1 ansible-playbook ....
This protects the inputs the templates ask for. Semaphore itself still lets anyone who may run a task pass arbitrary extra vars through the API. In effect that permission can execute code on the Semaphore runner, so grant it only to trusted users, or isolate runners.
Variable layering
The layers this repo uses, lowest to highest precedence. Ansible has more layers; see its "variable precedence" docs.
- Role defaults:
roles/*/defaults/main.yml, safe fallbacks. - Inventory
group_vars/all.yml: per environment (env_name,app_log_level, …). - Playbook
group_vars/all.yml: settings shared by every inventory (demo_root,ansible_user). - Inventory
group_vars/<group>.yml: per tier (webserver_worker_processes,database_names, …). - Host vars:
ansible_portinhosts.yml, thenhost_vars/<host>.ymlfor single-host exceptions (prod-web-03,prod-db-01). - Play
vars/vars_files: survey answers read from the environment, with defaults. - Role
vars/and role params:app_deploy_release_dir;deploy.ymlpassesapp_deploy_versionas a role param. - Semaphore Environment: extra vars (
demo_step_delay,ansible_hostfor remote setups) plus the secret.
The app_deploy role also has meta/argument_specs.yml and validates the
version format, so bad input fails fast with a readable message.
Manual setup (without the bootstrap script)
To build the project by hand, for example to walk someone through the Semaphore UI, create these in order:
- Key Store: an SSH key with login
ansibleand the content ofkeys/sema-demo_ed25519. - Repository: your git URL and branch.
- Inventories: three of type File, with the paths from the table above and the lab key as the user credential.
- Environments:
- "Lab": extra vars
{"demo_step_delay": 0}, environment variableDEMO_OPERATOR_TEAM, and a secret extra vardemo_api_token. - "Lab - slow motion": the same with
"demo_step_delay": 3. - In both, add
"ansible_host": "<docker host ip>"when Semaphore is not on thesema-demonetwork.
- "Lab": extra vars
- Templates: as in the walkthrough. Create every survey field with the
target Environment variable. The
TEMPLATEStuple inscripts/bootstrap_semaphore.pylists every setting and survey field.
Development
make lint also needs hadolint and shellcheck on the PATH. They are not
Python packages.
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
source .venv/bin/activate
make lint # yamllint, ansible-lint (production profile), hadolint, shellcheck,
# black, isort, flake8, pylint, mypy --strict
make test # unit tests for the bootstrap script (stdlib unittest)
make smoke # ping all inventories from this machine via the published ports
Any playbook also runs without Semaphore:
ANSIBLE_PRIVATE_KEY_FILE=keys/sema-demo_ed25519 DEPLOY_BATCH_SIZE=2 CHANGE_TICKET=CHG-1 \
ansible-playbook -i inventories/production/hosts.yml -e ansible_host=127.0.0.1 \
playbooks/deploy.yml -e deploy_version=2.0.0
Security notes
These are lab-only shortcuts. Don't copy them into real automation:
host_key_checking = FalseandUserKnownHostsFile=/dev/null, because host keys are regenerated on every container start. On an untrusted network this allows an on-path attacker to impersonate a target.- One shared, passphrase-less key for all 8 targets.
The containers themselves are locked down:
- sshd: runs as the unprivileged user
ansiblewith key-only auth. All forwarding is disabled and pre-auth limits are set. The entrypoint accepts only a single, well-formed public key. - Container: drops all capabilities, sets
no-new-privileges, uses a read-only root filesystem and has CPU/memory/PID limits. - Image: the base image is pinned by digest, and the build context is an
allowlist (
lab/.dockerignore). - Ports: bound to
127.0.0.1unless you setLAB_BIND_ADDR.