Replies: 2 comments
-
Same issues here; Setting up the runner can be achieved with something like the ansible expect module, but I'm stuck on the two points @camaeel has mentioned. I see that I can use the API and post to # /api/runners/10/
{
"id": 10,
"project_id": null,
"webhook": "",
"max_parallel_tasks": 0,
"active": false,
"name": "",
"tag": "",
"touched": "2025-10-08T13:15:01.927474565Z",
"cleaning_requested": null
} We need to be able to enable the runner from the CLI, and we definitely need to be able to either be able to set the name, or have the CLI set it to the runner FQDN, or otherwise get some identifiable attributes for the runners. |
Beta Was this translation helpful? Give feedback.
-
For anyone who needs the same thing, I made an ansible module that can both enable and name the runner idempotently, and works a bit better than the Generate an API key as admin, then add a task like: - name: Runners | Register runner and generate configs
become: true
become_user: semaphore
semaphore_runner: # Custom module to manage runners
server: https://semaphoreserver.domain.net
api_key: "{{ semaphore_server_api_key }}"
runner_name: "runner_{{ ansible_fqdn }}"
active: true
state: present
configfile_location: /home/semaphore/runner-config.json
privatekey_location: /home/semaphore/runner-privatekey.pem
notify:
- Restart semaphore-runner
and add the custom module to your #!/usr/bin/python
import os
import json
import requests
from ansible.module_utils.basic import AnsibleModule
"""
Custom module to add runners to Semaphore UI. Uses undocumented API, so
probably not really stable at the moment
"""
def get_runner(module, base_url, headers, verify, runner_name):
"""Return a runner by name if it exists"""
resp = requests.get(f"{base_url}/runners",
headers=headers, verify=verify, timeout=10)
if resp.status_code != 200:
module.fail_json(
msg=f"Failed to get runners: {resp.status_code} {resp.text}")
for r in resp.json():
if r.get("name") == runner_name:
return r
return None
def create_runner(module, base_url, headers, verify, runner_name, active,
max_parallel_tasks=0):
"""Create a new runner (returns private_key + token)"""
payload = {
"name": runner_name,
"active": active,
"max_parallel_tasks": max_parallel_tasks,
"project_id": None,
}
resp = requests.post(f"{base_url}/runners", headers=headers,
json=payload, verify=verify, timeout=10)
if resp.status_code != 201:
module.fail_json(
msg=f"Failed to create runner: {resp.status_code} {resp.text}")
return resp.json()
def update_runner(module, base_url, headers, verify, runner_id, active,
max_parallel_tasks):
"""Update an existing runner"""
payload = {
"active": active,
"max_parallel_tasks": max_parallel_tasks,
}
resp = requests.put(f"{base_url}/runners/{runner_id}", headers=headers,
json=payload, verify=verify, timeout=10)
if resp.status_code != 204:
module.fail_json(
msg=f"Failed to update runner: {resp.status_code} {resp.text}")
return resp.json()
def delete_runner(module, base_url, headers, verify, runner_id):
"""Delete a runner"""
resp = requests.delete(
f"{base_url}/runners/{runner_id}", headers=headers, verify=verify,
timeout=10)
if resp.status_code not in (200, 204):
module.fail_json(
msg=f"Failed to delete runner: {resp.status_code} {resp.text}")
def write_file(module, path, content, mode=0o600):
"""Safely write file only if content differs"""
changed = False
if os.path.exists(path):
with open(path, "r") as f:
if f.read() != content:
changed = True
else:
changed = True
if changed and not module.check_mode:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(content)
os.chmod(path, mode)
return changed
def run_module():
module_args = {
"server": {"type": "str", "required": True},
"api_key": {"type": "str", "required": True, "no_log": True},
"runner_name": {"type": "str", "required": True},
"active": {"type": "bool", "required": True},
"max_parallel_tasks": {"type": "int", "default": 0},
"state": {
"type": "str", "choices": ["present", "absent"], "required": True
},
"privatekey_location": {"type": "str", "required": True},
"configfile_location": {"type": "str", "required": True},
"validate_certs": {"type": "bool", "default": True},
}
result = {"changed": False, "runner": None, "warnings": []}
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
# Disable proxy env vars
os.environ["https_proxy"] = ""
os.environ["http_proxy"] = ""
base_url = f"{module.params['server'].rstrip('/')}/api"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {module.params['api_key']}",
}
verify = module.params["validate_certs"]
state = module.params["state"]
runner_name = module.params["runner_name"]
privatekey_path = module.params["privatekey_location"]
config_path = module.params["configfile_location"]
# Lookup existing runner
runner = get_runner(module, base_url, headers, verify, runner_name)
if state == "absent":
if runner:
if not module.check_mode:
delete_runner(module, base_url, headers, verify, runner["id"])
for f in (privatekey_path, config_path):
try:
os.remove(f)
except FileNotFoundError:
pass
result["changed"] = True
module.exit_json(**result)
# State == present
created = updated = key_changed = config_changed = False
if not runner:
runner = create_runner(
module,
base_url,
headers,
verify,
runner_name,
module.params["active"],
module.params["max_parallel_tasks"],
)
created = True
else:
# If it already exists, see if update is needed
if (runner["active"] != module.params["active"]) or (
runner["max_parallel_tasks"] != module.params["max_parallel_tasks"]
):
if not module.check_mode:
runner = update_runner(
module,
base_url,
headers,
verify,
runner["id"],
module.params["active"],
module.params["max_parallel_tasks"],
)
updated = True
# Handle private key & config only if we just created the runner
if created:
key_changed = write_file(
module, privatekey_path, runner["private_key"])
config_data = {
"web_host": module.params["server"],
"runner": {
"token": runner["token"],
"private_key_file": privatekey_path,
},
}
config_changed = write_file(
module, config_path, json.dumps(config_data, indent=2))
else:
# Runner already exists — only warn if local files are missing
missing = []
if not os.path.exists(privatekey_path):
missing.append(privatekey_path)
if not os.path.exists(config_path):
missing.append(config_path)
if missing:
module.fail_json(
f"Runner '{runner_name}' already exists on Semaphore, but "
f"private key/config file not found locally. "
f"Missing: {', '.join(missing)}. You should check that the "
f"runner name and config/key paths is correct, and if it is, "
f"delete the runner from Semaphore and run this module again."
)
result["runner"] = runner
result["changed"] = any([created, updated, key_changed, config_changed])
module.exit_json(**result)
def main():
run_module()
if __name__ == "__main__":
main() |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Hi!
I'm working on setting up semaphore as automated as possible (infrastructure as a code, etc).
I registered a user with
semaphore runner register
command.All is fine excpet that:
semaphore runner start
- can I somehow enable it automatically?I know I can enable and set the name manually from the UI, or maybe calling some API endpoint (don't see this neither in swagger: https://semaphoreui.com/api-docs/#/ nor in Postman API), but it would be great if I could control both settings for example from runner config file.
Also there is no documentation about Webhook and OneOf settings.
Beta Was this translation helpful? Give feedback.
All reactions