Files
homelab/ansible/roles/semaphore/tasks/configure.yml
Hermes Agent service account 009f244739 feat(semaphore): add config-as-code via Semaphore REST API
Adds an idempotent configuration pass that drives a freshly-deployed
Semaphore instance into its desired state via the REST API. Declared
in group_vars/all/semaphore.yml, applied by tasks/configure.yml,
toggled by semaphore_configure feature flag (default off).

Object types managed:
  - Project (mk-labs)
  - Keys (ansible-vault-pass, gitea-deploy, jarvis-ssh)
  - Repositories (homelab on gitea)
  - Inventories (production -> ansible/inventory.yml in homelab repo)
  - Environments (default with ANSIBLE_HOST_KEY_CHECKING=False)
  - Templates (day0_linux_baseline + variants, day1_deploy_semaphore)
    with survey vars for runtime parameters

Each object found-or-created by name; existing ones never modified.
no_log on token-bearing calls to keep secrets out of stdout.

Inputs (already in vault):
  vault_semaphore_api_token
  vault_jarvis_ssh_private_key
  vault_gitea_deploy_key
  vault_ansible_vault_password
2026-05-29 22:44:15 -05:00

310 lines
12 KiB
YAML

---
# ============================================================================
# Semaphore configuration via REST API (idempotent)
# ============================================================================
# Each section: GET the existing list, build a name→id lookup, then POST
# any declared objects whose names are missing. Existing objects are
# never modified — change-by-recreate is the operator's job through the
# UI or by hand-editing semaphore_config and deleting the old object.
# ============================================================================
- name: Set Semaphore API auth header
ansible.builtin.set_fact:
_semaphore_headers:
Authorization: "Bearer {{ semaphore_api_token }}"
Content-Type: "application/json"
no_log: true
# ---------------------------------------------------------------------------
# Project — find-or-create
# ---------------------------------------------------------------------------
- name: List Semaphore projects
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/projects"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_projects
no_log: true
- name: Create Semaphore project
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/projects"
method: POST
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
body_format: json
body:
name: "{{ semaphore_config.project.name }}"
alert: "{{ semaphore_config.project.alert | default(false) }}"
max_parallel_tasks: "{{ semaphore_config.project.max_parallel_tasks | default(0) }}"
status_code: [201, 204]
when: semaphore_config.project.name not in (_semaphore_projects.json | map(attribute='name') | list)
no_log: true
- name: Resolve project id
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/projects"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_projects_after
no_log: true
- name: Set project id fact
ansible.builtin.set_fact:
_semaphore_project_id: >-
{{ (_semaphore_projects_after.json
| selectattr('name', 'equalto', semaphore_config.project.name)
| list | first).id }}
# ---------------------------------------------------------------------------
# Keys — find-or-create
# ---------------------------------------------------------------------------
- name: List existing keys
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/keys"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_keys
no_log: true
- name: Create missing keys
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/keys"
method: POST
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
body_format: json
body: >-
{{
{
'name': item.name,
'type': item.type,
'project_id': _semaphore_project_id | int
}
| combine(
{'login_password': {'login': item.login | default(''), 'password': item.password | default('')}}
if item.type == 'login_password' else {}
)
| combine(
{'ssh': {'login': item.ssh_login | default(''),
'passphrase': item.ssh_passphrase | default(''),
'private_key': item.ssh_private_key | default('')}}
if item.type == 'ssh' else {}
)
}}
status_code: [201, 204]
loop: "{{ semaphore_config.keys }}"
loop_control:
label: "{{ item.name }}"
when: item.name not in (_semaphore_keys.json | map(attribute='name') | list)
no_log: true
- name: Re-list keys with assigned ids
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/keys"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_keys_after
no_log: true
- name: Build key name→id lookup
ansible.builtin.set_fact:
_semaphore_key_ids: "{{ dict(_semaphore_keys_after.json | map(attribute='name') | zip(_semaphore_keys_after.json | map(attribute='id'))) }}"
# ---------------------------------------------------------------------------
# Repositories — find-or-create
# ---------------------------------------------------------------------------
- name: List existing repositories
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/repositories"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_repos
no_log: true
- name: Create missing repositories
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/repositories"
method: POST
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
body_format: json
body:
name: "{{ item.name }}"
project_id: "{{ _semaphore_project_id | int }}"
git_url: "{{ item.git_url }}"
git_branch: "{{ item.git_branch | default('main') }}"
ssh_key_id: "{{ _semaphore_key_ids[item.ssh_key] | int }}"
status_code: [201, 204]
loop: "{{ semaphore_config.repositories }}"
loop_control:
label: "{{ item.name }}"
when: item.name not in (_semaphore_repos.json | map(attribute='name') | list)
- name: Re-list repositories with assigned ids
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/repositories"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_repos_after
- name: Build repo name→id lookup
ansible.builtin.set_fact:
_semaphore_repo_ids: "{{ dict(_semaphore_repos_after.json | map(attribute='name') | zip(_semaphore_repos_after.json | map(attribute='id'))) }}"
# ---------------------------------------------------------------------------
# Inventories — find-or-create
# ---------------------------------------------------------------------------
- name: List existing inventories
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/inventory"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_inv
no_log: true
- name: Create missing inventories
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/inventory"
method: POST
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
body_format: json
body:
name: "{{ item.name }}"
project_id: "{{ _semaphore_project_id | int }}"
type: "{{ item.type | default('file') }}"
inventory: "{{ item.inventory_file }}"
repository_id: "{{ _semaphore_repo_ids[item.repository] | int }}"
ssh_key_id: "{{ _semaphore_key_ids[item.ssh_key] | int }}"
become_key_id: "{{ _semaphore_key_ids[item.become_key | default(item.ssh_key)] | int }}"
status_code: [201, 204]
loop: "{{ semaphore_config.inventories }}"
loop_control:
label: "{{ item.name }}"
when: item.name not in (_semaphore_inv.json | map(attribute='name') | list)
- name: Re-list inventories with assigned ids
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/inventory"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_inv_after
- name: Build inventory name→id lookup
ansible.builtin.set_fact:
_semaphore_inv_ids: "{{ dict(_semaphore_inv_after.json | map(attribute='name') | zip(_semaphore_inv_after.json | map(attribute='id'))) }}"
# ---------------------------------------------------------------------------
# Environments — find-or-create
# ---------------------------------------------------------------------------
- name: List existing environments
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/environment"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_envs
- name: Create missing environments
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/environment"
method: POST
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
body_format: json
body:
name: "{{ item.name }}"
project_id: "{{ _semaphore_project_id | int }}"
# Semaphore stores both env (process env vars) and json (extra
# variables). Pass an empty object for json unless someone
# extends the schema later.
env: "{{ item.env | default({}) | to_json }}"
json: "{{ item.extra_vars | default({}) | to_json }}"
status_code: [201, 204]
loop: "{{ semaphore_config.environments }}"
loop_control:
label: "{{ item.name }}"
when: item.name not in (_semaphore_envs.json | map(attribute='name') | list)
- name: Re-list environments with assigned ids
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/environment"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_envs_after
- name: Build environment name→id lookup
ansible.builtin.set_fact:
_semaphore_env_ids: "{{ dict(_semaphore_envs_after.json | map(attribute='name') | zip(_semaphore_envs_after.json | map(attribute='id'))) }}"
# ---------------------------------------------------------------------------
# Templates — find-or-create
# ---------------------------------------------------------------------------
- name: List existing templates
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/templates"
method: GET
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
return_content: true
register: _semaphore_templates
- name: Create missing templates
ansible.builtin.uri:
url: "{{ semaphore_api_url }}/project/{{ _semaphore_project_id }}/templates"
method: POST
headers: "{{ _semaphore_headers }}"
validate_certs: "{{ semaphore_api_validate_certs }}"
body_format: json
body:
project_id: "{{ _semaphore_project_id | int }}"
name: "{{ item.name }}"
description: "{{ item.description | default('') }}"
app: "{{ item.app | default('ansible') }}"
playbook: "{{ item.playbook }}"
arguments: "{{ item.arguments | default('[]') }}"
allow_override_args_in_task: "{{ item.allow_override_args | default(false) }}"
inventory_id: "{{ _semaphore_inv_ids[item.inventory] | int }}"
repository_id: "{{ _semaphore_repo_ids[item.repository] | int }}"
environment_id: "{{ _semaphore_env_ids[item.environment] | int }}"
vault_key_id: "{{ _semaphore_key_ids[item.vault_password] | int if item.vault_password is defined else omit }}"
survey_vars: "{{ item.survey_vars | default([]) }}"
status_code: [201, 204]
loop: "{{ semaphore_config.templates }}"
loop_control:
label: "{{ item.name }}"
when: item.name not in (_semaphore_templates.json | map(attribute='name') | list)
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
- name: Report configuration status
ansible.builtin.debug:
msg:
- "Semaphore project: {{ semaphore_config.project.name }} (id={{ _semaphore_project_id }})"
- "Keys: {{ _semaphore_key_ids | length }}"
- "Repositories: {{ _semaphore_repo_ids | length }}"
- "Inventories: {{ _semaphore_inv_ids | length }}"
- "Environments: {{ _semaphore_env_ids | length }}"
- "Configuration applied successfully."