From 009f2447399e42fb6dba5e2106fd250492df3ac6 Mon Sep 17 00:00:00 2001 From: Hermes Agent service account Date: Fri, 29 May 2026 22:44:15 -0500 Subject: [PATCH] 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 --- ansible/group_vars/all/semaphore.yml | 132 +++++++++ ansible/roles/semaphore/tasks/configure.yml | 309 ++++++++++++++++++++ ansible/roles/semaphore/tasks/main.yml | 5 + 3 files changed, 446 insertions(+) create mode 100644 ansible/group_vars/all/semaphore.yml create mode 100644 ansible/roles/semaphore/tasks/configure.yml diff --git a/ansible/group_vars/all/semaphore.yml b/ansible/group_vars/all/semaphore.yml new file mode 100644 index 0000000..a6b9f2c --- /dev/null +++ b/ansible/group_vars/all/semaphore.yml @@ -0,0 +1,132 @@ +--- +# ============================================================================ +# Semaphore configuration-as-code +# ============================================================================ +# Drives a freshly-deployed Semaphore instance into its desired state via +# the Semaphore REST API. Idempotent: every object is checked first; only +# missing ones are created. Existing objects are left alone. +# +# Loaded from group_vars/all/semaphore.yml so that the configuration is +# version-controlled in the homelab repo and survives a wipe-and-redeploy +# of the Semaphore VM. +# ============================================================================ + +# --------------------------------------------------------------------------- +# API connection (defaults to the local Traefik-fronted service-name URL). +# Override semaphore_api_url to point at a specific instance if needed. +# --------------------------------------------------------------------------- +semaphore_api_url: "https://semaphore.local.mk-labs.cloud/api" +semaphore_api_validate_certs: true +semaphore_api_token: "{{ vault_semaphore_api_token }}" + +# Feature flag — keeps day1_deploy_semaphore.yml deploy-only by default. +# Set true to also run the configuration pass. +semaphore_configure: false + +# --------------------------------------------------------------------------- +# Declarative configuration of the Semaphore instance. +# --------------------------------------------------------------------------- +# +# Top-level shape: +# +# semaphore_config: +# project: single dict — the lab uses one project ("mk-labs") +# keys: list of credentials Semaphore stores +# repositories: git repos Semaphore can clone +# inventories: Ansible inventories from those repos +# environments: env-var bundles +# templates: task templates that tie everything together +# +# Each list element has a unique "name" used as the natural identity key. +# --------------------------------------------------------------------------- + +semaphore_config: + project: + name: mk-labs + alert: false + max_parallel_tasks: 0 # 0 = unlimited + + keys: + # The ansible-vault password. login_password type with empty login + # — only the password field is consumed by Semaphore at runtime. + - name: ansible-vault-pass + type: login_password + login: "" + password: "{{ vault_ansible_vault_password }}" + + # SSH key for the gitea deploy access (clone the homelab repo). + - name: gitea-deploy + type: ssh + ssh_login: git + ssh_private_key: "{{ vault_gitea_deploy_key }}" + + # SSH key Semaphore uses to reach the fleet as jarvis. + - name: jarvis-ssh + type: ssh + ssh_login: jarvis + ssh_private_key: "{{ vault_jarvis_ssh_private_key }}" + + repositories: + - name: homelab + git_url: "ssh://git@gitea.mk-labs.cloud:2221/rblundon/homelab.git" + git_branch: main + ssh_key: gitea-deploy + + inventories: + - name: production + type: file + inventory_file: ansible/inventory.yml + repository: homelab + ssh_key: jarvis-ssh + become_key: jarvis-ssh + + environments: + - name: default + env: + ANSIBLE_HOST_KEY_CHECKING: "False" + ANSIBLE_FORCE_COLOR: "True" + + templates: + - name: "day0_linux_baseline" + description: "Apply the mk-labs Linux baseline to one or more hosts." + app: ansible + playbook: ansible/playbooks/day0_linux_baseline.yml + inventory: production + repository: homelab + environment: default + vault_password: ansible-vault-pass + arguments: '["--diff"]' + survey_vars: + - name: target + title: "Target host or group" + description: "Inventory target (e.g. figment, semaphore_server, all)" + required: true + type: TextVar + default_value: "all" + + - name: "day1_deploy_semaphore" + description: "Re-deploy Semaphore + PostgreSQL on figment." + app: ansible + playbook: ansible/playbooks/day1_deploy_semaphore.yml + inventory: production + repository: homelab + environment: default + vault_password: ansible-vault-pass + arguments: '["--diff"]' + + - name: "day0_linux_baseline_check" + description: "Dry-run the baseline — shows diffs, applies nothing." + app: ansible + playbook: ansible/playbooks/day0_linux_baseline.yml + inventory: production + repository: homelab + environment: default + vault_password: ansible-vault-pass + arguments: '["--check","--diff"]' + survey_vars: + - name: target + title: "Target host or group" + description: "Inventory target (e.g. figment, semaphore_server, all)" + required: true + type: TextVar + default_value: "all" diff --git a/ansible/roles/semaphore/tasks/configure.yml b/ansible/roles/semaphore/tasks/configure.yml new file mode 100644 index 0000000..a5e4002 --- /dev/null +++ b/ansible/roles/semaphore/tasks/configure.yml @@ -0,0 +1,309 @@ +--- +# ============================================================================ +# 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." diff --git a/ansible/roles/semaphore/tasks/main.yml b/ansible/roles/semaphore/tasks/main.yml index bf83ec7..dffd19d 100644 --- a/ansible/roles/semaphore/tasks/main.yml +++ b/ansible/roles/semaphore/tasks/main.yml @@ -34,3 +34,8 @@ - name: Verify Semaphore is reachable ansible.builtin.import_tasks: verify.yml tags: [semaphore, verify] + +- name: Apply Semaphore configuration (projects, keys, repos, templates) + ansible.builtin.import_tasks: configure.yml + when: semaphore_configure | default(false) | bool + tags: [semaphore, configure]