No description
  • Python 82.4%
  • Shell 6.7%
  • Makefile 4.3%
  • Jinja 3.9%
  • Dockerfile 2.7%
Find a file
Max Fiedler 8e9408a35e Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap
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>
2026-10-14 16:34:04 +02:00
inventories Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
lab Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
playbooks Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
roles Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
scripts Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
tests Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
.ansible-lint Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
.editorconfig Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
.flake8 Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
.gitignore Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
.hadolint.yaml Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
.yamllint.yml Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
ansible.cfg Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
CHANGELOG.md Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
CLAUDE.md Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
compose.yaml Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
Makefile Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
pyproject.toml Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
README.md Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00
requirements-dev.txt Add Semaphore UI showcase: lab fleet, Ansible repo, bootstrap 2026-10-14 16:34:04 +02:00

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.

  1. 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 .env before step 2 LAB_HOST=<docker-host-ip>

    By default the SSH ports are bound to 127.0.0.1 only. Each container listens on the same port inside and outside, so both ways use the inventories unchanged. playbooks/group_vars/all.yml explains 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.

  2. 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 down removes the sema-demo network, so after make down and make up run the docker network connect again.

  3. Push this repository to a git server that your Semaphore can reach. The lab key in keys/ and .env are git-ignored.

  4. 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= (default main). Call scripts/bootstrap_semaphore.py --help for 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 with ALLOW_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"
    

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 with no_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.

  1. Role defaults: roles/*/defaults/main.yml, safe fallbacks.
  2. Inventory group_vars/all.yml: per environment (env_name, app_log_level, …).
  3. Playbook group_vars/all.yml: settings shared by every inventory (demo_root, ansible_user).
  4. Inventory group_vars/<group>.yml: per tier (webserver_worker_processes, database_names, …).
  5. Host vars: ansible_port in hosts.yml, then host_vars/<host>.yml for single-host exceptions (prod-web-03, prod-db-01).
  6. Play vars / vars_files: survey answers read from the environment, with defaults.
  7. Role vars/ and role params: app_deploy_release_dir; deploy.yml passes app_deploy_version as a role param.
  8. Semaphore Environment: extra vars (demo_step_delay, ansible_host for 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:

  1. Key Store: an SSH key with login ansible and the content of keys/sema-demo_ed25519.
  2. Repository: your git URL and branch.
  3. Inventories: three of type File, with the paths from the table above and the lab key as the user credential.
  4. Environments:
    • "Lab": extra vars {"demo_step_delay": 0}, environment variable DEMO_OPERATOR_TEAM, and a secret extra var demo_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 the sema-demo network.
  5. Templates: as in the walkthrough. Create every survey field with the target Environment variable. The TEMPLATES tuple in scripts/bootstrap_semaphore.py lists 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 = False and UserKnownHostsFile=/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 ansible with 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.1 unless you set LAB_BIND_ADDR.